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

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)
}
}