Baseline: Ludic compiler + toolchain, Phase 1 syntax fixes complete
Self-hosted compiler (selfhost/*.ludic), runtime, examples, editor tooling, and docs. Phase 1 of the syntax-redesign cohesion pass has landed: edge-system fix, signature-query, when-alias, and the documentation truth-pass. Suite green (14/14), C-free bootstrap fixpoint holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
985f9ad8f2
418 changed files with 39065 additions and 0 deletions
148
tools/editors/README.md
Normal file
148
tools/editors/README.md
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# Ludic in your editor
|
||||
|
||||
Everything here is built on one idea: **write the language knowledge once, in C,
|
||||
and let every editor talk to it.** There is one lexer, one vocabulary, one
|
||||
formatter and one language server. VS Code and JetBrains get first-class plugins
|
||||
because those are the two that were asked for; every other editor gets the same
|
||||
capabilities by pointing at the same binary.
|
||||
|
||||
```
|
||||
tools/ludic-tools/ the actual language knowledge, in C, no dependencies
|
||||
ludic_syntax.h lexer + the vocabulary — the source of truth
|
||||
ludic_fmt.h comment-preserving formatter
|
||||
ludic_index.h error-tolerant reader: declarations, bindings, scopes
|
||||
ludic_workspace.h multi-file model: imports, compilation units, Markdown
|
||||
ludic_json.h just enough JSON for LSP
|
||||
ludic_fmt_main.c -> build/ludic-fmt
|
||||
ludic_lsp.c -> build/ludic-lsp
|
||||
|
||||
tools/editors/
|
||||
shared/ TextMate grammar + Markdown injection + language config
|
||||
vscode/ VS Code extension
|
||||
jetbrains/ IntelliJ Platform plugin (Community editions included)
|
||||
neovim/ helix/ emacs/ sublime/ zed/ configuration, no plugin needed
|
||||
```
|
||||
|
||||
## Build it
|
||||
|
||||
```bash
|
||||
./tools/build-tools.sh
|
||||
```
|
||||
|
||||
Produces `build/ludic-fmt` and `build/ludic-lsp`. Add `--install` to symlink both
|
||||
into `~/.local/bin`, `--test` to run `tools/test-tools.sh` afterwards.
|
||||
|
||||
## What you get, in any editor
|
||||
|
||||
| Feature | How |
|
||||
|---|---|
|
||||
| Syntax highlighting | LSP semantic tokens, or the TextMate grammar with no server at all |
|
||||
| Diagnostics | structural errors as you type; `ludicc`'s own errors on save |
|
||||
| Completion | context-aware — see below |
|
||||
| Hover | signature + docs for builtins, intrinsics, and everything you declared |
|
||||
| Go to definition, find usages, rename | across the whole compilation unit, following `import` — by resolving each occurrence, not by matching text, so renaming a component's `x` leaves every other `x` alone |
|
||||
| Formatting | whole-file, comment-preserving, idempotent |
|
||||
| Outline, folding, inlay hints, signature help, document links | |
|
||||
| ` ```ludic ` in Markdown | highlighted, checked and formatted |
|
||||
|
||||
Completion knows where the caret is: fields after `.`, components inside
|
||||
`query [...]` and after `spawn`, phase names after `phase`, scene names after
|
||||
`enter`, widget types and props inside a `ui` block, types after `:` and `->`,
|
||||
and otherwise keywords, builtins, and everything in scope.
|
||||
|
||||
## Per-editor setup
|
||||
|
||||
- **VS Code** — [`vscode/`](vscode/). `npm install && npx @vscode/vsce package`, then
|
||||
install the `.vsix`. Finds the binaries under `build/` on its own.
|
||||
- **JetBrains** — [`jetbrains/`](jetbrains/). `./gradlew buildPlugin`, then install the
|
||||
zip from disk. Uses LSP4IJ rather than the paid-IDE LSP API, so it works in
|
||||
Community editions too.
|
||||
- **Neovim** — `require('ludic').setup()` from [`neovim/`](neovim/lua/ludic.lua).
|
||||
- **Helix** — merge [`helix/languages.toml`](helix/languages.toml) into your config.
|
||||
- **Emacs** — [`emacs/ludic-mode.el`](emacs/ludic-mode.el); eglot is wired up in one line.
|
||||
- **Sublime** — [`sublime/README.md`](sublime/README.md); the TextMate grammar loads as-is.
|
||||
- **Zed** — [`zed/README.md`](zed/README.md); LSP-only, no grammar to build.
|
||||
- **Anything else** that speaks LSP:
|
||||
|
||||
```
|
||||
command: build/ludic-lsp --stdio
|
||||
languages: ludic, markdown
|
||||
initializationOptions: { "compilerPath": "build/ludicc", "compilerDiagnostics": true, "indentSize": 2 }
|
||||
```
|
||||
|
||||
## Markdown
|
||||
|
||||
Markdown fenced-code highlighting is not one standard — it is three mechanisms,
|
||||
and this ships all three so that a ` ```ludic ` block works wherever you write
|
||||
prose:
|
||||
|
||||
1. **TextMate injection** (`shared/ludic.markdown-injection.json`) — what VS Code,
|
||||
Sublime and other TextMate-based editors use. It is a plain grammar file with
|
||||
an `injectionSelector`, so it is portable rather than VS Code-specific.
|
||||
2. **Language-ID resolution** — JetBrains' Markdown support matches a fence's
|
||||
info string against registered language IDs, so the plugin registering as
|
||||
`Ludic` is all that is needed; no Markdown-specific code exists in the plugin.
|
||||
3. **The language server** — `ludic-lsp` accepts Markdown documents directly. It
|
||||
copies the buffer, blanks every byte outside a ` ```ludic ` fence, and
|
||||
analyses the result; offsets still line up with the real file, so semantic
|
||||
highlighting, hover, completion and go-to-definition all work inside fences
|
||||
with no editor-side support at all. This is the fallback that works even
|
||||
where the other two do not.
|
||||
|
||||
It reports **no diagnostics** in Markdown, deliberately. A fence is a
|
||||
snippet — an elision mark, or a body shown without its enclosing `game`
|
||||
block — so nearly every error found there would be the documentation doing
|
||||
its job. `ludic-fmt --check` still keeps the fences formatted, which is the
|
||||
part of "is this doc correct" that can be answered without guessing.
|
||||
|
||||
The formatter handles Markdown too, which keeps documentation honest:
|
||||
|
||||
```bash
|
||||
build/ludic-fmt --check LANGUAGE.md README.md # CI: fail if a fence is unformatted
|
||||
build/ludic-fmt -w LANGUAGE.md # reformat the fences, leave the prose
|
||||
```
|
||||
|
||||
## Keeping a tree formatted
|
||||
|
||||
```bash
|
||||
ln -sf ../../tools/git-hooks/pre-commit .git/hooks/pre-commit
|
||||
```
|
||||
|
||||
The hook runs `ludic-fmt --check` over the staged `.ludic` and `.md` files only,
|
||||
and does nothing at all when `build/ludic-fmt` has not been built — so it never
|
||||
blocks a commit on a machine that has not run `build-tools.sh`.
|
||||
|
||||
## Why the formatter is not `ludicc --fmt`
|
||||
|
||||
The compiler has a canonical printer, and it is the right tool for seeing what
|
||||
the compiler parsed. It is the wrong tool for an editor: it walks the AST *after*
|
||||
import splicing, so it drops every comment and inlines every imported file into
|
||||
whichever file you pointed it at. Running it as format-on-save on
|
||||
`chronorift/combat.ludic` would replace that file with the whole game.
|
||||
|
||||
`ludic-fmt` works on the token stream instead. Nothing is dropped or reordered —
|
||||
every token is re-emitted in order, and the only freedom taken is the whitespace
|
||||
between them. Lines are re-indented and respaced but never joined or split, so
|
||||
you keep control of line structure.
|
||||
|
||||
Two carve-outs keep the output idiomatic rather than merely uniform, because
|
||||
both spellings are what the language documents and uses:
|
||||
|
||||
- runs of two or more spaces are preserved, so hand-aligned columns
|
||||
(`const R_DIR: int = 0 # 0 up`) survive a save;
|
||||
- `id=Root` inside a `ui` block and `{Enemy}` inside a query stay tight.
|
||||
|
||||
`tools/test-tools.sh` checks the property that matters: formatting every file in
|
||||
the tree and re-running the *compiler's* canonical dump produces byte-identical
|
||||
output. The formatter cannot change what a program means.
|
||||
|
||||
## Keeping it honest
|
||||
|
||||
The vocabulary is written down in five places that cannot include each other —
|
||||
the compiler's two tables, `ludic_syntax.h`, the TextMate grammar (JSON), and the
|
||||
JetBrains lexer (Kotlin). Adding a builtin and forgetting the rest is silent
|
||||
failure, so `tools/check-vocabulary.py` compares all five and
|
||||
`tools/test-tools.sh` runs it.
|
||||
|
||||
When you add a keyword or builtin: put it in `ludic_syntax.h`, then run
|
||||
`./tools/test-tools.sh` and let it tell you which copies still need it.
|
||||
70
tools/editors/emacs/ludic-mode.el
Normal file
70
tools/editors/emacs/ludic-mode.el
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
;;; ludic-mode.el --- Major mode for the Ludic game language -*- lexical-binding: t; -*-
|
||||
|
||||
;; Highlighting here is a deliberate minimum: enough for a file to be readable
|
||||
;; with nothing installed. Everything real -- completion, diagnostics, hover,
|
||||
;; formatting -- comes from ludic-lsp via eglot (built into Emacs 29) or lsp-mode.
|
||||
;;
|
||||
;; Usage:
|
||||
;; (add-to-list 'load-path "/path/to/gpp/tools/editors/emacs")
|
||||
;; (require 'ludic-mode)
|
||||
|
||||
;;; Code:
|
||||
|
||||
(defconst ludic--declaration-keywords
|
||||
'("game" "module" "import" "component" "archetype" "ui" "scene" "layer"
|
||||
"const" "var" "export" "fn" "pure" "extern" "system" "edge" "start" "on"))
|
||||
|
||||
(defconst ludic--clause-keywords
|
||||
'("phase" "query" "reads" "writes" "needs" "uses"
|
||||
"requires" "ensures" "invariant" "effects"))
|
||||
|
||||
(defconst ludic--statement-keywords
|
||||
'("let" "return" "if" "else" "when" "while" "for" "in" "spawn" "despawn"
|
||||
"match" "machine" "state" "become" "enter" "where" "and" "or"))
|
||||
|
||||
(defconst ludic--types '("int" "fixed" "bool" "entity" "str" "ptr" "void"))
|
||||
(defconst ludic--phases '("Start" "Input" "FixedUpdate" "Update" "LateUpdate" "Render"))
|
||||
|
||||
(defconst ludic-font-lock-keywords
|
||||
(list
|
||||
(cons (regexp-opt ludic--declaration-keywords 'symbols) 'font-lock-keyword-face)
|
||||
(cons (regexp-opt ludic--clause-keywords 'symbols) 'font-lock-preprocessor-face)
|
||||
(cons (regexp-opt ludic--statement-keywords 'symbols) 'font-lock-keyword-face)
|
||||
(cons (regexp-opt ludic--types 'symbols) 'font-lock-type-face)
|
||||
(cons (regexp-opt ludic--phases 'symbols) 'font-lock-builtin-face)
|
||||
(cons "\\_<\\(true\\|false\\)\\_>" 'font-lock-constant-face)
|
||||
(cons "@[A-Za-z_][A-Za-z0-9_]*" 'font-lock-preprocessor-face)
|
||||
;; components, archetypes, scenes and UI handles are Capitalised by convention
|
||||
(cons "\\_<[A-Z][A-Za-z0-9_]*\\_>" 'font-lock-type-face)
|
||||
(cons "\\_<\\([a-z_][A-Za-z0-9_]*\\)\\s-*(" '(1 font-lock-function-name-face))))
|
||||
|
||||
(defvar ludic-mode-syntax-table
|
||||
(let ((table (make-syntax-table)))
|
||||
(modify-syntax-entry ?# "<" table)
|
||||
(modify-syntax-entry ?\n ">" table)
|
||||
(modify-syntax-entry ?_ "_" table)
|
||||
(modify-syntax-entry ?\" "\"" table)
|
||||
table)
|
||||
"Syntax table for `ludic-mode'.")
|
||||
|
||||
;;;###autoload
|
||||
(define-derived-mode ludic-mode prog-mode "Ludic"
|
||||
"Major mode for editing Ludic source."
|
||||
:syntax-table ludic-mode-syntax-table
|
||||
(setq-local font-lock-defaults '(ludic-font-lock-keywords))
|
||||
(setq-local comment-start "# ")
|
||||
(setq-local comment-end "")
|
||||
(setq-local indent-tabs-mode nil)
|
||||
(setq-local tab-width 2)
|
||||
(setq-local standard-indent 2))
|
||||
|
||||
;;;###autoload
|
||||
(add-to-list 'auto-mode-alist '("\\.ludic\\'" . ludic-mode))
|
||||
|
||||
;; eglot: one line, and the server does the rest. Markdown is registered too so
|
||||
;; that ```ludic fences are analysed in prose files.
|
||||
(with-eval-after-load 'eglot
|
||||
(add-to-list 'eglot-server-programs '(ludic-mode . ("ludic-lsp" "--stdio"))))
|
||||
|
||||
(provide 'ludic-mode)
|
||||
;;; ludic-mode.el ends here
|
||||
24
tools/editors/helix/languages.toml
Normal file
24
tools/editors/helix/languages.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Ludic for Helix. Append to ~/.config/helix/languages.toml, adjusting the two
|
||||
# paths. Highlighting comes from the server's semantic tokens; Helix applies
|
||||
# them with no grammar to build.
|
||||
|
||||
[[language]]
|
||||
name = "ludic"
|
||||
scope = "source.ludic"
|
||||
file-types = ["ludic"]
|
||||
comment-token = "#"
|
||||
indent = { tab-width = 2, unit = " " }
|
||||
language-servers = ["ludic-lsp"]
|
||||
auto-format = true
|
||||
roots = ["build.sh", "LANGUAGE.md", ".git"]
|
||||
|
||||
[language-server.ludic-lsp]
|
||||
command = "ludic-lsp"
|
||||
args = ["--stdio"]
|
||||
|
||||
# Ludic fences inside Markdown: point Helix's markdown at the same server so
|
||||
# `# ```ludic` blocks get diagnostics too. Merge this with your existing
|
||||
# markdown entry rather than adding a second one.
|
||||
# [[language]]
|
||||
# name = "markdown"
|
||||
# language-servers = ["marksman", "ludic-lsp"]
|
||||
58
tools/editors/jetbrains/README.md
Normal file
58
tools/editors/jetbrains/README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Ludic for JetBrains IDEs
|
||||
|
||||
Works in IntelliJ IDEA (Community and Ultimate), CLion, GoLand, PyCharm, Rider,
|
||||
WebStorm — anything on the IntelliJ Platform 2023.2 through 2026.2
|
||||
(`since-build` 232, `until-build` 262.*).
|
||||
|
||||
`until-build` 262 is deliberately ahead of the newest released platform — 2025.3
|
||||
is build **253**, and no 262 IDE exists yet. That headroom is the point: an
|
||||
`until-build` pinned to today's IDE gets the plugin auto-disabled the moment a
|
||||
user upgrades.
|
||||
|
||||
Two things are machine-checked rather than assumed, because a plugin that
|
||||
packages cleanly can still fail to load:
|
||||
|
||||
- `./gradlew verifyPlugin` runs JetBrains' own Plugin Verifier over the built
|
||||
artifact against 2025.3 and reports **Compatible**, with no deprecated or
|
||||
scheduled-for-removal API usages.
|
||||
- `./gradlew runIde` boots a real IDEA 2025.3 with the plugin installed. The
|
||||
sandbox log line to look for is
|
||||
`Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.0.0)` in
|
||||
`.intellijPlatform/sandbox/Ludic/IC-2025.3/log/idea.log`.
|
||||
|
||||
**If the plugin does not appear in Settings -> Plugins after installing**, the
|
||||
cause is almost always that the IDE's build number is outside
|
||||
`since-build`..`until-build`. IntelliJ installs the declared dependency (LSP4IJ)
|
||||
from Marketplace first and only then rejects the incompatible plugin, so the
|
||||
symptom is "LSP4IJ got installed, mine did not". Check **Help -> About** for the
|
||||
build number and widen `pluginUntilBuild` in `gradle.properties` to match.
|
||||
|
||||
## Building
|
||||
|
||||
Gradle must run on **JDK 17–21**. A newer JDK fails with a bare version number
|
||||
as the error message, which is not obvious the first time you see it:
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
|
||||
cd tools/editors/jetbrains
|
||||
./gradlew buildPlugin # -> build/distributions/Ludic-1.0.0.zip
|
||||
./gradlew runIde # try it in a sandbox IDE
|
||||
```
|
||||
|
||||
The first build downloads an IntelliJ IDEA Community distribution (over 1 GB),
|
||||
so expect it to take a while; later builds take seconds. Once the cache is warm,
|
||||
|
||||
```bash
|
||||
LUDIC_TEST_JETBRAINS=1 ./tools/test-tools.sh
|
||||
```
|
||||
|
||||
includes the plugin build in the toolchain's own test run.
|
||||
|
||||
Install the zip with **Settings -> Plugins -> ⚙ -> Install Plugin from Disk**.
|
||||
LSP4IJ is a required dependency; the IDE offers to install it for you.
|
||||
|
||||
## Settings
|
||||
|
||||
**Settings -> Languages & Frameworks -> Ludic** — the path to `ludic-lsp` and to
|
||||
`ludicc`. Both default to `build/` under the project root, which is where
|
||||
`tools/build-tools.sh` puts them.
|
||||
96
tools/editors/jetbrains/build.gradle.kts
Normal file
96
tools/editors/jetbrains/build.gradle.kts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
|
||||
import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
|
||||
|
||||
plugins {
|
||||
id("java")
|
||||
id("org.jetbrains.kotlin.jvm") version "2.4.10"
|
||||
// The IntelliJ Platform Gradle Plugin 2.x. The 1.x line (org.jetbrains.intellij)
|
||||
// is superseded and does not run on current Gradle.
|
||||
id("org.jetbrains.intellij.platform") version "2.18.1"
|
||||
}
|
||||
|
||||
group = providers.gradleProperty("pluginGroup").get()
|
||||
version = providers.gradleProperty("pluginVersion").get()
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
intellijPlatform {
|
||||
defaultRepositories()
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
intellijPlatform {
|
||||
// Community, deliberately: the plugin must work in the free IDEs, which
|
||||
// is also why the language server is wired up through LSP4IJ rather
|
||||
// than com.intellij.platform.lsp (Ultimate and friends only).
|
||||
// useInstaller = false takes the Maven artifact (com.jetbrains.intellij.idea:ideaIC)
|
||||
// instead of the .dmg/.tar.gz installer. Since 2025.x the Community installer is
|
||||
// published as `idea-<version>`, not `ideaIC-<version>`, and the installer path
|
||||
// cannot find it; the Maven coordinate is unaffected by that rename.
|
||||
intellijIdeaCommunity(providers.gradleProperty("platformVersion").get()) {
|
||||
useInstaller = false
|
||||
}
|
||||
bundledPlugin("org.intellij.plugins.markdown")
|
||||
plugin("com.redhat.devtools.lsp4ij", providers.gradleProperty("lsp4ijVersion").get())
|
||||
// JetBrains' Plugin Verifier CLI, used by the verifyPlugin task below
|
||||
pluginVerifier()
|
||||
// headless fixtures for the platform tests in src/test
|
||||
testFramework(TestFrameworkType.Platform)
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
intellijPlatform {
|
||||
pluginConfiguration {
|
||||
name = providers.gradleProperty("pluginName")
|
||||
version = providers.gradleProperty("pluginVersion")
|
||||
ideaVersion {
|
||||
sinceBuild = providers.gradleProperty("pluginSinceBuild")
|
||||
untilBuild = providers.gradleProperty("pluginUntilBuild")
|
||||
}
|
||||
}
|
||||
// Compatibility is claimed up to pluginUntilBuild, so it gets checked
|
||||
// against that IDE rather than assumed: `./gradlew verifyPlugin` runs
|
||||
// JetBrains' own Plugin Verifier over the built artifact.
|
||||
pluginVerification {
|
||||
ides {
|
||||
// The platform this is compiled against, which is also the newest
|
||||
// that exists (2025.3 = build 253). until-build claims 262, which
|
||||
// no released IDE reaches yet.
|
||||
create(IntelliJPlatformType.IntellijIdeaCommunity, providers.gradleProperty("platformVersion").get()) {
|
||||
useInstaller = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildSearchableOptions = false
|
||||
|
||||
// The platform's bytecode instrumenter only adds @NotNull assertions and
|
||||
// compiles .form files. This plugin is Kotlin with no GUI forms, so it has
|
||||
// nothing to instrument — and skipping it avoids the instrumenter's JBR
|
||||
// probing, which fails on a plain JDK layout.
|
||||
instrumentCode = false
|
||||
}
|
||||
|
||||
// `./gradlew runIde` opens the Ludic repository itself, so the sandbox has real
|
||||
// .ludic files to look at and build/ludic-lsp sitting where the plugin expects.
|
||||
// Three levels up: jetbrains -> editors -> tools -> repository root. Getting
|
||||
// this wrong points the IDE at tools/, where no build/ludic-lsp exists, and the
|
||||
// language server then cannot find itself.
|
||||
val repoRoot: File = rootProject.projectDir.parentFile.parentFile.parentFile
|
||||
tasks.named<JavaExec>("runIde") {
|
||||
args = listOf(repoRoot.absolutePath)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnit()
|
||||
testLogging {
|
||||
events("passed", "failed", "skipped")
|
||||
showStandardStreams = false
|
||||
}
|
||||
}
|
||||
26
tools/editors/jetbrains/gradle.properties
Normal file
26
tools/editors/jetbrains/gradle.properties
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
pluginGroup = io.ludic.ide
|
||||
pluginName = Ludic
|
||||
pluginVersion = 1.0.0
|
||||
|
||||
# 2023.2 is the first build with the platform APIs this plugin uses. LSP4IJ (not
|
||||
# the paid-IDE LSP API) supplies the language-server bridge, so Community
|
||||
# editions are supported as well as Ultimate.
|
||||
pluginSinceBuild = 232
|
||||
# Deliberately ahead of the newest released platform (2025.3 is build 253): an
|
||||
# until-build that stops at today's IDE gets the plugin auto-disabled the moment
|
||||
# a user upgrades. 262 buys roughly three release cycles of headroom. The APIs
|
||||
# this plugin uses are the stable ones (LexerBase, SyntaxHighlighter,
|
||||
# ParserDefinition, FoldingBuilder), and everything project-wide is delegated to
|
||||
# ludic-lsp over a versioned protocol rather than to platform internals.
|
||||
pluginUntilBuild = 262.*
|
||||
|
||||
platformVersion = 2025.3
|
||||
lsp4ijVersion = 0.20.1
|
||||
|
||||
# Gradle itself must run on JDK 17-21; a newer JDK reports only its version
|
||||
# number as the failure. The Kotlin toolchain below is pinned separately.
|
||||
# The wrapper is Gradle 9.x because IntelliJ Platform Gradle Plugin 2.13+
|
||||
# requires it.
|
||||
org.gradle.jvmargs = -Xmx2g
|
||||
org.gradle.caching = true
|
||||
kotlin.stdlib.default.dependency = false
|
||||
BIN
tools/editors/jetbrains/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
tools/editors/jetbrains/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
tools/editors/jetbrains/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
tools/editors/jetbrains/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
248
tools/editors/jetbrains/gradlew
vendored
Executable file
248
tools/editors/jetbrains/gradlew
vendored
Executable file
|
|
@ -0,0 +1,248 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
93
tools/editors/jetbrains/gradlew.bat
vendored
Normal file
93
tools/editors/jetbrains/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
2
tools/editors/jetbrains/settings.gradle.kts
Normal file
2
tools/editors/jetbrains/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// The distribution is named after this, so it is the plugin name, not a slug.
|
||||
rootProject.name = "Ludic"
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.openapi.options.Configurable
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.TextFieldWithBrowseButton
|
||||
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
|
||||
import com.intellij.ui.components.JBCheckBox
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.FormBuilder
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JSpinner
|
||||
import javax.swing.SpinnerNumberModel
|
||||
|
||||
/** Settings -> Languages and Frameworks -> Ludic */
|
||||
class LudicConfigurable(private val project: Project) : Configurable {
|
||||
private val serverField = TextFieldWithBrowseButton()
|
||||
private val compilerField = TextFieldWithBrowseButton()
|
||||
private val diagnostics = JBCheckBox("Report compiler errors on save")
|
||||
private val indent = JSpinner(SpinnerNumberModel(2, 1, 8, 1))
|
||||
private var panel: JPanel? = null
|
||||
|
||||
override fun getDisplayName() = "Ludic"
|
||||
|
||||
override fun createComponent(): JComponent {
|
||||
// The title travels on the descriptor now; the four-argument overload
|
||||
// that took it separately is scheduled for removal.
|
||||
serverField.addBrowseFolderListener(
|
||||
project,
|
||||
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludic-lsp")
|
||||
)
|
||||
compilerField.addBrowseFolderListener(
|
||||
project,
|
||||
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludicc")
|
||||
)
|
||||
|
||||
panel = FormBuilder.createFormBuilder()
|
||||
.addLabeledComponent(JBLabel("Language server (ludic-lsp):"), serverField, 1, false)
|
||||
.addComponentToRightColumn(
|
||||
JBLabel("Leave empty to use build/ludic-lsp under the project, then PATH.")
|
||||
)
|
||||
.addLabeledComponent(JBLabel("Compiler (ludicc):"), compilerField, 1, false)
|
||||
.addComponentToRightColumn(
|
||||
JBLabel("Used for authoritative diagnostics. Empty means build/ludicc.")
|
||||
)
|
||||
.addComponent(diagnostics)
|
||||
.addLabeledComponent(JBLabel("Formatter indent:"), indent, 1, false)
|
||||
.addComponentFillVertically(JPanel(), 0)
|
||||
.panel
|
||||
reset()
|
||||
return panel!!
|
||||
}
|
||||
|
||||
private fun settings() = LudicSettings.of(project)
|
||||
|
||||
override fun isModified(): Boolean {
|
||||
val s = settings()
|
||||
return serverField.text != s.serverPath ||
|
||||
compilerField.text != s.compilerPath ||
|
||||
diagnostics.isSelected != s.compilerDiagnostics ||
|
||||
(indent.value as Int) != s.indentSize
|
||||
}
|
||||
|
||||
override fun apply() {
|
||||
val s = settings()
|
||||
s.serverPath = serverField.text.trim()
|
||||
s.compilerPath = compilerField.text.trim()
|
||||
s.compilerDiagnostics = diagnostics.isSelected
|
||||
s.indentSize = indent.value as Int
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
val s = settings()
|
||||
serverField.text = s.serverPath
|
||||
compilerField.text = s.compilerPath
|
||||
diagnostics.isSelected = s.compilerDiagnostics
|
||||
indent.value = s.indentSize
|
||||
}
|
||||
|
||||
override fun disposeUIResources() {
|
||||
panel = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lang.BracePair
|
||||
import com.intellij.lang.Commenter
|
||||
import com.intellij.lang.PairedBraceMatcher
|
||||
import com.intellij.lang.folding.FoldingBuilderEx
|
||||
import com.intellij.lang.folding.FoldingDescriptor
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.tree.IElementType
|
||||
|
||||
/**
|
||||
* The small editor mechanics the platform expects a language to provide: how to
|
||||
* comment a line, which delimiters pair up, and what can be folded. None of
|
||||
* this needs the language server, so none of it waits for one.
|
||||
*/
|
||||
class LudicCommenter : Commenter {
|
||||
override fun getLineCommentPrefix() = "# "
|
||||
override fun getBlockCommentPrefix(): String? = null
|
||||
override fun getBlockCommentSuffix(): String? = null
|
||||
override fun getCommentedBlockCommentPrefix(): String? = null
|
||||
override fun getCommentedBlockCommentSuffix(): String? = null
|
||||
}
|
||||
|
||||
class LudicBraceMatcher : PairedBraceMatcher {
|
||||
override fun getPairs(): Array<BracePair> = PAIRS
|
||||
|
||||
override fun isPairedBracesAllowedBeforeType(lbraceType: IElementType, next: IElementType?): Boolean = true
|
||||
|
||||
override fun getCodeConstructStart(file: PsiFile?, openingBraceOffset: Int): Int = openingBraceOffset
|
||||
|
||||
private companion object {
|
||||
// Structural braces only: a record literal shares the token type, so
|
||||
// marking them all structural would make the "matched brace" gutter
|
||||
// noisy inside `spawn Hero { Pos = { x = 1 } }`.
|
||||
val PAIRS = arrayOf(
|
||||
BracePair(LudicTokens.BRACE, LudicTokens.BRACE, true),
|
||||
BracePair(LudicTokens.BRACKET, LudicTokens.BRACKET, false),
|
||||
BracePair(LudicTokens.PAREN, LudicTokens.PAREN, false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds every braced block. Working off the lexer's token stream rather than a
|
||||
* PSI tree keeps this correct for a flat PSI, and it degrades gracefully on a
|
||||
* file with unbalanced braces, which is most files while they are being typed.
|
||||
*/
|
||||
class LudicFoldingBuilder : FoldingBuilderEx(), DumbAware {
|
||||
override fun buildFoldRegions(root: PsiElement, document: Document, quick: Boolean): Array<FoldingDescriptor> {
|
||||
val text = root.containingFile?.text ?: return emptyArray()
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(text, 0, text.length, 0)
|
||||
|
||||
val opens = ArrayDeque<Int>()
|
||||
val regions = mutableListOf<FoldingDescriptor>()
|
||||
val node = root.node ?: return emptyArray()
|
||||
|
||||
while (lexer.tokenType != null) {
|
||||
if (lexer.tokenType == LudicTokens.BRACE) {
|
||||
when (text[lexer.tokenStart]) {
|
||||
'{' -> opens.addLast(lexer.tokenStart)
|
||||
'}' -> {
|
||||
val open = opens.removeLastOrNull()
|
||||
if (open != null) {
|
||||
val end = lexer.tokenEnd
|
||||
// only worth folding when it actually spans lines
|
||||
if (document.getLineNumber(end) > document.getLineNumber(open)) {
|
||||
regions += FoldingDescriptor(node, TextRange(open, end))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lexer.advance()
|
||||
}
|
||||
return regions.toTypedArray()
|
||||
}
|
||||
|
||||
override fun getPlaceholderText(node: com.intellij.lang.ASTNode) = "{...}"
|
||||
override fun isCollapsedByDefault(node: com.intellij.lang.ASTNode) = false
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lang.Language
|
||||
import com.intellij.openapi.fileTypes.LanguageFileType
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import javax.swing.Icon
|
||||
|
||||
/**
|
||||
* The language ID matters more than it looks: the IDE's Markdown support
|
||||
* resolves a fenced block's info string ("```ludic") against registered
|
||||
* language IDs, so registering as "Ludic" is what makes fences in .md files
|
||||
* highlight, with no Markdown-specific code on our side.
|
||||
*/
|
||||
object LudicLanguage : Language("Ludic") {
|
||||
private fun readResolve(): Any = LudicLanguage
|
||||
override fun getDisplayName() = "Ludic"
|
||||
override fun isCaseSensitive() = true
|
||||
}
|
||||
|
||||
object LudicIcons {
|
||||
val FILE: Icon = IconLoader.getIcon("/icons/ludic.svg", LudicIcons::class.java)
|
||||
}
|
||||
|
||||
object LudicFileType : LanguageFileType(LudicLanguage) {
|
||||
private fun readResolve(): Any = LudicFileType
|
||||
override fun getName() = "Ludic"
|
||||
override fun getDescription() = "Ludic source"
|
||||
override fun getDefaultExtension() = "ludic"
|
||||
override fun getIcon(): Icon = LudicIcons.FILE
|
||||
override fun getCharset(file: VirtualFile, content: ByteArray): String = "UTF-8"
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lexer.LexerBase
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.tree.IElementType
|
||||
|
||||
/**
|
||||
* A hand-written lexer matching tools/ludic-tools/ludic_syntax.h token for
|
||||
* token. Hand-written rather than generated because the grammar is small, the
|
||||
* rules are the ones the C lexer already implements, and keeping the two
|
||||
* readable side by side is worth more here than a JFlex spec.
|
||||
*
|
||||
* It must never throw and never fail to advance: the IDE lexes half-typed text
|
||||
* constantly, so anything unrecognised becomes one BAD character and the scan
|
||||
* carries on.
|
||||
*/
|
||||
class LudicLexer : LexerBase() {
|
||||
private var buffer: CharSequence = ""
|
||||
private var startOffset = 0
|
||||
private var endOffset = 0
|
||||
private var tokenStart = 0
|
||||
private var tokenEnd = 0
|
||||
private var tokenType: IElementType? = null
|
||||
|
||||
override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) {
|
||||
this.buffer = buffer
|
||||
this.startOffset = startOffset
|
||||
this.endOffset = endOffset
|
||||
this.tokenStart = startOffset
|
||||
this.tokenEnd = startOffset
|
||||
advance()
|
||||
}
|
||||
|
||||
override fun getState() = 0
|
||||
override fun getTokenType(): IElementType? = tokenType
|
||||
override fun getTokenStart() = tokenStart
|
||||
override fun getTokenEnd() = tokenEnd
|
||||
override fun getBufferSequence() = buffer
|
||||
override fun getBufferEnd() = endOffset
|
||||
|
||||
private fun at(i: Int): Char = if (i in startOffset until endOffset) buffer[i] else ' '
|
||||
private fun isIdStart(c: Char) = c.isLetter() || c == '_'
|
||||
private fun isIdPart(c: Char) = c.isLetterOrDigit() || c == '_'
|
||||
private fun isSpace(c: Char) = c == ' ' || c == '\t' || c == '\r' || c == '\n'
|
||||
|
||||
override fun advance() {
|
||||
tokenStart = tokenEnd
|
||||
if (tokenStart >= endOffset) {
|
||||
tokenType = null
|
||||
return
|
||||
}
|
||||
|
||||
var i = tokenStart
|
||||
val c = at(i)
|
||||
|
||||
if (isSpace(c)) {
|
||||
while (i < endOffset && isSpace(at(i))) i++
|
||||
emit(i, TokenType.WHITE_SPACE)
|
||||
return
|
||||
}
|
||||
if (c == '#') {
|
||||
while (i < endOffset && at(i) != '\n') i++
|
||||
emit(i, LudicTokens.COMMENT)
|
||||
return
|
||||
}
|
||||
if (c == '"') {
|
||||
i++
|
||||
while (i < endOffset && at(i) != '"' && at(i) != '\n') {
|
||||
if (at(i) == '\\' && i + 1 < endOffset) i += 2 else i++
|
||||
}
|
||||
if (i < endOffset && at(i) == '"') i++
|
||||
emit(i, LudicTokens.STRING)
|
||||
return
|
||||
}
|
||||
if (c == '\'') {
|
||||
i++
|
||||
if (at(i) == '\\' && i + 1 < endOffset) i += 2 else if (i < endOffset && at(i) != '\n') i++
|
||||
if (i < endOffset && at(i) == '\'') i++
|
||||
emit(i, LudicTokens.CHAR)
|
||||
return
|
||||
}
|
||||
if (c.isDigit()) {
|
||||
if (c == '0' && (at(i + 1) == 'x' || at(i + 1) == 'X')) {
|
||||
i += 2
|
||||
while (i < endOffset && (at(i).isDigit() || at(i) in 'a'..'f' || at(i) in 'A'..'F')) i++
|
||||
} else {
|
||||
while (i < endOffset && at(i).isDigit()) i++
|
||||
if (at(i) == '.' && at(i + 1).isDigit()) {
|
||||
i++
|
||||
while (i < endOffset && at(i).isDigit()) i++
|
||||
}
|
||||
}
|
||||
emit(i, LudicTokens.NUMBER)
|
||||
return
|
||||
}
|
||||
// @deterministic is one token: the sigil never stands alone
|
||||
if (c == '@' && isIdStart(at(i + 1))) {
|
||||
i++
|
||||
while (i < endOffset && isIdPart(at(i))) i++
|
||||
emit(i, LudicTokens.ANNOTATION)
|
||||
return
|
||||
}
|
||||
if (isIdStart(c)) {
|
||||
while (i < endOffset && isIdPart(at(i))) i++
|
||||
val word = buffer.subSequence(tokenStart, i).toString()
|
||||
val type = when {
|
||||
word == "true" || word == "false" -> LudicTokens.BOOLEAN
|
||||
word in LudicVocabulary.PRIMITIVES -> LudicTokens.PRIMITIVE
|
||||
word in LudicVocabulary.PHASES -> LudicTokens.PHASE
|
||||
word in LudicVocabulary.DECL -> LudicTokens.DECL_KEYWORD
|
||||
word in LudicVocabulary.CLAUSE -> LudicTokens.CLAUSE
|
||||
word in LudicVocabulary.STMT -> LudicTokens.KEYWORD
|
||||
word in LudicVocabulary.BUILTINS -> LudicTokens.BUILTIN
|
||||
word[0].isUpperCase() -> LudicTokens.TYPE_NAME
|
||||
else -> LudicTokens.IDENTIFIER
|
||||
}
|
||||
emit(i, type)
|
||||
return
|
||||
}
|
||||
// two-character operators before one-character ones
|
||||
val two = if (i + 1 < endOffset) buffer.subSequence(i, i + 2).toString() else ""
|
||||
if (two in TWO_CHAR_OPS) {
|
||||
emit(i + 2, LudicTokens.OPERATOR)
|
||||
return
|
||||
}
|
||||
when (c) {
|
||||
'{', '}' -> { emit(i + 1, LudicTokens.BRACE); return }
|
||||
'[', ']' -> { emit(i + 1, LudicTokens.BRACKET); return }
|
||||
'(', ')' -> { emit(i + 1, LudicTokens.PAREN); return }
|
||||
',' -> { emit(i + 1, LudicTokens.COMMA); return }
|
||||
}
|
||||
if (c in SINGLE_CHAR_OPS) {
|
||||
emit(i + 1, LudicTokens.OPERATOR)
|
||||
return
|
||||
}
|
||||
emit(i + 1, LudicTokens.BAD)
|
||||
}
|
||||
|
||||
private fun emit(end: Int, type: IElementType) {
|
||||
tokenEnd = end
|
||||
tokenType = type
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val TWO_CHAR_OPS = setOf("->", "+=", "-=", "*=", "/=", "==", "!=", "<=", ">=", "&&", "||", "..", "=>")
|
||||
const val SINGLE_CHAR_OPS = "+-*/%<>=:.!@;"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lang.ASTNode
|
||||
import com.intellij.lang.ParserDefinition
|
||||
import com.intellij.lang.PsiParser
|
||||
import com.intellij.lexer.Lexer
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.psi.FileViewProvider
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.tree.IFileElementType
|
||||
import com.intellij.psi.tree.TokenSet
|
||||
import com.intellij.extapi.psi.ASTWrapperPsiElement
|
||||
import com.intellij.extapi.psi.PsiFileBase
|
||||
|
||||
/**
|
||||
* A flat PSI: every token becomes a leaf under the file node, with no tree
|
||||
* structure above it.
|
||||
*
|
||||
* That is enough for the two things the platform genuinely needs a parser for —
|
||||
* injecting Ludic into Markdown fences, and letting the editor treat a .ludic
|
||||
* file as a real language rather than plain text — while all actual analysis
|
||||
* stays in ludic-lsp, where it is shared with every other editor. Building a
|
||||
* second, divergent parser in Kotlin would mean two grammars to keep honest
|
||||
* against one compiler.
|
||||
*/
|
||||
class LudicParserDefinition : ParserDefinition {
|
||||
override fun createLexer(project: Project?): Lexer = LudicLexer()
|
||||
|
||||
override fun createParser(project: Project?): PsiParser = PsiParser { root, builder ->
|
||||
val mark = builder.mark()
|
||||
while (!builder.eof()) builder.advanceLexer()
|
||||
mark.done(root)
|
||||
builder.treeBuilt
|
||||
}
|
||||
|
||||
override fun getFileNodeType(): IFileElementType = LudicTokens.FILE
|
||||
override fun getCommentTokens(): TokenSet = TokenSet.create(LudicTokens.COMMENT)
|
||||
override fun getStringLiteralElements(): TokenSet = TokenSet.create(LudicTokens.STRING, LudicTokens.CHAR)
|
||||
override fun getWhitespaceTokens(): TokenSet = TokenSet.create(TokenType.WHITE_SPACE)
|
||||
// Only ever reached for composite nodes, and this grammar marks none beyond
|
||||
// the file itself — but the platform requires an answer, so give it a real
|
||||
// PSI element rather than something that would surprise it later.
|
||||
override fun createElement(node: ASTNode): PsiElement = ASTWrapperPsiElement(node)
|
||||
override fun createFile(viewProvider: FileViewProvider): PsiFile = LudicPsiFile(viewProvider)
|
||||
}
|
||||
|
||||
class LudicPsiFile(viewProvider: FileViewProvider) : PsiFileBase(viewProvider, LudicLanguage) {
|
||||
override fun getFileType() = LudicFileType
|
||||
override fun toString() = "Ludic file"
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.openapi.components.PersistentStateComponent
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.State
|
||||
import com.intellij.openapi.components.Storage
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.util.xmlb.XmlSerializerUtil
|
||||
import com.redhat.devtools.lsp4ij.LanguageServerFactory
|
||||
import com.redhat.devtools.lsp4ij.client.LanguageClientImpl
|
||||
import com.redhat.devtools.lsp4ij.server.CannotStartProcessException
|
||||
import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider
|
||||
import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Where to find the toolchain. Both paths may be left blank, in which case the
|
||||
* plugin looks under the project root for what tools/build-tools.sh produces —
|
||||
* which is the case for anyone working in the Ludic repository itself.
|
||||
*/
|
||||
@Service(Service.Level.PROJECT)
|
||||
@State(name = "LudicSettings", storages = [Storage("ludic.xml")])
|
||||
class LudicSettings : PersistentStateComponent<LudicSettings> {
|
||||
var serverPath: String = ""
|
||||
var compilerPath: String = ""
|
||||
var compilerDiagnostics: Boolean = true
|
||||
var indentSize: Int = 2
|
||||
|
||||
override fun getState(): LudicSettings = this
|
||||
override fun loadState(state: LudicSettings) = XmlSerializerUtil.copyBean(state, this)
|
||||
|
||||
companion object {
|
||||
fun of(project: Project): LudicSettings = project.service()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First existing executable among the usual places, or null.
|
||||
*
|
||||
* Takes the project root as a plain path rather than a Project so it can be
|
||||
* tested without booting an IDE — this lookup silently returning null is the
|
||||
* difference between a working language server and one that never starts.
|
||||
*/
|
||||
internal fun findExecutable(root: String?, configured: String, names: List<String>, subdirs: List<String>): String? {
|
||||
if (configured.isNotBlank()) {
|
||||
val f = File(expandHome(configured))
|
||||
return if (f.canExecute()) f.absolutePath else null
|
||||
}
|
||||
if (root == null) return searchPath(names)
|
||||
for (dir in subdirs) {
|
||||
for (name in names) {
|
||||
val f = File(File(root, dir), name)
|
||||
if (f.isFile && f.canExecute()) return f.absolutePath
|
||||
}
|
||||
}
|
||||
return searchPath(names)
|
||||
}
|
||||
|
||||
/** Fall back to PATH, so a system-wide install works with no configuration. */
|
||||
private fun searchPath(names: List<String>): String? {
|
||||
val path = System.getenv("PATH") ?: return null
|
||||
for (dir in path.split(File.pathSeparator)) {
|
||||
for (name in names) {
|
||||
val f = File(dir, name)
|
||||
if (f.isFile && f.canExecute()) return f.absolutePath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun expandHome(p: String) =
|
||||
if (p.startsWith("~")) System.getProperty("user.home") + p.substring(1) else p
|
||||
|
||||
class LudicConnectionProvider(private val project: Project) : ProcessStreamConnectionProvider() {
|
||||
|
||||
/**
|
||||
* Resolve at start(), not at construction.
|
||||
*
|
||||
* The provider is built when the project opens, which is usually before the
|
||||
* toolchain has been compiled. Resolving in an `init` block froze that
|
||||
* answer forever: build ludic-lsp afterwards and the server stayed dead
|
||||
* until the IDE was restarted, with `commands=[]` as the only clue.
|
||||
*/
|
||||
override fun start() {
|
||||
val settings = LudicSettings.of(project)
|
||||
val root = project.basePath
|
||||
setCommands(serverCommand(root, settings.serverPath)
|
||||
?: throw CannotStartProcessException(notFoundMessage(root, settings.serverPath)))
|
||||
root?.let { setWorkingDirectory(it) }
|
||||
super.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* What went wrong, in terms of the user's filesystem. "commands=[]" tells
|
||||
* nobody anything; this names every path that was tried.
|
||||
*/
|
||||
private fun notFoundMessage(root: String?, configured: String): String = buildString {
|
||||
if (configured.isNotBlank()) {
|
||||
append("The Ludic language server configured at '")
|
||||
append(configured)
|
||||
append("' is missing or not executable.")
|
||||
} else {
|
||||
append("Could not find the 'ludic-lsp' executable. Looked in:\n")
|
||||
SERVER_SUBDIRS.forEach { d ->
|
||||
append(" ")
|
||||
append(if (root != null) File(File(root, d), "ludic-lsp").path else "<no project root>/$d/ludic-lsp")
|
||||
append('\n')
|
||||
}
|
||||
append(" and on PATH.\n")
|
||||
}
|
||||
append("\nBuild it with ./tools/build-tools.sh from the repository root, ")
|
||||
append("or set the path in Settings | Languages & Frameworks | Ludic. ")
|
||||
append("Syntax highlighting works without it; completion and diagnostics do not.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to the server's `initialize`. The server can find `ludicc` on its
|
||||
* own, but telling it explicitly means the IDE's setting wins.
|
||||
*/
|
||||
override fun getInitializationOptions(rootUri: VirtualFile?): Any {
|
||||
val settings = LudicSettings.of(project)
|
||||
val compiler = findExecutable(
|
||||
project.basePath, settings.compilerPath,
|
||||
listOf("ludicc", "ludicc.exe"),
|
||||
COMPILER_SUBDIRS
|
||||
) ?: ""
|
||||
return mapOf(
|
||||
"compilerPath" to compiler,
|
||||
"compilerDiagnostics" to settings.compilerDiagnostics,
|
||||
"indentSize" to settings.indentSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact argv the language server is launched with, or null when the binary
|
||||
* cannot be found. Split out from start() so the command line itself is
|
||||
* testable — an empty or wrong command here is invisible until LSP4IJ reports
|
||||
* `commands=[]`, which says nothing about why.
|
||||
*/
|
||||
internal fun serverCommand(root: String?, configured: String): List<String>? {
|
||||
val exe = findExecutable(root, configured, listOf("ludic-lsp", "ludic-lsp.exe"), SERVER_SUBDIRS)
|
||||
return exe?.let { listOf(it, "--stdio") }
|
||||
}
|
||||
|
||||
/** Where the toolchain is looked for, relative to the project root. */
|
||||
internal val SERVER_SUBDIRS = listOf("build", "tools/build", "bin", ".")
|
||||
internal val COMPILER_SUBDIRS = listOf("build", "bin", ".")
|
||||
|
||||
class LudicLanguageServerFactory : LanguageServerFactory {
|
||||
override fun createConnectionProvider(project: Project): StreamConnectionProvider =
|
||||
LudicConnectionProvider(project)
|
||||
|
||||
override fun createLanguageClient(project: Project): LanguageClientImpl =
|
||||
LanguageClientImpl(project)
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lexer.Lexer
|
||||
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors as D
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey.createTextAttributesKey
|
||||
import com.intellij.openapi.editor.HighlighterColors
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighter
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighterBase
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighterFactory
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.psi.tree.IElementType
|
||||
|
||||
/**
|
||||
* Colours are all derived from the IDE's own semantic keys rather than hard
|
||||
* coded, so Ludic looks native in whatever theme the user has, dark or light,
|
||||
* without shipping a colour scheme.
|
||||
*/
|
||||
object LudicColors {
|
||||
val COMMENT = key("LUDIC_COMMENT", D.LINE_COMMENT)
|
||||
val STRING = key("LUDIC_STRING", D.STRING)
|
||||
val NUMBER = key("LUDIC_NUMBER", D.NUMBER)
|
||||
val KEYWORD = key("LUDIC_KEYWORD", D.KEYWORD)
|
||||
val DECL_KEYWORD = key("LUDIC_DECL_KEYWORD", D.KEYWORD)
|
||||
val CLAUSE = key("LUDIC_CLAUSE", D.METADATA)
|
||||
val PRIMITIVE = key("LUDIC_PRIMITIVE", D.KEYWORD)
|
||||
val PHASE = key("LUDIC_PHASE", D.STATIC_FIELD)
|
||||
val BOOLEAN = key("LUDIC_BOOLEAN", D.KEYWORD)
|
||||
val BUILTIN = key("LUDIC_BUILTIN", D.PREDEFINED_SYMBOL)
|
||||
val TYPE_NAME = key("LUDIC_TYPE", D.CLASS_NAME)
|
||||
val IDENTIFIER = key("LUDIC_IDENTIFIER", D.IDENTIFIER)
|
||||
val ANNOTATION = key("LUDIC_ANNOTATION", D.METADATA)
|
||||
val OPERATOR = key("LUDIC_OPERATOR", D.OPERATION_SIGN)
|
||||
val BRACE = key("LUDIC_BRACE", D.BRACES)
|
||||
val BRACKET = key("LUDIC_BRACKET", D.BRACKETS)
|
||||
val PAREN = key("LUDIC_PAREN", D.PARENTHESES)
|
||||
val COMMA = key("LUDIC_COMMA", D.COMMA)
|
||||
val BAD = createTextAttributesKey("LUDIC_BAD_CHARACTER", HighlighterColors.BAD_CHARACTER)
|
||||
|
||||
private fun key(name: String, base: TextAttributesKey) = createTextAttributesKey(name, base)
|
||||
}
|
||||
|
||||
class LudicSyntaxHighlighter : SyntaxHighlighterBase() {
|
||||
override fun getHighlightingLexer(): Lexer = LudicLexer()
|
||||
|
||||
override fun getTokenHighlights(tokenType: IElementType): Array<TextAttributesKey> = pack(
|
||||
when (tokenType) {
|
||||
LudicTokens.COMMENT -> LudicColors.COMMENT
|
||||
LudicTokens.STRING, LudicTokens.CHAR -> LudicColors.STRING
|
||||
LudicTokens.NUMBER -> LudicColors.NUMBER
|
||||
LudicTokens.KEYWORD -> LudicColors.KEYWORD
|
||||
LudicTokens.DECL_KEYWORD -> LudicColors.DECL_KEYWORD
|
||||
LudicTokens.CLAUSE -> LudicColors.CLAUSE
|
||||
LudicTokens.PRIMITIVE -> LudicColors.PRIMITIVE
|
||||
LudicTokens.PHASE -> LudicColors.PHASE
|
||||
LudicTokens.BOOLEAN -> LudicColors.BOOLEAN
|
||||
LudicTokens.BUILTIN -> LudicColors.BUILTIN
|
||||
LudicTokens.TYPE_NAME -> LudicColors.TYPE_NAME
|
||||
LudicTokens.IDENTIFIER -> LudicColors.IDENTIFIER
|
||||
LudicTokens.ANNOTATION -> LudicColors.ANNOTATION
|
||||
LudicTokens.OPERATOR -> LudicColors.OPERATOR
|
||||
LudicTokens.BRACE -> LudicColors.BRACE
|
||||
LudicTokens.BRACKET -> LudicColors.BRACKET
|
||||
LudicTokens.PAREN -> LudicColors.PAREN
|
||||
LudicTokens.COMMA -> LudicColors.COMMA
|
||||
LudicTokens.BAD -> LudicColors.BAD
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class LudicSyntaxHighlighterFactory : SyntaxHighlighterFactory() {
|
||||
override fun getSyntaxHighlighter(project: Project?, virtualFile: VirtualFile?): SyntaxHighlighter =
|
||||
LudicSyntaxHighlighter()
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.psi.tree.IElementType
|
||||
import com.intellij.psi.tree.IFileElementType
|
||||
|
||||
/**
|
||||
* One token type per thing the highlighter needs to tell apart. This is a
|
||||
* lexer-only language on the JetBrains side: the token set is the whole grammar
|
||||
* the platform sees, and ludic-lsp supplies everything structural.
|
||||
*/
|
||||
class LudicTokenType(debugName: String) : IElementType(debugName, LudicLanguage)
|
||||
|
||||
object LudicTokens {
|
||||
val FILE = IFileElementType(LudicLanguage)
|
||||
|
||||
val COMMENT = LudicTokenType("COMMENT")
|
||||
val STRING = LudicTokenType("STRING")
|
||||
val CHAR = LudicTokenType("CHAR")
|
||||
val NUMBER = LudicTokenType("NUMBER")
|
||||
val IDENTIFIER = LudicTokenType("IDENTIFIER")
|
||||
val TYPE_NAME = LudicTokenType("TYPE_NAME") // Capitalised: component, archetype, scene
|
||||
val KEYWORD = LudicTokenType("KEYWORD")
|
||||
val DECL_KEYWORD = LudicTokenType("DECL_KEYWORD") // game, component, system, fn, ...
|
||||
val CLAUSE = LudicTokenType("CLAUSE") // phase, query, reads, writes, ...
|
||||
val PRIMITIVE = LudicTokenType("PRIMITIVE") // int, fixed, bool, entity, str, ptr, void
|
||||
val PHASE = LudicTokenType("PHASE") // Start, Update, Render, ...
|
||||
val BOOLEAN = LudicTokenType("BOOLEAN")
|
||||
val BUILTIN = LudicTokenType("BUILTIN") // runtime surface + compiler intrinsics
|
||||
val ANNOTATION = LudicTokenType("ANNOTATION") // @deterministic
|
||||
val OPERATOR = LudicTokenType("OPERATOR")
|
||||
val BRACE = LudicTokenType("BRACE")
|
||||
val BRACKET = LudicTokenType("BRACKET")
|
||||
val PAREN = LudicTokenType("PAREN")
|
||||
val COMMA = LudicTokenType("COMMA")
|
||||
val BAD = LudicTokenType("BAD_CHARACTER")
|
||||
}
|
||||
|
||||
/**
|
||||
* The vocabulary, mirroring tools/ludic-tools/ludic_syntax.h. When the language
|
||||
* grows a keyword both sides change together; that header is the reference.
|
||||
*/
|
||||
object LudicVocabulary {
|
||||
val DECL = setOf(
|
||||
"game", "module", "import", "component", "struct", "archetype", "ui", "scene", "layer",
|
||||
"const", "var", "export", "fn", "pure", "extern", "system", "edge", "start", "on"
|
||||
)
|
||||
val CLAUSE = setOf(
|
||||
"phase", "query", "reads", "writes", "needs", "uses",
|
||||
"requires", "ensures", "invariant", "effects"
|
||||
)
|
||||
val STMT = setOf(
|
||||
"let", "return", "if", "else", "when", "while", "for", "in", "spawn", "despawn",
|
||||
"match", "machine", "state", "become", "enter", "where", "and", "or", "not",
|
||||
"break", "continue", "new"
|
||||
)
|
||||
val PRIMITIVES = setOf("int", "fixed", "bool", "entity", "str", "ptr", "void")
|
||||
val PHASES = setOf("Start", "Input", "FixedUpdate", "Update", "LateUpdate", "Render")
|
||||
val WIDGETS = setOf("panel", "col", "row", "label", "button", "image", "spacer")
|
||||
|
||||
val BUILTINS = setOf(
|
||||
"min", "max", "abs", "clamp", "seed", "rng_range", "rng_chance", "fx", "flr",
|
||||
"map_size", "map_row", "tile", "clear", "present", "fill_rect", "frame_rect",
|
||||
"put_px", "text", "text_int", "font_load", "text_ttf", "text_w", "text_h",
|
||||
"image_load", "draw_image", "draw_image_scaled", "draw_9slice", "load_png",
|
||||
"load_sprites", "draw_sprite", "draw_sprite_scaled", "ui_build", "ui_open",
|
||||
"ui_tick", "ui_render", "ui_clicked", "ui_set_text", "ui_set_int", "ui_focus",
|
||||
"ui_focused", "ui_visible", "key", "reg", "setreg", "self", "save", "load",
|
||||
"status", "print_int", "quit",
|
||||
// compiler intrinsics: the floor the Ludic-written runtime stands on
|
||||
"mem_alloc", "mem_realloc", "mem_free", "mem_copy", "mem_set", "peek8", "poke8", "peek32",
|
||||
"os_argc", "os_arg", "file_stderr",
|
||||
"poke32", "peekp", "pokep", "peekf", "pokef", "ptr_add", "ptr_null",
|
||||
"ptr_is_null", "as_fixed", "as_int", "file_open", "file_read", "file_write",
|
||||
"file_seek", "file_tell", "file_close", "read_byte", "write_byte", "print_str",
|
||||
"str_len", "shl", "shr", "band", "bor", "bxor", "bnot", "os_exit", "os_time",
|
||||
"is_windowed", "game_title", "win_open", "win_poll", "win_present",
|
||||
"win_running", "win_close"
|
||||
)
|
||||
|
||||
fun isKeyword(w: String) = w in DECL || w in CLAUSE || w in STMT
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<idea-plugin>
|
||||
<!--
|
||||
Loaded only when the Markdown plugin is present.
|
||||
|
||||
Nothing needs to be declared for ```ludic fences to highlight: the
|
||||
Markdown plugin resolves a fence's info string against registered language
|
||||
IDs, and io.ludic.ide.LudicLanguage registers as "Ludic". This file exists
|
||||
so that the dependency is expressed honestly — the feature does depend on
|
||||
the Markdown plugin — and as the place to hang Markdown-specific behaviour
|
||||
if the fence syntax ever needs more than the default.
|
||||
-->
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<idea-plugin>
|
||||
<id>io.ludic.ide</id>
|
||||
<name>Ludic</name>
|
||||
<vendor>Ludic</vendor>
|
||||
|
||||
<description><![CDATA[
|
||||
<p>Language support for <b>Ludic</b>, the AI-first, ECS-native game language.</p>
|
||||
<ul>
|
||||
<li>Syntax highlighting, brace matching, commenting and folding, implemented natively so a
|
||||
<code>.ludic</code> file is coloured with no external process.</li>
|
||||
<li>Completion, diagnostics, hover, go-to-definition, find usages, rename and formatting via
|
||||
the <code>ludic-lsp</code> language server, through LSP4IJ, so this works in Community
|
||||
editions as well as the paid IDEs.</li>
|
||||
<li>Errors from the Ludic compiler itself, reported on save.</li>
|
||||
<li>Highlighting of <code>```ludic</code> fenced blocks in Markdown.</li>
|
||||
</ul>
|
||||
<p>Set the paths under <b>Settings → Languages & Frameworks → Ludic</b>, or leave
|
||||
them blank to use <code>build/ludic-lsp</code> and <code>build/ludicc</code> under the
|
||||
project root.</p>
|
||||
]]></description>
|
||||
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
<depends>com.redhat.devtools.lsp4ij</depends>
|
||||
<!-- Optional: without it everything still works, we just do not get the
|
||||
Markdown fence injection that the platform's own Markdown plugin does
|
||||
for any registered language. -->
|
||||
<depends optional="true" config-file="ludic-markdown.xml">org.intellij.plugins.markdown</depends>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<fileType
|
||||
name="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicFileType"
|
||||
fieldName="INSTANCE"
|
||||
language="Ludic"
|
||||
extensions="ludic"/>
|
||||
|
||||
<lang.parserDefinition
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicParserDefinition"/>
|
||||
|
||||
<lang.syntaxHighlighterFactory
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicSyntaxHighlighterFactory"/>
|
||||
|
||||
<lang.commenter
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicCommenter"/>
|
||||
|
||||
<lang.braceMatcher
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicBraceMatcher"/>
|
||||
|
||||
<lang.foldingBuilder
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicFoldingBuilder"/>
|
||||
|
||||
<projectConfigurable
|
||||
parentId="language"
|
||||
instance="io.ludic.ide.LudicConfigurable"
|
||||
id="io.ludic.ide.settings"
|
||||
displayName="Ludic"/>
|
||||
</extensions>
|
||||
|
||||
<extensions defaultExtensionNs="com.redhat.devtools.lsp4ij">
|
||||
<server
|
||||
id="ludic"
|
||||
name="Ludic"
|
||||
factoryClass="io.ludic.ide.LudicLanguageServerFactory">
|
||||
<description><![CDATA[
|
||||
The Ludic language server. Built by <code>tools/build-tools.sh</code>.
|
||||
]]></description>
|
||||
</server>
|
||||
<languageMapping language="Ludic" serverId="ludic"/>
|
||||
<!-- Markdown is mapped too: the server understands ```ludic fences in a
|
||||
.md and reports diagnostics and semantic tokens for them. -->
|
||||
<languageMapping language="Markdown" serverId="ludic"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2c"/>
|
||||
<rect x="7" y="7" width="8" height="8" rx="1.5" fill="#ffe060"/>
|
||||
<rect x="17" y="7" width="8" height="8" rx="1.5" fill="#4a4a72"/>
|
||||
<rect x="7" y="17" width="8" height="8" rx="1.5" fill="#4a4a72"/>
|
||||
<rect x="17" y="17" width="8" height="8" rx="1.5" fill="#9aa0c8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 418 B |
|
|
@ -0,0 +1,155 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Locating `ludic-lsp` is the single point where the whole language server can
|
||||
* fail silently: if this returns null the provider starts with an empty command
|
||||
* list, LSP4IJ reports `commands=[]`, and nothing in that message points at the
|
||||
* cause. It shipped broken once for exactly that reason, so it gets tests.
|
||||
*/
|
||||
class FindExecutableTest {
|
||||
|
||||
@get:Rule
|
||||
val tmp = TemporaryFolder()
|
||||
|
||||
private val names = listOf("ludic-lsp", "ludic-lsp.exe")
|
||||
|
||||
private fun executableAt(vararg segments: String): File {
|
||||
val f = File(tmp.root, segments.joinToString(File.separator))
|
||||
f.parentFile.mkdirs()
|
||||
f.writeText("#!/bin/sh\n")
|
||||
check(f.setExecutable(true))
|
||||
return f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finds the binary that build-tools_sh produces`() {
|
||||
val exe = executableAt("build", "ludic-lsp")
|
||||
assertEquals(exe.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a project root that is a subdirectory of the repo finds nothing`() {
|
||||
// The bug that shipped: the IDE was opened on <repo>/tools, so the
|
||||
// lookup searched <repo>/tools/build and came back empty while
|
||||
// <repo>/build/ludic-lsp sat there the whole time.
|
||||
executableAt("build", "ludic-lsp")
|
||||
val subdir = File(tmp.root, "tools").apply { mkdirs() }
|
||||
assertNull(
|
||||
"must not silently resolve from an unrelated root",
|
||||
findExecutable(subdir.path, "", names, SERVER_SUBDIRS)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search order prefers build over the other candidates`() {
|
||||
executableAt("bin", "ludic-lsp")
|
||||
val preferred = executableAt("build", "ludic-lsp")
|
||||
assertEquals(preferred.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every documented subdirectory is actually searched`() {
|
||||
for (dir in SERVER_SUBDIRS) {
|
||||
val fresh = TemporaryFolder().apply { create() }
|
||||
val f = File(fresh.root, "$dir${File.separator}ludic-lsp")
|
||||
f.parentFile.mkdirs()
|
||||
f.writeText("#!/bin/sh\n")
|
||||
f.setExecutable(true)
|
||||
assertEquals(
|
||||
"subdir '$dir' is advertised but not searched",
|
||||
f.canonicalPath,
|
||||
File(findExecutable(fresh.root.path, "", names, SERVER_SUBDIRS)!!).canonicalPath
|
||||
)
|
||||
fresh.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicitly configured path wins over anything found by search`() {
|
||||
executableAt("build", "ludic-lsp")
|
||||
val custom = executableAt("elsewhere", "my-lsp")
|
||||
assertEquals(custom.absolutePath, findExecutable(tmp.root.path, custom.absolutePath, names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a configured path that does not exist fails instead of falling back`() {
|
||||
// Silently ignoring the setting and using a different binary would be
|
||||
// worse than failing: the user asked for a specific server.
|
||||
executableAt("build", "ludic-lsp")
|
||||
assertNull(findExecutable(tmp.root.path, File(tmp.root, "nope").path, names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-executable file is not accepted`() {
|
||||
val f = File(tmp.root, "build/ludic-lsp")
|
||||
f.parentFile.mkdirs()
|
||||
f.writeText("not executable")
|
||||
f.setExecutable(false)
|
||||
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a directory named like the binary is not accepted`() {
|
||||
File(tmp.root, "build/ludic-lsp").mkdirs()
|
||||
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null project root still allows a PATH lookup`() {
|
||||
// No exception, no crash: the default project has no basePath.
|
||||
findExecutable(null, "", names, SERVER_SUBDIRS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the real repository layout resolves`() {
|
||||
// Guards the arrangement the plugin actually ships against: run
|
||||
// ./tools/build-tools.sh and opening the repo root must just work.
|
||||
val repoRoot = File(System.getProperty("user.dir")).parentFile.parentFile.parentFile
|
||||
val built = File(repoRoot, "build/ludic-lsp")
|
||||
if (!built.isFile) return // toolchain not built in this checkout
|
||||
val found = findExecutable(repoRoot.path, "", names, SERVER_SUBDIRS)
|
||||
assertTrue("repo root must resolve to build/ludic-lsp", found != null)
|
||||
assertEquals(built.canonicalPath, File(found!!).canonicalPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The launch command itself. LSP4IJ only ever reports `commands=[...]`, so if
|
||||
* this list is wrong the failure surfaces with no explanation at all.
|
||||
*/
|
||||
class ServerCommandTest {
|
||||
|
||||
private val repoRoot: File =
|
||||
File(System.getProperty("user.dir")).parentFile.parentFile.parentFile
|
||||
|
||||
@Test
|
||||
fun `the repository root produces a runnable stdio command`() {
|
||||
val built = File(repoRoot, "build/ludic-lsp")
|
||||
if (!built.isFile) return // toolchain not built in this checkout
|
||||
|
||||
val cmd = serverCommand(repoRoot.path, "")
|
||||
assertEquals(listOf(built.canonicalPath, "--stdio"), cmd!!.let { listOf(File(it[0]).canonicalPath, it[1]) })
|
||||
|
||||
// and it must actually be executable, not merely present
|
||||
val proc = ProcessBuilder(cmd[0], "--version").redirectErrorStream(true).start()
|
||||
val out = proc.inputStream.bufferedReader().readText().trim()
|
||||
proc.waitFor()
|
||||
assertTrue("expected a version banner, got '$out'", out.startsWith("ludic-lsp"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a root without the toolchain yields no command rather than an empty one`() {
|
||||
// The distinction that matters: null lets start() throw an explanatory
|
||||
// error; an empty list is what produced the useless `commands=[]`.
|
||||
val cmd = serverCommand(File(repoRoot, "tools/editors").path, "")
|
||||
assertTrue("must be null, not an empty list", cmd == null || cmd.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.tree.IElementType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Syntax highlighting is the lexer plus a colour map, so this is where a
|
||||
* highlighting regression actually shows up. These are plain JUnit tests: the
|
||||
* lexer has no platform dependencies, so they run in milliseconds without
|
||||
* booting an IDE.
|
||||
*/
|
||||
class LudicLexerTest {
|
||||
|
||||
private fun lex(text: String): List<Pair<IElementType, String>> {
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(text, 0, text.length, 0)
|
||||
val out = mutableListOf<Pair<IElementType, String>>()
|
||||
while (true) {
|
||||
val t = lexer.tokenType ?: break
|
||||
if (t != TokenType.WHITE_SPACE) {
|
||||
out += t to text.substring(lexer.tokenStart, lexer.tokenEnd)
|
||||
}
|
||||
lexer.advance()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun typesOf(text: String) = lex(text).map { it.first }
|
||||
|
||||
@Test
|
||||
fun `declaration keywords are distinct from statement keywords`() {
|
||||
assertEquals(
|
||||
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.BRACE),
|
||||
typesOf("game Hello {")
|
||||
)
|
||||
assertEquals(LudicTokens.KEYWORD, typesOf("if x { }").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clause keywords are their own class`() {
|
||||
// `phase` and `query` colour differently from `if`/`let`, which is the
|
||||
// whole reason a system signature reads as a signature.
|
||||
assertEquals(
|
||||
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.CLAUSE, LudicTokens.PHASE),
|
||||
typesOf("system Move phase FixedUpdate")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primitive types and phases are not plain identifiers`() {
|
||||
assertEquals(LudicTokens.PRIMITIVE, typesOf("int").single())
|
||||
assertEquals(LudicTokens.PRIMITIVE, typesOf("fixed").single())
|
||||
assertEquals(LudicTokens.PHASE, typesOf("LateUpdate").single())
|
||||
assertEquals(LudicTokens.BOOLEAN, typesOf("true").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `builtins are recognised, unknown calls are not`() {
|
||||
assertEquals(LudicTokens.BUILTIN, typesOf("rng_chance").single())
|
||||
assertEquals(LudicTokens.BUILTIN, typesOf("peek32").single()) // intrinsic
|
||||
assertEquals(LudicTokens.IDENTIFIER, typesOf("my_helper").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capitalised words read as type names`() {
|
||||
assertEquals(LudicTokens.TYPE_NAME, typesOf("Pos").single())
|
||||
assertEquals(LudicTokens.IDENTIFIER, typesOf("pos").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `comments run to end of line and keep their text`() {
|
||||
val toks = lex("let a = 1 # keep me\nlet b = 2")
|
||||
val comment = toks.first { it.first == LudicTokens.COMMENT }
|
||||
assertEquals("# keep me", comment.second)
|
||||
// the newline ends it: the next line is code again
|
||||
assertEquals(LudicTokens.KEYWORD, toks[toks.indexOf(comment) + 1].first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strings handle escapes and do not swallow the rest of the file`() {
|
||||
val toks = lex(""""a\"b" 42""")
|
||||
assertEquals(LudicTokens.STRING, toks[0].first)
|
||||
assertEquals(""""a\"b"""", toks[0].second)
|
||||
assertEquals(LudicTokens.NUMBER, toks[1].first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unterminated string stops at the newline`() {
|
||||
// A half-typed string must not colour the whole rest of the file.
|
||||
val toks = lex("let s = \"oops\nlet t = 1")
|
||||
assertEquals(LudicTokens.STRING, toks[3].first)
|
||||
assertEquals("\"oops", toks[3].second)
|
||||
assertEquals(LudicTokens.KEYWORD, toks[4].first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numbers cover hex, fixed-point and the range operator`() {
|
||||
assertEquals(LudicTokens.NUMBER, typesOf("0x1affff").single())
|
||||
assertEquals(LudicTokens.NUMBER, typesOf("1.5").single())
|
||||
// `0..10` is two ints and a range op, not a malformed float
|
||||
assertEquals(
|
||||
listOf(LudicTokens.NUMBER, LudicTokens.OPERATOR, LudicTokens.NUMBER),
|
||||
typesOf("0..10")
|
||||
)
|
||||
assertEquals("..", lex("0..10")[1].second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotations lex as one token including the sigil`() {
|
||||
val toks = lex("@deterministic")
|
||||
assertEquals(LudicTokens.ANNOTATION, toks.single().first)
|
||||
assertEquals("@deterministic", toks.single().second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two-character operators beat one-character ones`() {
|
||||
assertEquals(listOf("->"), lex("->").map { it.second })
|
||||
assertEquals(listOf("=>"), lex("=>").map { it.second })
|
||||
assertEquals(listOf("!="), lex("!=").map { it.second })
|
||||
assertEquals(listOf("<="), lex("<=").map { it.second })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the lexer always terminates and covers every character`() {
|
||||
// The IDE lexes half-typed text constantly; a lexer that stalls hangs
|
||||
// the editor, and one that skips bytes corrupts every offset after it.
|
||||
val nasty = "game X { component ¿ { a: int = ' } # é\n query [{Tag}] \"unclosed"
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(nasty, 0, nasty.length, 0)
|
||||
var covered = 0
|
||||
var guard = 0
|
||||
while (lexer.tokenType != null) {
|
||||
assertTrue("token must advance", lexer.tokenEnd > lexer.tokenStart)
|
||||
assertEquals("no gaps between tokens", covered, lexer.tokenStart)
|
||||
covered = lexer.tokenEnd
|
||||
lexer.advance()
|
||||
check(guard++ < 10_000) { "lexer did not terminate" }
|
||||
}
|
||||
assertEquals("every character consumed", nasty.length, covered)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a real source file lexes with no bad characters`() {
|
||||
val src = """
|
||||
game Hello {
|
||||
component Pos { x: int = 0, y: int = 0 }
|
||||
archetype Mob { Pos }
|
||||
|
||||
system Move phase FixedUpdate
|
||||
query (p) [Pos, {Player}] where p.x > 0
|
||||
{
|
||||
p.x = p.x + 1
|
||||
for i in 0 .. 10 { print_int(i) }
|
||||
}
|
||||
|
||||
ui Menu {
|
||||
panel id=Root w=288 pad=16 {
|
||||
button id=Go text="Go" size=16
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val bad = lex(src).filter { it.first == LudicTokens.BAD }
|
||||
assertTrue("unexpected bad characters: ${bad.map { it.second }}", bad.isEmpty())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lang.LanguageParserDefinitions
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
|
||||
/**
|
||||
* The IDE-side wiring, exercised in a real (headless) platform fixture. These
|
||||
* cover the things that make a file feel like a supported language rather than
|
||||
* plain text: the extension is claimed, a PSI tree is built (which is what
|
||||
* indexing and Markdown injection both stand on), folds appear, braces pair up
|
||||
* and Ctrl+/ comments.
|
||||
*/
|
||||
class LudicPlatformTest : BasePlatformTestCase() {
|
||||
|
||||
private val sample = """
|
||||
game Hello {
|
||||
component Pos { x: int = 0, y: int = 0 }
|
||||
archetype Mob { Pos }
|
||||
|
||||
# move everything that has a position
|
||||
system Move phase FixedUpdate
|
||||
query (p) [Pos, {Player}]
|
||||
{
|
||||
p.x = p.x + 1
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun ludicFile(text: String = sample): PsiFile =
|
||||
myFixture.configureByText("sample.ludic", text)
|
||||
|
||||
fun testExtensionIsClaimedByTheLudicFileType() {
|
||||
val type = FileTypeManager.getInstance().getFileTypeByExtension("ludic")
|
||||
assertEquals("Ludic", type.name)
|
||||
assertEquals(LudicFileType, type)
|
||||
}
|
||||
|
||||
fun testFileTypeIsNotBinaryAndUsesUtf8() {
|
||||
assertFalse(LudicFileType.isBinary)
|
||||
assertEquals("ludic", LudicFileType.defaultExtension)
|
||||
}
|
||||
|
||||
fun testParserDefinitionIsRegisteredForTheLanguage() {
|
||||
// Markdown fence injection needs a PSI tree, so this registration is
|
||||
// what makes ```ludic blocks highlight in a .md file.
|
||||
val def = LanguageParserDefinitions.INSTANCE.forLanguage(LudicLanguage)
|
||||
assertNotNull("no ParserDefinition registered for Ludic", def)
|
||||
}
|
||||
|
||||
fun testPsiTreeIsBuiltAndCoversTheWholeFile() {
|
||||
val file = ludicFile()
|
||||
assertEquals(LudicLanguage, file.language)
|
||||
assertEquals(sample, file.text)
|
||||
assertTrue("expected a non-empty PSI tree", file.children.isNotEmpty())
|
||||
// every character must be reachable through the tree, or offsets drift
|
||||
assertEquals(sample.length, file.textLength)
|
||||
}
|
||||
|
||||
fun testHighlighterAssignsColoursToEveryMeaningfulToken() {
|
||||
val highlighter = LudicSyntaxHighlighter()
|
||||
val coloured = listOf(
|
||||
LudicTokens.DECL_KEYWORD, LudicTokens.KEYWORD, LudicTokens.CLAUSE,
|
||||
LudicTokens.PRIMITIVE, LudicTokens.PHASE, LudicTokens.BOOLEAN,
|
||||
LudicTokens.BUILTIN, LudicTokens.TYPE_NAME, LudicTokens.STRING,
|
||||
LudicTokens.NUMBER, LudicTokens.COMMENT, LudicTokens.ANNOTATION,
|
||||
LudicTokens.OPERATOR, LudicTokens.BRACE, LudicTokens.BRACKET,
|
||||
LudicTokens.PAREN, LudicTokens.COMMA, LudicTokens.IDENTIFIER
|
||||
)
|
||||
for (token in coloured) {
|
||||
val keys = highlighter.getTokenHighlights(token)
|
||||
assertTrue("no colour mapped for $token", keys.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun testFoldingCollapsesMultiLineBlocksOnly() {
|
||||
val file = ludicFile()
|
||||
val regions = LudicFoldingBuilder().buildFoldRegions(file.node, file.viewProvider.document!!)
|
||||
|
||||
// The sample has exactly two blocks that span lines: the `game` body and
|
||||
// the `system` body. `component Pos { … }`, `archetype Mob { … }` and the
|
||||
// `{Player}` query filter all fit on one line, and folding a single line
|
||||
// gains nothing, so they are deliberately skipped.
|
||||
assertEquals("multi-line blocks only", 2, regions.size)
|
||||
|
||||
val outermost = regions.maxByOrNull { it.range.length }!!
|
||||
assertEquals("outermost fold starts at the game block's brace",
|
||||
sample.indexOf('{'), outermost.range.startOffset)
|
||||
assertEquals("outermost fold ends at the last brace",
|
||||
sample.length, outermost.range.endOffset)
|
||||
|
||||
for (r in regions) {
|
||||
assertTrue("fold region must be non-empty", r.range.length > 0)
|
||||
assertEquals("{...}", LudicFoldingBuilder().getPlaceholderText(file.node))
|
||||
}
|
||||
}
|
||||
|
||||
fun testCommenterUsesHashWithASpace() {
|
||||
// The trailing space is deliberate: Ctrl+/ should produce "# code",
|
||||
// which is how every comment in the runtime and examples is written.
|
||||
val commenter = LudicCommenter()
|
||||
assertEquals("# ", commenter.lineCommentPrefix)
|
||||
assertNull("Ludic has no block comments", commenter.blockCommentPrefix)
|
||||
}
|
||||
|
||||
fun testBraceMatcherPairsAllThreeBracketKinds() {
|
||||
val matcher = LudicBraceMatcher()
|
||||
val pairs = matcher.pairs
|
||||
val open = pairs.map { it.leftBraceType }.toSet()
|
||||
assertTrue(open.contains(LudicTokens.BRACE))
|
||||
assertTrue(open.contains(LudicTokens.BRACKET))
|
||||
assertTrue(open.contains(LudicTokens.PAREN))
|
||||
}
|
||||
|
||||
fun testHalfTypedFileStillProducesAPsiTree() {
|
||||
// The IDE must survive what a file looks like mid-keystroke; a parser
|
||||
// that throws here takes highlighting and indexing down with it.
|
||||
val broken = "game X {\n component P { x: int =\n system"
|
||||
val file = myFixture.configureByText("broken.ludic", broken)
|
||||
assertEquals(broken.length, file.textLength)
|
||||
assertEquals(LudicLanguage, file.language)
|
||||
}
|
||||
}
|
||||
86
tools/editors/neovim/lua/ludic.lua
Normal file
86
tools/editors/neovim/lua/ludic.lua
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
-- Ludic support for Neovim (0.9+), with no plugin manager and no dependencies.
|
||||
--
|
||||
-- :lua require('ludic').setup()
|
||||
-- -- or, from init.lua:
|
||||
-- vim.opt.runtimepath:append('/path/to/gpp/tools/editors/neovim')
|
||||
-- require('ludic').setup({ server = '/path/to/gpp/build/ludic-lsp' })
|
||||
--
|
||||
-- Highlighting here comes from the language server's semantic tokens, which
|
||||
-- Neovim applies natively -- there is no tree-sitter grammar to install and no
|
||||
-- second copy of the vocabulary to keep in sync.
|
||||
|
||||
local M = {}
|
||||
|
||||
local function find_executable(names, roots, subdirs)
|
||||
for _, root in ipairs(roots) do
|
||||
for _, sub in ipairs(subdirs) do
|
||||
for _, name in ipairs(names) do
|
||||
local p = root .. '/' .. sub .. '/' .. name
|
||||
if vim.fn.executable(p) == 1 then return p end
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, name in ipairs(names) do
|
||||
if vim.fn.executable(name) == 1 then return name end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function M.setup(opts)
|
||||
opts = opts or {}
|
||||
|
||||
-- .ludic files are Ludic
|
||||
vim.filetype.add({ extension = { ludic = 'ludic' } })
|
||||
|
||||
vim.api.nvim_create_autocmd('FileType', {
|
||||
pattern = 'ludic',
|
||||
callback = function()
|
||||
vim.bo.commentstring = '# %s'
|
||||
vim.bo.comments = ':#'
|
||||
vim.bo.expandtab = true
|
||||
vim.bo.shiftwidth = opts.indent or 2
|
||||
vim.bo.tabstop = opts.indent or 2
|
||||
end,
|
||||
})
|
||||
|
||||
local root_markers = { 'build.sh', 'LANGUAGE.md', '.git' }
|
||||
|
||||
local function root_dir(fname)
|
||||
local found = vim.fs.find(root_markers, { path = vim.fs.dirname(fname), upward = true })[1]
|
||||
return found and vim.fs.dirname(found) or vim.fs.dirname(fname)
|
||||
end
|
||||
|
||||
vim.api.nvim_create_autocmd('FileType', {
|
||||
-- markdown is included on purpose: ```ludic fences get diagnostics and
|
||||
-- semantic highlighting like any source file
|
||||
pattern = { 'ludic', 'markdown' },
|
||||
callback = function(args)
|
||||
local root = root_dir(vim.api.nvim_buf_get_name(args.buf))
|
||||
local server = opts.server or find_executable(
|
||||
{ 'ludic-lsp' }, { root }, { 'build', 'tools/build', 'bin', '.' })
|
||||
if not server then return end
|
||||
local compiler = opts.compiler or find_executable(
|
||||
{ 'ludicc' }, { root }, { 'build', 'bin', '.' }) or ''
|
||||
|
||||
vim.lsp.start({
|
||||
name = 'ludic-lsp',
|
||||
cmd = { server, '--stdio' },
|
||||
root_dir = root,
|
||||
init_options = {
|
||||
compilerPath = compiler,
|
||||
compilerDiagnostics = opts.compiler_diagnostics ~= false,
|
||||
indentSize = opts.indent or 2,
|
||||
},
|
||||
}, { bufnr = args.buf })
|
||||
end,
|
||||
})
|
||||
|
||||
if opts.format_on_save ~= false then
|
||||
vim.api.nvim_create_autocmd('BufWritePre', {
|
||||
pattern = { '*.ludic' },
|
||||
callback = function() vim.lsp.buf.format({ async = false, timeout_ms = 2000 }) end,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
31
tools/editors/shared/language-configuration.json
Normal file
31
tools/editors/shared/language-configuration.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"comment": "Editor mechanics that are not highlighting and not the language server: comments, bracket pairs, auto-closing, indentation triggers. VS Code reads this file directly; the JetBrains plugin implements the same rules natively; other editors can transcribe it.",
|
||||
"comments": {
|
||||
"lineComment": "#"
|
||||
},
|
||||
"brackets": [["{", "}"], ["[", "]"], ["(", ")"]],
|
||||
"autoClosingPairs": [
|
||||
{ "open": "{", "close": "}" },
|
||||
{ "open": "[", "close": "]" },
|
||||
{ "open": "(", "close": ")" },
|
||||
{ "open": "\"", "close": "\"", "notIn": ["string", "comment"] }
|
||||
],
|
||||
"surroundingPairs": [["{", "}"], ["[", "]"], ["(", ")"], ["\"", "\""]],
|
||||
"indentationRules": {
|
||||
"increaseIndentPattern": "^((?!//).)*(\\{[^}\"']*|\\([^)\"']*|\\[[^\\]\"']*)$",
|
||||
"decreaseIndentPattern": "^\\s*[}\\])].*$"
|
||||
},
|
||||
"onEnterRules": [
|
||||
{
|
||||
"beforeText": "^\\s*#.*$",
|
||||
"action": { "indent": "none", "appendText": "# " }
|
||||
}
|
||||
],
|
||||
"wordPattern": "[A-Za-z_][A-Za-z0-9_]*",
|
||||
"folding": {
|
||||
"markers": {
|
||||
"start": "^\\s*#\\s*region\\b",
|
||||
"end": "^\\s*#\\s*endregion\\b"
|
||||
}
|
||||
}
|
||||
}
|
||||
27
tools/editors/shared/ludic.markdown-injection.json
Normal file
27
tools/editors/shared/ludic.markdown-injection.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"scopeName": "markdown.ludic.codeblock",
|
||||
"comment": "Injects Ludic highlighting into ```ludic fences in Markdown. Any TextMate-based editor (VS Code, Sublime, JetBrains' TextMate bundles, Zed) picks this up; JetBrains' native Markdown support gets the same thing for free from the registered `Ludic` language ID, and ludic-lsp will also serve semantic tokens and diagnostics for the fence bodies in a .md file.",
|
||||
"injectionSelector": "L:text.html.markdown",
|
||||
"patterns": [{ "include": "#ludic-code-block" }],
|
||||
"repository": {
|
||||
"ludic-code-block": {
|
||||
"begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(ludic)((\\s+|:|,|\\{|\\?)[^`~]*)?)$",
|
||||
"name": "markup.fenced_code.block.markdown",
|
||||
"end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$",
|
||||
"beginCaptures": {
|
||||
"3": { "name": "punctuation.definition.markdown" },
|
||||
"4": { "name": "fenced_code.block.language.markdown" },
|
||||
"5": { "name": "fenced_code.block.language.attributes.markdown" }
|
||||
},
|
||||
"endCaptures": { "3": { "name": "punctuation.definition.markdown" } },
|
||||
"patterns": [
|
||||
{
|
||||
"begin": "(^|\\G)(\\s*)(.*)",
|
||||
"while": "(^|\\G)(?!\\s*([`~]{3,})\\s*$)",
|
||||
"contentName": "meta.embedded.block.ludic",
|
||||
"patterns": [{ "include": "source.ludic" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
241
tools/editors/shared/ludic.tmLanguage.json
Normal file
241
tools/editors/shared/ludic.tmLanguage.json
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
|
||||
"name": "Ludic",
|
||||
"scopeName": "source.ludic",
|
||||
"fileTypes": ["ludic"],
|
||||
"comment": "Static grammar for Ludic. It is the fallback: when ludic-lsp is running, its semantic tokens refine everything here with real name resolution. Generated shapes are kept in sync with tools/ludic-tools/ludic_syntax.h.",
|
||||
"patterns": [
|
||||
{ "include": "#comment" },
|
||||
{ "include": "#declaration" },
|
||||
{ "include": "#query" },
|
||||
{ "include": "#string" },
|
||||
{ "include": "#number" },
|
||||
{ "include": "#annotation" },
|
||||
{ "include": "#keyword" },
|
||||
{ "include": "#widget" },
|
||||
{ "include": "#builtin" },
|
||||
{ "include": "#member" },
|
||||
{ "include": "#call" },
|
||||
{ "include": "#typename" },
|
||||
{ "include": "#operator" }
|
||||
],
|
||||
"repository": {
|
||||
"comment": {
|
||||
"match": "#.*$",
|
||||
"name": "comment.line.number-sign.ludic"
|
||||
},
|
||||
"string": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "string.quoted.double.ludic",
|
||||
"begin": "\"",
|
||||
"end": "\"",
|
||||
"beginCaptures": { "0": { "name": "punctuation.definition.string.begin.ludic" } },
|
||||
"endCaptures": { "0": { "name": "punctuation.definition.string.end.ludic" } },
|
||||
"patterns": [{ "name": "constant.character.escape.ludic", "match": "\\\\." }]
|
||||
},
|
||||
{
|
||||
"name": "string.quoted.single.ludic",
|
||||
"match": "'(\\\\.|[^'\\\\])'"
|
||||
}
|
||||
]
|
||||
},
|
||||
"number": {
|
||||
"patterns": [
|
||||
{ "name": "constant.numeric.hex.ludic", "match": "\\b0[xX][0-9a-fA-F]+\\b" },
|
||||
{ "name": "constant.numeric.fixed.ludic", "match": "\\b[0-9]+\\.[0-9]+\\b" },
|
||||
{ "name": "constant.numeric.integer.ludic", "match": "\\b[0-9]+\\b" }
|
||||
]
|
||||
},
|
||||
"annotation": {
|
||||
"name": "entity.name.function.decorator.ludic",
|
||||
"match": "@[A-Za-z_][A-Za-z0-9_]*"
|
||||
},
|
||||
"declaration": {
|
||||
"patterns": [
|
||||
{
|
||||
"match": "\\b(game|module)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.control.unit.ludic" },
|
||||
"2": { "name": "entity.name.type.unit.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(component)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "storage.type.component.ludic" },
|
||||
"2": { "name": "entity.name.type.component.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(archetype)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "storage.type.archetype.ludic" },
|
||||
"2": { "name": "entity.name.type.archetype.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(scene)\\s+([A-Za-z_][A-Za-z0-9_]*)(\\s+start)?",
|
||||
"captures": {
|
||||
"1": { "name": "storage.type.scene.ludic" },
|
||||
"2": { "name": "entity.name.type.scene.ludic" },
|
||||
"3": { "name": "keyword.other.start.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(layer|ui)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "storage.type.ludic" },
|
||||
"2": { "name": "entity.name.type.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(edge\\s+)?(system)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "storage.modifier.edge.ludic" },
|
||||
"2": { "name": "storage.type.system.ludic" },
|
||||
"3": { "name": "entity.name.function.system.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(export\\s+|pure\\s+)?(extern\\s+)?(fn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "storage.modifier.ludic" },
|
||||
"2": { "name": "storage.modifier.extern.ludic" },
|
||||
"3": { "name": "storage.type.function.ludic" },
|
||||
"4": { "name": "entity.name.function.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(const|var|let)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "storage.type.ludic" },
|
||||
"2": { "name": "variable.other.definition.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(state)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.control.state.ludic" },
|
||||
"2": { "name": "entity.name.constant.state.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(import)\\s+(?=\")",
|
||||
"captures": { "1": { "name": "keyword.control.import.ludic" } }
|
||||
},
|
||||
{
|
||||
"match": "\\b(phase)\\s+(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.other.clause.ludic" },
|
||||
"2": { "name": "constant.language.phase.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(enter)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.control.enter.ludic" },
|
||||
"2": { "name": "entity.name.type.scene.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(spawn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.control.spawn.ludic" },
|
||||
"2": { "name": "entity.name.type.archetype.ludic" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"query": {
|
||||
"comment": "Inside a query's brackets a bare word names a component and {Word} is a filter that does not bind.",
|
||||
"begin": "\\b(query)\\s*(\\[)",
|
||||
"beginCaptures": {
|
||||
"1": { "name": "keyword.other.query.ludic" },
|
||||
"2": { "name": "punctuation.section.brackets.begin.ludic" }
|
||||
},
|
||||
"end": "(\\])",
|
||||
"endCaptures": { "1": { "name": "punctuation.section.brackets.end.ludic" } },
|
||||
"patterns": [
|
||||
{
|
||||
"match": "(\\{)\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*(\\})",
|
||||
"captures": {
|
||||
"1": { "name": "punctuation.section.braces.begin.ludic" },
|
||||
"2": { "name": "entity.name.type.tag.ludic" },
|
||||
"3": { "name": "punctuation.section.braces.end.ludic" }
|
||||
}
|
||||
},
|
||||
{ "name": "entity.name.type.component.ludic", "match": "\\b[A-Za-z_][A-Za-z0-9_]*\\b" },
|
||||
{ "name": "punctuation.separator.ludic", "match": "," }
|
||||
]
|
||||
},
|
||||
"keyword": {
|
||||
"patterns": [
|
||||
{ "name": "keyword.control.ludic", "match": "\\b(if|else|when|while|for|in|match|machine|become|enter|return|spawn|despawn|where|break|continue|new)\\b" },
|
||||
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
|
||||
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes|needs|uses|requires|ensures|invariant|effects|on)\\b" },
|
||||
{ "name": "keyword.other.ludic", "match": "\\b(import|export|pure|extern|start|edge)\\b" },
|
||||
{ "name": "storage.type.ludic", "match": "\\b(game|module|component|archetype|ui|scene|layer|const|var|let|fn|system|state)\\b" },
|
||||
{ "name": "support.type.primitive.ludic", "match": "\\b(int|fixed|bool|entity|str|ptr|void)\\b" },
|
||||
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false)\\b" },
|
||||
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }
|
||||
]
|
||||
},
|
||||
"widget": {
|
||||
"patterns": [
|
||||
{
|
||||
"comment": "widget types, recognised by the props or the child block that follow them",
|
||||
"name": "support.class.widget.ludic",
|
||||
"match": "\\b(panel|col|row|label|button|image|spacer)\\b(?=\\s*(\\{|[a-z][A-Za-z0-9_]*\\s*=|$))"
|
||||
},
|
||||
{
|
||||
"comment": "widget props are written tight (id=Root); an assignment is written x = 1",
|
||||
"match": "\\b([a-z][A-Za-z0-9_]*)(=)(?!=)",
|
||||
"captures": {
|
||||
"1": { "name": "variable.parameter.widget.ludic" },
|
||||
"2": { "name": "keyword.operator.assignment.ludic" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"builtin": {
|
||||
"patterns": [
|
||||
{
|
||||
"comment": "the runtime surface — every name here resolves to rt_<name> in runtime/native",
|
||||
"name": "support.function.builtin.ludic",
|
||||
"match": "\\b(min|max|abs|clamp|seed|rng_range|rng_chance|fx|flr|map_size|map_row|tile|clear|present|fill_rect|frame_rect|put_px|text|text_int|font_load|text_ttf|text_w|text_h|image_load|draw_image|draw_image_scaled|draw_9slice|load_png|load_sprites|draw_sprite|draw_sprite_scaled|ui_build|ui_open|ui_tick|ui_render|ui_clicked|ui_set_text|ui_set_int|ui_focus|ui_focused|ui_visible|key|reg|setreg|self|save|load|status|print_int|quit)\\b(?=\\s*\\()"
|
||||
},
|
||||
{
|
||||
"comment": "compiler intrinsics — these lower straight to libc or the OS",
|
||||
"name": "support.function.intrinsic.ludic",
|
||||
"match": "\\b(mem_alloc|mem_realloc|mem_free|mem_copy|mem_set|peek8|poke8|peek32|poke32|peekp|pokep|peekf|pokef|ptr_add|ptr_null|ptr_is_null|as_fixed|as_int|file_open|file_read|file_write|file_seek|file_tell|file_close|read_byte|write_byte|print_str|str_len|shl|shr|band|bor|bxor|bnot|os_exit|os_time|os_argc|os_arg|file_stderr|is_windowed|game_title|win_open|win_poll|win_present|win_running|win_close)\\b(?=\\s*\\()"
|
||||
}
|
||||
]
|
||||
},
|
||||
"member": {
|
||||
"match": "(\\.)([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "punctuation.accessor.ludic" },
|
||||
"2": { "name": "variable.other.property.ludic" }
|
||||
}
|
||||
},
|
||||
"call": {
|
||||
"match": "\\b([A-Za-z_][A-Za-z0-9_]*)\\s*(?=\\()",
|
||||
"name": "entity.name.function.call.ludic"
|
||||
},
|
||||
"typename": {
|
||||
"comment": "components, archetypes, scenes and UI handles are Capitalised by convention",
|
||||
"name": "entity.name.type.ludic",
|
||||
"match": "\\b[A-Z][A-Za-z0-9_]*\\b"
|
||||
},
|
||||
"operator": {
|
||||
"patterns": [
|
||||
{ "name": "keyword.operator.arrow.ludic", "match": "->|=>" },
|
||||
{ "name": "keyword.operator.range.ludic", "match": "\\.\\." },
|
||||
{ "name": "keyword.operator.comparison.ludic", "match": "==|!=|<=|>=|<|>" },
|
||||
{ "name": "keyword.operator.assignment.ludic", "match": "\\+=|-=|\\*=|/=|=" },
|
||||
{ "name": "keyword.operator.arithmetic.ludic", "match": "[+\\-*/%]" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
35
tools/editors/sublime/README.md
Normal file
35
tools/editors/sublime/README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Ludic for Sublime Text
|
||||
|
||||
Sublime reads TextMate grammars directly, so the shared grammar works as-is.
|
||||
|
||||
1. **Highlighting.** Copy `tools/editors/shared/ludic.tmLanguage.json` into your
|
||||
`Packages/User/` directory, renamed to `Ludic.tmLanguage.json`. Sublime picks
|
||||
up `.ludic` from the grammar's `fileTypes`.
|
||||
|
||||
2. **Markdown fences.** Copy
|
||||
`tools/editors/shared/ludic.markdown-injection.json` alongside it as
|
||||
`Ludic Markdown.tmLanguage.json`. Sublime honours `injectionSelector`, so
|
||||
` ```ludic ` blocks in Markdown are highlighted with no further setup.
|
||||
|
||||
3. **Everything else** comes from the language server. Install
|
||||
[LSP](https://packagecontrol.io/packages/LSP), then add to
|
||||
`Preferences -> Package Settings -> LSP -> Settings`:
|
||||
|
||||
```json
|
||||
{
|
||||
"clients": {
|
||||
"ludic": {
|
||||
"enabled": true,
|
||||
"command": ["/path/to/gpp/build/ludic-lsp", "--stdio"],
|
||||
"selector": "source.ludic | text.html.markdown",
|
||||
"initializationOptions": {
|
||||
"compilerPath": "/path/to/gpp/build/ludicc",
|
||||
"compilerDiagnostics": true,
|
||||
"indentSize": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Format on save with `LSP-format-on-save`, or bind `lsp_format_document`.
|
||||
12
tools/editors/vscode/.vscodeignore
Normal file
12
tools/editors/vscode/.vscodeignore
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
.vscode/**
|
||||
node_modules/**
|
||||
!node_modules/vscode-languageclient/**
|
||||
!node_modules/vscode-jsonrpc/**
|
||||
!node_modules/vscode-languageserver-protocol/**
|
||||
!node_modules/vscode-languageserver-types/**
|
||||
!node_modules/semver/**
|
||||
!node_modules/balanced-match/**
|
||||
!node_modules/minimatch/**
|
||||
!node_modules/brace-expansion/**
|
||||
!node_modules/concat-map/**
|
||||
**/*.map
|
||||
54
tools/editors/vscode/README.md
Normal file
54
tools/editors/vscode/README.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Ludic for VS Code
|
||||
|
||||
Syntax highlighting, formatting, and full language-server support for **Ludic**,
|
||||
the AI-first, ECS-native game language. See `LANGUAGE.md` in the Ludic repository
|
||||
for the language reference.
|
||||
|
||||
## What you get
|
||||
|
||||
| | Source |
|
||||
|---|---|
|
||||
| Syntax highlighting | TextMate grammar, works with no build |
|
||||
| Semantic highlighting | `ludic-lsp` — real name resolution, so a component reads differently from a local |
|
||||
| Diagnostics | structural errors as you type; `ludicc`'s own errors on save |
|
||||
| Completion | context-aware: fields after `.`, components inside `query [...]`, phases after `phase`, widgets inside `ui` |
|
||||
| Hover | signatures and docs for builtins, intrinsics, and everything you declared |
|
||||
| Go to definition / references / rename | across every file in the compilation unit, following `import` |
|
||||
| Formatting | format-on-save, comment-preserving |
|
||||
| Outline, folding, inlay hints, signature help | |
|
||||
| ` ```ludic ` fences in Markdown | highlighted, checked, and formatted |
|
||||
|
||||
## Setup
|
||||
|
||||
Build the toolchain once, from the repository root:
|
||||
|
||||
```bash
|
||||
./tools/build-tools.sh
|
||||
```
|
||||
|
||||
That produces `build/ludic-lsp` and `build/ludic-fmt`, which the extension finds
|
||||
on its own. If you keep them elsewhere, set `ludic.server.path`.
|
||||
|
||||
Format on save:
|
||||
|
||||
```json
|
||||
"[ludic]": { "editor.formatOnSave": true }
|
||||
```
|
||||
|
||||
## Settings
|
||||
|
||||
- `ludic.server.path` — where `ludic-lsp` lives. Empty = search `build/`, then `PATH`.
|
||||
- `ludic.server.enabled` — turn the server off and keep only static highlighting.
|
||||
- `ludic.compilerPath` — where `ludicc` lives, for on-save diagnostics.
|
||||
- `ludic.compilerDiagnostics` — whether to run the compiler at all.
|
||||
- `ludic.indentSize` — spaces per level for the formatter.
|
||||
- `ludic.trace.server` — log LSP traffic (`Ludic: Show Language Server Log`).
|
||||
|
||||
## Installing without publishing
|
||||
|
||||
```bash
|
||||
cd tools/editors/vscode
|
||||
npm install
|
||||
npx @vscode/vsce package
|
||||
code --install-extension ludic-1.0.0.vsix
|
||||
```
|
||||
7
tools/editors/vscode/icons/ludic.svg
Normal file
7
tools/editors/vscode/icons/ludic.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2c"/>
|
||||
<rect x="7" y="7" width="8" height="8" rx="1.5" fill="#ffe060"/>
|
||||
<rect x="17" y="7" width="8" height="8" rx="1.5" fill="#4a4a72"/>
|
||||
<rect x="7" y="17" width="8" height="8" rx="1.5" fill="#4a4a72"/>
|
||||
<rect x="17" y="17" width="8" height="8" rx="1.5" fill="#9aa0c8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 418 B |
31
tools/editors/vscode/language-configuration.json
Normal file
31
tools/editors/vscode/language-configuration.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"comment": "Editor mechanics that are not highlighting and not the language server: comments, bracket pairs, auto-closing, indentation triggers. VS Code reads this file directly; the JetBrains plugin implements the same rules natively; other editors can transcribe it.",
|
||||
"comments": {
|
||||
"lineComment": "#"
|
||||
},
|
||||
"brackets": [["{", "}"], ["[", "]"], ["(", ")"]],
|
||||
"autoClosingPairs": [
|
||||
{ "open": "{", "close": "}" },
|
||||
{ "open": "[", "close": "]" },
|
||||
{ "open": "(", "close": ")" },
|
||||
{ "open": "\"", "close": "\"", "notIn": ["string", "comment"] }
|
||||
],
|
||||
"surroundingPairs": [["{", "}"], ["[", "]"], ["(", ")"], ["\"", "\""]],
|
||||
"indentationRules": {
|
||||
"increaseIndentPattern": "^((?!//).)*(\\{[^}\"']*|\\([^)\"']*|\\[[^\\]\"']*)$",
|
||||
"decreaseIndentPattern": "^\\s*[}\\])].*$"
|
||||
},
|
||||
"onEnterRules": [
|
||||
{
|
||||
"beforeText": "^\\s*#.*$",
|
||||
"action": { "indent": "none", "appendText": "# " }
|
||||
}
|
||||
],
|
||||
"wordPattern": "[A-Za-z_][A-Za-z0-9_]*",
|
||||
"folding": {
|
||||
"markers": {
|
||||
"start": "^\\s*#\\s*region\\b",
|
||||
"end": "^\\s*#\\s*endregion\\b"
|
||||
}
|
||||
}
|
||||
}
|
||||
97
tools/editors/vscode/package-lock.json
generated
Normal file
97
tools/editors/vscode/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
{
|
||||
"name": "ludic",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ludic",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-languageclient": "^9.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.75.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageclient": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz",
|
||||
"integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimatch": "^5.1.0",
|
||||
"semver": "^7.3.7",
|
||||
"vscode-languageserver-protocol": "3.17.5"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.82.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-protocol": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"vscode-jsonrpc": "8.2.0",
|
||||
"vscode-languageserver-types": "3.17.5"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-types": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
158
tools/editors/vscode/package.json
Normal file
158
tools/editors/vscode/package.json
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
{
|
||||
"name": "ludic",
|
||||
"displayName": "Ludic",
|
||||
"description": "Ludic language support: syntax highlighting, formatting, and the ludic-lsp language server. Also highlights ```ludic fences in Markdown.",
|
||||
"version": "1.0.0",
|
||||
"publisher": "ludic",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"vscode": "^1.75.0"
|
||||
},
|
||||
"categories": [
|
||||
"Programming Languages",
|
||||
"Formatters",
|
||||
"Linters"
|
||||
],
|
||||
"keywords": [
|
||||
"ludic",
|
||||
"ecs",
|
||||
"gamedev"
|
||||
],
|
||||
"main": "./src/extension.js",
|
||||
"activationEvents": [
|
||||
"onLanguage:ludic",
|
||||
"onLanguage:markdown",
|
||||
"workspaceContains:**/*.ludic"
|
||||
],
|
||||
"contributes": {
|
||||
"languages": [
|
||||
{
|
||||
"id": "ludic",
|
||||
"aliases": [
|
||||
"Ludic",
|
||||
"ludic"
|
||||
],
|
||||
"extensions": [
|
||||
".ludic"
|
||||
],
|
||||
"configuration": "./language-configuration.json",
|
||||
"icon": {
|
||||
"light": "./icons/ludic.svg",
|
||||
"dark": "./icons/ludic.svg"
|
||||
}
|
||||
}
|
||||
],
|
||||
"grammars": [
|
||||
{
|
||||
"language": "ludic",
|
||||
"scopeName": "source.ludic",
|
||||
"path": "./syntaxes/ludic.tmLanguage.json"
|
||||
},
|
||||
{
|
||||
"scopeName": "markdown.ludic.codeblock",
|
||||
"path": "./syntaxes/ludic.markdown-injection.json",
|
||||
"injectTo": [
|
||||
"text.html.markdown"
|
||||
],
|
||||
"embeddedLanguages": {
|
||||
"meta.embedded.block.ludic": "ludic"
|
||||
}
|
||||
}
|
||||
],
|
||||
"semanticTokenScopes": [
|
||||
{
|
||||
"language": "ludic",
|
||||
"scopes": {
|
||||
"struct": [
|
||||
"entity.name.type.component.ludic"
|
||||
],
|
||||
"class": [
|
||||
"entity.name.type.archetype.ludic"
|
||||
],
|
||||
"namespace": [
|
||||
"entity.name.type.scene.ludic"
|
||||
],
|
||||
"function.defaultLibrary": [
|
||||
"support.function.builtin.ludic"
|
||||
],
|
||||
"enumMember.readonly.defaultLibrary": [
|
||||
"constant.language.phase.ludic"
|
||||
],
|
||||
"decorator": [
|
||||
"entity.name.function.decorator.ludic"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "Ludic",
|
||||
"properties": {
|
||||
"ludic.server.path": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "Path to the `ludic-lsp` binary. Empty means: look in `build/ludic-lsp` under each workspace folder, then on `PATH`."
|
||||
},
|
||||
"ludic.server.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Run the Ludic language server. Turn this off to keep only the static syntax highlighting."
|
||||
},
|
||||
"ludic.compilerPath": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "Path to `ludicc`. The server shells out to it for authoritative diagnostics on save. Empty means `build/ludicc` under the workspace."
|
||||
},
|
||||
"ludic.compilerDiagnostics": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Report errors from the Ludic compiler on save, in addition to the server's own structural checks."
|
||||
},
|
||||
"ludic.indentSize": {
|
||||
"type": "number",
|
||||
"default": 2,
|
||||
"description": "Spaces per indent level used by the formatter when the editor does not say otherwise."
|
||||
},
|
||||
"ludic.trace.server": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"off",
|
||||
"messages",
|
||||
"verbose"
|
||||
],
|
||||
"default": "off",
|
||||
"description": "Log the traffic between VS Code and the Ludic language server."
|
||||
}
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"command": "ludic.restartServer",
|
||||
"title": "Ludic: Restart Language Server"
|
||||
},
|
||||
{
|
||||
"command": "ludic.showOutput",
|
||||
"title": "Ludic: Show Language Server Log"
|
||||
}
|
||||
],
|
||||
"configurationDefaults": {
|
||||
"[ludic]": {
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.semanticHighlighting.enabled": true
|
||||
}
|
||||
},
|
||||
"snippets": [
|
||||
{
|
||||
"language": "ludic",
|
||||
"path": "./snippets/ludic.json"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"vscode-languageclient": "^9.0.1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://example.invalid/ludic"
|
||||
}
|
||||
}
|
||||
96
tools/editors/vscode/snippets/ludic.json
Normal file
96
tools/editors/vscode/snippets/ludic.json
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"game": {
|
||||
"prefix": "game",
|
||||
"body": ["game ${1:Name} {", "\t$0", "}"],
|
||||
"description": "A game unit — compiles to an executable."
|
||||
},
|
||||
"module": {
|
||||
"prefix": "module",
|
||||
"body": ["module ${1:Name} {", "\t$0", "}"],
|
||||
"description": "A library unit — compiles to a shared library, no frame loop."
|
||||
},
|
||||
"component": {
|
||||
"prefix": "component",
|
||||
"body": ["component ${1:Name} { ${2:x}: ${3:int} = ${4:0} }"],
|
||||
"description": "A component: typed per-entity fields."
|
||||
},
|
||||
"archetype": {
|
||||
"prefix": "archetype",
|
||||
"body": ["archetype ${1:Name} {", "\t${2:Pos}", "}"],
|
||||
"description": "An entity kind bundling a fixed set of components."
|
||||
},
|
||||
"system": {
|
||||
"prefix": "system",
|
||||
"body": [
|
||||
"system ${1:Name} phase ${2|Start,Input,FixedUpdate,Update,LateUpdate,Render|} {",
|
||||
"\t$0",
|
||||
"}"
|
||||
],
|
||||
"description": "A system that runs once per tick in its phase."
|
||||
},
|
||||
"system with query": {
|
||||
"prefix": "systemq",
|
||||
"body": [
|
||||
"system ${1:Name} phase ${2|Update,FixedUpdate,LateUpdate,Render,Input|}",
|
||||
" query (${3:p}) [${4:Pos}]",
|
||||
"{",
|
||||
"\t$0",
|
||||
"}"
|
||||
],
|
||||
"description": "A system whose body runs once per matching entity."
|
||||
},
|
||||
"for query": {
|
||||
"prefix": "forq",
|
||||
"body": ["for (${1:p}) in query [${2:Pos}] {", "\t$0", "}"],
|
||||
"description": "Iterate every entity matching a query."
|
||||
},
|
||||
"spawn": {
|
||||
"prefix": "spawn",
|
||||
"body": ["spawn ${1:Kind} {", "\t${2:Pos} = { ${3:x} = ${4:0} }", "}"],
|
||||
"description": "Create an entity of an archetype."
|
||||
},
|
||||
"scene": {
|
||||
"prefix": "scene",
|
||||
"body": [
|
||||
"scene ${1:Name} {",
|
||||
"\ton enter { $2 }",
|
||||
"\tlayer ${3:Main} {",
|
||||
"\t\t$0",
|
||||
"\t}",
|
||||
"}"
|
||||
],
|
||||
"description": "A scene with lifecycle hooks and one layer of systems."
|
||||
},
|
||||
"ui": {
|
||||
"prefix": "ui",
|
||||
"body": [
|
||||
"ui ${1:Name} {",
|
||||
"\tpanel id=${2:Root} pad=16 gap=6 align=center {",
|
||||
"\t\t$0",
|
||||
"\t}",
|
||||
"}"
|
||||
],
|
||||
"description": "A retained UI tree."
|
||||
},
|
||||
"match": {
|
||||
"prefix": "match",
|
||||
"body": ["match ${1:x} {", "\t${2:0} => { $3 }", "\t_ => { $0 }", "}"],
|
||||
"description": "Pattern match."
|
||||
},
|
||||
"machine": {
|
||||
"prefix": "machine",
|
||||
"body": [
|
||||
"machine reg(${1:R_PHASE}) {",
|
||||
"\tstate ${2:Idle} = 0 {",
|
||||
"\t\t$0",
|
||||
"\t}",
|
||||
"}"
|
||||
],
|
||||
"description": "A state machine over a register."
|
||||
},
|
||||
"extern fn": {
|
||||
"prefix": "extern",
|
||||
"body": ["extern fn ${1:name}(${2:a}: ${3:int}) -> ${4:int} = \"${5:c_symbol}\""],
|
||||
"description": "Bind a C symbol through the FFI."
|
||||
}
|
||||
}
|
||||
149
tools/editors/vscode/src/extension.js
Normal file
149
tools/editors/vscode/src/extension.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/* ============================================================================
|
||||
* The VS Code half of Ludic support.
|
||||
*
|
||||
* There is deliberately very little here. Highlighting comes from the TextMate
|
||||
* grammar in syntaxes/ (shared, unmodified, with every other editor), and
|
||||
* everything else — completion, hover, diagnostics, formatting, symbols —
|
||||
* comes from `ludic-lsp`, the same binary JetBrains and Neovim talk to. This
|
||||
* file's whole job is finding that binary and keeping it alive.
|
||||
* ==========================================================================*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const vscode = require('vscode');
|
||||
const { LanguageClient, TransportKind } = require('vscode-languageclient/node');
|
||||
|
||||
let client = null;
|
||||
let output = null;
|
||||
|
||||
/** Where the language server might be, in the order we should look. */
|
||||
function resolveServer(folders) {
|
||||
const configured = vscode.workspace.getConfiguration('ludic').get('server.path');
|
||||
if (configured) return expand(configured);
|
||||
|
||||
const names = ['ludic-lsp', 'ludic-lsp.exe'];
|
||||
for (const folder of folders) {
|
||||
const root = folder.uri.fsPath;
|
||||
for (const rel of ['build', 'tools/build', 'bin', '.']) {
|
||||
for (const name of names) {
|
||||
const p = path.join(root, rel, name);
|
||||
if (isExecutable(p)) return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fall back to PATH; spawning by bare name lets the OS resolve it.
|
||||
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
|
||||
for (const name of names) {
|
||||
const p = path.join(dir, name);
|
||||
if (isExecutable(p)) return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveCompiler(folders) {
|
||||
const configured = vscode.workspace.getConfiguration('ludic').get('compilerPath');
|
||||
if (configured) return expand(configured);
|
||||
for (const folder of folders) {
|
||||
const p = path.join(folder.uri.fsPath, 'build', 'ludicc');
|
||||
if (isExecutable(p)) return p;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function expand(p) {
|
||||
return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
|
||||
}
|
||||
|
||||
function isExecutable(p) {
|
||||
try {
|
||||
fs.accessSync(p, fs.constants.X_OK);
|
||||
return fs.statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function start(context) {
|
||||
const cfg = vscode.workspace.getConfiguration('ludic');
|
||||
if (!cfg.get('server.enabled')) return;
|
||||
|
||||
const folders = vscode.workspace.workspaceFolders || [];
|
||||
const server = resolveServer(folders);
|
||||
if (!server) {
|
||||
// Not an error worth a modal: the grammar still highlights, and plenty of
|
||||
// people open a .ludic file without having built the toolchain.
|
||||
output.appendLine(
|
||||
'ludic-lsp not found. Build it with tools/build-tools.sh, or set "ludic.server.path". ' +
|
||||
'Syntax highlighting works without it; completion and diagnostics do not.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
output.appendLine(`starting ${server}`);
|
||||
|
||||
const serverOptions = {
|
||||
run: { command: server, args: ['--stdio'], transport: TransportKind.stdio },
|
||||
debug: { command: server, args: ['--stdio'], transport: TransportKind.stdio }
|
||||
};
|
||||
|
||||
const clientOptions = {
|
||||
// Markdown is included on purpose: ```ludic fences in a .md get the same
|
||||
// diagnostics, hover and semantic highlighting as a real source file.
|
||||
documentSelector: [
|
||||
{ scheme: 'file', language: 'ludic' },
|
||||
{ scheme: 'file', language: 'markdown' }
|
||||
],
|
||||
synchronize: {
|
||||
fileEvents: vscode.workspace.createFileSystemWatcher('**/*.ludic'),
|
||||
configurationSection: 'ludic'
|
||||
},
|
||||
outputChannel: output,
|
||||
initializationOptions: {
|
||||
compilerPath: resolveCompiler(folders),
|
||||
compilerDiagnostics: cfg.get('compilerDiagnostics'),
|
||||
indentSize: cfg.get('indentSize')
|
||||
}
|
||||
};
|
||||
|
||||
client = new LanguageClient('ludic', 'Ludic Language Server', serverOptions, clientOptions);
|
||||
await client.start();
|
||||
context.subscriptions.push(client);
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!client) return;
|
||||
const c = client;
|
||||
client = null;
|
||||
await c.stop();
|
||||
}
|
||||
|
||||
async function activate(context) {
|
||||
output = vscode.window.createOutputChannel('Ludic');
|
||||
context.subscriptions.push(output);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('ludic.restartServer', async () => {
|
||||
await stop();
|
||||
await start(context);
|
||||
vscode.window.setStatusBarMessage('Ludic: language server restarted', 3000);
|
||||
}),
|
||||
vscode.commands.registerCommand('ludic.showOutput', () => output.show())
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidChangeConfiguration(async (e) => {
|
||||
if (e.affectsConfiguration('ludic.server')) {
|
||||
await stop();
|
||||
await start(context);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await start(context);
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
return stop();
|
||||
}
|
||||
|
||||
module.exports = { activate, deactivate };
|
||||
27
tools/editors/vscode/syntaxes/ludic.markdown-injection.json
Normal file
27
tools/editors/vscode/syntaxes/ludic.markdown-injection.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"scopeName": "markdown.ludic.codeblock",
|
||||
"comment": "Injects Ludic highlighting into ```ludic fences in Markdown. Any TextMate-based editor (VS Code, Sublime, JetBrains' TextMate bundles, Zed) picks this up; JetBrains' native Markdown support gets the same thing for free from the registered `Ludic` language ID, and ludic-lsp will also serve semantic tokens and diagnostics for the fence bodies in a .md file.",
|
||||
"injectionSelector": "L:text.html.markdown",
|
||||
"patterns": [{ "include": "#ludic-code-block" }],
|
||||
"repository": {
|
||||
"ludic-code-block": {
|
||||
"begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(ludic)((\\s+|:|,|\\{|\\?)[^`~]*)?)$",
|
||||
"name": "markup.fenced_code.block.markdown",
|
||||
"end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$",
|
||||
"beginCaptures": {
|
||||
"3": { "name": "punctuation.definition.markdown" },
|
||||
"4": { "name": "fenced_code.block.language.markdown" },
|
||||
"5": { "name": "fenced_code.block.language.attributes.markdown" }
|
||||
},
|
||||
"endCaptures": { "3": { "name": "punctuation.definition.markdown" } },
|
||||
"patterns": [
|
||||
{
|
||||
"begin": "(^|\\G)(\\s*)(.*)",
|
||||
"while": "(^|\\G)(?!\\s*([`~]{3,})\\s*$)",
|
||||
"contentName": "meta.embedded.block.ludic",
|
||||
"patterns": [{ "include": "source.ludic" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
241
tools/editors/vscode/syntaxes/ludic.tmLanguage.json
Normal file
241
tools/editors/vscode/syntaxes/ludic.tmLanguage.json
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
{
|
||||
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
|
||||
"name": "Ludic",
|
||||
"scopeName": "source.ludic",
|
||||
"fileTypes": ["ludic"],
|
||||
"comment": "Static grammar for Ludic. It is the fallback: when ludic-lsp is running, its semantic tokens refine everything here with real name resolution. Generated shapes are kept in sync with tools/ludic-tools/ludic_syntax.h.",
|
||||
"patterns": [
|
||||
{ "include": "#comment" },
|
||||
{ "include": "#declaration" },
|
||||
{ "include": "#query" },
|
||||
{ "include": "#string" },
|
||||
{ "include": "#number" },
|
||||
{ "include": "#annotation" },
|
||||
{ "include": "#keyword" },
|
||||
{ "include": "#widget" },
|
||||
{ "include": "#builtin" },
|
||||
{ "include": "#member" },
|
||||
{ "include": "#call" },
|
||||
{ "include": "#typename" },
|
||||
{ "include": "#operator" }
|
||||
],
|
||||
"repository": {
|
||||
"comment": {
|
||||
"match": "#.*$",
|
||||
"name": "comment.line.number-sign.ludic"
|
||||
},
|
||||
"string": {
|
||||
"patterns": [
|
||||
{
|
||||
"name": "string.quoted.double.ludic",
|
||||
"begin": "\"",
|
||||
"end": "\"",
|
||||
"beginCaptures": { "0": { "name": "punctuation.definition.string.begin.ludic" } },
|
||||
"endCaptures": { "0": { "name": "punctuation.definition.string.end.ludic" } },
|
||||
"patterns": [{ "name": "constant.character.escape.ludic", "match": "\\\\." }]
|
||||
},
|
||||
{
|
||||
"name": "string.quoted.single.ludic",
|
||||
"match": "'(\\\\.|[^'\\\\])'"
|
||||
}
|
||||
]
|
||||
},
|
||||
"number": {
|
||||
"patterns": [
|
||||
{ "name": "constant.numeric.hex.ludic", "match": "\\b0[xX][0-9a-fA-F]+\\b" },
|
||||
{ "name": "constant.numeric.fixed.ludic", "match": "\\b[0-9]+\\.[0-9]+\\b" },
|
||||
{ "name": "constant.numeric.integer.ludic", "match": "\\b[0-9]+\\b" }
|
||||
]
|
||||
},
|
||||
"annotation": {
|
||||
"name": "entity.name.function.decorator.ludic",
|
||||
"match": "@[A-Za-z_][A-Za-z0-9_]*"
|
||||
},
|
||||
"declaration": {
|
||||
"patterns": [
|
||||
{
|
||||
"match": "\\b(game|module)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.control.unit.ludic" },
|
||||
"2": { "name": "entity.name.type.unit.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(component)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "storage.type.component.ludic" },
|
||||
"2": { "name": "entity.name.type.component.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(archetype)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "storage.type.archetype.ludic" },
|
||||
"2": { "name": "entity.name.type.archetype.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(scene)\\s+([A-Za-z_][A-Za-z0-9_]*)(\\s+start)?",
|
||||
"captures": {
|
||||
"1": { "name": "storage.type.scene.ludic" },
|
||||
"2": { "name": "entity.name.type.scene.ludic" },
|
||||
"3": { "name": "keyword.other.start.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(layer|ui)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "storage.type.ludic" },
|
||||
"2": { "name": "entity.name.type.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(edge\\s+)?(system)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "storage.modifier.edge.ludic" },
|
||||
"2": { "name": "storage.type.system.ludic" },
|
||||
"3": { "name": "entity.name.function.system.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(export\\s+|pure\\s+)?(extern\\s+)?(fn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "storage.modifier.ludic" },
|
||||
"2": { "name": "storage.modifier.extern.ludic" },
|
||||
"3": { "name": "storage.type.function.ludic" },
|
||||
"4": { "name": "entity.name.function.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(const|var|let)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "storage.type.ludic" },
|
||||
"2": { "name": "variable.other.definition.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(state)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.control.state.ludic" },
|
||||
"2": { "name": "entity.name.constant.state.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(import)\\s+(?=\")",
|
||||
"captures": { "1": { "name": "keyword.control.import.ludic" } }
|
||||
},
|
||||
{
|
||||
"match": "\\b(phase)\\s+(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.other.clause.ludic" },
|
||||
"2": { "name": "constant.language.phase.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(enter)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.control.enter.ludic" },
|
||||
"2": { "name": "entity.name.type.scene.ludic" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b(spawn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "keyword.control.spawn.ludic" },
|
||||
"2": { "name": "entity.name.type.archetype.ludic" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"query": {
|
||||
"comment": "Inside a query's brackets a bare word names a component and {Word} is a filter that does not bind.",
|
||||
"begin": "\\b(query)\\s*(\\[)",
|
||||
"beginCaptures": {
|
||||
"1": { "name": "keyword.other.query.ludic" },
|
||||
"2": { "name": "punctuation.section.brackets.begin.ludic" }
|
||||
},
|
||||
"end": "(\\])",
|
||||
"endCaptures": { "1": { "name": "punctuation.section.brackets.end.ludic" } },
|
||||
"patterns": [
|
||||
{
|
||||
"match": "(\\{)\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*(\\})",
|
||||
"captures": {
|
||||
"1": { "name": "punctuation.section.braces.begin.ludic" },
|
||||
"2": { "name": "entity.name.type.tag.ludic" },
|
||||
"3": { "name": "punctuation.section.braces.end.ludic" }
|
||||
}
|
||||
},
|
||||
{ "name": "entity.name.type.component.ludic", "match": "\\b[A-Za-z_][A-Za-z0-9_]*\\b" },
|
||||
{ "name": "punctuation.separator.ludic", "match": "," }
|
||||
]
|
||||
},
|
||||
"keyword": {
|
||||
"patterns": [
|
||||
{ "name": "keyword.control.ludic", "match": "\\b(if|else|when|while|for|in|match|machine|become|enter|return|spawn|despawn|where|break|continue|new)\\b" },
|
||||
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
|
||||
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes|needs|uses|requires|ensures|invariant|effects|on)\\b" },
|
||||
{ "name": "keyword.other.ludic", "match": "\\b(import|export|pure|extern|start|edge)\\b" },
|
||||
{ "name": "storage.type.ludic", "match": "\\b(game|module|component|archetype|ui|scene|layer|const|var|let|fn|system|state)\\b" },
|
||||
{ "name": "support.type.primitive.ludic", "match": "\\b(int|fixed|bool|entity|str|ptr|void)\\b" },
|
||||
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false)\\b" },
|
||||
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }
|
||||
]
|
||||
},
|
||||
"widget": {
|
||||
"patterns": [
|
||||
{
|
||||
"comment": "widget types, recognised by the props or the child block that follow them",
|
||||
"name": "support.class.widget.ludic",
|
||||
"match": "\\b(panel|col|row|label|button|image|spacer)\\b(?=\\s*(\\{|[a-z][A-Za-z0-9_]*\\s*=|$))"
|
||||
},
|
||||
{
|
||||
"comment": "widget props are written tight (id=Root); an assignment is written x = 1",
|
||||
"match": "\\b([a-z][A-Za-z0-9_]*)(=)(?!=)",
|
||||
"captures": {
|
||||
"1": { "name": "variable.parameter.widget.ludic" },
|
||||
"2": { "name": "keyword.operator.assignment.ludic" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"builtin": {
|
||||
"patterns": [
|
||||
{
|
||||
"comment": "the runtime surface — every name here resolves to rt_<name> in runtime/native",
|
||||
"name": "support.function.builtin.ludic",
|
||||
"match": "\\b(min|max|abs|clamp|seed|rng_range|rng_chance|fx|flr|map_size|map_row|tile|clear|present|fill_rect|frame_rect|put_px|text|text_int|font_load|text_ttf|text_w|text_h|image_load|draw_image|draw_image_scaled|draw_9slice|load_png|load_sprites|draw_sprite|draw_sprite_scaled|ui_build|ui_open|ui_tick|ui_render|ui_clicked|ui_set_text|ui_set_int|ui_focus|ui_focused|ui_visible|key|reg|setreg|self|save|load|status|print_int|quit)\\b(?=\\s*\\()"
|
||||
},
|
||||
{
|
||||
"comment": "compiler intrinsics — these lower straight to libc or the OS",
|
||||
"name": "support.function.intrinsic.ludic",
|
||||
"match": "\\b(mem_alloc|mem_realloc|mem_free|mem_copy|mem_set|peek8|poke8|peek32|poke32|peekp|pokep|peekf|pokef|ptr_add|ptr_null|ptr_is_null|as_fixed|as_int|file_open|file_read|file_write|file_seek|file_tell|file_close|read_byte|write_byte|print_str|str_len|shl|shr|band|bor|bxor|bnot|os_exit|os_time|os_argc|os_arg|file_stderr|is_windowed|game_title|win_open|win_poll|win_present|win_running|win_close)\\b(?=\\s*\\()"
|
||||
}
|
||||
]
|
||||
},
|
||||
"member": {
|
||||
"match": "(\\.)([A-Za-z_][A-Za-z0-9_]*)",
|
||||
"captures": {
|
||||
"1": { "name": "punctuation.accessor.ludic" },
|
||||
"2": { "name": "variable.other.property.ludic" }
|
||||
}
|
||||
},
|
||||
"call": {
|
||||
"match": "\\b([A-Za-z_][A-Za-z0-9_]*)\\s*(?=\\()",
|
||||
"name": "entity.name.function.call.ludic"
|
||||
},
|
||||
"typename": {
|
||||
"comment": "components, archetypes, scenes and UI handles are Capitalised by convention",
|
||||
"name": "entity.name.type.ludic",
|
||||
"match": "\\b[A-Z][A-Za-z0-9_]*\\b"
|
||||
},
|
||||
"operator": {
|
||||
"patterns": [
|
||||
{ "name": "keyword.operator.arrow.ludic", "match": "->|=>" },
|
||||
{ "name": "keyword.operator.range.ludic", "match": "\\.\\." },
|
||||
{ "name": "keyword.operator.comparison.ludic", "match": "==|!=|<=|>=|<|>" },
|
||||
{ "name": "keyword.operator.assignment.ludic", "match": "\\+=|-=|\\*=|/=|=" },
|
||||
{ "name": "keyword.operator.arithmetic.ludic", "match": "[+\\-*/%]" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
39
tools/editors/zed/README.md
Normal file
39
tools/editors/zed/README.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Ludic for Zed
|
||||
|
||||
Zed extensions want a Tree-sitter grammar for highlighting, which this toolchain
|
||||
deliberately does not ship — the vocabulary lives in one C header and is exposed
|
||||
through the language server's semantic tokens instead, so there is no second
|
||||
grammar to keep honest.
|
||||
|
||||
That is enough for Zed: point it at `ludic-lsp` and highlighting, completion,
|
||||
diagnostics and formatting all arrive over LSP.
|
||||
|
||||
Add to `~/.config/zed/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"ludic-lsp": {
|
||||
"binary": { "path": "/path/to/gpp/build/ludic-lsp", "arguments": ["--stdio"] },
|
||||
"initialization_options": {
|
||||
"compilerPath": "/path/to/gpp/build/ludicc",
|
||||
"compilerDiagnostics": true,
|
||||
"indentSize": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"file_types": { "Ludic": ["ludic"] },
|
||||
"languages": {
|
||||
"Ludic": {
|
||||
"language_servers": ["ludic-lsp"],
|
||||
"tab_size": 2,
|
||||
"hard_tabs": false,
|
||||
"format_on_save": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you would rather have static highlighting too, the TextMate grammar in
|
||||
`tools/editors/shared/ludic.tmLanguage.json` can be dropped into a Zed extension
|
||||
that declares a `grammars` entry — but it is not required.
|
||||
Loading…
Add table
Add a link
Reference in a new issue