Baseline: Ludic compiler + toolchain, Phase 1 syntax fixes complete

Self-hosted compiler (selfhost/*.ludic), runtime, examples, editor tooling,
and docs. Phase 1 of the syntax-redesign cohesion pass has landed:
edge-system fix, signature-query, when-alias, and the documentation truth-pass.
Suite green (14/14), C-free bootstrap fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-27 15:15:35 +03:00
commit 985f9ad8f2
418 changed files with 39065 additions and 0 deletions

148
tools/editors/README.md Normal file
View file

@ -0,0 +1,148 @@
# Ludic in your editor
Everything here is built on one idea: **write the language knowledge once, in C,
and let every editor talk to it.** There is one lexer, one vocabulary, one
formatter and one language server. VS Code and JetBrains get first-class plugins
because those are the two that were asked for; every other editor gets the same
capabilities by pointing at the same binary.
```
tools/ludic-tools/ the actual language knowledge, in C, no dependencies
ludic_syntax.h lexer + the vocabulary — the source of truth
ludic_fmt.h comment-preserving formatter
ludic_index.h error-tolerant reader: declarations, bindings, scopes
ludic_workspace.h multi-file model: imports, compilation units, Markdown
ludic_json.h just enough JSON for LSP
ludic_fmt_main.c -> build/ludic-fmt
ludic_lsp.c -> build/ludic-lsp
tools/editors/
shared/ TextMate grammar + Markdown injection + language config
vscode/ VS Code extension
jetbrains/ IntelliJ Platform plugin (Community editions included)
neovim/ helix/ emacs/ sublime/ zed/ configuration, no plugin needed
```
## Build it
```bash
./tools/build-tools.sh
```
Produces `build/ludic-fmt` and `build/ludic-lsp`. Add `--install` to symlink both
into `~/.local/bin`, `--test` to run `tools/test-tools.sh` afterwards.
## What you get, in any editor
| Feature | How |
|---|---|
| Syntax highlighting | LSP semantic tokens, or the TextMate grammar with no server at all |
| Diagnostics | structural errors as you type; `ludicc`'s own errors on save |
| Completion | context-aware — see below |
| Hover | signature + docs for builtins, intrinsics, and everything you declared |
| Go to definition, find usages, rename | across the whole compilation unit, following `import` — by resolving each occurrence, not by matching text, so renaming a component's `x` leaves every other `x` alone |
| Formatting | whole-file, comment-preserving, idempotent |
| Outline, folding, inlay hints, signature help, document links | |
| ` ```ludic ` in Markdown | highlighted, checked and formatted |
Completion knows where the caret is: fields after `.`, components inside
`query [...]` and after `spawn`, phase names after `phase`, scene names after
`enter`, widget types and props inside a `ui` block, types after `:` and `->`,
and otherwise keywords, builtins, and everything in scope.
## Per-editor setup
- **VS Code** — [`vscode/`](vscode/). `npm install && npx @vscode/vsce package`, then
install the `.vsix`. Finds the binaries under `build/` on its own.
- **JetBrains** — [`jetbrains/`](jetbrains/). `./gradlew buildPlugin`, then install the
zip from disk. Uses LSP4IJ rather than the paid-IDE LSP API, so it works in
Community editions too.
- **Neovim** — `require('ludic').setup()` from [`neovim/`](neovim/lua/ludic.lua).
- **Helix** — merge [`helix/languages.toml`](helix/languages.toml) into your config.
- **Emacs** — [`emacs/ludic-mode.el`](emacs/ludic-mode.el); eglot is wired up in one line.
- **Sublime** — [`sublime/README.md`](sublime/README.md); the TextMate grammar loads as-is.
- **Zed** — [`zed/README.md`](zed/README.md); LSP-only, no grammar to build.
- **Anything else** that speaks LSP:
```
command: build/ludic-lsp --stdio
languages: ludic, markdown
initializationOptions: { "compilerPath": "build/ludicc", "compilerDiagnostics": true, "indentSize": 2 }
```
## Markdown
Markdown fenced-code highlighting is not one standard — it is three mechanisms,
and this ships all three so that a ` ```ludic ` block works wherever you write
prose:
1. **TextMate injection** (`shared/ludic.markdown-injection.json`) — what VS Code,
Sublime and other TextMate-based editors use. It is a plain grammar file with
an `injectionSelector`, so it is portable rather than VS Code-specific.
2. **Language-ID resolution** — JetBrains' Markdown support matches a fence's
info string against registered language IDs, so the plugin registering as
`Ludic` is all that is needed; no Markdown-specific code exists in the plugin.
3. **The language server** — `ludic-lsp` accepts Markdown documents directly. It
copies the buffer, blanks every byte outside a ` ```ludic ` fence, and
analyses the result; offsets still line up with the real file, so semantic
highlighting, hover, completion and go-to-definition all work inside fences
with no editor-side support at all. This is the fallback that works even
where the other two do not.
It reports **no diagnostics** in Markdown, deliberately. A fence is a
snippet — an elision mark, or a body shown without its enclosing `game`
block — so nearly every error found there would be the documentation doing
its job. `ludic-fmt --check` still keeps the fences formatted, which is the
part of "is this doc correct" that can be answered without guessing.
The formatter handles Markdown too, which keeps documentation honest:
```bash
build/ludic-fmt --check LANGUAGE.md README.md # CI: fail if a fence is unformatted
build/ludic-fmt -w LANGUAGE.md # reformat the fences, leave the prose
```
## Keeping a tree formatted
```bash
ln -sf ../../tools/git-hooks/pre-commit .git/hooks/pre-commit
```
The hook runs `ludic-fmt --check` over the staged `.ludic` and `.md` files only,
and does nothing at all when `build/ludic-fmt` has not been built — so it never
blocks a commit on a machine that has not run `build-tools.sh`.
## Why the formatter is not `ludicc --fmt`
The compiler has a canonical printer, and it is the right tool for seeing what
the compiler parsed. It is the wrong tool for an editor: it walks the AST *after*
import splicing, so it drops every comment and inlines every imported file into
whichever file you pointed it at. Running it as format-on-save on
`chronorift/combat.ludic` would replace that file with the whole game.
`ludic-fmt` works on the token stream instead. Nothing is dropped or reordered —
every token is re-emitted in order, and the only freedom taken is the whitespace
between them. Lines are re-indented and respaced but never joined or split, so
you keep control of line structure.
Two carve-outs keep the output idiomatic rather than merely uniform, because
both spellings are what the language documents and uses:
- runs of two or more spaces are preserved, so hand-aligned columns
(`const R_DIR: int = 0 # 0 up`) survive a save;
- `id=Root` inside a `ui` block and `{Enemy}` inside a query stay tight.
`tools/test-tools.sh` checks the property that matters: formatting every file in
the tree and re-running the *compiler's* canonical dump produces byte-identical
output. The formatter cannot change what a program means.
## Keeping it honest
The vocabulary is written down in five places that cannot include each other —
the compiler's two tables, `ludic_syntax.h`, the TextMate grammar (JSON), and the
JetBrains lexer (Kotlin). Adding a builtin and forgetting the rest is silent
failure, so `tools/check-vocabulary.py` compares all five and
`tools/test-tools.sh` runs it.
When you add a keyword or builtin: put it in `ludic_syntax.h`, then run
`./tools/test-tools.sh` and let it tell you which copies still need it.

View file

@ -0,0 +1,70 @@
;;; ludic-mode.el --- Major mode for the Ludic game language -*- lexical-binding: t; -*-
;; Highlighting here is a deliberate minimum: enough for a file to be readable
;; with nothing installed. Everything real -- completion, diagnostics, hover,
;; formatting -- comes from ludic-lsp via eglot (built into Emacs 29) or lsp-mode.
;;
;; Usage:
;; (add-to-list 'load-path "/path/to/gpp/tools/editors/emacs")
;; (require 'ludic-mode)
;;; Code:
(defconst ludic--declaration-keywords
'("game" "module" "import" "component" "archetype" "ui" "scene" "layer"
"const" "var" "export" "fn" "pure" "extern" "system" "edge" "start" "on"))
(defconst ludic--clause-keywords
'("phase" "query" "reads" "writes" "needs" "uses"
"requires" "ensures" "invariant" "effects"))
(defconst ludic--statement-keywords
'("let" "return" "if" "else" "when" "while" "for" "in" "spawn" "despawn"
"match" "machine" "state" "become" "enter" "where" "and" "or"))
(defconst ludic--types '("int" "fixed" "bool" "entity" "str" "ptr" "void"))
(defconst ludic--phases '("Start" "Input" "FixedUpdate" "Update" "LateUpdate" "Render"))
(defconst ludic-font-lock-keywords
(list
(cons (regexp-opt ludic--declaration-keywords 'symbols) 'font-lock-keyword-face)
(cons (regexp-opt ludic--clause-keywords 'symbols) 'font-lock-preprocessor-face)
(cons (regexp-opt ludic--statement-keywords 'symbols) 'font-lock-keyword-face)
(cons (regexp-opt ludic--types 'symbols) 'font-lock-type-face)
(cons (regexp-opt ludic--phases 'symbols) 'font-lock-builtin-face)
(cons "\\_<\\(true\\|false\\)\\_>" 'font-lock-constant-face)
(cons "@[A-Za-z_][A-Za-z0-9_]*" 'font-lock-preprocessor-face)
;; components, archetypes, scenes and UI handles are Capitalised by convention
(cons "\\_<[A-Z][A-Za-z0-9_]*\\_>" 'font-lock-type-face)
(cons "\\_<\\([a-z_][A-Za-z0-9_]*\\)\\s-*(" '(1 font-lock-function-name-face))))
(defvar ludic-mode-syntax-table
(let ((table (make-syntax-table)))
(modify-syntax-entry ?# "<" table)
(modify-syntax-entry ?\n ">" table)
(modify-syntax-entry ?_ "_" table)
(modify-syntax-entry ?\" "\"" table)
table)
"Syntax table for `ludic-mode'.")
;;;###autoload
(define-derived-mode ludic-mode prog-mode "Ludic"
"Major mode for editing Ludic source."
:syntax-table ludic-mode-syntax-table
(setq-local font-lock-defaults '(ludic-font-lock-keywords))
(setq-local comment-start "# ")
(setq-local comment-end "")
(setq-local indent-tabs-mode nil)
(setq-local tab-width 2)
(setq-local standard-indent 2))
;;;###autoload
(add-to-list 'auto-mode-alist '("\\.ludic\\'" . ludic-mode))
;; eglot: one line, and the server does the rest. Markdown is registered too so
;; that ```ludic fences are analysed in prose files.
(with-eval-after-load 'eglot
(add-to-list 'eglot-server-programs '(ludic-mode . ("ludic-lsp" "--stdio"))))
(provide 'ludic-mode)
;;; ludic-mode.el ends here

View file

@ -0,0 +1,24 @@
# Ludic for Helix. Append to ~/.config/helix/languages.toml, adjusting the two
# paths. Highlighting comes from the server's semantic tokens; Helix applies
# them with no grammar to build.
[[language]]
name = "ludic"
scope = "source.ludic"
file-types = ["ludic"]
comment-token = "#"
indent = { tab-width = 2, unit = " " }
language-servers = ["ludic-lsp"]
auto-format = true
roots = ["build.sh", "LANGUAGE.md", ".git"]
[language-server.ludic-lsp]
command = "ludic-lsp"
args = ["--stdio"]
# Ludic fences inside Markdown: point Helix's markdown at the same server so
# `# ```ludic` blocks get diagnostics too. Merge this with your existing
# markdown entry rather than adding a second one.
# [[language]]
# name = "markdown"
# language-servers = ["marksman", "ludic-lsp"]

View file

@ -0,0 +1,58 @@
# Ludic for JetBrains IDEs
Works in IntelliJ IDEA (Community and Ultimate), CLion, GoLand, PyCharm, Rider,
WebStorm — anything on the IntelliJ Platform 2023.2 through 2026.2
(`since-build` 232, `until-build` 262.*).
`until-build` 262 is deliberately ahead of the newest released platform — 2025.3
is build **253**, and no 262 IDE exists yet. That headroom is the point: an
`until-build` pinned to today's IDE gets the plugin auto-disabled the moment a
user upgrades.
Two things are machine-checked rather than assumed, because a plugin that
packages cleanly can still fail to load:
- `./gradlew verifyPlugin` runs JetBrains' own Plugin Verifier over the built
artifact against 2025.3 and reports **Compatible**, with no deprecated or
scheduled-for-removal API usages.
- `./gradlew runIde` boots a real IDEA 2025.3 with the plugin installed. The
sandbox log line to look for is
`Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.0.0)` in
`.intellijPlatform/sandbox/Ludic/IC-2025.3/log/idea.log`.
**If the plugin does not appear in Settings -> Plugins after installing**, the
cause is almost always that the IDE's build number is outside
`since-build`..`until-build`. IntelliJ installs the declared dependency (LSP4IJ)
from Marketplace first and only then rejects the incompatible plugin, so the
symptom is "LSP4IJ got installed, mine did not". Check **Help -> About** for the
build number and widen `pluginUntilBuild` in `gradle.properties` to match.
## Building
Gradle must run on **JDK 17–21**. A newer JDK fails with a bare version number
as the error message, which is not obvious the first time you see it:
```bash
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
cd tools/editors/jetbrains
./gradlew buildPlugin # -> build/distributions/Ludic-1.0.0.zip
./gradlew runIde # try it in a sandbox IDE
```
The first build downloads an IntelliJ IDEA Community distribution (over 1 GB),
so expect it to take a while; later builds take seconds. Once the cache is warm,
```bash
LUDIC_TEST_JETBRAINS=1 ./tools/test-tools.sh
```
includes the plugin build in the toolchain's own test run.
Install the zip with **Settings -> Plugins -> ⚙ -> Install Plugin from Disk**.
LSP4IJ is a required dependency; the IDE offers to install it for you.
## Settings
**Settings -> Languages & Frameworks -> Ludic** — the path to `ludic-lsp` and to
`ludicc`. Both default to `build/` under the project root, which is where
`tools/build-tools.sh` puts them.

View file

@ -0,0 +1,96 @@
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
plugins {
id("java")
id("org.jetbrains.kotlin.jvm") version "2.4.10"
// The IntelliJ Platform Gradle Plugin 2.x. The 1.x line (org.jetbrains.intellij)
// is superseded and does not run on current Gradle.
id("org.jetbrains.intellij.platform") version "2.18.1"
}
group = providers.gradleProperty("pluginGroup").get()
version = providers.gradleProperty("pluginVersion").get()
repositories {
mavenCentral()
intellijPlatform {
defaultRepositories()
}
}
dependencies {
testImplementation("junit:junit:4.13.2")
intellijPlatform {
// Community, deliberately: the plugin must work in the free IDEs, which
// is also why the language server is wired up through LSP4IJ rather
// than com.intellij.platform.lsp (Ultimate and friends only).
// useInstaller = false takes the Maven artifact (com.jetbrains.intellij.idea:ideaIC)
// instead of the .dmg/.tar.gz installer. Since 2025.x the Community installer is
// published as `idea-<version>`, not `ideaIC-<version>`, and the installer path
// cannot find it; the Maven coordinate is unaffected by that rename.
intellijIdeaCommunity(providers.gradleProperty("platformVersion").get()) {
useInstaller = false
}
bundledPlugin("org.intellij.plugins.markdown")
plugin("com.redhat.devtools.lsp4ij", providers.gradleProperty("lsp4ijVersion").get())
// JetBrains' Plugin Verifier CLI, used by the verifyPlugin task below
pluginVerifier()
// headless fixtures for the platform tests in src/test
testFramework(TestFrameworkType.Platform)
}
}
kotlin {
jvmToolchain(17)
}
intellijPlatform {
pluginConfiguration {
name = providers.gradleProperty("pluginName")
version = providers.gradleProperty("pluginVersion")
ideaVersion {
sinceBuild = providers.gradleProperty("pluginSinceBuild")
untilBuild = providers.gradleProperty("pluginUntilBuild")
}
}
// Compatibility is claimed up to pluginUntilBuild, so it gets checked
// against that IDE rather than assumed: `./gradlew verifyPlugin` runs
// JetBrains' own Plugin Verifier over the built artifact.
pluginVerification {
ides {
// The platform this is compiled against, which is also the newest
// that exists (2025.3 = build 253). until-build claims 262, which
// no released IDE reaches yet.
create(IntelliJPlatformType.IntellijIdeaCommunity, providers.gradleProperty("platformVersion").get()) {
useInstaller = false
}
}
}
buildSearchableOptions = false
// The platform's bytecode instrumenter only adds @NotNull assertions and
// compiles .form files. This plugin is Kotlin with no GUI forms, so it has
// nothing to instrument — and skipping it avoids the instrumenter's JBR
// probing, which fails on a plain JDK layout.
instrumentCode = false
}
// `./gradlew runIde` opens the Ludic repository itself, so the sandbox has real
// .ludic files to look at and build/ludic-lsp sitting where the plugin expects.
// Three levels up: jetbrains -> editors -> tools -> repository root. Getting
// this wrong points the IDE at tools/, where no build/ludic-lsp exists, and the
// language server then cannot find itself.
val repoRoot: File = rootProject.projectDir.parentFile.parentFile.parentFile
tasks.named<JavaExec>("runIde") {
args = listOf(repoRoot.absolutePath)
}
tasks.test {
useJUnit()
testLogging {
events("passed", "failed", "skipped")
showStandardStreams = false
}
}

View file

@ -0,0 +1,26 @@
pluginGroup = io.ludic.ide
pluginName = Ludic
pluginVersion = 1.0.0
# 2023.2 is the first build with the platform APIs this plugin uses. LSP4IJ (not
# the paid-IDE LSP API) supplies the language-server bridge, so Community
# editions are supported as well as Ultimate.
pluginSinceBuild = 232
# Deliberately ahead of the newest released platform (2025.3 is build 253): an
# until-build that stops at today's IDE gets the plugin auto-disabled the moment
# a user upgrades. 262 buys roughly three release cycles of headroom. The APIs
# this plugin uses are the stable ones (LexerBase, SyntaxHighlighter,
# ParserDefinition, FoldingBuilder), and everything project-wide is delegated to
# ludic-lsp over a versioned protocol rather than to platform internals.
pluginUntilBuild = 262.*
platformVersion = 2025.3
lsp4ijVersion = 0.20.1
# Gradle itself must run on JDK 17-21; a newer JDK reports only its version
# number as the failure. The Kotlin toolchain below is pinned separately.
# The wrapper is Gradle 9.x because IntelliJ Platform Gradle Plugin 2.13+
# requires it.
org.gradle.jvmargs = -Xmx2g
org.gradle.caching = true
kotlin.stdlib.default.dependency = false

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
tools/editors/jetbrains/gradlew vendored Executable file
View file

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
tools/editors/jetbrains/gradlew.bat vendored Normal file
View file

@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,2 @@
// The distribution is named after this, so it is the plugin name, not a slug.
rootProject.name = "Ludic"

View file

@ -0,0 +1,83 @@
package io.ludic.ide
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.TextFieldWithBrowseButton
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.FormBuilder
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JSpinner
import javax.swing.SpinnerNumberModel
/** Settings -> Languages and Frameworks -> Ludic */
class LudicConfigurable(private val project: Project) : Configurable {
private val serverField = TextFieldWithBrowseButton()
private val compilerField = TextFieldWithBrowseButton()
private val diagnostics = JBCheckBox("Report compiler errors on save")
private val indent = JSpinner(SpinnerNumberModel(2, 1, 8, 1))
private var panel: JPanel? = null
override fun getDisplayName() = "Ludic"
override fun createComponent(): JComponent {
// The title travels on the descriptor now; the four-argument overload
// that took it separately is scheduled for removal.
serverField.addBrowseFolderListener(
project,
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludic-lsp")
)
compilerField.addBrowseFolderListener(
project,
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludicc")
)
panel = FormBuilder.createFormBuilder()
.addLabeledComponent(JBLabel("Language server (ludic-lsp):"), serverField, 1, false)
.addComponentToRightColumn(
JBLabel("Leave empty to use build/ludic-lsp under the project, then PATH.")
)
.addLabeledComponent(JBLabel("Compiler (ludicc):"), compilerField, 1, false)
.addComponentToRightColumn(
JBLabel("Used for authoritative diagnostics. Empty means build/ludicc.")
)
.addComponent(diagnostics)
.addLabeledComponent(JBLabel("Formatter indent:"), indent, 1, false)
.addComponentFillVertically(JPanel(), 0)
.panel
reset()
return panel!!
}
private fun settings() = LudicSettings.of(project)
override fun isModified(): Boolean {
val s = settings()
return serverField.text != s.serverPath ||
compilerField.text != s.compilerPath ||
diagnostics.isSelected != s.compilerDiagnostics ||
(indent.value as Int) != s.indentSize
}
override fun apply() {
val s = settings()
s.serverPath = serverField.text.trim()
s.compilerPath = compilerField.text.trim()
s.compilerDiagnostics = diagnostics.isSelected
s.indentSize = indent.value as Int
}
override fun reset() {
val s = settings()
serverField.text = s.serverPath
compilerField.text = s.compilerPath
diagnostics.isSelected = s.compilerDiagnostics
indent.value = s.indentSize
}
override fun disposeUIResources() {
panel = null
}
}

View file

@ -0,0 +1,85 @@
package io.ludic.ide
import com.intellij.lang.BracePair
import com.intellij.lang.Commenter
import com.intellij.lang.PairedBraceMatcher
import com.intellij.lang.folding.FoldingBuilderEx
import com.intellij.lang.folding.FoldingDescriptor
import com.intellij.openapi.editor.Document
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.tree.IElementType
/**
* The small editor mechanics the platform expects a language to provide: how to
* comment a line, which delimiters pair up, and what can be folded. None of
* this needs the language server, so none of it waits for one.
*/
class LudicCommenter : Commenter {
override fun getLineCommentPrefix() = "# "
override fun getBlockCommentPrefix(): String? = null
override fun getBlockCommentSuffix(): String? = null
override fun getCommentedBlockCommentPrefix(): String? = null
override fun getCommentedBlockCommentSuffix(): String? = null
}
class LudicBraceMatcher : PairedBraceMatcher {
override fun getPairs(): Array<BracePair> = PAIRS
override fun isPairedBracesAllowedBeforeType(lbraceType: IElementType, next: IElementType?): Boolean = true
override fun getCodeConstructStart(file: PsiFile?, openingBraceOffset: Int): Int = openingBraceOffset
private companion object {
// Structural braces only: a record literal shares the token type, so
// marking them all structural would make the "matched brace" gutter
// noisy inside `spawn Hero { Pos = { x = 1 } }`.
val PAIRS = arrayOf(
BracePair(LudicTokens.BRACE, LudicTokens.BRACE, true),
BracePair(LudicTokens.BRACKET, LudicTokens.BRACKET, false),
BracePair(LudicTokens.PAREN, LudicTokens.PAREN, false)
)
}
}
/**
* Folds every braced block. Working off the lexer's token stream rather than a
* PSI tree keeps this correct for a flat PSI, and it degrades gracefully on a
* file with unbalanced braces, which is most files while they are being typed.
*/
class LudicFoldingBuilder : FoldingBuilderEx(), DumbAware {
override fun buildFoldRegions(root: PsiElement, document: Document, quick: Boolean): Array<FoldingDescriptor> {
val text = root.containingFile?.text ?: return emptyArray()
val lexer = LudicLexer()
lexer.start(text, 0, text.length, 0)
val opens = ArrayDeque<Int>()
val regions = mutableListOf<FoldingDescriptor>()
val node = root.node ?: return emptyArray()
while (lexer.tokenType != null) {
if (lexer.tokenType == LudicTokens.BRACE) {
when (text[lexer.tokenStart]) {
'{' -> opens.addLast(lexer.tokenStart)
'}' -> {
val open = opens.removeLastOrNull()
if (open != null) {
val end = lexer.tokenEnd
// only worth folding when it actually spans lines
if (document.getLineNumber(end) > document.getLineNumber(open)) {
regions += FoldingDescriptor(node, TextRange(open, end))
}
}
}
}
}
lexer.advance()
}
return regions.toTypedArray()
}
override fun getPlaceholderText(node: com.intellij.lang.ASTNode) = "{...}"
override fun isCollapsedByDefault(node: com.intellij.lang.ASTNode) = false
}

View file

@ -0,0 +1,32 @@
package io.ludic.ide
import com.intellij.lang.Language
import com.intellij.openapi.fileTypes.LanguageFileType
import com.intellij.openapi.util.IconLoader
import com.intellij.openapi.vfs.VirtualFile
import javax.swing.Icon
/**
* The language ID matters more than it looks: the IDE's Markdown support
* resolves a fenced block's info string ("```ludic") against registered
* language IDs, so registering as "Ludic" is what makes fences in .md files
* highlight, with no Markdown-specific code on our side.
*/
object LudicLanguage : Language("Ludic") {
private fun readResolve(): Any = LudicLanguage
override fun getDisplayName() = "Ludic"
override fun isCaseSensitive() = true
}
object LudicIcons {
val FILE: Icon = IconLoader.getIcon("/icons/ludic.svg", LudicIcons::class.java)
}
object LudicFileType : LanguageFileType(LudicLanguage) {
private fun readResolve(): Any = LudicFileType
override fun getName() = "Ludic"
override fun getDescription() = "Ludic source"
override fun getDefaultExtension() = "ludic"
override fun getIcon(): Icon = LudicIcons.FILE
override fun getCharset(file: VirtualFile, content: ByteArray): String = "UTF-8"
}

View file

@ -0,0 +1,148 @@
package io.ludic.ide
import com.intellij.lexer.LexerBase
import com.intellij.psi.TokenType
import com.intellij.psi.tree.IElementType
/**
* A hand-written lexer matching tools/ludic-tools/ludic_syntax.h token for
* token. Hand-written rather than generated because the grammar is small, the
* rules are the ones the C lexer already implements, and keeping the two
* readable side by side is worth more here than a JFlex spec.
*
* It must never throw and never fail to advance: the IDE lexes half-typed text
* constantly, so anything unrecognised becomes one BAD character and the scan
* carries on.
*/
class LudicLexer : LexerBase() {
private var buffer: CharSequence = ""
private var startOffset = 0
private var endOffset = 0
private var tokenStart = 0
private var tokenEnd = 0
private var tokenType: IElementType? = null
override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) {
this.buffer = buffer
this.startOffset = startOffset
this.endOffset = endOffset
this.tokenStart = startOffset
this.tokenEnd = startOffset
advance()
}
override fun getState() = 0
override fun getTokenType(): IElementType? = tokenType
override fun getTokenStart() = tokenStart
override fun getTokenEnd() = tokenEnd
override fun getBufferSequence() = buffer
override fun getBufferEnd() = endOffset
private fun at(i: Int): Char = if (i in startOffset until endOffset) buffer[i] else ' '
private fun isIdStart(c: Char) = c.isLetter() || c == '_'
private fun isIdPart(c: Char) = c.isLetterOrDigit() || c == '_'
private fun isSpace(c: Char) = c == ' ' || c == '\t' || c == '\r' || c == '\n'
override fun advance() {
tokenStart = tokenEnd
if (tokenStart >= endOffset) {
tokenType = null
return
}
var i = tokenStart
val c = at(i)
if (isSpace(c)) {
while (i < endOffset && isSpace(at(i))) i++
emit(i, TokenType.WHITE_SPACE)
return
}
if (c == '#') {
while (i < endOffset && at(i) != '\n') i++
emit(i, LudicTokens.COMMENT)
return
}
if (c == '"') {
i++
while (i < endOffset && at(i) != '"' && at(i) != '\n') {
if (at(i) == '\\' && i + 1 < endOffset) i += 2 else i++
}
if (i < endOffset && at(i) == '"') i++
emit(i, LudicTokens.STRING)
return
}
if (c == '\'') {
i++
if (at(i) == '\\' && i + 1 < endOffset) i += 2 else if (i < endOffset && at(i) != '\n') i++
if (i < endOffset && at(i) == '\'') i++
emit(i, LudicTokens.CHAR)
return
}
if (c.isDigit()) {
if (c == '0' && (at(i + 1) == 'x' || at(i + 1) == 'X')) {
i += 2
while (i < endOffset && (at(i).isDigit() || at(i) in 'a'..'f' || at(i) in 'A'..'F')) i++
} else {
while (i < endOffset && at(i).isDigit()) i++
if (at(i) == '.' && at(i + 1).isDigit()) {
i++
while (i < endOffset && at(i).isDigit()) i++
}
}
emit(i, LudicTokens.NUMBER)
return
}
// @deterministic is one token: the sigil never stands alone
if (c == '@' && isIdStart(at(i + 1))) {
i++
while (i < endOffset && isIdPart(at(i))) i++
emit(i, LudicTokens.ANNOTATION)
return
}
if (isIdStart(c)) {
while (i < endOffset && isIdPart(at(i))) i++
val word = buffer.subSequence(tokenStart, i).toString()
val type = when {
word == "true" || word == "false" -> LudicTokens.BOOLEAN
word in LudicVocabulary.PRIMITIVES -> LudicTokens.PRIMITIVE
word in LudicVocabulary.PHASES -> LudicTokens.PHASE
word in LudicVocabulary.DECL -> LudicTokens.DECL_KEYWORD
word in LudicVocabulary.CLAUSE -> LudicTokens.CLAUSE
word in LudicVocabulary.STMT -> LudicTokens.KEYWORD
word in LudicVocabulary.BUILTINS -> LudicTokens.BUILTIN
word[0].isUpperCase() -> LudicTokens.TYPE_NAME
else -> LudicTokens.IDENTIFIER
}
emit(i, type)
return
}
// two-character operators before one-character ones
val two = if (i + 1 < endOffset) buffer.subSequence(i, i + 2).toString() else ""
if (two in TWO_CHAR_OPS) {
emit(i + 2, LudicTokens.OPERATOR)
return
}
when (c) {
'{', '}' -> { emit(i + 1, LudicTokens.BRACE); return }
'[', ']' -> { emit(i + 1, LudicTokens.BRACKET); return }
'(', ')' -> { emit(i + 1, LudicTokens.PAREN); return }
',' -> { emit(i + 1, LudicTokens.COMMA); return }
}
if (c in SINGLE_CHAR_OPS) {
emit(i + 1, LudicTokens.OPERATOR)
return
}
emit(i + 1, LudicTokens.BAD)
}
private fun emit(end: Int, type: IElementType) {
tokenEnd = end
tokenType = type
}
private companion object {
val TWO_CHAR_OPS = setOf("->", "+=", "-=", "*=", "/=", "==", "!=", "<=", ">=", "&&", "||", "..", "=>")
const val SINGLE_CHAR_OPS = "+-*/%<>=:.!@;"
}
}

View file

@ -0,0 +1,52 @@
package io.ludic.ide
import com.intellij.lang.ASTNode
import com.intellij.lang.ParserDefinition
import com.intellij.lang.PsiParser
import com.intellij.lexer.Lexer
import com.intellij.openapi.project.Project
import com.intellij.psi.FileViewProvider
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.TokenType
import com.intellij.psi.tree.IFileElementType
import com.intellij.psi.tree.TokenSet
import com.intellij.extapi.psi.ASTWrapperPsiElement
import com.intellij.extapi.psi.PsiFileBase
/**
* A flat PSI: every token becomes a leaf under the file node, with no tree
* structure above it.
*
* That is enough for the two things the platform genuinely needs a parser for —
* injecting Ludic into Markdown fences, and letting the editor treat a .ludic
* file as a real language rather than plain text — while all actual analysis
* stays in ludic-lsp, where it is shared with every other editor. Building a
* second, divergent parser in Kotlin would mean two grammars to keep honest
* against one compiler.
*/
class LudicParserDefinition : ParserDefinition {
override fun createLexer(project: Project?): Lexer = LudicLexer()
override fun createParser(project: Project?): PsiParser = PsiParser { root, builder ->
val mark = builder.mark()
while (!builder.eof()) builder.advanceLexer()
mark.done(root)
builder.treeBuilt
}
override fun getFileNodeType(): IFileElementType = LudicTokens.FILE
override fun getCommentTokens(): TokenSet = TokenSet.create(LudicTokens.COMMENT)
override fun getStringLiteralElements(): TokenSet = TokenSet.create(LudicTokens.STRING, LudicTokens.CHAR)
override fun getWhitespaceTokens(): TokenSet = TokenSet.create(TokenType.WHITE_SPACE)
// Only ever reached for composite nodes, and this grammar marks none beyond
// the file itself — but the platform requires an answer, so give it a real
// PSI element rather than something that would surprise it later.
override fun createElement(node: ASTNode): PsiElement = ASTWrapperPsiElement(node)
override fun createFile(viewProvider: FileViewProvider): PsiFile = LudicPsiFile(viewProvider)
}
class LudicPsiFile(viewProvider: FileViewProvider) : PsiFileBase(viewProvider, LudicLanguage) {
override fun getFileType() = LudicFileType
override fun toString() = "Ludic file"
}

View file

@ -0,0 +1,158 @@
package io.ludic.ide
import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.util.xmlb.XmlSerializerUtil
import com.redhat.devtools.lsp4ij.LanguageServerFactory
import com.redhat.devtools.lsp4ij.client.LanguageClientImpl
import com.redhat.devtools.lsp4ij.server.CannotStartProcessException
import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider
import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider
import java.io.File
/**
* Where to find the toolchain. Both paths may be left blank, in which case the
* plugin looks under the project root for what tools/build-tools.sh produces —
* which is the case for anyone working in the Ludic repository itself.
*/
@Service(Service.Level.PROJECT)
@State(name = "LudicSettings", storages = [Storage("ludic.xml")])
class LudicSettings : PersistentStateComponent<LudicSettings> {
var serverPath: String = ""
var compilerPath: String = ""
var compilerDiagnostics: Boolean = true
var indentSize: Int = 2
override fun getState(): LudicSettings = this
override fun loadState(state: LudicSettings) = XmlSerializerUtil.copyBean(state, this)
companion object {
fun of(project: Project): LudicSettings = project.service()
}
}
/**
* First existing executable among the usual places, or null.
*
* Takes the project root as a plain path rather than a Project so it can be
* tested without booting an IDE — this lookup silently returning null is the
* difference between a working language server and one that never starts.
*/
internal fun findExecutable(root: String?, configured: String, names: List<String>, subdirs: List<String>): String? {
if (configured.isNotBlank()) {
val f = File(expandHome(configured))
return if (f.canExecute()) f.absolutePath else null
}
if (root == null) return searchPath(names)
for (dir in subdirs) {
for (name in names) {
val f = File(File(root, dir), name)
if (f.isFile && f.canExecute()) return f.absolutePath
}
}
return searchPath(names)
}
/** Fall back to PATH, so a system-wide install works with no configuration. */
private fun searchPath(names: List<String>): String? {
val path = System.getenv("PATH") ?: return null
for (dir in path.split(File.pathSeparator)) {
for (name in names) {
val f = File(dir, name)
if (f.isFile && f.canExecute()) return f.absolutePath
}
}
return null
}
private fun expandHome(p: String) =
if (p.startsWith("~")) System.getProperty("user.home") + p.substring(1) else p
class LudicConnectionProvider(private val project: Project) : ProcessStreamConnectionProvider() {
/**
* Resolve at start(), not at construction.
*
* The provider is built when the project opens, which is usually before the
* toolchain has been compiled. Resolving in an `init` block froze that
* answer forever: build ludic-lsp afterwards and the server stayed dead
* until the IDE was restarted, with `commands=[]` as the only clue.
*/
override fun start() {
val settings = LudicSettings.of(project)
val root = project.basePath
setCommands(serverCommand(root, settings.serverPath)
?: throw CannotStartProcessException(notFoundMessage(root, settings.serverPath)))
root?.let { setWorkingDirectory(it) }
super.start()
}
/**
* What went wrong, in terms of the user's filesystem. "commands=[]" tells
* nobody anything; this names every path that was tried.
*/
private fun notFoundMessage(root: String?, configured: String): String = buildString {
if (configured.isNotBlank()) {
append("The Ludic language server configured at '")
append(configured)
append("' is missing or not executable.")
} else {
append("Could not find the 'ludic-lsp' executable. Looked in:\n")
SERVER_SUBDIRS.forEach { d ->
append(" ")
append(if (root != null) File(File(root, d), "ludic-lsp").path else "<no project root>/$d/ludic-lsp")
append('\n')
}
append(" and on PATH.\n")
}
append("\nBuild it with ./tools/build-tools.sh from the repository root, ")
append("or set the path in Settings | Languages & Frameworks | Ludic. ")
append("Syntax highlighting works without it; completion and diagnostics do not.")
}
/**
* Passed to the server's `initialize`. The server can find `ludicc` on its
* own, but telling it explicitly means the IDE's setting wins.
*/
override fun getInitializationOptions(rootUri: VirtualFile?): Any {
val settings = LudicSettings.of(project)
val compiler = findExecutable(
project.basePath, settings.compilerPath,
listOf("ludicc", "ludicc.exe"),
COMPILER_SUBDIRS
) ?: ""
return mapOf(
"compilerPath" to compiler,
"compilerDiagnostics" to settings.compilerDiagnostics,
"indentSize" to settings.indentSize
)
}
}
/**
* The exact argv the language server is launched with, or null when the binary
* cannot be found. Split out from start() so the command line itself is
* testable — an empty or wrong command here is invisible until LSP4IJ reports
* `commands=[]`, which says nothing about why.
*/
internal fun serverCommand(root: String?, configured: String): List<String>? {
val exe = findExecutable(root, configured, listOf("ludic-lsp", "ludic-lsp.exe"), SERVER_SUBDIRS)
return exe?.let { listOf(it, "--stdio") }
}
/** Where the toolchain is looked for, relative to the project root. */
internal val SERVER_SUBDIRS = listOf("build", "tools/build", "bin", ".")
internal val COMPILER_SUBDIRS = listOf("build", "bin", ".")
class LudicLanguageServerFactory : LanguageServerFactory {
override fun createConnectionProvider(project: Project): StreamConnectionProvider =
LudicConnectionProvider(project)
override fun createLanguageClient(project: Project): LanguageClientImpl =
LanguageClientImpl(project)
}

View file

@ -0,0 +1,76 @@
package io.ludic.ide
import com.intellij.lexer.Lexer
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors as D
import com.intellij.openapi.editor.colors.TextAttributesKey
import com.intellij.openapi.editor.colors.TextAttributesKey.createTextAttributesKey
import com.intellij.openapi.editor.HighlighterColors
import com.intellij.openapi.fileTypes.SyntaxHighlighter
import com.intellij.openapi.fileTypes.SyntaxHighlighterBase
import com.intellij.openapi.fileTypes.SyntaxHighlighterFactory
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.tree.IElementType
/**
* Colours are all derived from the IDE's own semantic keys rather than hard
* coded, so Ludic looks native in whatever theme the user has, dark or light,
* without shipping a colour scheme.
*/
object LudicColors {
val COMMENT = key("LUDIC_COMMENT", D.LINE_COMMENT)
val STRING = key("LUDIC_STRING", D.STRING)
val NUMBER = key("LUDIC_NUMBER", D.NUMBER)
val KEYWORD = key("LUDIC_KEYWORD", D.KEYWORD)
val DECL_KEYWORD = key("LUDIC_DECL_KEYWORD", D.KEYWORD)
val CLAUSE = key("LUDIC_CLAUSE", D.METADATA)
val PRIMITIVE = key("LUDIC_PRIMITIVE", D.KEYWORD)
val PHASE = key("LUDIC_PHASE", D.STATIC_FIELD)
val BOOLEAN = key("LUDIC_BOOLEAN", D.KEYWORD)
val BUILTIN = key("LUDIC_BUILTIN", D.PREDEFINED_SYMBOL)
val TYPE_NAME = key("LUDIC_TYPE", D.CLASS_NAME)
val IDENTIFIER = key("LUDIC_IDENTIFIER", D.IDENTIFIER)
val ANNOTATION = key("LUDIC_ANNOTATION", D.METADATA)
val OPERATOR = key("LUDIC_OPERATOR", D.OPERATION_SIGN)
val BRACE = key("LUDIC_BRACE", D.BRACES)
val BRACKET = key("LUDIC_BRACKET", D.BRACKETS)
val PAREN = key("LUDIC_PAREN", D.PARENTHESES)
val COMMA = key("LUDIC_COMMA", D.COMMA)
val BAD = createTextAttributesKey("LUDIC_BAD_CHARACTER", HighlighterColors.BAD_CHARACTER)
private fun key(name: String, base: TextAttributesKey) = createTextAttributesKey(name, base)
}
class LudicSyntaxHighlighter : SyntaxHighlighterBase() {
override fun getHighlightingLexer(): Lexer = LudicLexer()
override fun getTokenHighlights(tokenType: IElementType): Array<TextAttributesKey> = pack(
when (tokenType) {
LudicTokens.COMMENT -> LudicColors.COMMENT
LudicTokens.STRING, LudicTokens.CHAR -> LudicColors.STRING
LudicTokens.NUMBER -> LudicColors.NUMBER
LudicTokens.KEYWORD -> LudicColors.KEYWORD
LudicTokens.DECL_KEYWORD -> LudicColors.DECL_KEYWORD
LudicTokens.CLAUSE -> LudicColors.CLAUSE
LudicTokens.PRIMITIVE -> LudicColors.PRIMITIVE
LudicTokens.PHASE -> LudicColors.PHASE
LudicTokens.BOOLEAN -> LudicColors.BOOLEAN
LudicTokens.BUILTIN -> LudicColors.BUILTIN
LudicTokens.TYPE_NAME -> LudicColors.TYPE_NAME
LudicTokens.IDENTIFIER -> LudicColors.IDENTIFIER
LudicTokens.ANNOTATION -> LudicColors.ANNOTATION
LudicTokens.OPERATOR -> LudicColors.OPERATOR
LudicTokens.BRACE -> LudicColors.BRACE
LudicTokens.BRACKET -> LudicColors.BRACKET
LudicTokens.PAREN -> LudicColors.PAREN
LudicTokens.COMMA -> LudicColors.COMMA
LudicTokens.BAD -> LudicColors.BAD
else -> null
}
)
}
class LudicSyntaxHighlighterFactory : SyntaxHighlighterFactory() {
override fun getSyntaxHighlighter(project: Project?, virtualFile: VirtualFile?): SyntaxHighlighter =
LudicSyntaxHighlighter()
}

View file

@ -0,0 +1,81 @@
package io.ludic.ide
import com.intellij.psi.tree.IElementType
import com.intellij.psi.tree.IFileElementType
/**
* One token type per thing the highlighter needs to tell apart. This is a
* lexer-only language on the JetBrains side: the token set is the whole grammar
* the platform sees, and ludic-lsp supplies everything structural.
*/
class LudicTokenType(debugName: String) : IElementType(debugName, LudicLanguage)
object LudicTokens {
val FILE = IFileElementType(LudicLanguage)
val COMMENT = LudicTokenType("COMMENT")
val STRING = LudicTokenType("STRING")
val CHAR = LudicTokenType("CHAR")
val NUMBER = LudicTokenType("NUMBER")
val IDENTIFIER = LudicTokenType("IDENTIFIER")
val TYPE_NAME = LudicTokenType("TYPE_NAME") // Capitalised: component, archetype, scene
val KEYWORD = LudicTokenType("KEYWORD")
val DECL_KEYWORD = LudicTokenType("DECL_KEYWORD") // game, component, system, fn, ...
val CLAUSE = LudicTokenType("CLAUSE") // phase, query, reads, writes, ...
val PRIMITIVE = LudicTokenType("PRIMITIVE") // int, fixed, bool, entity, str, ptr, void
val PHASE = LudicTokenType("PHASE") // Start, Update, Render, ...
val BOOLEAN = LudicTokenType("BOOLEAN")
val BUILTIN = LudicTokenType("BUILTIN") // runtime surface + compiler intrinsics
val ANNOTATION = LudicTokenType("ANNOTATION") // @deterministic
val OPERATOR = LudicTokenType("OPERATOR")
val BRACE = LudicTokenType("BRACE")
val BRACKET = LudicTokenType("BRACKET")
val PAREN = LudicTokenType("PAREN")
val COMMA = LudicTokenType("COMMA")
val BAD = LudicTokenType("BAD_CHARACTER")
}
/**
* The vocabulary, mirroring tools/ludic-tools/ludic_syntax.h. When the language
* grows a keyword both sides change together; that header is the reference.
*/
object LudicVocabulary {
val DECL = setOf(
"game", "module", "import", "component", "struct", "archetype", "ui", "scene", "layer",
"const", "var", "export", "fn", "pure", "extern", "system", "edge", "start", "on"
)
val CLAUSE = setOf(
"phase", "query", "reads", "writes", "needs", "uses",
"requires", "ensures", "invariant", "effects"
)
val STMT = setOf(
"let", "return", "if", "else", "when", "while", "for", "in", "spawn", "despawn",
"match", "machine", "state", "become", "enter", "where", "and", "or", "not",
"break", "continue", "new"
)
val PRIMITIVES = setOf("int", "fixed", "bool", "entity", "str", "ptr", "void")
val PHASES = setOf("Start", "Input", "FixedUpdate", "Update", "LateUpdate", "Render")
val WIDGETS = setOf("panel", "col", "row", "label", "button", "image", "spacer")
val BUILTINS = setOf(
"min", "max", "abs", "clamp", "seed", "rng_range", "rng_chance", "fx", "flr",
"map_size", "map_row", "tile", "clear", "present", "fill_rect", "frame_rect",
"put_px", "text", "text_int", "font_load", "text_ttf", "text_w", "text_h",
"image_load", "draw_image", "draw_image_scaled", "draw_9slice", "load_png",
"load_sprites", "draw_sprite", "draw_sprite_scaled", "ui_build", "ui_open",
"ui_tick", "ui_render", "ui_clicked", "ui_set_text", "ui_set_int", "ui_focus",
"ui_focused", "ui_visible", "key", "reg", "setreg", "self", "save", "load",
"status", "print_int", "quit",
// compiler intrinsics: the floor the Ludic-written runtime stands on
"mem_alloc", "mem_realloc", "mem_free", "mem_copy", "mem_set", "peek8", "poke8", "peek32",
"os_argc", "os_arg", "file_stderr",
"poke32", "peekp", "pokep", "peekf", "pokef", "ptr_add", "ptr_null",
"ptr_is_null", "as_fixed", "as_int", "file_open", "file_read", "file_write",
"file_seek", "file_tell", "file_close", "read_byte", "write_byte", "print_str",
"str_len", "shl", "shr", "band", "bor", "bxor", "bnot", "os_exit", "os_time",
"is_windowed", "game_title", "win_open", "win_poll", "win_present",
"win_running", "win_close"
)
fun isKeyword(w: String) = w in DECL || w in CLAUSE || w in STMT
}

View file

@ -0,0 +1,14 @@
<idea-plugin>
<!--
Loaded only when the Markdown plugin is present.
Nothing needs to be declared for ```ludic fences to highlight: the
Markdown plugin resolves a fence's info string against registered language
IDs, and io.ludic.ide.LudicLanguage registers as "Ludic". This file exists
so that the dependency is expressed honestly — the feature does depend on
the Markdown plugin — and as the place to hang Markdown-specific behaviour
if the fence syntax ever needs more than the default.
-->
<extensions defaultExtensionNs="com.intellij">
</extensions>
</idea-plugin>

View file

@ -0,0 +1,78 @@
<idea-plugin>
<id>io.ludic.ide</id>
<name>Ludic</name>
<vendor>Ludic</vendor>
<description><![CDATA[
<p>Language support for <b>Ludic</b>, the AI-first, ECS-native game language.</p>
<ul>
<li>Syntax highlighting, brace matching, commenting and folding, implemented natively so a
<code>.ludic</code> file is coloured with no external process.</li>
<li>Completion, diagnostics, hover, go-to-definition, find usages, rename and formatting via
the <code>ludic-lsp</code> language server, through LSP4IJ, so this works in Community
editions as well as the paid IDEs.</li>
<li>Errors from the Ludic compiler itself, reported on save.</li>
<li>Highlighting of <code>```ludic</code> fenced blocks in Markdown.</li>
</ul>
<p>Set the paths under <b>Settings &rarr; Languages &amp; Frameworks &rarr; Ludic</b>, or leave
them blank to use <code>build/ludic-lsp</code> and <code>build/ludicc</code> under the
project root.</p>
]]></description>
<depends>com.intellij.modules.platform</depends>
<depends>com.redhat.devtools.lsp4ij</depends>
<!-- Optional: without it everything still works, we just do not get the
Markdown fence injection that the platform's own Markdown plugin does
for any registered language. -->
<depends optional="true" config-file="ludic-markdown.xml">org.intellij.plugins.markdown</depends>
<extensions defaultExtensionNs="com.intellij">
<fileType
name="Ludic"
implementationClass="io.ludic.ide.LudicFileType"
fieldName="INSTANCE"
language="Ludic"
extensions="ludic"/>
<lang.parserDefinition
language="Ludic"
implementationClass="io.ludic.ide.LudicParserDefinition"/>
<lang.syntaxHighlighterFactory
language="Ludic"
implementationClass="io.ludic.ide.LudicSyntaxHighlighterFactory"/>
<lang.commenter
language="Ludic"
implementationClass="io.ludic.ide.LudicCommenter"/>
<lang.braceMatcher
language="Ludic"
implementationClass="io.ludic.ide.LudicBraceMatcher"/>
<lang.foldingBuilder
language="Ludic"
implementationClass="io.ludic.ide.LudicFoldingBuilder"/>
<projectConfigurable
parentId="language"
instance="io.ludic.ide.LudicConfigurable"
id="io.ludic.ide.settings"
displayName="Ludic"/>
</extensions>
<extensions defaultExtensionNs="com.redhat.devtools.lsp4ij">
<server
id="ludic"
name="Ludic"
factoryClass="io.ludic.ide.LudicLanguageServerFactory">
<description><![CDATA[
The Ludic language server. Built by <code>tools/build-tools.sh</code>.
]]></description>
</server>
<languageMapping language="Ludic" serverId="ludic"/>
<!-- Markdown is mapped too: the server understands ```ludic fences in a
.md and reports diagnostics and semantic tokens for them. -->
<languageMapping language="Markdown" serverId="ludic"/>
</extensions>
</idea-plugin>

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" rx="6" fill="#1a1a2c"/>
<rect x="7" y="7" width="8" height="8" rx="1.5" fill="#ffe060"/>
<rect x="17" y="7" width="8" height="8" rx="1.5" fill="#4a4a72"/>
<rect x="7" y="17" width="8" height="8" rx="1.5" fill="#4a4a72"/>
<rect x="17" y="17" width="8" height="8" rx="1.5" fill="#9aa0c8"/>
</svg>

After

Width:  |  Height:  |  Size: 418 B

View file

@ -0,0 +1,155 @@
package io.ludic.ide
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
/**
* Locating `ludic-lsp` is the single point where the whole language server can
* fail silently: if this returns null the provider starts with an empty command
* list, LSP4IJ reports `commands=[]`, and nothing in that message points at the
* cause. It shipped broken once for exactly that reason, so it gets tests.
*/
class FindExecutableTest {
@get:Rule
val tmp = TemporaryFolder()
private val names = listOf("ludic-lsp", "ludic-lsp.exe")
private fun executableAt(vararg segments: String): File {
val f = File(tmp.root, segments.joinToString(File.separator))
f.parentFile.mkdirs()
f.writeText("#!/bin/sh\n")
check(f.setExecutable(true))
return f
}
@Test
fun `finds the binary that build-tools_sh produces`() {
val exe = executableAt("build", "ludic-lsp")
assertEquals(exe.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
}
@Test
fun `a project root that is a subdirectory of the repo finds nothing`() {
// The bug that shipped: the IDE was opened on <repo>/tools, so the
// lookup searched <repo>/tools/build and came back empty while
// <repo>/build/ludic-lsp sat there the whole time.
executableAt("build", "ludic-lsp")
val subdir = File(tmp.root, "tools").apply { mkdirs() }
assertNull(
"must not silently resolve from an unrelated root",
findExecutable(subdir.path, "", names, SERVER_SUBDIRS)
)
}
@Test
fun `search order prefers build over the other candidates`() {
executableAt("bin", "ludic-lsp")
val preferred = executableAt("build", "ludic-lsp")
assertEquals(preferred.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
}
@Test
fun `every documented subdirectory is actually searched`() {
for (dir in SERVER_SUBDIRS) {
val fresh = TemporaryFolder().apply { create() }
val f = File(fresh.root, "$dir${File.separator}ludic-lsp")
f.parentFile.mkdirs()
f.writeText("#!/bin/sh\n")
f.setExecutable(true)
assertEquals(
"subdir '$dir' is advertised but not searched",
f.canonicalPath,
File(findExecutable(fresh.root.path, "", names, SERVER_SUBDIRS)!!).canonicalPath
)
fresh.delete()
}
}
@Test
fun `an explicitly configured path wins over anything found by search`() {
executableAt("build", "ludic-lsp")
val custom = executableAt("elsewhere", "my-lsp")
assertEquals(custom.absolutePath, findExecutable(tmp.root.path, custom.absolutePath, names, SERVER_SUBDIRS))
}
@Test
fun `a configured path that does not exist fails instead of falling back`() {
// Silently ignoring the setting and using a different binary would be
// worse than failing: the user asked for a specific server.
executableAt("build", "ludic-lsp")
assertNull(findExecutable(tmp.root.path, File(tmp.root, "nope").path, names, SERVER_SUBDIRS))
}
@Test
fun `a non-executable file is not accepted`() {
val f = File(tmp.root, "build/ludic-lsp")
f.parentFile.mkdirs()
f.writeText("not executable")
f.setExecutable(false)
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
}
@Test
fun `a directory named like the binary is not accepted`() {
File(tmp.root, "build/ludic-lsp").mkdirs()
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
}
@Test
fun `a null project root still allows a PATH lookup`() {
// No exception, no crash: the default project has no basePath.
findExecutable(null, "", names, SERVER_SUBDIRS)
}
@Test
fun `the real repository layout resolves`() {
// Guards the arrangement the plugin actually ships against: run
// ./tools/build-tools.sh and opening the repo root must just work.
val repoRoot = File(System.getProperty("user.dir")).parentFile.parentFile.parentFile
val built = File(repoRoot, "build/ludic-lsp")
if (!built.isFile) return // toolchain not built in this checkout
val found = findExecutable(repoRoot.path, "", names, SERVER_SUBDIRS)
assertTrue("repo root must resolve to build/ludic-lsp", found != null)
assertEquals(built.canonicalPath, File(found!!).canonicalPath)
}
}
/**
* The launch command itself. LSP4IJ only ever reports `commands=[...]`, so if
* this list is wrong the failure surfaces with no explanation at all.
*/
class ServerCommandTest {
private val repoRoot: File =
File(System.getProperty("user.dir")).parentFile.parentFile.parentFile
@Test
fun `the repository root produces a runnable stdio command`() {
val built = File(repoRoot, "build/ludic-lsp")
if (!built.isFile) return // toolchain not built in this checkout
val cmd = serverCommand(repoRoot.path, "")
assertEquals(listOf(built.canonicalPath, "--stdio"), cmd!!.let { listOf(File(it[0]).canonicalPath, it[1]) })
// and it must actually be executable, not merely present
val proc = ProcessBuilder(cmd[0], "--version").redirectErrorStream(true).start()
val out = proc.inputStream.bufferedReader().readText().trim()
proc.waitFor()
assertTrue("expected a version banner, got '$out'", out.startsWith("ludic-lsp"))
}
@Test
fun `a root without the toolchain yields no command rather than an empty one`() {
// The distinction that matters: null lets start() throw an explanatory
// error; an empty list is what produced the useless `commands=[]`.
val cmd = serverCommand(File(repoRoot, "tools/editors").path, "")
assertTrue("must be null, not an empty list", cmd == null || cmd.isNotEmpty())
}
}

View file

@ -0,0 +1,169 @@
package io.ludic.ide
import com.intellij.psi.TokenType
import com.intellij.psi.tree.IElementType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Syntax highlighting is the lexer plus a colour map, so this is where a
* highlighting regression actually shows up. These are plain JUnit tests: the
* lexer has no platform dependencies, so they run in milliseconds without
* booting an IDE.
*/
class LudicLexerTest {
private fun lex(text: String): List<Pair<IElementType, String>> {
val lexer = LudicLexer()
lexer.start(text, 0, text.length, 0)
val out = mutableListOf<Pair<IElementType, String>>()
while (true) {
val t = lexer.tokenType ?: break
if (t != TokenType.WHITE_SPACE) {
out += t to text.substring(lexer.tokenStart, lexer.tokenEnd)
}
lexer.advance()
}
return out
}
private fun typesOf(text: String) = lex(text).map { it.first }
@Test
fun `declaration keywords are distinct from statement keywords`() {
assertEquals(
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.BRACE),
typesOf("game Hello {")
)
assertEquals(LudicTokens.KEYWORD, typesOf("if x { }").first())
}
@Test
fun `clause keywords are their own class`() {
// `phase` and `query` colour differently from `if`/`let`, which is the
// whole reason a system signature reads as a signature.
assertEquals(
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.CLAUSE, LudicTokens.PHASE),
typesOf("system Move phase FixedUpdate")
)
}
@Test
fun `primitive types and phases are not plain identifiers`() {
assertEquals(LudicTokens.PRIMITIVE, typesOf("int").single())
assertEquals(LudicTokens.PRIMITIVE, typesOf("fixed").single())
assertEquals(LudicTokens.PHASE, typesOf("LateUpdate").single())
assertEquals(LudicTokens.BOOLEAN, typesOf("true").single())
}
@Test
fun `builtins are recognised, unknown calls are not`() {
assertEquals(LudicTokens.BUILTIN, typesOf("rng_chance").single())
assertEquals(LudicTokens.BUILTIN, typesOf("peek32").single()) // intrinsic
assertEquals(LudicTokens.IDENTIFIER, typesOf("my_helper").single())
}
@Test
fun `capitalised words read as type names`() {
assertEquals(LudicTokens.TYPE_NAME, typesOf("Pos").single())
assertEquals(LudicTokens.IDENTIFIER, typesOf("pos").single())
}
@Test
fun `comments run to end of line and keep their text`() {
val toks = lex("let a = 1 # keep me\nlet b = 2")
val comment = toks.first { it.first == LudicTokens.COMMENT }
assertEquals("# keep me", comment.second)
// the newline ends it: the next line is code again
assertEquals(LudicTokens.KEYWORD, toks[toks.indexOf(comment) + 1].first)
}
@Test
fun `strings handle escapes and do not swallow the rest of the file`() {
val toks = lex(""""a\"b" 42""")
assertEquals(LudicTokens.STRING, toks[0].first)
assertEquals(""""a\"b"""", toks[0].second)
assertEquals(LudicTokens.NUMBER, toks[1].first)
}
@Test
fun `an unterminated string stops at the newline`() {
// A half-typed string must not colour the whole rest of the file.
val toks = lex("let s = \"oops\nlet t = 1")
assertEquals(LudicTokens.STRING, toks[3].first)
assertEquals("\"oops", toks[3].second)
assertEquals(LudicTokens.KEYWORD, toks[4].first)
}
@Test
fun `numbers cover hex, fixed-point and the range operator`() {
assertEquals(LudicTokens.NUMBER, typesOf("0x1affff").single())
assertEquals(LudicTokens.NUMBER, typesOf("1.5").single())
// `0..10` is two ints and a range op, not a malformed float
assertEquals(
listOf(LudicTokens.NUMBER, LudicTokens.OPERATOR, LudicTokens.NUMBER),
typesOf("0..10")
)
assertEquals("..", lex("0..10")[1].second)
}
@Test
fun `annotations lex as one token including the sigil`() {
val toks = lex("@deterministic")
assertEquals(LudicTokens.ANNOTATION, toks.single().first)
assertEquals("@deterministic", toks.single().second)
}
@Test
fun `two-character operators beat one-character ones`() {
assertEquals(listOf("->"), lex("->").map { it.second })
assertEquals(listOf("=>"), lex("=>").map { it.second })
assertEquals(listOf("!="), lex("!=").map { it.second })
assertEquals(listOf("<="), lex("<=").map { it.second })
}
@Test
fun `the lexer always terminates and covers every character`() {
// The IDE lexes half-typed text constantly; a lexer that stalls hangs
// the editor, and one that skips bytes corrupts every offset after it.
val nasty = "game X { component ¿ { a: int = ' } # é\n query [{Tag}] \"unclosed"
val lexer = LudicLexer()
lexer.start(nasty, 0, nasty.length, 0)
var covered = 0
var guard = 0
while (lexer.tokenType != null) {
assertTrue("token must advance", lexer.tokenEnd > lexer.tokenStart)
assertEquals("no gaps between tokens", covered, lexer.tokenStart)
covered = lexer.tokenEnd
lexer.advance()
check(guard++ < 10_000) { "lexer did not terminate" }
}
assertEquals("every character consumed", nasty.length, covered)
}
@Test
fun `a real source file lexes with no bad characters`() {
val src = """
game Hello {
component Pos { x: int = 0, y: int = 0 }
archetype Mob { Pos }
system Move phase FixedUpdate
query (p) [Pos, {Player}] where p.x > 0
{
p.x = p.x + 1
for i in 0 .. 10 { print_int(i) }
}
ui Menu {
panel id=Root w=288 pad=16 {
button id=Go text="Go" size=16
}
}
}
""".trimIndent()
val bad = lex(src).filter { it.first == LudicTokens.BAD }
assertTrue("unexpected bad characters: ${bad.map { it.second }}", bad.isEmpty())
}
}

View file

@ -0,0 +1,124 @@
package io.ludic.ide
import com.intellij.lang.LanguageParserDefinitions
import com.intellij.openapi.fileTypes.FileTypeManager
import com.intellij.psi.PsiFile
import com.intellij.testFramework.fixtures.BasePlatformTestCase
/**
* The IDE-side wiring, exercised in a real (headless) platform fixture. These
* cover the things that make a file feel like a supported language rather than
* plain text: the extension is claimed, a PSI tree is built (which is what
* indexing and Markdown injection both stand on), folds appear, braces pair up
* and Ctrl+/ comments.
*/
class LudicPlatformTest : BasePlatformTestCase() {
private val sample = """
game Hello {
component Pos { x: int = 0, y: int = 0 }
archetype Mob { Pos }
# move everything that has a position
system Move phase FixedUpdate
query (p) [Pos, {Player}]
{
p.x = p.x + 1
}
}
""".trimIndent()
private fun ludicFile(text: String = sample): PsiFile =
myFixture.configureByText("sample.ludic", text)
fun testExtensionIsClaimedByTheLudicFileType() {
val type = FileTypeManager.getInstance().getFileTypeByExtension("ludic")
assertEquals("Ludic", type.name)
assertEquals(LudicFileType, type)
}
fun testFileTypeIsNotBinaryAndUsesUtf8() {
assertFalse(LudicFileType.isBinary)
assertEquals("ludic", LudicFileType.defaultExtension)
}
fun testParserDefinitionIsRegisteredForTheLanguage() {
// Markdown fence injection needs a PSI tree, so this registration is
// what makes ```ludic blocks highlight in a .md file.
val def = LanguageParserDefinitions.INSTANCE.forLanguage(LudicLanguage)
assertNotNull("no ParserDefinition registered for Ludic", def)
}
fun testPsiTreeIsBuiltAndCoversTheWholeFile() {
val file = ludicFile()
assertEquals(LudicLanguage, file.language)
assertEquals(sample, file.text)
assertTrue("expected a non-empty PSI tree", file.children.isNotEmpty())
// every character must be reachable through the tree, or offsets drift
assertEquals(sample.length, file.textLength)
}
fun testHighlighterAssignsColoursToEveryMeaningfulToken() {
val highlighter = LudicSyntaxHighlighter()
val coloured = listOf(
LudicTokens.DECL_KEYWORD, LudicTokens.KEYWORD, LudicTokens.CLAUSE,
LudicTokens.PRIMITIVE, LudicTokens.PHASE, LudicTokens.BOOLEAN,
LudicTokens.BUILTIN, LudicTokens.TYPE_NAME, LudicTokens.STRING,
LudicTokens.NUMBER, LudicTokens.COMMENT, LudicTokens.ANNOTATION,
LudicTokens.OPERATOR, LudicTokens.BRACE, LudicTokens.BRACKET,
LudicTokens.PAREN, LudicTokens.COMMA, LudicTokens.IDENTIFIER
)
for (token in coloured) {
val keys = highlighter.getTokenHighlights(token)
assertTrue("no colour mapped for $token", keys.isNotEmpty())
}
}
fun testFoldingCollapsesMultiLineBlocksOnly() {
val file = ludicFile()
val regions = LudicFoldingBuilder().buildFoldRegions(file.node, file.viewProvider.document!!)
// The sample has exactly two blocks that span lines: the `game` body and
// the `system` body. `component Pos { … }`, `archetype Mob { … }` and the
// `{Player}` query filter all fit on one line, and folding a single line
// gains nothing, so they are deliberately skipped.
assertEquals("multi-line blocks only", 2, regions.size)
val outermost = regions.maxByOrNull { it.range.length }!!
assertEquals("outermost fold starts at the game block's brace",
sample.indexOf('{'), outermost.range.startOffset)
assertEquals("outermost fold ends at the last brace",
sample.length, outermost.range.endOffset)
for (r in regions) {
assertTrue("fold region must be non-empty", r.range.length > 0)
assertEquals("{...}", LudicFoldingBuilder().getPlaceholderText(file.node))
}
}
fun testCommenterUsesHashWithASpace() {
// The trailing space is deliberate: Ctrl+/ should produce "# code",
// which is how every comment in the runtime and examples is written.
val commenter = LudicCommenter()
assertEquals("# ", commenter.lineCommentPrefix)
assertNull("Ludic has no block comments", commenter.blockCommentPrefix)
}
fun testBraceMatcherPairsAllThreeBracketKinds() {
val matcher = LudicBraceMatcher()
val pairs = matcher.pairs
val open = pairs.map { it.leftBraceType }.toSet()
assertTrue(open.contains(LudicTokens.BRACE))
assertTrue(open.contains(LudicTokens.BRACKET))
assertTrue(open.contains(LudicTokens.PAREN))
}
fun testHalfTypedFileStillProducesAPsiTree() {
// The IDE must survive what a file looks like mid-keystroke; a parser
// that throws here takes highlighting and indexing down with it.
val broken = "game X {\n component P { x: int =\n system"
val file = myFixture.configureByText("broken.ludic", broken)
assertEquals(broken.length, file.textLength)
assertEquals(LudicLanguage, file.language)
}
}

View file

@ -0,0 +1,86 @@
-- Ludic support for Neovim (0.9+), with no plugin manager and no dependencies.
--
-- :lua require('ludic').setup()
-- -- or, from init.lua:
-- vim.opt.runtimepath:append('/path/to/gpp/tools/editors/neovim')
-- require('ludic').setup({ server = '/path/to/gpp/build/ludic-lsp' })
--
-- Highlighting here comes from the language server's semantic tokens, which
-- Neovim applies natively -- there is no tree-sitter grammar to install and no
-- second copy of the vocabulary to keep in sync.
local M = {}
local function find_executable(names, roots, subdirs)
for _, root in ipairs(roots) do
for _, sub in ipairs(subdirs) do
for _, name in ipairs(names) do
local p = root .. '/' .. sub .. '/' .. name
if vim.fn.executable(p) == 1 then return p end
end
end
end
for _, name in ipairs(names) do
if vim.fn.executable(name) == 1 then return name end
end
return nil
end
function M.setup(opts)
opts = opts or {}
-- .ludic files are Ludic
vim.filetype.add({ extension = { ludic = 'ludic' } })
vim.api.nvim_create_autocmd('FileType', {
pattern = 'ludic',
callback = function()
vim.bo.commentstring = '# %s'
vim.bo.comments = ':#'
vim.bo.expandtab = true
vim.bo.shiftwidth = opts.indent or 2
vim.bo.tabstop = opts.indent or 2
end,
})
local root_markers = { 'build.sh', 'LANGUAGE.md', '.git' }
local function root_dir(fname)
local found = vim.fs.find(root_markers, { path = vim.fs.dirname(fname), upward = true })[1]
return found and vim.fs.dirname(found) or vim.fs.dirname(fname)
end
vim.api.nvim_create_autocmd('FileType', {
-- markdown is included on purpose: ```ludic fences get diagnostics and
-- semantic highlighting like any source file
pattern = { 'ludic', 'markdown' },
callback = function(args)
local root = root_dir(vim.api.nvim_buf_get_name(args.buf))
local server = opts.server or find_executable(
{ 'ludic-lsp' }, { root }, { 'build', 'tools/build', 'bin', '.' })
if not server then return end
local compiler = opts.compiler or find_executable(
{ 'ludicc' }, { root }, { 'build', 'bin', '.' }) or ''
vim.lsp.start({
name = 'ludic-lsp',
cmd = { server, '--stdio' },
root_dir = root,
init_options = {
compilerPath = compiler,
compilerDiagnostics = opts.compiler_diagnostics ~= false,
indentSize = opts.indent or 2,
},
}, { bufnr = args.buf })
end,
})
if opts.format_on_save ~= false then
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = { '*.ludic' },
callback = function() vim.lsp.buf.format({ async = false, timeout_ms = 2000 }) end,
})
end
end
return M

View file

@ -0,0 +1,31 @@
{
"comment": "Editor mechanics that are not highlighting and not the language server: comments, bracket pairs, auto-closing, indentation triggers. VS Code reads this file directly; the JetBrains plugin implements the same rules natively; other editors can transcribe it.",
"comments": {
"lineComment": "#"
},
"brackets": [["{", "}"], ["[", "]"], ["(", ")"]],
"autoClosingPairs": [
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"", "close": "\"", "notIn": ["string", "comment"] }
],
"surroundingPairs": [["{", "}"], ["[", "]"], ["(", ")"], ["\"", "\""]],
"indentationRules": {
"increaseIndentPattern": "^((?!//).)*(\\{[^}\"']*|\\([^)\"']*|\\[[^\\]\"']*)$",
"decreaseIndentPattern": "^\\s*[}\\])].*$"
},
"onEnterRules": [
{
"beforeText": "^\\s*#.*$",
"action": { "indent": "none", "appendText": "# " }
}
],
"wordPattern": "[A-Za-z_][A-Za-z0-9_]*",
"folding": {
"markers": {
"start": "^\\s*#\\s*region\\b",
"end": "^\\s*#\\s*endregion\\b"
}
}
}

View file

@ -0,0 +1,27 @@
{
"scopeName": "markdown.ludic.codeblock",
"comment": "Injects Ludic highlighting into ```ludic fences in Markdown. Any TextMate-based editor (VS Code, Sublime, JetBrains' TextMate bundles, Zed) picks this up; JetBrains' native Markdown support gets the same thing for free from the registered `Ludic` language ID, and ludic-lsp will also serve semantic tokens and diagnostics for the fence bodies in a .md file.",
"injectionSelector": "L:text.html.markdown",
"patterns": [{ "include": "#ludic-code-block" }],
"repository": {
"ludic-code-block": {
"begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(ludic)((\\s+|:|,|\\{|\\?)[^`~]*)?)$",
"name": "markup.fenced_code.block.markdown",
"end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$",
"beginCaptures": {
"3": { "name": "punctuation.definition.markdown" },
"4": { "name": "fenced_code.block.language.markdown" },
"5": { "name": "fenced_code.block.language.attributes.markdown" }
},
"endCaptures": { "3": { "name": "punctuation.definition.markdown" } },
"patterns": [
{
"begin": "(^|\\G)(\\s*)(.*)",
"while": "(^|\\G)(?!\\s*([`~]{3,})\\s*$)",
"contentName": "meta.embedded.block.ludic",
"patterns": [{ "include": "source.ludic" }]
}
]
}
}
}

View file

@ -0,0 +1,241 @@
{
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "Ludic",
"scopeName": "source.ludic",
"fileTypes": ["ludic"],
"comment": "Static grammar for Ludic. It is the fallback: when ludic-lsp is running, its semantic tokens refine everything here with real name resolution. Generated shapes are kept in sync with tools/ludic-tools/ludic_syntax.h.",
"patterns": [
{ "include": "#comment" },
{ "include": "#declaration" },
{ "include": "#query" },
{ "include": "#string" },
{ "include": "#number" },
{ "include": "#annotation" },
{ "include": "#keyword" },
{ "include": "#widget" },
{ "include": "#builtin" },
{ "include": "#member" },
{ "include": "#call" },
{ "include": "#typename" },
{ "include": "#operator" }
],
"repository": {
"comment": {
"match": "#.*$",
"name": "comment.line.number-sign.ludic"
},
"string": {
"patterns": [
{
"name": "string.quoted.double.ludic",
"begin": "\"",
"end": "\"",
"beginCaptures": { "0": { "name": "punctuation.definition.string.begin.ludic" } },
"endCaptures": { "0": { "name": "punctuation.definition.string.end.ludic" } },
"patterns": [{ "name": "constant.character.escape.ludic", "match": "\\\\." }]
},
{
"name": "string.quoted.single.ludic",
"match": "'(\\\\.|[^'\\\\])'"
}
]
},
"number": {
"patterns": [
{ "name": "constant.numeric.hex.ludic", "match": "\\b0[xX][0-9a-fA-F]+\\b" },
{ "name": "constant.numeric.fixed.ludic", "match": "\\b[0-9]+\\.[0-9]+\\b" },
{ "name": "constant.numeric.integer.ludic", "match": "\\b[0-9]+\\b" }
]
},
"annotation": {
"name": "entity.name.function.decorator.ludic",
"match": "@[A-Za-z_][A-Za-z0-9_]*"
},
"declaration": {
"patterns": [
{
"match": "\\b(game|module)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.unit.ludic" },
"2": { "name": "entity.name.type.unit.ludic" }
}
},
{
"match": "\\b(component)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.component.ludic" },
"2": { "name": "entity.name.type.component.ludic" }
}
},
{
"match": "\\b(archetype)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.archetype.ludic" },
"2": { "name": "entity.name.type.archetype.ludic" }
}
},
{
"match": "\\b(scene)\\s+([A-Za-z_][A-Za-z0-9_]*)(\\s+start)?",
"captures": {
"1": { "name": "storage.type.scene.ludic" },
"2": { "name": "entity.name.type.scene.ludic" },
"3": { "name": "keyword.other.start.ludic" }
}
},
{
"match": "\\b(layer|ui)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.ludic" },
"2": { "name": "entity.name.type.ludic" }
}
},
{
"match": "\\b(edge\\s+)?(system)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.modifier.edge.ludic" },
"2": { "name": "storage.type.system.ludic" },
"3": { "name": "entity.name.function.system.ludic" }
}
},
{
"match": "\\b(export\\s+|pure\\s+)?(extern\\s+)?(fn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.modifier.ludic" },
"2": { "name": "storage.modifier.extern.ludic" },
"3": { "name": "storage.type.function.ludic" },
"4": { "name": "entity.name.function.ludic" }
}
},
{
"match": "\\b(const|var|let)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.ludic" },
"2": { "name": "variable.other.definition.ludic" }
}
},
{
"match": "\\b(state)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.state.ludic" },
"2": { "name": "entity.name.constant.state.ludic" }
}
},
{
"match": "\\b(import)\\s+(?=\")",
"captures": { "1": { "name": "keyword.control.import.ludic" } }
},
{
"match": "\\b(phase)\\s+(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b",
"captures": {
"1": { "name": "keyword.other.clause.ludic" },
"2": { "name": "constant.language.phase.ludic" }
}
},
{
"match": "\\b(enter)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.enter.ludic" },
"2": { "name": "entity.name.type.scene.ludic" }
}
},
{
"match": "\\b(spawn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.spawn.ludic" },
"2": { "name": "entity.name.type.archetype.ludic" }
}
}
]
},
"query": {
"comment": "Inside a query's brackets a bare word names a component and {Word} is a filter that does not bind.",
"begin": "\\b(query)\\s*(\\[)",
"beginCaptures": {
"1": { "name": "keyword.other.query.ludic" },
"2": { "name": "punctuation.section.brackets.begin.ludic" }
},
"end": "(\\])",
"endCaptures": { "1": { "name": "punctuation.section.brackets.end.ludic" } },
"patterns": [
{
"match": "(\\{)\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*(\\})",
"captures": {
"1": { "name": "punctuation.section.braces.begin.ludic" },
"2": { "name": "entity.name.type.tag.ludic" },
"3": { "name": "punctuation.section.braces.end.ludic" }
}
},
{ "name": "entity.name.type.component.ludic", "match": "\\b[A-Za-z_][A-Za-z0-9_]*\\b" },
{ "name": "punctuation.separator.ludic", "match": "," }
]
},
"keyword": {
"patterns": [
{ "name": "keyword.control.ludic", "match": "\\b(if|else|when|while|for|in|match|machine|become|enter|return|spawn|despawn|where|break|continue|new)\\b" },
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes|needs|uses|requires|ensures|invariant|effects|on)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|export|pure|extern|start|edge)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(game|module|component|archetype|ui|scene|layer|const|var|let|fn|system|state)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|fixed|bool|entity|str|ptr|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }
]
},
"widget": {
"patterns": [
{
"comment": "widget types, recognised by the props or the child block that follow them",
"name": "support.class.widget.ludic",
"match": "\\b(panel|col|row|label|button|image|spacer)\\b(?=\\s*(\\{|[a-z][A-Za-z0-9_]*\\s*=|$))"
},
{
"comment": "widget props are written tight (id=Root); an assignment is written x = 1",
"match": "\\b([a-z][A-Za-z0-9_]*)(=)(?!=)",
"captures": {
"1": { "name": "variable.parameter.widget.ludic" },
"2": { "name": "keyword.operator.assignment.ludic" }
}
}
]
},
"builtin": {
"patterns": [
{
"comment": "the runtime surface — every name here resolves to rt_<name> in runtime/native",
"name": "support.function.builtin.ludic",
"match": "\\b(min|max|abs|clamp|seed|rng_range|rng_chance|fx|flr|map_size|map_row|tile|clear|present|fill_rect|frame_rect|put_px|text|text_int|font_load|text_ttf|text_w|text_h|image_load|draw_image|draw_image_scaled|draw_9slice|load_png|load_sprites|draw_sprite|draw_sprite_scaled|ui_build|ui_open|ui_tick|ui_render|ui_clicked|ui_set_text|ui_set_int|ui_focus|ui_focused|ui_visible|key|reg|setreg|self|save|load|status|print_int|quit)\\b(?=\\s*\\()"
},
{
"comment": "compiler intrinsics — these lower straight to libc or the OS",
"name": "support.function.intrinsic.ludic",
"match": "\\b(mem_alloc|mem_realloc|mem_free|mem_copy|mem_set|peek8|poke8|peek32|poke32|peekp|pokep|peekf|pokef|ptr_add|ptr_null|ptr_is_null|as_fixed|as_int|file_open|file_read|file_write|file_seek|file_tell|file_close|read_byte|write_byte|print_str|str_len|shl|shr|band|bor|bxor|bnot|os_exit|os_time|os_argc|os_arg|file_stderr|is_windowed|game_title|win_open|win_poll|win_present|win_running|win_close)\\b(?=\\s*\\()"
}
]
},
"member": {
"match": "(\\.)([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "punctuation.accessor.ludic" },
"2": { "name": "variable.other.property.ludic" }
}
},
"call": {
"match": "\\b([A-Za-z_][A-Za-z0-9_]*)\\s*(?=\\()",
"name": "entity.name.function.call.ludic"
},
"typename": {
"comment": "components, archetypes, scenes and UI handles are Capitalised by convention",
"name": "entity.name.type.ludic",
"match": "\\b[A-Z][A-Za-z0-9_]*\\b"
},
"operator": {
"patterns": [
{ "name": "keyword.operator.arrow.ludic", "match": "->|=>" },
{ "name": "keyword.operator.range.ludic", "match": "\\.\\." },
{ "name": "keyword.operator.comparison.ludic", "match": "==|!=|<=|>=|<|>" },
{ "name": "keyword.operator.assignment.ludic", "match": "\\+=|-=|\\*=|/=|=" },
{ "name": "keyword.operator.arithmetic.ludic", "match": "[+\\-*/%]" }
]
}
}
}

View file

@ -0,0 +1,35 @@
# Ludic for Sublime Text
Sublime reads TextMate grammars directly, so the shared grammar works as-is.
1. **Highlighting.** Copy `tools/editors/shared/ludic.tmLanguage.json` into your
`Packages/User/` directory, renamed to `Ludic.tmLanguage.json`. Sublime picks
up `.ludic` from the grammar's `fileTypes`.
2. **Markdown fences.** Copy
`tools/editors/shared/ludic.markdown-injection.json` alongside it as
`Ludic Markdown.tmLanguage.json`. Sublime honours `injectionSelector`, so
` ```ludic ` blocks in Markdown are highlighted with no further setup.
3. **Everything else** comes from the language server. Install
[LSP](https://packagecontrol.io/packages/LSP), then add to
`Preferences -> Package Settings -> LSP -> Settings`:
```json
{
"clients": {
"ludic": {
"enabled": true,
"command": ["/path/to/gpp/build/ludic-lsp", "--stdio"],
"selector": "source.ludic | text.html.markdown",
"initializationOptions": {
"compilerPath": "/path/to/gpp/build/ludicc",
"compilerDiagnostics": true,
"indentSize": 2
}
}
}
}
```
Format on save with `LSP-format-on-save`, or bind `lsp_format_document`.

View file

@ -0,0 +1,12 @@
.vscode/**
node_modules/**
!node_modules/vscode-languageclient/**
!node_modules/vscode-jsonrpc/**
!node_modules/vscode-languageserver-protocol/**
!node_modules/vscode-languageserver-types/**
!node_modules/semver/**
!node_modules/balanced-match/**
!node_modules/minimatch/**
!node_modules/brace-expansion/**
!node_modules/concat-map/**
**/*.map

View file

@ -0,0 +1,54 @@
# Ludic for VS Code
Syntax highlighting, formatting, and full language-server support for **Ludic**,
the AI-first, ECS-native game language. See `LANGUAGE.md` in the Ludic repository
for the language reference.
## What you get
| | Source |
|---|---|
| Syntax highlighting | TextMate grammar, works with no build |
| Semantic highlighting | `ludic-lsp` — real name resolution, so a component reads differently from a local |
| Diagnostics | structural errors as you type; `ludicc`'s own errors on save |
| Completion | context-aware: fields after `.`, components inside `query [...]`, phases after `phase`, widgets inside `ui` |
| Hover | signatures and docs for builtins, intrinsics, and everything you declared |
| Go to definition / references / rename | across every file in the compilation unit, following `import` |
| Formatting | format-on-save, comment-preserving |
| Outline, folding, inlay hints, signature help | |
| ` ```ludic ` fences in Markdown | highlighted, checked, and formatted |
## Setup
Build the toolchain once, from the repository root:
```bash
./tools/build-tools.sh
```
That produces `build/ludic-lsp` and `build/ludic-fmt`, which the extension finds
on its own. If you keep them elsewhere, set `ludic.server.path`.
Format on save:
```json
"[ludic]": { "editor.formatOnSave": true }
```
## Settings
- `ludic.server.path` — where `ludic-lsp` lives. Empty = search `build/`, then `PATH`.
- `ludic.server.enabled` — turn the server off and keep only static highlighting.
- `ludic.compilerPath` — where `ludicc` lives, for on-save diagnostics.
- `ludic.compilerDiagnostics` — whether to run the compiler at all.
- `ludic.indentSize` — spaces per level for the formatter.
- `ludic.trace.server` — log LSP traffic (`Ludic: Show Language Server Log`).
## Installing without publishing
```bash
cd tools/editors/vscode
npm install
npx @vscode/vsce package
code --install-extension ludic-1.0.0.vsix
```

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" rx="6" fill="#1a1a2c"/>
<rect x="7" y="7" width="8" height="8" rx="1.5" fill="#ffe060"/>
<rect x="17" y="7" width="8" height="8" rx="1.5" fill="#4a4a72"/>
<rect x="7" y="17" width="8" height="8" rx="1.5" fill="#4a4a72"/>
<rect x="17" y="17" width="8" height="8" rx="1.5" fill="#9aa0c8"/>
</svg>

After

Width:  |  Height:  |  Size: 418 B

View file

@ -0,0 +1,31 @@
{
"comment": "Editor mechanics that are not highlighting and not the language server: comments, bracket pairs, auto-closing, indentation triggers. VS Code reads this file directly; the JetBrains plugin implements the same rules natively; other editors can transcribe it.",
"comments": {
"lineComment": "#"
},
"brackets": [["{", "}"], ["[", "]"], ["(", ")"]],
"autoClosingPairs": [
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"", "close": "\"", "notIn": ["string", "comment"] }
],
"surroundingPairs": [["{", "}"], ["[", "]"], ["(", ")"], ["\"", "\""]],
"indentationRules": {
"increaseIndentPattern": "^((?!//).)*(\\{[^}\"']*|\\([^)\"']*|\\[[^\\]\"']*)$",
"decreaseIndentPattern": "^\\s*[}\\])].*$"
},
"onEnterRules": [
{
"beforeText": "^\\s*#.*$",
"action": { "indent": "none", "appendText": "# " }
}
],
"wordPattern": "[A-Za-z_][A-Za-z0-9_]*",
"folding": {
"markers": {
"start": "^\\s*#\\s*region\\b",
"end": "^\\s*#\\s*endregion\\b"
}
}
}

97
tools/editors/vscode/package-lock.json generated Normal file
View file

@ -0,0 +1,97 @@
{
"name": "ludic",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ludic",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"vscode-languageclient": "^9.0.1"
},
"engines": {
"vscode": "^1.75.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageclient": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz",
"integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==",
"license": "MIT",
"dependencies": {
"minimatch": "^5.1.0",
"semver": "^7.3.7",
"vscode-languageserver-protocol": "3.17.5"
},
"engines": {
"vscode": "^1.82.0"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
}
}
}

View file

@ -0,0 +1,158 @@
{
"name": "ludic",
"displayName": "Ludic",
"description": "Ludic language support: syntax highlighting, formatting, and the ludic-lsp language server. Also highlights ```ludic fences in Markdown.",
"version": "1.0.0",
"publisher": "ludic",
"license": "MIT",
"engines": {
"vscode": "^1.75.0"
},
"categories": [
"Programming Languages",
"Formatters",
"Linters"
],
"keywords": [
"ludic",
"ecs",
"gamedev"
],
"main": "./src/extension.js",
"activationEvents": [
"onLanguage:ludic",
"onLanguage:markdown",
"workspaceContains:**/*.ludic"
],
"contributes": {
"languages": [
{
"id": "ludic",
"aliases": [
"Ludic",
"ludic"
],
"extensions": [
".ludic"
],
"configuration": "./language-configuration.json",
"icon": {
"light": "./icons/ludic.svg",
"dark": "./icons/ludic.svg"
}
}
],
"grammars": [
{
"language": "ludic",
"scopeName": "source.ludic",
"path": "./syntaxes/ludic.tmLanguage.json"
},
{
"scopeName": "markdown.ludic.codeblock",
"path": "./syntaxes/ludic.markdown-injection.json",
"injectTo": [
"text.html.markdown"
],
"embeddedLanguages": {
"meta.embedded.block.ludic": "ludic"
}
}
],
"semanticTokenScopes": [
{
"language": "ludic",
"scopes": {
"struct": [
"entity.name.type.component.ludic"
],
"class": [
"entity.name.type.archetype.ludic"
],
"namespace": [
"entity.name.type.scene.ludic"
],
"function.defaultLibrary": [
"support.function.builtin.ludic"
],
"enumMember.readonly.defaultLibrary": [
"constant.language.phase.ludic"
],
"decorator": [
"entity.name.function.decorator.ludic"
]
}
}
],
"configuration": {
"title": "Ludic",
"properties": {
"ludic.server.path": {
"type": "string",
"default": "",
"markdownDescription": "Path to the `ludic-lsp` binary. Empty means: look in `build/ludic-lsp` under each workspace folder, then on `PATH`."
},
"ludic.server.enabled": {
"type": "boolean",
"default": true,
"description": "Run the Ludic language server. Turn this off to keep only the static syntax highlighting."
},
"ludic.compilerPath": {
"type": "string",
"default": "",
"markdownDescription": "Path to `ludicc`. The server shells out to it for authoritative diagnostics on save. Empty means `build/ludicc` under the workspace."
},
"ludic.compilerDiagnostics": {
"type": "boolean",
"default": true,
"description": "Report errors from the Ludic compiler on save, in addition to the server's own structural checks."
},
"ludic.indentSize": {
"type": "number",
"default": 2,
"description": "Spaces per indent level used by the formatter when the editor does not say otherwise."
},
"ludic.trace.server": {
"type": "string",
"enum": [
"off",
"messages",
"verbose"
],
"default": "off",
"description": "Log the traffic between VS Code and the Ludic language server."
}
}
},
"commands": [
{
"command": "ludic.restartServer",
"title": "Ludic: Restart Language Server"
},
{
"command": "ludic.showOutput",
"title": "Ludic: Show Language Server Log"
}
],
"configurationDefaults": {
"[ludic]": {
"editor.insertSpaces": true,
"editor.tabSize": 2,
"editor.semanticHighlighting.enabled": true
}
},
"snippets": [
{
"language": "ludic",
"path": "./snippets/ludic.json"
}
]
},
"dependencies": {
"vscode-languageclient": "^9.0.1"
},
"repository": {
"type": "git",
"url": "https://example.invalid/ludic"
}
}

View file

@ -0,0 +1,96 @@
{
"game": {
"prefix": "game",
"body": ["game ${1:Name} {", "\t$0", "}"],
"description": "A game unit — compiles to an executable."
},
"module": {
"prefix": "module",
"body": ["module ${1:Name} {", "\t$0", "}"],
"description": "A library unit — compiles to a shared library, no frame loop."
},
"component": {
"prefix": "component",
"body": ["component ${1:Name} { ${2:x}: ${3:int} = ${4:0} }"],
"description": "A component: typed per-entity fields."
},
"archetype": {
"prefix": "archetype",
"body": ["archetype ${1:Name} {", "\t${2:Pos}", "}"],
"description": "An entity kind bundling a fixed set of components."
},
"system": {
"prefix": "system",
"body": [
"system ${1:Name} phase ${2|Start,Input,FixedUpdate,Update,LateUpdate,Render|} {",
"\t$0",
"}"
],
"description": "A system that runs once per tick in its phase."
},
"system with query": {
"prefix": "systemq",
"body": [
"system ${1:Name} phase ${2|Update,FixedUpdate,LateUpdate,Render,Input|}",
" query (${3:p}) [${4:Pos}]",
"{",
"\t$0",
"}"
],
"description": "A system whose body runs once per matching entity."
},
"for query": {
"prefix": "forq",
"body": ["for (${1:p}) in query [${2:Pos}] {", "\t$0", "}"],
"description": "Iterate every entity matching a query."
},
"spawn": {
"prefix": "spawn",
"body": ["spawn ${1:Kind} {", "\t${2:Pos} = { ${3:x} = ${4:0} }", "}"],
"description": "Create an entity of an archetype."
},
"scene": {
"prefix": "scene",
"body": [
"scene ${1:Name} {",
"\ton enter { $2 }",
"\tlayer ${3:Main} {",
"\t\t$0",
"\t}",
"}"
],
"description": "A scene with lifecycle hooks and one layer of systems."
},
"ui": {
"prefix": "ui",
"body": [
"ui ${1:Name} {",
"\tpanel id=${2:Root} pad=16 gap=6 align=center {",
"\t\t$0",
"\t}",
"}"
],
"description": "A retained UI tree."
},
"match": {
"prefix": "match",
"body": ["match ${1:x} {", "\t${2:0} => { $3 }", "\t_ => { $0 }", "}"],
"description": "Pattern match."
},
"machine": {
"prefix": "machine",
"body": [
"machine reg(${1:R_PHASE}) {",
"\tstate ${2:Idle} = 0 {",
"\t\t$0",
"\t}",
"}"
],
"description": "A state machine over a register."
},
"extern fn": {
"prefix": "extern",
"body": ["extern fn ${1:name}(${2:a}: ${3:int}) -> ${4:int} = \"${5:c_symbol}\""],
"description": "Bind a C symbol through the FFI."
}
}

View file

@ -0,0 +1,149 @@
/* ============================================================================
* The VS Code half of Ludic support.
*
* There is deliberately very little here. Highlighting comes from the TextMate
* grammar in syntaxes/ (shared, unmodified, with every other editor), and
* everything else — completion, hover, diagnostics, formatting, symbols —
* comes from `ludic-lsp`, the same binary JetBrains and Neovim talk to. This
* file's whole job is finding that binary and keeping it alive.
* ==========================================================================*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const vscode = require('vscode');
const { LanguageClient, TransportKind } = require('vscode-languageclient/node');
let client = null;
let output = null;
/** Where the language server might be, in the order we should look. */
function resolveServer(folders) {
const configured = vscode.workspace.getConfiguration('ludic').get('server.path');
if (configured) return expand(configured);
const names = ['ludic-lsp', 'ludic-lsp.exe'];
for (const folder of folders) {
const root = folder.uri.fsPath;
for (const rel of ['build', 'tools/build', 'bin', '.']) {
for (const name of names) {
const p = path.join(root, rel, name);
if (isExecutable(p)) return p;
}
}
}
// Fall back to PATH; spawning by bare name lets the OS resolve it.
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
for (const name of names) {
const p = path.join(dir, name);
if (isExecutable(p)) return p;
}
}
return null;
}
function resolveCompiler(folders) {
const configured = vscode.workspace.getConfiguration('ludic').get('compilerPath');
if (configured) return expand(configured);
for (const folder of folders) {
const p = path.join(folder.uri.fsPath, 'build', 'ludicc');
if (isExecutable(p)) return p;
}
return '';
}
function expand(p) {
return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
}
function isExecutable(p) {
try {
fs.accessSync(p, fs.constants.X_OK);
return fs.statSync(p).isFile();
} catch {
return false;
}
}
async function start(context) {
const cfg = vscode.workspace.getConfiguration('ludic');
if (!cfg.get('server.enabled')) return;
const folders = vscode.workspace.workspaceFolders || [];
const server = resolveServer(folders);
if (!server) {
// Not an error worth a modal: the grammar still highlights, and plenty of
// people open a .ludic file without having built the toolchain.
output.appendLine(
'ludic-lsp not found. Build it with tools/build-tools.sh, or set "ludic.server.path". ' +
'Syntax highlighting works without it; completion and diagnostics do not.'
);
return;
}
output.appendLine(`starting ${server}`);
const serverOptions = {
run: { command: server, args: ['--stdio'], transport: TransportKind.stdio },
debug: { command: server, args: ['--stdio'], transport: TransportKind.stdio }
};
const clientOptions = {
// Markdown is included on purpose: ```ludic fences in a .md get the same
// diagnostics, hover and semantic highlighting as a real source file.
documentSelector: [
{ scheme: 'file', language: 'ludic' },
{ scheme: 'file', language: 'markdown' }
],
synchronize: {
fileEvents: vscode.workspace.createFileSystemWatcher('**/*.ludic'),
configurationSection: 'ludic'
},
outputChannel: output,
initializationOptions: {
compilerPath: resolveCompiler(folders),
compilerDiagnostics: cfg.get('compilerDiagnostics'),
indentSize: cfg.get('indentSize')
}
};
client = new LanguageClient('ludic', 'Ludic Language Server', serverOptions, clientOptions);
await client.start();
context.subscriptions.push(client);
}
async function stop() {
if (!client) return;
const c = client;
client = null;
await c.stop();
}
async function activate(context) {
output = vscode.window.createOutputChannel('Ludic');
context.subscriptions.push(output);
context.subscriptions.push(
vscode.commands.registerCommand('ludic.restartServer', async () => {
await stop();
await start(context);
vscode.window.setStatusBarMessage('Ludic: language server restarted', 3000);
}),
vscode.commands.registerCommand('ludic.showOutput', () => output.show())
);
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(async (e) => {
if (e.affectsConfiguration('ludic.server')) {
await stop();
await start(context);
}
})
);
await start(context);
}
function deactivate() {
return stop();
}
module.exports = { activate, deactivate };

View file

@ -0,0 +1,27 @@
{
"scopeName": "markdown.ludic.codeblock",
"comment": "Injects Ludic highlighting into ```ludic fences in Markdown. Any TextMate-based editor (VS Code, Sublime, JetBrains' TextMate bundles, Zed) picks this up; JetBrains' native Markdown support gets the same thing for free from the registered `Ludic` language ID, and ludic-lsp will also serve semantic tokens and diagnostics for the fence bodies in a .md file.",
"injectionSelector": "L:text.html.markdown",
"patterns": [{ "include": "#ludic-code-block" }],
"repository": {
"ludic-code-block": {
"begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(ludic)((\\s+|:|,|\\{|\\?)[^`~]*)?)$",
"name": "markup.fenced_code.block.markdown",
"end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$",
"beginCaptures": {
"3": { "name": "punctuation.definition.markdown" },
"4": { "name": "fenced_code.block.language.markdown" },
"5": { "name": "fenced_code.block.language.attributes.markdown" }
},
"endCaptures": { "3": { "name": "punctuation.definition.markdown" } },
"patterns": [
{
"begin": "(^|\\G)(\\s*)(.*)",
"while": "(^|\\G)(?!\\s*([`~]{3,})\\s*$)",
"contentName": "meta.embedded.block.ludic",
"patterns": [{ "include": "source.ludic" }]
}
]
}
}
}

View file

@ -0,0 +1,241 @@
{
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "Ludic",
"scopeName": "source.ludic",
"fileTypes": ["ludic"],
"comment": "Static grammar for Ludic. It is the fallback: when ludic-lsp is running, its semantic tokens refine everything here with real name resolution. Generated shapes are kept in sync with tools/ludic-tools/ludic_syntax.h.",
"patterns": [
{ "include": "#comment" },
{ "include": "#declaration" },
{ "include": "#query" },
{ "include": "#string" },
{ "include": "#number" },
{ "include": "#annotation" },
{ "include": "#keyword" },
{ "include": "#widget" },
{ "include": "#builtin" },
{ "include": "#member" },
{ "include": "#call" },
{ "include": "#typename" },
{ "include": "#operator" }
],
"repository": {
"comment": {
"match": "#.*$",
"name": "comment.line.number-sign.ludic"
},
"string": {
"patterns": [
{
"name": "string.quoted.double.ludic",
"begin": "\"",
"end": "\"",
"beginCaptures": { "0": { "name": "punctuation.definition.string.begin.ludic" } },
"endCaptures": { "0": { "name": "punctuation.definition.string.end.ludic" } },
"patterns": [{ "name": "constant.character.escape.ludic", "match": "\\\\." }]
},
{
"name": "string.quoted.single.ludic",
"match": "'(\\\\.|[^'\\\\])'"
}
]
},
"number": {
"patterns": [
{ "name": "constant.numeric.hex.ludic", "match": "\\b0[xX][0-9a-fA-F]+\\b" },
{ "name": "constant.numeric.fixed.ludic", "match": "\\b[0-9]+\\.[0-9]+\\b" },
{ "name": "constant.numeric.integer.ludic", "match": "\\b[0-9]+\\b" }
]
},
"annotation": {
"name": "entity.name.function.decorator.ludic",
"match": "@[A-Za-z_][A-Za-z0-9_]*"
},
"declaration": {
"patterns": [
{
"match": "\\b(game|module)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.unit.ludic" },
"2": { "name": "entity.name.type.unit.ludic" }
}
},
{
"match": "\\b(component)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.component.ludic" },
"2": { "name": "entity.name.type.component.ludic" }
}
},
{
"match": "\\b(archetype)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.archetype.ludic" },
"2": { "name": "entity.name.type.archetype.ludic" }
}
},
{
"match": "\\b(scene)\\s+([A-Za-z_][A-Za-z0-9_]*)(\\s+start)?",
"captures": {
"1": { "name": "storage.type.scene.ludic" },
"2": { "name": "entity.name.type.scene.ludic" },
"3": { "name": "keyword.other.start.ludic" }
}
},
{
"match": "\\b(layer|ui)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.ludic" },
"2": { "name": "entity.name.type.ludic" }
}
},
{
"match": "\\b(edge\\s+)?(system)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.modifier.edge.ludic" },
"2": { "name": "storage.type.system.ludic" },
"3": { "name": "entity.name.function.system.ludic" }
}
},
{
"match": "\\b(export\\s+|pure\\s+)?(extern\\s+)?(fn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.modifier.ludic" },
"2": { "name": "storage.modifier.extern.ludic" },
"3": { "name": "storage.type.function.ludic" },
"4": { "name": "entity.name.function.ludic" }
}
},
{
"match": "\\b(const|var|let)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.ludic" },
"2": { "name": "variable.other.definition.ludic" }
}
},
{
"match": "\\b(state)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.state.ludic" },
"2": { "name": "entity.name.constant.state.ludic" }
}
},
{
"match": "\\b(import)\\s+(?=\")",
"captures": { "1": { "name": "keyword.control.import.ludic" } }
},
{
"match": "\\b(phase)\\s+(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b",
"captures": {
"1": { "name": "keyword.other.clause.ludic" },
"2": { "name": "constant.language.phase.ludic" }
}
},
{
"match": "\\b(enter)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.enter.ludic" },
"2": { "name": "entity.name.type.scene.ludic" }
}
},
{
"match": "\\b(spawn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.spawn.ludic" },
"2": { "name": "entity.name.type.archetype.ludic" }
}
}
]
},
"query": {
"comment": "Inside a query's brackets a bare word names a component and {Word} is a filter that does not bind.",
"begin": "\\b(query)\\s*(\\[)",
"beginCaptures": {
"1": { "name": "keyword.other.query.ludic" },
"2": { "name": "punctuation.section.brackets.begin.ludic" }
},
"end": "(\\])",
"endCaptures": { "1": { "name": "punctuation.section.brackets.end.ludic" } },
"patterns": [
{
"match": "(\\{)\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*(\\})",
"captures": {
"1": { "name": "punctuation.section.braces.begin.ludic" },
"2": { "name": "entity.name.type.tag.ludic" },
"3": { "name": "punctuation.section.braces.end.ludic" }
}
},
{ "name": "entity.name.type.component.ludic", "match": "\\b[A-Za-z_][A-Za-z0-9_]*\\b" },
{ "name": "punctuation.separator.ludic", "match": "," }
]
},
"keyword": {
"patterns": [
{ "name": "keyword.control.ludic", "match": "\\b(if|else|when|while|for|in|match|machine|become|enter|return|spawn|despawn|where|break|continue|new)\\b" },
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes|needs|uses|requires|ensures|invariant|effects|on)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|export|pure|extern|start|edge)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(game|module|component|archetype|ui|scene|layer|const|var|let|fn|system|state)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|fixed|bool|entity|str|ptr|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }
]
},
"widget": {
"patterns": [
{
"comment": "widget types, recognised by the props or the child block that follow them",
"name": "support.class.widget.ludic",
"match": "\\b(panel|col|row|label|button|image|spacer)\\b(?=\\s*(\\{|[a-z][A-Za-z0-9_]*\\s*=|$))"
},
{
"comment": "widget props are written tight (id=Root); an assignment is written x = 1",
"match": "\\b([a-z][A-Za-z0-9_]*)(=)(?!=)",
"captures": {
"1": { "name": "variable.parameter.widget.ludic" },
"2": { "name": "keyword.operator.assignment.ludic" }
}
}
]
},
"builtin": {
"patterns": [
{
"comment": "the runtime surface — every name here resolves to rt_<name> in runtime/native",
"name": "support.function.builtin.ludic",
"match": "\\b(min|max|abs|clamp|seed|rng_range|rng_chance|fx|flr|map_size|map_row|tile|clear|present|fill_rect|frame_rect|put_px|text|text_int|font_load|text_ttf|text_w|text_h|image_load|draw_image|draw_image_scaled|draw_9slice|load_png|load_sprites|draw_sprite|draw_sprite_scaled|ui_build|ui_open|ui_tick|ui_render|ui_clicked|ui_set_text|ui_set_int|ui_focus|ui_focused|ui_visible|key|reg|setreg|self|save|load|status|print_int|quit)\\b(?=\\s*\\()"
},
{
"comment": "compiler intrinsics — these lower straight to libc or the OS",
"name": "support.function.intrinsic.ludic",
"match": "\\b(mem_alloc|mem_realloc|mem_free|mem_copy|mem_set|peek8|poke8|peek32|poke32|peekp|pokep|peekf|pokef|ptr_add|ptr_null|ptr_is_null|as_fixed|as_int|file_open|file_read|file_write|file_seek|file_tell|file_close|read_byte|write_byte|print_str|str_len|shl|shr|band|bor|bxor|bnot|os_exit|os_time|os_argc|os_arg|file_stderr|is_windowed|game_title|win_open|win_poll|win_present|win_running|win_close)\\b(?=\\s*\\()"
}
]
},
"member": {
"match": "(\\.)([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "punctuation.accessor.ludic" },
"2": { "name": "variable.other.property.ludic" }
}
},
"call": {
"match": "\\b([A-Za-z_][A-Za-z0-9_]*)\\s*(?=\\()",
"name": "entity.name.function.call.ludic"
},
"typename": {
"comment": "components, archetypes, scenes and UI handles are Capitalised by convention",
"name": "entity.name.type.ludic",
"match": "\\b[A-Z][A-Za-z0-9_]*\\b"
},
"operator": {
"patterns": [
{ "name": "keyword.operator.arrow.ludic", "match": "->|=>" },
{ "name": "keyword.operator.range.ludic", "match": "\\.\\." },
{ "name": "keyword.operator.comparison.ludic", "match": "==|!=|<=|>=|<|>" },
{ "name": "keyword.operator.assignment.ludic", "match": "\\+=|-=|\\*=|/=|=" },
{ "name": "keyword.operator.arithmetic.ludic", "match": "[+\\-*/%]" }
]
}
}
}

View file

@ -0,0 +1,39 @@
# Ludic for Zed
Zed extensions want a Tree-sitter grammar for highlighting, which this toolchain
deliberately does not ship — the vocabulary lives in one C header and is exposed
through the language server's semantic tokens instead, so there is no second
grammar to keep honest.
That is enough for Zed: point it at `ludic-lsp` and highlighting, completion,
diagnostics and formatting all arrive over LSP.
Add to `~/.config/zed/settings.json`:
```json
{
"lsp": {
"ludic-lsp": {
"binary": { "path": "/path/to/gpp/build/ludic-lsp", "arguments": ["--stdio"] },
"initialization_options": {
"compilerPath": "/path/to/gpp/build/ludicc",
"compilerDiagnostics": true,
"indentSize": 2
}
}
},
"file_types": { "Ludic": ["ludic"] },
"languages": {
"Ludic": {
"language_servers": ["ludic-lsp"],
"tab_size": 2,
"hard_tabs": false,
"format_on_save": "on"
}
}
}
```
If you would rather have static highlighting too, the TextMate grammar in
`tools/editors/shared/ludic.tmLanguage.json` can be dropped into a Zed extension
that declares a `grammars` entry — but it is not required.