feat(tooling): JetBrains IDE support, LSP navigation, barrel imports, package scripts and hooks
- JetBrains plugin 1.4.0: semantic colours (builtin / vendor / own), template strings, brace handling, run configurations and a test console, package.ludic and package.lock.ludic editing (completion, docs, app preview, colour previews, asset navigation), External Libraries for the runtime and packages, doc pages for built-ins - ludic-lsp: go to definition for imports, document links, hover with inferred types, type definition, signature help with parameters, docs from docs/language - `import "dir"` resolves a barrel `dir/index.ludic` - package.ludic `entry`, `script` and `hook before|after <command>`; `ludic <script>`, `ludic script`, `ludic scripts` - `ludic test --verbose` and `--test NAME`; the test runner filters by name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
da57b20156
commit
f92d7f89c6
75 changed files with 46802 additions and 40343 deletions
|
|
@ -45,7 +45,11 @@ program ChronoRift {
|
|||
```
|
||||
|
||||
`import "emberdepths/*.ludic"` imports every `.ludic` file of a directory, in name
|
||||
order — a game lists its modules once. An imported file is a **fragment**: bare
|
||||
order — a game lists its modules once. `import "camp"` names a directory through its
|
||||
**barrel**, `camp/index.ludic`: a fragment that lists the directory's own imports
|
||||
(relative to itself), in the order it wants them. A directory with no `index.ludic`
|
||||
is an error that says so. Packages resolve the same way: `import "ludic.render3d"`
|
||||
reads `ludic.render3d/index.ludic` from `ludic_modules/` or the toolchain. An imported file is a **fragment**: bare
|
||||
declarations, no `program` wrapper. Its
|
||||
declarations are spliced into the importing program. Imports may appear inside
|
||||
the `program` block or before it, they may nest (a fragment may import fragments),
|
||||
|
|
@ -1028,6 +1032,7 @@ ludic new mygame # a project that builds and plays as it
|
|||
ludic run # compile src/main.ludic and run it
|
||||
ludic build --headless # headless build (renders out.ppm; reads stdin)
|
||||
ludic test # compile and run the project's `test` blocks
|
||||
ludic test tests/math.ludic --test adds # just the test named "adds" (-v: every result line)
|
||||
|
||||
ludicc app.ludic -o build/app # the compiler directly: a native binary
|
||||
ludicc app.ludic --emit-llvm -o app.ll # stop at LLVM IR
|
||||
|
|
|
|||
5
changes/barrel-imports.md
Normal file
5
changes/barrel-imports.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
bump: minor
|
||||
type: feat
|
||||
`import "camp"` imports a directory through its barrel, `camp/index.ludic` (a fragment
|
||||
listing the directory's own imports). A directory without one is a clear error. Packages
|
||||
resolve the same way: `import "ludic.render3d"` reads `ludic.render3d/index.ludic`.
|
||||
23
changes/jetbrains-ide.md
Normal file
23
changes/jetbrains-ide.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
bump: minor
|
||||
type: feat
|
||||
The JetBrains plugin covers much more of the IDE:
|
||||
- Run configurations for `ludic run`/`build`/`test` (plus any other `ludic` command), with
|
||||
gutter run buttons on `program` and on every `test "name"` block.
|
||||
- Test results appear in the IDE's test tree, with navigation back to each test block.
|
||||
- `file.ludic:line` locations are clickable in every console.
|
||||
- Smart indentation on Enter and when typing a closing bracket; quotes pair up.
|
||||
- A Color Scheme page and a Code Style page.
|
||||
- Live templates, New Ludic File templates, and a Ludic project in File | New | Project.
|
||||
- A Tools | Ludic menu, and a warning when the toolchain is missing.
|
||||
- `package.ludic` is treated as a package manifest:
|
||||
- a banner offers Fetch, Update, Verify, Run, Test, Build and Bundle, and warns when the lock is
|
||||
missing or older than the manifest;
|
||||
- gutter actions on `package` and `require` lines;
|
||||
- completion and quick documentation for directives and `app` keys;
|
||||
- its own icon.
|
||||
- `script` and `hook` lines complete and are validated; each script has a run button, the
|
||||
package banner has script shortcuts, and Tools | Ludic | Run Script… lists them all.
|
||||
- The toolchain's and the project's packages are listed under External Libraries.
|
||||
- The Structure view, parameter info and code-block navigation now come from `ludic-lsp`.
|
||||
- The plugin is now 1.4.0 and requires IntelliJ Platform 2024.2 or newer and LSP4IJ 0.21.
|
||||
- Fixed: Enter no longer inserts an extra `}`, and Comment Line no longer writes two spaces.
|
||||
13
changes/lsp-navigation.md
Normal file
13
changes/lsp-navigation.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
bump: minor
|
||||
type: feat
|
||||
`ludic-lsp` navigates like the compiler resolves:
|
||||
- Package imports (`import "ludic.render3d/r3d.ludic"`) resolve from `ludic_modules/` and the
|
||||
toolchain's `packages/`, so their names hover, complete and jump. Import strings are links.
|
||||
- `var` locals are tracked (declaration, usages, rename).
|
||||
- Hover shows the declared or inferred type of locals, parameters and globals.
|
||||
- Hover and go-to-definition on built-ins, built-in types and namespaces show the reference
|
||||
page, with a link to the online docs. Release archives now ship `docs/language` for this.
|
||||
- Go to type declaration (`textDocument/typeDefinition`) is supported.
|
||||
- Signature help carries per-parameter ranges and follows named arguments.
|
||||
- Completion inside a call offers the parameter names not given yet, `Namespace.` offers its
|
||||
members, and results are ranked: locals first, then globals, built-ins and keywords.
|
||||
10
changes/package-scripts.md
Normal file
10
changes/package-scripts.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
bump: minor
|
||||
type: feat
|
||||
`package.ludic` gains `entry`, scripts and lifecycle hooks:
|
||||
- `entry "src/game.ludic"` names the program `ludic run`/`build`/`bundle` compile. Without
|
||||
it, the one file under `src/` that declares a `program` is used.
|
||||
- `script "dev" "ludic run --headless"` defines a command: `ludic dev` (or `ludic script dev`)
|
||||
runs it with any extra arguments, and `ludic scripts` lists them.
|
||||
- `hook before|after <command> "…"` wraps `build`, `run`, `test`, `bundle`, `pack`, `get`, …
|
||||
or a script. A failing `before` hook stops the command; `after` hooks run only on success.
|
||||
- Quoted manifest values accept `\"` and `\\` escapes.
|
||||
7
changes/test-filter.md
Normal file
7
changes/test-filter.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
bump: minor
|
||||
type: feat
|
||||
Run a single test by name: a compiled test program takes the test's exact name as its
|
||||
first argument (`./tests "adds"`), and `ludic test` gains `--test NAME` to pass it through.
|
||||
A name that matches no test is reported and fails the run. `ludic test --verbose` (`-v`)
|
||||
prints every test's `ok`/`FAIL` line, framed by `RUN <file>` … `PASS|FAIL <file>`, which is
|
||||
what editor test views parse.
|
||||
|
|
@ -50,6 +50,37 @@ A consumer project's manifest is the same file, usually with only `require`
|
|||
lines (the `package`/`version` fields describe a *publishable* package and are
|
||||
optional for a leaf application).
|
||||
|
||||
### The entry program
|
||||
|
||||
`entry "src/game.ludic"` names the program `ludic run`, `build` and `bundle` compile
|
||||
when no file is given. Without it the CLI tries `src/main.ludic`, `main.ludic`, then
|
||||
the one file under `src/` that declares a `program`.
|
||||
|
||||
### Scripts and hooks
|
||||
|
||||
```
|
||||
script "dev" "ludic run --headless" # ludic dev (or: ludic script dev)
|
||||
script "shots" "tools/shots.sh" # ludic shots a b -> tools/shots.sh 'a' 'b'
|
||||
hook before build "tools/gen_atlas.sh" # a failing before hook stops the build
|
||||
hook after bundle "tools/notarize.sh" # after hooks run only when the command succeeded
|
||||
hook before dev "ludic fmt --check" # a script is an event too
|
||||
```
|
||||
|
||||
A **script** is a named shell command, run from the package root. `ludic <name>` runs
|
||||
it when no built-in command has that name; `ludic script <name>` always does, and
|
||||
`ludic scripts` lists them. Arguments after the name are passed on, quoted.
|
||||
|
||||
A **hook** runs a command `before` or `after` a built-in command (`build`, `run`,
|
||||
`test`, `bundle`, `pack`, `clean`, `fmt`, `get`, `add`, `update`, `verify`, `vendor`,
|
||||
`assets`, `build-lib`) or a script. Hooks of one event run in the order written. A
|
||||
`before` hook that exits non-zero stops the command with that exit code; `after`
|
||||
hooks run only when the command succeeded.
|
||||
|
||||
Both run through the shell with the toolchain's `bin/` first on `PATH` and
|
||||
`LUDIC_PACKAGE_ROOT`, `LUDIC_EVENT` (the command or script) and `LUDIC_PHASE`
|
||||
(`before`, `after` or `run`) set. Inside a quoted value, `\"` is a quote and `\\` a
|
||||
backslash.
|
||||
|
||||
## The lockfile — `package.lock.ludic`
|
||||
|
||||
Generated by `ludic get`; do not edit by hand. One line per resolved module, pinning
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ order: 10
|
|||
|
||||
An <code>import</code> pulls the declarations of another Ludic file into this program, letting you split a game across many files instead of one giant block. The imported file is a <b>fragment</b> — bare declarations with no `program` wrapper — and its contents are spliced in as if written here. Paths resolve relative to the importing file, imports may nest, and each resolved path is include-guarded, so importing the same file twice (even through different chains) pulls it in exactly once. Diagnostics still point at the real source file, so errors in an imported fragment report that file's name and line.
|
||||
|
||||
A path without <code>.ludic</code> names a directory through its barrel, <code>index.ludic</code>: <code>import "camp"</code> reads <code>camp/index.ludic</code>, which lists the directory's modules with imports relative to itself. <code>import "dir/*.ludic"</code> instead takes every file in the directory, in name order.
|
||||
|
||||
```ludic
|
||||
# doc-check: skip — illustrative; the imported paths are example files, not
|
||||
# resolvable when this snippet is checked in isolation.
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ function emit_main(d: Node) -> void {
|
|||
# A `test "name" { ... }` block lowers to a void function; `expect*` assertions
|
||||
# inside it flip @L_test_fail. A synthetic runner @main runs every test, prints
|
||||
# `ok - name` / `FAIL - name`, a summary, and exits non-zero if any failed.
|
||||
# `./tests "name"` runs only the test with exactly that name (what an editor's
|
||||
# per-test run button passes); naming a test that does not exist is a failure.
|
||||
|
||||
# one test block -> a void function @fn__test_<idx> (mirrors emit_fn's shape).
|
||||
function emit_test_fn(t: Node, idx: int) -> void {
|
||||
|
|
@ -98,6 +100,7 @@ function emit_test_fn(t: Node, idx: int) -> void {
|
|||
function emit_test_runner() -> void {
|
||||
emith("@L_test_fail = internal global i32 0\n")
|
||||
emith("@.fmt_test_sum = private unnamed_addr constant [28 x i8] c\"== %d passed, %d failed ==\\0A\\00\"\n")
|
||||
emith("@.fmt_test_none = private unnamed_addr constant [20 x i8] c\"no test named \\22%s\\22\\0A\\00\"\n")
|
||||
var i = 0
|
||||
while i < len(g_tests) { emit_test_fn(g_tests[i], i); i += 1 }
|
||||
if g_uses_expect { emith("@.fmt_expect = private unnamed_addr constant [22 x i8] c\"%s (got %d, want %d)\\0A\\00\"\n") }
|
||||
|
|
@ -107,12 +110,37 @@ function emit_test_runner() -> void {
|
|||
emit(" store i32 %argc, ptr @L_argc\n")
|
||||
emit(" store ptr %argv, ptr @L_argv\n")
|
||||
emit(" %failed = alloca i32\n store i32 0, ptr %failed\n")
|
||||
emit(" %ran = alloca i32\n store i32 0, ptr %ran\n")
|
||||
# argv[1], when given, names the one test to run
|
||||
emit(" %filter = alloca ptr\n store ptr null, ptr %filter\n")
|
||||
let lhas = lbl("tfilt"); let lstart = lbl("tbegin")
|
||||
let hasf = emit_bind("icmp sgt i32 %argc, 1")
|
||||
emit(` br i1 {hasf}, label %{lhas}, label %{lstart}\n`)
|
||||
emit(`{lhas}:\n`)
|
||||
let fslot = emit_bind("getelementptr ptr, ptr %argv, i64 1")
|
||||
let fval = emit_bind(`load ptr, ptr {fslot}`)
|
||||
emit(` store ptr {fval}, ptr %filter\n`)
|
||||
emit(` br label %{lstart}\n`)
|
||||
emit(`{lstart}:\n`)
|
||||
if has_ecs() { emit(" call void @rt_init()\n") }
|
||||
emit(" call void @L_init_globals()\n")
|
||||
i = 0
|
||||
while i < len(g_tests) {
|
||||
let okmsg = emit_str_const(`ok - {g_tests[i].s}`)
|
||||
let failmsg = emit_str_const(`FAIL - {g_tests[i].s}`)
|
||||
let tname = emit_str_const(g_tests[i].s)
|
||||
let lcmp = lbl("tcmp"); let lrun = lbl("trun"); let lskip = lbl("tskip")
|
||||
let fl = emit_bind("load ptr, ptr %filter")
|
||||
let nof = emit_bind(`icmp eq ptr {fl}, null`)
|
||||
emit(` br i1 {nof}, label %{lrun}, label %{lcmp}\n`)
|
||||
emit(`{lcmp}:\n`)
|
||||
let cmp = emit_bind(`call i32 @strcmp(ptr {fl}, ptr {tname})`)
|
||||
let same = emit_bind(`icmp eq i32 {cmp}, 0`)
|
||||
emit(` br i1 {same}, label %{lrun}, label %{lskip}\n`)
|
||||
emit(`{lrun}:\n`)
|
||||
let rn = emit_bind("load i32, ptr %ran")
|
||||
let rn1 = emit_bind(`add i32 {rn}, 1`)
|
||||
emit(` store i32 {rn1}, ptr %ran\n`)
|
||||
emit(" store i32 0, ptr @L_test_fail\n")
|
||||
emit(` call void @fn__test_{itoa(i)}()\n`)
|
||||
let f = emit_bind("load i32, ptr @L_test_fail")
|
||||
|
|
@ -129,10 +157,24 @@ function emit_test_runner() -> void {
|
|||
emit(` call i32 (ptr, ...) @printf(ptr @.fmt_line, ptr {failmsg})\n`)
|
||||
emit(` br label %{ldone}\n`)
|
||||
emit(`{ldone}:\n`)
|
||||
emit(` br label %{lskip}\n`)
|
||||
emit(`{lskip}:\n`)
|
||||
i += 1
|
||||
}
|
||||
# a filter that matched nothing is a typo, not a pass
|
||||
let lnone = lbl("tnone"); let lsum = lbl("tsum")
|
||||
let ran = emit_bind("load i32, ptr %ran")
|
||||
let fl = emit_bind("load ptr, ptr %filter")
|
||||
let hasfilter = emit_bind(`icmp ne ptr {fl}, null`)
|
||||
let noran = emit_bind(`icmp eq i32 {ran}, 0`)
|
||||
let missed = emit_bind(`and i1 {hasfilter}, {noran}`)
|
||||
emit(` br i1 {missed}, label %{lnone}, label %{lsum}\n`)
|
||||
emit(`{lnone}:\n`)
|
||||
emit(` call i32 (ptr, ...) @printf(ptr @.fmt_test_none, ptr {fl})\n`)
|
||||
emit(" ret i32 1\n")
|
||||
emit(`{lsum}:\n`)
|
||||
let totf = emit_bind("load i32, ptr %failed")
|
||||
let passed = emit_bind(`sub i32 {itoa(len(g_tests))}, {totf}`)
|
||||
let passed = emit_bind(`sub i32 {ran}, {totf}`)
|
||||
emit(` call i32 (ptr, ...) @printf(ptr @.fmt_test_sum, i32 {passed}, i32 {totf})\n`)
|
||||
let allok = emit_bind(`icmp eq i32 {totf}, 0`)
|
||||
let rc = emit_bind(`select i1 {allok}, i32 0, i32 1`)
|
||||
|
|
|
|||
|
|
@ -945,8 +945,25 @@ function do_import_dir(rel: pointer) -> void {
|
|||
}
|
||||
}
|
||||
|
||||
# `import "camp"` names a directory: its index.ludic is the barrel that lists
|
||||
# what the directory exports (its own imports, relative to itself).
|
||||
function barrel_of(rel: pointer) -> pointer {
|
||||
let n = len(rel)
|
||||
if (n > 6) and (rel[n - 6..n] == ".ludic") { return "" }
|
||||
if rel[n - 1] == '/' { return rel + "index.ludic" }
|
||||
return rel + "/index.ludic"
|
||||
}
|
||||
|
||||
function do_import(rel: pointer) -> void {
|
||||
if (len(rel) > 8) and (rel[len(rel) - 8..len(rel)] == "/*.ludic") { do_import_dir(rel); return }
|
||||
let barrel = barrel_of(rel)
|
||||
if barrel != "" {
|
||||
if (read_file(join_path(cur_dir, barrel)) == null) and Fs.is_dir(join_path(cur_dir, rel)) {
|
||||
perr(`import: {rel} is a directory with no index.ludic (add {barrel} listing its imports)`)
|
||||
}
|
||||
do_import(barrel)
|
||||
return
|
||||
}
|
||||
var full = join_path(cur_dir, rel)
|
||||
var src = read_file(full)
|
||||
# #75 — the engine runtime (runtime/native/*) ships with the toolchain, not the
|
||||
|
|
|
|||
40455
selfhost/ludicc.seed.ll
40455
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,8 @@
|
|||
# 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.*).
|
||||
WebStorm — anything on the IntelliJ Platform 2024.2 through 2026.2
|
||||
(`since-build` 242, `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
|
||||
|
|
@ -17,7 +17,7 @@ packages cleanly can still fail to load:
|
|||
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
|
||||
`Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.4.0)` in
|
||||
`.intellijPlatform/sandbox/Ludic/IC-2025.3/log/idea.log`.
|
||||
|
||||
**If the plugin does not appear in Settings -> Plugins after installing**, the
|
||||
|
|
@ -35,7 +35,7 @@ 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 buildPlugin # -> build/distributions/Ludic-1.4.0.zip
|
||||
./gradlew runIde # try it in a sandbox IDE
|
||||
```
|
||||
|
||||
|
|
@ -51,8 +51,39 @@ 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.
|
||||
|
||||
## What you get
|
||||
|
||||
- **Editing**: highlighting (Settings | Editor | Color Scheme | Ludic), brace and
|
||||
quote pairing, folding, `#` comments, smart indent on Enter and on typing a
|
||||
closer, Code Style page (2 spaces by default), live templates (`prog`,
|
||||
`handler`, `on`, `test`, `fn`, `forq`, ...), `package.ludic` directives.
|
||||
- **Language server** (`ludic-lsp` via LSP4IJ): completion, diagnostics
|
||||
(including the compiler's own on save), hover, parameter info, go to
|
||||
definition, find/highlight usages, rename, formatting, inlay hints, semantic
|
||||
colours, Structure view, breadcrumbs, Go to Symbol.
|
||||
- **Run / test**: a *Ludic* run configuration (`ludic run`, `build`, `test` or
|
||||
any other `ludic` command); green gutter buttons on `program` and on every
|
||||
`test "name"` (which runs just that test via `ludic test --test NAME`);
|
||||
results in the test tree with navigation; `file.ludic:line` links in every
|
||||
console. Per-test results need a toolchain with `ludic test --verbose`
|
||||
(0.17+); an older one still gives per-file results.
|
||||
- **Project**: File | New | Project | Ludic (runs `ludic new`), File | New |
|
||||
Ludic File (program, module, test), Tools | Ludic (run, build, test, fmt,
|
||||
get, pack, bundle, clean, doctor).
|
||||
|
||||
There is no debugger: `ludicc` does not emit debug info yet.
|
||||
|
||||
## Settings
|
||||
|
||||
**Settings -> Languages & Frameworks -> Ludic** — the path to `ludic-lsp` and to
|
||||
`ludicc`. Both default to `bin/` under the project root, which is where
|
||||
`bin/ludic-dev tools` puts them.
|
||||
**Settings -> Languages & Frameworks -> Ludic** — the paths to `ludic`,
|
||||
`ludic-lsp` and `ludicc`. Each blank one is looked up in `bin/` under the
|
||||
project root, then on `PATH`, then in `~/.ludic/bin`. Changing a path restarts
|
||||
the language server.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
./gradlew test # ~70 headless platform tests, ~25 s
|
||||
./gradlew verifyPlugin -PlocalIde="/Applications/IntelliJ IDEA.app" # also verify against an installed IDE
|
||||
./gradlew runLocalIde -PlocalIde="/Applications/IntelliJ IDEA.app" -PopenProject=/path/to/game
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import java.time.Duration
|
||||
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
|
||||
import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
|
||||
|
||||
|
|
@ -9,6 +10,8 @@ plugins {
|
|||
id("org.jetbrains.intellij.platform") version "2.18.1"
|
||||
}
|
||||
|
||||
val localIde = providers.gradleProperty("localIde")
|
||||
|
||||
group = providers.gradleProperty("pluginGroup").get()
|
||||
version = providers.gradleProperty("pluginVersion").get()
|
||||
|
||||
|
|
@ -65,6 +68,9 @@ intellijPlatform {
|
|||
create(IntelliJPlatformType.IntellijIdeaCommunity, providers.gradleProperty("platformVersion").get()) {
|
||||
useInstaller = false
|
||||
}
|
||||
// -PlocalIde=/path/to/IDE.app also checks against an installed IDE,
|
||||
// e.g. the newer one a developer actually uses
|
||||
localIde.orNull?.let { local(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,8 +93,32 @@ tasks.named<JavaExec>("runIde") {
|
|||
args = listOf(repoRoot.absolutePath)
|
||||
}
|
||||
|
||||
// `./gradlew runLocalIde -PlocalIde=/Applications/IntelliJ IDEA.app [-PopenProject=dir]`:
|
||||
// the same sandbox as runIde, booted from an installed IDE instead of the
|
||||
// downloaded one, so the plugin can be tried on exactly that build.
|
||||
val runLocalIde by intellijPlatformTesting.runIde.registering {
|
||||
localPath = localIde.map { file(it) }
|
||||
task {
|
||||
args = listOf(providers.gradleProperty("openProject").getOrElse(repoRoot.absolutePath))
|
||||
}
|
||||
plugins {
|
||||
plugin("com.redhat.devtools.lsp4ij", providers.gradleProperty("lsp4ijVersion").get())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// The palette Color.* resolves against, straight from the docs it is generated
|
||||
// into (`ludic-dev docs-palette`), so the swatches cannot drift from the compiler.
|
||||
tasks.named<ProcessResources>("processResources") {
|
||||
from(File(repoRoot, "docs/language/colors/palette.json"))
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnit()
|
||||
// a platform test that waits on a modal dialog or an event that never
|
||||
// comes blocks forever; fail the run instead of hanging it
|
||||
timeout.set(Duration.ofMinutes(10))
|
||||
systemProperty("idea.test.timeout", "120")
|
||||
testLogging {
|
||||
events("passed", "failed", "skipped")
|
||||
showStandardStreams = false
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
pluginGroup = io.ludic.ide
|
||||
pluginName = Ludic
|
||||
pluginVersion = 1.0.0
|
||||
pluginVersion = 1.4.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
|
||||
# 2024.2 (242) is the oldest platform LSP4IJ 0.20+ installs on, so it is the
|
||||
# floor here too: claiming older would let an IDE accept this plugin and then
|
||||
# fail to find its required dependency. LSP4IJ (not the paid-IDE LSP API)
|
||||
# supplies the language-server bridge, so the free IDEs are supported as well.
|
||||
pluginSinceBuild = 242
|
||||
# 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
|
||||
|
|
@ -15,7 +16,7 @@ pluginSinceBuild = 232
|
|||
pluginUntilBuild = 262.*
|
||||
|
||||
platformVersion = 2025.3
|
||||
lsp4ijVersion = 0.20.1
|
||||
lsp4ijVersion = 0.21.0
|
||||
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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 com.redhat.devtools.lsp4ij.LanguageServerManager
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JSpinner
|
||||
|
|
@ -16,6 +17,7 @@ import javax.swing.SpinnerNumberModel
|
|||
class LudicConfigurable(private val project: Project) : Configurable {
|
||||
private val serverField = TextFieldWithBrowseButton()
|
||||
private val compilerField = TextFieldWithBrowseButton()
|
||||
private val cliField = TextFieldWithBrowseButton()
|
||||
private val diagnostics = JBCheckBox("Report compiler errors on save")
|
||||
private val indent = JSpinner(SpinnerNumberModel(2, 1, 8, 1))
|
||||
private var panel: JPanel? = null
|
||||
|
|
@ -33,6 +35,10 @@ class LudicConfigurable(private val project: Project) : Configurable {
|
|||
project,
|
||||
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludicc")
|
||||
)
|
||||
cliField.addBrowseFolderListener(
|
||||
project,
|
||||
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludic")
|
||||
)
|
||||
|
||||
panel = FormBuilder.createFormBuilder()
|
||||
.addLabeledComponent(JBLabel("Language server (ludic-lsp):"), serverField, 1, false)
|
||||
|
|
@ -43,6 +49,10 @@ class LudicConfigurable(private val project: Project) : Configurable {
|
|||
.addComponentToRightColumn(
|
||||
JBLabel("Used for authoritative diagnostics. Empty means bin/ludicc.")
|
||||
)
|
||||
.addLabeledComponent(JBLabel("Command line (ludic):"), cliField, 1, false)
|
||||
.addComponentToRightColumn(
|
||||
JBLabel("Runs, builds, tests and new projects. Empty means bin/ludic, then PATH, then ~/.ludic.")
|
||||
)
|
||||
.addComponent(diagnostics)
|
||||
.addLabeledComponent(JBLabel("Formatter indent:"), indent, 1, false)
|
||||
.addComponentFillVertically(JPanel(), 0)
|
||||
|
|
@ -57,22 +67,37 @@ class LudicConfigurable(private val project: Project) : Configurable {
|
|||
val s = settings()
|
||||
return serverField.text != s.serverPath ||
|
||||
compilerField.text != s.compilerPath ||
|
||||
cliField.text != s.cliPath ||
|
||||
diagnostics.isSelected != s.compilerDiagnostics ||
|
||||
(indent.value as Int) != s.indentSize
|
||||
}
|
||||
|
||||
override fun apply() {
|
||||
val s = settings()
|
||||
val restart = serverField.text.trim() != s.serverPath ||
|
||||
compilerField.text.trim() != s.compilerPath ||
|
||||
diagnostics.isSelected != s.compilerDiagnostics ||
|
||||
(indent.value as Int) != s.indentSize
|
||||
s.serverPath = serverField.text.trim()
|
||||
s.compilerPath = compilerField.text.trim()
|
||||
s.cliPath = cliField.text.trim()
|
||||
s.compilerDiagnostics = diagnostics.isSelected
|
||||
s.indentSize = indent.value as Int
|
||||
// the server reads all of these once, at initialize
|
||||
if (restart) restartServer()
|
||||
}
|
||||
|
||||
private fun restartServer() {
|
||||
val manager = LanguageServerManager.getInstance(project)
|
||||
manager.stop("ludic")
|
||||
manager.start("ludic")
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
val s = settings()
|
||||
serverField.text = s.serverPath
|
||||
compilerField.text = s.compilerPath
|
||||
cliField.text = s.cliPath
|
||||
diagnostics.isSelected = s.compilerDiagnostics
|
||||
indent.value = s.indentSize
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import com.intellij.psi.tree.IElementType
|
|||
* this needs the language server, so none of it waits for one.
|
||||
*/
|
||||
class LudicCommenter : Commenter {
|
||||
override fun getLineCommentPrefix() = "# "
|
||||
override fun getLineCommentPrefix() = "#"
|
||||
override fun getBlockCommentPrefix(): String? = null
|
||||
override fun getBlockCommentSuffix(): String? = null
|
||||
override fun getCommentedBlockCommentPrefix(): String? = null
|
||||
|
|
@ -37,9 +37,10 @@ class LudicBraceMatcher : PairedBraceMatcher {
|
|||
// 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)
|
||||
BracePair(LudicTokens.LBRACE, LudicTokens.RBRACE, true),
|
||||
BracePair(LudicTokens.LBRACKET, LudicTokens.RBRACKET, false),
|
||||
BracePair(LudicTokens.LPAREN, LudicTokens.RPAREN, false),
|
||||
BracePair(LudicTokens.TEMPLATE_HOLE_START, LudicTokens.TEMPLATE_HOLE_END, false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -60,17 +61,15 @@ class LudicFoldingBuilder : FoldingBuilderEx(), DumbAware {
|
|||
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))
|
||||
}
|
||||
when (lexer.tokenType) {
|
||||
LudicTokens.LBRACE -> opens.addLast(lexer.tokenStart)
|
||||
LudicTokens.RBRACE -> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,14 @@ import com.intellij.psi.tree.IElementType
|
|||
* rules are the ones the shared header already implements, and keeping the two
|
||||
* readable side by side is worth more here than a JFlex spec.
|
||||
*
|
||||
* With [manifest] set it lexes a package.ludic, where the directives
|
||||
* (`package`, `require`, `app`…) are the keywords instead.
|
||||
*
|
||||
* 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() {
|
||||
class LudicLexer(private val manifest: Boolean = false) : LexerBase() {
|
||||
private var buffer: CharSequence = ""
|
||||
private var startOffset = 0
|
||||
private var endOffset = 0
|
||||
|
|
@ -22,16 +25,35 @@ class LudicLexer : LexerBase() {
|
|||
private var tokenEnd = 0
|
||||
private var tokenType: IElementType? = null
|
||||
|
||||
/**
|
||||
* What the previous significant token was, which decides whether a
|
||||
* declaration word is a keyword here (see [isNamePosition]). It is the
|
||||
* lexer state, so an incremental restart mid-file lexes the same way.
|
||||
*/
|
||||
private var prev = PREV_START
|
||||
private var stateAtToken = PREV_START
|
||||
|
||||
/**
|
||||
* Where we are relative to a `template {hole} string`: in code, in the
|
||||
* template's text, or in a hole (with how many braces it has opened).
|
||||
* Packed into the state with [prev].
|
||||
*/
|
||||
private var mode = MODE_CODE
|
||||
private var holeDepth = 0
|
||||
|
||||
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
|
||||
this.prev = initialState and 3
|
||||
this.mode = (initialState shr 2) and 3
|
||||
this.holeDepth = initialState shr 4
|
||||
advance()
|
||||
}
|
||||
|
||||
override fun getState() = 0
|
||||
override fun getState() = stateAtToken
|
||||
override fun getTokenType(): IElementType? = tokenType
|
||||
override fun getTokenStart() = tokenStart
|
||||
override fun getTokenEnd() = tokenEnd
|
||||
|
|
@ -44,6 +66,44 @@ class LudicLexer : LexerBase() {
|
|||
private fun isSpace(c: Char) = c == ' ' || c == '\t' || c == '\r' || c == '\n'
|
||||
|
||||
override fun advance() {
|
||||
stateAtToken = prev or (mode shl 2) or (holeDepth shl 4)
|
||||
if (mode == MODE_TEXT) { lexTemplateText(); return }
|
||||
lexOne()
|
||||
val t = tokenType ?: return
|
||||
if (t != TokenType.WHITE_SPACE && t != LudicTokens.COMMENT) prev = classOf(t)
|
||||
}
|
||||
|
||||
private fun classOf(t: IElementType): Int {
|
||||
val text = buffer.subSequence(tokenStart, tokenEnd)
|
||||
return when {
|
||||
t == LudicTokens.LBRACE || t == LudicTokens.RBRACE -> PREV_START
|
||||
t == LudicTokens.RPAREN || t == LudicTokens.RBRACKET -> PREV_OTHER
|
||||
t == LudicTokens.OPERATOR && text == ";" -> PREV_START
|
||||
t == LudicTokens.OPERATOR && text == "." -> PREV_NAME_BEFORE
|
||||
t == LudicTokens.OPERATOR || t == LudicTokens.LPAREN || t == LudicTokens.LBRACKET || t == LudicTokens.COMMA -> PREV_NAME_BEFORE
|
||||
t == LudicTokens.DECL_KEYWORD && (text == "var" || text == "const") -> PREV_NAME_BEFORE
|
||||
t == LudicTokens.KEYWORD && (text == "let" || text == "return") -> PREV_NAME_BEFORE
|
||||
else -> PREV_OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The compiler reserves declaration words only as function names, so
|
||||
* `var model = …`, `model: Model` and `actor.model` are ordinary names.
|
||||
*/
|
||||
private fun isNamePosition(end: Int): Boolean {
|
||||
if (prev == PREV_NAME_BEFORE) return true
|
||||
var j = end
|
||||
while (j < endOffset && (at(j) == ' ' || at(j) == '\t')) j++
|
||||
if (j >= endOffset) return false
|
||||
val c = at(j)
|
||||
if (c == ':' || c == '.' || c == ')' || c == ',' || c == ']') return true
|
||||
if (c == '=' && at(j + 1) != '>') return true // `x = …`, `x == …`
|
||||
if ((c == '+' || c == '-' || c == '*' || c == '/' || c == '!') && at(j + 1) == '=') return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun lexOne() {
|
||||
tokenStart = tokenEnd
|
||||
if (tokenStart >= endOffset) {
|
||||
tokenType = null
|
||||
|
|
@ -63,6 +123,21 @@ class LudicLexer : LexerBase() {
|
|||
emit(i, LudicTokens.COMMENT)
|
||||
return
|
||||
}
|
||||
if (c == '`') {
|
||||
// opens a template, or (from a hole) closes it: the compiler ends a
|
||||
// template at the next backtick whatever it is inside
|
||||
mode = if (mode == MODE_CODE) MODE_TEXT else MODE_CODE
|
||||
holeDepth = 0
|
||||
emit(i + 1, LudicTokens.TEMPLATE_QUOTE)
|
||||
return
|
||||
}
|
||||
if (mode == MODE_HOLE && c == '}' && holeDepth == 0) {
|
||||
mode = MODE_TEXT
|
||||
emit(i + 1, LudicTokens.TEMPLATE_HOLE_END)
|
||||
return
|
||||
}
|
||||
if (mode == MODE_HOLE && c == '{') holeDepth++
|
||||
if (mode == MODE_HOLE && c == '}') holeDepth--
|
||||
if (c == '"') {
|
||||
i++
|
||||
while (i < endOffset && at(i) != '"' && at(i) != '\n') {
|
||||
|
|
@ -104,9 +179,11 @@ class LudicLexer : LexerBase() {
|
|||
while (i < endOffset && isIdPart(at(i))) i++
|
||||
val word = buffer.subSequence(tokenStart, i).toString()
|
||||
val type = when {
|
||||
manifest && word in LudicVocabulary.MANIFEST -> LudicTokens.MANIFEST_KEYWORD
|
||||
word == "true" || word == "false" -> LudicTokens.BOOLEAN
|
||||
word in LudicVocabulary.PRIMITIVES -> LudicTokens.PRIMITIVE
|
||||
word in LudicVocabulary.PHASES -> LudicTokens.PHASE
|
||||
word in LudicVocabulary.CONTEXTUAL && isNamePosition(i) -> LudicTokens.IDENTIFIER
|
||||
word in LudicVocabulary.DECL -> LudicTokens.DECL_KEYWORD
|
||||
word in LudicVocabulary.CLAUSE -> LudicTokens.CLAUSE
|
||||
word in LudicVocabulary.STMT -> LudicTokens.KEYWORD
|
||||
|
|
@ -124,9 +201,12 @@ class LudicLexer : LexerBase() {
|
|||
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.LBRACE); return }
|
||||
'}' -> { emit(i + 1, LudicTokens.RBRACE); return }
|
||||
'[' -> { emit(i + 1, LudicTokens.LBRACKET); return }
|
||||
']' -> { emit(i + 1, LudicTokens.RBRACKET); return }
|
||||
'(' -> { emit(i + 1, LudicTokens.LPAREN); return }
|
||||
')' -> { emit(i + 1, LudicTokens.RPAREN); return }
|
||||
',' -> { emit(i + 1, LudicTokens.COMMA); return }
|
||||
}
|
||||
if (c in SINGLE_CHAR_OPS) {
|
||||
|
|
@ -136,12 +216,38 @@ class LudicLexer : LexerBase() {
|
|||
emit(i + 1, LudicTokens.BAD)
|
||||
}
|
||||
|
||||
/** One run of template text, an escape, a hole opener or the closing backtick. */
|
||||
private fun lexTemplateText() {
|
||||
tokenStart = tokenEnd
|
||||
if (tokenStart >= endOffset) { tokenType = null; return }
|
||||
var i = tokenStart
|
||||
val c = at(i)
|
||||
when {
|
||||
c == '`' -> { mode = MODE_CODE; emit(i + 1, LudicTokens.TEMPLATE_QUOTE); return }
|
||||
(c == '{' && at(i + 1) == '{') || (c == '}' && at(i + 1) == '}') -> { emit(i + 2, LudicTokens.TEMPLATE_ESCAPE); return }
|
||||
c == '{' -> { mode = MODE_HOLE; holeDepth = 0; emit(i + 1, LudicTokens.TEMPLATE_HOLE_START); return }
|
||||
c == '\\' && i + 1 < endOffset -> { emit(i + 2, LudicTokens.TEMPLATE_ESCAPE); return }
|
||||
}
|
||||
while (i < endOffset) {
|
||||
val d = at(i)
|
||||
if (d == '`' || d == '{' || d == '\\' || (d == '}' && at(i + 1) == '}')) break
|
||||
i++
|
||||
}
|
||||
emit(maxOf(i, tokenStart + 1), LudicTokens.TEMPLATE_TEXT)
|
||||
}
|
||||
|
||||
private fun emit(end: Int, type: IElementType) {
|
||||
tokenEnd = end
|
||||
tokenType = type
|
||||
}
|
||||
|
||||
private companion object {
|
||||
companion object {
|
||||
const val MODE_CODE = 0
|
||||
const val MODE_TEXT = 1
|
||||
const val MODE_HOLE = 2
|
||||
const val PREV_START = 0 // file start, `{`, `}`, `;`: a declaration may begin
|
||||
const val PREV_OTHER = 1
|
||||
const val PREV_NAME_BEFORE = 2 // `.`, an operator, `(`, `var`, `let`, `return`: a name follows
|
||||
val TWO_CHAR_OPS = setOf("->", "+=", "-=", "*=", "/=", "==", "!=", "<=", ">=", "<<", ">>", "..", "=>")
|
||||
const val SINGLE_CHAR_OPS = "+-*/%<>=:.!@;&|^~"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class LudicParserDefinition : ParserDefinition {
|
|||
|
||||
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 getStringLiteralElements(): TokenSet = TokenSet.create(LudicTokens.STRING, LudicTokens.CHAR, LudicTokens.TEMPLATE_TEXT)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,18 @@ 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.intellij.psi.PsiFile
|
||||
import com.redhat.devtools.lsp4ij.client.LanguageClientImpl
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPCompletionFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPDefinitionFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPDiagnosticFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPDocumentHighlightFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPDocumentLinkFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPHoverFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPInlayHintFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPSemanticTokensFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPSignatureHelpFeature
|
||||
import com.redhat.devtools.lsp4ij.server.CannotStartProcessException
|
||||
import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider
|
||||
import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider
|
||||
|
|
@ -25,6 +36,7 @@ import java.io.File
|
|||
class LudicSettings : PersistentStateComponent<LudicSettings> {
|
||||
var serverPath: String = ""
|
||||
var compilerPath: String = ""
|
||||
var cliPath: String = ""
|
||||
var compilerDiagnostics: Boolean = true
|
||||
var indentSize: Int = 2
|
||||
|
||||
|
|
@ -43,26 +55,38 @@ class LudicSettings : PersistentStateComponent<LudicSettings> {
|
|||
* 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? {
|
||||
internal fun findExecutable(
|
||||
root: String?,
|
||||
configured: String,
|
||||
names: List<String>,
|
||||
subdirs: List<String>,
|
||||
path: String? = System.getenv("PATH"),
|
||||
home: String = ludicHome(),
|
||||
): String? {
|
||||
if (configured.isNotBlank()) {
|
||||
val f = File(expandHome(configured))
|
||||
return if (f.canExecute()) f.absolutePath else null
|
||||
return if (f.isFile && 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
|
||||
if (root != null) {
|
||||
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)
|
||||
return searchPath(names, path, home)
|
||||
}
|
||||
|
||||
/** The install root: `$LUDIC_HOME`, else `~/.ludic`. */
|
||||
internal fun ludicHome(): String =
|
||||
System.getenv("LUDIC_HOME") ?: (System.getProperty("user.home") + "/.ludic")
|
||||
|
||||
/** 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")
|
||||
private fun searchPath(names: List<String>, path: String?, home: String): String? {
|
||||
if (path != null) {
|
||||
for (dir in path.split(File.pathSeparator)) {
|
||||
if (dir.isEmpty()) continue
|
||||
for (name in names) {
|
||||
val f = File(dir, name)
|
||||
if (f.isFile && f.canExecute()) return f.absolutePath
|
||||
|
|
@ -72,7 +96,6 @@ private fun searchPath(names: List<String>): String? {
|
|||
// The install root, last: an IDE launched from the desktop does not inherit
|
||||
// the PATH the installer wrote to the shell profile, so a working ~/.ludic
|
||||
// install would otherwise look missing.
|
||||
val home = System.getenv("LUDIC_HOME") ?: (System.getProperty("user.home") + "/.ludic")
|
||||
for (name in names) {
|
||||
val f = File(File(home, "bin"), name)
|
||||
if (f.isFile && f.canExecute()) return f.absolutePath
|
||||
|
|
@ -80,6 +103,39 @@ private fun searchPath(names: List<String>): String? {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The three toolchain binaries, resolved the same way everywhere: the setting,
|
||||
* then the project's own bin/, then PATH, then the install root.
|
||||
*/
|
||||
object LudicToolchain {
|
||||
fun server(project: Project): String? =
|
||||
findExecutable(project.basePath, LudicSettings.of(project).serverPath, listOf("ludic-lsp", "ludic-lsp.exe"), SERVER_SUBDIRS)
|
||||
|
||||
fun compiler(project: Project): String? =
|
||||
findExecutable(project.basePath, LudicSettings.of(project).compilerPath, listOf("ludicc", "ludicc.exe"), COMPILER_SUBDIRS)
|
||||
|
||||
/**
|
||||
* The install root the toolchain runs from: $LUDIC_HOME, else the
|
||||
* directory above the `ludic` binary's bin/ (a checkout, or ~/.ludic).
|
||||
*/
|
||||
fun home(project: Project?): File? {
|
||||
System.getenv("LUDIC_HOME")?.let { return File(it) }
|
||||
val cli = (if (project != null) cli(project) else cli()) ?: return null
|
||||
val bin = File(cli).parentFile ?: return null
|
||||
return if (bin.name == "bin") bin.parentFile else bin
|
||||
}
|
||||
|
||||
fun cli(project: Project): String? =
|
||||
findExecutable(project.basePath, LudicSettings.of(project).cliPath, listOf("ludic", "ludic.exe"), COMPILER_SUBDIRS)
|
||||
|
||||
/** For places with no project yet, such as the New Project wizard. */
|
||||
fun cli(): String? = findExecutable(null, "", listOf("ludic", "ludic.exe"), COMPILER_SUBDIRS)
|
||||
|
||||
const val INSTALL_HINT =
|
||||
"Install the toolchain (curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh) " +
|
||||
"or set the paths in Settings | Languages & Frameworks | Ludic."
|
||||
}
|
||||
|
||||
private fun expandHome(p: String) =
|
||||
if (p.startsWith("~")) System.getProperty("user.home") + p.substring(1) else p
|
||||
|
||||
|
|
@ -132,11 +188,7 @@ class LudicConnectionProvider(private val project: Project) : ProcessStreamConne
|
|||
*/
|
||||
override fun getInitializationOptions(rootUri: VirtualFile?): Any {
|
||||
val settings = LudicSettings.of(project)
|
||||
val compiler = findExecutable(
|
||||
project.basePath, settings.compilerPath,
|
||||
listOf("ludicc", "ludicc.exe"),
|
||||
COMPILER_SUBDIRS
|
||||
) ?: ""
|
||||
val compiler = LudicToolchain.compiler(project) ?: ""
|
||||
return mapOf(
|
||||
"compilerPath" to compiler,
|
||||
"compilerDiagnostics" to settings.compilerDiagnostics,
|
||||
|
|
@ -160,7 +212,50 @@ internal fun serverCommand(root: String?, configured: String): List<String>? {
|
|||
internal val SERVER_SUBDIRS = listOf("bin", "build", "tools/build", ".")
|
||||
internal val COMPILER_SUBDIRS = listOf("bin", "build", ".")
|
||||
|
||||
/**
|
||||
* package.ludic is a Ludic file to the IDE but not Ludic source to the server:
|
||||
* its directives would read as unknown names, get keyword completions and have
|
||||
* their manifest colours painted over. The plugin handles it natively instead.
|
||||
*/
|
||||
private class ManifestAwareFeatures : LSPClientFeatures() {
|
||||
init {
|
||||
completionFeature = object : LSPCompletionFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
semanticTokensFeature = object : LSPSemanticTokensFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
override fun getTextAttributesKey(tokenType: String, tokenModifiers: List<String>, file: PsiFile) =
|
||||
io.ludic.ide.editor.LudicSemanticColors.keyFor(tokenType, tokenModifiers)
|
||||
}
|
||||
diagnosticFeature = object : LSPDiagnosticFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
signatureHelpFeature = object : LSPSignatureHelpFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
inlayHintFeature = object : LSPInlayHintFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
definitionFeature = object : LSPDefinitionFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
documentLinkFeature = object : LSPDocumentLinkFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
documentHighlightFeature = object : LSPDocumentHighlightFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
hoverFeature = object : LSPHoverFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isManifest(file: PsiFile) = file.name == "package.ludic" || file.name == "package.lock.ludic"
|
||||
}
|
||||
|
||||
class LudicLanguageServerFactory : LanguageServerFactory {
|
||||
override fun createClientFeatures(): LSPClientFeatures = ManifestAwareFeatures()
|
||||
|
||||
override fun createConnectionProvider(project: Project): StreamConnectionProvider =
|
||||
LudicConnectionProvider(project)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ 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 STRING_ESCAPE = key("LUDIC_STRING_ESCAPE", D.VALID_STRING_ESCAPE)
|
||||
val TEMPLATE_BRACES = key("LUDIC_TEMPLATE_BRACES", D.KEYWORD)
|
||||
val KEYWORD = key("LUDIC_KEYWORD", D.KEYWORD)
|
||||
val DECL_KEYWORD = key("LUDIC_DECL_KEYWORD", D.KEYWORD)
|
||||
val CLAUSE = key("LUDIC_CLAUSE", D.METADATA)
|
||||
|
|
@ -36,18 +38,43 @@ object LudicColors {
|
|||
val BRACKET = key("LUDIC_BRACKET", D.BRACKETS)
|
||||
val PAREN = key("LUDIC_PAREN", D.PARENTHESES)
|
||||
val COMMA = key("LUDIC_COMMA", D.COMMA)
|
||||
val MANIFEST = key("LUDIC_MANIFEST_DIRECTIVE", D.KEYWORD)
|
||||
|
||||
// Semantic colours, painted from ludic-lsp's tokens. "Built-in" is the
|
||||
// language and its standard library, "package" a ludic.* or fetched
|
||||
// package, and the plain key the program's own code.
|
||||
val FUNCTION_DECLARATION = key("LUDIC_FUNCTION_DECLARATION", D.FUNCTION_DECLARATION)
|
||||
val FUNCTION_CALL = key("LUDIC_FUNCTION_CALL", D.FUNCTION_CALL)
|
||||
val FUNCTION_PACKAGE = key("LUDIC_FUNCTION_PACKAGE", D.FUNCTION_CALL)
|
||||
val FUNCTION_BUILTIN = key("LUDIC_FUNCTION_BUILTIN", D.PREDEFINED_SYMBOL)
|
||||
val HANDLER = key("LUDIC_HANDLER", D.FUNCTION_DECLARATION)
|
||||
val CONSTANT = key("LUDIC_CONSTANT", D.CONSTANT)
|
||||
val CONSTANT_PACKAGE = key("LUDIC_CONSTANT_PACKAGE", D.CONSTANT)
|
||||
val CONSTANT_BUILTIN = key("LUDIC_CONSTANT_BUILTIN", D.STATIC_FIELD)
|
||||
val GLOBAL_VARIABLE = key("LUDIC_GLOBAL_VARIABLE", D.GLOBAL_VARIABLE)
|
||||
val GLOBAL_VARIABLE_PACKAGE = key("LUDIC_GLOBAL_VARIABLE_PACKAGE", D.GLOBAL_VARIABLE)
|
||||
val LOCAL_VARIABLE = key("LUDIC_LOCAL_VARIABLE", D.LOCAL_VARIABLE)
|
||||
val PARAMETER = key("LUDIC_PARAMETER", D.PARAMETER)
|
||||
val FIELD = key("LUDIC_FIELD", D.INSTANCE_FIELD)
|
||||
val PROPERTY_TYPE = key("LUDIC_PROPERTY_TYPE", D.CLASS_NAME)
|
||||
val MODEL_TYPE = key("LUDIC_MODEL_TYPE", D.CLASS_NAME)
|
||||
val TYPE_PACKAGE = key("LUDIC_TYPE_PACKAGE", D.CLASS_REFERENCE)
|
||||
val NAMESPACE = key("LUDIC_NAMESPACE", D.CLASS_REFERENCE)
|
||||
val NAMESPACE_BUILTIN = key("LUDIC_NAMESPACE_BUILTIN", D.PREDEFINED_SYMBOL)
|
||||
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()
|
||||
class LudicSyntaxHighlighter(private val manifest: Boolean = false) : SyntaxHighlighterBase() {
|
||||
override fun getHighlightingLexer(): Lexer = LudicLexer(manifest)
|
||||
|
||||
override fun getTokenHighlights(tokenType: IElementType): Array<TextAttributesKey> = pack(
|
||||
when (tokenType) {
|
||||
LudicTokens.COMMENT -> LudicColors.COMMENT
|
||||
LudicTokens.STRING, LudicTokens.CHAR -> LudicColors.STRING
|
||||
LudicTokens.STRING, LudicTokens.CHAR, LudicTokens.TEMPLATE_QUOTE, LudicTokens.TEMPLATE_TEXT -> LudicColors.STRING
|
||||
LudicTokens.TEMPLATE_ESCAPE -> LudicColors.STRING_ESCAPE
|
||||
LudicTokens.TEMPLATE_HOLE_START, LudicTokens.TEMPLATE_HOLE_END -> LudicColors.TEMPLATE_BRACES
|
||||
LudicTokens.NUMBER -> LudicColors.NUMBER
|
||||
LudicTokens.KEYWORD -> LudicColors.KEYWORD
|
||||
LudicTokens.DECL_KEYWORD -> LudicColors.DECL_KEYWORD
|
||||
|
|
@ -60,10 +87,11 @@ class LudicSyntaxHighlighter : SyntaxHighlighterBase() {
|
|||
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.LBRACE, LudicTokens.RBRACE -> LudicColors.BRACE
|
||||
LudicTokens.LBRACKET, LudicTokens.RBRACKET -> LudicColors.BRACKET
|
||||
LudicTokens.LPAREN, LudicTokens.RPAREN -> LudicColors.PAREN
|
||||
LudicTokens.COMMA -> LudicColors.COMMA
|
||||
LudicTokens.MANIFEST_KEYWORD -> LudicColors.MANIFEST
|
||||
LudicTokens.BAD -> LudicColors.BAD
|
||||
else -> null
|
||||
}
|
||||
|
|
@ -72,5 +100,5 @@ class LudicSyntaxHighlighter : SyntaxHighlighterBase() {
|
|||
|
||||
class LudicSyntaxHighlighterFactory : SyntaxHighlighterFactory() {
|
||||
override fun getSyntaxHighlighter(project: Project?, virtualFile: VirtualFile?): SyntaxHighlighter =
|
||||
LudicSyntaxHighlighter()
|
||||
LudicSyntaxHighlighter(manifest = virtualFile?.name == "package.ludic" || virtualFile?.name == "package.lock.ludic")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package io.ludic.ide
|
|||
|
||||
import com.intellij.psi.tree.IElementType
|
||||
import com.intellij.psi.tree.IFileElementType
|
||||
import com.intellij.psi.tree.TokenSet
|
||||
|
||||
/**
|
||||
* One token type per thing the highlighter needs to tell apart. This is a
|
||||
|
|
@ -16,6 +17,13 @@ object LudicTokens {
|
|||
val COMMENT = LudicTokenType("COMMENT")
|
||||
val STRING = LudicTokenType("STRING")
|
||||
val CHAR = LudicTokenType("CHAR")
|
||||
// `text {hole} text`: the pieces of a template string
|
||||
val TEMPLATE_QUOTE = LudicTokenType("TEMPLATE_QUOTE")
|
||||
val TEMPLATE_TEXT = LudicTokenType("TEMPLATE_TEXT")
|
||||
val TEMPLATE_ESCAPE = LudicTokenType("TEMPLATE_ESCAPE") // {{ }} \n
|
||||
val TEMPLATE_HOLE_START = LudicTokenType("TEMPLATE_HOLE_START")
|
||||
val TEMPLATE_HOLE_END = LudicTokenType("TEMPLATE_HOLE_END")
|
||||
val TEMPLATE_PARTS = TokenSet.create(TEMPLATE_QUOTE, TEMPLATE_TEXT, TEMPLATE_ESCAPE)
|
||||
val NUMBER = LudicTokenType("NUMBER")
|
||||
val IDENTIFIER = LudicTokenType("IDENTIFIER")
|
||||
val TYPE_NAME = LudicTokenType("TYPE_NAME") // Capitalised: component, archetype, scene
|
||||
|
|
@ -27,10 +35,21 @@ object LudicTokens {
|
|||
val BOOLEAN = LudicTokenType("BOOLEAN")
|
||||
val BUILTIN = LudicTokenType("BUILTIN") // runtime surface + compiler intrinsics
|
||||
val ANNOTATION = LudicTokenType("ANNOTATION") // @deterministic
|
||||
val MANIFEST_KEYWORD = LudicTokenType("MANIFEST_KEYWORD") // package.ludic: package, require, app, ...
|
||||
val OPERATOR = LudicTokenType("OPERATOR")
|
||||
val BRACE = LudicTokenType("BRACE")
|
||||
val BRACKET = LudicTokenType("BRACKET")
|
||||
val PAREN = LudicTokenType("PAREN")
|
||||
// Opening and closing delimiters are distinct types: the platform's brace
|
||||
// matching (and Enter's "close the unmatched brace") cannot tell `{` from
|
||||
// `}` by text, only by token type.
|
||||
val LBRACE = LudicTokenType("LBRACE")
|
||||
val RBRACE = LudicTokenType("RBRACE")
|
||||
val LBRACKET = LudicTokenType("LBRACKET")
|
||||
val RBRACKET = LudicTokenType("RBRACKET")
|
||||
val LPAREN = LudicTokenType("LPAREN")
|
||||
val RPAREN = LudicTokenType("RPAREN")
|
||||
val BRACES = TokenSet.create(LBRACE, RBRACE)
|
||||
val BRACKETS = TokenSet.create(LBRACKET, RBRACKET)
|
||||
val PARENS = TokenSet.create(LPAREN, RPAREN)
|
||||
val DELIMITERS = TokenSet.orSet(BRACES, BRACKETS, PARENS)
|
||||
val COMMA = LudicTokenType("COMMA")
|
||||
val BAD = LudicTokenType("BAD_CHARACTER")
|
||||
}
|
||||
|
|
@ -75,5 +94,25 @@ object LudicVocabulary {
|
|||
"win_running", "win_close"
|
||||
)
|
||||
|
||||
/**
|
||||
* Declaration and clause words the compiler does not reserve as names
|
||||
* (selfhost/frontend/parse.ludic is_reserved_word only guards function
|
||||
* names): they are keywords only where a declaration can start.
|
||||
*/
|
||||
val CONTEXTUAL = setOf(
|
||||
"program", "import", "property", "model", "enum", "ui", "namespace", "function", "extern",
|
||||
"handler", "entry", "event", "scene", "test", "phase", "query", "on", "cancellable", "public",
|
||||
"layer", "start"
|
||||
)
|
||||
|
||||
/**
|
||||
* The directives parse_manifest (tools/ludic-cli/pkg.ludic) understands,
|
||||
* and the words of the lock file parse_lock reads.
|
||||
*/
|
||||
val MANIFEST = setOf(
|
||||
"package", "version", "kind", "entry", "provides", "targets", "require", "pack", "app", "script", "hook",
|
||||
"lock", "module", "hash"
|
||||
)
|
||||
|
||||
fun isKeyword(w: String) = w in DECL || w in CLAUSE || w in STMT
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.openapi.command.WriteCommandAction
|
||||
import com.intellij.openapi.editor.ElementColorProvider
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.psi.PsiDocumentManager
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.util.PsiTreeUtil
|
||||
import com.intellij.psi.util.elementType
|
||||
import io.ludic.ide.LudicTokens
|
||||
import java.awt.Color
|
||||
|
||||
/**
|
||||
* Colour swatches in the gutter, with the colour picker behind them, for the
|
||||
* three ways Ludic writes a colour:
|
||||
*
|
||||
* Screen.clear(0x0d1020) a 0xRRGGBB int
|
||||
* Screen.clear(Color.Crimson) a palette name (docs/language/colors/palette.json)
|
||||
* app splash_bg "#0b1622" a CSS-style string (package.ludic)
|
||||
*
|
||||
* Picking a new colour rewrites the literal; a palette name becomes the hex int
|
||||
* it now is.
|
||||
*/
|
||||
class LudicColorPreview : ElementColorProvider {
|
||||
|
||||
override fun getColorFrom(element: PsiElement): Color? {
|
||||
if (element.firstChild != null) return null
|
||||
return when (element.elementType) {
|
||||
LudicTokens.NUMBER -> HEX_INT.matchEntire(element.text)?.let { rgb(it.groupValues[1]) }
|
||||
LudicTokens.STRING -> HEX_STRING.matchEntire(element.text)?.let { rgb(expand(it.groupValues[1])) }
|
||||
LudicTokens.IDENTIFIER, LudicTokens.TYPE_NAME -> paletteName(element)?.let { LudicPalette.lookup(it)?.let(::rgb) }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
override fun setColorTo(element: PsiElement, color: Color) {
|
||||
val file = element.containingFile ?: return
|
||||
val doc = PsiDocumentManager.getInstance(element.project).getDocument(file) ?: return
|
||||
val hex = "%02x%02x%02x".format(color.red, color.green, color.blue)
|
||||
val (range, text) = when (element.elementType) {
|
||||
LudicTokens.NUMBER -> element.textRange to "0x$hex"
|
||||
LudicTokens.STRING -> element.textRange to "\"#$hex\""
|
||||
else -> {
|
||||
// `Color.Crimson` -> `0xrrggbb`
|
||||
val start = paletteReceiver(element)?.textRange?.startOffset ?: return
|
||||
TextRange(start, element.textRange.endOffset) to "0x$hex"
|
||||
}
|
||||
}
|
||||
WriteCommandAction.runWriteCommandAction(element.project, "Change Colour", null, {
|
||||
doc.replaceString(range.startOffset, range.endOffset, text)
|
||||
PsiDocumentManager.getInstance(element.project).commitDocument(doc)
|
||||
}, file)
|
||||
}
|
||||
|
||||
private fun paletteName(element: PsiElement): String? =
|
||||
if (paletteReceiver(element) != null) element.text else null
|
||||
|
||||
/** The `Color` of `Color.Name`, when element is the Name. */
|
||||
private fun paletteReceiver(element: PsiElement): PsiElement? {
|
||||
val dot = prevSignificant(element) ?: return null
|
||||
if (dot.elementType != LudicTokens.OPERATOR || dot.text != ".") return null
|
||||
val recv = prevSignificant(dot) ?: return null
|
||||
return if (recv.text == "Color") recv else null
|
||||
}
|
||||
|
||||
private fun prevSignificant(e: PsiElement): PsiElement? {
|
||||
var p = PsiTreeUtil.prevLeaf(e)
|
||||
while (p != null && p.elementType == TokenType.WHITE_SPACE) p = PsiTreeUtil.prevLeaf(p)
|
||||
return p
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val HEX_INT = Regex("0[xX]([0-9a-fA-F]{6})")
|
||||
val HEX_STRING = Regex("\"#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\"")
|
||||
|
||||
fun expand(h: String) = if (h.length == 3) h.map { "$it$it" }.joinToString("") else h
|
||||
fun rgb(hex: String) = Color(hex.toInt(16))
|
||||
}
|
||||
}
|
||||
|
||||
/** The named colours `Color.*` resolves, from the palette the docs are generated from. */
|
||||
object LudicPalette {
|
||||
private val names: Map<String, String> by lazy { load() }
|
||||
|
||||
fun lookup(name: String): String? = names[name]
|
||||
fun all(): Map<String, String> = names
|
||||
|
||||
private fun load(): Map<String, String> {
|
||||
val text = LudicPalette::class.java.getResourceAsStream("/palette.json")?.bufferedReader()?.readText() ?: return emptyMap()
|
||||
return parse(text)
|
||||
}
|
||||
|
||||
fun parse(json: String): Map<String, String> =
|
||||
Regex("\"name\"\\s*:\\s*\"([^\"]+)\"\\s*,\\s*\"hex\"\\s*:\\s*\"([0-9A-Fa-f]{6})\"")
|
||||
.findAll(json).associate { it.groupValues[1] to it.groupValues[2] }
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.lang.documentation.AbstractDocumentationProvider
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The language reference pages (the docs/language pages) as documentation.
|
||||
*
|
||||
* Go-to-definition on a built-in lands on its page, and the IDE then describes
|
||||
* that target — on Cmd/Ctrl-hover and in Quick Documentation — through
|
||||
* whatever element the language server handed back, which would otherwise read
|
||||
* `LSP Psi Element "---" [type-void.md]`. This renders the page's signature,
|
||||
* summary and body instead.
|
||||
*/
|
||||
class LudicDocPageDocumentation : AbstractDocumentationProvider() {
|
||||
|
||||
override fun getQuickNavigateInfo(element: PsiElement?, originalElement: PsiElement?): String? {
|
||||
val page = pageOf(element) ?: return null
|
||||
val sig = page.meta["sig"]?.takeIf { it.isNotBlank() } ?: page.meta["name"] ?: return null
|
||||
val tip = page.meta["tip"].orEmpty()
|
||||
return "<b>${esc(sig)}</b>" + if (tip.isNotEmpty()) "<br>${esc(tip)}" else ""
|
||||
}
|
||||
|
||||
override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
|
||||
val page = pageOf(element) ?: return null
|
||||
val sig = page.meta["sig"]?.takeIf { it.isNotBlank() } ?: page.meta["name"] ?: page.meta["title"] ?: return null
|
||||
val sb = StringBuilder()
|
||||
sb.append("<div class='definition'><pre>").append(esc(sig)).append("</pre></div><div class='content'>")
|
||||
page.meta["tip"]?.takeIf { it.isNotBlank() }?.let { sb.append("<p><b>").append(esc(it)).append("</b></p>") }
|
||||
sb.append(markdownToHtml(page.body))
|
||||
sb.append("</div>")
|
||||
page.meta["id"]?.let { id ->
|
||||
val url = DOCS_URL + (if (page.file.name == "_section.md") "ns-$id" else id) + ".html"
|
||||
sb.append("<div class='bottom'><a href='").append(url).append("'>").append(url).append("</a></div>")
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
override fun getUrlFor(element: PsiElement?, originalElement: PsiElement?): List<String>? {
|
||||
val page = pageOf(element) ?: return null
|
||||
val id = page.meta["id"] ?: return null
|
||||
return listOf(DOCS_URL + (if (page.file.name == "_section.md") "ns-$id" else id) + ".html")
|
||||
}
|
||||
|
||||
data class Page(val file: File, val meta: Map<String, String>, val body: String)
|
||||
|
||||
companion object {
|
||||
const val DOCS_URL = "https://workshopsoft.pages.workshopsoft.io/ludic/"
|
||||
|
||||
fun pageOf(element: PsiElement?): Page? {
|
||||
val file: PsiFile = element?.containingFile ?: (element as? PsiFile) ?: return null
|
||||
val path = file.virtualFile?.path ?: return null
|
||||
if (!path.contains("/docs/language/") || !path.endsWith(".md")) return null
|
||||
return parse(File(path), file.text)
|
||||
}
|
||||
|
||||
fun parse(file: File, text: String): Page? {
|
||||
if (!text.startsWith("---\n")) return null
|
||||
val end = text.indexOf("\n---", 4)
|
||||
if (end < 0) return null
|
||||
val meta = text.substring(4, end).lines().mapNotNull { line ->
|
||||
val c = line.indexOf(':')
|
||||
if (c <= 0) null else line.substring(0, c).trim() to line.substring(c + 1).trim()
|
||||
}.toMap()
|
||||
val body = text.substring(end + 4).trimStart('-', '\n', '\r')
|
||||
return Page(file, meta, body)
|
||||
}
|
||||
|
||||
private fun esc(s: String) = StringUtil.escapeXmlEntities(s)
|
||||
|
||||
/**
|
||||
* Just enough markdown for these pages: paragraphs, `code`, **bold**,
|
||||
* fenced ```ludic blocks and the inline HTML the pages already use.
|
||||
*/
|
||||
fun markdownToHtml(md: String): String {
|
||||
val out = StringBuilder()
|
||||
val lines = md.lines()
|
||||
var i = 0
|
||||
val para = StringBuilder()
|
||||
fun flush() {
|
||||
if (para.isNotBlank()) out.append("<p>").append(inline(para.toString().trim())).append("</p>")
|
||||
para.setLength(0)
|
||||
}
|
||||
while (i < lines.size) {
|
||||
val line = lines[i]
|
||||
if (line.trimStart().startsWith("```")) {
|
||||
flush()
|
||||
val code = StringBuilder()
|
||||
i++
|
||||
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
|
||||
if (!lines[i].trimStart().startsWith("# doc-check")) code.append(lines[i]).append('\n')
|
||||
i++
|
||||
}
|
||||
out.append("<pre><code>").append(esc(code.toString().trimEnd())).append("</code></pre>")
|
||||
} else if (line.isBlank()) {
|
||||
flush()
|
||||
} else {
|
||||
para.append(line).append(' ')
|
||||
}
|
||||
i++
|
||||
}
|
||||
flush()
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun inline(s: String): String {
|
||||
// the pages mix markdown with a little inline HTML (<code>, <b>);
|
||||
// keep those tags, escape everything else
|
||||
val keep = Regex("</?(code|b|i|em|strong|br)>")
|
||||
val parts = StringBuilder()
|
||||
var last = 0
|
||||
for (m in keep.findAll(s)) {
|
||||
parts.append(mdSpans(esc(s.substring(last, m.range.first))))
|
||||
parts.append(m.value)
|
||||
last = m.range.last + 1
|
||||
}
|
||||
parts.append(mdSpans(esc(s.substring(last))))
|
||||
return parts.toString()
|
||||
}
|
||||
|
||||
private fun mdSpans(s: String): String = s
|
||||
.replace(Regex("`([^`]+)`"), "<code>$1</code>")
|
||||
.replace(Regex("\\*\\*([^*]+)\\*\\*"), "<b>$1</b>")
|
||||
.replace(Regex("\\[([^\\]]+)]\\(([^)]+)\\)"), "<a href='$2'>$1</a>")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.application.options.CodeStyle
|
||||
import com.intellij.codeInsight.editorActions.SimpleTokenSetQuoteHandler
|
||||
import com.intellij.codeInsight.editorActions.TypedHandlerDelegate
|
||||
import com.intellij.lang.Language
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.codeStyle.lineIndent.LineIndentProvider
|
||||
import io.ludic.ide.LudicFileType
|
||||
import io.ludic.ide.LudicLanguage
|
||||
import io.ludic.ide.LudicLexer
|
||||
import io.ludic.ide.LudicTokens
|
||||
|
||||
/**
|
||||
* Where a line's indentation belongs, from bracket nesting alone.
|
||||
*
|
||||
* Every open `{`, `[` or `(` before the offset is a level; a level's body is
|
||||
* indented one unit past the *line* its opener sits on, so
|
||||
*
|
||||
* handler Move phase Update {
|
||||
* for (p) in query [Pos] {
|
||||
* |
|
||||
*
|
||||
* lands where it should however the header lines were themselves indented.
|
||||
* Strings and comments go through the lexer, so a brace inside `"{"` or a
|
||||
* comment does not count. A line that starts with a closer lines up with its
|
||||
* opener's line.
|
||||
*/
|
||||
object LudicIndenter {
|
||||
|
||||
fun indentFor(text: CharSequence, offset: Int, unit: String): String {
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(text, 0, offset.coerceIn(0, text.length), 0)
|
||||
val opens = ArrayDeque<Int>()
|
||||
while (true) {
|
||||
val type = lexer.tokenType ?: break
|
||||
when (type) {
|
||||
LudicTokens.LBRACE, LudicTokens.LBRACKET, LudicTokens.LPAREN -> opens.addLast(lexer.tokenStart)
|
||||
LudicTokens.RBRACE, LudicTokens.RBRACKET, LudicTokens.RPAREN -> opens.removeLastOrNull()
|
||||
}
|
||||
lexer.advance()
|
||||
}
|
||||
val opener = opens.lastOrNull() ?: return ""
|
||||
val base = leadingWhitespace(text, lineStart(text, opener))
|
||||
return if (startsWithCloser(text, offset)) base else base + unit
|
||||
}
|
||||
|
||||
/** Does the text from [offset] to the end of its line begin with a closer? */
|
||||
fun startsWithCloser(text: CharSequence, offset: Int): Boolean {
|
||||
var i = offset
|
||||
while (i < text.length && (text[i] == ' ' || text[i] == '\t')) i++
|
||||
return i < text.length && text[i] in "}])"
|
||||
}
|
||||
|
||||
fun lineStart(text: CharSequence, offset: Int): Int {
|
||||
var i = offset.coerceAtMost(text.length)
|
||||
while (i > 0 && text[i - 1] != '\n') i--
|
||||
return i
|
||||
}
|
||||
|
||||
fun leadingWhitespace(text: CharSequence, from: Int): String {
|
||||
var i = from
|
||||
while (i < text.length && (text[i] == ' ' || text[i] == '\t')) i++
|
||||
return text.subSequence(from, i).toString()
|
||||
}
|
||||
|
||||
fun unit(project: Project): String {
|
||||
val o = CodeStyle.getSettings(project).getIndentOptions(LudicFileType)
|
||||
return if (o.USE_TAB_CHARACTER) "\t" else " ".repeat(o.INDENT_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
/** Enter: the new line starts at the right depth. */
|
||||
class LudicLineIndentProvider : LineIndentProvider {
|
||||
override fun getLineIndent(project: Project, editor: Editor, language: Language?, offset: Int): String? =
|
||||
LudicIndenter.indentFor(editor.document.charsSequence, offset, LudicIndenter.unit(project))
|
||||
|
||||
override fun isSuitableFor(language: Language?) = language == LudicLanguage
|
||||
}
|
||||
|
||||
/**
|
||||
* Typing a closer as the first thing on a line pulls it back to its opener's
|
||||
* depth: ` }` becomes ` }` the moment the brace is typed.
|
||||
*/
|
||||
class LudicCloserTypedHandler : TypedHandlerDelegate() {
|
||||
override fun charTyped(c: Char, project: Project, editor: Editor, file: PsiFile): Result {
|
||||
if (file.fileType != LudicFileType || c !in "}])") return Result.CONTINUE
|
||||
val doc = editor.document
|
||||
val caret = editor.caretModel.offset
|
||||
val closer = caret - 1
|
||||
if (closer < 0) return Result.CONTINUE
|
||||
val text = doc.charsSequence
|
||||
val start = LudicIndenter.lineStart(text, closer)
|
||||
if (text.subSequence(start, closer).isNotBlank()) return Result.CONTINUE
|
||||
val want = LudicIndenter.indentFor(text, closer, LudicIndenter.unit(project))
|
||||
reindent(doc, start, closer, want)
|
||||
return Result.CONTINUE
|
||||
}
|
||||
|
||||
private fun reindent(doc: Document, start: Int, end: Int, want: String) {
|
||||
if (doc.charsSequence.subSequence(start, end).toString() == want) return
|
||||
doc.replaceString(start, end, want)
|
||||
}
|
||||
}
|
||||
|
||||
class LudicQuoteHandler : SimpleTokenSetQuoteHandler(LudicTokens.STRING, LudicTokens.CHAR, LudicTokens.TEMPLATE_QUOTE)
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey
|
||||
import io.ludic.ide.LudicColors
|
||||
|
||||
/**
|
||||
* ludic-lsp's semantic tokens, in the plugin's own colour keys, so every
|
||||
* colour is on Settings | Editor | Color Scheme | Ludic and tells the program's
|
||||
* code, package code and the built-in language apart.
|
||||
*
|
||||
* Keywords, literals, comments and operators return null: the lexer already
|
||||
* colours those (including contextual keywords and manifest directives), and a
|
||||
* semantic colour on top would only flatten them.
|
||||
*/
|
||||
object LudicSemanticColors {
|
||||
fun keyFor(type: String, modifiers: List<String>): TextAttributesKey? {
|
||||
val builtin = "defaultLibrary" in modifiers
|
||||
val pkg = "vendor" in modifiers
|
||||
val decl = "declaration" in modifiers
|
||||
val readonly = "readonly" in modifiers
|
||||
val global = "static" in modifiers
|
||||
return when (type) {
|
||||
"function" -> when {
|
||||
readonly -> LudicColors.HANDLER // ludic-lsp marks handlers readonly
|
||||
builtin -> LudicColors.FUNCTION_BUILTIN
|
||||
pkg -> LudicColors.FUNCTION_PACKAGE
|
||||
decl -> LudicColors.FUNCTION_DECLARATION
|
||||
else -> LudicColors.FUNCTION_CALL
|
||||
}
|
||||
"variable" -> when {
|
||||
readonly && builtin -> LudicColors.CONSTANT_BUILTIN
|
||||
readonly && pkg -> LudicColors.CONSTANT_PACKAGE
|
||||
readonly -> LudicColors.CONSTANT
|
||||
global && pkg -> LudicColors.GLOBAL_VARIABLE_PACKAGE
|
||||
global -> LudicColors.GLOBAL_VARIABLE
|
||||
else -> LudicColors.LOCAL_VARIABLE
|
||||
}
|
||||
"parameter" -> LudicColors.PARAMETER
|
||||
"property" -> LudicColors.FIELD
|
||||
"struct" -> if (pkg) LudicColors.TYPE_PACKAGE else LudicColors.PROPERTY_TYPE
|
||||
"class" -> when {
|
||||
builtin -> LudicColors.KEYWORD // ui widgets: panel, button, …
|
||||
pkg -> LudicColors.TYPE_PACKAGE
|
||||
else -> LudicColors.MODEL_TYPE
|
||||
}
|
||||
"namespace" -> if (builtin) LudicColors.NAMESPACE_BUILTIN else LudicColors.NAMESPACE
|
||||
"enumMember" -> if (readonly && builtin) LudicColors.PHASE else LudicColors.CONSTANT_BUILTIN
|
||||
"decorator" -> LudicColors.ANNOTATION
|
||||
"type" -> if (builtin) null else LudicColors.TYPE_NAME
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.application.options.CodeStyleAbstractConfigurable
|
||||
import com.intellij.application.options.CodeStyleAbstractPanel
|
||||
import com.intellij.application.options.IndentOptionsEditor
|
||||
import com.intellij.application.options.SmartIndentOptionsEditor
|
||||
import com.intellij.application.options.TabbedLanguageCodeStylePanel
|
||||
import com.intellij.lang.Language
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighter
|
||||
import com.intellij.openapi.options.colors.AttributesDescriptor
|
||||
import com.intellij.openapi.options.colors.ColorDescriptor
|
||||
import com.intellij.openapi.options.colors.ColorSettingsPage
|
||||
import com.intellij.psi.codeStyle.CodeStyleConfigurable
|
||||
import com.intellij.psi.codeStyle.CodeStyleSettings
|
||||
import com.intellij.psi.codeStyle.CommonCodeStyleSettings
|
||||
import com.intellij.psi.codeStyle.LanguageCodeStyleSettingsProvider
|
||||
import io.ludic.ide.LudicColors
|
||||
import io.ludic.ide.LudicIcons
|
||||
import io.ludic.ide.LudicLanguage
|
||||
import io.ludic.ide.LudicSyntaxHighlighter
|
||||
import javax.swing.Icon
|
||||
|
||||
private val SAMPLE = """
|
||||
# Bouncing balls: properties, a handler per phase, and a test.
|
||||
program Demo {
|
||||
import "ludic.render3d/r3d.ludic"
|
||||
|
||||
property Pos { x: int = 0, y: int = 0 }
|
||||
model Score { value: int, best: int }
|
||||
enum Mode { Title, Playing }
|
||||
|
||||
const SPEED: int = 2
|
||||
var g_mode: Mode = Mode.Title
|
||||
|
||||
@deterministic
|
||||
handler Move phase FixedUpdate {
|
||||
for (p) in query [Pos] where p.x < 304 {
|
||||
p.x = p.x + SPEED
|
||||
if p.x > 0x130 { despawn(self) } else { print("moving") }
|
||||
}
|
||||
}
|
||||
|
||||
function half(f: fixed) -> fixed { return f / 2 }
|
||||
|
||||
test "half of one" {
|
||||
expect_near(half(fixed(1)), 32768, 0)
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
// the sample with semantic tags, as the server would colour it
|
||||
private val DEMO = """
|
||||
# Bouncing balls: properties, a handler per phase, and a test.
|
||||
program Demo {
|
||||
import "ludic.render3d/r3d.ludic"
|
||||
|
||||
property <prop>Pos</prop> { <field>x</field>: int = 0, <field>y</field>: int = 0 }
|
||||
model <model>Score</model> { <field>value</field>: int, <field>best</field>: int }
|
||||
|
||||
const <const>SPEED</const>: int = 2
|
||||
var <global>g_model</global>: int = 0
|
||||
|
||||
@deterministic
|
||||
handler <handler>Move</handler> phase FixedUpdate {
|
||||
for (<local>p</local>) in query [<prop>Pos</prop>] where <local>p</local>.<field>x</field> < 304 {
|
||||
let <local>model</local> = <fpkg>f_clamp</fpkg>(<local>p</local>.<field>x</field>, 0, 0x130)
|
||||
<local>p</local>.<field>x</field> = <fcall>half</fcall>(<local>model</local>) + <const>SPEED</const>
|
||||
<fbuiltin>print</fbuiltin>(`x is {<local>p</local>.<field>x</field>} of {{304}}`)
|
||||
<fbuiltin>print</fbuiltin>(`x is {<local>p</local>.<field>x</field>} of {{304}}\n`)
|
||||
<ns>Screen</ns>.<fbuiltin>clear</fbuiltin>(<ns>Color</ns>.<cbuiltin>Crimson</cbuiltin>)
|
||||
}
|
||||
}
|
||||
|
||||
function <fdecl>half</fdecl>(<param>f</param>: fixed) -> fixed { return <param>f</param> / 2 }
|
||||
|
||||
test "half of one" {
|
||||
<fbuiltin>expect_near</fbuiltin>(<fcall>half</fcall>(<fbuiltin>fixed</fbuiltin>(1)), 32768, 0)
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
/** Settings | Editor | Color Scheme | Ludic */
|
||||
class LudicColorSettingsPage : ColorSettingsPage {
|
||||
override fun getIcon(): Icon = LudicIcons.FILE
|
||||
override fun getHighlighter(): SyntaxHighlighter = LudicSyntaxHighlighter()
|
||||
override fun getDemoText() = DEMO
|
||||
override fun getAdditionalHighlightingTagToDescriptorMap(): Map<String, TextAttributesKey> = mapOf(
|
||||
"fdecl" to LudicColors.FUNCTION_DECLARATION,
|
||||
"fcall" to LudicColors.FUNCTION_CALL,
|
||||
"fpkg" to LudicColors.FUNCTION_PACKAGE,
|
||||
"fbuiltin" to LudicColors.FUNCTION_BUILTIN,
|
||||
"handler" to LudicColors.HANDLER,
|
||||
"const" to LudicColors.CONSTANT,
|
||||
"cbuiltin" to LudicColors.CONSTANT_BUILTIN,
|
||||
"global" to LudicColors.GLOBAL_VARIABLE,
|
||||
"local" to LudicColors.LOCAL_VARIABLE,
|
||||
"param" to LudicColors.PARAMETER,
|
||||
"field" to LudicColors.FIELD,
|
||||
"prop" to LudicColors.PROPERTY_TYPE,
|
||||
"model" to LudicColors.MODEL_TYPE,
|
||||
"ns" to LudicColors.NAMESPACE_BUILTIN,
|
||||
)
|
||||
override fun getColorDescriptors(): Array<ColorDescriptor> = ColorDescriptor.EMPTY_ARRAY
|
||||
override fun getDisplayName() = "Ludic"
|
||||
override fun getAttributeDescriptors(): Array<AttributesDescriptor> = arrayOf(
|
||||
AttributesDescriptor("Keywords//Declaration (program, property, handler, function…)", LudicColors.DECL_KEYWORD),
|
||||
AttributesDescriptor("Keywords//Statement (if, for, spawn, return…)", LudicColors.KEYWORD),
|
||||
AttributesDescriptor("Keywords//Clause (phase, query, on…)", LudicColors.CLAUSE),
|
||||
AttributesDescriptor("Keywords//Boolean", LudicColors.BOOLEAN),
|
||||
AttributesDescriptor("Types//Built-in type (int, fixed, entity…)", LudicColors.PRIMITIVE),
|
||||
AttributesDescriptor("Types//Type or namespace name", LudicColors.TYPE_NAME),
|
||||
AttributesDescriptor("Phase (Start, Update, Render…)", LudicColors.PHASE),
|
||||
AttributesDescriptor("Built-in function", LudicColors.BUILTIN),
|
||||
AttributesDescriptor("Annotation", LudicColors.ANNOTATION),
|
||||
AttributesDescriptor("Identifier", LudicColors.IDENTIFIER),
|
||||
AttributesDescriptor("Literals//String and char", LudicColors.STRING),
|
||||
AttributesDescriptor("Literals//Number", LudicColors.NUMBER),
|
||||
AttributesDescriptor("Literals//Escape ({{, }}, \\n)", LudicColors.STRING_ESCAPE),
|
||||
AttributesDescriptor("Literals//Template hole braces", LudicColors.TEMPLATE_BRACES),
|
||||
AttributesDescriptor("Comment", LudicColors.COMMENT),
|
||||
AttributesDescriptor("Braces and Operators//Operator", LudicColors.OPERATOR),
|
||||
AttributesDescriptor("Braces and Operators//Braces", LudicColors.BRACE),
|
||||
AttributesDescriptor("Braces and Operators//Brackets", LudicColors.BRACKET),
|
||||
AttributesDescriptor("Braces and Operators//Parentheses", LudicColors.PAREN),
|
||||
AttributesDescriptor("Braces and Operators//Comma", LudicColors.COMMA),
|
||||
AttributesDescriptor("Bad character", LudicColors.BAD),
|
||||
AttributesDescriptor("Manifest directive (package.ludic)", LudicColors.MANIFEST),
|
||||
AttributesDescriptor("Functions//Declaration", LudicColors.FUNCTION_DECLARATION),
|
||||
AttributesDescriptor("Functions//Call (this program)", LudicColors.FUNCTION_CALL),
|
||||
AttributesDescriptor("Functions//Call (package)", LudicColors.FUNCTION_PACKAGE),
|
||||
AttributesDescriptor("Functions//Call (built-in)", LudicColors.FUNCTION_BUILTIN),
|
||||
AttributesDescriptor("Functions//Handler", LudicColors.HANDLER),
|
||||
AttributesDescriptor("Constants//This program", LudicColors.CONSTANT),
|
||||
AttributesDescriptor("Constants//Package", LudicColors.CONSTANT_PACKAGE),
|
||||
AttributesDescriptor("Constants//Built-in (Color.Gold, …)", LudicColors.CONSTANT_BUILTIN),
|
||||
AttributesDescriptor("Variables//Global (this program)", LudicColors.GLOBAL_VARIABLE),
|
||||
AttributesDescriptor("Variables//Global (package)", LudicColors.GLOBAL_VARIABLE_PACKAGE),
|
||||
AttributesDescriptor("Variables//Local", LudicColors.LOCAL_VARIABLE),
|
||||
AttributesDescriptor("Variables//Parameter", LudicColors.PARAMETER),
|
||||
AttributesDescriptor("Variables//Field", LudicColors.FIELD),
|
||||
AttributesDescriptor("Types//Property (component)", LudicColors.PROPERTY_TYPE),
|
||||
AttributesDescriptor("Types//Model", LudicColors.MODEL_TYPE),
|
||||
AttributesDescriptor("Types//From a package", LudicColors.TYPE_PACKAGE),
|
||||
AttributesDescriptor("Types//Namespace", LudicColors.NAMESPACE),
|
||||
AttributesDescriptor("Types//Built-in namespace (Screen, Math, …)", LudicColors.NAMESPACE_BUILTIN),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings | Editor | Code Style | Ludic. Two-space indents, spaces not tabs:
|
||||
* the house style `ludic fmt` produces. Enter, closing braces and Reformat all
|
||||
* read the indent from here.
|
||||
*/
|
||||
class LudicCodeStyleSettingsProvider : LanguageCodeStyleSettingsProvider() {
|
||||
override fun getLanguage(): Language = LudicLanguage
|
||||
|
||||
override fun getCodeSample(settingsType: SettingsType) = SAMPLE
|
||||
|
||||
override fun getIndentOptionsEditor(): IndentOptionsEditor = SmartIndentOptionsEditor()
|
||||
|
||||
override fun customizeDefaults(common: CommonCodeStyleSettings, indentOptions: CommonCodeStyleSettings.IndentOptions) {
|
||||
indentOptions.INDENT_SIZE = 2
|
||||
indentOptions.CONTINUATION_INDENT_SIZE = 2
|
||||
indentOptions.TAB_SIZE = 2
|
||||
indentOptions.USE_TAB_CHARACTER = false
|
||||
common.LINE_COMMENT_AT_FIRST_COLUMN = false
|
||||
common.LINE_COMMENT_ADD_SPACE = true
|
||||
}
|
||||
|
||||
override fun createConfigurable(baseSettings: CodeStyleSettings, modelSettings: CodeStyleSettings): CodeStyleConfigurable =
|
||||
object : CodeStyleAbstractConfigurable(baseSettings, modelSettings, "Ludic") {
|
||||
override fun createPanel(settings: CodeStyleSettings): CodeStyleAbstractPanel =
|
||||
object : TabbedLanguageCodeStylePanel(LudicLanguage, currentSettings, settings) {
|
||||
override fun initTabs(settings: CodeStyleSettings) {
|
||||
addIndentOptionsTab(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getConfigurableDisplayName() = "Ludic"
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.lang.documentation.AbstractDocumentationProvider
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.psi.PsiDocumentManager
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.redhat.devtools.lsp4ij.LanguageServerManager
|
||||
import io.ludic.ide.LudicLanguage
|
||||
import org.eclipse.lsp4j.HoverParams
|
||||
import org.eclipse.lsp4j.Position
|
||||
import org.eclipse.lsp4j.TextDocumentIdentifier
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* What the IDE says about a declaration the language server navigated to.
|
||||
*
|
||||
* Go-to-definition hands the IDE a synthetic element for the target, which it
|
||||
* would describe on Cmd/Ctrl-hover as `LSP Symbol "dists" [valley.ludic]`.
|
||||
* This asks ludic-lsp for its hover at that spot instead, so the tooltip is
|
||||
* the declaration and its type: `let dists: words`.
|
||||
*/
|
||||
class LudicTargetDocumentation : AbstractDocumentationProvider() {
|
||||
|
||||
override fun getQuickNavigateInfo(element: PsiElement?, originalElement: PsiElement?): String? {
|
||||
val md = hoverAt(element) ?: return null
|
||||
val code = codeOf(md) ?: return null
|
||||
val where = element?.containingFile?.name?.let { " <span class='grayed'>$it</span>" } ?: ""
|
||||
return "<b>${StringUtil.escapeXmlEntities(code)}</b>$where"
|
||||
}
|
||||
|
||||
override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
|
||||
val md = hoverAt(element) ?: return null
|
||||
val code = codeOf(md)
|
||||
val rest = md.substringAfter("```", "").substringAfter("```", md).trim()
|
||||
val sb = StringBuilder()
|
||||
if (code != null) sb.append("<div class='definition'><pre>").append(StringUtil.escapeXmlEntities(code)).append("</pre></div>")
|
||||
if (rest.isNotEmpty()) sb.append("<div class='content'>").append(LudicDocPageDocumentation.markdownToHtml(rest)).append("</div>")
|
||||
return sb.toString().ifEmpty { null }
|
||||
}
|
||||
|
||||
private fun codeOf(md: String): String? {
|
||||
val m = Regex("```ludic\\n([\\s\\S]*?)\\n```").find(md) ?: return null
|
||||
return m.groupValues[1].trim()
|
||||
}
|
||||
|
||||
/** ludic-lsp's hover markdown at the element's start, or null. */
|
||||
private fun hoverAt(element: PsiElement?): String? {
|
||||
if (element == null || !element.javaClass.name.startsWith("com.redhat.devtools.lsp4ij")) return null
|
||||
val file = element.containingFile ?: return null
|
||||
if (file.language != LudicLanguage) return null
|
||||
val vf = file.virtualFile ?: return null
|
||||
val doc = PsiDocumentManager.getInstance(file.project).getDocument(file) ?: return null
|
||||
val offset = element.textOffset.coerceIn(0, doc.textLength)
|
||||
val line = doc.getLineNumber(offset)
|
||||
val pos = Position(line, offset - doc.getLineStartOffset(line))
|
||||
return runCatching {
|
||||
val item = LanguageServerManager.getInstance(file.project).getLanguageServer("ludic").get(500, TimeUnit.MILLISECONDS)
|
||||
?: return null
|
||||
val params = HoverParams(TextDocumentIdentifier(vf.toNioPath().toUri().toString()), pos)
|
||||
val hover = item.server.textDocumentService.hover(params).get(800, TimeUnit.MILLISECONDS) ?: return null
|
||||
val contents = hover.contents
|
||||
if (contents.isRight) contents.right.value else null
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.codeInsight.template.TemplateActionContext
|
||||
import com.intellij.codeInsight.template.TemplateContextType
|
||||
import com.intellij.ide.actions.CreateFileFromTemplateAction
|
||||
import com.intellij.ide.actions.CreateFileFromTemplateDialog
|
||||
import com.intellij.ide.fileTemplates.FileTemplate
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.psi.PsiDirectory
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.util.PsiUtilCore
|
||||
import io.ludic.ide.LudicIcons
|
||||
import io.ludic.ide.LudicLanguage
|
||||
import io.ludic.ide.LudicTokens
|
||||
|
||||
/** Live templates (`prog`, `test`, `handler`, `fn`, …) apply in Ludic code, not inside strings or comments. */
|
||||
class LudicTemplateContext : TemplateContextType("Ludic") {
|
||||
override fun isInContext(ctx: TemplateActionContext): Boolean {
|
||||
val file = ctx.file
|
||||
if (!PsiUtilCore.getLanguageAtOffset(file, ctx.startOffset).isKindOf(LudicLanguage)) return false
|
||||
val at = file.findElementAt((ctx.startOffset - 1).coerceAtLeast(0)) ?: return true
|
||||
val type = at.node.elementType
|
||||
return type != LudicTokens.STRING && type != LudicTokens.COMMENT && type != LudicTokens.CHAR
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File | New | Ludic File. The kinds mirror what a project holds: a program
|
||||
* (the entry), a module imported by one, and a test program for tests/.
|
||||
*/
|
||||
class CreateLudicFileAction : CreateFileFromTemplateAction(), DumbAware {
|
||||
|
||||
override fun buildDialog(project: Project, directory: PsiDirectory, builder: CreateFileFromTemplateDialog.Builder) {
|
||||
builder.setTitle("New Ludic File")
|
||||
.addKind("Program", LudicIcons.FILE, PROGRAM)
|
||||
.addKind("Module", LudicIcons.FILE, MODULE)
|
||||
.addKind("Test", LudicIcons.FILE, TEST)
|
||||
.addKind("Empty file", LudicIcons.FILE, EMPTY)
|
||||
}
|
||||
|
||||
override fun getActionName(directory: PsiDirectory, newName: String, templateName: String) = "Create Ludic file $newName"
|
||||
|
||||
override fun createFileFromTemplate(name: String, template: FileTemplate, dir: PsiDirectory): PsiFile? {
|
||||
val base = name.removeSuffix(".ludic")
|
||||
return createFileFromTemplate(base, template, dir, null, true, emptyMap(), mapOf("PROGRAM_NAME" to programName(base)))
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PROGRAM = "Ludic Program"
|
||||
const val MODULE = "Ludic Module"
|
||||
const val TEST = "Ludic Test"
|
||||
const val EMPTY = "Ludic File"
|
||||
|
||||
/** `enemy_ai` -> `EnemyAi`: a file name as a program name. */
|
||||
fun programName(file: String): String {
|
||||
val parts = file.split('_', '-', '.', ' ').filter { it.isNotEmpty() }
|
||||
val joined = parts.joinToString("") { p -> p.replaceFirstChar { it.uppercaseChar() } }
|
||||
val clean = joined.filter { it.isLetterOrDigit() }
|
||||
return if (clean.isEmpty() || clean[0].isDigit()) "P$clean" else clean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
package io.ludic.ide.project
|
||||
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.psi.PsiFile
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* `package.ludic`, read the way `parse_manifest` (tools/ludic-cli/pkg.ludic)
|
||||
* reads it: one directive per line, `#` comments, double-quoted strings.
|
||||
*/
|
||||
data class LudicManifest(
|
||||
val module: String?,
|
||||
val version: String?,
|
||||
val kind: String?,
|
||||
val requires: List<Require>,
|
||||
val entry: String? = null,
|
||||
val app: Map<String, String> = emptyMap(),
|
||||
val packs: List<String> = emptyList(),
|
||||
val scripts: List<Script> = emptyList(),
|
||||
val hooks: List<Hook> = emptyList(),
|
||||
) {
|
||||
data class Script(val name: String, val command: String, val line: Int)
|
||||
data class Hook(val phase: String, val event: String, val command: String, val line: Int)
|
||||
|
||||
data class Require(val module: String, val version: String, val line: Int)
|
||||
|
||||
/** One `module "…" version "…" hash "…" kind "…" provides "…"` line of the lock. */
|
||||
data class Locked(val module: String, val version: String, val hash: String, val kind: String, val provides: List<String>, val line: Int)
|
||||
|
||||
companion object {
|
||||
const val FILE_NAME = "package.ludic"
|
||||
const val LOCK_NAME = "package.lock.ludic"
|
||||
|
||||
fun isManifest(file: VirtualFile?) = file?.name == FILE_NAME
|
||||
fun isManifest(file: PsiFile?) = file?.name == FILE_NAME
|
||||
|
||||
fun parse(text: CharSequence): LudicManifest {
|
||||
var module: String? = null
|
||||
var version: String? = null
|
||||
var kind: String? = null
|
||||
var entry: String? = null
|
||||
val requires = mutableListOf<Require>()
|
||||
val app = linkedMapOf<String, String>()
|
||||
val packs = mutableListOf<String>()
|
||||
val scripts = mutableListOf<Script>()
|
||||
val hooks = mutableListOf<Hook>()
|
||||
text.lines().forEachIndexed { i, raw ->
|
||||
val words = words(raw)
|
||||
when (words.firstOrNull()) {
|
||||
"package" -> module = words.getOrNull(1)
|
||||
"version" -> version = words.getOrNull(1)
|
||||
"kind" -> kind = words.getOrNull(1)
|
||||
"entry" -> entry = words.getOrNull(1)
|
||||
"require" -> if (words.size > 2) requires += Require(words[1], words[2], i)
|
||||
"pack" -> packs += words.drop(1)
|
||||
"app" -> if (words.size > 2) app[words[1]] = words[2]
|
||||
"script" -> if (words.size > 2) scripts += Script(words[1], words[2], i)
|
||||
"hook" -> if (words.size > 3) hooks += Hook(words[1], words[2], words[3], i)
|
||||
}
|
||||
}
|
||||
return LudicManifest(module, version, kind, requires, entry, app, packs, scripts, hooks)
|
||||
}
|
||||
|
||||
fun parseLock(text: CharSequence): List<Locked> = text.lines().mapIndexedNotNull { i, raw ->
|
||||
val w = words(raw)
|
||||
if (w.size < 2 || w[0] != "module") return@mapIndexedNotNull null
|
||||
val kv = mutableMapOf<String, String>()
|
||||
val provides = mutableListOf<String>()
|
||||
var k = 2
|
||||
while (k + 1 < w.size) {
|
||||
if (w[k] == "provides") provides += w[k + 1] else kv[w[k]] = w[k + 1]
|
||||
k += 2
|
||||
}
|
||||
Locked(w[1], kv["version"].orEmpty(), kv["hash"].orEmpty(), kv["kind"] ?: "source", provides, i)
|
||||
}
|
||||
|
||||
fun isLock(file: VirtualFile?) = file?.name == LOCK_NAME
|
||||
fun isLock(file: PsiFile?) = file?.name == LOCK_NAME
|
||||
|
||||
/** Directives whose values are paths, relative to the package root. */
|
||||
val PATH_DIRECTIVES = setOf("entry", "pack")
|
||||
val PATH_APP_KEYS = setOf("icon", "splash", "native", "out")
|
||||
|
||||
/** The words of a manifest line: bare words and quoted strings, comment dropped. */
|
||||
fun words(line: String): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
var i = 0
|
||||
while (i < line.length) {
|
||||
val c = line[i]
|
||||
when {
|
||||
c == '#' -> return out
|
||||
c.isWhitespace() -> i++
|
||||
c == '"' -> {
|
||||
// a quoted word, with \" and \\ escapes as the CLI reads them
|
||||
val sb = StringBuilder()
|
||||
i++
|
||||
while (i < line.length && line[i] != '"') {
|
||||
if (line[i] == '\\' && i + 1 < line.length) { sb.append(line[i + 1]); i += 2 } else { sb.append(line[i]); i++ }
|
||||
}
|
||||
out += sb.toString()
|
||||
i++
|
||||
}
|
||||
else -> {
|
||||
val s = i
|
||||
while (i < line.length && !line[i].isWhitespace() && line[i] != '#') i++
|
||||
out += line.substring(s, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the dependencies need fetching, or null when they look current:
|
||||
* no lock yet, a manifest edited after the lock, or nothing linked.
|
||||
*/
|
||||
fun staleReason(dir: File, manifest: LudicManifest): String? {
|
||||
if (manifest.requires.isEmpty()) return null
|
||||
val man = File(dir, FILE_NAME)
|
||||
val lock = File(dir, LOCK_NAME)
|
||||
if (!lock.isFile) return "There is no $LOCK_NAME yet."
|
||||
if (man.lastModified() > lock.lastModified()) return "$FILE_NAME changed after the lock was written."
|
||||
val modules = File(dir, System.getenv("LUDIC_MODULES") ?: "ludic_modules")
|
||||
if (!modules.isDirectory) return "Nothing is linked into ${modules.name}/ yet."
|
||||
return null
|
||||
}
|
||||
|
||||
/** Directive -> what it does, for completion and quick documentation. */
|
||||
val DIRECTIVES = linkedMapOf(
|
||||
"package" to "This package's import path, e.g. \"git.example.com/me/pkg\". Optional for a game.",
|
||||
"version" to "The version this checkout publishes (a git tag vX.Y.Z publishes it).",
|
||||
"kind" to "source (compiled with the game) or prebuilt (ships a dylib per target).",
|
||||
"entry" to "The program `ludic run`, `build` and `bundle` compile, e.g. \"src/game.ludic\".",
|
||||
"provides" to "The Foo.* namespace(s) this package registers. Repeatable.",
|
||||
"targets" to "For a prebuilt package: the targets it ships, e.g. \"native-arm64\" \"wasm32\".",
|
||||
"require" to "A dependency and its minimum version: require \"host/user/pkg\" \"1.0.0\".",
|
||||
"pack" to "An asset root `ludic pack` walks into the .lpak. Repeatable, in order.",
|
||||
"app" to "App bundle metadata for `ludic bundle`: app <key> \"value\".",
|
||||
"script" to "A named command: script \"dev\" \"ludic run --headless\". Run it with `ludic dev` or `ludic script dev`; extra arguments are passed on.",
|
||||
"hook" to "A command around another: hook before|after <command> \"…\". A failing before hook stops the command; after hooks run only when it succeeded.",
|
||||
)
|
||||
|
||||
val HOOK_PHASES = linkedMapOf(
|
||||
"before" to "Runs first; a non-zero exit stops the command.",
|
||||
"after" to "Runs once the command succeeded.",
|
||||
)
|
||||
|
||||
/** The built-in commands a hook can wrap (tools/ludic-cli/scripts.ludic is_hookable). */
|
||||
val HOOK_EVENTS = linkedMapOf(
|
||||
"build" to "ludic build", "run" to "ludic run", "test" to "ludic test", "bundle" to "ludic bundle",
|
||||
"pack" to "ludic pack", "clean" to "ludic clean", "fmt" to "ludic fmt", "get" to "ludic get",
|
||||
"add" to "ludic add", "update" to "ludic update", "verify" to "ludic verify", "vendor" to "ludic vendor",
|
||||
"assets" to "ludic assets", "build-lib" to "ludic build-lib",
|
||||
)
|
||||
|
||||
/** Commands a script name would be shadowed by (the CLI's own). */
|
||||
val BUILTIN_COMMANDS = setOf(
|
||||
"new", "build", "run", "test", "clean", "fmt", "lsp", "doctor", "upgrade", "script", "scripts", "add", "get",
|
||||
"update", "verify", "vendor", "assets", "pack", "bundle", "build-lib", "link-flags", "version", "help", "dev",
|
||||
)
|
||||
|
||||
val KINDS = linkedMapOf(
|
||||
"source" to "Compiled from source together with the program that imports it.",
|
||||
"prebuilt" to "Ships a compiled library per target (see targets); built with `ludic build-lib`.",
|
||||
)
|
||||
|
||||
/** What each lock-file word means. */
|
||||
val LOCK_WORDS = linkedMapOf(
|
||||
"lock" to "The lock format version.",
|
||||
"module" to "A resolved package: its import path, then the pinned facts about it.",
|
||||
"version" to "The version Minimum Version Selection picked.",
|
||||
"hash" to "The content hash of the package in the store; `ludic verify` re-checks it.",
|
||||
"kind" to "source or prebuilt, as the package declares.",
|
||||
"provides" to "A Foo.* namespace the package registers.",
|
||||
)
|
||||
|
||||
fun isSemver(v: String) = Regex("\\d+\\.\\d+\\.\\d+([-+][0-9A-Za-z.-]+)?").matches(v)
|
||||
|
||||
/** The keys `ludic bundle` reads from `app` lines (tools/ludic-cli/bundle.ludic). */
|
||||
val APP_KEYS = linkedMapOf(
|
||||
"name" to "The app's display name.",
|
||||
"id" to "The bundle identifier, e.g. io.example.game.",
|
||||
"version" to "The bundle version (defaults to the package version).",
|
||||
"icon" to "A square PNG the .icns is built from.",
|
||||
"splash" to "An image shown while the game loads; it must be inside a packed asset root.",
|
||||
"splash_bg" to "The splash background colour, \"#rrggbb\".",
|
||||
"category" to "The macOS app category, e.g. public.app-category.games.",
|
||||
"copyright" to "The copyright line shown in Finder.",
|
||||
"min_macos" to "The minimum macOS version, e.g. \"11.0\".",
|
||||
"native" to "A directory of native libraries copied beside the executable.",
|
||||
"out" to "Where the .app is written (default build/<name>.app).",
|
||||
"sign" to "The codesign identity (default \"-\": ad-hoc signing).",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,390 @@
|
|||
package io.ludic.ide.project
|
||||
|
||||
import com.intellij.codeInsight.completion.CompletionContributor
|
||||
import com.intellij.codeInsight.completion.CompletionParameters
|
||||
import com.intellij.codeInsight.completion.CompletionProvider
|
||||
import com.intellij.codeInsight.completion.CompletionResultSet
|
||||
import com.intellij.codeInsight.completion.CompletionType
|
||||
import com.intellij.codeInsight.lookup.LookupElementBuilder
|
||||
import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandler
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.lang.annotation.AnnotationHolder
|
||||
import com.intellij.lang.annotation.Annotator
|
||||
import com.intellij.lang.annotation.HighlightSeverity
|
||||
import com.intellij.lang.documentation.AbstractDocumentationProvider
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.patterns.PlatformPatterns
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.PsiManager
|
||||
import com.intellij.psi.util.elementType
|
||||
import com.intellij.util.ProcessingContext
|
||||
import io.ludic.ide.LudicLanguage
|
||||
import io.ludic.ide.LudicTokens
|
||||
import java.io.File
|
||||
|
||||
private fun isPackageFile(file: PsiFile?) = LudicManifest.isManifest(file) || LudicManifest.isLock(file)
|
||||
|
||||
private fun unquote(s: String) = s.removePrefix("\"").removeSuffix("\"")
|
||||
|
||||
/**
|
||||
* What a string on a manifest line points at: a file or directory for path
|
||||
* values (`entry`, `pack`, `app icon|splash|native|out`), a package directory
|
||||
* for `require` / lock `module` lines. Null for anything else.
|
||||
*/
|
||||
internal fun manifestTarget(element: PsiElement): VirtualFile? = manifestTargetSpec(element)?.let { spec ->
|
||||
when (spec) {
|
||||
is TargetSpec.Path -> spec.relative?.let { rel -> spec.base?.findFileByRelativePath(rel) }
|
||||
?: spec.absolute?.let { LocalFileSystem.getInstance().findFileByIoFile(it) }
|
||||
is TargetSpec.Package -> spec.dir?.let { LocalFileSystem.getInstance().findFileByIoFile(it) }
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TargetSpec {
|
||||
class Path(val base: VirtualFile?, val relative: String?, val absolute: File?) : TargetSpec()
|
||||
class Package(val dir: File?) : TargetSpec()
|
||||
}
|
||||
|
||||
private fun manifestTargetSpec(element: PsiElement): TargetSpec? {
|
||||
if (element.elementType != LudicTokens.STRING) return null
|
||||
val file = element.containingFile ?: return null
|
||||
if (!isPackageFile(file)) return null
|
||||
val line = ManifestLine.at(element) ?: return null
|
||||
val value = unquote(element.text)
|
||||
val words = line.words
|
||||
val isPath = when (line.directive) {
|
||||
in LudicManifest.PATH_DIRECTIVES -> true
|
||||
"app" -> words.getOrNull(1) in LudicManifest.PATH_APP_KEYS && words.getOrNull(2) == value
|
||||
else -> false
|
||||
}
|
||||
if (isPath && value.isNotBlank()) {
|
||||
return if (File(value).isAbsolute) TargetSpec.Path(null, null, File(value))
|
||||
else TargetSpec.Path(file.originalFile.virtualFile?.parent, value, null)
|
||||
}
|
||||
if ((line.directive == "require" || line.directive == "module") && words.getOrNull(1) == value) {
|
||||
return TargetSpec.Package(packageDir(file, value))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Where a required package is on disk: ludic_modules/, vendor/, or the toolchain. */
|
||||
internal fun packageDir(file: PsiFile, module: String): File? {
|
||||
val dir = file.virtualFile?.parent?.path ?: return null
|
||||
val modules = System.getenv("LUDIC_MODULES") ?: "ludic_modules"
|
||||
return listOfNotNull(
|
||||
File(File(dir, modules), module),
|
||||
File(File(dir, "vendor"), module),
|
||||
io.ludic.ide.LudicToolchain.home(file.project)?.let { File(File(it, "packages"), module) },
|
||||
).firstOrNull { it.exists() }
|
||||
}
|
||||
|
||||
/** Cmd/Ctrl-click on a manifest path or package opens it. */
|
||||
class LudicManifestGotoHandler : GotoDeclarationHandler {
|
||||
override fun getGotoDeclarationTargets(sourceElement: PsiElement?, offset: Int, editor: Editor?): Array<PsiElement>? {
|
||||
val el = sourceElement ?: return null
|
||||
val vf = manifestTarget(el) ?: return null
|
||||
val psi = PsiManager.getInstance(el.project)
|
||||
var node = vf
|
||||
// a package directory opens on its manifest, where its story starts
|
||||
if (vf.isDirectory) vf.findChild(LudicManifest.FILE_NAME)?.let { node = it }
|
||||
val found: PsiElement? = if (node.isDirectory) psi.findDirectory(node) else psi.findFile(node)
|
||||
return found?.let { arrayOf(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private val WORD = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||
private val WORD_OR_DASH = Regex("[A-Za-z_][A-Za-z0-9_-]*")
|
||||
|
||||
/** Mistakes the CLI would otherwise report late, or silently ignore. */
|
||||
class LudicManifestAnnotator : Annotator {
|
||||
override fun annotate(element: PsiElement, holder: AnnotationHolder) {
|
||||
val file = element.containingFile ?: return
|
||||
if (!isPackageFile(file) || element.firstChild != null) return
|
||||
val lock = LudicManifest.isLock(file)
|
||||
val line = ManifestLine.at(element) ?: return
|
||||
when {
|
||||
element.elementType == LudicTokens.STRING -> checkString(element, line, file, lock, holder)
|
||||
line.startsWith(element) && WORD.matches(element.text) -> {
|
||||
val known = if (lock) LudicManifest.LOCK_WORDS.keys else LudicManifest.DIRECTIVES.keys
|
||||
if (element.text !in known) {
|
||||
holder.newAnnotation(HighlightSeverity.WARNING, "Unknown directive '${element.text}': ludic ignores this line")
|
||||
.range(element).create()
|
||||
}
|
||||
}
|
||||
!lock && line.directive == "hook" && !line.startsWith(element) && WORD_OR_DASH.matches(element.text) -> {
|
||||
val w = line.words
|
||||
if (w.getOrNull(1) == element.text && element.text !in LudicManifest.HOOK_PHASES) {
|
||||
holder.newAnnotation(HighlightSeverity.ERROR, "A hook runs before or after").range(element).create()
|
||||
} else if (w.getOrNull(2) == element.text && element.text !in LudicManifest.HOOK_EVENTS &&
|
||||
LudicManifest.parse(file.text).scripts.none { it.name == element.text }) {
|
||||
holder.newAnnotation(HighlightSeverity.ERROR, "No command or script named '${element.text}': hooks wrap ${LudicManifest.HOOK_EVENTS.keys.joinToString()} or a script")
|
||||
.range(element).create()
|
||||
}
|
||||
}
|
||||
!lock && line.directive == "kind" && line.words.getOrNull(1) == element.text && !line.startsWith(element) -> {
|
||||
if (element.text !in LudicManifest.KINDS) {
|
||||
holder.newAnnotation(HighlightSeverity.ERROR, "kind must be one of: ${LudicManifest.KINDS.keys.joinToString()}")
|
||||
.range(element).create()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkString(element: PsiElement, line: ManifestLine, file: PsiFile, lock: Boolean, holder: AnnotationHolder) {
|
||||
val value = unquote(element.text)
|
||||
val words = line.words
|
||||
if (!lock && line.directive == "version" && words.getOrNull(1) == value && !LudicManifest.isSemver(value)) {
|
||||
holder.newAnnotation(HighlightSeverity.WARNING, "Not a semantic version (MAJOR.MINOR.PATCH): a git tag v$value will not resolve")
|
||||
.range(element).create()
|
||||
return
|
||||
}
|
||||
if (!lock && line.directive == "require" && words.getOrNull(2) == value && !LudicManifest.isSemver(value)) {
|
||||
holder.newAnnotation(HighlightSeverity.WARNING, "A minimum version is MAJOR.MINOR.PATCH")
|
||||
.range(element).create()
|
||||
return
|
||||
}
|
||||
if (!lock && line.directive == "script" && words.getOrNull(1) == value && value in LudicManifest.BUILTIN_COMMANDS) {
|
||||
holder.newAnnotation(HighlightSeverity.WARNING, "'ludic $value' is a built-in command: run this script with `ludic script $value`")
|
||||
.range(element).create()
|
||||
return
|
||||
}
|
||||
if (!lock && line.directive == "app" && words.getOrNull(1) == "splash_bg" && words.getOrNull(2) == value &&
|
||||
!Regex("#[0-9a-fA-F]{6}").matches(value)) {
|
||||
holder.newAnnotation(HighlightSeverity.WARNING, "splash_bg is a colour: \"#rrggbb\"").range(element).create()
|
||||
return
|
||||
}
|
||||
val spec = manifestTargetSpec(element) ?: return
|
||||
if (spec is TargetSpec.Package) {
|
||||
if (spec.dir == null) {
|
||||
holder.newAnnotation(HighlightSeverity.WEAK_WARNING, "$value is not fetched yet: run `ludic get`")
|
||||
.range(element).create()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (manifestTarget(element) == null) {
|
||||
// `out` names something bundle creates
|
||||
if (line.directive == "app" && words.getOrNull(1) == "out") return
|
||||
holder.newAnnotation(HighlightSeverity.ERROR, "No such file or directory: $value")
|
||||
.range(TextRange(element.textRange.startOffset + 1, element.textRange.endOffset - 1)).create()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Completion in package.ludic: directives at the start of a line, then their values. */
|
||||
class LudicManifestCompletion : CompletionContributor() {
|
||||
init {
|
||||
extend(CompletionType.BASIC, PlatformPatterns.psiElement().withLanguage(LudicLanguage), object : CompletionProvider<CompletionParameters>() {
|
||||
override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) {
|
||||
val file = parameters.originalFile
|
||||
if (!LudicManifest.isManifest(file)) {
|
||||
if (LudicManifest.isLock(file)) result.stopHere()
|
||||
return
|
||||
}
|
||||
val doc = parameters.editor.document
|
||||
val offset = parameters.offset
|
||||
val line = doc.getLineNumber(offset)
|
||||
val before = doc.getText(TextRange(doc.getLineStartOffset(line), offset))
|
||||
if (before.contains('#')) return
|
||||
val words = LudicManifest.words(before)
|
||||
val typing = before.isNotEmpty() && !before.last().isWhitespace()
|
||||
val inString = before.count { it == '"' } % 2 == 1
|
||||
val done = if (typing && !inString) words.dropLast(1) else if (inString) words.dropLast(1) else words
|
||||
val dir = file.virtualFile?.parent?.path?.let(::File)
|
||||
when {
|
||||
done.isEmpty() -> for ((d, help) in LudicManifest.DIRECTIVES) {
|
||||
result.addElement(LookupElementBuilder.create("$d ").withPresentableText(d).bold().withTypeText(help, true).withIcon(LudicPackageIcons.MANIFEST))
|
||||
}
|
||||
done == listOf("app") -> for ((k, help) in LudicManifest.APP_KEYS) {
|
||||
result.addElement(LookupElementBuilder.create("$k ").withPresentableText(k).withTypeText(help, true))
|
||||
}
|
||||
done == listOf("kind") -> for ((k, help) in LudicManifest.KINDS) {
|
||||
result.addElement(LookupElementBuilder.create(k).withTypeText(help, true))
|
||||
}
|
||||
dir != null && done == listOf("entry") ->
|
||||
paths(dir, inString) { it.isFile && it.extension == "ludic" && isProgram(it) }.forEach(result::addElement)
|
||||
dir != null && done.firstOrNull() == "pack" ->
|
||||
paths(dir, inString) { it.isDirectory }.forEach(result::addElement)
|
||||
dir != null && done.size == 2 && done[0] == "app" && done[1] in setOf("icon", "splash") ->
|
||||
paths(dir, inString) { it.isFile && it.extension.lowercase() in setOf("png", "jpg", "jpeg") }.forEach(result::addElement)
|
||||
dir != null && done.size == 2 && done[0] == "app" && done[1] == "native" ->
|
||||
paths(dir, inString) { it.isDirectory }.forEach(result::addElement)
|
||||
done == listOf("hook") -> for ((p, help) in LudicManifest.HOOK_PHASES) {
|
||||
result.addElement(LookupElementBuilder.create("$p ").withPresentableText(p).withTypeText(help, true))
|
||||
}
|
||||
done.size == 2 && done[0] == "hook" -> {
|
||||
for ((ev, cmd) in LudicManifest.HOOK_EVENTS) {
|
||||
result.addElement(LookupElementBuilder.create("$ev ").withPresentableText(ev).withTypeText(cmd, true))
|
||||
}
|
||||
for (s in LudicManifest.parse(doc.charsSequence).scripts) {
|
||||
result.addElement(LookupElementBuilder.create("${s.name} ").withPresentableText(s.name).withTypeText("script: ${s.command}", true).withIcon(AllIcons.Nodes.Console))
|
||||
}
|
||||
}
|
||||
done.size == 2 && done[0] == "app" && done[1] == "category" -> CATEGORIES.forEach {
|
||||
result.addElement(LookupElementBuilder.create(if (inString) it else "\"$it\""))
|
||||
}
|
||||
}
|
||||
result.stopHere()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun isProgram(f: File) = runCatching { f.useLines { l -> l.any { it.startsWith("program ") } } }.getOrDefault(false)
|
||||
|
||||
private fun paths(root: File, inString: Boolean, keep: (File) -> Boolean) =
|
||||
root.walkTopDown()
|
||||
.onEnter { d -> d == root || !(d.name.startsWith(".") || d.name == "build" || d.name == "ludic_modules" || d.name == "node_modules") }
|
||||
.maxDepth(4)
|
||||
.filter { it != root && keep(it) }
|
||||
.take(200)
|
||||
.map { f ->
|
||||
val rel = f.relativeTo(root).path
|
||||
LookupElementBuilder.create(if (inString) rel else "\"$rel\"")
|
||||
.withPresentableText(rel)
|
||||
.withIcon(if (f.isDirectory) AllIcons.Nodes.Folder else AllIcons.FileTypes.Any_type)
|
||||
}
|
||||
.toList()
|
||||
|
||||
private companion object {
|
||||
val CATEGORIES = listOf(
|
||||
"public.app-category.games", "public.app-category.action-games", "public.app-category.adventure-games",
|
||||
"public.app-category.arcade-games", "public.app-category.puzzle-games", "public.app-category.role-playing-games",
|
||||
"public.app-category.simulation-games", "public.app-category.strategy-games", "public.app-category.sports-games",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick documentation (F1 / hover) in package.ludic and its lock: what each
|
||||
* directive and value means, and for any `app` line a preview of the app all
|
||||
* the `app` lines describe together.
|
||||
*/
|
||||
class LudicManifestDocumentation : AbstractDocumentationProvider() {
|
||||
override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
|
||||
val el = originalElement ?: element ?: return null
|
||||
val file = el.containingFile ?: return null
|
||||
if (!isPackageFile(file)) return null
|
||||
val line = ManifestLine.at(el) ?: return null
|
||||
if (LudicManifest.isLock(file)) return lockDoc(file, line)
|
||||
return when (line.directive) {
|
||||
"app" -> appPreview(file, highlight = line.words.getOrNull(1))
|
||||
null -> null
|
||||
else -> directiveDoc(file, line)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCustomDocumentationElement(editor: Editor, file: PsiFile, contextElement: PsiElement?, targetOffset: Int): PsiElement? =
|
||||
if (isPackageFile(file)) contextElement else null
|
||||
|
||||
private fun esc(s: String) = StringUtil.escapeXmlEntities(s)
|
||||
|
||||
private fun directiveDoc(file: PsiFile, line: ManifestLine): String? {
|
||||
val d = line.directive ?: return null
|
||||
val help = LudicManifest.DIRECTIVES[d] ?: return null
|
||||
val value = line.words.getOrNull(1)
|
||||
val sb = StringBuilder("<div class='definition'><pre>").append(esc(line.text.substringBefore('#').trim())).append("</pre></div>")
|
||||
sb.append("<div class='content'><p>").append(esc(help)).append("</p>")
|
||||
when (d) {
|
||||
"kind" -> LudicManifest.KINDS[value]?.let { sb.append("<p><b>").append(esc(value!!)).append("</b>: ").append(esc(it)).append("</p>") }
|
||||
"version" -> if (value != null) sb.append("<p>Published by the git tag <code>v").append(esc(value)).append("</code>.</p>")
|
||||
"package" -> if (value != null) sb.append("<p>Others depend on it with <code>require \"").append(esc(value)).append("\" \"").append(esc(LudicManifest.parse(file.text).version ?: "1.0.0")).append("\"</code>.</p>")
|
||||
"require" -> requireDetail(file, line, sb)
|
||||
"hook" -> {
|
||||
line.words.getOrNull(1)?.let { p -> LudicManifest.HOOK_PHASES[p]?.let { sb.append("<p><b>").append(esc(p)).append("</b>: ").append(esc(it)).append("</p>") } }
|
||||
line.words.getOrNull(2)?.let { ev ->
|
||||
val what = LudicManifest.HOOK_EVENTS[ev] ?: LudicManifest.parse(file.text).scripts.firstOrNull { it.name == ev }?.let { "the script ${it.name}: ${it.command}" }
|
||||
if (what != null) sb.append("<p>Wraps <code>").append(esc(what)).append("</code></p>")
|
||||
}
|
||||
sb.append("<p>Runs through the shell in the package root with <code>LUDIC_EVENT</code>, <code>LUDIC_PHASE</code> and <code>LUDIC_PACKAGE_ROOT</code> set.</p>")
|
||||
}
|
||||
"script" -> {
|
||||
val name = line.words.getOrNull(1)
|
||||
if (name != null) {
|
||||
val how = if (name in LudicManifest.BUILTIN_COMMANDS) "ludic script $name" else "ludic $name"
|
||||
sb.append("<p>Run with <code>").append(esc(how)).append(" [args]</code>.</p>")
|
||||
val hooks = LudicManifest.parse(file.text).hooks.filter { it.event == name }
|
||||
hooks.forEach { sb.append("<p>").append(esc(it.phase)).append(": <code>").append(esc(it.command)).append("</code></p>") }
|
||||
}
|
||||
}
|
||||
"entry", "pack" -> line.words.drop(1).forEach { p ->
|
||||
val f = resolveManifestPath(file, p)
|
||||
sb.append("<p><code>").append(esc(p)).append("</code> — ")
|
||||
.append(if (f?.exists() == true) (if (f.isDirectory) "directory" else "file") else "<b>missing</b>").append("</p>")
|
||||
}
|
||||
}
|
||||
sb.append("</div>")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun requireDetail(file: PsiFile, line: ManifestLine, sb: StringBuilder) {
|
||||
val module = line.words.getOrNull(1) ?: return
|
||||
val lockFile = file.virtualFile?.parent?.findChild(LudicManifest.LOCK_NAME)
|
||||
val locked = lockFile?.let { runCatching { LudicManifest.parseLock(String(it.contentsToByteArray())) }.getOrNull() }
|
||||
?.firstOrNull { it.module == module }
|
||||
if (locked == null) sb.append("<p>Not in the lock yet: run <code>ludic get</code>.</p>")
|
||||
else sb.append("<p>Locked at <b>").append(esc(locked.version)).append("</b> (").append(esc(locked.kind)).append(")<br>hash <code>")
|
||||
.append(esc(locked.hash.take(16))).append("…</code></p>")
|
||||
val dir = packageDir(file, module)
|
||||
sb.append("<p>").append(if (dir != null) "Linked at <code>${esc(dir.path)}</code>" else "Not linked into ludic_modules/").append("</p>")
|
||||
}
|
||||
|
||||
private fun lockDoc(file: PsiFile, line: ManifestLine): String? {
|
||||
if (line.directive != "module") return line.directive?.let { LudicManifest.LOCK_WORDS[it] }?.let { "<div class='content'>${esc(it)}</div>" }
|
||||
val m = LudicManifest.parseLock(line.text).firstOrNull() ?: return null
|
||||
val dir = packageDir(file, m.module)
|
||||
return buildString {
|
||||
append("<div class='definition'><pre>").append(esc(m.module)).append("</pre></div><table class='sections'>")
|
||||
row("Version", esc(m.version))
|
||||
row("Kind", esc(m.kind))
|
||||
if (m.provides.isNotEmpty()) row("Provides", m.provides.joinToString { "<code>${esc(it)}.*</code>" })
|
||||
row("Hash", "<code>${esc(m.hash)}</code>")
|
||||
row("On disk", if (dir != null) "<code>${esc(dir.path)}</code>" else "not linked — run <code>ludic get</code>")
|
||||
append("</table>")
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.row(k: String, v: String) {
|
||||
append("<tr><td valign='top' class='section'><p>").append(k).append("</p></td><td valign='top'>").append(v).append("</td></tr>")
|
||||
}
|
||||
|
||||
/** The app the `app` lines describe, as `ludic bundle` would build it. */
|
||||
private fun appPreview(file: PsiFile, highlight: String?): String {
|
||||
val m = LudicManifest.parse(file.text)
|
||||
val app = m.app
|
||||
val name = app["name"] ?: m.module?.substringAfterLast('/') ?: file.virtualFile?.parent?.name ?: "app"
|
||||
fun img(key: String, size: Int): String? {
|
||||
val f = app[key]?.let { resolveManifestPath(file, it) }?.takeIf { it.isFile } ?: return null
|
||||
return "<img src='${f.toURI()}' width='$size' height='$size'>"
|
||||
}
|
||||
fun mark(k: String, v: String) = if (k == highlight) "<b>$v</b>" else v
|
||||
return buildString {
|
||||
append("<div class='definition'><pre>").append(esc(name)).append(".app</pre></div>")
|
||||
append("<div class='content'><table><tr><td valign='top'>")
|
||||
append(img("icon", 64) ?: "")
|
||||
append("</td><td valign='top'><p><b>").append(esc(name)).append("</b></p><p>")
|
||||
append(esc(app["id"] ?: "(no bundle id)")).append("<br>version ")
|
||||
append(esc(app["version"] ?: m.version ?: "0.1.0"))
|
||||
app["category"]?.let { append("<br>").append(esc(it.removePrefix("public.app-category."))) }
|
||||
app["min_macos"]?.let { append("<br>macOS ").append(esc(it)).append("+") }
|
||||
append("</p></td></tr></table>")
|
||||
app["splash"]?.let { s ->
|
||||
val f = resolveManifestPath(file, s)
|
||||
val bg = app["splash_bg"] ?: "#000000"
|
||||
append("<p>Splash</p><table><tr><td bgcolor='").append(esc(bg)).append("'>")
|
||||
if (f?.isFile == true) append("<img src='").append(f.toURI()).append("' width='240'>") else append(" missing: ").append(esc(s))
|
||||
append("</td></tr></table>")
|
||||
}
|
||||
append("</div><table class='sections'>")
|
||||
for ((k, help) in LudicManifest.APP_KEYS) {
|
||||
val v = app[k] ?: continue
|
||||
val shown = if (k == "splash_bg") "<span style='background-color:${esc(v)}'> </span> ${esc(v)}" else esc(v)
|
||||
row(mark(k, k), mark(k, shown) + "<br><span class='grayed'>${esc(help)}</span>")
|
||||
}
|
||||
append("</table>")
|
||||
m.entry?.let { append("<div class='bottom'>entry ").append(esc(it)).append("</div>") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package io.ludic.ide.project
|
||||
|
||||
import com.intellij.execution.configurations.GeneralCommandLine
|
||||
import com.intellij.execution.util.ExecUtil
|
||||
import com.intellij.ide.util.projectWizard.WizardContext
|
||||
import com.intellij.ide.wizard.AbstractNewProjectWizardStep
|
||||
import com.intellij.ide.wizard.GeneratorNewProjectWizard
|
||||
import com.intellij.ide.wizard.GeneratorNewProjectWizardBuilderAdapter
|
||||
import com.intellij.ide.wizard.GitNewProjectWizardStep
|
||||
import com.intellij.ide.wizard.NewProjectWizardBaseData.Companion.baseData
|
||||
import com.intellij.ide.wizard.NewProjectWizardBaseStep
|
||||
import com.intellij.ide.wizard.NewProjectWizardChainStep.Companion.nextStep
|
||||
import com.intellij.ide.wizard.NewProjectWizardStep
|
||||
import com.intellij.ide.wizard.RootNewProjectWizardStep
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.module.GeneralModuleType
|
||||
import com.intellij.openapi.module.ModuleTypeManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.Messages
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.ui.dsl.builder.Panel
|
||||
import io.ludic.ide.LudicIcons
|
||||
import io.ludic.ide.LudicToolchain
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
/**
|
||||
* File | New | Project | Ludic. The project is whatever `ludic new` makes,
|
||||
* so the IDE never carries its own copy of the template to drift from the CLI.
|
||||
*/
|
||||
class LudicNewProjectWizard : GeneratorNewProjectWizard {
|
||||
override val id = "Ludic"
|
||||
override val name = "Ludic"
|
||||
override val icon = LudicIcons.FILE
|
||||
override val description = "A Ludic game: package.ludic, src/main.ludic and a test, as <code>ludic new</code> creates them."
|
||||
|
||||
override fun createStep(context: WizardContext): NewProjectWizardStep =
|
||||
RootNewProjectWizardStep(context)
|
||||
.nextStep(::NewProjectWizardBaseStep)
|
||||
.nextStep(::GitNewProjectWizardStep)
|
||||
.nextStep(::LudicProjectStep)
|
||||
}
|
||||
|
||||
/** A file a freshly created project should show first. */
|
||||
internal val OPEN_ON_START = Key.create<String>("ludic.openOnStart")
|
||||
|
||||
class LudicModuleBuilder : GeneratorNewProjectWizardBuilderAdapter(LudicNewProjectWizard())
|
||||
|
||||
private class LudicProjectStep(parent: NewProjectWizardStep) : AbstractNewProjectWizardStep(parent) {
|
||||
|
||||
override fun setupUI(builder: Panel) {
|
||||
val cli = LudicToolchain.cli()
|
||||
builder.row {
|
||||
if (cli != null) comment("Toolchain: $cli")
|
||||
else comment("The <code>ludic</code> command was not found. ${LudicToolchain.INSTALL_HINT}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun setupProject(project: Project) {
|
||||
val data = baseData ?: return
|
||||
val dir = File(data.path, data.name)
|
||||
val error = scaffold(dir, data.name)
|
||||
if (error != null) {
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
Messages.showErrorDialog(project, error, "Could Not Create the Ludic Project")
|
||||
}
|
||||
}
|
||||
// a plain module whose content root is the project directory, so the
|
||||
// Project view shows the files
|
||||
val builder = ModuleTypeManager.getInstance().findByID(GeneralModuleType.TYPE_ID).createModuleBuilder()
|
||||
builder.name = data.name
|
||||
builder.contentEntryPath = dir.path
|
||||
builder.moduleFilePath = File(File(dir, ".idea"), "${data.name}.iml").path
|
||||
builder.commit(project)
|
||||
|
||||
// LudicToolchainCheck opens it once the project window is up
|
||||
project.putUserData(OPEN_ON_START, File(dir, "src/main.ludic").path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `ludic new` in a scratch directory and move the result into [dir],
|
||||
* which the wizard may already have created (with .idea/ inside). Returns an
|
||||
* error message, or null on success.
|
||||
*/
|
||||
internal fun scaffold(dir: File, displayName: String, cli: String? = LudicToolchain.cli()): String? {
|
||||
cli ?: return "The 'ludic' command was not found.\n${LudicToolchain.INSTALL_HINT}"
|
||||
val slug = packageSlug(displayName)
|
||||
val scratch = Files.createTempDirectory("ludic-new").toFile()
|
||||
try {
|
||||
val out = ExecUtil.execAndGetOutput(
|
||||
GeneralCommandLine(cli, "new", slug).withWorkDirectory(scratch).withCharset(Charsets.UTF_8),
|
||||
60_000
|
||||
)
|
||||
val made = File(scratch, slug)
|
||||
if (out.exitCode != 0 || !made.isDirectory) {
|
||||
return "ludic new $slug failed (exit ${out.exitCode}):\n${out.stderr.ifBlank { out.stdout }}"
|
||||
}
|
||||
dir.mkdirs()
|
||||
made.walkTopDown().filter { it.isFile }.forEach { src ->
|
||||
val dst = File(dir, src.relativeTo(made).path)
|
||||
if (!dst.exists()) {
|
||||
dst.parentFile.mkdirs()
|
||||
src.copyTo(dst)
|
||||
}
|
||||
}
|
||||
// keep the empty directories (assets/) the template promises
|
||||
made.walkTopDown().filter { it.isDirectory }.forEach { File(dir, it.relativeTo(made).path).mkdirs() }
|
||||
LocalFileSystem.getInstance().refreshAndFindFileByIoFile(dir)?.refresh(false, true)
|
||||
return null
|
||||
} finally {
|
||||
scratch.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
/** A name `ludic new` accepts: letters, digits, '-', '_' and '.'. */
|
||||
internal fun packageSlug(name: String): String {
|
||||
val s = name.trim().map { if (it.isLetterOrDigit() || it in "-_.") it else '-' }.joinToString("")
|
||||
.trim('-', '.').ifEmpty { "game" }
|
||||
return s
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
package io.ludic.ide.project
|
||||
|
||||
import com.intellij.execution.lineMarker.RunLineMarkerContributor
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ide.IconProvider
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.fileEditor.FileEditor
|
||||
import com.intellij.openapi.fileEditor.OpenFileDescriptor
|
||||
import com.intellij.openapi.options.ShowSettingsUtil
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.openapi.vfs.newvfs.BulkFileListener
|
||||
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
|
||||
import com.intellij.psi.PsiDocumentManager
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.ui.EditorNotificationPanel
|
||||
import com.intellij.ui.EditorNotificationProvider
|
||||
import com.intellij.ui.EditorNotifications
|
||||
import io.ludic.ide.LudicConfigurable
|
||||
import io.ludic.ide.LudicToolchain
|
||||
import io.ludic.ide.run.LudicCliAction
|
||||
import io.ludic.ide.run.LudicCommand
|
||||
import java.io.File
|
||||
import java.util.function.Function
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
|
||||
object LudicPackageIcons {
|
||||
val MANIFEST: Icon = IconLoader.getIcon("/icons/ludicPackage.svg", LudicPackageIcons::class.java)
|
||||
}
|
||||
|
||||
/** Run a `ludic` command in the package that owns [dir]. */
|
||||
internal fun runLudic(project: Project, command: LudicCommand, args: String, dir: String) {
|
||||
FileDocumentManager.getInstance().saveAllDocuments()
|
||||
LudicCliAction.run(project, command, args, dir)
|
||||
}
|
||||
|
||||
internal fun cliAction(text: String, icon: Icon?, project: Project, dir: String, command: LudicCommand, args: String = ""): AnAction =
|
||||
object : AnAction(text, null, icon), DumbAware {
|
||||
override fun actionPerformed(e: AnActionEvent) = runLudic(project, command, args, dir)
|
||||
}
|
||||
|
||||
/** The whole line an element sits on, and the words on it. */
|
||||
internal data class ManifestLine(val index: Int, val range: TextRange, val text: String, val words: List<String>) {
|
||||
val directive: String? get() = words.firstOrNull()
|
||||
|
||||
companion object {
|
||||
fun at(element: PsiElement): ManifestLine? {
|
||||
val file = element.containingFile ?: return null
|
||||
val doc = PsiDocumentManager.getInstance(file.project).getDocument(file) ?: return null
|
||||
return at(doc, element.textOffset)
|
||||
}
|
||||
|
||||
fun at(doc: Document, offset: Int): ManifestLine {
|
||||
val line = doc.getLineNumber(offset.coerceIn(0, doc.textLength))
|
||||
val range = TextRange(doc.getLineStartOffset(line), doc.getLineEndOffset(line))
|
||||
val text = doc.getText(range)
|
||||
return ManifestLine(line, range, text, LudicManifest.words(text))
|
||||
}
|
||||
}
|
||||
|
||||
/** Is the element the first word on its line? */
|
||||
fun startsWith(element: PsiElement) = text.substring(0, element.textOffset - range.startOffset).isBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar across the top of package.ludic and package.lock.ludic.
|
||||
*
|
||||
* On the manifest: the package's identity and the commands that act on it,
|
||||
* turning into a prompt to fetch when the lock is missing or older than the
|
||||
* manifest, the way a build-file sync does. On the lock: a reminder that it is
|
||||
* generated, with the commands that regenerate and check it.
|
||||
*/
|
||||
class LudicPackageNotificationProvider : EditorNotificationProvider, DumbAware {
|
||||
override fun collectNotificationData(project: Project, file: VirtualFile): Function<in FileEditor, out JComponent?>? {
|
||||
val isManifest = LudicManifest.isManifest(file)
|
||||
if (!isManifest && !LudicManifest.isLock(file)) return null
|
||||
val dir = file.parent?.path ?: return null
|
||||
val cli = LudicToolchain.cli(project)
|
||||
if (!isManifest) return lockBanner(project, dir, cli != null)
|
||||
|
||||
val text = FileDocumentManager.getInstance().getCachedDocument(file)?.charsSequence
|
||||
?: runCatching { String(file.contentsToByteArray(), Charsets.UTF_8) }.getOrNull() ?: return null
|
||||
val manifest = LudicManifest.parse(text)
|
||||
val stale = LudicManifest.staleReason(File(dir), manifest)
|
||||
return Function { editor ->
|
||||
val panel = if (stale != null) EditorNotificationPanel(editor, EditorNotificationPanel.Status.Warning)
|
||||
else EditorNotificationPanel(editor, EditorNotificationPanel.Status.Info)
|
||||
panel.icon(LudicPackageIcons.MANIFEST)
|
||||
val name = manifest.module ?: File(dir).name
|
||||
val ver = manifest.version?.let { " $it" } ?: ""
|
||||
val deps = manifest.requires.size.let { if (it == 1) "1 dependency" else "$it dependencies" }
|
||||
val entry = manifest.entry?.let { " · entry $it" } ?: ""
|
||||
panel.text = when {
|
||||
cli == null -> "Ludic package $name$ver — the 'ludic' command was not found."
|
||||
stale != null -> "$stale Fetch the dependencies so builds and code insight see them."
|
||||
else -> "Ludic package $name$ver · $deps$entry"
|
||||
}
|
||||
if (cli != null) {
|
||||
val get = if (stale != null) "Fetch Dependencies" else "Fetch"
|
||||
panel.createActionLabel(get) { runLudic(project, LudicCommand.CUSTOM, "get", dir) }
|
||||
if (manifest.requires.isNotEmpty()) {
|
||||
panel.createActionLabel("Update All") { runLudic(project, LudicCommand.CUSTOM, "update", dir) }
|
||||
panel.createActionLabel("Verify") { runLudic(project, LudicCommand.CUSTOM, "verify", dir) }
|
||||
}
|
||||
panel.createActionLabel("Run") { runLudic(project, LudicCommand.RUN, "", dir) }
|
||||
panel.createActionLabel("Test") { runLudic(project, LudicCommand.TEST, "", dir) }
|
||||
panel.createActionLabel("Build") { runLudic(project, LudicCommand.BUILD, "", dir) }
|
||||
panel.createActionLabel("Bundle") { runLudic(project, LudicCommand.CUSTOM, "bundle", dir) }
|
||||
manifest.scripts.take(4).forEach { s ->
|
||||
panel.createActionLabel("▶ ${s.name}") { runLudic(project, LudicCommand.CUSTOM, "script ${s.name}", dir) }
|
||||
}
|
||||
} else {
|
||||
panel.createActionLabel("Set Paths…") {
|
||||
ShowSettingsUtil.getInstance().showSettingsDialog(project, LudicConfigurable::class.java)
|
||||
}
|
||||
}
|
||||
panel
|
||||
}
|
||||
}
|
||||
|
||||
private fun lockBanner(project: Project, dir: String, hasCli: Boolean) = Function<FileEditor, JComponent?> { editor ->
|
||||
val panel = EditorNotificationPanel(editor, EditorNotificationPanel.Status.Info)
|
||||
panel.icon(LudicPackageIcons.MANIFEST)
|
||||
panel.text = "Generated by `ludic get` — edit package.ludic, then fetch again."
|
||||
panel.createActionLabel("Open package.ludic") {
|
||||
LocalFileSystem.getInstance().findFileByPath("$dir/${LudicManifest.FILE_NAME}")?.let {
|
||||
OpenFileDescriptor(project, it).navigate(true)
|
||||
}
|
||||
}
|
||||
if (hasCli) {
|
||||
panel.createActionLabel("Regenerate") { runLudic(project, LudicCommand.CUSTOM, "get", dir) }
|
||||
panel.createActionLabel("Verify Hashes") { runLudic(project, LudicCommand.CUSTOM, "verify", dir) }
|
||||
panel.createActionLabel("Vendor") { runLudic(project, LudicCommand.CUSTOM, "vendor", dir) }
|
||||
}
|
||||
panel
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the bars and the library list current as manifests, locks and linked packages change. */
|
||||
class LudicManifestWatcher(private val project: Project) : BulkFileListener {
|
||||
override fun after(events: List<VFileEvent>) {
|
||||
val relevant = events.any { e ->
|
||||
val n = e.path.substringAfterLast('/')
|
||||
n == LudicManifest.FILE_NAME || n == LudicManifest.LOCK_NAME || n == "ludic_modules" || n == "vendor"
|
||||
}
|
||||
if (relevant && !project.isDisposed) EditorNotifications.getInstance(project).updateAllNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gutter actions: the package line runs the package, the entry line runs the
|
||||
* entry, a require (or lock module) line updates that dependency.
|
||||
*/
|
||||
class LudicManifestLineMarker : RunLineMarkerContributor() {
|
||||
override fun getInfo(element: PsiElement): Info? {
|
||||
val file = element.containingFile ?: return null
|
||||
val manifest = LudicManifest.isManifest(file)
|
||||
if (!manifest && !LudicManifest.isLock(file)) return null
|
||||
if (element.firstChild != null) return null
|
||||
val word = element.text
|
||||
if (word != "package" && word != "require" && word != "entry" && word != "module" && word != "script") return null
|
||||
val line = ManifestLine.at(element) ?: return null
|
||||
if (!line.startsWith(element)) return null
|
||||
val dir = file.virtualFile?.parent?.path ?: return null
|
||||
val project = file.project
|
||||
return when {
|
||||
manifest && word == "package" -> Info(
|
||||
AllIcons.RunConfigurations.TestState.Run,
|
||||
arrayOf(
|
||||
cliAction("Run Package", AllIcons.Actions.Execute, project, dir, LudicCommand.RUN),
|
||||
cliAction("Test Package", AllIcons.RunConfigurations.TestState.Run_run, project, dir, LudicCommand.TEST),
|
||||
cliAction("Build Package", AllIcons.Actions.Compile, project, dir, LudicCommand.BUILD),
|
||||
cliAction("Fetch Dependencies", AllIcons.Actions.Download, project, dir, LudicCommand.CUSTOM, "get"),
|
||||
cliAction("Bundle App", AllIcons.Nodes.Artifact, project, dir, LudicCommand.CUSTOM, "bundle"),
|
||||
)
|
||||
) { "Run, test or build this package" }
|
||||
manifest && word == "entry" -> Info(
|
||||
AllIcons.RunConfigurations.TestState.Run,
|
||||
arrayOf(
|
||||
cliAction("Run Entry", AllIcons.Actions.Execute, project, dir, LudicCommand.RUN),
|
||||
cliAction("Build Entry", AllIcons.Actions.Compile, project, dir, LudicCommand.BUILD),
|
||||
)
|
||||
) { "Run the entry program" }
|
||||
manifest && word == "script" -> {
|
||||
val name = line.words.getOrNull(1) ?: return null
|
||||
Info(
|
||||
AllIcons.RunConfigurations.TestState.Run,
|
||||
arrayOf(cliAction("Run Script '$name'", AllIcons.Actions.Execute, project, dir, LudicCommand.CUSTOM, "script $name"))
|
||||
) { "ludic script $name" }
|
||||
}
|
||||
word == "require" || word == "module" -> {
|
||||
val module = line.words.getOrNull(1) ?: return null
|
||||
Info(
|
||||
AllIcons.Actions.Refresh,
|
||||
arrayOf(
|
||||
cliAction("Update $module", AllIcons.Actions.Refresh, project, dir, LudicCommand.CUSTOM, "update $module"),
|
||||
cliAction("Fetch Dependencies", AllIcons.Actions.Download, project, dir, LudicCommand.CUSTOM, "get"),
|
||||
cliAction("Verify Locked Packages", AllIcons.Actions.Checked, project, dir, LudicCommand.CUSTOM, "verify"),
|
||||
)
|
||||
) { "Update $module" }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** package.ludic and its lock get the package icon in the Project view and on their tabs. */
|
||||
class LudicManifestIconProvider : IconProvider(), DumbAware {
|
||||
override fun getIcon(element: PsiElement, flags: Int): Icon? =
|
||||
if (element is PsiFile && (LudicManifest.isManifest(element) || LudicManifest.isLock(element))) LudicPackageIcons.MANIFEST else null
|
||||
}
|
||||
|
||||
/** A manifest path value, resolved against the package root. */
|
||||
internal fun resolveManifestPath(file: PsiFile, value: String): File? {
|
||||
val dir = file.virtualFile?.parent?.path ?: return null
|
||||
if (value.isBlank()) return null
|
||||
return File(value).let { if (it.isAbsolute) it else File(dir, value) }
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package io.ludic.ide.project
|
||||
|
||||
import com.intellij.navigation.ItemPresentation
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.roots.AdditionalLibraryRootsProvider
|
||||
import com.intellij.openapi.roots.SyntheticLibrary
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import io.ludic.ide.LudicIcons
|
||||
import io.ludic.ide.LudicToolchain
|
||||
import java.io.File
|
||||
import javax.swing.Icon
|
||||
|
||||
/**
|
||||
* External Libraries, one node per thing a build can import:
|
||||
*
|
||||
* Ludic built-in runtime <home>/runtime — the language's own library
|
||||
* Ludic language reference <home>/docs/language — what hover and go-to open
|
||||
* ludic.render3d 0.4.0 <home>/packages/<name> — packages shipped with the toolchain
|
||||
* git.example.com/me/util 1.2.0 ludic_modules/ or vendor/ — fetched packages, per the lock
|
||||
*
|
||||
* so they are indexed, searchable and browsable next to the project.
|
||||
*/
|
||||
class LudicPackagesLibraryProvider : AdditionalLibraryRootsProvider() {
|
||||
|
||||
override fun getAdditionalProjectLibraries(project: Project): Collection<SyntheticLibrary> {
|
||||
val base = project.basePath ?: return emptyList()
|
||||
val lfs = LocalFileSystem.getInstance()
|
||||
val out = mutableListOf<SyntheticLibrary>()
|
||||
fun add(name: String, kind: Kind, path: File) {
|
||||
lfs.findFileByIoFile(path)?.takeIf { it.isDirectory }?.let { out += LudicPackagesLibrary(name, kind, it) }
|
||||
}
|
||||
|
||||
val home = LudicToolchain.home(project)
|
||||
// a checkout of the toolchain already has all of this as project content
|
||||
val insideHome = home != null && File(base).canonicalPath.let { it == home.canonicalPath || it.startsWith(home.canonicalPath + "/") }
|
||||
if (home != null && !insideHome) {
|
||||
add("Ludic built-in runtime", Kind.BUILTIN, File(home, "runtime"))
|
||||
add("Ludic language reference", Kind.BUILTIN, File(home, "docs/language"))
|
||||
File(home, "packages").listFiles()?.filter { it.isDirectory }?.sortedBy { it.name }?.forEach { pkg ->
|
||||
add(labelOf(pkg, pkg.name), Kind.TOOLCHAIN, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
val lock = File(base, LudicManifest.LOCK_NAME)
|
||||
val locked = if (lock.isFile) runCatching { LudicManifest.parseLock(lock.readText()) }.getOrDefault(emptyList()) else emptyList()
|
||||
val modules = File(base, System.getenv("LUDIC_MODULES") ?: "ludic_modules")
|
||||
val vendor = File(base, "vendor")
|
||||
for (m in locked) {
|
||||
val dir = listOf(File(vendor, m.module), File(modules, m.module)).firstOrNull { it.isDirectory } ?: continue
|
||||
add("${m.module} ${m.version}", Kind.REMOTE, dir)
|
||||
}
|
||||
if (locked.isEmpty()) {
|
||||
// fetched without a lock we can read: show the link roots as they are
|
||||
add("Ludic packages (ludic_modules)", Kind.REMOTE, modules)
|
||||
add("Ludic packages (vendor)", Kind.REMOTE, vendor)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
override fun getRootsToWatch(project: Project): Collection<VirtualFile> =
|
||||
getAdditionalProjectLibraries(project).flatMap { it.sourceRoots }
|
||||
|
||||
/** `ludic.render3d 0.4.0` from the package's own manifest, when it has one. */
|
||||
private fun labelOf(dir: File, fallback: String): String {
|
||||
val man = File(dir, LudicManifest.FILE_NAME)
|
||||
if (!man.isFile) return fallback
|
||||
val m = runCatching { LudicManifest.parse(man.readText()) }.getOrNull() ?: return fallback
|
||||
return listOfNotNull(fallback, m.version).joinToString(" ")
|
||||
}
|
||||
|
||||
enum class Kind { BUILTIN, TOOLCHAIN, REMOTE }
|
||||
}
|
||||
|
||||
class LudicPackagesLibrary(private val name: String, private val kind: LudicPackagesLibraryProvider.Kind, private val root: VirtualFile) :
|
||||
SyntheticLibrary("ludic:" + root.path, ExcludeFileCondition { isDir, filename, _, _, _ ->
|
||||
// sources, manifests and docs only: packages also carry large binary assets
|
||||
!isDir && !(filename.endsWith(".ludic") || filename.endsWith(".md"))
|
||||
}), ItemPresentation {
|
||||
override fun getSourceRoots(): Collection<VirtualFile> = listOf(root)
|
||||
override fun equals(other: Any?) = other is LudicPackagesLibrary && other.root == root && other.name == name
|
||||
override fun hashCode() = root.hashCode()
|
||||
override fun getPresentableText() = name
|
||||
override fun getLocationString(): String? = when (kind) {
|
||||
LudicPackagesLibraryProvider.Kind.BUILTIN -> "built-in"
|
||||
LudicPackagesLibraryProvider.Kind.TOOLCHAIN -> "toolchain"
|
||||
LudicPackagesLibraryProvider.Kind.REMOTE -> "fetched"
|
||||
}
|
||||
override fun getIcon(unused: Boolean): Icon = if (kind == LudicPackagesLibraryProvider.Kind.BUILTIN) LudicIcons.FILE else LudicPackageIcons.MANIFEST
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package io.ludic.ide.project
|
||||
|
||||
import com.intellij.notification.NotificationAction
|
||||
import com.intellij.notification.NotificationGroupManager
|
||||
import com.intellij.notification.NotificationType
|
||||
import com.intellij.openapi.application.EDT
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.options.ShowSettingsUtil
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.startup.ProjectActivity
|
||||
import com.intellij.ide.BrowserUtil
|
||||
import io.ludic.ide.LudicConfigurable
|
||||
import io.ludic.ide.LudicToolchain
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Opening a Ludic project on a machine without the toolchain would otherwise
|
||||
* look like a plugin that half works: colours, but no completion and no run
|
||||
* button that does anything. Say so once, with the fix attached.
|
||||
*/
|
||||
class LudicToolchainCheck : ProjectActivity {
|
||||
override suspend fun execute(project: Project) {
|
||||
project.getUserData(OPEN_ON_START)?.let { path ->
|
||||
project.putUserData(OPEN_ON_START, null)
|
||||
val main = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(File(path))
|
||||
if (main != null) {
|
||||
withContext(Dispatchers.EDT) { FileEditorManager.getInstance(project).openFile(main, true) }
|
||||
}
|
||||
}
|
||||
val root = project.basePath ?: return
|
||||
if (!looksLikeLudic(File(root))) return
|
||||
val missing = buildList {
|
||||
if (LudicToolchain.cli(project) == null) add("ludic")
|
||||
if (LudicToolchain.server(project) == null) add("ludic-lsp")
|
||||
}
|
||||
if (missing.isEmpty()) return
|
||||
NotificationGroupManager.getInstance().getNotificationGroup("Ludic")
|
||||
.createNotification(
|
||||
"Ludic toolchain not found",
|
||||
"Could not find ${missing.joinToString(" or ") { "<code>$it</code>" }}. " +
|
||||
"Highlighting works without it; completion, diagnostics, run and test do not.",
|
||||
NotificationType.WARNING
|
||||
)
|
||||
.addAction(NotificationAction.createSimple("Install instructions") {
|
||||
BrowserUtil.browse("https://workshopsoft.pages.workshopsoft.io/ludic/")
|
||||
})
|
||||
.addAction(NotificationAction.createSimple("Set paths…") {
|
||||
ShowSettingsUtil.getInstance().showSettingsDialog(project, LudicConfigurable::class.java)
|
||||
})
|
||||
.notify(project)
|
||||
}
|
||||
|
||||
private fun looksLikeLudic(root: File): Boolean =
|
||||
File(root, "package.ludic").isFile ||
|
||||
File(root, "src").listFiles()?.any { it.extension == "ludic" } == true ||
|
||||
root.listFiles()?.any { it.extension == "ludic" } == true
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
package io.ludic.ide.psi
|
||||
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.util.PsiTreeUtil
|
||||
import com.intellij.psi.util.elementType
|
||||
import io.ludic.ide.LudicLanguage
|
||||
import io.ludic.ide.LudicTokens
|
||||
|
||||
/**
|
||||
* Questions about a Ludic file answered from its token stream.
|
||||
*
|
||||
* The PSI is flat (see LudicParserDefinition), so "is this a test block" is a
|
||||
* question about neighbouring leaves: a `test` declaration keyword followed by
|
||||
* a string literal. That is all the run buttons, the configuration producer and
|
||||
* the test locator need, and it holds up in a file that does not parse yet.
|
||||
*/
|
||||
object LudicPsi {
|
||||
|
||||
/** The next leaf that is not whitespace or a comment, or null. */
|
||||
fun nextSignificant(e: PsiElement): PsiElement? {
|
||||
var n = PsiTreeUtil.nextLeaf(e)
|
||||
while (n != null && (n.elementType == TokenType.WHITE_SPACE || n.elementType == LudicTokens.COMMENT)) {
|
||||
n = PsiTreeUtil.nextLeaf(n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
fun isKeyword(e: PsiElement?, word: String): Boolean =
|
||||
e != null && e.elementType == LudicTokens.DECL_KEYWORD && e.textMatches(word)
|
||||
|
||||
/** `test "name"`: the keyword leaf's test name, or null when [e] is not one. */
|
||||
fun testNameAt(e: PsiElement): String? {
|
||||
if (!isKeyword(e, "test")) return null
|
||||
val str = nextSignificant(e) ?: return null
|
||||
if (str.elementType != LudicTokens.STRING) return null
|
||||
return unquote(str.text)
|
||||
}
|
||||
|
||||
/** `program Name`: the program's name, or null when [e] is not its keyword. */
|
||||
fun programNameAt(e: PsiElement): String? {
|
||||
if (!isKeyword(e, "program")) return null
|
||||
val name = nextSignificant(e) ?: return null
|
||||
return if (name.elementType == LudicTokens.TYPE_NAME) name.text else null
|
||||
}
|
||||
|
||||
/**
|
||||
* The `test` keyword of the block around [e], when [e] sits on that block's
|
||||
* header line: the keyword itself, the name string, or the whitespace
|
||||
* between them. A caret deep inside the body is deliberately not enough;
|
||||
* "run the test I am in" is decided by the producer from the header.
|
||||
*/
|
||||
fun enclosingTestKeyword(e: PsiElement): PsiElement? {
|
||||
if (testNameAt(e) != null) return e
|
||||
if (e.elementType == LudicTokens.STRING) {
|
||||
val prev = PsiTreeUtil.prevLeaf(e)?.let { if (it.elementType == TokenType.WHITE_SPACE) PsiTreeUtil.prevLeaf(it) else it }
|
||||
if (prev != null && testNameAt(prev) != null) return prev
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Every `test "name"` keyword leaf in the file, in source order. */
|
||||
fun testKeywords(file: PsiFile): List<PsiElement> = leaves(file).filter { testNameAt(it) != null }.toList()
|
||||
|
||||
fun findTest(file: PsiFile, name: String): PsiElement? = leaves(file).firstOrNull { testNameAt(it) == name }
|
||||
|
||||
fun findProgram(file: PsiFile): PsiElement? = leaves(file).firstOrNull { programNameAt(it) != null }
|
||||
|
||||
/** A file whose only runnable content is its test blocks. */
|
||||
fun isTestFile(file: PsiFile): Boolean {
|
||||
if (file.language != LudicLanguage) return false
|
||||
val name = file.name
|
||||
if (name.endsWith("_test.ludic")) return true
|
||||
if (file.virtualFile?.parent?.name == "tests") return true
|
||||
return hasTests(file) && !hasEntry(file)
|
||||
}
|
||||
|
||||
fun hasTests(file: PsiFile) = leaves(file).any { testNameAt(it) != null }
|
||||
|
||||
/** `entry` or a phase handler: something that makes a program run as a game. */
|
||||
fun hasEntry(file: PsiFile) = leaves(file).any { isKeyword(it, "entry") || isKeyword(it, "handler") }
|
||||
|
||||
/** A file that is a program in its own right, rather than a module imported by one. */
|
||||
fun isProgramFile(file: PsiFile) = file.language == LudicLanguage && findProgram(file) != null
|
||||
|
||||
private fun leaves(file: PsiFile): Sequence<PsiElement> =
|
||||
generateSequence(PsiTreeUtil.firstChild(file)) { PsiTreeUtil.nextLeaf(it) }
|
||||
.filter { it.elementType == LudicTokens.DECL_KEYWORD }
|
||||
|
||||
/** The value of a Ludic string literal: `"a\"b"` -> `a"b`. */
|
||||
fun unquote(lit: String): String {
|
||||
val body = lit.removePrefix("\"").let { if (it.endsWith("\"") && !it.endsWith("\\\"")) it.dropLast(1) else it }
|
||||
val out = StringBuilder(body.length)
|
||||
var i = 0
|
||||
while (i < body.length) {
|
||||
val c = body[i]
|
||||
if (c == '\\' && i + 1 < body.length) {
|
||||
when (val n = body[i + 1]) {
|
||||
'n' -> out.append('\n')
|
||||
't' -> out.append('\t')
|
||||
else -> out.append(n)
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
out.append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.ProgramRunnerUtil
|
||||
import com.intellij.execution.RunManager
|
||||
import com.intellij.execution.configurations.ConfigurationTypeUtil
|
||||
import com.intellij.execution.executors.DefaultRunExecutor
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.actionSystem.CommonDataKeys
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Tools | Ludic. Each action is a throwaway run configuration, so its output
|
||||
* lands in the Run tool window with the same clickable locations as any run.
|
||||
*/
|
||||
abstract class LudicCliAction(private val command: LudicCommand, private val args: String = "") : AnAction(), DumbAware {
|
||||
|
||||
override fun getActionUpdateThread() = ActionUpdateThread.BGT
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
e.presentation.isEnabledAndVisible = e.project != null
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val project = e.project ?: return
|
||||
FileDocumentManager.getInstance().saveAllDocuments()
|
||||
run(project, command, args, packageRootFor(e, project))
|
||||
}
|
||||
|
||||
private fun packageRootFor(e: AnActionEvent, project: Project): String? {
|
||||
val start = e.getData(CommonDataKeys.VIRTUAL_FILE)?.let { if (it.isDirectory) it.path else it.parent?.path }
|
||||
?: project.basePath ?: return null
|
||||
return LudicRunConfiguration.packageRootOf(File(start))?.path ?: project.basePath
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun run(project: Project, command: LudicCommand, args: String, workDir: String?) {
|
||||
val type = ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java)
|
||||
val settings = RunManager.getInstance(project).createConfiguration("ludic", type.factory)
|
||||
val config = settings.configuration as LudicRunConfiguration
|
||||
config.command = command
|
||||
config.arguments = args
|
||||
workDir?.let { config.workingDirectory = it }
|
||||
settings.name = config.suggestedName()
|
||||
settings.isTemporary = true
|
||||
ProgramRunnerUtil.executeConfiguration(settings, DefaultRunExecutor.getRunExecutorInstance())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LudicRunProjectAction : LudicCliAction(LudicCommand.RUN)
|
||||
class LudicBuildProjectAction : LudicCliAction(LudicCommand.BUILD)
|
||||
class LudicTestProjectAction : LudicCliAction(LudicCommand.TEST)
|
||||
class LudicFormatProjectAction : LudicCliAction(LudicCommand.CUSTOM, "fmt")
|
||||
class LudicGetPackagesAction : LudicCliAction(LudicCommand.CUSTOM, "get")
|
||||
class LudicPackAction : LudicCliAction(LudicCommand.CUSTOM, "pack")
|
||||
class LudicBundleAction : LudicCliAction(LudicCommand.CUSTOM, "bundle")
|
||||
class LudicCleanAction : LudicCliAction(LudicCommand.CUSTOM, "clean")
|
||||
class LudicDoctorAction : LudicCliAction(LudicCommand.CUSTOM, "doctor")
|
||||
|
||||
/** Tools | Ludic | Run Script…: pick one of package.ludic's scripts. */
|
||||
class LudicRunScriptAction : AnAction(), DumbAware {
|
||||
override fun getActionUpdateThread() = ActionUpdateThread.BGT
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
e.presentation.isEnabledAndVisible = e.project != null
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val project = e.project ?: return
|
||||
val base = project.basePath ?: return
|
||||
val start = e.getData(CommonDataKeys.VIRTUAL_FILE)?.let { if (it.isDirectory) it.path else it.parent?.path } ?: base
|
||||
val root = LudicRunConfiguration.packageRootOf(File(start)) ?: File(base)
|
||||
val manifest = File(root, "package.ludic").takeIf { it.isFile }?.let { io.ludic.ide.project.LudicManifest.parse(it.readText()) }
|
||||
val scripts = manifest?.scripts.orEmpty()
|
||||
if (scripts.isEmpty()) {
|
||||
com.intellij.openapi.ui.Messages.showInfoMessage(project,
|
||||
"package.ludic defines no scripts. Add one:\n\nscript \"dev\" \"ludic run --headless\"", "Ludic Scripts")
|
||||
return
|
||||
}
|
||||
com.intellij.openapi.ui.popup.JBPopupFactory.getInstance()
|
||||
.createPopupChooserBuilder(scripts)
|
||||
.setTitle("Run Ludic Script")
|
||||
.setRenderer(com.intellij.ui.SimpleListCellRenderer.create { label, s, _ -> label.text = "${s.name} — ${s.command}" })
|
||||
.setItemChosenCallback { s ->
|
||||
FileDocumentManager.getInstance().saveAllDocuments()
|
||||
LudicCliAction.run(project, LudicCommand.CUSTOM, "script ${s.name}", root.path)
|
||||
}
|
||||
.createPopup()
|
||||
.showCenteredInCurrentWindow(project)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.filters.ConsoleFilterProvider
|
||||
import com.intellij.execution.filters.Filter
|
||||
import com.intellij.execution.filters.OpenFileHyperlinkInfo
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.psi.search.FilenameIndex
|
||||
import com.intellij.psi.search.GlobalSearchScope
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Makes `path/to/file.ludic:42:` clickable. That is the shape of every
|
||||
* compiler error (`game.ludic:4: error: unknown function foo`) and every failed
|
||||
* assertion (`smoke.ludic:12: expect_eq failed`).
|
||||
*
|
||||
* The compiler often prints only the file's base name, so a path that does not
|
||||
* resolve against the working directory falls back to a project-wide lookup by
|
||||
* name, preferring a match under the working directory.
|
||||
*/
|
||||
class LudicConsoleFilter(private val project: Project, private val workDir: String?) : Filter {
|
||||
|
||||
override fun applyFilter(line: String, entireLength: Int): Filter.Result? {
|
||||
val m = LOCATION.find(line) ?: return null
|
||||
val path = m.groupValues[1]
|
||||
val lineNo = m.groupValues[2].toIntOrNull() ?: return null
|
||||
val col = m.groupValues[3].toIntOrNull()
|
||||
val file = resolve(path) ?: return null
|
||||
val lineStart = entireLength - line.length
|
||||
val start = lineStart + m.groups[1]!!.range.first
|
||||
val end = lineStart + m.range.last + 1
|
||||
return Filter.Result(start, end, OpenFileHyperlinkInfo(project, file, lineNo - 1, (col ?: 1) - 1))
|
||||
}
|
||||
|
||||
private fun resolve(path: String): VirtualFile? {
|
||||
val lfs = LocalFileSystem.getInstance()
|
||||
val candidates = buildList {
|
||||
val f = File(path)
|
||||
if (f.isAbsolute) add(f)
|
||||
if (workDir != null) add(File(workDir, path))
|
||||
project.basePath?.let { add(File(it, path)) }
|
||||
}
|
||||
for (c in candidates) {
|
||||
if (c.isFile) lfs.findFileByIoFile(c)?.let { return it }
|
||||
}
|
||||
val name = File(path).name
|
||||
return ApplicationManager.getApplication().runReadAction<VirtualFile?> {
|
||||
if (project.isDisposed) return@runReadAction null
|
||||
val found = FilenameIndex.getVirtualFilesByName(name, GlobalSearchScope.projectScope(project))
|
||||
.filter { it.path.endsWith("/$path") || it.name == path }
|
||||
val wd = workDir?.let { File(it).path + "/" }
|
||||
found.firstOrNull { wd != null && it.path.startsWith(wd) } ?: found.singleOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LOCATION = Regex("""((?:[A-Za-z]:)?[^\s:"'(]*\.ludic):(\d+)(?::(\d+))?""")
|
||||
}
|
||||
}
|
||||
|
||||
/** The same links in every console: Run, Terminal-launched tasks, external tools. */
|
||||
class LudicConsoleFilterProvider : ConsoleFilterProvider {
|
||||
override fun getDefaultFilters(project: Project): Array<Filter> = arrayOf(LudicConsoleFilter(project, project.basePath))
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.DefaultExecutionResult
|
||||
import com.intellij.execution.ExecutionException
|
||||
import com.intellij.execution.ExecutionResult
|
||||
import com.intellij.execution.Executor
|
||||
import com.intellij.execution.configurations.CommandLineState
|
||||
import com.intellij.execution.configurations.ConfigurationFactory
|
||||
import com.intellij.execution.configurations.ConfigurationTypeBase
|
||||
import com.intellij.execution.configurations.GeneralCommandLine
|
||||
import com.intellij.execution.configurations.LocatableConfigurationBase
|
||||
import com.intellij.execution.configurations.LocatableRunConfigurationOptions
|
||||
import com.intellij.execution.configurations.RunConfiguration
|
||||
import com.intellij.execution.configurations.RunProfileState
|
||||
import com.intellij.execution.configurations.RuntimeConfigurationError
|
||||
import com.intellij.execution.process.KillableColoredProcessHandler
|
||||
import com.intellij.execution.process.ProcessEvent
|
||||
import com.intellij.execution.process.ProcessHandler
|
||||
import com.intellij.execution.process.ProcessListener
|
||||
import com.intellij.execution.process.ProcessTerminatedListener
|
||||
import com.intellij.execution.runners.ExecutionEnvironment
|
||||
import com.intellij.execution.runners.ProgramRunner
|
||||
import com.intellij.execution.testframework.sm.SMTestRunnerConnectionUtil
|
||||
import com.intellij.openapi.components.BaseState
|
||||
import com.intellij.openapi.options.SettingsEditor
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.NotNullLazyValue
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VfsUtil
|
||||
import com.intellij.util.execution.ParametersListUtil
|
||||
import io.ludic.ide.LudicIcons
|
||||
import io.ludic.ide.LudicToolchain
|
||||
import java.io.File
|
||||
|
||||
/** What a Ludic run configuration asks the `ludic` CLI to do. */
|
||||
enum class LudicCommand(val cli: String, val label: String) {
|
||||
RUN("run", "Run"),
|
||||
BUILD("build", "Build"),
|
||||
TEST("test", "Test"),
|
||||
CUSTOM("", "Other ludic command");
|
||||
|
||||
override fun toString() = label
|
||||
|
||||
companion object {
|
||||
fun of(name: String?) = entries.firstOrNull { it.name == name } ?: RUN
|
||||
}
|
||||
}
|
||||
|
||||
class LudicConfigurationType : ConfigurationTypeBase(
|
||||
ID, "Ludic", "Build, run or test a Ludic program with the ludic CLI",
|
||||
NotNullLazyValue.createValue { LudicIcons.FILE }
|
||||
) {
|
||||
init {
|
||||
addFactory(LudicConfigurationFactory(this))
|
||||
}
|
||||
|
||||
val factory: ConfigurationFactory get() = configurationFactories.single()
|
||||
|
||||
companion object {
|
||||
const val ID = "LudicRunConfiguration"
|
||||
}
|
||||
}
|
||||
|
||||
class LudicConfigurationFactory(type: LudicConfigurationType) : ConfigurationFactory(type) {
|
||||
override fun getId() = LudicConfigurationType.ID
|
||||
override fun createTemplateConfiguration(project: Project): RunConfiguration =
|
||||
LudicRunConfiguration(project, this, "Ludic")
|
||||
override fun getOptionsClass(): Class<out BaseState> = LudicRunOptions::class.java
|
||||
}
|
||||
|
||||
class LudicRunOptions : LocatableRunConfigurationOptions() {
|
||||
var command by string(LudicCommand.RUN.name)
|
||||
/** The .ludic file; empty means "the project" (ludic finds the entry). */
|
||||
var file by string("")
|
||||
/** One test block to run, by its exact name; empty runs every test. */
|
||||
var testName by string("")
|
||||
var headless by property(false)
|
||||
/** Extra CLI arguments, or the whole command for CUSTOM (e.g. `fmt --check`). */
|
||||
var arguments by string("")
|
||||
var workingDirectory by string("")
|
||||
var env by map<String, String>()
|
||||
var passParentEnv by property(true)
|
||||
}
|
||||
|
||||
class LudicRunConfiguration(project: Project, factory: ConfigurationFactory, name: String) :
|
||||
LocatableConfigurationBase<LudicRunOptions>(project, factory, name) {
|
||||
|
||||
public override fun getOptions(): LudicRunOptions = super.getOptions() as LudicRunOptions
|
||||
|
||||
var command: LudicCommand
|
||||
get() = LudicCommand.of(options.command)
|
||||
set(v) { options.command = v.name }
|
||||
var file: String
|
||||
get() = options.file ?: ""
|
||||
set(v) { options.file = v }
|
||||
var testName: String
|
||||
get() = options.testName ?: ""
|
||||
set(v) { options.testName = v }
|
||||
var headless: Boolean
|
||||
get() = options.headless
|
||||
set(v) { options.headless = v }
|
||||
var arguments: String
|
||||
get() = options.arguments ?: ""
|
||||
set(v) { options.arguments = v }
|
||||
var workingDirectory: String
|
||||
get() = options.workingDirectory ?: ""
|
||||
set(v) { options.workingDirectory = v }
|
||||
var env: Map<String, String>
|
||||
get() = options.env
|
||||
set(v) { options.env.clear(); options.env.putAll(v) }
|
||||
var passParentEnv: Boolean
|
||||
get() = options.passParentEnv
|
||||
set(v) { options.passParentEnv = v }
|
||||
|
||||
override fun getConfigurationEditor(): SettingsEditor<out RunConfiguration> = LudicRunConfigurationEditor(project)
|
||||
|
||||
override fun checkConfiguration() {
|
||||
if (LudicToolchain.cli(project) == null) {
|
||||
throw RuntimeConfigurationError("The 'ludic' command was not found. ${LudicToolchain.INSTALL_HINT}")
|
||||
}
|
||||
if (file.isNotBlank() && !File(file).isFile && !File(resolvedWorkingDirectory(), file).isFile) {
|
||||
throw RuntimeConfigurationError("No such file: $file")
|
||||
}
|
||||
if (command == LudicCommand.CUSTOM && arguments.isBlank()) {
|
||||
throw RuntimeConfigurationError("Name the ludic command to run, for example: fmt --check")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState =
|
||||
LudicRunState(this, environment)
|
||||
|
||||
override fun suggestedName(): String {
|
||||
val f = file.substringAfterLast('/').ifEmpty { project.name }
|
||||
return when (command) {
|
||||
LudicCommand.TEST -> if (testName.isNotEmpty()) "Test '$testName'" else "Test $f"
|
||||
LudicCommand.BUILD -> "Build $f"
|
||||
LudicCommand.RUN -> "Run $f"
|
||||
LudicCommand.CUSTOM -> "ludic $arguments"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured directory, else the package that owns the file (the
|
||||
* nearest directory with a package.ludic, which is where `ludic` expects
|
||||
* to run), else the project root.
|
||||
*/
|
||||
fun resolvedWorkingDirectory(): String {
|
||||
if (workingDirectory.isNotBlank()) return workingDirectory
|
||||
val base = project.basePath
|
||||
if (file.isNotBlank()) {
|
||||
val f = File(file).let { if (it.isAbsolute) it else File(base ?: ".", file) }
|
||||
packageRootOf(f.parentFile)?.let { return it.path }
|
||||
return base ?: f.parent
|
||||
}
|
||||
return base ?: File(".").absolutePath
|
||||
}
|
||||
|
||||
/** The argv after `ludic`, exactly as it will be passed. */
|
||||
fun cliArguments(): List<String> {
|
||||
val args = mutableListOf<String>()
|
||||
val extra = ParametersListUtil.parse(arguments)
|
||||
if (command == LudicCommand.CUSTOM) return extra
|
||||
args += command.cli
|
||||
if (file.isNotBlank()) args += relativeToWorkingDirectory(file)
|
||||
when (command) {
|
||||
LudicCommand.TEST -> {
|
||||
args += "--verbose"
|
||||
if (testName.isNotEmpty()) { args += "--test"; args += testName }
|
||||
}
|
||||
else -> if (headless) args += "--headless"
|
||||
}
|
||||
args += extra
|
||||
return args
|
||||
}
|
||||
|
||||
private fun relativeToWorkingDirectory(path: String): String {
|
||||
val wd = File(resolvedWorkingDirectory())
|
||||
val f = File(path).let { if (it.isAbsolute) it else File(project.basePath ?: wd.path, path) }
|
||||
val rel = runCatching { f.canonicalFile.relativeTo(wd.canonicalFile).path }.getOrNull()
|
||||
return if (rel != null && !rel.startsWith("..")) rel else f.path
|
||||
}
|
||||
|
||||
fun commandLine(): GeneralCommandLine {
|
||||
val cli = LudicToolchain.cli(project)
|
||||
?: throw ExecutionException("The 'ludic' command was not found. ${LudicToolchain.INSTALL_HINT}")
|
||||
return GeneralCommandLine(cli)
|
||||
.withParameters(cliArguments())
|
||||
.withWorkDirectory(resolvedWorkingDirectory())
|
||||
.withEnvironment(env)
|
||||
.withParentEnvironmentType(
|
||||
if (passParentEnv) GeneralCommandLine.ParentEnvironmentType.CONSOLE
|
||||
else GeneralCommandLine.ParentEnvironmentType.NONE
|
||||
)
|
||||
.withCharset(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The nearest directory at or above [dir] holding a package.ludic. */
|
||||
fun packageRootOf(dir: File?): File? {
|
||||
var d = dir
|
||||
while (d != null) {
|
||||
if (File(d, "package.ludic").isFile) return d
|
||||
d = d.parentFile
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LudicRunState(private val config: LudicRunConfiguration, env: ExecutionEnvironment) : CommandLineState(env) {
|
||||
|
||||
override fun startProcess(): ProcessHandler {
|
||||
val handler = KillableColoredProcessHandler(config.commandLine())
|
||||
ProcessTerminatedListener.attach(handler)
|
||||
// get/update/pack/build write files the IDE should see straight away
|
||||
val dir = config.resolvedWorkingDirectory()
|
||||
handler.addProcessListener(object : ProcessListener {
|
||||
override fun processTerminated(event: ProcessEvent) {
|
||||
LocalFileSystem.getInstance().findFileByPath(dir)?.let { VfsUtil.markDirtyAndRefresh(true, true, true, it) }
|
||||
}
|
||||
})
|
||||
return handler
|
||||
}
|
||||
|
||||
override fun execute(executor: Executor, runner: ProgramRunner<*>): ExecutionResult {
|
||||
val filter = LudicConsoleFilter(config.project, config.resolvedWorkingDirectory())
|
||||
if (config.command != LudicCommand.TEST) {
|
||||
addConsoleFilters(filter)
|
||||
return super.execute(executor, runner)
|
||||
}
|
||||
val handler = startProcess()
|
||||
val props = LudicTestConsoleProperties(config, executor)
|
||||
val console = SMTestRunnerConnectionUtil.createAndAttachConsole(LudicTestConsoleProperties.FRAMEWORK, handler, props)
|
||||
console.addMessageFilter(filter)
|
||||
return DefaultExecutionResult(console, handler, *createActions(console, handler, executor))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.configuration.EnvironmentVariablesTextFieldWithBrowseButton
|
||||
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
|
||||
import com.intellij.openapi.options.SettingsEditor
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.ComboBox
|
||||
import com.intellij.openapi.ui.TextFieldWithBrowseButton
|
||||
import com.intellij.ui.RawCommandLineEditor
|
||||
import com.intellij.ui.components.JBCheckBox
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBTextField
|
||||
import com.intellij.util.ui.FormBuilder
|
||||
import io.ludic.ide.LudicFileType
|
||||
import javax.swing.JComponent
|
||||
|
||||
class LudicRunConfigurationEditor(private val project: Project) : SettingsEditor<LudicRunConfiguration>() {
|
||||
private val command = ComboBox(LudicCommand.entries.toTypedArray())
|
||||
private val file = TextFieldWithBrowseButton()
|
||||
private val testName = JBTextField()
|
||||
private val headless = JBCheckBox("Headless (no window: frames go to build/out.ppm, keys come from stdin)")
|
||||
private val arguments = RawCommandLineEditor()
|
||||
private val workingDirectory = TextFieldWithBrowseButton()
|
||||
private val env = EnvironmentVariablesTextFieldWithBrowseButton()
|
||||
private val argumentsLabel = JBLabel("Extra arguments:")
|
||||
|
||||
init {
|
||||
file.addBrowseFolderListener(
|
||||
project,
|
||||
FileChooserDescriptorFactory.createSingleFileDescriptor(LudicFileType).withTitle("Ludic File")
|
||||
)
|
||||
workingDirectory.addBrowseFolderListener(
|
||||
project,
|
||||
FileChooserDescriptorFactory.createSingleFolderDescriptor().withTitle("Working Directory")
|
||||
)
|
||||
testName.emptyText.text = "all tests in the file (or the project's tests/)"
|
||||
file.textField.let { (it as? JBTextField)?.emptyText?.text = "the project's entry program" }
|
||||
workingDirectory.textField.let { (it as? JBTextField)?.emptyText?.text = "the package root that owns the file" }
|
||||
command.addActionListener { updateEnabled() }
|
||||
}
|
||||
|
||||
private fun updateEnabled() {
|
||||
val c = command.item ?: LudicCommand.RUN
|
||||
testName.isEnabled = c == LudicCommand.TEST
|
||||
headless.isEnabled = c == LudicCommand.RUN || c == LudicCommand.BUILD
|
||||
file.isEnabled = c != LudicCommand.CUSTOM
|
||||
argumentsLabel.text = if (c == LudicCommand.CUSTOM) "ludic" else "Extra arguments:"
|
||||
}
|
||||
|
||||
override fun createEditor(): JComponent = FormBuilder.createFormBuilder()
|
||||
.addLabeledComponent("Command:", command)
|
||||
.addLabeledComponent("File:", file)
|
||||
.addLabeledComponent("Test name:", testName)
|
||||
.addComponent(headless)
|
||||
.addLabeledComponent(argumentsLabel, arguments)
|
||||
.addLabeledComponent("Working directory:", workingDirectory)
|
||||
.addLabeledComponent("Environment variables:", env)
|
||||
.panel
|
||||
|
||||
override fun resetEditorFrom(c: LudicRunConfiguration) {
|
||||
command.item = c.command
|
||||
file.text = c.file
|
||||
testName.text = c.testName
|
||||
headless.isSelected = c.headless
|
||||
arguments.text = c.arguments
|
||||
workingDirectory.text = c.workingDirectory
|
||||
env.envs = c.env
|
||||
env.isPassParentEnvs = c.passParentEnv
|
||||
updateEnabled()
|
||||
}
|
||||
|
||||
override fun applyEditorTo(c: LudicRunConfiguration) {
|
||||
c.command = command.item ?: LudicCommand.RUN
|
||||
c.file = file.text.trim()
|
||||
c.testName = testName.text
|
||||
c.headless = headless.isSelected
|
||||
c.arguments = arguments.text
|
||||
c.workingDirectory = workingDirectory.text.trim()
|
||||
c.env = env.envs
|
||||
c.passParentEnv = env.isPassParentEnvs
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.actions.ConfigurationContext
|
||||
import com.intellij.execution.actions.LazyRunConfigurationProducer
|
||||
import com.intellij.execution.configurations.ConfigurationFactory
|
||||
import com.intellij.execution.configurations.ConfigurationTypeUtil
|
||||
import com.intellij.openapi.util.Ref
|
||||
import com.intellij.psi.PsiDirectory
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import io.ludic.ide.LudicFileType
|
||||
import io.ludic.ide.psi.LudicPsi
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Right-click -> Run, and the gutter buttons. What gets run follows from
|
||||
* where the user is:
|
||||
*
|
||||
* - a `test "name"` header -> ludic test <file> --test name
|
||||
* - a test file (tests/, *_test, or
|
||||
* a program made only of tests) -> ludic test <file>
|
||||
* - a file declaring a `program` -> ludic run <file>
|
||||
* - any other .ludic file (a module) -> ludic run, for the package that owns it
|
||||
* - the `tests` directory -> ludic test
|
||||
* - a package root directory -> ludic run
|
||||
*/
|
||||
class LudicRunConfigurationProducer : LazyRunConfigurationProducer<LudicRunConfiguration>() {
|
||||
|
||||
override fun getConfigurationFactory(): ConfigurationFactory =
|
||||
ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java).factory
|
||||
|
||||
private data class Target(val command: LudicCommand, val file: String, val testName: String)
|
||||
|
||||
private fun targetFor(element: PsiElement): Target? {
|
||||
if (element is PsiDirectory) {
|
||||
val dir = File(element.virtualFile.path)
|
||||
return when {
|
||||
dir.name == "tests" && LudicRunConfiguration.packageRootOf(dir.parentFile) == dir.parentFile ->
|
||||
Target(LudicCommand.TEST, "", "")
|
||||
File(dir, "package.ludic").isFile -> Target(LudicCommand.RUN, "", "")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
val file: PsiFile = element.containingFile ?: return null
|
||||
if (file.fileType != LudicFileType) return null
|
||||
val vf = file.virtualFile ?: return null
|
||||
if (file.name == "package.ludic") return Target(LudicCommand.RUN, "", "")
|
||||
|
||||
LudicPsi.enclosingTestKeyword(element)?.let { kw ->
|
||||
return Target(LudicCommand.TEST, vf.path, LudicPsi.testNameAt(kw)!!)
|
||||
}
|
||||
if (LudicPsi.isTestFile(file)) return Target(LudicCommand.TEST, vf.path, "")
|
||||
if (LudicPsi.isProgramFile(file)) return Target(LudicCommand.RUN, vf.path, "")
|
||||
// a module on its own does not run; its package does
|
||||
return if (LudicRunConfiguration.packageRootOf(File(vf.path).parentFile) != null) Target(LudicCommand.RUN, "", "") else null
|
||||
}
|
||||
|
||||
override fun setupConfigurationFromContext(
|
||||
configuration: LudicRunConfiguration,
|
||||
context: ConfigurationContext,
|
||||
sourceElement: Ref<PsiElement>,
|
||||
): Boolean {
|
||||
val element = context.psiLocation ?: return false
|
||||
val t = targetFor(element) ?: return false
|
||||
configuration.command = t.command
|
||||
configuration.file = t.file
|
||||
configuration.testName = t.testName
|
||||
if (t.file.isEmpty()) {
|
||||
// a project-level run executes from the package root
|
||||
val start = (element as? PsiDirectory)?.virtualFile?.path ?: element.containingFile?.virtualFile?.parent?.path
|
||||
start?.let { LudicRunConfiguration.packageRootOf(File(it)) }?.let { configuration.workingDirectory = it.path }
|
||||
}
|
||||
configuration.setGeneratedName()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun isConfigurationFromContext(configuration: LudicRunConfiguration, context: ConfigurationContext): Boolean {
|
||||
val element = context.psiLocation ?: return false
|
||||
val t = targetFor(element) ?: return false
|
||||
return configuration.command == t.command &&
|
||||
configuration.file == t.file &&
|
||||
configuration.testName == t.testName &&
|
||||
configuration.arguments.isBlank()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.lineMarker.ExecutorAction
|
||||
import com.intellij.execution.lineMarker.RunLineMarkerContributor
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.psi.PsiElement
|
||||
import io.ludic.ide.psi.LudicPsi
|
||||
|
||||
/**
|
||||
* The green gutter buttons: one on every `test "name"` (showing that test's
|
||||
* last result), and one on `program` — run it, or run all of its tests when
|
||||
* the file is a test program.
|
||||
*/
|
||||
class LudicRunLineMarker : RunLineMarkerContributor() {
|
||||
|
||||
override fun getInfo(element: PsiElement): Info? {
|
||||
LudicPsi.testNameAt(element)?.let { name ->
|
||||
val path = element.containingFile?.virtualFile?.path ?: return null
|
||||
val icon = getTestStateIcon(LudicTestUrls.test(path, name), element.project, false)
|
||||
return Info(icon, ExecutorAction.getActions(0)) { "Run test '$name'" }
|
||||
}
|
||||
if (LudicPsi.programNameAt(element) != null) {
|
||||
val file = element.containingFile ?: return null
|
||||
if (LudicPsi.isTestFile(file)) {
|
||||
val path = file.virtualFile?.path ?: return null
|
||||
val icon = getTestStateIcon(LudicTestUrls.file(path), element.project, true)
|
||||
return Info(icon, ExecutorAction.getActions(0)) { "Run all tests in ${file.name}" }
|
||||
}
|
||||
return Info(AllIcons.RunConfigurations.TestState.Run, ExecutorAction.getActions(0)) { "Run ${file.name}" }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.Executor
|
||||
import com.intellij.execution.Location
|
||||
import com.intellij.execution.PsiLocation
|
||||
import com.intellij.execution.process.ProcessOutputTypes
|
||||
import com.intellij.execution.testframework.TestConsoleProperties
|
||||
import com.intellij.execution.testframework.sm.ServiceMessageBuilder
|
||||
import com.intellij.execution.testframework.sm.SMCustomMessagesParsing
|
||||
import com.intellij.execution.testframework.sm.runner.OutputToGeneralTestEventsConverter
|
||||
import com.intellij.execution.testframework.sm.runner.SMTRunnerConsoleProperties
|
||||
import com.intellij.execution.testframework.sm.runner.SMTestLocator
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.psi.PsiManager
|
||||
import com.intellij.psi.search.FilenameIndex
|
||||
import com.intellij.psi.search.GlobalSearchScope
|
||||
import io.ludic.ide.psi.LudicPsi
|
||||
import java.io.File
|
||||
|
||||
class LudicTestConsoleProperties(config: LudicRunConfiguration, executor: Executor) :
|
||||
SMTRunnerConsoleProperties(config, FRAMEWORK, executor), SMCustomMessagesParsing {
|
||||
|
||||
private val workDir = config.resolvedWorkingDirectory()
|
||||
|
||||
init {
|
||||
isIdBasedTestTree = false
|
||||
isPrintTestingStartedTime = false
|
||||
}
|
||||
|
||||
override fun getTestLocator(): SMTestLocator = LudicTestLocator
|
||||
|
||||
override fun createTestEventsConverter(testFrameworkName: String, props: TestConsoleProperties): OutputToGeneralTestEventsConverter =
|
||||
LudicTestEventsConverter(testFrameworkName, props, workDir)
|
||||
|
||||
companion object {
|
||||
const val FRAMEWORK = "Ludic"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test URLs: `ludic://<absolute file>` for a file's suite and
|
||||
* `ludic://<absolute file>#<test name>` for one test block. The gutter icons
|
||||
* look test state up by the same string, so the two must agree exactly.
|
||||
*/
|
||||
object LudicTestUrls {
|
||||
const val PROTOCOL = "ludic"
|
||||
|
||||
fun file(path: String) = "$PROTOCOL://$path"
|
||||
fun test(path: String, name: String) = "$PROTOCOL://$path#$name"
|
||||
|
||||
/** (file, test name or null) from the part after `ludic://`. */
|
||||
fun split(path: String): Pair<String, String?> {
|
||||
val at = path.indexOf(".ludic#")
|
||||
return if (at < 0) path to null else path.substring(0, at + 6) to path.substring(at + 7)
|
||||
}
|
||||
}
|
||||
|
||||
object LudicTestLocator : SMTestLocator {
|
||||
override fun getLocation(protocol: String, path: String, project: Project, scope: GlobalSearchScope): List<Location<*>> {
|
||||
if (protocol != LudicTestUrls.PROTOCOL) return emptyList()
|
||||
val (filePath, name) = LudicTestUrls.split(path)
|
||||
val vf = LocalFileSystem.getInstance().findFileByPath(filePath)
|
||||
?: FilenameIndex.getVirtualFilesByName(File(filePath).name, scope).firstOrNull { it.path == filePath }
|
||||
?: return emptyList()
|
||||
val psi = PsiManager.getInstance(project).findFile(vf) ?: return emptyList()
|
||||
if (name == null) return listOf(PsiLocation(psi))
|
||||
val el = LudicPsi.findTest(psi, name) ?: return listOf(PsiLocation(psi))
|
||||
return listOf(PsiLocation(el))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns `ludic test --verbose` output into test events.
|
||||
*
|
||||
* The runner's lines, as the CLI frames them:
|
||||
*
|
||||
* RUN tests/smoke.ludic
|
||||
* smoke.ludic:12: expect_eq failed (got 1, want 2) <- assertion, before its result
|
||||
* ok - adds
|
||||
* FAIL - subtracts
|
||||
* == 1 passed, 1 failed ==
|
||||
* FAIL tests/smoke.ludic (exit 1)
|
||||
*
|
||||
* Older toolchains have no --verbose: they print only `PASS file` for a
|
||||
* passing file and dump the runner output *after* `FAIL file`. Both shapes are
|
||||
* accepted, so a file-level result still shows with an old install.
|
||||
*/
|
||||
open class LudicTestEventsConverter(
|
||||
name: String,
|
||||
props: TestConsoleProperties,
|
||||
private val workDir: String,
|
||||
) : OutputToGeneralTestEventsConverter(name, props) {
|
||||
|
||||
private var suite: String? = null
|
||||
private var suitePath: String? = null
|
||||
private var testsInSuite = 0
|
||||
private var framed = false // saw a RUN line: this CLI speaks --verbose
|
||||
private val pending = StringBuilder() // assertion lines awaiting their test's result
|
||||
private val suiteOutput = StringBuilder() // everything else inside the suite (compile errors)
|
||||
|
||||
override fun processConsistentText(text: String, outputType: Key<*>) {
|
||||
val line = ANSI.replace(text, "").trimEnd('\n', '\r')
|
||||
val t = line.trim()
|
||||
// runner lines start in column 0; the CLI's own lines are indented,
|
||||
// which is what keeps a test named "x.ludic" from reading as a file
|
||||
TEST_RESULT.matchEntire(line)?.let { m ->
|
||||
val (verdict, testName) = m.destructured
|
||||
if (suite == null) openSuite("tests", outputType)
|
||||
reportTest(testName, verdict == "ok", outputType)
|
||||
return
|
||||
}
|
||||
RUN.matchEntire(t)?.let { m ->
|
||||
framed = true
|
||||
closeSuite(outputType)
|
||||
openSuite(m.groupValues[1], outputType)
|
||||
return
|
||||
}
|
||||
FILE_RESULT.matchEntire(t)?.let { m ->
|
||||
val (verdict, file, why) = m.destructured
|
||||
if (!framed) closeSuite(outputType)
|
||||
if (suite == null) {
|
||||
openSuite(file, outputType)
|
||||
// legacy: a passing file prints nothing more; a failing one
|
||||
// dumps its runner output next, so keep the suite open for it
|
||||
if (verdict == "PASS") closeSuite(outputType)
|
||||
else if (why.contains("did not compile")) reportCompileFailure(outputType, why)
|
||||
return
|
||||
}
|
||||
if (verdict == "FAIL" && testsInSuite == 0) reportCompileFailure(outputType, why)
|
||||
closeSuite(outputType)
|
||||
return
|
||||
}
|
||||
NO_SUCH_TEST.matchEntire(t)?.let { m ->
|
||||
if (suite == null) openSuite("tests", outputType)
|
||||
pending.append(line).append('\n')
|
||||
reportTest(m.groupValues[1], false, outputType)
|
||||
return
|
||||
}
|
||||
if (t.startsWith("== ") && t.endsWith(" ==")) {
|
||||
if (FILES_SUMMARY.matches(t)) closeSuite(outputType)
|
||||
forward(text, outputType)
|
||||
return
|
||||
}
|
||||
if (suite != null) {
|
||||
if (ASSERTION.containsMatchIn(t)) pending.append(line).append('\n')
|
||||
else if (t.isNotEmpty()) suiteOutput.append(line).append('\n')
|
||||
}
|
||||
forward(text, outputType)
|
||||
}
|
||||
|
||||
override fun flushBufferOnProcessTermination(code: Int) {
|
||||
closeSuite(ProcessOutputTypes.STDOUT)
|
||||
super.flushBufferOnProcessTermination(code)
|
||||
}
|
||||
|
||||
private fun openSuite(file: String, outputType: Key<*>) {
|
||||
val abs = File(file).let { if (it.isAbsolute) it else File(workDir, file) }.path
|
||||
suite = file
|
||||
suitePath = abs
|
||||
testsInSuite = 0
|
||||
pending.setLength(0)
|
||||
suiteOutput.setLength(0)
|
||||
emit(ServiceMessageBuilder.testSuiteStarted(file).addAttribute("locationHint", LudicTestUrls.file(abs)), outputType)
|
||||
}
|
||||
|
||||
private fun closeSuite(outputType: Key<*>) {
|
||||
val s = suite ?: return
|
||||
suite = null
|
||||
emit(ServiceMessageBuilder.testSuiteFinished(s), outputType)
|
||||
}
|
||||
|
||||
private fun reportTest(name: String, passed: Boolean, outputType: Key<*>) {
|
||||
testsInSuite++
|
||||
val path = suitePath ?: return
|
||||
emit(ServiceMessageBuilder.testStarted(name).addAttribute("locationHint", LudicTestUrls.test(path, name)), outputType)
|
||||
if (!passed) {
|
||||
val message = pending.lines().firstOrNull { it.isNotBlank() }?.let { ASSERTION_PREFIX.replace(it, "") } ?: "test failed"
|
||||
emit(ServiceMessageBuilder.testFailed(name).addAttribute("message", message).addAttribute("details", pending.toString()), outputType)
|
||||
}
|
||||
emit(ServiceMessageBuilder.testFinished(name), outputType)
|
||||
pending.setLength(0)
|
||||
}
|
||||
|
||||
/** A file that never ran shows as one failed node carrying the compiler's output. */
|
||||
private fun reportCompileFailure(outputType: Key<*>, why: String) {
|
||||
val path = suitePath ?: return
|
||||
val node = "(compile)"
|
||||
testsInSuite++
|
||||
emit(ServiceMessageBuilder.testStarted(node).addAttribute("locationHint", LudicTestUrls.file(path)), outputType)
|
||||
val details = suiteOutput.toString().ifBlank { why }
|
||||
val first = suiteOutput.lines().firstOrNull { it.isNotBlank() } ?: why.trim().removeSurrounding("(", ")")
|
||||
emit(ServiceMessageBuilder.testFailed(node).addAttribute("message", first).addAttribute("details", details), outputType)
|
||||
emit(ServiceMessageBuilder.testFinished(node), outputType)
|
||||
}
|
||||
|
||||
private fun emit(msg: ServiceMessageBuilder, outputType: Key<*>) {
|
||||
forward("$msg\n", outputType)
|
||||
}
|
||||
|
||||
/** Hands text on to the platform's parser; the tests intercept it here. */
|
||||
internal open fun forward(text: String, outputType: Key<*>) {
|
||||
super.processConsistentText(text, outputType)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ANSI = Regex("\\[[0-9;]*m")
|
||||
val RUN = Regex("RUN\\s+(.+\\.ludic)")
|
||||
val FILE_RESULT = Regex("(PASS|FAIL)\\s+(\\S.*?\\.ludic)(\\s+\\(.*\\))?")
|
||||
val TEST_RESULT = Regex("(ok|FAIL) +- (.*)")
|
||||
val NO_SUCH_TEST = Regex("no test named \"(.*)\"")
|
||||
val FILES_SUMMARY = Regex("== .*test files (passed|failed) ==")
|
||||
val ASSERTION = Regex("\\.ludic:\\d+: .*failed")
|
||||
val ASSERTION_PREFIX = Regex("^\\S+\\.ludic:\\d+: ")
|
||||
}
|
||||
}
|
||||
|
|
@ -6,17 +6,25 @@
|
|||
<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>Syntax highlighting (customisable under Color Scheme), brace matching and auto-closing,
|
||||
quote pairing, commenting, folding and smart indentation, implemented natively so a
|
||||
<code>.ludic</code> file is edited well with no external process.</li>
|
||||
<li>Completion, diagnostics, hover docs, parameter info, go-to-definition, find usages,
|
||||
highlight usages, rename, formatting, inlay hints, semantic highlighting, the Structure
|
||||
view, breadcrumbs and Go to Symbol via the <code>ludic-lsp</code> language server,
|
||||
through LSP4IJ, so this works in the free IDEs as well as the paid ones.</li>
|
||||
<li>Errors from the Ludic compiler itself, reported on save.</li>
|
||||
<li>Highlighting of <code>```ludic</code> fenced blocks in Markdown.</li>
|
||||
<li>Run configurations for <code>ludic run</code>, <code>build</code> and <code>test</code>,
|
||||
with gutter run buttons on programs and on every <code>test "name"</code> block,
|
||||
results in the test runner tree, and clickable <code>file.ludic:line</code> locations
|
||||
in every console.</li>
|
||||
<li>New Project wizard (<code>ludic new</code>), New Ludic File templates, live templates,
|
||||
and a Tools | Ludic menu (format, fetch packages, pack, bundle, doctor).</li>
|
||||
<li><code>package.ludic</code> manifest highlighting, and <code>```ludic</code> fences in Markdown.</li>
|
||||
</ul>
|
||||
<p>Set the paths under <b>Settings → Languages & Frameworks → Ludic</b>, or leave
|
||||
them blank to use <code>bin/ludic-lsp</code> and <code>bin/ludicc</code> under the
|
||||
project root.</p>
|
||||
them blank to use <code>bin/</code> under the project root, then <code>PATH</code>, then
|
||||
<code>~/.ludic/bin</code>.</p>
|
||||
]]></description>
|
||||
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
|
|
@ -54,6 +62,60 @@
|
|||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicFoldingBuilder"/>
|
||||
|
||||
<lang.quoteHandler
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.editor.LudicQuoteHandler"/>
|
||||
|
||||
<lineIndentProvider implementation="io.ludic.ide.editor.LudicLineIndentProvider"/>
|
||||
<typedHandler implementation="io.ludic.ide.editor.LudicCloserTypedHandler" id="ludicCloser"/>
|
||||
|
||||
<colorSettingsPage implementation="io.ludic.ide.editor.LudicColorSettingsPage"/>
|
||||
<langCodeStyleSettingsProvider implementation="io.ludic.ide.editor.LudicCodeStyleSettingsProvider"/>
|
||||
|
||||
<liveTemplateContext contextId="Ludic" implementation="io.ludic.ide.editor.LudicTemplateContext"/>
|
||||
<defaultLiveTemplates file="/liveTemplates/Ludic.xml"/>
|
||||
<internalFileTemplate name="Ludic Program"/>
|
||||
<internalFileTemplate name="Ludic Module"/>
|
||||
<internalFileTemplate name="Ludic Test"/>
|
||||
<internalFileTemplate name="Ludic File"/>
|
||||
|
||||
<!-- Language-server features LSP4IJ only wires up by itself for TEXT and
|
||||
TextMate files; a language with its own parser opts in per feature. -->
|
||||
<lang.psiStructureViewFactory
|
||||
language="Ludic"
|
||||
implementationClass="com.redhat.devtools.lsp4ij.features.documentSymbol.LSPDocumentSymbolStructureViewFactory"/>
|
||||
<codeInsight.parameterInfo
|
||||
language="Ludic"
|
||||
implementationClass="com.redhat.devtools.lsp4ij.features.signatureHelp.LSPParameterInfoHandler"/>
|
||||
<codeBlockProvider
|
||||
language="Ludic"
|
||||
implementationClass="com.redhat.devtools.lsp4ij.features.codeBlockProvider.LSPCodeBlockProvider"/>
|
||||
|
||||
<configurationType implementation="io.ludic.ide.run.LudicConfigurationType"/>
|
||||
<runConfigurationProducer implementation="io.ludic.ide.run.LudicRunConfigurationProducer"/>
|
||||
<runLineMarkerContributor language="Ludic" implementationClass="io.ludic.ide.run.LudicRunLineMarker"/>
|
||||
<consoleFilterProvider implementation="io.ludic.ide.run.LudicConsoleFilterProvider"/>
|
||||
|
||||
<moduleBuilder builderClass="io.ludic.ide.project.LudicModuleBuilder"/>
|
||||
|
||||
<!-- package.ludic: the package manifest -->
|
||||
<editorNotificationProvider implementation="io.ludic.ide.project.LudicPackageNotificationProvider"/>
|
||||
<runLineMarkerContributor language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestLineMarker"/>
|
||||
<completion.contributor language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestCompletion" order="first"/>
|
||||
<lang.documentationProvider language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestDocumentation" order="first"/>
|
||||
<gotoDeclarationHandler implementation="io.ludic.ide.project.LudicManifestGotoHandler" order="first"/>
|
||||
<annotator language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestAnnotator"/>
|
||||
<iconProvider implementation="io.ludic.ide.project.LudicManifestIconProvider" order="first"/>
|
||||
<additionalLibraryRootsProvider implementation="io.ludic.ide.project.LudicPackagesLibraryProvider"/>
|
||||
<notificationGroup id="Ludic" displayType="BALLOON"/>
|
||||
<postStartupActivity implementation="io.ludic.ide.project.LudicToolchainCheck"/>
|
||||
|
||||
<colorProvider implementation="io.ludic.ide.editor.LudicColorPreview"/>
|
||||
<!-- built-in reference pages as documentation, whatever element points at them -->
|
||||
<documentationProvider implementation="io.ludic.ide.editor.LudicDocPageDocumentation" order="first"/>
|
||||
<!-- a declaration the language server navigated to, described by the server -->
|
||||
<documentationProvider implementation="io.ludic.ide.editor.LudicTargetDocumentation" order="first"/>
|
||||
|
||||
<projectConfigurable
|
||||
parentId="language"
|
||||
instance="io.ludic.ide.LudicConfigurable"
|
||||
|
|
@ -61,6 +123,43 @@
|
|||
displayName="Ludic"/>
|
||||
</extensions>
|
||||
|
||||
<projectListeners>
|
||||
<listener class="io.ludic.ide.project.LudicManifestWatcher"
|
||||
topic="com.intellij.openapi.vfs.newvfs.BulkFileListener"/>
|
||||
</projectListeners>
|
||||
|
||||
<actions>
|
||||
<action id="Ludic.NewFile" class="io.ludic.ide.editor.CreateLudicFileAction"
|
||||
text="Ludic File" description="Create a Ludic program, module or test" icon="/icons/ludic.svg">
|
||||
<add-to-group group-id="NewGroup" anchor="before" relative-to-action="NewFromTemplate"/>
|
||||
</action>
|
||||
<group id="Ludic.Tools" text="Ludic" popup="true" icon="/icons/ludic.svg">
|
||||
<add-to-group group-id="ToolsMenu" anchor="last"/>
|
||||
<action id="Ludic.Run" class="io.ludic.ide.run.LudicRunProjectAction"
|
||||
text="Run Project" description="ludic run"/>
|
||||
<action id="Ludic.Build" class="io.ludic.ide.run.LudicBuildProjectAction"
|
||||
text="Build Project" description="ludic build"/>
|
||||
<action id="Ludic.Test" class="io.ludic.ide.run.LudicTestProjectAction"
|
||||
text="Run All Tests" description="ludic test"/>
|
||||
<action id="Ludic.RunScript" class="io.ludic.ide.run.LudicRunScriptAction"
|
||||
text="Run Script…" description="Run one of package.ludic's scripts"/>
|
||||
<separator/>
|
||||
<action id="Ludic.Format" class="io.ludic.ide.run.LudicFormatProjectAction"
|
||||
text="Format Project" description="ludic fmt: format src/ and tests/ in place"/>
|
||||
<action id="Ludic.Get" class="io.ludic.ide.run.LudicGetPackagesAction"
|
||||
text="Fetch Packages" description="ludic get: resolve and fetch package.ludic dependencies"/>
|
||||
<action id="Ludic.Pack" class="io.ludic.ide.run.LudicPackAction"
|
||||
text="Pack Assets" description="ludic pack: build the .lpak asset pack"/>
|
||||
<action id="Ludic.Bundle" class="io.ludic.ide.run.LudicBundleAction"
|
||||
text="Bundle App" description="ludic bundle: build a macOS .app"/>
|
||||
<action id="Ludic.Clean" class="io.ludic.ide.run.LudicCleanAction"
|
||||
text="Clean" description="ludic clean: remove build/"/>
|
||||
<separator/>
|
||||
<action id="Ludic.Doctor" class="io.ludic.ide.run.LudicDoctorAction"
|
||||
text="Check Toolchain (Doctor)" description="ludic doctor"/>
|
||||
</group>
|
||||
</actions>
|
||||
|
||||
<extensions defaultExtensionNs="com.redhat.devtools.lsp4ij">
|
||||
<server
|
||||
id="ludic"
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
<html><body>Creates a Ludic File. <code>${PROGRAM_NAME}</code> is the file name in UpperCamelCase.</body></html>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# ${NAME}.ludic - import it from a program with: import "${NAME}.ludic"
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
<html><body>Creates a Ludic Module. <code>${PROGRAM_NAME}</code> is the file name in UpperCamelCase.</body></html>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# ${NAME}.ludic
|
||||
program ${PROGRAM_NAME} {
|
||||
|
||||
handler Boot phase Start {
|
||||
print("${PROGRAM_NAME} started")
|
||||
}
|
||||
|
||||
handler Draw phase Render {
|
||||
Screen.clear(0x101820)
|
||||
Screen.show()
|
||||
}
|
||||
|
||||
handler Keys phase Input {
|
||||
if Input.key() == 'q' { quit() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<html><body>Creates a Ludic Program. <code>${PROGRAM_NAME}</code> is the file name in UpperCamelCase.</body></html>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# ${NAME}.ludic - run with: ludic test ${NAME}.ludic
|
||||
program ${PROGRAM_NAME}Spec {
|
||||
test "${NAME} works" {
|
||||
expect_eq(2 + 2, 4)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<html><body>Creates a Ludic Test. <code>${PROGRAM_NAME}</code> is the file name in UpperCamelCase.</body></html>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<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"/>
|
||||
<path d="M16 5 L27 10.5 L27 21.5 L16 27 L5 21.5 L5 10.5 Z" fill="#4a4a72"/>
|
||||
<path d="M16 5 L27 10.5 L16 16 L5 10.5 Z" fill="#ffe060"/>
|
||||
<path d="M16 16 L27 10.5 L27 21.5 L16 27 Z" fill="#9aa0c8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
|
|
@ -0,0 +1,85 @@
|
|||
<templateSet group="Ludic">
|
||||
<template name="prog" value="program $NAME$ { $END$ }" description="program block" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="capitalize(camelCase(fileNameWithoutExtension()))" defaultValue=""Game"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="entry" value="entry { $END$ }" description="entry block (runs once, for a program with no phases)" toReformat="false" toShortenFQNames="false">
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="handler" value="handler $NAME$ phase $PHASE$ { $END$ }" description="phase handler" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""Tick"" alwaysStopAt="true" />
|
||||
<variable name="PHASE" expression="enum("Update", "FixedUpdate", "LateUpdate", "Start", "Input", "Render", "Overlay")" defaultValue=""Update"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="on" value="@On($EVENT$) handler $NAME$ { $END$ }" description="event handler" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="EVENT" expression="" defaultValue=""Hit"" alwaysStopAt="true" />
|
||||
<variable name="NAME" expression="" defaultValue=""OnHit"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="event" value="event $NAME$ { $FIELD$: int = 0 }" description="event declaration" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""Hit"" alwaysStopAt="true" />
|
||||
<variable name="FIELD" expression="" defaultValue=""amount"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="prop" value="property $NAME$ { $FIELD$: $TYPE$ = $VALUE$ }" description="property (a component)" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""Pos"" alwaysStopAt="true" />
|
||||
<variable name="FIELD" expression="" defaultValue=""x"" alwaysStopAt="true" />
|
||||
<variable name="TYPE" expression="enum("int", "fixed", "bool", "string", "entity")" defaultValue=""int"" alwaysStopAt="true" />
|
||||
<variable name="VALUE" expression="" defaultValue=""0"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="model" value="model $NAME$ { $FIELD$: $TYPE$$END$ }" description="model (a record type)" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""Stats"" alwaysStopAt="true" />
|
||||
<variable name="FIELD" expression="" defaultValue=""hp"" alwaysStopAt="true" />
|
||||
<variable name="TYPE" expression="enum("int", "fixed", "bool", "string", "entity")" defaultValue=""int"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="fn" value="function $NAME$($PARAMS$) -> $RET$ { $END$ }" description="function" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""helper"" alwaysStopAt="true" />
|
||||
<variable name="PARAMS" expression="" defaultValue="""" alwaysStopAt="true" />
|
||||
<variable name="RET" expression="enum("void", "int", "fixed", "bool", "string")" defaultValue=""void"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="test" value="test "$NAME$" { $END$ }" description="test block" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""it works"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="expeq" value="expect_eq($ACTUAL$, $EXPECTED$)" description="expect_eq assertion" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="ACTUAL" expression="" defaultValue="" alwaysStopAt="true" />
|
||||
<variable name="EXPECTED" expression="" defaultValue="" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="forq" value="for ($VARS$) in query [$PROPS$] { $END$ }" description="for over an entity query" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="VARS" expression="" defaultValue=""p"" alwaysStopAt="true" />
|
||||
<variable name="PROPS" expression="" defaultValue=""Pos"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="fori" value="for $I$ in $FROM$ .. $TO$ { $END$ }" description="for over a range" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="I" expression="" defaultValue=""i"" alwaysStopAt="true" />
|
||||
<variable name="FROM" expression="" defaultValue=""0"" alwaysStopAt="true" />
|
||||
<variable name="TO" expression="" defaultValue=""10"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="if" value="if $COND$ { $END$ }" description="if block" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="COND" expression="" defaultValue="" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="ife" value="if $COND$ { $THEN$ } else { $END$ }" description="if / else" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="COND" expression="" defaultValue="" alwaysStopAt="true" />
|
||||
<variable name="THEN" expression="" defaultValue="" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="while" value="while $COND$ { $END$ }" description="while loop" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="COND" expression="" defaultValue="" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="spawn" value="spawn $NAME$ { $PROP$ { $END$ } }" description="spawn an entity" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""Thing"" alwaysStopAt="true" />
|
||||
<variable name="PROP" expression="" defaultValue=""Pos"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="imp" value="import "$PATH$"" description="import a module" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="PATH" expression="" defaultValue=""module.ludic"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
</templateSet>
|
||||
|
|
@ -21,6 +21,10 @@ class FindExecutableTest {
|
|||
|
||||
private val names = listOf("ludic-lsp", "ludic-lsp.exe")
|
||||
|
||||
// Every lookup below is cut off from the developer's PATH and ~/.ludic:
|
||||
// a real install on the machine running the tests must not decide them.
|
||||
private val noHome: String get() = File(tmp.root, "no-home").path
|
||||
|
||||
private fun executableAt(vararg segments: String): File {
|
||||
val f = File(tmp.root, segments.joinToString(File.separator))
|
||||
f.parentFile.mkdirs()
|
||||
|
|
@ -30,9 +34,9 @@ class FindExecutableTest {
|
|||
}
|
||||
|
||||
@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))
|
||||
fun `finds the binary that ludic-dev tools produces`() {
|
||||
val exe = executableAt("bin", "ludic-lsp")
|
||||
assertEquals(exe.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -40,19 +44,19 @@ class FindExecutableTest {
|
|||
// 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")
|
||||
executableAt("bin", "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)
|
||||
findExecutable(subdir.path, "", names, SERVER_SUBDIRS, path = null, home = noHome)
|
||||
)
|
||||
}
|
||||
|
||||
@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))
|
||||
fun `search order prefers bin over the other candidates`() {
|
||||
executableAt("build", "ludic-lsp")
|
||||
val preferred = executableAt("bin", "ludic-lsp")
|
||||
assertEquals(preferred.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -66,7 +70,7 @@ class FindExecutableTest {
|
|||
assertEquals(
|
||||
"subdir '$dir' is advertised but not searched",
|
||||
f.canonicalPath,
|
||||
File(findExecutable(fresh.root.path, "", names, SERVER_SUBDIRS)!!).canonicalPath
|
||||
File(findExecutable(fresh.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome)!!).canonicalPath
|
||||
)
|
||||
fresh.delete()
|
||||
}
|
||||
|
|
@ -76,7 +80,7 @@ class FindExecutableTest {
|
|||
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))
|
||||
assertEquals(custom.absolutePath, findExecutable(tmp.root.path, custom.absolutePath, names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -84,7 +88,7 @@ class FindExecutableTest {
|
|||
// 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))
|
||||
assertNull(findExecutable(tmp.root.path, File(tmp.root, "nope").path, names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -93,30 +97,47 @@ class FindExecutableTest {
|
|||
f.parentFile.mkdirs()
|
||||
f.writeText("not executable")
|
||||
f.setExecutable(false)
|
||||
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@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))
|
||||
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the install root is the last resort`() {
|
||||
val installed = executableAt("no-home", "bin", "ludic-lsp")
|
||||
assertEquals(installed.absolutePath, findExecutable(null, "", names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
// ...and a project-local binary still beats it
|
||||
val local = executableAt("bin", "ludic-lsp")
|
||||
assertEquals(local.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PATH is searched before the install root`() {
|
||||
executableAt("no-home", "bin", "ludic-lsp")
|
||||
val onPath = executableAt("somewhere", "ludic-lsp")
|
||||
val path = listOf("", File(tmp.root, "empty").path, onPath.parent).joinToString(File.pathSeparator)
|
||||
assertEquals(onPath.absolutePath, findExecutable(null, "", names, SERVER_SUBDIRS, path = path, home = noHome))
|
||||
}
|
||||
|
||||
@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)
|
||||
findExecutable(null, "", names, SERVER_SUBDIRS, path = null, home = noHome)
|
||||
}
|
||||
|
||||
@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.
|
||||
// `bin/ludic-dev tools` 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")
|
||||
val built = File(repoRoot, "bin/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)
|
||||
val found = findExecutable(repoRoot.path, "", names, SERVER_SUBDIRS, path = null, home = noHome)
|
||||
assertTrue("repo root must resolve to bin/ludic-lsp", found != null)
|
||||
assertEquals(built.canonicalPath, File(found!!).canonicalPath)
|
||||
}
|
||||
}
|
||||
|
|
@ -132,17 +153,23 @@ class ServerCommandTest {
|
|||
|
||||
@Test
|
||||
fun `the repository root produces a runnable stdio command`() {
|
||||
val built = File(repoRoot, "build/ludic-lsp")
|
||||
val built = File(repoRoot, "bin/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"))
|
||||
// and it must actually start, not merely be present. The server reads
|
||||
// stdin until EOF whatever its arguments, so stdin is closed and the
|
||||
// wait is bounded: an open stdin here once hung the whole test run.
|
||||
val proc = ProcessBuilder(cmd)
|
||||
.redirectInput(ProcessBuilder.Redirect.from(File("/dev/null")))
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val exited = proc.waitFor(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||
if (!exited) proc.destroyForcibly()
|
||||
assertTrue("ludic-lsp did not exit on EOF", exited)
|
||||
assertEquals("ludic-lsp exit code on EOF", 0, proc.exitValue())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import io.ludic.ide.editor.LudicColorPreview
|
||||
import io.ludic.ide.editor.LudicDocPageDocumentation
|
||||
import io.ludic.ide.editor.LudicPalette
|
||||
import io.ludic.ide.editor.LudicSemanticColors
|
||||
import java.awt.Color
|
||||
import java.io.File
|
||||
|
||||
/** Colour swatches, semantic colours and the reference-page documentation. */
|
||||
class LudicEditorExtrasTest : BasePlatformTestCase() {
|
||||
|
||||
fun testHexIntsHexStringsAndPaletteNamesHaveSwatches() {
|
||||
val file = myFixture.configureByText("c.ludic", "Screen.clear(0x0d1020)\nScreen.clear(Color.Crimson)\nlet mask = 0xff\n")
|
||||
val provider = LudicColorPreview()
|
||||
val hex = file.findElementAt(file.text.indexOf("0x0d"))!!
|
||||
assertEquals(Color(0x0d1020), provider.getColorFrom(hex))
|
||||
val named = file.findElementAt(file.text.indexOf("Crimson"))!!
|
||||
assertEquals(Color(LudicPalette.lookup("Crimson")!!.toInt(16)), provider.getColorFrom(named))
|
||||
assertNull("a short hex is a mask, not a colour", provider.getColorFrom(file.findElementAt(file.text.indexOf("0xff"))!!))
|
||||
val man = myFixture.configureByText("package.ludic", "app splash_bg \"#0b1622\"\n")
|
||||
assertEquals(Color(0x0b1622), provider.getColorFrom(man.findElementAt(man.text.indexOf("\"#"))!!))
|
||||
}
|
||||
|
||||
fun testPickingAColourRewritesTheLiteral() {
|
||||
val file = myFixture.configureByText("c.ludic", "Screen.clear(Color.Crimson)\n")
|
||||
LudicColorPreview().setColorTo(file.findElementAt(file.text.indexOf("Crimson"))!!, Color(0x123456))
|
||||
assertEquals("Screen.clear(0x123456)\n", myFixture.editor.document.text)
|
||||
}
|
||||
|
||||
fun testPaletteIsBundled() {
|
||||
assertTrue(LudicPalette.all().size > 200)
|
||||
}
|
||||
|
||||
fun testSemanticTokensMapToLudicKeys() {
|
||||
assertSame(LudicColors.FUNCTION_BUILTIN, LudicSemanticColors.keyFor("function", listOf("defaultLibrary")))
|
||||
assertSame(LudicColors.FUNCTION_PACKAGE, LudicSemanticColors.keyFor("function", listOf("vendor")))
|
||||
assertSame(LudicColors.FUNCTION_DECLARATION, LudicSemanticColors.keyFor("function", listOf("declaration")))
|
||||
assertSame(LudicColors.FUNCTION_CALL, LudicSemanticColors.keyFor("function", emptyList()))
|
||||
assertSame(LudicColors.HANDLER, LudicSemanticColors.keyFor("function", listOf("readonly", "declaration")))
|
||||
assertSame(LudicColors.CONSTANT_PACKAGE, LudicSemanticColors.keyFor("variable", listOf("readonly", "static", "vendor")))
|
||||
assertSame(LudicColors.GLOBAL_VARIABLE, LudicSemanticColors.keyFor("variable", listOf("static")))
|
||||
assertSame(LudicColors.LOCAL_VARIABLE, LudicSemanticColors.keyFor("variable", emptyList()))
|
||||
assertSame(LudicColors.NAMESPACE_BUILTIN, LudicSemanticColors.keyFor("namespace", listOf("defaultLibrary")))
|
||||
assertNull("keywords keep the lexer's colour", LudicSemanticColors.keyFor("keyword", emptyList()))
|
||||
}
|
||||
|
||||
fun testReferencePagesRenderAsDocumentation() {
|
||||
val md = "---\nid: type-void\nname: void\nkind: type\nsig: void\ntip: No value.\n---\n\n`void` is what a function returns when it returns nothing.\n\n```ludic\n# doc-check: skip\nfunction f() -> void { }\n```\n"
|
||||
val page = LudicDocPageDocumentation.parse(File("/x/docs/language/types/type-void.md"), md)!!
|
||||
assertEquals("void", page.meta["sig"])
|
||||
val html = LudicDocPageDocumentation.markdownToHtml(page.body)
|
||||
assertTrue(html, html.contains("<code>void</code> is what a function returns"))
|
||||
assertTrue(html, html.contains("function f() -> void"))
|
||||
assertFalse(html, html.contains("doc-check"))
|
||||
assertFalse(html, html.contains("---"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.application.options.CodeStyle
|
||||
import com.intellij.codeInsight.template.impl.TemplateManagerImpl
|
||||
import com.intellij.codeInsight.template.impl.TemplateSettings
|
||||
import com.intellij.ide.fileTemplates.FileTemplateManager
|
||||
import com.intellij.openapi.actionSystem.IdeActions
|
||||
import com.intellij.openapi.options.colors.ColorSettingsPages
|
||||
import com.intellij.psi.search.PsiTodoSearchHelper
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import io.ludic.ide.editor.CreateLudicFileAction
|
||||
import io.ludic.ide.editor.LudicColorSettingsPage
|
||||
import io.ludic.ide.editor.LudicIndenter
|
||||
|
||||
/**
|
||||
* Typing in a .ludic file: indentation, closers, quotes, comments, templates.
|
||||
* Each test drives the real editor actions in a headless fixture, so these
|
||||
* fail when an extension is registered wrongly, not only when the logic is.
|
||||
*/
|
||||
class LudicEditorTest : BasePlatformTestCase() {
|
||||
|
||||
private fun type(before: String, typed: String, after: String) {
|
||||
myFixture.configureByText("t.ludic", before)
|
||||
myFixture.type(typed)
|
||||
val doc = myFixture.editor.document.text
|
||||
val caret = myFixture.editor.caretModel.offset
|
||||
assertEquals(after, doc.substring(0, caret) + "<caret>" + doc.substring(caret))
|
||||
}
|
||||
|
||||
fun testEnterAfterAnOpeningBraceIndentsOneLevel() {
|
||||
type(
|
||||
"program X {<caret>\n}",
|
||||
"\n",
|
||||
"program X {\n <caret>\n}"
|
||||
)
|
||||
}
|
||||
|
||||
fun testEnterInsideNestedBlocksFollowsTheOpenersLine() {
|
||||
type(
|
||||
"program X {\n handler H phase Update {\n for (p) in query [Pos] {<caret>\n }\n }\n}",
|
||||
"\n",
|
||||
"program X {\n handler H phase Update {\n for (p) in query [Pos] {\n <caret>\n }\n }\n}"
|
||||
)
|
||||
}
|
||||
|
||||
fun testEnterAfterAPlainStatementKeepsTheDepth() {
|
||||
type(
|
||||
"program X {\n entry {\n let a = 1<caret>\n }\n}",
|
||||
"\n",
|
||||
"program X {\n entry {\n let a = 1\n <caret>\n }\n}"
|
||||
)
|
||||
}
|
||||
|
||||
fun testBracesInStringsAndCommentsDoNotCount() {
|
||||
val text = "program X {\n let s = \"{{{\" # {\n"
|
||||
assertEquals(" ", LudicIndenter.indentFor(text, text.length, " "))
|
||||
}
|
||||
|
||||
fun testTypingAClosingBraceDedentsTheLine() {
|
||||
type(
|
||||
"program X {\n entry {\n let a = 1\n <caret>\n}",
|
||||
"}",
|
||||
"program X {\n entry {\n let a = 1\n }<caret>\n}"
|
||||
)
|
||||
}
|
||||
|
||||
fun testAClosingBraceAfterCodeIsLeftAlone() {
|
||||
type(
|
||||
"program X {\n entry { let a = 1 <caret>\n}",
|
||||
"}",
|
||||
"program X {\n entry { let a = 1 }<caret>\n}"
|
||||
)
|
||||
}
|
||||
|
||||
fun testQuotesAndBracketsPairUp() {
|
||||
type("program X {\n let s = <caret>\n}", "\"", "program X {\n let s = \"<caret>\"\n}")
|
||||
type("program X {\n f<caret>\n}", "(", "program X {\n f(<caret>)\n}")
|
||||
type("program X {\n let a = <caret>\n}", "[", "program X {\n let a = [<caret>]\n}")
|
||||
}
|
||||
|
||||
fun testBackticksPairAndHolesMatch() {
|
||||
type("program X {\n let s = <caret>\n}", "`", "program X {\n let s = `<caret>`\n}")
|
||||
}
|
||||
|
||||
fun testLineCommentActionUsesHash() {
|
||||
myFixture.configureByText("t.ludic", "let a = 1<caret>\n")
|
||||
myFixture.performEditorAction(IdeActions.ACTION_COMMENT_LINE)
|
||||
assertEquals("# let a = 1\n", myFixture.editor.document.text)
|
||||
}
|
||||
|
||||
fun testTodoCommentsAreIndexed() {
|
||||
val file = myFixture.configureByText("t.ludic", "# TODO: make it faster\nlet a = 1\n")
|
||||
val todos = PsiTodoSearchHelper.getInstance(project).findTodoItems(file)
|
||||
assertEquals(1, todos.size)
|
||||
}
|
||||
|
||||
fun testManifestDirectivesHighlightOnlyInPackageLudic() {
|
||||
val manifest = LudicSyntaxHighlighterFactory().getSyntaxHighlighter(project, myFixture.configureByText("package.ludic", "require \"x\" \"1\"").virtualFile)
|
||||
val lexer = manifest.highlightingLexer
|
||||
lexer.start("require \"x\" \"1\"")
|
||||
assertEquals(LudicTokens.MANIFEST_KEYWORD, lexer.tokenType)
|
||||
|
||||
val plain = LudicSyntaxHighlighterFactory().getSyntaxHighlighter(project, myFixture.configureByText("main.ludic", "require").virtualFile)
|
||||
val plainLexer = plain.highlightingLexer
|
||||
plainLexer.start("require")
|
||||
assertEquals(LudicTokens.IDENTIFIER, plainLexer.tokenType)
|
||||
}
|
||||
|
||||
fun testColorSettingsPageIsRegisteredAndCoversEveryKey() {
|
||||
val page = ColorSettingsPages.getInstance().registeredPages.filterIsInstance<LudicColorSettingsPage>().single()
|
||||
val keys = page.attributeDescriptors.map { it.key }.toSet()
|
||||
for (field in LudicColors::class.java.declaredFields) {
|
||||
val value = runCatching { field.isAccessible = true; field.get(LudicColors) }.getOrNull()
|
||||
if (value is com.intellij.openapi.editor.colors.TextAttributesKey) {
|
||||
assertTrue("color key ${value.externalName} missing from the settings page", value in keys)
|
||||
}
|
||||
}
|
||||
// the demo text must lex cleanly, or the preview shows red
|
||||
val lexer = page.highlighter.highlightingLexer
|
||||
lexer.start(page.demoText)
|
||||
while (lexer.tokenType != null) {
|
||||
assertNotSame("bad character in demo text at ${lexer.tokenStart}", LudicTokens.BAD, lexer.tokenType)
|
||||
lexer.advance()
|
||||
}
|
||||
}
|
||||
|
||||
fun testCodeStyleDefaultsToTwoSpaces() {
|
||||
val opts = CodeStyle.getSettings(project).getIndentOptions(LudicFileType)
|
||||
assertEquals(2, opts.INDENT_SIZE)
|
||||
assertFalse(opts.USE_TAB_CHARACTER)
|
||||
}
|
||||
|
||||
fun testLiveTemplatesAreRegisteredAndExpand() {
|
||||
val template = TemplateSettings.getInstance().getTemplate("test", "Ludic")
|
||||
assertNotNull("the `test` live template is not registered", template)
|
||||
TemplateManagerImpl.setTemplateTesting(testRootDisposable)
|
||||
myFixture.configureByText("t.ludic", "program X {\n test<caret>\n}")
|
||||
myFixture.performEditorAction(IdeActions.ACTION_EXPAND_LIVE_TEMPLATE_BY_TAB)
|
||||
assertTrue(myFixture.editor.document.text, myFixture.editor.document.text.contains("test \"it works\" {"))
|
||||
}
|
||||
|
||||
fun testLiveTemplatesStayOutOfStringsAndComments() {
|
||||
myFixture.configureByText("t.ludic", "# test<caret>\n")
|
||||
myFixture.performEditorAction(IdeActions.ACTION_EXPAND_LIVE_TEMPLATE_BY_TAB)
|
||||
assertFalse(myFixture.editor.document.text.contains("it works"))
|
||||
}
|
||||
|
||||
fun testNewFileTemplatesExistAndRenderAProgramName() {
|
||||
val mgr = FileTemplateManager.getInstance(project)
|
||||
for (name in listOf(CreateLudicFileAction.PROGRAM, CreateLudicFileAction.MODULE, CreateLudicFileAction.TEST, CreateLudicFileAction.EMPTY)) {
|
||||
val t = mgr.getInternalTemplate(name)
|
||||
assertEquals("ludic", t.extension)
|
||||
}
|
||||
val props = mgr.defaultProperties.apply {
|
||||
setProperty("NAME", "enemy_ai")
|
||||
setProperty("PROGRAM_NAME", CreateLudicFileAction.programName("enemy_ai"))
|
||||
}
|
||||
val text = mgr.getInternalTemplate(CreateLudicFileAction.PROGRAM).getText(props)
|
||||
assertTrue(text, text.contains("program EnemyAi {"))
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(text)
|
||||
while (lexer.tokenType != null) {
|
||||
assertNotSame(LudicTokens.BAD, lexer.tokenType)
|
||||
lexer.advance()
|
||||
}
|
||||
}
|
||||
|
||||
fun testProgramNamesFromFileNames() {
|
||||
assertEquals("EnemyAi", CreateLudicFileAction.programName("enemy_ai"))
|
||||
assertEquals("Hud", CreateLudicFileAction.programName("hud"))
|
||||
assertEquals("P2d", CreateLudicFileAction.programName("2d"))
|
||||
assertEquals("MyGame", CreateLudicFileAction.programName("my-game"))
|
||||
}
|
||||
}
|
||||
|
|
@ -33,8 +33,8 @@ class LudicLexerTest {
|
|||
@Test
|
||||
fun `declaration keywords are distinct from statement keywords`() {
|
||||
assertEquals(
|
||||
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.BRACE),
|
||||
typesOf("game Hello {")
|
||||
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.LBRACE),
|
||||
typesOf("program Hello {")
|
||||
)
|
||||
assertEquals(LudicTokens.KEYWORD, typesOf("if x { }").first())
|
||||
}
|
||||
|
|
@ -42,10 +42,10 @@ class LudicLexerTest {
|
|||
@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.
|
||||
// whole reason a handler signature reads as a signature.
|
||||
assertEquals(
|
||||
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.CLAUSE, LudicTokens.PHASE),
|
||||
typesOf("system Move phase FixedUpdate")
|
||||
typesOf("handler Move phase FixedUpdate")
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -60,10 +60,87 @@ class LudicLexerTest {
|
|||
@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.BUILTIN, typesOf("arg_count").single()) // intrinsic
|
||||
assertEquals(LudicTokens.IDENTIFIER, typesOf("my_helper").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `declaration words used as names are names`() {
|
||||
// the compiler reserves these only as function names
|
||||
assertEquals(LudicTokens.IDENTIFIER, lex("var model = look_model(x)")[1].first)
|
||||
assertEquals(LudicTokens.IDENTIFIER, lex(" model: Model,")[0].first)
|
||||
assertEquals(LudicTokens.IDENTIFIER, lex("a.model = m")[2].first)
|
||||
assertEquals(LudicTokens.IDENTIFIER, lex("if model == null { }")[1].first)
|
||||
assertEquals(LudicTokens.IDENTIFIER, lex("f(model, 1)")[2].first)
|
||||
assertEquals(LudicTokens.DECL_KEYWORD, lex("model Actor {")[0].first)
|
||||
assertEquals(LudicTokens.DECL_KEYWORD, lex("{\n model Score { a: int }")[1].first)
|
||||
assertEquals(LudicTokens.CLAUSE, lex("for (p) in query [Pos] { }").first { it.second == "query" }.first)
|
||||
assertEquals(LudicTokens.DECL_KEYWORD, lex("@On(Hit) handler Armor { }").first { it.second == "handler" }.first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restarting mid-file with the saved state lexes the same`() {
|
||||
val text = "program X {\n var model = 1\n model Foo { model: int }\n}"
|
||||
val full = LudicLexer()
|
||||
full.start(text, 0, text.length, 0)
|
||||
val states = mutableListOf<Triple<Int, Int, IElementType>>()
|
||||
while (full.tokenType != null) { states += Triple(full.tokenStart, full.state, full.tokenType!!); full.advance() }
|
||||
for ((start, state, _) in states) {
|
||||
val part = LudicLexer()
|
||||
part.start(text, start, text.length, state)
|
||||
val rest = states.filter { it.first >= start }.map { it.third }
|
||||
val got = mutableListOf<IElementType>()
|
||||
while (part.tokenType != null) { got += part.tokenType!!; part.advance() }
|
||||
assertEquals("restart at $start", rest, got)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `template strings lex their holes as code`() {
|
||||
val toks = lex("push(lods, gltf_load(dir, \"rock.gltf\", `rock_{variant}_LOD{k}`))")
|
||||
val t = toks.dropWhile { it.first != LudicTokens.TEMPLATE_QUOTE }
|
||||
assertEquals(
|
||||
listOf(
|
||||
LudicTokens.TEMPLATE_QUOTE to "`", LudicTokens.TEMPLATE_TEXT to "rock_",
|
||||
LudicTokens.TEMPLATE_HOLE_START to "{", LudicTokens.IDENTIFIER to "variant", LudicTokens.TEMPLATE_HOLE_END to "}",
|
||||
LudicTokens.TEMPLATE_TEXT to "_LOD",
|
||||
LudicTokens.TEMPLATE_HOLE_START to "{", LudicTokens.IDENTIFIER to "k", LudicTokens.TEMPLATE_HOLE_END to "}",
|
||||
LudicTokens.TEMPLATE_QUOTE to "`", LudicTokens.RPAREN to ")", LudicTokens.RPAREN to ")",
|
||||
),
|
||||
t
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `template escapes, nested braces and strings in holes`() {
|
||||
val toks = lex("`a {{b}} \\n {Pos { x: 1 }.x} {f(\"}\")}`")
|
||||
assertEquals(2, toks.count { it.first == LudicTokens.TEMPLATE_QUOTE })
|
||||
assertEquals(listOf("{{", "}}", "\\n"), toks.filter { it.first == LudicTokens.TEMPLATE_ESCAPE }.map { it.second })
|
||||
assertEquals(listOf(LudicTokens.LBRACE, LudicTokens.RBRACE), toks.filter { it.first == LudicTokens.LBRACE || it.first == LudicTokens.RBRACE }.map { it.first })
|
||||
assertEquals(2, toks.count { it.first == LudicTokens.TEMPLATE_HOLE_END })
|
||||
assertEquals("\"}\"", toks.single { it.first == LudicTokens.STRING }.second)
|
||||
// code after the template is code again
|
||||
val after = lex("`x {y}` let z = 1")
|
||||
assertEquals(LudicTokens.KEYWORD, after.first { it.second == "let" }.first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-line template restarts correctly mid-text`() {
|
||||
val text = "let s = `one {a}\ntwo {b} three`\nlet t = 1"
|
||||
val full = LudicLexer()
|
||||
full.start(text, 0, text.length, 0)
|
||||
val all = mutableListOf<Triple<Int, Int, IElementType>>()
|
||||
while (full.tokenType != null) { all += Triple(full.tokenStart, full.state, full.tokenType!!); full.advance() }
|
||||
for ((start, state, _) in all) {
|
||||
val part = LudicLexer()
|
||||
part.start(text, start, text.length, state)
|
||||
val got = mutableListOf<IElementType>()
|
||||
while (part.tokenType != null) { got += part.tokenType!!; part.advance() }
|
||||
assertEquals("restart at $start", all.filter { it.first >= start }.map { it.third }, got)
|
||||
}
|
||||
assertEquals(LudicTokens.KEYWORD, lex(text).last { it.second == "let" }.first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capitalised words read as type names`() {
|
||||
assertEquals(LudicTokens.TYPE_NAME, typesOf("Pos").single())
|
||||
|
|
@ -127,7 +204,7 @@ class LudicLexerTest {
|
|||
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 nasty = "program X { property ¿ { a: int = ' } # é\n query [{Tag}] \"unclosed"
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(nasty, 0, nasty.length, 0)
|
||||
var covered = 0
|
||||
|
|
@ -145,17 +222,20 @@ class LudicLexerTest {
|
|||
@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 }
|
||||
program Hello {
|
||||
property Pos { x: int = 0, y: int = 0 }
|
||||
model Mob { a: int, b: fixed }
|
||||
|
||||
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) }
|
||||
handler Move phase FixedUpdate {
|
||||
for (p) in query [Pos] where p.x > 0 {
|
||||
p.x = p.x + 1
|
||||
Screen.fill_rectangle(x: p.x, y: 0, width: 4, height: 4, color: Color.White)
|
||||
}
|
||||
for i in 0 .. 10 { print(string(i)) }
|
||||
}
|
||||
|
||||
test "adds" { expect_eq(1 + 1, 2) }
|
||||
|
||||
ui Menu {
|
||||
panel id=Root w=288 pad=16 {
|
||||
button id=Go text="Go" size=16
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.execution.lineMarker.RunLineMarkerContributor
|
||||
import com.intellij.ide.IconProvider
|
||||
import com.intellij.psi.util.PsiTreeUtil
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import io.ludic.ide.project.LudicManifest
|
||||
import io.ludic.ide.project.LudicManifestDocumentation
|
||||
import io.ludic.ide.project.LudicPackageIcons
|
||||
import io.ludic.ide.project.LudicPackageNotificationProvider
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
/** package.ludic as a package file: the banner, gutter actions, completion, docs. */
|
||||
class LudicManifestTest : BasePlatformTestCase() {
|
||||
|
||||
private val manifest = """
|
||||
package "git.example.com/me/game" # the import path
|
||||
version "0.2.0"
|
||||
kind source
|
||||
|
||||
require "git.example.com/me/util" "1.0.0"
|
||||
require "git.example.com/me/fx" "0.3.1"
|
||||
|
||||
app name "My Game"
|
||||
pack "assets/kit"
|
||||
""".trimIndent()
|
||||
|
||||
fun testParsingFollowsTheCli() {
|
||||
val m = LudicManifest.parse(manifest)
|
||||
assertEquals("git.example.com/me/game", m.module)
|
||||
assertEquals("0.2.0", m.version)
|
||||
assertEquals("source", m.kind)
|
||||
assertEquals(listOf("git.example.com/me/util", "git.example.com/me/fx"), m.requires.map { it.module })
|
||||
assertEquals(listOf(4, 5), m.requires.map { it.line })
|
||||
assertEquals(listOf("app", "name", "My Game"), LudicManifest.words("app name \"My Game\" # comment"))
|
||||
}
|
||||
|
||||
fun testStaleWhenTheLockIsMissingOrOlder() {
|
||||
val dir = Files.createTempDirectory("ludic-manifest").toFile()
|
||||
try {
|
||||
val m = LudicManifest.parse(manifest)
|
||||
File(dir, "package.ludic").writeText(manifest)
|
||||
assertNotNull("no lock yet", LudicManifest.staleReason(dir, m))
|
||||
val lock = File(dir, "package.lock.ludic").apply { writeText("lock 1\n") }
|
||||
File(dir, "ludic_modules").mkdirs()
|
||||
lock.setLastModified(File(dir, "package.ludic").lastModified() + 5_000)
|
||||
assertNull("lock newer than the manifest", LudicManifest.staleReason(dir, m))
|
||||
File(dir, "package.ludic").setLastModified(lock.lastModified() + 5_000)
|
||||
assertNotNull("manifest edited after the lock", LudicManifest.staleReason(dir, m))
|
||||
assertNull("no requires, nothing to fetch", LudicManifest.staleReason(dir, LudicManifest.parse("package \"x\"")))
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
fun testDirectivesCompleteAtLineStart() {
|
||||
myFixture.configureByText("package.ludic", "package \"x\"\nre<caret>")
|
||||
val items = myFixture.completeBasic()?.map { it.lookupString } ?: listOf(myFixture.editor.document.text)
|
||||
assertTrue(items.toString(), items.any { it.startsWith("require") } || myFixture.editor.document.text.contains("require "))
|
||||
assertFalse("no Ludic keywords in a manifest", items.contains("let"))
|
||||
}
|
||||
|
||||
fun testAppKeysAndKindsComplete() {
|
||||
myFixture.configureByText("package.ludic", "app <caret>")
|
||||
val keys = myFixture.completeBasic().map { it.lookupString.trim() }
|
||||
assertTrue(keys.containsAll(listOf("name", "id", "icon", "splash_bg")))
|
||||
myFixture.configureByText("package.ludic", "kind <caret>")
|
||||
assertEquals(setOf("source", "prebuilt"), myFixture.completeBasic().map { it.lookupString }.toSet())
|
||||
}
|
||||
|
||||
fun testManifestCompletionStaysOutOfSource() {
|
||||
myFixture.configureByText("main.ludic", "program X {\n re<caret>\n}")
|
||||
val items = myFixture.completeBasic()?.map { it.lookupString } ?: emptyList()
|
||||
assertFalse(items.any { it.startsWith("require ") })
|
||||
}
|
||||
|
||||
fun testGutterActionsOnPackageAndRequireLines() {
|
||||
myFixture.configureByText("package.ludic", manifest)
|
||||
val marked = PsiTreeUtil.collectElements(myFixture.file) { it.firstChild == null }
|
||||
.mapNotNull { e ->
|
||||
RunLineMarkerContributor.EXTENSION.allForLanguage(LudicLanguage)
|
||||
.firstNotNullOfOrNull { it.getInfo(e) }?.let { e.text to it.actions.size }
|
||||
}
|
||||
assertEquals(listOf("package" to 5, "require" to 3, "require" to 3), marked)
|
||||
}
|
||||
|
||||
fun testDirectivesHaveQuickDocumentation() {
|
||||
myFixture.configureByText("package.ludic", manifest)
|
||||
val file = myFixture.file
|
||||
val req = file.findElementAt(file.text.indexOf("require"))!!
|
||||
val doc = LudicManifestDocumentation().generateDoc(req, req)
|
||||
assertNotNull(doc)
|
||||
assertTrue(doc!!.contains("minimum version"))
|
||||
val key = file.findElementAt(file.text.indexOf("name \"My"))!!
|
||||
assertTrue(LudicManifestDocumentation().generateDoc(key, key)!!.contains("display name"))
|
||||
val str = file.findElementAt(file.text.indexOf("0.2.0"))!!
|
||||
assertTrue("a version value explains its tag", LudicManifestDocumentation().generateDoc(str, str)!!.contains("v0.2.0"))
|
||||
val comment = file.findElementAt(file.text.indexOf("the import path"))!!
|
||||
assertNotNull(LudicManifestDocumentation().generateDoc(comment, comment))
|
||||
}
|
||||
|
||||
fun testBannerAppearsOnlyOnTheManifest() {
|
||||
val provider = LudicPackageNotificationProvider()
|
||||
val man = myFixture.configureByText("package.ludic", manifest).virtualFile
|
||||
assertNotNull(provider.collectNotificationData(project, man))
|
||||
val src = myFixture.configureByText("main.ludic", "program X {}").virtualFile
|
||||
assertNull(provider.collectNotificationData(project, src))
|
||||
}
|
||||
|
||||
fun testPathValuesNavigateAndMissingOnesAreErrors() {
|
||||
myFixture.addFileToProject("assets/app/icon.png", "png")
|
||||
myFixture.addFileToProject("src/game.ludic", "program G {}")
|
||||
val file = myFixture.configureByText("package.ludic", "entry \"src/game.ludic\"\napp icon \"assets/app/icon.png\"\npack \"assets/nope\"\nkind sauce\nbogus 1\n")
|
||||
val entry = file.findElementAt(file.text.indexOf("\"src/"))!!
|
||||
val targets = io.ludic.ide.project.LudicManifestGotoHandler().getGotoDeclarationTargets(entry, entry.textOffset, myFixture.editor)
|
||||
assertEquals("game.ludic", (targets!!.single() as com.intellij.psi.PsiFile).name)
|
||||
val icon = file.findElementAt(file.text.indexOf("\"assets/app"))!!
|
||||
assertNotNull(io.ludic.ide.project.manifestTarget(icon))
|
||||
val problems = myFixture.doHighlighting().map { it.description }
|
||||
assertTrue(problems.toString(), problems.any { it?.contains("No such file or directory: assets/nope") == true })
|
||||
assertTrue(problems.toString(), problems.any { it?.startsWith("kind must be one of") == true })
|
||||
assertTrue(problems.toString(), problems.any { it?.contains("Unknown directive 'bogus'") == true })
|
||||
assertFalse(problems.toString(), problems.any { it?.contains("icon.png") == true })
|
||||
}
|
||||
|
||||
fun testAppLinesPreviewTheWholeApp() {
|
||||
val file = myFixture.configureByText("package.ludic", "app name \"Maroon Lake\"\napp id \"io.x.lake\"\napp splash_bg \"#0b1622\"\nversion \"0.1.7\"\n")
|
||||
val el = file.findElementAt(file.text.indexOf("io.x"))!!
|
||||
val doc = LudicManifestDocumentation().generateDoc(el, el)!!
|
||||
assertTrue(doc, doc.contains("Maroon Lake.app"))
|
||||
assertTrue(doc, doc.contains("io.x.lake"))
|
||||
assertTrue(doc, doc.contains("0.1.7"))
|
||||
assertTrue(doc, doc.contains("#0b1622"))
|
||||
val kind = myFixture.configureByText("package.ludic", "kind source\n")
|
||||
val src = kind.findElementAt(kind.text.indexOf("source"))!!
|
||||
assertTrue(LudicManifestDocumentation().generateDoc(src, src)!!.contains("Compiled from source"))
|
||||
}
|
||||
|
||||
fun testLockFileIsReadAsALock() {
|
||||
val text = "# generated\nlock 1\nmodule \"git.x/me/util\" version \"1.2.0\" hash \"abc\" kind \"source\" provides \"Util\"\n"
|
||||
val locked = LudicManifest.parseLock(text).single()
|
||||
assertEquals("git.x/me/util", locked.module)
|
||||
assertEquals("1.2.0", locked.version)
|
||||
assertEquals(listOf("Util"), locked.provides)
|
||||
val file = myFixture.configureByText("package.lock.ludic", text)
|
||||
assertNotNull(LudicPackageNotificationProvider().collectNotificationData(project, file.virtualFile))
|
||||
val mod = file.findElementAt(file.text.indexOf("module"))!!
|
||||
val doc = LudicManifestDocumentation().generateDoc(mod, mod)!!
|
||||
assertTrue(doc, doc.contains("1.2.0") && doc.contains("Util.*"))
|
||||
val problems = myFixture.doHighlighting().map { it.description }
|
||||
assertTrue(problems.toString(), problems.any { it?.contains("not fetched yet") == true })
|
||||
}
|
||||
|
||||
fun testScriptsAndHooks() {
|
||||
val text = "script \"dev\" \"ludic run --headless\"\nscript \"build\" \"echo \\\"x\\\"\"\nhook before build \"tools/prep.sh\"\nhook after dev \"echo done\"\nhook during build \"x\"\nhook before deploy \"x\"\n"
|
||||
val m = LudicManifest.parse(text)
|
||||
assertEquals(listOf("dev", "build"), m.scripts.map { it.name })
|
||||
assertEquals("echo \"x\"", m.scripts[1].command)
|
||||
assertEquals(listOf("before build", "after dev", "during build", "before deploy"), m.hooks.map { "${it.phase} ${it.event}" })
|
||||
|
||||
myFixture.configureByText("package.ludic", text)
|
||||
val problems = myFixture.doHighlighting().mapNotNull { it.description }
|
||||
assertTrue(problems.toString(), problems.any { it.startsWith("A hook runs before or after") })
|
||||
assertTrue(problems.toString(), problems.any { it.startsWith("No command or script named 'deploy'") })
|
||||
assertTrue(problems.toString(), problems.any { it.contains("'ludic build' is a built-in command") })
|
||||
assertFalse(problems.toString(), problems.any { it.contains("'dev'") })
|
||||
|
||||
val marked = PsiTreeUtil.collectElements(myFixture.file) { it.firstChild == null }
|
||||
.mapNotNull { e -> RunLineMarkerContributor.EXTENSION.allForLanguage(LudicLanguage).firstNotNullOfOrNull { it.getInfo(e) }?.let { e.text } }
|
||||
assertEquals(listOf("script", "script"), marked)
|
||||
|
||||
val hook = myFixture.file.findElementAt(text.indexOf("hook after"))!!
|
||||
assertTrue(LudicManifestDocumentation().generateDoc(hook, hook)!!.contains("the script dev"))
|
||||
}
|
||||
|
||||
fun testHookEventsComplete() {
|
||||
myFixture.configureByText("package.ludic", "script \"dev\" \"x\"\nhook before <caret>")
|
||||
val items = myFixture.completeBasic().map { it.lookupString.trim() }
|
||||
assertTrue(items.toString(), items.containsAll(listOf("build", "bundle", "test", "dev")))
|
||||
myFixture.configureByText("package.ludic", "hook <caret>")
|
||||
assertEquals(setOf("before", "after"), myFixture.completeBasic().map { it.lookupString.trim() }.toSet())
|
||||
}
|
||||
|
||||
fun testManifestHasItsOwnIcon() {
|
||||
val file = myFixture.configureByText("package.ludic", manifest)
|
||||
val icon = IconProvider.EXTENSION_POINT_NAME.extensionList.firstNotNullOfOrNull { it.getIcon(file, 0) }
|
||||
assertSame(LudicPackageIcons.MANIFEST, icon)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import io.ludic.ide.project.packageSlug
|
||||
import io.ludic.ide.project.scaffold
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
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
|
||||
|
||||
class LudicNewProjectTest {
|
||||
|
||||
@get:Rule
|
||||
val tmp = TemporaryFolder()
|
||||
|
||||
private val repoCli: File =
|
||||
File(System.getProperty("user.dir")).parentFile.parentFile.parentFile.resolve("bin/ludic")
|
||||
|
||||
@Test
|
||||
fun `project names become names ludic new accepts`() {
|
||||
assertEquals("my-game", packageSlug("my game"))
|
||||
assertEquals("Maroon-Lake", packageSlug("Maroon Lake!"))
|
||||
assertEquals("space_1.0", packageSlug("space_1.0"))
|
||||
assertEquals("game", packageSlug("!!!"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scaffold fills a directory the wizard already created`() {
|
||||
if (!repoCli.canExecute()) return // toolchain not built in this checkout
|
||||
val dir = File(tmp.root, "My Game").apply { mkdirs() }
|
||||
File(dir, ".idea").mkdirs()
|
||||
File(dir, "README.md").writeText("mine")
|
||||
|
||||
assertNull(scaffold(dir, "My Game", repoCli.path))
|
||||
|
||||
assertTrue(File(dir, "package.ludic").readText().contains("My-Game"))
|
||||
assertTrue(File(dir, "src/main.ludic").readText().contains("program "))
|
||||
assertTrue(File(dir, "tests/smoke.ludic").isFile)
|
||||
assertTrue(File(dir, "assets").isDirectory)
|
||||
assertTrue(File(dir, ".idea").isDirectory)
|
||||
assertEquals("an existing file is never overwritten", "mine", File(dir, "README.md").readText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scaffold without a toolchain explains itself`() {
|
||||
val msg = scaffold(File(tmp.root, "x"), "x", cli = null)
|
||||
assertNotNull(msg)
|
||||
assertTrue(msg!!.contains("install", ignoreCase = true))
|
||||
}
|
||||
}
|
||||
|
|
@ -15,15 +15,15 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
|||
class LudicPlatformTest : BasePlatformTestCase() {
|
||||
|
||||
private val sample = """
|
||||
game Hello {
|
||||
component Pos { x: int = 0, y: int = 0 }
|
||||
archetype Mob { Pos }
|
||||
program Hello {
|
||||
property Pos { x: int = 0, y: int = 0 }
|
||||
model Mob { hp: int }
|
||||
|
||||
# move everything that has a position
|
||||
system Move phase FixedUpdate
|
||||
query (p) [Pos, {Player}]
|
||||
{
|
||||
p.x = p.x + 1
|
||||
handler Move phase FixedUpdate {
|
||||
for (p) in query [Pos] {
|
||||
p.x = p.x + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
|
@ -65,8 +65,9 @@ class LudicPlatformTest : BasePlatformTestCase() {
|
|||
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
|
||||
LudicTokens.OPERATOR, LudicTokens.LBRACE, LudicTokens.RBRACE, LudicTokens.LBRACKET,
|
||||
LudicTokens.RBRACKET, LudicTokens.LPAREN, LudicTokens.RPAREN, LudicTokens.COMMA,
|
||||
LudicTokens.IDENTIFIER, LudicTokens.MANIFEST_KEYWORD
|
||||
)
|
||||
for (token in coloured) {
|
||||
val keys = highlighter.getTokenHighlights(token)
|
||||
|
|
@ -78,14 +79,14 @@ class LudicPlatformTest : BasePlatformTestCase() {
|
|||
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)
|
||||
// The sample has exactly three blocks that span lines: the `program`
|
||||
// body, the `handler` body and the `for` loop. `property Pos { … }` and
|
||||
// `model Mob { … }` fit on one line, and folding a single line gains
|
||||
// nothing, so they are deliberately skipped.
|
||||
assertEquals("multi-line blocks only", 3, regions.size)
|
||||
|
||||
val outermost = regions.maxByOrNull { it.range.length }!!
|
||||
assertEquals("outermost fold starts at the game block's brace",
|
||||
assertEquals("outermost fold starts at the program block's brace",
|
||||
sample.indexOf('{'), outermost.range.startOffset)
|
||||
assertEquals("outermost fold ends at the last brace",
|
||||
sample.length, outermost.range.endOffset)
|
||||
|
|
@ -97,26 +98,25 @@ class LudicPlatformTest : BasePlatformTestCase() {
|
|||
}
|
||||
|
||||
fun testCommenterUsesHashWithASpace() {
|
||||
// The trailing space is deliberate: Ctrl+/ should produce "# code",
|
||||
// which is how every comment in the runtime and examples is written.
|
||||
// No trailing space in the prefix: the code style's "add a space"
|
||||
// option supplies it, so Ctrl+/ gives "# code" and not "# code".
|
||||
val commenter = LudicCommenter()
|
||||
assertEquals("# ", commenter.lineCommentPrefix)
|
||||
assertEquals("#", commenter.lineCommentPrefix)
|
||||
assertNull("Ludic has no block comments", commenter.blockCommentPrefix)
|
||||
}
|
||||
|
||||
fun testBraceMatcherPairsAllThreeBracketKinds() {
|
||||
fun testBraceMatcherPairsEveryBracketKind() {
|
||||
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))
|
||||
assertEquals(setOf(LudicTokens.LBRACE, LudicTokens.LBRACKET, LudicTokens.LPAREN, LudicTokens.TEMPLATE_HOLE_START), open)
|
||||
assertEquals(setOf(LudicTokens.RBRACE, LudicTokens.RBRACKET, LudicTokens.RPAREN, LudicTokens.TEMPLATE_HOLE_END), pairs.map { it.rightBraceType }.toSet())
|
||||
}
|
||||
|
||||
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 broken = "program X {\n property P { x: int =\n handler"
|
||||
val file = myFixture.configureByText("broken.ludic", broken)
|
||||
assertEquals(broken.length, file.textLength)
|
||||
assertEquals(LudicLanguage, file.language)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.execution.actions.ConfigurationContext
|
||||
import com.intellij.execution.executors.DefaultRunExecutor
|
||||
import com.intellij.execution.filters.OpenFileHyperlinkInfo
|
||||
import com.intellij.execution.lineMarker.RunLineMarkerContributor
|
||||
import com.intellij.execution.process.ProcessOutputTypes
|
||||
import com.intellij.execution.testframework.sm.runner.SMTestLocator
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.search.GlobalSearchScope
|
||||
import com.intellij.psi.util.PsiTreeUtil
|
||||
import com.intellij.testFramework.MapDataContext
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.openapi.actionSystem.CommonDataKeys
|
||||
import com.intellij.openapi.actionSystem.PlatformCoreDataKeys
|
||||
import com.intellij.execution.Location
|
||||
import com.intellij.execution.PsiLocation
|
||||
import io.ludic.ide.psi.LudicPsi
|
||||
import io.ludic.ide.run.LudicCommand
|
||||
import io.ludic.ide.run.LudicConfigurationType
|
||||
import io.ludic.ide.run.LudicConsoleFilter
|
||||
import io.ludic.ide.run.LudicRunConfiguration
|
||||
import io.ludic.ide.run.LudicRunConfigurationProducer
|
||||
import io.ludic.ide.run.LudicTestConsoleProperties
|
||||
import io.ludic.ide.run.LudicTestEventsConverter
|
||||
import io.ludic.ide.run.LudicTestLocator
|
||||
import io.ludic.ide.run.LudicTestUrls
|
||||
import com.intellij.execution.configurations.ConfigurationTypeUtil
|
||||
|
||||
/**
|
||||
* Run configurations, gutter buttons, the test tree and console links —
|
||||
* everything between "click the green arrow" and "see which test failed".
|
||||
*/
|
||||
class LudicRunTest : BasePlatformTestCase() {
|
||||
|
||||
private val spec = """
|
||||
program MathSpec {
|
||||
function add(a: int, b: int) -> int { return a + b }
|
||||
|
||||
test "adds" {
|
||||
expect_eq(add(1, 1), 2)
|
||||
}
|
||||
|
||||
test "it's \"quoted\"" { expect(true) }
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private val game = """
|
||||
program Game {
|
||||
handler Boot phase Start { print("hi") }
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun leafAt(marker: String): PsiElement {
|
||||
val offset = myFixture.file.text.indexOf(marker)
|
||||
assertTrue("marker '$marker' not found", offset >= 0)
|
||||
return myFixture.file.findElementAt(offset)!!
|
||||
}
|
||||
|
||||
private fun configFor(element: PsiElement): LudicRunConfiguration? {
|
||||
val ctx = ConfigurationContext.getFromContext(
|
||||
MapDataContext().apply {
|
||||
put(CommonDataKeys.PROJECT, project)
|
||||
put(PlatformCoreDataKeys.MODULE, myFixture.module)
|
||||
put(Location.DATA_KEY, PsiLocation(element))
|
||||
},
|
||||
"test"
|
||||
)
|
||||
val producer = LudicRunConfigurationProducer()
|
||||
val from = producer.createConfigurationFromContext(ctx) ?: return null
|
||||
val config = from.configuration as LudicRunConfiguration
|
||||
assertTrue("the producer must recognise its own configuration", producer.isConfigurationFromContext(config, ctx))
|
||||
return config
|
||||
}
|
||||
|
||||
fun testTestHeaderRunsJustThatTest() {
|
||||
myFixture.configureByText("math_test.ludic", spec)
|
||||
val c = configFor(leafAt("test \"adds\""))!!
|
||||
c.workingDirectory = "/src"
|
||||
assertEquals(LudicCommand.TEST, c.command)
|
||||
assertEquals("adds", c.testName)
|
||||
assertEquals("Test 'adds'", c.name)
|
||||
assertEquals(listOf("test", "math_test.ludic", "--verbose", "--test", "adds"), c.cliArguments())
|
||||
}
|
||||
|
||||
fun testEscapedTestNamesAreUnescaped() {
|
||||
myFixture.configureByText("math_test.ludic", spec)
|
||||
val kw = LudicPsi.testKeywords(myFixture.file)
|
||||
assertEquals(listOf("adds", "it's \"quoted\""), kw.map { LudicPsi.testNameAt(it) })
|
||||
assertEquals("it's \"quoted\"", configFor(kw[1].nextSibling.nextSibling)!!.testName)
|
||||
}
|
||||
|
||||
fun testTestFileRunsAllItsTests() {
|
||||
myFixture.configureByText("math_test.ludic", spec)
|
||||
val c = configFor(leafAt("function"))!!
|
||||
assertEquals(LudicCommand.TEST, c.command)
|
||||
assertEquals("", c.testName)
|
||||
assertEquals("Test math_test.ludic", c.name)
|
||||
}
|
||||
|
||||
fun testProgramFileRuns() {
|
||||
myFixture.configureByText("game.ludic", game)
|
||||
val c = configFor(leafAt("print"))!!
|
||||
c.workingDirectory = "/src"
|
||||
assertEquals(LudicCommand.RUN, c.command)
|
||||
assertEquals(listOf("run", "game.ludic"), c.cliArguments())
|
||||
c.headless = true
|
||||
assertEquals(listOf("run", "game.ludic", "--headless"), c.cliArguments())
|
||||
}
|
||||
|
||||
fun testCustomCommandPassesArgumentsThrough() {
|
||||
val type = ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java)
|
||||
val c = type.factory.createTemplateConfiguration(project) as LudicRunConfiguration
|
||||
c.command = LudicCommand.CUSTOM
|
||||
c.arguments = "fmt --check \"src/a b.ludic\""
|
||||
assertEquals(listOf("fmt", "--check", "src/a b.ludic"), c.cliArguments())
|
||||
}
|
||||
|
||||
fun testConfigurationSurvivesSerialization() {
|
||||
val type = ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java)
|
||||
val c = type.factory.createTemplateConfiguration(project) as LudicRunConfiguration
|
||||
c.command = LudicCommand.TEST
|
||||
c.file = "/tmp/x.ludic"
|
||||
c.testName = "adds"
|
||||
c.env = mapOf("R3D_GAME" to "aimprobe")
|
||||
val element = org.jdom.Element("configuration")
|
||||
c.writeExternal(element)
|
||||
val back = type.factory.createTemplateConfiguration(project) as LudicRunConfiguration
|
||||
back.readExternal(element)
|
||||
assertEquals(LudicCommand.TEST, back.command)
|
||||
assertEquals("/tmp/x.ludic", back.file)
|
||||
assertEquals("adds", back.testName)
|
||||
assertEquals(mapOf("R3D_GAME" to "aimprobe"), back.env)
|
||||
}
|
||||
|
||||
fun testGutterMarksEveryTestAndTheProgram() {
|
||||
myFixture.configureByText("math_test.ludic", spec)
|
||||
val marked = PsiTreeUtil.collectElements(myFixture.file) { it.firstChild == null }
|
||||
.filter { e -> RunLineMarkerContributor.EXTENSION.allForLanguage(LudicLanguage).any { it.getInfo(e) != null } }
|
||||
.map { it.text }
|
||||
assertEquals(listOf("program", "test", "test"), marked)
|
||||
}
|
||||
|
||||
fun testLocatorFindsTheTestBlock() {
|
||||
val file = myFixture.configureByText("math_test.ludic", spec)
|
||||
val path = file.virtualFile.path
|
||||
val url = LudicTestUrls.test(path, "adds").removePrefix("ludic://")
|
||||
val loc = LudicTestLocator.getLocation("ludic", url, project, GlobalSearchScope.allScope(project)).single()
|
||||
assertEquals("test", loc.psiElement.text)
|
||||
assertEquals(file.text.indexOf("test \"adds\""), loc.psiElement.textOffset)
|
||||
val fileLoc = (LudicTestLocator as SMTestLocator).getLocation("ludic", path, project, GlobalSearchScope.allScope(project)).single()
|
||||
assertEquals(file, fileLoc.psiElement)
|
||||
}
|
||||
|
||||
fun testUrlSplitKeepsHashesInTestNames() {
|
||||
assertEquals("/a/b.ludic" to "x # y", LudicTestUrls.split("/a/b.ludic#x # y"))
|
||||
assertEquals("/a/b.ludic" to null, LudicTestUrls.split("/a/b.ludic"))
|
||||
}
|
||||
|
||||
// ---- the output converter ---------------------------------------------
|
||||
|
||||
private fun convert(vararg lines: String): List<String> {
|
||||
val type = ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java)
|
||||
val c = type.factory.createTemplateConfiguration(project) as LudicRunConfiguration
|
||||
c.command = LudicCommand.TEST
|
||||
c.workingDirectory = "/proj"
|
||||
val props = LudicTestConsoleProperties(c, DefaultRunExecutor.getRunExecutorInstance())
|
||||
val out = mutableListOf<String>()
|
||||
val conv = object : LudicTestEventsConverter("Ludic", props, "/proj") {
|
||||
override fun forward(text: String, outputType: Key<*>) {
|
||||
if (text.startsWith("##teamcity")) out += text.trim()
|
||||
}
|
||||
fun feed(l: String) = processConsistentText(l + "\n", ProcessOutputTypes.STDOUT)
|
||||
}
|
||||
lines.forEach { conv.feed(it) }
|
||||
conv.flushBufferOnProcessTermination(0)
|
||||
return out.map { it.removePrefix("##teamcity[").substringBefore(" ") + " " + (Regex("name='([^']*)'").find(it)?.groupValues?.get(1) ?: "") }
|
||||
}
|
||||
|
||||
fun testVerboseOutputBecomesATestTree() {
|
||||
val events = convert(
|
||||
" RUN tests/smoke.ludic",
|
||||
"ok - adds",
|
||||
"smoke.ludic:12: expect_eq failed (got 1, want 2)",
|
||||
"FAIL - subtracts",
|
||||
"== 1 passed, 1 failed ==",
|
||||
" [31mFAIL[0m tests/smoke.ludic (exit 1)",
|
||||
" RUN tests/other.ludic",
|
||||
"ok - x.ludic",
|
||||
"== 1 passed, 0 failed ==",
|
||||
" [32mPASS[0m tests/other.ludic",
|
||||
"",
|
||||
"== 1 of 2 test files failed ==",
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"testSuiteStarted tests/smoke.ludic",
|
||||
"testStarted adds", "testFinished adds",
|
||||
"testStarted subtracts", "testFailed subtracts", "testFinished subtracts",
|
||||
"testSuiteFinished tests/smoke.ludic",
|
||||
"testSuiteStarted tests/other.ludic",
|
||||
"testStarted x.ludic", "testFinished x.ludic",
|
||||
"testSuiteFinished tests/other.ludic",
|
||||
),
|
||||
events
|
||||
)
|
||||
}
|
||||
|
||||
fun testCompileFailureShowsAsAFailedNode() {
|
||||
val events = convert(
|
||||
" RUN tests/broken.ludic",
|
||||
"broken.ludic:2: error: unknown identifier zz",
|
||||
" FAIL tests/broken.ludic (did not compile)",
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"testSuiteStarted tests/broken.ludic",
|
||||
"testStarted (compile)", "testFailed (compile)", "testFinished (compile)",
|
||||
"testSuiteFinished tests/broken.ludic",
|
||||
),
|
||||
events
|
||||
)
|
||||
}
|
||||
|
||||
fun testUnknownTestNameIsAFailure() {
|
||||
val events = convert(" RUN tests/a.ludic", "no test named \"nope\"", " FAIL tests/a.ludic (exit 1)")
|
||||
assertEquals(
|
||||
listOf("testSuiteStarted tests/a.ludic", "testStarted nope", "testFailed nope", "testFinished nope", "testSuiteFinished tests/a.ludic"),
|
||||
events
|
||||
)
|
||||
}
|
||||
|
||||
fun testOldToolchainOutputStillGivesFileResults() {
|
||||
// an install without --verbose: PASS lines alone, and a failing
|
||||
// file's runner output after its FAIL line
|
||||
val events = convert(
|
||||
" PASS tests/a.ludic",
|
||||
" FAIL tests/b.ludic (exit 1)",
|
||||
"ok - one",
|
||||
"FAIL - two",
|
||||
"== 1 passed, 1 failed ==",
|
||||
" PASS tests/c.ludic",
|
||||
"",
|
||||
"== 1 of 3 test files failed ==",
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"testSuiteStarted tests/a.ludic", "testSuiteFinished tests/a.ludic",
|
||||
"testSuiteStarted tests/b.ludic",
|
||||
"testStarted one", "testFinished one",
|
||||
"testStarted two", "testFailed two", "testFinished two",
|
||||
"testSuiteFinished tests/b.ludic",
|
||||
"testSuiteStarted tests/c.ludic", "testSuiteFinished tests/c.ludic",
|
||||
),
|
||||
events
|
||||
)
|
||||
}
|
||||
|
||||
// ---- console links -----------------------------------------------------
|
||||
|
||||
fun testConsoleLinksResolveBaseNamesAcrossTheProject() {
|
||||
val file = myFixture.addFileToProject("tests/smoke.ludic", "program S {\n test \"t\" { expect(false) }\n}\n")
|
||||
val filter = LudicConsoleFilter(project, "/nonexistent")
|
||||
val line = "smoke.ludic:2: expect failed\n"
|
||||
val result = filter.applyFilter(line, line.length)!!
|
||||
val item = result.resultItems.single()
|
||||
assertEquals(0, item.highlightStartOffset)
|
||||
assertEquals("smoke.ludic:2".length, item.highlightEndOffset)
|
||||
val info = item.hyperlinkInfo as OpenFileHyperlinkInfo
|
||||
assertEquals(file.virtualFile, info.descriptor!!.file)
|
||||
assertEquals(file.text.indexOf(" test"), info.descriptor!!.offset)
|
||||
}
|
||||
|
||||
fun testConsoleLinksIgnoreOtherText() {
|
||||
val filter = LudicConsoleFilter(project, null)
|
||||
assertNull(filter.applyFilter("built build/demo\n", 17))
|
||||
assertNull(filter.applyFilter("missing.ludic:3: error\n", 23))
|
||||
}
|
||||
}
|
||||
|
|
@ -289,6 +289,54 @@ function cmd_test_lsp() -> int {
|
|||
# ---- workspace-wide symbol search ---------------------------------------
|
||||
let ws = lsp_request("workspace/symbol", `{{"query":"Stats"}}`)
|
||||
|
||||
# ---- navigation an IDE leans on --------------------------------------------
|
||||
# package imports resolve from the toolchain's packages/, import strings are
|
||||
# links, locals of both kinds have a type, built-ins lead to their reference
|
||||
# page, and a call's argument list completes its parameter names.
|
||||
let nsrc = "program Nav {\n import \"ludic.render3d/fmath.ludic\"\n function area(width: int, height: int) -> int { return width * height }\n entry {\n var total = 0\n let name = \"x\"\n total = total + area(width: 2, )\n Screen.fill_circle(x: 1, y: f_clamp(1, 0, 2), )\n let s = Screen.\n }\n}\n"
|
||||
let nfile = tmp_path("lsp_nav.ludic")
|
||||
write_file(nfile, nsrc)
|
||||
lsp_open(nfile, "ludic")
|
||||
let nuri = lsp_doc(nfile)
|
||||
let links = lsp_request("textDocument/documentLink", `{{"textDocument":{nuri}}}`)
|
||||
let pkgdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "ludic.render3d", 3)}}}`)
|
||||
let xdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "f_clamp", 2)}}}`)
|
||||
let vhov = lsp_request("textDocument/hover", `{{"textDocument":{nuri},"position":{position_of(nsrc, "total = total", 10)}}}`)
|
||||
let lhov = lsp_request("textDocument/hover", `{{"textDocument":{nuri},"position":{position_of(nsrc, "name = ", 1)}}}`)
|
||||
let vrefs = lsp_request("textDocument/references", `{{"textDocument":{nuri},"position":{position_of(nsrc, "var total", 5)},"context":{{"includeDeclaration":true}}}}`)
|
||||
let bdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "fill_circle", 2)}}}`)
|
||||
let bhov = lsp_request("textDocument/hover", `{{"textDocument":{nuri},"position":{position_of(nsrc, "fill_circle", 2)}}}`)
|
||||
let tdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "int, height", 1)}}}`)
|
||||
let acomp = lsp_request("textDocument/completion", `{{"textDocument":{nuri},"position":{position_of(nsrc, "2, )", 3)}}}`)
|
||||
let nscomp = lsp_request("textDocument/completion", `{{"textDocument":{nuri},"position":{position_of(nsrc, "= Screen.", 9)}}}`)
|
||||
let ahelp = lsp_request("textDocument/signatureHelp", `{{"textDocument":{nuri},"position":{position_of(nsrc, "2), )", 4)}}}`)
|
||||
|
||||
# contextual keywords, highlights, barrels, semantic modifiers
|
||||
let kdir = tmp_path("lsp_barrel")
|
||||
shell(`mkdir -p {kdir}/camp`)
|
||||
write_file(`{kdir}/camp/index.ludic`, "import \"tent.ludic\"\n")
|
||||
write_file(`{kdir}/camp/tent.ludic`, "function pitch(size: int) -> int { return size }\n")
|
||||
let ksrc = "program K {\n import \"camp\"\n import \"ludic.render3d/fmath.ludic\"\n function go() -> int {\n var model = pitch(2)\n model = model + f_clamp(1, 0, 2)\n Screen.clear(0)\n return model\n }\n}\n"
|
||||
let kfile = `{kdir}/k.ludic`
|
||||
write_file(kfile, ksrc)
|
||||
lsp_open(kfile, "ludic")
|
||||
let kuri = lsp_doc(kfile)
|
||||
let khov = lsp_request("textDocument/hover", `{{"textDocument":{kuri},"position":{position_of(ksrc, "var model", 5)}}}`)
|
||||
let krefs = lsp_request("textDocument/references", `{{"textDocument":{kuri},"position":{position_of(ksrc, "var model", 5)},"context":{{"includeDeclaration":true}}}}`)
|
||||
let kbarrel = lsp_request("textDocument/definition", `{{"textDocument":{kuri},"position":{position_of(ksrc, "pitch(2)", 1)}}}`)
|
||||
let khl = lsp_request("textDocument/documentHighlight", `{{"textDocument":{kuri},"position":{position_of(ksrc, "f_clamp", 1)}}}`)
|
||||
let ktoks = lsp_request("textDocument/semanticTokens/full", `{{"textDocument":{kuri}}}`)
|
||||
|
||||
# names inside `template {holes}`, and positions after a multi-line template
|
||||
let tsrc = "program TT {\n function tag(variant: int) -> pointer {\n let multi = `a\nb {variant}`\n return `rock_{variant}` + multi\n }\n}\n"
|
||||
let tfile = tmp_path("lsp_tmpl.ludic")
|
||||
write_file(tfile, tsrc)
|
||||
lsp_open(tfile, "ludic")
|
||||
let turi = lsp_doc(tfile)
|
||||
let thov = lsp_request("textDocument/hover", `{{"textDocument":{turi},"position":{position_of(tsrc, "rock_{variant", 7)}}}`)
|
||||
let trefs = lsp_request("textDocument/references", `{{"textDocument":{turi},"position":{position_of(tsrc, "tag(variant", 5)},"context":{{"includeDeclaration":true}}}}`)
|
||||
let tafter = lsp_request("textDocument/hover", `{{"textDocument":{turi},"position":{position_of(tsrc, "+ multi", 3)}}}`)
|
||||
|
||||
lsp_request("shutdown", "null")
|
||||
lsp_notify("exit", "null")
|
||||
|
||||
|
|
@ -322,7 +370,10 @@ function cmd_test_lsp() -> int {
|
|||
check_true("find-usages spans the compilation unit", distinct_files(lsp_result(refs)) >= 3)
|
||||
check_true("inlay hints name query binding types", j_any_field(lsp_result(hints), "label", ": Stats"))
|
||||
let sigs = j_get(lsp_result(sig), "signatures")
|
||||
check_true("signature help for a namespaced builtin", len(sigs.kids) > 0 and Text.starts_with(j_get(sigs.kids[0], "label").s, "rng_chance("))
|
||||
# the documented, namespaced spelling (docs/language) when the reference is
|
||||
# present, else the builtin the method lowers to
|
||||
check_true("signature help for a namespaced builtin", len(sigs.kids) > 0 and (Text.starts_with(j_get(sigs.kids[0], "label").s, "Random.chance(") or Text.starts_with(j_get(sigs.kids[0], "label").s, "rng_chance(")))
|
||||
check_true("signature help splits the parameters", len(sigs.kids) > 0 and len(j_get(sigs.kids[0], "parameters").kids) == 1)
|
||||
let tdata = j_get(lsp_result(toks), "data")
|
||||
check_true("semantic tokens produced", len(tdata.kids) / 5 > 100)
|
||||
check_true("semantic token stream is well formed", len(tdata.kids) % 5 == 0)
|
||||
|
|
@ -369,5 +420,41 @@ function cmd_test_lsp() -> int {
|
|||
while ci < len(ccmsgs) { if Text.contains(ccmsgs[ci], "expected expression") { cc_seen = true }; ci += 1 }
|
||||
check_true("ludicc's own error is published on save (compilerDiagnostics)", cc_seen)
|
||||
|
||||
let link0 = lsp_result(links)
|
||||
check_true("an import string is a link to its file", len(link0.kids) == 1 and Text.ends_with(j_get(link0.kids[0], "target").s, "packages/ludic.render3d/fmath.ludic"))
|
||||
let pd = lsp_result(pkgdef)
|
||||
check_true("go-to-definition on a package import opens the package file", len(pd.kids) == 1 and Text.ends_with(j_get(pd.kids[0], "uri").s, "packages/ludic.render3d/fmath.ludic"))
|
||||
check_true("a name from an imported package resolves into it", Text.ends_with(j_get(lsp_result(xdef), "uri").s, "fmath.ludic"))
|
||||
check_true("hover gives a var local its inferred type", Text.contains(j_get(j_get(lsp_result(vhov), "contents"), "value").s, "var total: int"))
|
||||
check_true("hover gives a let local its inferred type", Text.contains(j_get(j_get(lsp_result(lhov), "contents"), "value").s, "let name: string"))
|
||||
check("find-usages of a var local", loc_lines_key(lsp_result(vrefs)), "4,6")
|
||||
check_true("go-to-definition on a namespace method opens its reference page", Text.ends_with(j_get(lsp_result(bdef), "uri").s, "screen-fill_circle.md"))
|
||||
check_true("hover on a namespace method links its documentation", Text.contains(j_get(j_get(lsp_result(bhov), "contents"), "value").s, "screen-fill_circle.html"))
|
||||
check_true("go-to-definition on a built-in type opens its reference page", Text.ends_with(j_get(lsp_result(tdef), "uri").s, "type-int.md"))
|
||||
let aitems = j_get(lsp_result(acomp), "items")
|
||||
check_true("an argument list completes the parameters not given yet", j_any_field(aitems, "label", "height:") and not j_any_field(aitems, "label", "width:") and not j_any_field(aitems, "label", "let"))
|
||||
check_true("Namespace. completes its members", j_any_field(j_get(lsp_result(nscomp), "items"), "label", "fill_circle"))
|
||||
let ah = lsp_result(ahelp)
|
||||
check_true("signature help tracks the argument after a named one", j_get(ah, "activeParameter").num == 2 and len(j_get(j_get(ah, "signatures").kids[0], "parameters").kids) == 4)
|
||||
|
||||
check_true("a declaration word used as a variable is a variable", Text.contains(j_get(j_get(lsp_result(khov), "contents"), "value").s, "var model: int"))
|
||||
check("find-usages of a local named like a keyword", loc_lines_key(lsp_result(krefs)), "4,5,7")
|
||||
check_true("an import of a directory reads its index.ludic barrel", Text.ends_with(j_get(lsp_result(kbarrel), "uri").s, "camp/tent.ludic"))
|
||||
let hl = lsp_result(khl)
|
||||
check_true("highlight usages stays inside the document", len(hl.kids) == 1 and j_get(j_get(j_get(hl.kids[0], "range"), "start"), "line").num == 5)
|
||||
let kd = j_get(lsp_result(ktoks), "data")
|
||||
var vendor_seen = false
|
||||
var lib_seen = false
|
||||
var ti = 0
|
||||
while ti + 4 < len(kd.kids) {
|
||||
if (kd.kids[ti + 4].num & 8) != 0 { vendor_seen = true }
|
||||
if (kd.kids[ti + 4].num & 4) != 0 and kd.kids[ti + 3].num == 5 { lib_seen = true }
|
||||
ti += 5
|
||||
}
|
||||
check_true("semantic tokens mark package symbols as vendor", vendor_seen)
|
||||
check_true("semantic tokens mark library calls as defaultLibrary", lib_seen)
|
||||
check_true("a name inside a template hole resolves", Text.contains(j_get(j_get(lsp_result(thov), "contents"), "value").s, "param variant: int"))
|
||||
check("find-usages reaches into template holes", loc_lines_key(lsp_result(trefs)), "1,3,4")
|
||||
check_true("positions after a multi-line template stay on the right line", Text.contains(j_get(j_get(lsp_result(tafter), "contents"), "value").s, "let multi"))
|
||||
return report()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ program Ludic {
|
|||
import "packignore.ludic"
|
||||
import "pack.ludic"
|
||||
import "bundle.ludic"
|
||||
import "scripts.ludic"
|
||||
|
||||
function usage() -> void {
|
||||
print("ludic — the toolchain for the Ludic language")
|
||||
|
|
@ -39,7 +40,8 @@ program Ludic {
|
|||
print(" run [file] [--headless] build the project and run it")
|
||||
print(" build [file] [--headless] [-o out]")
|
||||
print(" compile to build/<name> (a native binary, nothing to ship beside it)")
|
||||
print(" test [file...] compile and run the project's tests")
|
||||
print(" test [file...] [-v] [--test NAME]")
|
||||
print(" compile and run the project's tests (-v: every test's line)")
|
||||
print(" clean remove build/")
|
||||
print("")
|
||||
print("packages:")
|
||||
|
|
@ -67,6 +69,11 @@ program Ludic {
|
|||
print(" lsp run the language server on stdio (what editors spawn)")
|
||||
print(" help this message")
|
||||
print("")
|
||||
print("package scripts (package.ludic):")
|
||||
print(" script <name> [args] run a `script \"name\" \"command\"` line (or just: ludic <name>)")
|
||||
print(" scripts list the package's scripts and hooks")
|
||||
print(" `hook before|after <command> \"…\"` wraps build, run, test, bundle, …")
|
||||
print("")
|
||||
print("A file argument runs it: ludic mygame.ludic")
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +98,8 @@ program Ludic {
|
|||
if (cmd == "lsp") { return cmd_lsp() }
|
||||
if (cmd == "doctor") { return cmd_doctor() }
|
||||
if (cmd == "upgrade") { return cmd_upgrade() }
|
||||
if (cmd == "script") { return cmd_script() }
|
||||
if (cmd == "scripts") { return cmd_scripts() }
|
||||
|
||||
if (cmd == "add") { return cmd_pkg_add() }
|
||||
if (cmd == "get") { return cmd_pkg_get() }
|
||||
|
|
@ -128,7 +137,16 @@ program Ludic {
|
|||
tmp_cleanup()
|
||||
exit(rc)
|
||||
}
|
||||
let code = dispatch(cmd)
|
||||
# package.ludic hooks wrap the built-in commands: `before` can veto,
|
||||
# `after` runs only when the command succeeded
|
||||
if is_hookable(cmd) {
|
||||
let pre = run_hooks("before", cmd)
|
||||
if pre != 0 { tmp_cleanup(); exit(pre) }
|
||||
}
|
||||
var code = dispatch(cmd)
|
||||
if code == 0 and is_hookable(cmd) { code = run_hooks("after", cmd) }
|
||||
# an unknown command may be one of the package's scripts
|
||||
if code == -1 { code = run_script(cmd, 2) }
|
||||
tmp_cleanup()
|
||||
if code >= 0 { exit(code) }
|
||||
err(`ludic: unknown command '{cmd}'\n`)
|
||||
|
|
|
|||
|
|
@ -33,8 +33,11 @@ property Manifest {
|
|||
provides: []pointer, # the Foo.* namespace(s) this package registers
|
||||
targets: []pointer, # prebuilt: the targets it ships (native-arm64, wasm32, …)
|
||||
deps: []Dep,
|
||||
entry: pointer = "", # `entry "src/game.ludic"` — the program run/build/bundle compile
|
||||
packs: []pointer, # `pack "<dir>"` — asset roots that go into the .lpak
|
||||
app: []pointer # `app <key> "<value>"` — flattened key, value, key, value…
|
||||
app: []pointer, # `app <key> "<value>"` — flattened key, value, key, value…
|
||||
scripts: []pointer, # `script "<name>" "<command>"` — flattened name, command, …
|
||||
hooks: []pointer # `hook <before|after> <event> "<command>"` — flattened triples
|
||||
}
|
||||
|
||||
function manifest_new() -> Manifest {
|
||||
|
|
@ -43,11 +46,14 @@ function manifest_new() -> Manifest {
|
|||
m.ver = ""
|
||||
m.kind = "source"
|
||||
m.hash = ""
|
||||
m.entry = ""
|
||||
m.provides = new []pointer
|
||||
m.targets = new []pointer
|
||||
m.deps = new []Dep
|
||||
m.packs = new []pointer
|
||||
m.app = new []pointer
|
||||
m.scripts = new []pointer
|
||||
m.hooks = new []pointer
|
||||
return m
|
||||
}
|
||||
|
||||
|
|
@ -114,11 +120,18 @@ function tok_line(line: pointer) -> []pointer {
|
|||
let c = line[i]
|
||||
if c == '#' { i = n } # '#' comment
|
||||
else if c == ' ' or c == '\t' { i += 1 } # whitespace
|
||||
else if c == '"' { # "quoted"
|
||||
else if c == '"' { # "quoted", with \" and \\ escapes
|
||||
i += 1
|
||||
let start = i
|
||||
while i < n and line[i] != '"' { i += 1 }
|
||||
push(toks, sslice(line, start, i))
|
||||
var word = ""
|
||||
var start = i
|
||||
while i < n and line[i] != '"' {
|
||||
if line[i] == 92 and i + 1 < n {
|
||||
word = word + sslice(line, start, i) + sslice(line, i + 1, i + 2)
|
||||
i += 2
|
||||
start = i
|
||||
} else { i += 1 }
|
||||
}
|
||||
push(toks, word + sslice(line, start, i))
|
||||
if i < n { i += 1 }
|
||||
} else { # bare word
|
||||
let start = i
|
||||
|
|
@ -158,6 +171,7 @@ function parse_manifest(text: pointer) -> Manifest {
|
|||
if head == "package" { if len(ts) > 1 { m.module = ts[1] } }
|
||||
else if head == "version" { if len(ts) > 1 { m.ver = ts[1] } }
|
||||
else if head == "kind" { if len(ts) > 1 { m.kind = ts[1] } }
|
||||
else if head == "entry" { if len(ts) > 1 { m.entry = ts[1] } }
|
||||
else if head == "provides" { var k = 1; while k < len(ts) { push(m.provides, ts[k]); k += 1 } }
|
||||
else if head == "targets" { var k = 1; while k < len(ts) { push(m.targets, ts[k]); k += 1 } }
|
||||
else if head == "require" {
|
||||
|
|
@ -169,6 +183,12 @@ function parse_manifest(text: pointer) -> Manifest {
|
|||
# `app name "Maroon Lake"` — the metadata a macOS bundle is built from.
|
||||
# Held as a flat key/value list rather than a property per key, so a new
|
||||
# Info.plist field is a line in the bundler and nothing here.
|
||||
else if head == "script" {
|
||||
if len(ts) > 2 { push(m.scripts, ts[1]); push(m.scripts, ts[2]) }
|
||||
}
|
||||
else if head == "hook" {
|
||||
if len(ts) > 3 { push(m.hooks, ts[1]); push(m.hooks, ts[2]); push(m.hooks, ts[3]) }
|
||||
}
|
||||
else if head == "app" {
|
||||
if len(ts) > 2 { push(m.app, ts[1]); push(m.app, ts[2]) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,14 +47,25 @@ function project_name(entry: pointer) -> pointer {
|
|||
return capture_line(`basename {entry} .ludic`)
|
||||
}
|
||||
|
||||
# The program to compile. An explicit path always wins; otherwise the
|
||||
# conventional entry points are tried in order, and finally a lone .ludic file in
|
||||
# the current directory (so a scratch directory needs no ceremony). "" when
|
||||
# nothing was found.
|
||||
# The program to compile. An explicit path always wins; then package.ludic's
|
||||
# `entry "…"`; then the conventional entry points in order; then the one file
|
||||
# under src/ that declares a `program` (the rest being modules it imports); and
|
||||
# finally a lone .ludic file in the current directory (so a scratch directory
|
||||
# needs no ceremony). "" when nothing was found.
|
||||
function find_entry(explicit: pointer) -> pointer {
|
||||
if explicit != "" { return explicit }
|
||||
if file_exists("package.ludic") {
|
||||
let m = read_root_manifest()
|
||||
if m.entry != "" {
|
||||
if file_exists(m.entry) { return m.entry }
|
||||
err(`ludic: package.ludic names entry "{m.entry}", which does not exist\n`)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if file_exists("src/main.ludic") { return "src/main.ludic" }
|
||||
if file_exists("main.ludic") { return "main.ludic" }
|
||||
let progs = split_lines(capture("grep -rlE --include='*.ludic' '^program[[:space:]]' src 2>/dev/null | sort"))
|
||||
if len(progs) == 1 { return progs[0] }
|
||||
let one = capture_line("ls *.ludic 2>/dev/null")
|
||||
if one != "" and not shq("ls *.ludic 2>/dev/null | tail -n +2 | grep -q .") { return one }
|
||||
return ""
|
||||
|
|
@ -63,7 +74,8 @@ function find_entry(explicit: pointer) -> pointer {
|
|||
# report the "no entry point" case the same way everywhere
|
||||
function no_entry() -> int {
|
||||
err("ludic: no program to build here.\n")
|
||||
err(" expected src/main.ludic (or main.ludic), or name a file: ludic build path/to/game.ludic\n")
|
||||
err(" expected `entry \"src/game.ludic\"` in package.ludic, src/main.ludic (or main.ludic),\n")
|
||||
err(" a single program under src/, or name a file: ludic build path/to/game.ludic\n")
|
||||
err(" start a new project with: ludic new <name>\n")
|
||||
return 1
|
||||
}
|
||||
|
|
@ -337,13 +349,35 @@ function test_files() -> []pointer {
|
|||
return files
|
||||
}
|
||||
|
||||
# ludic test [file...] — compile each test program headlessly and run it. A test
|
||||
# program's own runner prints ok/FAIL per test block and exits non-zero if any
|
||||
# failed, so this reports one line per file and forwards the failure.
|
||||
# ludic test [file...] [--verbose] [--test NAME] — compile each test program
|
||||
# headlessly and run it. A test program's own runner prints ok/FAIL per test
|
||||
# block and exits non-zero if any failed, so this reports one line per file and
|
||||
# forwards the failure.
|
||||
#
|
||||
# --verbose streams every runner line, framed by `RUN <file>` ... `PASS|FAIL
|
||||
# <file>`: the shape an IDE test view parses. --test NAME runs only the test
|
||||
# block with exactly that name (the runner's argv[1]).
|
||||
function cmd_test() -> int {
|
||||
var files = new []pointer
|
||||
var verbose = false
|
||||
var only = ""
|
||||
var ai = 2
|
||||
while ai < arg_count() { if arg(ai)[0] != '-' { push(files, arg(ai)) }; ai += 1 }
|
||||
while ai < arg_count() {
|
||||
let a = arg(ai)
|
||||
if (a == "--verbose") or (a == "-v") { verbose = true }
|
||||
else if a == "--test" {
|
||||
if ai + 1 >= arg_count() { err("ludic test: --test needs a test name\n"); return 1 }
|
||||
ai += 1
|
||||
only = arg(ai)
|
||||
}
|
||||
else if a[0] == '-' {
|
||||
err(`ludic test: unknown option {a}\n`)
|
||||
err(" usage: ludic test [file...] [--verbose] [--test NAME]\n")
|
||||
return 1
|
||||
}
|
||||
else { push(files, a) }
|
||||
ai += 1
|
||||
}
|
||||
if len(files) == 0 { files = test_files() }
|
||||
if len(files) == 0 {
|
||||
err("ludic test: no tests found (expected tests/*.ludic or src/**/*_test.ludic)\n")
|
||||
|
|
@ -355,19 +389,27 @@ function cmd_test() -> int {
|
|||
mi += 1
|
||||
}
|
||||
shell("mkdir -p build")
|
||||
var filter = ""
|
||||
if only != "" { filter = ` {sh_single(only)}` }
|
||||
var failed = 0
|
||||
var i = 0
|
||||
while i < len(files) {
|
||||
let f = files[i]
|
||||
let bin = `{tmp_dir()}/{flat(strip_ext(f))}`
|
||||
if verbose { say(` RUN {f}`) }
|
||||
if not compile_app(f, bin, 2, false) {
|
||||
print(` {c_red()}FAIL{c_reset()} {f} (did not compile)`)
|
||||
let line = ` {c_red()}FAIL{c_reset()} {f} (did not compile)`
|
||||
if verbose { say(line) } else { print(line) }
|
||||
failed += 1
|
||||
} else if verbose {
|
||||
let rc = sh(`{bin}{filter} < /dev/null 2>&1`)
|
||||
if rc == 0 { say(` {c_green()}PASS{c_reset()} {f}`) }
|
||||
else { say(` {c_red()}FAIL{c_reset()} {f} (exit {string(rc)})`); failed += 1 }
|
||||
} else {
|
||||
# a test program prints one line per test block; that belongs on screen
|
||||
# when something failed and nowhere when everything passed.
|
||||
let log = `{bin}.out`
|
||||
let rc = sh(`{bin} < /dev/null > {log} 2>&1`)
|
||||
let rc = sh(`{bin}{filter} < /dev/null > {log} 2>&1`)
|
||||
if rc == 0 { print(` {c_green()}PASS{c_reset()} {f}`) }
|
||||
else {
|
||||
print(` {c_red()}FAIL{c_reset()} {f} (exit {string(rc)})`)
|
||||
|
|
@ -387,6 +429,22 @@ function cmd_test() -> int {
|
|||
return 1
|
||||
}
|
||||
|
||||
# print a line through the shell. The runner's own stdout is buffered, so a
|
||||
# print() made between two child processes would surface after both of them;
|
||||
# under --verbose the framing has to interleave with the runner output exactly.
|
||||
function say(line: pointer) -> void { shell(`printf '%s\\n' {sh_single(line)}`) }
|
||||
|
||||
# one shell word, whatever it contains: 'it'\''s' for it's
|
||||
function sh_single(s: pointer) -> pointer {
|
||||
var o = "'"
|
||||
var i = 0
|
||||
while i < len(s) {
|
||||
if s[i] == 39 { o = o + "'\\''" } else { o = o + s[i..i + 1] }
|
||||
i += 1
|
||||
}
|
||||
return o + "'"
|
||||
}
|
||||
|
||||
# drop the extension from a path ("tests/combat.ludic" -> "tests/combat")
|
||||
function strip_ext(p: pointer) -> pointer {
|
||||
let n = len(p)
|
||||
|
|
|
|||
|
|
@ -330,6 +330,9 @@ function build_artifacts(ver: pointer) -> bool {
|
|||
}
|
||||
shell(`cp -R runtime {stage}/runtime`)
|
||||
shell(`cp -R packages {stage}/packages`)
|
||||
# the language reference: ludic-lsp indexes it for hover and go-to-definition
|
||||
# on built-ins (docs/language/**.md, ~4 MB of markdown)
|
||||
shell(`mkdir -p {stage}/docs && cp -R docs/language {stage}/docs/language`)
|
||||
shell(`cp VERSION LICENSE README.md {stage}/`)
|
||||
# the Linux link needs the Darwin-stdio shim injected through $LUDIC_CC; it
|
||||
# travels with the toolchain that needs it (see tools/ci/linux_stdio_shim.ll).
|
||||
|
|
|
|||
127
tools/ludic-cli/scripts.ludic
Normal file
127
tools/ludic-cli/scripts.ludic
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# scripts.ludic — package.ludic scripts and lifecycle hooks.
|
||||
#
|
||||
# script "dev" "ludic run --headless" # ludic dev / ludic script dev
|
||||
# script "shots" "tools/shots.sh" # extra arguments are passed on
|
||||
# hook before build "tools/gen_atlas.sh" # runs first; failing stops the build
|
||||
# hook after bundle "tools/notarize.sh" # runs only when the bundle succeeded
|
||||
# hook before dev "ludic fmt --check" # a script is an event too
|
||||
#
|
||||
# Commands run through the shell in the package root, with the toolchain's bin/
|
||||
# first on PATH and LUDIC_PACKAGE_ROOT, LUDIC_EVENT and LUDIC_PHASE set. Hooks of
|
||||
# one event run in the order written.
|
||||
|
||||
# the built-in commands a hook can wrap (every user command that does work)
|
||||
function is_hookable(cmd: pointer) -> bool {
|
||||
return (cmd == "build") or (cmd == "run") or (cmd == "test") or (cmd == "bundle") or (cmd == "pack") or (cmd == "clean") or (cmd == "fmt") or (cmd == "get") or (cmd == "add") or (cmd == "update") or (cmd == "verify") or (cmd == "vendor") or (cmd == "assets") or (cmd == "build-lib")
|
||||
}
|
||||
|
||||
# the directory this `ludic` binary lives in, with no trailing slash
|
||||
function ludic_bin_dir() -> pointer {
|
||||
let me = arg(0)
|
||||
var i = slen(me)
|
||||
while i > 0 and me[i - 1] != '/' { i -= 1 }
|
||||
if i > 0 { return capture_line(`cd {sh_single(sslice(me, 0, i))} && pwd`) }
|
||||
return capture_line(`dirname "$(command -v {sh_single(me)})"`)
|
||||
}
|
||||
|
||||
# the shell prelude every script and hook runs under
|
||||
function script_env(event: pointer, phase: pointer) -> pointer {
|
||||
let bin = ludic_bin_dir()
|
||||
var env = `export LUDIC_PACKAGE_ROOT="$(pwd)"; export LUDIC_EVENT={sh_single(event)}; export LUDIC_PHASE={sh_single(phase)}; `
|
||||
if bin != "" { env = env + `export PATH={sh_single(bin)}:"$PATH"; ` }
|
||||
return env
|
||||
}
|
||||
|
||||
# run every `hook <phase> <event>` in order; the first failure's exit code, or 0
|
||||
function run_hooks(phase: pointer, event: pointer) -> int {
|
||||
if not file_exists("package.ludic") { return 0 }
|
||||
let m = read_root_manifest()
|
||||
var i = 0
|
||||
while i + 2 < len(m.hooks) {
|
||||
if m.hooks[i] == phase and m.hooks[i + 1] == event {
|
||||
let cmd = m.hooks[i + 2]
|
||||
say(`> {phase} {event}: {cmd}`)
|
||||
let rc = sh(`{script_env(event, phase)}{cmd}`)
|
||||
if rc != 0 {
|
||||
err(`ludic: the {phase} {event} hook failed (exit {string(rc)}): {cmd}\n`)
|
||||
return rc
|
||||
}
|
||||
}
|
||||
i += 3
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
# the command a script names, or "" when package.ludic has no such script
|
||||
function script_command(name: pointer) -> pointer {
|
||||
if not file_exists("package.ludic") { return "" }
|
||||
let m = read_root_manifest()
|
||||
var i = 0
|
||||
while i + 1 < len(m.scripts) {
|
||||
if m.scripts[i] == name { return m.scripts[i + 1] }
|
||||
i += 2
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# run script `name` with the arguments from argv[first] on, wrapped in its hooks.
|
||||
# -1 when there is no such script.
|
||||
function run_script(name: pointer, first: int) -> int {
|
||||
let cmd = script_command(name)
|
||||
if cmd == "" { return -1 }
|
||||
var extra = ""
|
||||
var ai = first
|
||||
while ai < arg_count() { extra = extra + " " + sh_single(arg(ai)); ai += 1 }
|
||||
let pre = run_hooks("before", name)
|
||||
if pre != 0 { return pre }
|
||||
say(`> {name}: {cmd}{extra}`)
|
||||
let rc = sh(`{script_env(name, "run")}{cmd}{extra}`)
|
||||
if rc != 0 { return rc }
|
||||
return run_hooks("after", name)
|
||||
}
|
||||
|
||||
# ludic script [name [args…]] — run a script, or list them
|
||||
function cmd_script() -> int {
|
||||
if arg_count() < 3 { return cmd_scripts() }
|
||||
let rc = run_script(arg(2), 3)
|
||||
if rc == -1 {
|
||||
err(`ludic script: package.ludic has no script '{arg(2)}'\n`)
|
||||
cmd_scripts()
|
||||
return 1
|
||||
}
|
||||
return rc
|
||||
}
|
||||
|
||||
# ludic scripts — what package.ludic defines
|
||||
function cmd_scripts() -> int {
|
||||
if not file_exists("package.ludic") {
|
||||
err("ludic scripts: no package.ludic here\n")
|
||||
return 1
|
||||
}
|
||||
let m = read_root_manifest()
|
||||
if len(m.scripts) == 0 and len(m.hooks) == 0 {
|
||||
print("package.ludic defines no scripts or hooks")
|
||||
print(" add one: script \"dev\" \"ludic run --headless\"")
|
||||
print(" hook before build \"tools/prepare.sh\"")
|
||||
return 0
|
||||
}
|
||||
if len(m.scripts) > 0 { print("scripts:") }
|
||||
var i = 0
|
||||
while i + 1 < len(m.scripts) {
|
||||
print(` {pad_right(m.scripts[i], 14)} {m.scripts[i + 1]}`)
|
||||
i += 2
|
||||
}
|
||||
if len(m.hooks) > 0 { print("hooks:") }
|
||||
i = 0
|
||||
while i + 2 < len(m.hooks) {
|
||||
print(` {pad_right(m.hooks[i] + " " + m.hooks[i + 1], 20)} {m.hooks[i + 2]}`)
|
||||
i += 3
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function pad_right(s: pointer, w: int) -> pointer {
|
||||
var o = s
|
||||
while slen(o) < w { o = o + " " }
|
||||
return o
|
||||
}
|
||||
|
|
@ -400,6 +400,35 @@ function scaffold_names_case() -> void {
|
|||
ok(lbl)
|
||||
}
|
||||
|
||||
# package.ludic drives the CLI: `entry` (or the one program under src/) is what
|
||||
# build compiles, scripts run by name, and hooks wrap commands — a failing
|
||||
# `before` stops the command, `after` runs only on success.
|
||||
function package_scripts_case() -> void {
|
||||
let lbl = "package.ludic entry, scripts and hooks"
|
||||
let work = `{tmp_dir()}/pkgscripts`
|
||||
shell(`rm -rf {work} && mkdir -p {work}`)
|
||||
let root = capture_line("pwd")
|
||||
let L = `{root}/bin/ludic`
|
||||
if not shq(`cd {work} && {L} new g > /dev/null 2>&1 && mv g/src/main.ludic g/src/game.ludic`) { bad2(lbl, "setup failed"); return }
|
||||
# no src/main.ludic: the single program under src/ is found
|
||||
if not shq(`cd {work}/g && {L} build --headless > {work}/b1.out 2>&1`) { bad2(lbl, "the lone src/ program was not found"); return }
|
||||
# a second program makes it ambiguous; `entry` settles it
|
||||
write_file(`{work}/g/src/other.ludic`, "program Other {\n entry { }\n}\n")
|
||||
if shq(`cd {work}/g && {L} build --headless > {work}/b2.out 2>&1`) { bad2(lbl, "two programs and no entry should not build"); return }
|
||||
var man = read_file(`{work}/g/package.ludic`)
|
||||
man = man + "entry \"src/game.ludic\"\n"
|
||||
man = man + "script \"greet\" \"echo hi-$LUDIC_EVENT\"\n"
|
||||
man = man + "hook before build \"echo pre > hook.log\"\n"
|
||||
man = man + "hook after build \"echo post >> hook.log\"\n"
|
||||
man = man + "hook before test \"exit 7\"\n"
|
||||
write_file(`{work}/g/package.ludic`, man)
|
||||
if not shq(`cd {work}/g && {L} build --headless > {work}/b3.out 2>&1`) { bad2(lbl, "entry was not honoured"); return }
|
||||
if not (capture_line(`tr '\n' ' ' < {work}/g/hook.log`) == "pre post") { bad2(lbl, "build hooks did not run in order"); return }
|
||||
if not (capture_line(`cd {work}/g && {L} greet | tail -1`) == "hi-greet") { bad2(lbl, "a script did not run by name"); return }
|
||||
if not (capture_line(`cd {work}/g && {L} test > /dev/null 2>&1; echo $?`) == "7") { bad2(lbl, "a failing before hook did not stop the command"); return }
|
||||
ok(lbl)
|
||||
}
|
||||
|
||||
# a "does it still compile" smoke test (parse -> lower -> link), no run
|
||||
function qsmoke(path: pointer) -> void {
|
||||
let nm = flat(path)
|
||||
|
|
@ -762,6 +791,7 @@ function cmd_dev_test() -> int {
|
|||
# from its own location. This is the shape `curl … | sh` produces.
|
||||
install_layout_case()
|
||||
scaffold_names_case()
|
||||
package_scripts_case()
|
||||
pack_roundtrip_case()
|
||||
packignore_case()
|
||||
os_dirs_case()
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ function cmd_test_tools() -> int {
|
|||
# IntelliJ SDK). One env var away: LUDIC_TEST_JETBRAINS=1 ludic-dev test-tools
|
||||
if (getenv_or("LUDIC_TEST_JETBRAINS", "0") == "1") {
|
||||
if shq("test -x tools/editors/jetbrains/gradlew") {
|
||||
if shq("cd tools/editors/jetbrains && ./gradlew buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.0.0.zip") { ok("jetbrains plugin builds and verifies") } else { bad("jetbrains plugin build") }
|
||||
if shq("cd tools/editors/jetbrains && ./gradlew buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.4.0.zip") { ok("jetbrains plugin builds and verifies") } else { bad("jetbrains plugin build") }
|
||||
if shq("cd tools/editors/jetbrains && ./gradlew test --console=plain -q >/dev/null 2>&1") { ok("jetbrains platform tests (lexer, file type, PSI, folding)") } else { bad("jetbrains platform tests") }
|
||||
} else { print(" skip jetbrains plugin (no gradle wrapper)") }
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@
|
|||
# whole compilation unit. Ground-truth compilation stays with ludicc; this layer
|
||||
# supplies structure, not judgement.
|
||||
program LudicLsp {
|
||||
import "lsp/imports.ludic"
|
||||
import "lsp/docs.ludic"
|
||||
import "lsp/types.ludic"
|
||||
import "lsp/sighelp.ludic"
|
||||
|
||||
# ---- token kinds ----
|
||||
const LT_EOF: int = 0
|
||||
const LT_NL: int = 1
|
||||
|
|
@ -70,6 +75,13 @@ program LudicLsp {
|
|||
const SC_OPERATOR: int = 22
|
||||
const SC_ANNOTATION: int = 23
|
||||
const SC_UNKNOWN: int = 24
|
||||
const SC_NAMESPACE: int = 25
|
||||
|
||||
# semantic token modifier bits (the legend's order)
|
||||
const SEM_DECLARATION: int = 1
|
||||
const SEM_READONLY: int = 2
|
||||
const SEM_DEFAULT_LIBRARY: int = 4
|
||||
const SEM_VENDOR: int = 8
|
||||
|
||||
# ---- tiny helpers ----
|
||||
function read_file(path: pointer) -> pointer {
|
||||
|
|
@ -161,6 +173,8 @@ program LudicLsp {
|
|||
bend: int = 0
|
||||
parent: int = 0
|
||||
exported: int = 0
|
||||
init: int = 0 # first token of the initializer (let/var/const), or -1
|
||||
mutable: int = 0 # a `var` local
|
||||
}
|
||||
property Doc {
|
||||
path: pointer = null
|
||||
|
|
@ -179,6 +193,7 @@ program LudicLsp {
|
|||
linestart: []int
|
||||
tmatch: []int
|
||||
cls: []int
|
||||
clsmods: []int # extra semantic modifiers per token (declaration, vendor, …)
|
||||
syms: []Sym
|
||||
imports: []pointer
|
||||
had_diags: int = 0
|
||||
|
|
@ -236,6 +251,8 @@ program LudicLsp {
|
|||
D.linestart = new []int
|
||||
push(D.linestart, 0)
|
||||
var i = 0; var line = 0
|
||||
var tmode = 0 # 0 code, 1 template text, 2 inside a template hole
|
||||
var tdepth = 0 # braces opened inside the current hole
|
||||
while s[i] != 0 {
|
||||
let c = s[i]
|
||||
if c == '\n' { push(D.tk_kind, LT_NL); push(D.tk_start, i); push(D.tk_end, i + 1); push(D.tk_line, line); i += 1; line += 1; push(D.linestart, i); continue }
|
||||
|
|
@ -243,7 +260,33 @@ program LudicLsp {
|
|||
var st = i
|
||||
if c == '#' { while s[i] != 0 and s[i] != '\n' { i += 1 }; push(D.tk_kind, LT_COMMENT); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, line); continue }
|
||||
if c == '"' { i += 1; while s[i] != 0 and s[i] != '"' and s[i] != '\n' { if s[i] == '\\' and s[i + 1] != 0 { i += 2 } else { i += 1 } }; if s[i] == '"' { i += 1 }; push(D.tk_kind, LT_STR); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, line); continue }
|
||||
if c == '`' { i += 1; while s[i] != 0 and s[i] != '`' { if s[i] == '\\' and s[i + 1] != 0 { i += 2 } else { i += 1 } }; if s[i] == '`' { i += 1 }; push(D.tk_kind, LT_STR); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, line); continue }
|
||||
# `text {hole} text`: the text runs are string tokens (the hole's braces
|
||||
# ride on them), the hole is lexed as code, so its names resolve
|
||||
if c == '`' or (tmode == 2 and c == '}' and tdepth == 0) {
|
||||
if c == '`' and tmode == 2 { i += 1; tmode = 0; push(D.tk_kind, LT_STR); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, line); continue }
|
||||
i += 1
|
||||
tmode = 1
|
||||
var seg_line = line
|
||||
while s[i] != 0 and tmode == 1 {
|
||||
if s[i] == '`' { i += 1; tmode = 0 }
|
||||
else if s[i] == '{' and s[i + 1] == '{' { i += 2 }
|
||||
else if s[i] == '}' and s[i + 1] == '}' { i += 2 }
|
||||
else if s[i] == '{' { i += 1; tmode = 2; tdepth = 0 }
|
||||
else if s[i] == '\\' and s[i + 1] != 0 { i += 2 }
|
||||
else if s[i] == '\n' {
|
||||
# a template may span lines: close this run, keep the line table right
|
||||
if i > st { push(D.tk_kind, LT_STR); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, seg_line) }
|
||||
i += 1; line += 1; push(D.linestart, i)
|
||||
st = i; seg_line = line
|
||||
}
|
||||
else { i += 1 }
|
||||
}
|
||||
if tmode == 1 { tmode = 0 }
|
||||
if i > st { push(D.tk_kind, LT_STR); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, seg_line) }
|
||||
continue
|
||||
}
|
||||
if tmode == 2 and c == '{' { tdepth += 1 }
|
||||
if tmode == 2 and c == '}' { tdepth -= 1 }
|
||||
if c == '\'' { i += 1; if s[i] == '\\' and s[i + 1] != 0 { i += 2 } else { if s[i] != 0 and s[i] != '\n' { i += 1 } }; if s[i] == '\'' { i += 1 }; push(D.tk_kind, LT_CHAR); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, line); continue }
|
||||
if char_is_digit(c) {
|
||||
if c == '0' and (s[i + 1] == 'x' or s[i + 1] == 'X') { i += 2; while char_is_hex(s[i]) { i += 1 }; push(D.tk_kind, LT_INT); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, line); continue }
|
||||
|
|
@ -271,6 +314,7 @@ program LudicLsp {
|
|||
push(D.tk_kind, LT_ERR); push(D.tk_start, i); push(D.tk_end, i + ln); push(D.tk_line, line); i += ln
|
||||
}
|
||||
push(D.tk_kind, LT_EOF); push(D.tk_start, i); push(D.tk_end, i); push(D.tk_line, line)
|
||||
demote_contextual_keywords(D)
|
||||
}
|
||||
|
||||
function ntok(D: Doc) -> int { return len(D.tk_kind) }
|
||||
|
|
@ -373,8 +417,13 @@ program LudicLsp {
|
|||
if D.tk_kind[i] == LT_COMMENT {
|
||||
var s = D.tk_start[i] + 1
|
||||
while s < D.tk_end[i] and (D.text[s] == ' ' or D.text[s] == '\t' or D.text[s] == '#' or D.text[s] == '*') { s += 1 }
|
||||
buf_addrange(out, D.text, s, D.tk_end[i])
|
||||
buf_putc(out, '\n')
|
||||
var rule = true
|
||||
var r = s
|
||||
while r < D.tk_end[i] { let rc = D.text[r]; if not (rc == '=' or rc == '-' or rc == '~' or rc == ' ') { rule = false }; r += 1 }
|
||||
if not rule or s == D.tk_end[i] {
|
||||
buf_addrange(out, D.text, s, D.tk_end[i])
|
||||
buf_putc(out, '\n')
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
|
@ -400,7 +449,7 @@ program LudicLsp {
|
|||
function sym_add(kind: int, nametok: int, parent: int) -> int {
|
||||
let s = new Sym
|
||||
s.kind = kind; s.tok = nametok; s.parent = parent
|
||||
s.name = ""; s.ty = ""; s.detail = ""; s.doc = ""
|
||||
s.name = ""; s.ty = ""; s.detail = ""; s.doc = ""; s.init = -1; s.mutable = 0
|
||||
if nametok >= 0 { s.start = pD.tk_start[nametok]; s.end = pD.tk_end[nametok]; s.name = ttext(pD, nametok) }
|
||||
s.bstart = 0; s.bend = cstr_len(pD.text)
|
||||
push(pD.syms, s)
|
||||
|
|
@ -425,6 +474,7 @@ program LudicLsp {
|
|||
if pis(":") { padv(); let tt = pname(); if tt >= 0 { ty = ttext(pD, tt) } }
|
||||
let sidx = sym_add(kind, nt, owner)
|
||||
pD.syms[sidx].ty = ty
|
||||
pD.syms[sidx].detail = concat3("param ", pD.syms[sidx].name, concat3(": ", ty, ""))
|
||||
if not first { buf_puts(sig, ", ") }
|
||||
buf_puts(sig, pD.syms[sidx].name); buf_puts(sig, ": ")
|
||||
if ty == "" { buf_puts(sig, "?") } else { buf_puts(sig, ty) }
|
||||
|
|
@ -435,6 +485,17 @@ program LudicLsp {
|
|||
buf_putc(sig, ')') # ')'
|
||||
}
|
||||
|
||||
# the type written at pi (`int`, `[]Doc`, `?Actor`), consumed; "" when none
|
||||
function lp_type_text() -> pointer {
|
||||
var pre = ""
|
||||
while pis("[") or pis("?") {
|
||||
if pis("[") { let m = cur_match(); if m < 0 { return "" }; pi = m + 1; pre = pre + "[]" } else { pi += 1; pre = pre + "?" }
|
||||
}
|
||||
let tt = pname()
|
||||
if tt < 0 { return "" }
|
||||
return pre + ttext(pD, tt)
|
||||
}
|
||||
|
||||
# (a, b) variable list -> token indices into out; returns count
|
||||
function lp_varlist(out: []int) -> int {
|
||||
var n = 0
|
||||
|
|
@ -496,12 +557,15 @@ program LudicLsp {
|
|||
pskipnl()
|
||||
}
|
||||
function lp_stmt(owner: int, scope_end: int) -> void {
|
||||
if pis("let") {
|
||||
if pis("let") or pis("var") {
|
||||
let is_var = pis("var")
|
||||
padv(); let nt = pname(); if nt < 0 { return }
|
||||
var ty = ""
|
||||
if pis(":") { padv(); let tt = pname(); if tt >= 0 { ty = ttext(pD, tt) } }
|
||||
if pis(":") { padv(); ty = lp_type_text() }
|
||||
let sidx = sym_add(LS_LOCAL, nt, owner)
|
||||
pD.syms[sidx].ty = ty
|
||||
if is_var { pD.syms[sidx].mutable = 1 }
|
||||
if pis("=") { padv(); pD.syms[sidx].init = pi }
|
||||
pD.syms[sidx].bstart = pD.tk_start[nt]; pD.syms[sidx].bend = scope_end
|
||||
return
|
||||
}
|
||||
|
|
@ -612,19 +676,14 @@ program LudicLsp {
|
|||
if pis(",") { padv(); continue }
|
||||
while pk() == LT_ANNO { padv() } # @Sync / @Computed on a field
|
||||
let ft = pname(); if ft < 0 { padv(); continue }
|
||||
if is_comp {
|
||||
let f = sym_add(LS_FIELD, ft, sidx)
|
||||
var fty = ""
|
||||
if pis(":") { padv(); let tt = pname(); if tt >= 0 { fty = ttext(pD, tt) } }
|
||||
pD.syms[f].ty = fty
|
||||
pD.syms[f].detail = concat3(pD.syms[sidx].name, ".", concat3(pD.syms[f].name, ": ", fty))
|
||||
if pis("=") { padv(); while pi < ntok(pD) and not pis(",") and not pis("}") and pk() != LT_EOF { if pis("(") or pis("{") or pis("[") { skip_group() } else { padv() } } }
|
||||
if not first { buf_putc(det, ',') }
|
||||
buf_putc(det, ' '); buf_puts(det, pD.syms[f].name); buf_puts(det, ": "); buf_puts(det, fty)
|
||||
} else {
|
||||
if not first { buf_putc(det, ',') }
|
||||
buf_putc(det, ' '); buf_puts(det, ttext(pD, ft))
|
||||
}
|
||||
let f = sym_add(LS_FIELD, ft, sidx)
|
||||
var fty = ""
|
||||
if pis(":") { padv(); fty = lp_type_text() }
|
||||
pD.syms[f].ty = fty
|
||||
pD.syms[f].detail = concat3(pD.syms[sidx].name, ".", concat3(pD.syms[f].name, ": ", fty))
|
||||
if pis("=") { padv(); while pi < ntok(pD) and not pis(",") and not pis("}") and pk() != LT_EOF { if pis("(") or pis("{") or pis("[") { skip_group() } else { padv() } } }
|
||||
if not first { buf_putc(det, ',') }
|
||||
buf_putc(det, ' '); buf_puts(det, pD.syms[f].name); buf_puts(det, ": "); buf_puts(det, fty)
|
||||
first = false
|
||||
}
|
||||
buf_puts(det, " }")
|
||||
|
|
@ -639,7 +698,8 @@ program LudicLsp {
|
|||
padv(); let nt = pname(); if nt < 0 { return }
|
||||
let sidx = sym_add(kind, nt, parent)
|
||||
pD.syms[sidx].doc = collect_doc(pD, decl_tok)
|
||||
if pis(":") { padv(); let tt = pname(); if tt >= 0 { pD.syms[sidx].ty = ttext(pD, tt) } }
|
||||
if pis(":") { padv(); pD.syms[sidx].ty = lp_type_text() }
|
||||
if pis("=") { padv(); pD.syms[sidx].init = pi }
|
||||
pD.syms[sidx].detail = concat3(w, " ", concat3(pD.syms[sidx].name, ": ", pD.syms[sidx].ty))
|
||||
while pi < ntok(pD) and pk() != LT_NL and pk() != LT_EOF { if pis("(") or pis("{") or pis("[") { skip_group() } else { pi += 1 } }
|
||||
pskipnl()
|
||||
|
|
@ -1052,13 +1112,17 @@ program LudicLsp {
|
|||
function imports_of(D: Doc, out: []Doc) -> void {
|
||||
var i = 0
|
||||
while i < len(D.imports) {
|
||||
let full = join_path(dirname(D.path), D.imports[i])
|
||||
let I = ensure_doc(full)
|
||||
if (I != null) {
|
||||
var seen = false
|
||||
var k = 0
|
||||
while k < len(out) { if (out[k].path == I.path) { seen = true }; k += 1 }
|
||||
if not seen { push(out, I); imports_of(I, out) }
|
||||
let files = resolve_import_all(D.path, D.imports[i])
|
||||
var f = 0
|
||||
while f < len(files) {
|
||||
let I = ensure_doc(files[f])
|
||||
if (I != null) {
|
||||
var seen = false
|
||||
var k = 0
|
||||
while k < len(out) { if (out[k].path == I.path) { seen = true }; k += 1 }
|
||||
if not seen { push(out, I); imports_of(I, out) }
|
||||
}
|
||||
f += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
|
@ -1585,6 +1649,9 @@ program LudicLsp {
|
|||
function classify(D: Doc) -> void {
|
||||
let N = ntok(D)
|
||||
if len(D.cls) < N { D.cls = new []int; var z = 0; while z < N { push(D.cls, SC_NONE); z += 1 } }
|
||||
D.clsmods = new []int
|
||||
var zm = 0
|
||||
while zm < N { push(D.clsmods, 0); zm += 1 }
|
||||
var i = 0
|
||||
while i < N {
|
||||
let k = D.tk_kind[i]
|
||||
|
|
@ -1603,9 +1670,26 @@ program LudicLsp {
|
|||
var decl = -1
|
||||
var s = 0
|
||||
while s < len(D.syms) { if D.syms[s].tok == i { decl = s; break }; s += 1 }
|
||||
if decl >= 0 { D.cls[i] = class_of_symkind(D.syms[decl].kind); i += 1; continue }
|
||||
if decl >= 0 {
|
||||
D.cls[i] = class_of_symkind(D.syms[decl].kind)
|
||||
D.clsmods[i] = SEM_DECLARATION | vendor_mod(D)
|
||||
i += 1; continue
|
||||
}
|
||||
let prev = prev_sig(D, i)
|
||||
if prev >= 0 and tis(D, prev, ".") { D.cls[i] = SC_FIELD; i += 1; continue }
|
||||
if prev >= 0 and tis(D, prev, ".") {
|
||||
# Namespace.member: a standard-library call or constant
|
||||
let recv = prev_sig(D, prev)
|
||||
if recv >= 0 and D.tk_kind[recv] == LT_ID and is_namespace_name(ttext(D, recv)) {
|
||||
let nx = next_sig(D, i)
|
||||
if nx >= 0 and tis(D, nx, "(") { D.cls[i] = SC_BUILTIN } else { D.cls[i] = SC_CONST; D.clsmods[i] = SEM_DEFAULT_LIBRARY }
|
||||
i += 1; continue
|
||||
}
|
||||
D.cls[i] = SC_FIELD; i += 1; continue
|
||||
}
|
||||
if is_namespace_name(name) and find_top(D, name) < 0 {
|
||||
let nx = next_sig(D, i)
|
||||
if nx >= 0 and tis(D, nx, ".") { D.cls[i] = SC_NAMESPACE; i += 1; continue }
|
||||
}
|
||||
let nxt = next_sig(D, i)
|
||||
var followed_eq = false
|
||||
if nxt >= 0 and tis(D, nxt, "=") { followed_eq = true }
|
||||
|
|
@ -1619,8 +1703,8 @@ program LudicLsp {
|
|||
let l = find_local(D, name, D.tk_start[i])
|
||||
if l >= 0 { if D.syms[l].kind == LS_PARAM { D.cls[i] = SC_PARAM } else { D.cls[i] = SC_VARIABLE }; i += 1; continue }
|
||||
let top = find_top(D, name)
|
||||
if top >= 0 { D.cls[i] = class_of_symkind(g_owner.syms[top].kind); i += 1; continue }
|
||||
if is_builtin_name(name) { D.cls[i] = SC_BUILTIN; i += 1; continue }
|
||||
if top >= 0 { D.cls[i] = class_of_symkind(g_owner.syms[top].kind); D.clsmods[i] = vendor_mod(g_owner); i += 1; continue }
|
||||
if is_builtin_name(name) or docs_index(name) >= 0 { D.cls[i] = SC_BUILTIN; i += 1; continue }
|
||||
if followed_eq { D.cls[i] = SC_FIELD; i += 1; continue }
|
||||
D.cls[i] = SC_UNKNOWN
|
||||
i += 1
|
||||
|
|
@ -1639,13 +1723,13 @@ program LudicLsp {
|
|||
if cls == SC_LAYER { return 4 }
|
||||
if cls == SC_UI { return 3 }
|
||||
if cls == SC_WIDGET { g_semmods = 4; return 3 }
|
||||
if cls == SC_SYSTEM { return 5 }
|
||||
if cls == SC_SYSTEM { g_semmods = 2; return 5 } # a handler: readonly function
|
||||
if cls == SC_FUNCTION { return 5 }
|
||||
if cls == SC_BUILTIN { g_semmods = 4; return 5 }
|
||||
if cls == SC_PARAM { return 6 }
|
||||
if cls == SC_VARIABLE { return 7 }
|
||||
if cls == SC_MODVAR { return 7 }
|
||||
if cls == SC_CONST { g_semmods = 2; return 7 }
|
||||
if cls == SC_MODVAR { g_semmods = 16; return 7 }
|
||||
if cls == SC_CONST { g_semmods = 18; return 7 }
|
||||
if cls == SC_PROP { return 8 }
|
||||
if cls == SC_FIELD { return 8 }
|
||||
if cls == SC_PHASE { g_semmods = 6; return 9 }
|
||||
|
|
@ -1654,6 +1738,7 @@ program LudicLsp {
|
|||
if cls == SC_COMMENT { return 12 }
|
||||
if cls == SC_OPERATOR { return 13 }
|
||||
if cls == SC_ANNOTATION { return 14 }
|
||||
if cls == SC_NAMESPACE { g_semmods = 4; return 4 }
|
||||
return -1
|
||||
}
|
||||
|
||||
|
|
@ -2275,16 +2360,28 @@ program LudicLsp {
|
|||
let off = off_of(msg, D, "params.position")
|
||||
let tok = tok_at(D, off)
|
||||
if tok < 0 { send_result(id, "null"); return }
|
||||
if is_import_string(D, tok) {
|
||||
let files = resolve_import_all(D.path, import_rel_of(D, tok))
|
||||
let o = buf_new()
|
||||
if len(files) == 0 { hover_md(o, "import " + ttext(D, tok), "_not found: not beside this file, in ludic_modules/, or in the toolchain's packages/_") }
|
||||
else { hover_md(o, "import " + ttext(D, tok), "`" + files[0] + "`") }
|
||||
send_result(id, buf_str(o)); return
|
||||
}
|
||||
resolve_at(D, off)
|
||||
if (g_owner != null) and g_symidx >= 0 {
|
||||
let ow = g_owner
|
||||
let y = ow.syms[g_symidx]
|
||||
var code = y.detail
|
||||
if code == "" { code = y.name }
|
||||
let si = g_symidx
|
||||
let y = ow.syms[si]
|
||||
let code = sym_hover_code(ow, si)
|
||||
var docstr = y.doc
|
||||
if not (ow.path == D.path) { docstr = concat3(docstr, "\n\n_declared in ", concat3(basename(ow.path), "_", "")) }
|
||||
if not (ow.path == D.path) {
|
||||
let where = concat3("_declared in ", basename(ow.path), "_")
|
||||
if (docstr == "") { docstr = where } else { docstr = concat3(docstr, "\n\n", where) }
|
||||
}
|
||||
let o = buf_new(); hover_md(o, code, docstr); send_result(id, buf_str(o)); return
|
||||
}
|
||||
let dh = docs_hover(D, tok)
|
||||
if (dh != null) { send_result(id, dh); return }
|
||||
let name = ttext(D, tok)
|
||||
let sig = builtin_sig(name)
|
||||
if (sig != null) { let o = buf_new(); hover_md(o, sig, ""); send_result(id, buf_str(o)); return }
|
||||
|
|
@ -2299,18 +2396,17 @@ program LudicLsp {
|
|||
if (D == null) { send_result(id, "null"); return }
|
||||
let off = off_of(msg, D, "params.position")
|
||||
let tok = tok_at(D, off)
|
||||
if tok >= 0 and D.tk_kind[tok] == LT_STR {
|
||||
let p = prev_sig(D, tok)
|
||||
if p >= 0 and tis(D, p, "import") {
|
||||
var n = tlen(D, tok) - 2; if n < 0 { n = 0 }
|
||||
let rel = D.src[D.tk_start[tok] + 1..D.tk_start[tok] + 1 + n]
|
||||
let full = join_path(dirname(D.path), rel)
|
||||
let T = ensure_doc(full)
|
||||
if (T != null) { let o = buf_new(); put_location(o, T, 0, 0); send_result(id, buf_str(o)); return }
|
||||
}
|
||||
if is_import_string(D, tok) {
|
||||
let r = import_definition(D, tok)
|
||||
if (r != null) { send_result(id, r); return }
|
||||
send_result(id, "null"); return
|
||||
}
|
||||
resolve_at(D, off)
|
||||
if (g_owner == null) or g_symidx < 0 { send_result(id, "null"); return }
|
||||
if (g_owner == null) or g_symidx < 0 {
|
||||
# a built-in, a built-in type or a namespace: its reference page
|
||||
if tok >= 0 { let dd = docs_definition(D, tok); if (dd != null) { send_result(id, dd); return } }
|
||||
send_result(id, "null"); return
|
||||
}
|
||||
let ow = g_owner
|
||||
let y = ow.syms[g_symidx]
|
||||
let o = buf_new(); put_location(o, ow, y.start, y.end); send_result(id, buf_str(o))
|
||||
|
|
@ -2356,6 +2452,35 @@ program LudicLsp {
|
|||
}
|
||||
buf_putc(o, ']'); send_result(id, buf_str(o))
|
||||
}
|
||||
# highlight usages: only the occurrences in THIS document — a highlight has no
|
||||
# uri, so a range from another file would be painted onto this one
|
||||
function on_document_highlight(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "[]"); return }
|
||||
let off = off_of(msg, D, "params.position")
|
||||
let tok = resolve_at(D, off)
|
||||
if tok < 0 or (g_owner == null) or g_symidx < 0 { send_result(id, "[]"); return }
|
||||
let tdoc = g_owner; let tsym = g_symidx
|
||||
find_occurrences(D, tdoc, tsym)
|
||||
let o = buf_new(); buf_putc(o, '[')
|
||||
var first = true
|
||||
var i = 0
|
||||
while i < len(g_occ_doc) {
|
||||
if (g_occ_doc[i].path == D.path) {
|
||||
let k = g_occ_tok[i]
|
||||
if not first { buf_putc(o, ',') }
|
||||
first = false
|
||||
buf_puts(o, "{\"range\":"); put_range(o, D, D.tk_start[k], D.tk_end[k])
|
||||
var kind = 1
|
||||
let nx = next_sig(D, k)
|
||||
if D.tk_start[k] == tdoc.syms[tsym].start and (tdoc.path == D.path) { kind = 3 }
|
||||
else if nx >= 0 and (tis(D, nx, "=") or tis(D, nx, "+=") or tis(D, nx, "-=") or tis(D, nx, "*=") or tis(D, nx, "/=")) { kind = 3 }
|
||||
buf_puts(o, ",\"kind\":"); buf_puti(o, kind); buf_putc(o, '}')
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
buf_putc(o, ']'); send_result(id, buf_str(o))
|
||||
}
|
||||
function on_rename(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
let newname = jstr(jpath(msg, "params.newName"), null)
|
||||
|
|
@ -2397,25 +2522,34 @@ program LudicLsp {
|
|||
|
||||
# ---- completion ----
|
||||
var g_first: int = 1
|
||||
# the rank prefix for the next items: in-scope names before globals before
|
||||
# builtins before keywords
|
||||
var g_rank: pointer = "5"
|
||||
function comp_item(o: Buf, label: pointer, kind: int, detail: pointer) -> void {
|
||||
comp_item_full(o, label, kind, detail, g_rank + label, "")
|
||||
}
|
||||
function comp_item_full(o: Buf, label: pointer, kind: int, detail: pointer, sort: pointer, insert: pointer) -> void {
|
||||
if g_first == 0 { buf_putc(o, ',') }
|
||||
g_first = 0
|
||||
buf_puts(o, "{\"label\":"); jstr_out(o, label); buf_puts(o, ",\"kind\":"); buf_puti(o, kind)
|
||||
if (detail != null) and (detail[0] != 0) { buf_puts(o, ",\"detail\":"); jstr_out(o, detail) }
|
||||
buf_puts(o, ",\"sortText\":"); jstr_out(o, sort)
|
||||
if (insert != null) and (insert[0] != 0) { buf_puts(o, ",\"insertText\":"); jstr_out(o, insert) }
|
||||
buf_putc(o, '}')
|
||||
}
|
||||
function comp_scope(o: Buf, D: Doc, off: int, types_only: int) -> void {
|
||||
if types_only == 0 {
|
||||
g_rank = "1"
|
||||
var i = 0
|
||||
while i < len(D.syms) {
|
||||
let y = D.syms[i]
|
||||
if is_local_kind(y.kind) and off >= y.bstart and off <= y.bend {
|
||||
var det = y.detail; if det == "" { det = y.ty }
|
||||
comp_item(o, y.name, 6, det)
|
||||
if is_local_kind(y.kind) and off >= y.bstart and off <= y.bend and y.bstart <= off {
|
||||
comp_item(o, y.name, 6, sym_type(D, i, 0))
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
g_rank = "2"
|
||||
let rel = related(D)
|
||||
var r = 0
|
||||
while r < len(rel) {
|
||||
|
|
@ -2436,7 +2570,7 @@ program LudicLsp {
|
|||
comp_item(o, "int", 14, "built-in type"); comp_item(o, "long", 14, "built-in type"); comp_item(o, "fixed", 14, "built-in type"); comp_item(o, "bool", 14, "built-in type")
|
||||
comp_item(o, "entity", 14, "built-in type"); comp_item(o, "string", 14, "built-in type"); comp_item(o, "pointer", 14, "built-in type"); comp_item(o, "Vector", 14, "built-in type"); comp_item(o, "IVec2", 14, "built-in type"); comp_item(o, "Rect", 14, "built-in type"); comp_item(o, "void", 14, "built-in type")
|
||||
}
|
||||
function comp_builtins(o: Buf) -> void { var i = 0; while i < len(g_builtins) { comp_item(o, g_builtins[i], 3, g_bsigs[i]); i += 1 } }
|
||||
function comp_builtins(o: Buf) -> void { g_rank = "3"; var i = 0; while i < len(g_builtins) { comp_item(o, g_builtins[i], 3, g_bsigs[i]); i += 1 } }
|
||||
function on_completion(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "{\"isIncomplete\":false,\"items\":[]}"); return }
|
||||
|
|
@ -2469,6 +2603,8 @@ program LudicLsp {
|
|||
if in_qt == 1 { comp_scope(o, D, off, 1); done = 1 }
|
||||
else { if anchor >= 0 {
|
||||
if tis(D, anchor, ".") {
|
||||
let recv = prev_sig(D, anchor)
|
||||
if recv >= 0 and D.tk_kind[recv] == LT_ID { if comp_ns_members(o, ttext(D, recv)) { done = 1 } }
|
||||
let comp = receiver_component(D, anchor)
|
||||
if comp >= 0 and (g_owner != null) {
|
||||
let co = g_owner
|
||||
|
|
@ -2486,11 +2622,23 @@ program LudicLsp {
|
|||
if done == 0 and in_ui_body(D, off) {
|
||||
comp_item(o, "panel", 7, "widget"); comp_item(o, "col", 7, "widget"); comp_item(o, "row", 7, "widget"); comp_item(o, "label", 7, "widget"); comp_item(o, "button", 7, "widget"); comp_item(o, "image", 7, "widget"); comp_item(o, "spacer", 7, "widget")
|
||||
}
|
||||
var in_args = false
|
||||
if done == 0 and find_call_at(D, off) {
|
||||
in_args = true
|
||||
if anchor >= 0 and (tis(D, anchor, "(") or tis(D, anchor, ",")) { if comp_call_args(o, D, off) { } }
|
||||
}
|
||||
if done == 0 and in_args {
|
||||
comp_builtins(o)
|
||||
comp_scope(o, D, off, 0)
|
||||
done = 1
|
||||
}
|
||||
if done == 0 {
|
||||
comp_item(o, "let", 14, "statement"); comp_item(o, "if", 14, "statement"); comp_item(o, "for", 14, "statement"); comp_item(o, "while", 14, "statement"); comp_item(o, "return", 14, "statement"); comp_item(o, "spawn", 14, "statement"); comp_item(o, "handler", 14, "declaration"); comp_item(o, "property", 14, "declaration"); comp_item(o, "model", 14, "declaration")
|
||||
g_rank = "4"
|
||||
comp_item(o, "let", 14, "statement"); comp_item(o, "var", 14, "statement"); comp_item(o, "if", 14, "statement"); comp_item(o, "for", 14, "statement"); comp_item(o, "while", 14, "statement"); comp_item(o, "return", 14, "statement"); comp_item(o, "spawn", 14, "statement"); comp_item(o, "handler", 14, "declaration"); comp_item(o, "property", 14, "declaration"); comp_item(o, "model", 14, "declaration")
|
||||
comp_types(o)
|
||||
comp_builtins(o)
|
||||
comp_scope(o, D, off, 0)
|
||||
g_rank = "5"
|
||||
}
|
||||
buf_puts(o, "]}")
|
||||
send_result(id, buf_str(o))
|
||||
|
|
@ -2514,51 +2662,6 @@ program LudicLsp {
|
|||
}
|
||||
buf_putc(o, ']'); send_result(id, buf_str(o))
|
||||
}
|
||||
function on_signature_help(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "null"); return }
|
||||
let off = off_of(msg, D, "params.position")
|
||||
var depth = 0; var callee = -1; var arg = 0
|
||||
var i = ntok(D) - 1
|
||||
while i >= 0 {
|
||||
let t = i
|
||||
if D.tk_start[t] >= off { i -= 1; continue }
|
||||
if D.tk_kind[t] == LT_OP and tlen(D, t) == 1 {
|
||||
let c = D.src[D.tk_start[t]]
|
||||
if c == ')' { depth += 1 }
|
||||
else { if c == '(' {
|
||||
if depth == 0 {
|
||||
callee = prev_sig(D, t)
|
||||
var k = t + 1
|
||||
while k < ntok(D) and D.tk_start[k] < off { if tis(D, k, ",") { arg += 1 }; k += 1 }
|
||||
i = -1
|
||||
} else { depth -= 1 }
|
||||
} }
|
||||
}
|
||||
i -= 1
|
||||
}
|
||||
if callee < 0 or D.tk_kind[callee] != LT_ID { send_result(id, "null"); return }
|
||||
let name = ttext(D, callee)
|
||||
var sig: pointer = null
|
||||
# a namespaced call `Namespace.method(` resolves through the ns surface first
|
||||
let dot = prev_sig(D, callee)
|
||||
if dot >= 0 and tis(D, dot, ".") {
|
||||
let nsi = prev_sig(D, dot)
|
||||
if nsi >= 0 and D.tk_kind[nsi] == LT_ID { sig = ns_method_sig(ttext(D, nsi), name) }
|
||||
}
|
||||
if (sig == null) { sig = builtin_sig(name) }
|
||||
var docstr = ""
|
||||
if (sig == null) {
|
||||
let t = find_top(D, name)
|
||||
if t >= 0 and (g_owner.syms[t].kind == LS_FN or g_owner.syms[t].kind == LS_EXTERN) { sig = g_owner.syms[t].detail; docstr = g_owner.syms[t].doc }
|
||||
}
|
||||
if (sig == null) { send_result(id, "null"); return }
|
||||
let o = buf_new()
|
||||
buf_puts(o, "{\"signatures\":[{\"label\":"); jstr_out(o, sig)
|
||||
if (docstr != null) and (docstr[0] != 0) { buf_puts(o, ",\"documentation\":"); jstr_out(o, docstr) }
|
||||
buf_puts(o, "}],\"activeSignature\":0,\"activeParameter\":"); buf_puti(o, arg); buf_putc(o, '}')
|
||||
send_result(id, buf_str(o))
|
||||
}
|
||||
function on_semantic_tokens(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "null"); return }
|
||||
|
|
@ -2580,7 +2683,9 @@ program LudicLsp {
|
|||
if dl == 0 { dc = ch - prev_char }
|
||||
if first == 0 { buf_putc(o, ',') }
|
||||
first = 0
|
||||
buf_puti(o, dl); buf_putc(o, ','); buf_puti(o, dc); buf_putc(o, ','); buf_puti(o, ln); buf_putc(o, ','); buf_puti(o, ty); buf_putc(o, ','); buf_puti(o, g_semmods)
|
||||
var mods = g_semmods
|
||||
if i < len(D.clsmods) { mods = mods | D.clsmods[i] }
|
||||
buf_puti(o, dl); buf_putc(o, ','); buf_puti(o, dc); buf_putc(o, ','); buf_puti(o, ln); buf_putc(o, ','); buf_puti(o, ty); buf_putc(o, ','); buf_puti(o, mods)
|
||||
prev_line = line; prev_char = ch
|
||||
}
|
||||
}
|
||||
|
|
@ -2703,6 +2808,7 @@ program LudicLsp {
|
|||
buf_puts(o, ",\"documentSymbolProvider\":true")
|
||||
buf_puts(o, ",\"workspaceSymbolProvider\":true")
|
||||
buf_puts(o, ",\"definitionProvider\":true")
|
||||
buf_puts(o, ",\"typeDefinitionProvider\":true")
|
||||
buf_puts(o, ",\"declarationProvider\":true")
|
||||
buf_puts(o, ",\"referencesProvider\":true")
|
||||
buf_puts(o, ",\"documentHighlightProvider\":true")
|
||||
|
|
@ -2715,7 +2821,7 @@ program LudicLsp {
|
|||
buf_puts(o, ",\"inlayHintProvider\":true")
|
||||
buf_puts(o, ",\"completionProvider\":{\"triggerCharacters\":[\".\",\"@\",\"[\",\" \"],\"resolveProvider\":false}")
|
||||
buf_puts(o, ",\"signatureHelpProvider\":{\"triggerCharacters\":[\"(\",\",\"]}")
|
||||
buf_puts(o, ",\"semanticTokensProvider\":{\"legend\":{\"tokenTypes\":[\"keyword\",\"type\",\"struct\",\"class\",\"namespace\",\"function\",\"parameter\",\"variable\",\"property\",\"enumMember\",\"number\",\"string\",\"comment\",\"operator\",\"decorator\"],\"tokenModifiers\":[\"declaration\",\"readonly\",\"defaultLibrary\"]},\"full\":true,\"range\":true}")
|
||||
buf_puts(o, ",\"semanticTokensProvider\":{\"legend\":{\"tokenTypes\":[\"keyword\",\"type\",\"struct\",\"class\",\"namespace\",\"function\",\"parameter\",\"variable\",\"property\",\"enumMember\",\"number\",\"string\",\"comment\",\"operator\",\"decorator\"],\"tokenModifiers\":[\"declaration\",\"readonly\",\"defaultLibrary\",\"vendor\",\"static\"]},\"full\":true,\"range\":true}")
|
||||
buf_puts(o, "},\"serverInfo\":{\"name\":\"ludic-lsp\",\"version\":\"1.0.0\"}}")
|
||||
return buf_str(o)
|
||||
}
|
||||
|
|
@ -2754,18 +2860,19 @@ program LudicLsp {
|
|||
if (method == "textDocument/didSave") { on_did_save(msg); return }
|
||||
if (method == "textDocument/documentSymbol") { let D = doc_of(msg); if (D == null) { send_result(id, "[]"); return }; let o = buf_new(); put_doc_symbols(o, D, -1); send_result(id, buf_str(o)); return }
|
||||
if (method == "workspace/symbol") { on_workspace_symbol(msg, id); return }
|
||||
if (method == "textDocument/definition") or (method == "textDocument/declaration") or (method == "textDocument/typeDefinition") { on_definition(msg, id); return }
|
||||
if (method == "textDocument/definition") or (method == "textDocument/declaration") { on_definition(msg, id); return }
|
||||
if (method == "textDocument/typeDefinition") { on_type_definition(msg, id); return }
|
||||
if (method == "textDocument/hover") { on_hover(msg, id); return }
|
||||
if (method == "textDocument/references") { on_references(msg, id); return }
|
||||
if (method == "textDocument/documentHighlight") { on_references(msg, id); return }
|
||||
if (method == "textDocument/documentHighlight") { on_document_highlight(msg, id); return }
|
||||
if (method == "textDocument/rename") { on_rename(msg, id); return }
|
||||
if (method == "textDocument/prepareRename") { let D = doc_of(msg); if (D == null) { send_result(id, "null"); return }; let off = off_of(msg, D, "params.position"); let tok = resolve_at(D, off); if tok < 0 or (g_owner == null) or g_symidx < 0 or D.tk_kind[tok] != LT_ID { send_result(id, "null"); return }; let o = buf_new(); buf_puts(o, "{\"range\":"); put_range(o, D, D.tk_start[tok], D.tk_end[tok]); buf_puts(o, ",\"placeholder\":"); jstr_out(o, ttext(D, tok)); buf_putc(o, '}'); send_result(id, buf_str(o)); return }
|
||||
if (method == "textDocument/formatting") or (method == "textDocument/rangeFormatting") { on_formatting(msg, id); return }
|
||||
if (method == "textDocument/foldingRange") { on_folding(msg, id); return }
|
||||
if (method == "textDocument/inlayHint") { on_inlay_hint(msg, id); return }
|
||||
if (method == "textDocument/signatureHelp") { on_signature_help(msg, id); return }
|
||||
if (method == "textDocument/signatureHelp") { on_signature_help2(msg, id); return }
|
||||
if (method == "textDocument/completion") { on_completion(msg, id); return }
|
||||
if (method == "textDocument/documentLink") { send_result(id, "[]"); return }
|
||||
if (method == "textDocument/documentLink") { on_document_link(msg, id); return }
|
||||
if (method == "textDocument/semanticTokens/full") or (method == "textDocument/semanticTokens/range") { on_semantic_tokens(msg, id); return }
|
||||
if (id != null) { send_result(id, "null") }
|
||||
}
|
||||
|
|
|
|||
253
tools/ludic-tools/lsp/docs.ludic
Normal file
253
tools/ludic-tools/lsp/docs.ludic
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
# lsp/docs.ludic — the language reference, indexed for hover and go-to.
|
||||
#
|
||||
# docs/language/**.md (shipped in the install root) holds one page per symbol,
|
||||
# with front matter:
|
||||
#
|
||||
# id: screen-fill_circle -> <DOCS_URL><id>.html
|
||||
# tokens: Screen.fill_circle the source spellings it documents
|
||||
# sig: / tip: / kind: / ns:
|
||||
#
|
||||
# Built-ins, built-in types and namespace methods therefore get a signature, a
|
||||
# one-line summary and a link in hover, and go-to-definition opens the page.
|
||||
# Loaded once, on first use; a toolchain without docs simply has no entries.
|
||||
|
||||
var DOCS_URL: pointer = "https://workshopsoft.pages.workshopsoft.io/ludic/"
|
||||
|
||||
var g_docs_loaded: bool = false
|
||||
var g_dx_token: []pointer
|
||||
var g_dx_file: []pointer
|
||||
var g_dx_id: []pointer
|
||||
var g_dx_sig: []pointer
|
||||
var g_dx_tip: []pointer
|
||||
var g_dx_kind: []pointer
|
||||
# namespace name -> its section page
|
||||
var g_dxn_name: []pointer
|
||||
var g_dxn_file: []pointer
|
||||
var g_dxn_id: []pointer
|
||||
|
||||
# the value of `key: …` in a front-matter block, or ""
|
||||
function fm_get(fm: pointer, key: pointer) -> pointer {
|
||||
let n = cstr_len(fm)
|
||||
let kl = cstr_len(key)
|
||||
var i = 0
|
||||
while i < n {
|
||||
var e = i
|
||||
while e < n and fm[e] != '\n' { e += 1 }
|
||||
if e - i > kl and fm[i..i + kl] == key and fm[i + kl] == ':' {
|
||||
var s = i + kl + 1
|
||||
while s < e and fm[s] == ' ' { s += 1 }
|
||||
var t = e
|
||||
while t > s and (fm[t - 1] == ' ' or fm[t - 1] == '\r') { t -= 1 }
|
||||
return fm[s..t]
|
||||
}
|
||||
i = e + 1
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# the front matter of a page (between the leading `---` lines), or ""
|
||||
function fm_of(text: pointer) -> pointer {
|
||||
if not starts_with(text, "---\n") { return "" }
|
||||
let n = cstr_len(text)
|
||||
var i = 4
|
||||
while i < n {
|
||||
if text[i] == '-' and text[i - 1] == '\n' and i + 3 <= n and text[i..i + 3] == "---" { return text[4..i] }
|
||||
i += 1
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# the body after the front matter
|
||||
function fm_body(text: pointer) -> pointer {
|
||||
let fm = fm_of(text)
|
||||
if (fm == "") { return text }
|
||||
var i = 4 + cstr_len(fm) + 3
|
||||
while text[i] == '\n' or text[i] == '\r' { i += 1 }
|
||||
return text[i..cstr_len(text)]
|
||||
}
|
||||
|
||||
function docs_add(tok: pointer, file: pointer, fm: pointer) -> void {
|
||||
push(g_dx_token, tok)
|
||||
push(g_dx_file, file)
|
||||
push(g_dx_id, fm_get(fm, "id"))
|
||||
push(g_dx_sig, fm_get(fm, "sig"))
|
||||
push(g_dx_tip, fm_get(fm, "tip"))
|
||||
push(g_dx_kind, fm_get(fm, "kind"))
|
||||
}
|
||||
|
||||
function docs_load() -> void {
|
||||
if g_docs_loaded { return }
|
||||
g_docs_loaded = true
|
||||
g_dx_token = new []pointer; g_dx_file = new []pointer; g_dx_id = new []pointer
|
||||
g_dx_sig = new []pointer; g_dx_tip = new []pointer; g_dx_kind = new []pointer
|
||||
g_dxn_name = new []pointer; g_dxn_file = new []pointer; g_dxn_id = new []pointer
|
||||
let root = lsp_home() + "docs/language"
|
||||
if not Fs.is_dir(root) { return }
|
||||
let listfile = `{Os.temp_dir()}/ludic_lsp_docs_{Os.pid()}.txt`
|
||||
run(`find '{root}' -name '*.md' > '{listfile}' 2>/dev/null`)
|
||||
let list = read_file(listfile)
|
||||
run(`rm -f '{listfile}'`)
|
||||
if (list == null) { return }
|
||||
var i = 0
|
||||
var start = 0
|
||||
while list[i] != 0 {
|
||||
if list[i] == '\n' {
|
||||
if i > start { docs_index_file(list[start..i]) }
|
||||
start = i + 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
if i > start { docs_index_file(list[start..i]) }
|
||||
}
|
||||
|
||||
function docs_index_file(path: pointer) -> void {
|
||||
if ends_with(path, "/_section.md") { return }
|
||||
let text = read_file(path)
|
||||
if (text == null) { return }
|
||||
let fm = fm_of(text)
|
||||
if (fm == "") { return }
|
||||
let toks = fm_get(fm, "tokens")
|
||||
var i = 0
|
||||
let n = cstr_len(toks)
|
||||
while i < n {
|
||||
while i < n and toks[i] == ' ' { i += 1 }
|
||||
let s = i
|
||||
while i < n and toks[i] != ' ' { i += 1 }
|
||||
if i > s { docs_add(toks[s..i], path, fm) }
|
||||
}
|
||||
# a namespace method also tells us where its namespace's section page is
|
||||
let ns = fm_get(fm, "ns")
|
||||
if not (ns == "") and docs_ns_index(ns) < 0 {
|
||||
let sec = dirname(path) + "/_section.md"
|
||||
if Fs.exists(sec) {
|
||||
push(g_dxn_name, ns)
|
||||
push(g_dxn_file, sec)
|
||||
push(g_dxn_id, "ns-" + lower_str(ns))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function lower_str(s: pointer) -> pointer {
|
||||
let n = cstr_len(s)
|
||||
let b = bytes(n + 1)
|
||||
var i = 0
|
||||
while i < n { b[i] = lower(s[i]); i += 1 }
|
||||
b[n] = 0
|
||||
return b
|
||||
}
|
||||
|
||||
function docs_index(tok: pointer) -> int {
|
||||
docs_load()
|
||||
var i = 0
|
||||
while i < len(g_dx_token) { if (g_dx_token[i] == tok) { return i }; i += 1 }
|
||||
return -1
|
||||
}
|
||||
|
||||
function docs_ns_index(ns: pointer) -> int {
|
||||
var i = 0
|
||||
while i < len(g_dxn_name) { if (g_dxn_name[i] == ns) { return i }; i += 1 }
|
||||
return -1
|
||||
}
|
||||
|
||||
function docs_url(i: int) -> pointer { return DOCS_URL + g_dx_id[i] + ".html" }
|
||||
|
||||
# the spelling a token is documented under: `Screen.fill_circle` for the
|
||||
# method name in `Screen.fill_circle(…)`, else the word itself
|
||||
function doc_key_at(D: Doc, tok: int) -> pointer {
|
||||
let name = ttext(D, tok)
|
||||
let dot = prev_sig(D, tok)
|
||||
if dot >= 0 and tis(D, dot, ".") {
|
||||
let recv = prev_sig(D, dot)
|
||||
if recv >= 0 { return ttext(D, recv) + "." + name }
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
# a hover for a documented name, or null
|
||||
function docs_hover(D: Doc, tok: int) -> pointer {
|
||||
let key = doc_key_at(D, tok)
|
||||
let i = docs_index(key)
|
||||
if i >= 0 {
|
||||
var sig = g_dx_sig[i]
|
||||
if (sig == "") { sig = key }
|
||||
let m = buf_new()
|
||||
buf_puts(m, g_dx_tip[i])
|
||||
let body = docs_first_paragraph(g_dx_file[i])
|
||||
if not (body == "") and not (body == g_dx_tip[i]) { buf_puts(m, "\n\n"); buf_puts(m, body) }
|
||||
buf_puts(m, "\n\n[Documentation](")
|
||||
buf_puts(m, docs_url(i))
|
||||
buf_puts(m, ")")
|
||||
let o = buf_new()
|
||||
hover_md(o, sig, buf_str(m))
|
||||
return buf_str(o)
|
||||
}
|
||||
let n = docs_ns_index(ttext(D, tok))
|
||||
if n >= 0 {
|
||||
let o = buf_new()
|
||||
let body = docs_first_paragraph(g_dxn_file[n])
|
||||
hover_md(o, "namespace " + g_dxn_name[n], body + "\n\n[Documentation](" + DOCS_URL + g_dxn_id[n] + ".html)")
|
||||
return buf_str(o)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
# go-to-definition for a documented name: its page
|
||||
function docs_definition(D: Doc, tok: int) -> pointer {
|
||||
var file: pointer = null
|
||||
let i = docs_index(doc_key_at(D, tok))
|
||||
if i >= 0 { file = g_dx_file[i] }
|
||||
else {
|
||||
let n = docs_ns_index(ttext(D, tok))
|
||||
if n >= 0 { file = g_dxn_file[n] }
|
||||
}
|
||||
if (file == null) { return null }
|
||||
let o = buf_new()
|
||||
buf_puts(o, "{\"uri\":")
|
||||
jstr_out(o, path_to_uri(file))
|
||||
buf_puts(o, ",\"range\":{\"start\":{\"line\":0,\"character\":0},\"end\":{\"line\":0,\"character\":0}}}")
|
||||
return buf_str(o)
|
||||
}
|
||||
|
||||
# the first prose paragraph of a page, markup kept (the editor renders markdown)
|
||||
function docs_first_paragraph(path: pointer) -> pointer {
|
||||
let text = read_file(path)
|
||||
if (text == null) { return "" }
|
||||
let body = fm_body(text)
|
||||
let n = cstr_len(body)
|
||||
var i = 0
|
||||
while i < n and (body[i] == '\n' or body[i] == ' ') { i += 1 }
|
||||
if i + 2 < n and body[i..i + 3] == "```" { return "" }
|
||||
let s = i
|
||||
while i < n {
|
||||
if body[i] == '\n' and i + 1 < n and body[i + 1] == '\n' { break }
|
||||
i += 1
|
||||
}
|
||||
var p = body[s..i]
|
||||
if cstr_len(p) > 600 { p = p[0..600] + "…" }
|
||||
return p
|
||||
}
|
||||
|
||||
# the documented signature of a namespace method or builtin, or null
|
||||
function docs_sig(key: pointer) -> pointer {
|
||||
let i = docs_index(key)
|
||||
if i < 0 { return null }
|
||||
if (g_dx_sig[i] == "") { return null }
|
||||
return g_dx_sig[i]
|
||||
}
|
||||
|
||||
function docs_tip(key: pointer) -> pointer {
|
||||
let i = docs_index(key)
|
||||
if i < 0 { return "" }
|
||||
return g_dx_tip[i]
|
||||
}
|
||||
|
||||
# every documented member of a namespace: `Screen` -> fill_circle, show, …
|
||||
function docs_ns_members(ns: pointer, out: []int) -> void {
|
||||
docs_load()
|
||||
let pre = ns + "."
|
||||
var i = 0
|
||||
while i < len(g_dx_token) {
|
||||
if starts_with(g_dx_token[i], pre) { push(out, i) }
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
215
tools/ludic-tools/lsp/imports.ludic
Normal file
215
tools/ludic-tools/lsp/imports.ludic
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# lsp/imports.ludic — where an `import "…"` string points, resolved the way the
|
||||
# compiler's do_import (selfhost/frontend/parse.ludic) resolves it:
|
||||
#
|
||||
# 1. relative to the importing file import "camp/save.ludic"
|
||||
# 2. the package's ludic_modules/ (or $LUDIC_MODULES) third-party packages
|
||||
# 3. the install root: runtime/… and packages/… import "ludic.render3d/r3d.ludic"
|
||||
#
|
||||
# `dir/*.ludic` imports every .ludic in the directory, sorted by name.
|
||||
|
||||
var g_home: pointer = null
|
||||
|
||||
# the install root with a trailing '/': $LUDIC_HOME, else the directory above
|
||||
# this binary's bin/ (a checkout's bin/ludic-lsp gives the checkout itself)
|
||||
function lsp_home() -> pointer {
|
||||
if (g_home != null) { return g_home }
|
||||
var h = Os.env_or("LUDIC_HOME", "")
|
||||
if not (h == "") {
|
||||
if not ends_with(h, "/") { h = h + "/" }
|
||||
g_home = h
|
||||
return h
|
||||
}
|
||||
let me = arg(0)
|
||||
var i = cstr_len(me)
|
||||
while i > 0 and me[i - 1] != '/' { i -= 1 }
|
||||
var d = ""
|
||||
if i > 0 { d = me[0..i] }
|
||||
else { d = path_dir_holding(me) } # started by name, from $PATH
|
||||
if not (d == "") and d[0] != '/' { # started by a relative path
|
||||
let cwd = Os.env_or("PWD", "")
|
||||
if not (cwd == "") { d = cwd + "/" + d }
|
||||
}
|
||||
if not (d == "") { d = normpath(d) + "/" }
|
||||
if ends_with(d, "/bin/") { d = d[0..len(d) - 4] }
|
||||
g_home = d
|
||||
return d
|
||||
}
|
||||
|
||||
# the first $PATH directory holding `name`, with a trailing '/', or ""
|
||||
function path_dir_holding(name: pointer) -> pointer {
|
||||
let p = Os.env_or("PATH", "")
|
||||
let n = cstr_len(p)
|
||||
var a = 0
|
||||
while a < n {
|
||||
var b = a
|
||||
while b < n and p[b] != ':' { b += 1 }
|
||||
if b > a {
|
||||
let dir = p[a..b]
|
||||
if Fs.exists(dir + "/" + name) { return dir + "/" }
|
||||
}
|
||||
a = b + 1
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# the nearest directory at or above dir holding a package.ludic, or null
|
||||
function package_root_of(dir: pointer) -> pointer {
|
||||
var d = dir
|
||||
var guard = 0
|
||||
while guard < 64 {
|
||||
if Fs.exists(d + "/package.ludic") { return d }
|
||||
if (d == "/") or (d == ".") or (d == "") { return null }
|
||||
d = dirname(d)
|
||||
guard += 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function modules_dir_for(from: pointer) -> pointer {
|
||||
let env = Os.env_or("LUDIC_MODULES", "")
|
||||
if not (env == "") {
|
||||
if env[0] == '/' { return env }
|
||||
let r = package_root_of(dirname(from))
|
||||
if (r != null) { return join_path(r, env) }
|
||||
return null
|
||||
}
|
||||
let root = package_root_of(dirname(from))
|
||||
if (root == null) { return null }
|
||||
return root + "/ludic_modules"
|
||||
}
|
||||
|
||||
# one file an import names, or null when it cannot be found anywhere
|
||||
function resolve_import(from: pointer, rel: pointer) -> pointer {
|
||||
if (rel == "") { return null }
|
||||
# `import "camp"`: the directory's barrel
|
||||
if not ends_with(rel, ".ludic") {
|
||||
if ends_with(rel, "/") { return resolve_import(from, rel + "index.ludic") }
|
||||
return resolve_import(from, rel + "/index.ludic")
|
||||
}
|
||||
let direct = join_path(dirname(from), rel)
|
||||
if Fs.exists(direct) and not Fs.is_dir(direct) { return direct }
|
||||
if rel[0] == '/' { return null }
|
||||
let mods = modules_dir_for(from)
|
||||
if (mods != null) {
|
||||
let m = join_path(mods, rel)
|
||||
if Fs.exists(m) and not Fs.is_dir(m) { return m }
|
||||
}
|
||||
let home = lsp_home()
|
||||
if not (home == "") {
|
||||
if starts_with(rel, "runtime/") {
|
||||
let rt = join_path(home, rel)
|
||||
if Fs.exists(rt) { return rt }
|
||||
}
|
||||
let pk = join_path(home + "packages", rel)
|
||||
if Fs.exists(pk) and not Fs.is_dir(pk) { return pk }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function is_glob_import(rel: pointer) -> bool { return ends_with(rel, "/*.ludic") }
|
||||
|
||||
# every file an import names: one for a plain import, the sorted directory
|
||||
# listing for `dir/*.ludic`
|
||||
function resolve_import_all(from: pointer, rel: pointer) -> []pointer {
|
||||
let out = new []pointer
|
||||
if not is_glob_import(rel) {
|
||||
let one = resolve_import(from, rel)
|
||||
if (one != null) { push(out, one) }
|
||||
return out
|
||||
}
|
||||
let dirrel = rel[0..len(rel) - 8]
|
||||
var dir = join_path(dirname(from), dirrel)
|
||||
if not Fs.is_dir(dir) {
|
||||
let home = lsp_home()
|
||||
dir = join_path(home + "packages", dirrel)
|
||||
if not Fs.is_dir(dir) { return out }
|
||||
}
|
||||
let names = Fs.list(dir)
|
||||
if (names == null) { return out }
|
||||
var i = 0
|
||||
while i < len(names) {
|
||||
var best = i
|
||||
var j = i + 1
|
||||
while j < len(names) { if str_before(names[j], names[best]) { best = j }; j += 1 }
|
||||
let t = names[i]; names[i] = names[best]; names[best] = t
|
||||
i += 1
|
||||
}
|
||||
i = 0
|
||||
while i < len(names) {
|
||||
if ends_with(names[i], ".ludic") { push(out, join_path(dir, names[i])) }
|
||||
i += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function str_before(a: pointer, b: pointer) -> bool {
|
||||
var i = 0
|
||||
while a[i] != 0 and b[i] != 0 {
|
||||
if a[i] != b[i] { return a[i] < b[i] }
|
||||
i += 1
|
||||
}
|
||||
return a[i] == 0 and b[i] != 0
|
||||
}
|
||||
|
||||
# the text inside an import's string token (no quotes)
|
||||
function import_rel_of(D: Doc, tok: int) -> pointer {
|
||||
var n = tlen(D, tok) - 2
|
||||
if n < 0 { n = 0 }
|
||||
return D.src[D.tk_start[tok] + 1..D.tk_start[tok] + 1 + n]
|
||||
}
|
||||
|
||||
# is tok the string of an `import "…"`?
|
||||
function is_import_string(D: Doc, tok: int) -> bool {
|
||||
if tok < 0 or D.tk_kind[tok] != LT_STR { return false }
|
||||
let p = prev_sig(D, tok)
|
||||
return p >= 0 and tis(D, p, "import")
|
||||
}
|
||||
|
||||
# textDocument/documentLink: every import string links to its file, so the
|
||||
# editor underlines it and Cmd/Ctrl-click opens it
|
||||
function on_document_link(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "[]"); return }
|
||||
let o = buf_new()
|
||||
buf_putc(o, '[')
|
||||
var first = true
|
||||
var t = 0
|
||||
while t < ntok(D) {
|
||||
if is_import_string(D, t) {
|
||||
let files = resolve_import_all(D.path, import_rel_of(D, t))
|
||||
if len(files) > 0 and tlen(D, t) > 2 {
|
||||
if not first { buf_putc(o, ',') }
|
||||
first = false
|
||||
buf_puts(o, "{\"range\":")
|
||||
put_range(o, D, D.tk_start[t] + 1, D.tk_end[t] - 1)
|
||||
buf_puts(o, ",\"target\":")
|
||||
jstr_out(o, path_to_uri(files[0]))
|
||||
buf_puts(o, ",\"tooltip\":")
|
||||
jstr_out(o, files[0])
|
||||
buf_putc(o, '}')
|
||||
}
|
||||
}
|
||||
t += 1
|
||||
}
|
||||
buf_putc(o, ']')
|
||||
send_result(id, buf_str(o))
|
||||
}
|
||||
|
||||
# go-to-definition on an import string: the file (every file, for a glob)
|
||||
function import_definition(D: Doc, tok: int) -> pointer {
|
||||
let files = resolve_import_all(D.path, import_rel_of(D, tok))
|
||||
if len(files) == 0 { return null }
|
||||
let o = buf_new()
|
||||
buf_putc(o, '[')
|
||||
var i = 0
|
||||
while i < len(files) {
|
||||
let T = ensure_doc(files[i])
|
||||
if (T != null) {
|
||||
if o.len > 1 { buf_putc(o, ',') }
|
||||
put_location(o, T, 0, 0)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
buf_putc(o, ']')
|
||||
return buf_str(o)
|
||||
}
|
||||
226
tools/ludic-tools/lsp/sighelp.ludic
Normal file
226
tools/ludic-tools/lsp/sighelp.ludic
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
# lsp/sighelp.ludic — the call the cursor is inside: signature help with
|
||||
# per-parameter ranges (so the editor bolds the active one), and named-argument
|
||||
# completion (`Screen.fill_circle(x: …, radius: …)`).
|
||||
|
||||
# the call around off: sets g_call_* and returns true when there is one
|
||||
var g_call_callee: int = -1 # token of the function name
|
||||
var g_call_open: int = -1 # token of its `(`
|
||||
var g_call_arg: int = 0 # which argument (0-based) the cursor is in
|
||||
var g_call_named: pointer = "" # `name:` written at the start of that argument, if any
|
||||
var g_call_used: []pointer # names already given in earlier arguments
|
||||
|
||||
function find_call_at(D: Doc, off: int) -> bool {
|
||||
g_call_callee = -1; g_call_open = -1; g_call_arg = 0; g_call_named = ""
|
||||
g_call_used = new []pointer
|
||||
var depth = 0
|
||||
var i = ntok(D) - 1
|
||||
while i >= 0 and D.tk_start[i] >= off { i -= 1 }
|
||||
while i >= 0 {
|
||||
if D.tk_kind[i] == LT_OP and tlen(D, i) == 1 {
|
||||
let c = D.src[D.tk_start[i]]
|
||||
if c == ')' or c == ']' { depth += 1 }
|
||||
else if c == '[' { if depth == 0 { return false }; depth -= 1 }
|
||||
else if c == '{' or c == '}' { if depth == 0 { return false } }
|
||||
else if c == '(' {
|
||||
if depth == 0 {
|
||||
let callee = prev_sig(D, i)
|
||||
if callee < 0 or D.tk_kind[callee] != LT_ID { return false }
|
||||
g_call_callee = callee
|
||||
g_call_open = i
|
||||
call_args_before(D, i, off)
|
||||
return true
|
||||
}
|
||||
depth -= 1
|
||||
}
|
||||
}
|
||||
i -= 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
# count the arguments before off and collect `name:` labels
|
||||
function call_args_before(D: Doc, open: int, off: int) -> void {
|
||||
var depth = 0
|
||||
var arg_start = open + 1
|
||||
var k = open + 1
|
||||
while k < ntok(D) and D.tk_start[k] < off {
|
||||
if D.tk_kind[k] == LT_OP and tlen(D, k) == 1 {
|
||||
let c = D.src[D.tk_start[k]]
|
||||
if c == '(' or c == '[' or c == '{' { depth += 1 }
|
||||
else if c == ')' or c == ']' or c == '}' { depth -= 1 }
|
||||
else if c == ',' and depth == 0 {
|
||||
let lbl = arg_label(D, arg_start)
|
||||
if not (lbl == "") { push(g_call_used, lbl) }
|
||||
g_call_arg += 1
|
||||
arg_start = k + 1
|
||||
}
|
||||
}
|
||||
k += 1
|
||||
}
|
||||
let here = arg_label(D, arg_start)
|
||||
if not (here == "") and D.tk_end[arg_start] <= off { g_call_named = here }
|
||||
}
|
||||
|
||||
# `name:` at the start of an argument -> name, else ""
|
||||
function arg_label(D: Doc, t: int) -> pointer {
|
||||
var s = t
|
||||
while s < ntok(D) and (D.tk_kind[s] == LT_NL or D.tk_kind[s] == LT_COMMENT) { s += 1 }
|
||||
if s >= ntok(D) or D.tk_kind[s] != LT_ID { return "" }
|
||||
let c = next_sig(D, s)
|
||||
if c >= 0 and tis(D, c, ":") { return ttext(D, s) }
|
||||
return ""
|
||||
}
|
||||
|
||||
# the signature label and documentation for the call's callee
|
||||
var g_call_doc: pointer = ""
|
||||
function call_signature(D: Doc) -> pointer {
|
||||
g_call_doc = ""
|
||||
let name = ttext(D, g_call_callee)
|
||||
let dot = prev_sig(D, g_call_callee)
|
||||
if dot >= 0 and tis(D, dot, ".") {
|
||||
let nsi = prev_sig(D, dot)
|
||||
if nsi >= 0 and D.tk_kind[nsi] == LT_ID {
|
||||
let key = ttext(D, nsi) + "." + name
|
||||
let ds = docs_sig(key)
|
||||
if (ds != null) { g_call_doc = docs_tip(key); return ds }
|
||||
let ns = ns_method_sig(ttext(D, nsi), name)
|
||||
if (ns != null) { return ns }
|
||||
}
|
||||
return null
|
||||
}
|
||||
let t = find_top(D, name)
|
||||
if t >= 0 and (g_owner.syms[t].kind == LS_FN or g_owner.syms[t].kind == LS_EXTERN) {
|
||||
g_call_doc = g_owner.syms[t].doc
|
||||
return g_owner.syms[t].detail
|
||||
}
|
||||
let ds = docs_sig(name)
|
||||
if (ds != null) { g_call_doc = docs_tip(name); return ds }
|
||||
return builtin_sig(name)
|
||||
}
|
||||
|
||||
# [start, end) of each parameter inside the label's outermost ( )
|
||||
var g_ps: []int
|
||||
var g_pe: []int
|
||||
function split_params(label: pointer) -> void {
|
||||
g_ps = new []int; g_pe = new []int
|
||||
let n = cstr_len(label)
|
||||
var open = 0
|
||||
while open < n and label[open] != '(' { open += 1 }
|
||||
if open >= n { return }
|
||||
var depth = 0
|
||||
var s = open + 1
|
||||
var i = open + 1
|
||||
while i < n {
|
||||
let c = label[i]
|
||||
if c == '(' or c == '[' { depth += 1 }
|
||||
else if (c == ')' and depth == 0) or (c == ',' and depth == 0) {
|
||||
var a = s
|
||||
while a < i and label[a] == ' ' { a += 1 }
|
||||
var b = i
|
||||
while b > a and label[b - 1] == ' ' { b -= 1 }
|
||||
if b > a { push(g_ps, a); push(g_pe, b) }
|
||||
if c == ')' { return }
|
||||
s = i + 1
|
||||
}
|
||||
else if c == ')' or c == ']' { depth -= 1 }
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
# the parameter name in label[a..b): `radius`, `sk: Skin` -> sk
|
||||
function param_name(label: pointer, a: int, b: int) -> pointer {
|
||||
var e = a
|
||||
while e < b and label[e] != ':' and label[e] != ' ' and label[e] != '=' { e += 1 }
|
||||
return label[a..e]
|
||||
}
|
||||
|
||||
function param_type(label: pointer, a: int, b: int) -> pointer {
|
||||
var c = a
|
||||
while c < b and label[c] != ':' { c += 1 }
|
||||
if c >= b { return "" }
|
||||
c += 1
|
||||
while c < b and label[c] == ' ' { c += 1 }
|
||||
return label[c..b]
|
||||
}
|
||||
|
||||
function on_signature_help2(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "null"); return }
|
||||
let off = off_of(msg, D, "params.position")
|
||||
if not find_call_at(D, off) { send_result(id, "null"); return }
|
||||
let sig = call_signature(D)
|
||||
if (sig == null) { send_result(id, "null"); return }
|
||||
split_params(sig)
|
||||
var active = g_call_arg
|
||||
if not (g_call_named == "") {
|
||||
var p = 0
|
||||
while p < len(g_ps) { if (param_name(sig, g_ps[p], g_pe[p]) == g_call_named) { active = p }; p += 1 }
|
||||
}
|
||||
let o = buf_new()
|
||||
buf_puts(o, "{\"signatures\":[{\"label\":")
|
||||
jstr_out(o, sig)
|
||||
if (g_call_doc != null) and not (g_call_doc == "") {
|
||||
buf_puts(o, ",\"documentation\":{\"kind\":\"markdown\",\"value\":")
|
||||
jstr_out(o, g_call_doc)
|
||||
buf_putc(o, '}')
|
||||
}
|
||||
buf_puts(o, ",\"parameters\":[")
|
||||
var i = 0
|
||||
while i < len(g_ps) {
|
||||
if i > 0 { buf_putc(o, ',') }
|
||||
buf_puts(o, "{\"label\":[")
|
||||
buf_puti(o, utf16_len(sig, 0, g_ps[i]))
|
||||
buf_putc(o, ',')
|
||||
buf_puti(o, utf16_len(sig, 0, g_pe[i]))
|
||||
buf_puts(o, "]")
|
||||
let ty = param_type(sig, g_ps[i], g_pe[i])
|
||||
if not (ty == "") { buf_puts(o, ",\"documentation\":"); jstr_out(o, ty) }
|
||||
buf_putc(o, '}')
|
||||
i += 1
|
||||
}
|
||||
buf_puts(o, "]}],\"activeSignature\":0,\"activeParameter\":")
|
||||
buf_puti(o, active)
|
||||
buf_putc(o, '}')
|
||||
send_result(id, buf_str(o))
|
||||
}
|
||||
|
||||
# completion inside a call's argument list: `name: ` for every parameter not
|
||||
# given yet, ranked first. Returns true when it added anything.
|
||||
function comp_call_args(o: Buf, D: Doc, off: int) -> bool {
|
||||
if not find_call_at(D, off) { return false }
|
||||
if not (g_call_named == "") { return false }
|
||||
let sig = call_signature(D)
|
||||
if (sig == null) { return false }
|
||||
split_params(sig)
|
||||
var added = false
|
||||
var i = 0
|
||||
while i < len(g_ps) {
|
||||
let pn = param_name(sig, g_ps[i], g_pe[i])
|
||||
var used = false
|
||||
var u = 0
|
||||
while u < len(g_call_used) { if (g_call_used[u] == pn) { used = true }; u += 1 }
|
||||
if not used and cstr_len(pn) > 0 {
|
||||
comp_item_full(o, pn + ":", 5, param_type(sig, g_ps[i], g_pe[i]), "0" + itoa(i + 10), pn + ": ")
|
||||
added = true
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
# completion for `Namespace.`: its documented members
|
||||
function comp_ns_members(o: Buf, ns: pointer) -> bool {
|
||||
let idx = new []int
|
||||
docs_ns_members(ns, idx)
|
||||
var i = 0
|
||||
while i < len(idx) {
|
||||
let k = idx[i]
|
||||
let full = g_dx_token[k]
|
||||
let member = full[cstr_len(ns) + 1..cstr_len(full)]
|
||||
var sig = g_dx_sig[k]
|
||||
if (sig == "") { sig = g_dx_tip[k] }
|
||||
comp_item_full(o, member, 2, sig, "0" + member, "")
|
||||
i += 1
|
||||
}
|
||||
return len(idx) > 0
|
||||
}
|
||||
277
tools/ludic-tools/lsp/types.ludic
Normal file
277
tools/ludic-tools/lsp/types.ludic
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
# lsp/types.ludic — what type a name has, for hover and go-to-type.
|
||||
#
|
||||
# Declared types are taken as written (`var hp: int`, `sk: Skin`). An
|
||||
# undeclared one (`let i = skin_joint(sk, "hip")`) is inferred from its
|
||||
# initializer, shallowly: literals, `new T`, calls to functions and builtins
|
||||
# with a known return type, other names, fields, and comparisons. Anything
|
||||
# cleverer is the compiler's job; an unknown type is simply left out.
|
||||
|
||||
# the return type written after the last `->` of a signature label, or ""
|
||||
function sig_return(sig: pointer) -> pointer {
|
||||
if (sig == null) { return "" }
|
||||
let n = cstr_len(sig)
|
||||
var at = -1
|
||||
var i = 0
|
||||
while i + 1 < n {
|
||||
if sig[i] == '-' and sig[i + 1] == '>' { at = i }
|
||||
i += 1
|
||||
}
|
||||
if at < 0 { return "" }
|
||||
var s = at + 2
|
||||
while s < n and sig[s] == ' ' { s += 1 }
|
||||
var e = n
|
||||
while e > s and sig[e - 1] == ' ' { e -= 1 }
|
||||
return sig[s..e]
|
||||
}
|
||||
|
||||
function is_compare_op(D: Doc, t: int) -> bool {
|
||||
return tis(D, t, "==") or tis(D, t, "!=") or tis(D, t, "<") or tis(D, t, ">") or tis(D, t, "<=") or tis(D, t, ">=") or tis(D, t, "and") or tis(D, t, "or")
|
||||
}
|
||||
|
||||
# does the expression starting at t contain a top-level comparison before it ends?
|
||||
function expr_is_boolean(D: Doc, t: int) -> bool {
|
||||
var depth = 0
|
||||
var k = t
|
||||
let line = D.tk_line[t]
|
||||
while k >= 0 and k < ntok(D) {
|
||||
let kind = D.tk_kind[k]
|
||||
if kind == LT_NL or kind == LT_EOF or kind == LT_COMMENT { return false }
|
||||
if tis(D, k, "(") or tis(D, k, "[") { depth += 1 }
|
||||
else if tis(D, k, ")") or tis(D, k, "]") { if depth == 0 { return false }; depth -= 1 }
|
||||
else if tis(D, k, "{") or tis(D, k, "}") or tis(D, k, ";") or tis(D, k, ",") { if depth == 0 { return false } }
|
||||
else if depth == 0 and is_compare_op(D, k) { return true }
|
||||
if D.tk_line[k] != line { return false }
|
||||
k += 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
# the declared or inferred type of symbol y (which lives in doc O), or ""
|
||||
function sym_type(O: Doc, si: int, depth: int) -> pointer {
|
||||
let y = O.syms[si]
|
||||
if (y.ty != null) and not (y.ty == "") { return y.ty }
|
||||
if y.init >= 0 and depth < 8 { return infer_type(O, y.init, depth + 1) }
|
||||
return ""
|
||||
}
|
||||
|
||||
# the type of the expression whose first token is t, or ""
|
||||
function infer_type(D: Doc, t: int, depth: int) -> pointer {
|
||||
if t < 0 or t >= ntok(D) or depth > 8 { return "" }
|
||||
if expr_is_boolean(D, t) { return "bool" }
|
||||
let k = D.tk_kind[t]
|
||||
if k == LT_INT { return "int" }
|
||||
if k == LT_FLOAT { return "fixed" }
|
||||
if k == LT_STR { return "string" }
|
||||
if k == LT_CHAR { return "int" }
|
||||
if k == LT_BOOL { return "bool" }
|
||||
if tis(D, t, "not") { return "bool" }
|
||||
if tis(D, t, "(") { return infer_type(D, t + 1, depth + 1) }
|
||||
if tis(D, t, "-") { return infer_type(D, next_sig(D, t), depth + 1) }
|
||||
if tis(D, t, "new") {
|
||||
let n = next_sig(D, t)
|
||||
if n < 0 { return "" }
|
||||
if tis(D, n, "[") {
|
||||
let close = D.tmatch[n]
|
||||
if close >= 0 { let el = next_sig(D, close); if el >= 0 { return "[]" + ttext(D, el) } }
|
||||
return ""
|
||||
}
|
||||
return ttext(D, n)
|
||||
}
|
||||
if tis(D, t, "[") {
|
||||
let first = next_sig(D, t)
|
||||
if first >= 0 and not tis(D, first, "]") { let et = infer_type(D, first, depth + 1); if not (et == "") { return "[]" + et } }
|
||||
return ""
|
||||
}
|
||||
if k != LT_ID and k != LT_TYPE { return "" }
|
||||
let name = ttext(D, t)
|
||||
let nx = next_sig(D, t)
|
||||
# a call: its return type
|
||||
if nx >= 0 and tis(D, nx, "(") {
|
||||
if k == LT_TYPE { return name } # int(x), string(n)
|
||||
let top = find_top(D, name)
|
||||
if top >= 0 {
|
||||
let ow = g_owner
|
||||
if ow.syms[top].kind == LS_FN or ow.syms[top].kind == LS_EXTERN {
|
||||
let r = ow.syms[top].ty
|
||||
if (r == "") { return "void" }
|
||||
return r
|
||||
}
|
||||
}
|
||||
let r2 = sig_return(docs_sig(name))
|
||||
if not (r2 == "") { return r2 }
|
||||
return sig_return(builtin_sig(name))
|
||||
}
|
||||
# Name.member: a namespace call, or a field of a typed receiver
|
||||
if nx >= 0 and tis(D, nx, ".") {
|
||||
let m = next_sig(D, nx)
|
||||
if m < 0 { return "" }
|
||||
let mn = next_sig(D, m)
|
||||
if mn >= 0 and tis(D, mn, "(") {
|
||||
let r = sig_return(docs_sig(name + "." + ttext(D, m)))
|
||||
if not (r == "") { return r }
|
||||
return sig_return(ns_method_sig(name, ttext(D, m)))
|
||||
}
|
||||
let comp = receiver_component(D, nx)
|
||||
if comp >= 0 and (g_owner != null) {
|
||||
let co = g_owner
|
||||
var f = 0
|
||||
while f < len(co.syms) {
|
||||
if co.syms[f].kind == LS_FIELD and co.syms[f].parent == comp and (co.syms[f].name == ttext(D, m)) { return co.syms[f].ty }
|
||||
f += 1
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
# a record literal: `Pos { x: 1 }`
|
||||
if nx >= 0 and tis(D, nx, "{") and name[0] >= 'A' and name[0] <= 'Z' { return name }
|
||||
if k == LT_TYPE { return name }
|
||||
# another name
|
||||
let l = find_local(D, name, D.tk_start[t])
|
||||
if l >= 0 {
|
||||
if D.syms[l].init == t { return "" }
|
||||
return sym_type(D, l, depth + 1)
|
||||
}
|
||||
let top = find_top(D, name)
|
||||
if top >= 0 {
|
||||
let ow = g_owner
|
||||
let yk = ow.syms[top].kind
|
||||
if yk == LS_VAR or yk == LS_CONST { return sym_type(ow, top, depth + 1) }
|
||||
if yk == LS_FN or yk == LS_EXTERN { return ow.syms[top].detail }
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# the hover code line for a symbol: `var hp: int`, `param sk: Skin`, …
|
||||
function sym_hover_code(O: Doc, si: int) -> pointer {
|
||||
let y = O.syms[si]
|
||||
let k = y.kind
|
||||
if k == LS_QUERYVAR and not (y.detail == "") { return y.detail }
|
||||
if k == LS_LOCAL or k == LS_PARAM or k == LS_VAR or k == LS_CONST or k == LS_QUERYVAR {
|
||||
let ty = sym_type(O, si, 0)
|
||||
var head = "let "
|
||||
if k == LS_LOCAL and y.mutable == 1 { head = "var " }
|
||||
if k == LS_PARAM { head = "param " }
|
||||
if k == LS_VAR { head = "var " }
|
||||
if k == LS_CONST { head = "const " }
|
||||
if k == LS_QUERYVAR { head = "query " }
|
||||
if (ty == "") { return head + y.name }
|
||||
return head + y.name + ": " + ty
|
||||
}
|
||||
if k == LS_COMPONENT or k == LS_ARCHETYPE { return record_hover_code(O, si) }
|
||||
if (y.detail == "") { return y.name }
|
||||
return y.detail
|
||||
}
|
||||
|
||||
# `property Actor {` with one field per line, the way it is written
|
||||
function record_hover_code(O: Doc, si: int) -> pointer {
|
||||
let y = O.syms[si]
|
||||
let b = buf_new()
|
||||
if y.kind == LS_COMPONENT { buf_puts(b, "property ") } else { buf_puts(b, "model ") }
|
||||
buf_puts(b, y.name)
|
||||
buf_puts(b, " {")
|
||||
var n = 0
|
||||
var f = 0
|
||||
while f < len(O.syms) {
|
||||
let fy = O.syms[f]
|
||||
if fy.kind == LS_FIELD and fy.parent == si {
|
||||
buf_puts(b, "\n ")
|
||||
buf_puts(b, fy.name)
|
||||
if not (fy.ty == "") { buf_puts(b, ": "); buf_puts(b, fy.ty) }
|
||||
n += 1
|
||||
}
|
||||
f += 1
|
||||
}
|
||||
if n == 0 { buf_puts(b, " }") } else { buf_puts(b, "\n}") }
|
||||
return buf_str(b)
|
||||
}
|
||||
|
||||
# the declaration of a symbol's type: a model/property in the workspace, else
|
||||
# the reference page of a built-in type. Location JSON or null.
|
||||
function type_definition_of(O: Doc, si: int, from: Doc) -> pointer {
|
||||
var ty = sym_type(O, si, 0)
|
||||
if (ty == "") { return null }
|
||||
while starts_with(ty, "[]") { ty = ty[2..cstr_len(ty)] }
|
||||
let top = find_top(from, ty)
|
||||
if top >= 0 {
|
||||
let ow = g_owner
|
||||
let o = buf_new()
|
||||
put_location(o, ow, ow.syms[top].start, ow.syms[top].end)
|
||||
return buf_str(o)
|
||||
}
|
||||
let i = docs_index(ty)
|
||||
if i < 0 { return null }
|
||||
let o = buf_new()
|
||||
buf_puts(o, "{\"uri\":")
|
||||
jstr_out(o, path_to_uri(g_dx_file[i]))
|
||||
buf_puts(o, ",\"range\":{\"start\":{\"line\":0,\"character\":0},\"end\":{\"line\":0,\"character\":0}}}")
|
||||
return buf_str(o)
|
||||
}
|
||||
|
||||
function on_type_definition(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "null"); return }
|
||||
let off = off_of(msg, D, "params.position")
|
||||
resolve_at(D, off)
|
||||
if (g_owner == null) or g_symidx < 0 { on_definition(msg, id); return }
|
||||
let r = type_definition_of(g_owner, g_symidx, D)
|
||||
if (r == null) { on_definition(msg, id); return }
|
||||
send_result(id, r)
|
||||
}
|
||||
|
||||
# ---- contextual keywords ------------------------------------------------------
|
||||
# The compiler reserves its declaration words only as function names
|
||||
# (is_reserved_word in selfhost/frontend/parse.ludic): `var model = …`,
|
||||
# `model: Model` and `a.model` are all ordinary names. A word is a keyword only
|
||||
# where it could start or continue a declaration.
|
||||
|
||||
function is_contextual_word(w: pointer) -> bool {
|
||||
if (w == "program") or (w == "import") or (w == "property") or (w == "model") or (w == "enum") or (w == "ui") { return true }
|
||||
if (w == "function") or (w == "extern") or (w == "handler") or (w == "entry") or (w == "event") or (w == "scene") { return true }
|
||||
if (w == "phase") or (w == "query") or (w == "on") or (w == "cancellable") or (w == "public") or (w == "layer") or (w == "start") { return true }
|
||||
if (w == "state") or (w == "machine") or (w == "prefab") or (w == "namespace") or (w == "test") { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
function demote_contextual_keywords(D: Doc) -> void {
|
||||
let N = ntok(D)
|
||||
var i = 0
|
||||
while i < N {
|
||||
if D.tk_kind[i] == LT_KW and is_contextual_word(ttext(D, i)) and used_as_name(D, i) { D.tk_kind[i] = LT_ID }
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
function used_as_name(D: Doc, i: int) -> bool {
|
||||
let p = prev_sig(D, i)
|
||||
if p >= 0 {
|
||||
if tis(D, p, ".") or tis(D, p, "var") or tis(D, p, "let") or tis(D, p, "const") or tis(D, p, "return") { return true }
|
||||
if D.tk_kind[p] == LT_OP and not (tis(D, p, "{") or tis(D, p, "}") or tis(D, p, ";") or tis(D, p, ")") or tis(D, p, "]")) {
|
||||
# `import` after `(` never happens; `query` after `in` is not an op
|
||||
return true
|
||||
}
|
||||
}
|
||||
let n = next_sig(D, i)
|
||||
if n >= 0 and D.tk_kind[n] == LT_OP {
|
||||
if tis(D, n, ":") or tis(D, n, ".") or tis(D, n, "=") or tis(D, n, ")") or tis(D, n, ",") or tis(D, n, "]") { return true }
|
||||
if tis(D, n, "+=") or tis(D, n, "-=") or tis(D, n, "==") or tis(D, n, "!=") { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
# is this a documented standard-library namespace (Screen, Color, Math, …)?
|
||||
function is_namespace_name(w: pointer) -> bool {
|
||||
if cstr_len(w) == 0 or w[0] < 'A' or w[0] > 'Z' { return false }
|
||||
docs_load()
|
||||
if docs_ns_index(w) >= 0 { return true }
|
||||
return ns_method_sig(w, "") != null
|
||||
}
|
||||
|
||||
# SEM_VENDOR when a symbol lives in a package (the toolchain's or ludic_modules/)
|
||||
function vendor_mod(O: Doc) -> int {
|
||||
if (O == null) { return 0 }
|
||||
if Text.contains(O.path, "/ludic_modules/") { return SEM_VENDOR }
|
||||
let home = lsp_home()
|
||||
if not (home == "") and starts_with(O.path, home + "packages/") { return SEM_VENDOR }
|
||||
if not (home == "") and starts_with(O.path, home + "runtime/") { return SEM_DEFAULT_LIBRARY }
|
||||
return 0
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue