Baseline: Ludic compiler + toolchain, Phase 1 syntax fixes complete
Self-hosted compiler (selfhost/*.ludic), runtime, examples, editor tooling, and docs. Phase 1 of the syntax-redesign cohesion pass has landed: edge-system fix, signature-query, when-alias, and the documentation truth-pass. Suite green (14/14), C-free bootstrap fixpoint holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
985f9ad8f2
418 changed files with 39065 additions and 0 deletions
58
tools/editors/jetbrains/README.md
Normal file
58
tools/editors/jetbrains/README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Ludic for JetBrains IDEs
|
||||
|
||||
Works in IntelliJ IDEA (Community and Ultimate), CLion, GoLand, PyCharm, Rider,
|
||||
WebStorm — anything on the IntelliJ Platform 2023.2 through 2026.2
|
||||
(`since-build` 232, `until-build` 262.*).
|
||||
|
||||
`until-build` 262 is deliberately ahead of the newest released platform — 2025.3
|
||||
is build **253**, and no 262 IDE exists yet. That headroom is the point: an
|
||||
`until-build` pinned to today's IDE gets the plugin auto-disabled the moment a
|
||||
user upgrades.
|
||||
|
||||
Two things are machine-checked rather than assumed, because a plugin that
|
||||
packages cleanly can still fail to load:
|
||||
|
||||
- `./gradlew verifyPlugin` runs JetBrains' own Plugin Verifier over the built
|
||||
artifact against 2025.3 and reports **Compatible**, with no deprecated or
|
||||
scheduled-for-removal API usages.
|
||||
- `./gradlew runIde` boots a real IDEA 2025.3 with the plugin installed. The
|
||||
sandbox log line to look for is
|
||||
`Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.0.0)` in
|
||||
`.intellijPlatform/sandbox/Ludic/IC-2025.3/log/idea.log`.
|
||||
|
||||
**If the plugin does not appear in Settings -> Plugins after installing**, the
|
||||
cause is almost always that the IDE's build number is outside
|
||||
`since-build`..`until-build`. IntelliJ installs the declared dependency (LSP4IJ)
|
||||
from Marketplace first and only then rejects the incompatible plugin, so the
|
||||
symptom is "LSP4IJ got installed, mine did not". Check **Help -> About** for the
|
||||
build number and widen `pluginUntilBuild` in `gradle.properties` to match.
|
||||
|
||||
## Building
|
||||
|
||||
Gradle must run on **JDK 17–21**. A newer JDK fails with a bare version number
|
||||
as the error message, which is not obvious the first time you see it:
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
|
||||
cd tools/editors/jetbrains
|
||||
./gradlew buildPlugin # -> build/distributions/Ludic-1.0.0.zip
|
||||
./gradlew runIde # try it in a sandbox IDE
|
||||
```
|
||||
|
||||
The first build downloads an IntelliJ IDEA Community distribution (over 1 GB),
|
||||
so expect it to take a while; later builds take seconds. Once the cache is warm,
|
||||
|
||||
```bash
|
||||
LUDIC_TEST_JETBRAINS=1 ./tools/test-tools.sh
|
||||
```
|
||||
|
||||
includes the plugin build in the toolchain's own test run.
|
||||
|
||||
Install the zip with **Settings -> Plugins -> ⚙ -> Install Plugin from Disk**.
|
||||
LSP4IJ is a required dependency; the IDE offers to install it for you.
|
||||
|
||||
## Settings
|
||||
|
||||
**Settings -> Languages & Frameworks -> Ludic** — the path to `ludic-lsp` and to
|
||||
`ludicc`. Both default to `build/` under the project root, which is where
|
||||
`tools/build-tools.sh` puts them.
|
||||
96
tools/editors/jetbrains/build.gradle.kts
Normal file
96
tools/editors/jetbrains/build.gradle.kts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
|
||||
import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
|
||||
|
||||
plugins {
|
||||
id("java")
|
||||
id("org.jetbrains.kotlin.jvm") version "2.4.10"
|
||||
// The IntelliJ Platform Gradle Plugin 2.x. The 1.x line (org.jetbrains.intellij)
|
||||
// is superseded and does not run on current Gradle.
|
||||
id("org.jetbrains.intellij.platform") version "2.18.1"
|
||||
}
|
||||
|
||||
group = providers.gradleProperty("pluginGroup").get()
|
||||
version = providers.gradleProperty("pluginVersion").get()
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
intellijPlatform {
|
||||
defaultRepositories()
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
intellijPlatform {
|
||||
// Community, deliberately: the plugin must work in the free IDEs, which
|
||||
// is also why the language server is wired up through LSP4IJ rather
|
||||
// than com.intellij.platform.lsp (Ultimate and friends only).
|
||||
// useInstaller = false takes the Maven artifact (com.jetbrains.intellij.idea:ideaIC)
|
||||
// instead of the .dmg/.tar.gz installer. Since 2025.x the Community installer is
|
||||
// published as `idea-<version>`, not `ideaIC-<version>`, and the installer path
|
||||
// cannot find it; the Maven coordinate is unaffected by that rename.
|
||||
intellijIdeaCommunity(providers.gradleProperty("platformVersion").get()) {
|
||||
useInstaller = false
|
||||
}
|
||||
bundledPlugin("org.intellij.plugins.markdown")
|
||||
plugin("com.redhat.devtools.lsp4ij", providers.gradleProperty("lsp4ijVersion").get())
|
||||
// JetBrains' Plugin Verifier CLI, used by the verifyPlugin task below
|
||||
pluginVerifier()
|
||||
// headless fixtures for the platform tests in src/test
|
||||
testFramework(TestFrameworkType.Platform)
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
intellijPlatform {
|
||||
pluginConfiguration {
|
||||
name = providers.gradleProperty("pluginName")
|
||||
version = providers.gradleProperty("pluginVersion")
|
||||
ideaVersion {
|
||||
sinceBuild = providers.gradleProperty("pluginSinceBuild")
|
||||
untilBuild = providers.gradleProperty("pluginUntilBuild")
|
||||
}
|
||||
}
|
||||
// Compatibility is claimed up to pluginUntilBuild, so it gets checked
|
||||
// against that IDE rather than assumed: `./gradlew verifyPlugin` runs
|
||||
// JetBrains' own Plugin Verifier over the built artifact.
|
||||
pluginVerification {
|
||||
ides {
|
||||
// The platform this is compiled against, which is also the newest
|
||||
// that exists (2025.3 = build 253). until-build claims 262, which
|
||||
// no released IDE reaches yet.
|
||||
create(IntelliJPlatformType.IntellijIdeaCommunity, providers.gradleProperty("platformVersion").get()) {
|
||||
useInstaller = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildSearchableOptions = false
|
||||
|
||||
// The platform's bytecode instrumenter only adds @NotNull assertions and
|
||||
// compiles .form files. This plugin is Kotlin with no GUI forms, so it has
|
||||
// nothing to instrument — and skipping it avoids the instrumenter's JBR
|
||||
// probing, which fails on a plain JDK layout.
|
||||
instrumentCode = false
|
||||
}
|
||||
|
||||
// `./gradlew runIde` opens the Ludic repository itself, so the sandbox has real
|
||||
// .ludic files to look at and build/ludic-lsp sitting where the plugin expects.
|
||||
// Three levels up: jetbrains -> editors -> tools -> repository root. Getting
|
||||
// this wrong points the IDE at tools/, where no build/ludic-lsp exists, and the
|
||||
// language server then cannot find itself.
|
||||
val repoRoot: File = rootProject.projectDir.parentFile.parentFile.parentFile
|
||||
tasks.named<JavaExec>("runIde") {
|
||||
args = listOf(repoRoot.absolutePath)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnit()
|
||||
testLogging {
|
||||
events("passed", "failed", "skipped")
|
||||
showStandardStreams = false
|
||||
}
|
||||
}
|
||||
26
tools/editors/jetbrains/gradle.properties
Normal file
26
tools/editors/jetbrains/gradle.properties
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
pluginGroup = io.ludic.ide
|
||||
pluginName = Ludic
|
||||
pluginVersion = 1.0.0
|
||||
|
||||
# 2023.2 is the first build with the platform APIs this plugin uses. LSP4IJ (not
|
||||
# the paid-IDE LSP API) supplies the language-server bridge, so Community
|
||||
# editions are supported as well as Ultimate.
|
||||
pluginSinceBuild = 232
|
||||
# Deliberately ahead of the newest released platform (2025.3 is build 253): an
|
||||
# until-build that stops at today's IDE gets the plugin auto-disabled the moment
|
||||
# a user upgrades. 262 buys roughly three release cycles of headroom. The APIs
|
||||
# this plugin uses are the stable ones (LexerBase, SyntaxHighlighter,
|
||||
# ParserDefinition, FoldingBuilder), and everything project-wide is delegated to
|
||||
# ludic-lsp over a versioned protocol rather than to platform internals.
|
||||
pluginUntilBuild = 262.*
|
||||
|
||||
platformVersion = 2025.3
|
||||
lsp4ijVersion = 0.20.1
|
||||
|
||||
# Gradle itself must run on JDK 17-21; a newer JDK reports only its version
|
||||
# number as the failure. The Kotlin toolchain below is pinned separately.
|
||||
# The wrapper is Gradle 9.x because IntelliJ Platform Gradle Plugin 2.13+
|
||||
# requires it.
|
||||
org.gradle.jvmargs = -Xmx2g
|
||||
org.gradle.caching = true
|
||||
kotlin.stdlib.default.dependency = false
|
||||
BIN
tools/editors/jetbrains/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
tools/editors/jetbrains/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
tools/editors/jetbrains/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
tools/editors/jetbrains/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
248
tools/editors/jetbrains/gradlew
vendored
Executable file
248
tools/editors/jetbrains/gradlew
vendored
Executable file
|
|
@ -0,0 +1,248 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
93
tools/editors/jetbrains/gradlew.bat
vendored
Normal file
93
tools/editors/jetbrains/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
2
tools/editors/jetbrains/settings.gradle.kts
Normal file
2
tools/editors/jetbrains/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// The distribution is named after this, so it is the plugin name, not a slug.
|
||||
rootProject.name = "Ludic"
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.openapi.options.Configurable
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.TextFieldWithBrowseButton
|
||||
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
|
||||
import com.intellij.ui.components.JBCheckBox
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.FormBuilder
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JSpinner
|
||||
import javax.swing.SpinnerNumberModel
|
||||
|
||||
/** Settings -> Languages and Frameworks -> Ludic */
|
||||
class LudicConfigurable(private val project: Project) : Configurable {
|
||||
private val serverField = TextFieldWithBrowseButton()
|
||||
private val compilerField = TextFieldWithBrowseButton()
|
||||
private val diagnostics = JBCheckBox("Report compiler errors on save")
|
||||
private val indent = JSpinner(SpinnerNumberModel(2, 1, 8, 1))
|
||||
private var panel: JPanel? = null
|
||||
|
||||
override fun getDisplayName() = "Ludic"
|
||||
|
||||
override fun createComponent(): JComponent {
|
||||
// The title travels on the descriptor now; the four-argument overload
|
||||
// that took it separately is scheduled for removal.
|
||||
serverField.addBrowseFolderListener(
|
||||
project,
|
||||
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludic-lsp")
|
||||
)
|
||||
compilerField.addBrowseFolderListener(
|
||||
project,
|
||||
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludicc")
|
||||
)
|
||||
|
||||
panel = FormBuilder.createFormBuilder()
|
||||
.addLabeledComponent(JBLabel("Language server (ludic-lsp):"), serverField, 1, false)
|
||||
.addComponentToRightColumn(
|
||||
JBLabel("Leave empty to use build/ludic-lsp under the project, then PATH.")
|
||||
)
|
||||
.addLabeledComponent(JBLabel("Compiler (ludicc):"), compilerField, 1, false)
|
||||
.addComponentToRightColumn(
|
||||
JBLabel("Used for authoritative diagnostics. Empty means build/ludicc.")
|
||||
)
|
||||
.addComponent(diagnostics)
|
||||
.addLabeledComponent(JBLabel("Formatter indent:"), indent, 1, false)
|
||||
.addComponentFillVertically(JPanel(), 0)
|
||||
.panel
|
||||
reset()
|
||||
return panel!!
|
||||
}
|
||||
|
||||
private fun settings() = LudicSettings.of(project)
|
||||
|
||||
override fun isModified(): Boolean {
|
||||
val s = settings()
|
||||
return serverField.text != s.serverPath ||
|
||||
compilerField.text != s.compilerPath ||
|
||||
diagnostics.isSelected != s.compilerDiagnostics ||
|
||||
(indent.value as Int) != s.indentSize
|
||||
}
|
||||
|
||||
override fun apply() {
|
||||
val s = settings()
|
||||
s.serverPath = serverField.text.trim()
|
||||
s.compilerPath = compilerField.text.trim()
|
||||
s.compilerDiagnostics = diagnostics.isSelected
|
||||
s.indentSize = indent.value as Int
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
val s = settings()
|
||||
serverField.text = s.serverPath
|
||||
compilerField.text = s.compilerPath
|
||||
diagnostics.isSelected = s.compilerDiagnostics
|
||||
indent.value = s.indentSize
|
||||
}
|
||||
|
||||
override fun disposeUIResources() {
|
||||
panel = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lang.BracePair
|
||||
import com.intellij.lang.Commenter
|
||||
import com.intellij.lang.PairedBraceMatcher
|
||||
import com.intellij.lang.folding.FoldingBuilderEx
|
||||
import com.intellij.lang.folding.FoldingDescriptor
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.tree.IElementType
|
||||
|
||||
/**
|
||||
* The small editor mechanics the platform expects a language to provide: how to
|
||||
* comment a line, which delimiters pair up, and what can be folded. None of
|
||||
* this needs the language server, so none of it waits for one.
|
||||
*/
|
||||
class LudicCommenter : Commenter {
|
||||
override fun getLineCommentPrefix() = "# "
|
||||
override fun getBlockCommentPrefix(): String? = null
|
||||
override fun getBlockCommentSuffix(): String? = null
|
||||
override fun getCommentedBlockCommentPrefix(): String? = null
|
||||
override fun getCommentedBlockCommentSuffix(): String? = null
|
||||
}
|
||||
|
||||
class LudicBraceMatcher : PairedBraceMatcher {
|
||||
override fun getPairs(): Array<BracePair> = PAIRS
|
||||
|
||||
override fun isPairedBracesAllowedBeforeType(lbraceType: IElementType, next: IElementType?): Boolean = true
|
||||
|
||||
override fun getCodeConstructStart(file: PsiFile?, openingBraceOffset: Int): Int = openingBraceOffset
|
||||
|
||||
private companion object {
|
||||
// Structural braces only: a record literal shares the token type, so
|
||||
// marking them all structural would make the "matched brace" gutter
|
||||
// noisy inside `spawn Hero { Pos = { x = 1 } }`.
|
||||
val PAIRS = arrayOf(
|
||||
BracePair(LudicTokens.BRACE, LudicTokens.BRACE, true),
|
||||
BracePair(LudicTokens.BRACKET, LudicTokens.BRACKET, false),
|
||||
BracePair(LudicTokens.PAREN, LudicTokens.PAREN, false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds every braced block. Working off the lexer's token stream rather than a
|
||||
* PSI tree keeps this correct for a flat PSI, and it degrades gracefully on a
|
||||
* file with unbalanced braces, which is most files while they are being typed.
|
||||
*/
|
||||
class LudicFoldingBuilder : FoldingBuilderEx(), DumbAware {
|
||||
override fun buildFoldRegions(root: PsiElement, document: Document, quick: Boolean): Array<FoldingDescriptor> {
|
||||
val text = root.containingFile?.text ?: return emptyArray()
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(text, 0, text.length, 0)
|
||||
|
||||
val opens = ArrayDeque<Int>()
|
||||
val regions = mutableListOf<FoldingDescriptor>()
|
||||
val node = root.node ?: return emptyArray()
|
||||
|
||||
while (lexer.tokenType != null) {
|
||||
if (lexer.tokenType == LudicTokens.BRACE) {
|
||||
when (text[lexer.tokenStart]) {
|
||||
'{' -> opens.addLast(lexer.tokenStart)
|
||||
'}' -> {
|
||||
val open = opens.removeLastOrNull()
|
||||
if (open != null) {
|
||||
val end = lexer.tokenEnd
|
||||
// only worth folding when it actually spans lines
|
||||
if (document.getLineNumber(end) > document.getLineNumber(open)) {
|
||||
regions += FoldingDescriptor(node, TextRange(open, end))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lexer.advance()
|
||||
}
|
||||
return regions.toTypedArray()
|
||||
}
|
||||
|
||||
override fun getPlaceholderText(node: com.intellij.lang.ASTNode) = "{...}"
|
||||
override fun isCollapsedByDefault(node: com.intellij.lang.ASTNode) = false
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lang.Language
|
||||
import com.intellij.openapi.fileTypes.LanguageFileType
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import javax.swing.Icon
|
||||
|
||||
/**
|
||||
* The language ID matters more than it looks: the IDE's Markdown support
|
||||
* resolves a fenced block's info string ("```ludic") against registered
|
||||
* language IDs, so registering as "Ludic" is what makes fences in .md files
|
||||
* highlight, with no Markdown-specific code on our side.
|
||||
*/
|
||||
object LudicLanguage : Language("Ludic") {
|
||||
private fun readResolve(): Any = LudicLanguage
|
||||
override fun getDisplayName() = "Ludic"
|
||||
override fun isCaseSensitive() = true
|
||||
}
|
||||
|
||||
object LudicIcons {
|
||||
val FILE: Icon = IconLoader.getIcon("/icons/ludic.svg", LudicIcons::class.java)
|
||||
}
|
||||
|
||||
object LudicFileType : LanguageFileType(LudicLanguage) {
|
||||
private fun readResolve(): Any = LudicFileType
|
||||
override fun getName() = "Ludic"
|
||||
override fun getDescription() = "Ludic source"
|
||||
override fun getDefaultExtension() = "ludic"
|
||||
override fun getIcon(): Icon = LudicIcons.FILE
|
||||
override fun getCharset(file: VirtualFile, content: ByteArray): String = "UTF-8"
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lexer.LexerBase
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.tree.IElementType
|
||||
|
||||
/**
|
||||
* A hand-written lexer matching tools/ludic-tools/ludic_syntax.h token for
|
||||
* token. Hand-written rather than generated because the grammar is small, the
|
||||
* rules are the ones the C lexer already implements, and keeping the two
|
||||
* readable side by side is worth more here than a JFlex spec.
|
||||
*
|
||||
* It must never throw and never fail to advance: the IDE lexes half-typed text
|
||||
* constantly, so anything unrecognised becomes one BAD character and the scan
|
||||
* carries on.
|
||||
*/
|
||||
class LudicLexer : LexerBase() {
|
||||
private var buffer: CharSequence = ""
|
||||
private var startOffset = 0
|
||||
private var endOffset = 0
|
||||
private var tokenStart = 0
|
||||
private var tokenEnd = 0
|
||||
private var tokenType: IElementType? = null
|
||||
|
||||
override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) {
|
||||
this.buffer = buffer
|
||||
this.startOffset = startOffset
|
||||
this.endOffset = endOffset
|
||||
this.tokenStart = startOffset
|
||||
this.tokenEnd = startOffset
|
||||
advance()
|
||||
}
|
||||
|
||||
override fun getState() = 0
|
||||
override fun getTokenType(): IElementType? = tokenType
|
||||
override fun getTokenStart() = tokenStart
|
||||
override fun getTokenEnd() = tokenEnd
|
||||
override fun getBufferSequence() = buffer
|
||||
override fun getBufferEnd() = endOffset
|
||||
|
||||
private fun at(i: Int): Char = if (i in startOffset until endOffset) buffer[i] else ' '
|
||||
private fun isIdStart(c: Char) = c.isLetter() || c == '_'
|
||||
private fun isIdPart(c: Char) = c.isLetterOrDigit() || c == '_'
|
||||
private fun isSpace(c: Char) = c == ' ' || c == '\t' || c == '\r' || c == '\n'
|
||||
|
||||
override fun advance() {
|
||||
tokenStart = tokenEnd
|
||||
if (tokenStart >= endOffset) {
|
||||
tokenType = null
|
||||
return
|
||||
}
|
||||
|
||||
var i = tokenStart
|
||||
val c = at(i)
|
||||
|
||||
if (isSpace(c)) {
|
||||
while (i < endOffset && isSpace(at(i))) i++
|
||||
emit(i, TokenType.WHITE_SPACE)
|
||||
return
|
||||
}
|
||||
if (c == '#') {
|
||||
while (i < endOffset && at(i) != '\n') i++
|
||||
emit(i, LudicTokens.COMMENT)
|
||||
return
|
||||
}
|
||||
if (c == '"') {
|
||||
i++
|
||||
while (i < endOffset && at(i) != '"' && at(i) != '\n') {
|
||||
if (at(i) == '\\' && i + 1 < endOffset) i += 2 else i++
|
||||
}
|
||||
if (i < endOffset && at(i) == '"') i++
|
||||
emit(i, LudicTokens.STRING)
|
||||
return
|
||||
}
|
||||
if (c == '\'') {
|
||||
i++
|
||||
if (at(i) == '\\' && i + 1 < endOffset) i += 2 else if (i < endOffset && at(i) != '\n') i++
|
||||
if (i < endOffset && at(i) == '\'') i++
|
||||
emit(i, LudicTokens.CHAR)
|
||||
return
|
||||
}
|
||||
if (c.isDigit()) {
|
||||
if (c == '0' && (at(i + 1) == 'x' || at(i + 1) == 'X')) {
|
||||
i += 2
|
||||
while (i < endOffset && (at(i).isDigit() || at(i) in 'a'..'f' || at(i) in 'A'..'F')) i++
|
||||
} else {
|
||||
while (i < endOffset && at(i).isDigit()) i++
|
||||
if (at(i) == '.' && at(i + 1).isDigit()) {
|
||||
i++
|
||||
while (i < endOffset && at(i).isDigit()) i++
|
||||
}
|
||||
}
|
||||
emit(i, LudicTokens.NUMBER)
|
||||
return
|
||||
}
|
||||
// @deterministic is one token: the sigil never stands alone
|
||||
if (c == '@' && isIdStart(at(i + 1))) {
|
||||
i++
|
||||
while (i < endOffset && isIdPart(at(i))) i++
|
||||
emit(i, LudicTokens.ANNOTATION)
|
||||
return
|
||||
}
|
||||
if (isIdStart(c)) {
|
||||
while (i < endOffset && isIdPart(at(i))) i++
|
||||
val word = buffer.subSequence(tokenStart, i).toString()
|
||||
val type = when {
|
||||
word == "true" || word == "false" -> LudicTokens.BOOLEAN
|
||||
word in LudicVocabulary.PRIMITIVES -> LudicTokens.PRIMITIVE
|
||||
word in LudicVocabulary.PHASES -> LudicTokens.PHASE
|
||||
word in LudicVocabulary.DECL -> LudicTokens.DECL_KEYWORD
|
||||
word in LudicVocabulary.CLAUSE -> LudicTokens.CLAUSE
|
||||
word in LudicVocabulary.STMT -> LudicTokens.KEYWORD
|
||||
word in LudicVocabulary.BUILTINS -> LudicTokens.BUILTIN
|
||||
word[0].isUpperCase() -> LudicTokens.TYPE_NAME
|
||||
else -> LudicTokens.IDENTIFIER
|
||||
}
|
||||
emit(i, type)
|
||||
return
|
||||
}
|
||||
// two-character operators before one-character ones
|
||||
val two = if (i + 1 < endOffset) buffer.subSequence(i, i + 2).toString() else ""
|
||||
if (two in TWO_CHAR_OPS) {
|
||||
emit(i + 2, LudicTokens.OPERATOR)
|
||||
return
|
||||
}
|
||||
when (c) {
|
||||
'{', '}' -> { emit(i + 1, LudicTokens.BRACE); return }
|
||||
'[', ']' -> { emit(i + 1, LudicTokens.BRACKET); return }
|
||||
'(', ')' -> { emit(i + 1, LudicTokens.PAREN); return }
|
||||
',' -> { emit(i + 1, LudicTokens.COMMA); return }
|
||||
}
|
||||
if (c in SINGLE_CHAR_OPS) {
|
||||
emit(i + 1, LudicTokens.OPERATOR)
|
||||
return
|
||||
}
|
||||
emit(i + 1, LudicTokens.BAD)
|
||||
}
|
||||
|
||||
private fun emit(end: Int, type: IElementType) {
|
||||
tokenEnd = end
|
||||
tokenType = type
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val TWO_CHAR_OPS = setOf("->", "+=", "-=", "*=", "/=", "==", "!=", "<=", ">=", "&&", "||", "..", "=>")
|
||||
const val SINGLE_CHAR_OPS = "+-*/%<>=:.!@;"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lang.ASTNode
|
||||
import com.intellij.lang.ParserDefinition
|
||||
import com.intellij.lang.PsiParser
|
||||
import com.intellij.lexer.Lexer
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.psi.FileViewProvider
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.tree.IFileElementType
|
||||
import com.intellij.psi.tree.TokenSet
|
||||
import com.intellij.extapi.psi.ASTWrapperPsiElement
|
||||
import com.intellij.extapi.psi.PsiFileBase
|
||||
|
||||
/**
|
||||
* A flat PSI: every token becomes a leaf under the file node, with no tree
|
||||
* structure above it.
|
||||
*
|
||||
* That is enough for the two things the platform genuinely needs a parser for —
|
||||
* injecting Ludic into Markdown fences, and letting the editor treat a .ludic
|
||||
* file as a real language rather than plain text — while all actual analysis
|
||||
* stays in ludic-lsp, where it is shared with every other editor. Building a
|
||||
* second, divergent parser in Kotlin would mean two grammars to keep honest
|
||||
* against one compiler.
|
||||
*/
|
||||
class LudicParserDefinition : ParserDefinition {
|
||||
override fun createLexer(project: Project?): Lexer = LudicLexer()
|
||||
|
||||
override fun createParser(project: Project?): PsiParser = PsiParser { root, builder ->
|
||||
val mark = builder.mark()
|
||||
while (!builder.eof()) builder.advanceLexer()
|
||||
mark.done(root)
|
||||
builder.treeBuilt
|
||||
}
|
||||
|
||||
override fun getFileNodeType(): IFileElementType = LudicTokens.FILE
|
||||
override fun getCommentTokens(): TokenSet = TokenSet.create(LudicTokens.COMMENT)
|
||||
override fun getStringLiteralElements(): TokenSet = TokenSet.create(LudicTokens.STRING, LudicTokens.CHAR)
|
||||
override fun getWhitespaceTokens(): TokenSet = TokenSet.create(TokenType.WHITE_SPACE)
|
||||
// Only ever reached for composite nodes, and this grammar marks none beyond
|
||||
// the file itself — but the platform requires an answer, so give it a real
|
||||
// PSI element rather than something that would surprise it later.
|
||||
override fun createElement(node: ASTNode): PsiElement = ASTWrapperPsiElement(node)
|
||||
override fun createFile(viewProvider: FileViewProvider): PsiFile = LudicPsiFile(viewProvider)
|
||||
}
|
||||
|
||||
class LudicPsiFile(viewProvider: FileViewProvider) : PsiFileBase(viewProvider, LudicLanguage) {
|
||||
override fun getFileType() = LudicFileType
|
||||
override fun toString() = "Ludic file"
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.openapi.components.PersistentStateComponent
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.State
|
||||
import com.intellij.openapi.components.Storage
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.util.xmlb.XmlSerializerUtil
|
||||
import com.redhat.devtools.lsp4ij.LanguageServerFactory
|
||||
import com.redhat.devtools.lsp4ij.client.LanguageClientImpl
|
||||
import com.redhat.devtools.lsp4ij.server.CannotStartProcessException
|
||||
import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider
|
||||
import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Where to find the toolchain. Both paths may be left blank, in which case the
|
||||
* plugin looks under the project root for what tools/build-tools.sh produces —
|
||||
* which is the case for anyone working in the Ludic repository itself.
|
||||
*/
|
||||
@Service(Service.Level.PROJECT)
|
||||
@State(name = "LudicSettings", storages = [Storage("ludic.xml")])
|
||||
class LudicSettings : PersistentStateComponent<LudicSettings> {
|
||||
var serverPath: String = ""
|
||||
var compilerPath: String = ""
|
||||
var compilerDiagnostics: Boolean = true
|
||||
var indentSize: Int = 2
|
||||
|
||||
override fun getState(): LudicSettings = this
|
||||
override fun loadState(state: LudicSettings) = XmlSerializerUtil.copyBean(state, this)
|
||||
|
||||
companion object {
|
||||
fun of(project: Project): LudicSettings = project.service()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First existing executable among the usual places, or null.
|
||||
*
|
||||
* Takes the project root as a plain path rather than a Project so it can be
|
||||
* tested without booting an IDE — this lookup silently returning null is the
|
||||
* difference between a working language server and one that never starts.
|
||||
*/
|
||||
internal fun findExecutable(root: String?, configured: String, names: List<String>, subdirs: List<String>): String? {
|
||||
if (configured.isNotBlank()) {
|
||||
val f = File(expandHome(configured))
|
||||
return if (f.canExecute()) f.absolutePath else null
|
||||
}
|
||||
if (root == null) return searchPath(names)
|
||||
for (dir in subdirs) {
|
||||
for (name in names) {
|
||||
val f = File(File(root, dir), name)
|
||||
if (f.isFile && f.canExecute()) return f.absolutePath
|
||||
}
|
||||
}
|
||||
return searchPath(names)
|
||||
}
|
||||
|
||||
/** Fall back to PATH, so a system-wide install works with no configuration. */
|
||||
private fun searchPath(names: List<String>): String? {
|
||||
val path = System.getenv("PATH") ?: return null
|
||||
for (dir in path.split(File.pathSeparator)) {
|
||||
for (name in names) {
|
||||
val f = File(dir, name)
|
||||
if (f.isFile && f.canExecute()) return f.absolutePath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun expandHome(p: String) =
|
||||
if (p.startsWith("~")) System.getProperty("user.home") + p.substring(1) else p
|
||||
|
||||
class LudicConnectionProvider(private val project: Project) : ProcessStreamConnectionProvider() {
|
||||
|
||||
/**
|
||||
* Resolve at start(), not at construction.
|
||||
*
|
||||
* The provider is built when the project opens, which is usually before the
|
||||
* toolchain has been compiled. Resolving in an `init` block froze that
|
||||
* answer forever: build ludic-lsp afterwards and the server stayed dead
|
||||
* until the IDE was restarted, with `commands=[]` as the only clue.
|
||||
*/
|
||||
override fun start() {
|
||||
val settings = LudicSettings.of(project)
|
||||
val root = project.basePath
|
||||
setCommands(serverCommand(root, settings.serverPath)
|
||||
?: throw CannotStartProcessException(notFoundMessage(root, settings.serverPath)))
|
||||
root?.let { setWorkingDirectory(it) }
|
||||
super.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* What went wrong, in terms of the user's filesystem. "commands=[]" tells
|
||||
* nobody anything; this names every path that was tried.
|
||||
*/
|
||||
private fun notFoundMessage(root: String?, configured: String): String = buildString {
|
||||
if (configured.isNotBlank()) {
|
||||
append("The Ludic language server configured at '")
|
||||
append(configured)
|
||||
append("' is missing or not executable.")
|
||||
} else {
|
||||
append("Could not find the 'ludic-lsp' executable. Looked in:\n")
|
||||
SERVER_SUBDIRS.forEach { d ->
|
||||
append(" ")
|
||||
append(if (root != null) File(File(root, d), "ludic-lsp").path else "<no project root>/$d/ludic-lsp")
|
||||
append('\n')
|
||||
}
|
||||
append(" and on PATH.\n")
|
||||
}
|
||||
append("\nBuild it with ./tools/build-tools.sh from the repository root, ")
|
||||
append("or set the path in Settings | Languages & Frameworks | Ludic. ")
|
||||
append("Syntax highlighting works without it; completion and diagnostics do not.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to the server's `initialize`. The server can find `ludicc` on its
|
||||
* own, but telling it explicitly means the IDE's setting wins.
|
||||
*/
|
||||
override fun getInitializationOptions(rootUri: VirtualFile?): Any {
|
||||
val settings = LudicSettings.of(project)
|
||||
val compiler = findExecutable(
|
||||
project.basePath, settings.compilerPath,
|
||||
listOf("ludicc", "ludicc.exe"),
|
||||
COMPILER_SUBDIRS
|
||||
) ?: ""
|
||||
return mapOf(
|
||||
"compilerPath" to compiler,
|
||||
"compilerDiagnostics" to settings.compilerDiagnostics,
|
||||
"indentSize" to settings.indentSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact argv the language server is launched with, or null when the binary
|
||||
* cannot be found. Split out from start() so the command line itself is
|
||||
* testable — an empty or wrong command here is invisible until LSP4IJ reports
|
||||
* `commands=[]`, which says nothing about why.
|
||||
*/
|
||||
internal fun serverCommand(root: String?, configured: String): List<String>? {
|
||||
val exe = findExecutable(root, configured, listOf("ludic-lsp", "ludic-lsp.exe"), SERVER_SUBDIRS)
|
||||
return exe?.let { listOf(it, "--stdio") }
|
||||
}
|
||||
|
||||
/** Where the toolchain is looked for, relative to the project root. */
|
||||
internal val SERVER_SUBDIRS = listOf("build", "tools/build", "bin", ".")
|
||||
internal val COMPILER_SUBDIRS = listOf("build", "bin", ".")
|
||||
|
||||
class LudicLanguageServerFactory : LanguageServerFactory {
|
||||
override fun createConnectionProvider(project: Project): StreamConnectionProvider =
|
||||
LudicConnectionProvider(project)
|
||||
|
||||
override fun createLanguageClient(project: Project): LanguageClientImpl =
|
||||
LanguageClientImpl(project)
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lexer.Lexer
|
||||
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors as D
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey.createTextAttributesKey
|
||||
import com.intellij.openapi.editor.HighlighterColors
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighter
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighterBase
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighterFactory
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.psi.tree.IElementType
|
||||
|
||||
/**
|
||||
* Colours are all derived from the IDE's own semantic keys rather than hard
|
||||
* coded, so Ludic looks native in whatever theme the user has, dark or light,
|
||||
* without shipping a colour scheme.
|
||||
*/
|
||||
object LudicColors {
|
||||
val COMMENT = key("LUDIC_COMMENT", D.LINE_COMMENT)
|
||||
val STRING = key("LUDIC_STRING", D.STRING)
|
||||
val NUMBER = key("LUDIC_NUMBER", D.NUMBER)
|
||||
val KEYWORD = key("LUDIC_KEYWORD", D.KEYWORD)
|
||||
val DECL_KEYWORD = key("LUDIC_DECL_KEYWORD", D.KEYWORD)
|
||||
val CLAUSE = key("LUDIC_CLAUSE", D.METADATA)
|
||||
val PRIMITIVE = key("LUDIC_PRIMITIVE", D.KEYWORD)
|
||||
val PHASE = key("LUDIC_PHASE", D.STATIC_FIELD)
|
||||
val BOOLEAN = key("LUDIC_BOOLEAN", D.KEYWORD)
|
||||
val BUILTIN = key("LUDIC_BUILTIN", D.PREDEFINED_SYMBOL)
|
||||
val TYPE_NAME = key("LUDIC_TYPE", D.CLASS_NAME)
|
||||
val IDENTIFIER = key("LUDIC_IDENTIFIER", D.IDENTIFIER)
|
||||
val ANNOTATION = key("LUDIC_ANNOTATION", D.METADATA)
|
||||
val OPERATOR = key("LUDIC_OPERATOR", D.OPERATION_SIGN)
|
||||
val BRACE = key("LUDIC_BRACE", D.BRACES)
|
||||
val BRACKET = key("LUDIC_BRACKET", D.BRACKETS)
|
||||
val PAREN = key("LUDIC_PAREN", D.PARENTHESES)
|
||||
val COMMA = key("LUDIC_COMMA", D.COMMA)
|
||||
val BAD = createTextAttributesKey("LUDIC_BAD_CHARACTER", HighlighterColors.BAD_CHARACTER)
|
||||
|
||||
private fun key(name: String, base: TextAttributesKey) = createTextAttributesKey(name, base)
|
||||
}
|
||||
|
||||
class LudicSyntaxHighlighter : SyntaxHighlighterBase() {
|
||||
override fun getHighlightingLexer(): Lexer = LudicLexer()
|
||||
|
||||
override fun getTokenHighlights(tokenType: IElementType): Array<TextAttributesKey> = pack(
|
||||
when (tokenType) {
|
||||
LudicTokens.COMMENT -> LudicColors.COMMENT
|
||||
LudicTokens.STRING, LudicTokens.CHAR -> LudicColors.STRING
|
||||
LudicTokens.NUMBER -> LudicColors.NUMBER
|
||||
LudicTokens.KEYWORD -> LudicColors.KEYWORD
|
||||
LudicTokens.DECL_KEYWORD -> LudicColors.DECL_KEYWORD
|
||||
LudicTokens.CLAUSE -> LudicColors.CLAUSE
|
||||
LudicTokens.PRIMITIVE -> LudicColors.PRIMITIVE
|
||||
LudicTokens.PHASE -> LudicColors.PHASE
|
||||
LudicTokens.BOOLEAN -> LudicColors.BOOLEAN
|
||||
LudicTokens.BUILTIN -> LudicColors.BUILTIN
|
||||
LudicTokens.TYPE_NAME -> LudicColors.TYPE_NAME
|
||||
LudicTokens.IDENTIFIER -> LudicColors.IDENTIFIER
|
||||
LudicTokens.ANNOTATION -> LudicColors.ANNOTATION
|
||||
LudicTokens.OPERATOR -> LudicColors.OPERATOR
|
||||
LudicTokens.BRACE -> LudicColors.BRACE
|
||||
LudicTokens.BRACKET -> LudicColors.BRACKET
|
||||
LudicTokens.PAREN -> LudicColors.PAREN
|
||||
LudicTokens.COMMA -> LudicColors.COMMA
|
||||
LudicTokens.BAD -> LudicColors.BAD
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class LudicSyntaxHighlighterFactory : SyntaxHighlighterFactory() {
|
||||
override fun getSyntaxHighlighter(project: Project?, virtualFile: VirtualFile?): SyntaxHighlighter =
|
||||
LudicSyntaxHighlighter()
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.psi.tree.IElementType
|
||||
import com.intellij.psi.tree.IFileElementType
|
||||
|
||||
/**
|
||||
* One token type per thing the highlighter needs to tell apart. This is a
|
||||
* lexer-only language on the JetBrains side: the token set is the whole grammar
|
||||
* the platform sees, and ludic-lsp supplies everything structural.
|
||||
*/
|
||||
class LudicTokenType(debugName: String) : IElementType(debugName, LudicLanguage)
|
||||
|
||||
object LudicTokens {
|
||||
val FILE = IFileElementType(LudicLanguage)
|
||||
|
||||
val COMMENT = LudicTokenType("COMMENT")
|
||||
val STRING = LudicTokenType("STRING")
|
||||
val CHAR = LudicTokenType("CHAR")
|
||||
val NUMBER = LudicTokenType("NUMBER")
|
||||
val IDENTIFIER = LudicTokenType("IDENTIFIER")
|
||||
val TYPE_NAME = LudicTokenType("TYPE_NAME") // Capitalised: component, archetype, scene
|
||||
val KEYWORD = LudicTokenType("KEYWORD")
|
||||
val DECL_KEYWORD = LudicTokenType("DECL_KEYWORD") // game, component, system, fn, ...
|
||||
val CLAUSE = LudicTokenType("CLAUSE") // phase, query, reads, writes, ...
|
||||
val PRIMITIVE = LudicTokenType("PRIMITIVE") // int, fixed, bool, entity, str, ptr, void
|
||||
val PHASE = LudicTokenType("PHASE") // Start, Update, Render, ...
|
||||
val BOOLEAN = LudicTokenType("BOOLEAN")
|
||||
val BUILTIN = LudicTokenType("BUILTIN") // runtime surface + compiler intrinsics
|
||||
val ANNOTATION = LudicTokenType("ANNOTATION") // @deterministic
|
||||
val OPERATOR = LudicTokenType("OPERATOR")
|
||||
val BRACE = LudicTokenType("BRACE")
|
||||
val BRACKET = LudicTokenType("BRACKET")
|
||||
val PAREN = LudicTokenType("PAREN")
|
||||
val COMMA = LudicTokenType("COMMA")
|
||||
val BAD = LudicTokenType("BAD_CHARACTER")
|
||||
}
|
||||
|
||||
/**
|
||||
* The vocabulary, mirroring tools/ludic-tools/ludic_syntax.h. When the language
|
||||
* grows a keyword both sides change together; that header is the reference.
|
||||
*/
|
||||
object LudicVocabulary {
|
||||
val DECL = setOf(
|
||||
"game", "module", "import", "component", "struct", "archetype", "ui", "scene", "layer",
|
||||
"const", "var", "export", "fn", "pure", "extern", "system", "edge", "start", "on"
|
||||
)
|
||||
val CLAUSE = setOf(
|
||||
"phase", "query", "reads", "writes", "needs", "uses",
|
||||
"requires", "ensures", "invariant", "effects"
|
||||
)
|
||||
val STMT = setOf(
|
||||
"let", "return", "if", "else", "when", "while", "for", "in", "spawn", "despawn",
|
||||
"match", "machine", "state", "become", "enter", "where", "and", "or", "not",
|
||||
"break", "continue", "new"
|
||||
)
|
||||
val PRIMITIVES = setOf("int", "fixed", "bool", "entity", "str", "ptr", "void")
|
||||
val PHASES = setOf("Start", "Input", "FixedUpdate", "Update", "LateUpdate", "Render")
|
||||
val WIDGETS = setOf("panel", "col", "row", "label", "button", "image", "spacer")
|
||||
|
||||
val BUILTINS = setOf(
|
||||
"min", "max", "abs", "clamp", "seed", "rng_range", "rng_chance", "fx", "flr",
|
||||
"map_size", "map_row", "tile", "clear", "present", "fill_rect", "frame_rect",
|
||||
"put_px", "text", "text_int", "font_load", "text_ttf", "text_w", "text_h",
|
||||
"image_load", "draw_image", "draw_image_scaled", "draw_9slice", "load_png",
|
||||
"load_sprites", "draw_sprite", "draw_sprite_scaled", "ui_build", "ui_open",
|
||||
"ui_tick", "ui_render", "ui_clicked", "ui_set_text", "ui_set_int", "ui_focus",
|
||||
"ui_focused", "ui_visible", "key", "reg", "setreg", "self", "save", "load",
|
||||
"status", "print_int", "quit",
|
||||
// compiler intrinsics: the floor the Ludic-written runtime stands on
|
||||
"mem_alloc", "mem_realloc", "mem_free", "mem_copy", "mem_set", "peek8", "poke8", "peek32",
|
||||
"os_argc", "os_arg", "file_stderr",
|
||||
"poke32", "peekp", "pokep", "peekf", "pokef", "ptr_add", "ptr_null",
|
||||
"ptr_is_null", "as_fixed", "as_int", "file_open", "file_read", "file_write",
|
||||
"file_seek", "file_tell", "file_close", "read_byte", "write_byte", "print_str",
|
||||
"str_len", "shl", "shr", "band", "bor", "bxor", "bnot", "os_exit", "os_time",
|
||||
"is_windowed", "game_title", "win_open", "win_poll", "win_present",
|
||||
"win_running", "win_close"
|
||||
)
|
||||
|
||||
fun isKeyword(w: String) = w in DECL || w in CLAUSE || w in STMT
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<idea-plugin>
|
||||
<!--
|
||||
Loaded only when the Markdown plugin is present.
|
||||
|
||||
Nothing needs to be declared for ```ludic fences to highlight: the
|
||||
Markdown plugin resolves a fence's info string against registered language
|
||||
IDs, and io.ludic.ide.LudicLanguage registers as "Ludic". This file exists
|
||||
so that the dependency is expressed honestly — the feature does depend on
|
||||
the Markdown plugin — and as the place to hang Markdown-specific behaviour
|
||||
if the fence syntax ever needs more than the default.
|
||||
-->
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<idea-plugin>
|
||||
<id>io.ludic.ide</id>
|
||||
<name>Ludic</name>
|
||||
<vendor>Ludic</vendor>
|
||||
|
||||
<description><![CDATA[
|
||||
<p>Language support for <b>Ludic</b>, the AI-first, ECS-native game language.</p>
|
||||
<ul>
|
||||
<li>Syntax highlighting, brace matching, commenting and folding, implemented natively so a
|
||||
<code>.ludic</code> file is coloured with no external process.</li>
|
||||
<li>Completion, diagnostics, hover, go-to-definition, find usages, rename and formatting via
|
||||
the <code>ludic-lsp</code> language server, through LSP4IJ, so this works in Community
|
||||
editions as well as the paid IDEs.</li>
|
||||
<li>Errors from the Ludic compiler itself, reported on save.</li>
|
||||
<li>Highlighting of <code>```ludic</code> fenced blocks in Markdown.</li>
|
||||
</ul>
|
||||
<p>Set the paths under <b>Settings → Languages & Frameworks → Ludic</b>, or leave
|
||||
them blank to use <code>build/ludic-lsp</code> and <code>build/ludicc</code> under the
|
||||
project root.</p>
|
||||
]]></description>
|
||||
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
<depends>com.redhat.devtools.lsp4ij</depends>
|
||||
<!-- Optional: without it everything still works, we just do not get the
|
||||
Markdown fence injection that the platform's own Markdown plugin does
|
||||
for any registered language. -->
|
||||
<depends optional="true" config-file="ludic-markdown.xml">org.intellij.plugins.markdown</depends>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<fileType
|
||||
name="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicFileType"
|
||||
fieldName="INSTANCE"
|
||||
language="Ludic"
|
||||
extensions="ludic"/>
|
||||
|
||||
<lang.parserDefinition
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicParserDefinition"/>
|
||||
|
||||
<lang.syntaxHighlighterFactory
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicSyntaxHighlighterFactory"/>
|
||||
|
||||
<lang.commenter
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicCommenter"/>
|
||||
|
||||
<lang.braceMatcher
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicBraceMatcher"/>
|
||||
|
||||
<lang.foldingBuilder
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicFoldingBuilder"/>
|
||||
|
||||
<projectConfigurable
|
||||
parentId="language"
|
||||
instance="io.ludic.ide.LudicConfigurable"
|
||||
id="io.ludic.ide.settings"
|
||||
displayName="Ludic"/>
|
||||
</extensions>
|
||||
|
||||
<extensions defaultExtensionNs="com.redhat.devtools.lsp4ij">
|
||||
<server
|
||||
id="ludic"
|
||||
name="Ludic"
|
||||
factoryClass="io.ludic.ide.LudicLanguageServerFactory">
|
||||
<description><![CDATA[
|
||||
The Ludic language server. Built by <code>tools/build-tools.sh</code>.
|
||||
]]></description>
|
||||
</server>
|
||||
<languageMapping language="Ludic" serverId="ludic"/>
|
||||
<!-- Markdown is mapped too: the server understands ```ludic fences in a
|
||||
.md and reports diagnostics and semantic tokens for them. -->
|
||||
<languageMapping language="Markdown" serverId="ludic"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2c"/>
|
||||
<rect x="7" y="7" width="8" height="8" rx="1.5" fill="#ffe060"/>
|
||||
<rect x="17" y="7" width="8" height="8" rx="1.5" fill="#4a4a72"/>
|
||||
<rect x="7" y="17" width="8" height="8" rx="1.5" fill="#4a4a72"/>
|
||||
<rect x="17" y="17" width="8" height="8" rx="1.5" fill="#9aa0c8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 418 B |
|
|
@ -0,0 +1,155 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Locating `ludic-lsp` is the single point where the whole language server can
|
||||
* fail silently: if this returns null the provider starts with an empty command
|
||||
* list, LSP4IJ reports `commands=[]`, and nothing in that message points at the
|
||||
* cause. It shipped broken once for exactly that reason, so it gets tests.
|
||||
*/
|
||||
class FindExecutableTest {
|
||||
|
||||
@get:Rule
|
||||
val tmp = TemporaryFolder()
|
||||
|
||||
private val names = listOf("ludic-lsp", "ludic-lsp.exe")
|
||||
|
||||
private fun executableAt(vararg segments: String): File {
|
||||
val f = File(tmp.root, segments.joinToString(File.separator))
|
||||
f.parentFile.mkdirs()
|
||||
f.writeText("#!/bin/sh\n")
|
||||
check(f.setExecutable(true))
|
||||
return f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finds the binary that build-tools_sh produces`() {
|
||||
val exe = executableAt("build", "ludic-lsp")
|
||||
assertEquals(exe.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a project root that is a subdirectory of the repo finds nothing`() {
|
||||
// The bug that shipped: the IDE was opened on <repo>/tools, so the
|
||||
// lookup searched <repo>/tools/build and came back empty while
|
||||
// <repo>/build/ludic-lsp sat there the whole time.
|
||||
executableAt("build", "ludic-lsp")
|
||||
val subdir = File(tmp.root, "tools").apply { mkdirs() }
|
||||
assertNull(
|
||||
"must not silently resolve from an unrelated root",
|
||||
findExecutable(subdir.path, "", names, SERVER_SUBDIRS)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search order prefers build over the other candidates`() {
|
||||
executableAt("bin", "ludic-lsp")
|
||||
val preferred = executableAt("build", "ludic-lsp")
|
||||
assertEquals(preferred.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every documented subdirectory is actually searched`() {
|
||||
for (dir in SERVER_SUBDIRS) {
|
||||
val fresh = TemporaryFolder().apply { create() }
|
||||
val f = File(fresh.root, "$dir${File.separator}ludic-lsp")
|
||||
f.parentFile.mkdirs()
|
||||
f.writeText("#!/bin/sh\n")
|
||||
f.setExecutable(true)
|
||||
assertEquals(
|
||||
"subdir '$dir' is advertised but not searched",
|
||||
f.canonicalPath,
|
||||
File(findExecutable(fresh.root.path, "", names, SERVER_SUBDIRS)!!).canonicalPath
|
||||
)
|
||||
fresh.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicitly configured path wins over anything found by search`() {
|
||||
executableAt("build", "ludic-lsp")
|
||||
val custom = executableAt("elsewhere", "my-lsp")
|
||||
assertEquals(custom.absolutePath, findExecutable(tmp.root.path, custom.absolutePath, names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a configured path that does not exist fails instead of falling back`() {
|
||||
// Silently ignoring the setting and using a different binary would be
|
||||
// worse than failing: the user asked for a specific server.
|
||||
executableAt("build", "ludic-lsp")
|
||||
assertNull(findExecutable(tmp.root.path, File(tmp.root, "nope").path, names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-executable file is not accepted`() {
|
||||
val f = File(tmp.root, "build/ludic-lsp")
|
||||
f.parentFile.mkdirs()
|
||||
f.writeText("not executable")
|
||||
f.setExecutable(false)
|
||||
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a directory named like the binary is not accepted`() {
|
||||
File(tmp.root, "build/ludic-lsp").mkdirs()
|
||||
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null project root still allows a PATH lookup`() {
|
||||
// No exception, no crash: the default project has no basePath.
|
||||
findExecutable(null, "", names, SERVER_SUBDIRS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the real repository layout resolves`() {
|
||||
// Guards the arrangement the plugin actually ships against: run
|
||||
// ./tools/build-tools.sh and opening the repo root must just work.
|
||||
val repoRoot = File(System.getProperty("user.dir")).parentFile.parentFile.parentFile
|
||||
val built = File(repoRoot, "build/ludic-lsp")
|
||||
if (!built.isFile) return // toolchain not built in this checkout
|
||||
val found = findExecutable(repoRoot.path, "", names, SERVER_SUBDIRS)
|
||||
assertTrue("repo root must resolve to build/ludic-lsp", found != null)
|
||||
assertEquals(built.canonicalPath, File(found!!).canonicalPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The launch command itself. LSP4IJ only ever reports `commands=[...]`, so if
|
||||
* this list is wrong the failure surfaces with no explanation at all.
|
||||
*/
|
||||
class ServerCommandTest {
|
||||
|
||||
private val repoRoot: File =
|
||||
File(System.getProperty("user.dir")).parentFile.parentFile.parentFile
|
||||
|
||||
@Test
|
||||
fun `the repository root produces a runnable stdio command`() {
|
||||
val built = File(repoRoot, "build/ludic-lsp")
|
||||
if (!built.isFile) return // toolchain not built in this checkout
|
||||
|
||||
val cmd = serverCommand(repoRoot.path, "")
|
||||
assertEquals(listOf(built.canonicalPath, "--stdio"), cmd!!.let { listOf(File(it[0]).canonicalPath, it[1]) })
|
||||
|
||||
// and it must actually be executable, not merely present
|
||||
val proc = ProcessBuilder(cmd[0], "--version").redirectErrorStream(true).start()
|
||||
val out = proc.inputStream.bufferedReader().readText().trim()
|
||||
proc.waitFor()
|
||||
assertTrue("expected a version banner, got '$out'", out.startsWith("ludic-lsp"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a root without the toolchain yields no command rather than an empty one`() {
|
||||
// The distinction that matters: null lets start() throw an explanatory
|
||||
// error; an empty list is what produced the useless `commands=[]`.
|
||||
val cmd = serverCommand(File(repoRoot, "tools/editors").path, "")
|
||||
assertTrue("must be null, not an empty list", cmd == null || cmd.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.tree.IElementType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Syntax highlighting is the lexer plus a colour map, so this is where a
|
||||
* highlighting regression actually shows up. These are plain JUnit tests: the
|
||||
* lexer has no platform dependencies, so they run in milliseconds without
|
||||
* booting an IDE.
|
||||
*/
|
||||
class LudicLexerTest {
|
||||
|
||||
private fun lex(text: String): List<Pair<IElementType, String>> {
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(text, 0, text.length, 0)
|
||||
val out = mutableListOf<Pair<IElementType, String>>()
|
||||
while (true) {
|
||||
val t = lexer.tokenType ?: break
|
||||
if (t != TokenType.WHITE_SPACE) {
|
||||
out += t to text.substring(lexer.tokenStart, lexer.tokenEnd)
|
||||
}
|
||||
lexer.advance()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun typesOf(text: String) = lex(text).map { it.first }
|
||||
|
||||
@Test
|
||||
fun `declaration keywords are distinct from statement keywords`() {
|
||||
assertEquals(
|
||||
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.BRACE),
|
||||
typesOf("game Hello {")
|
||||
)
|
||||
assertEquals(LudicTokens.KEYWORD, typesOf("if x { }").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clause keywords are their own class`() {
|
||||
// `phase` and `query` colour differently from `if`/`let`, which is the
|
||||
// whole reason a system signature reads as a signature.
|
||||
assertEquals(
|
||||
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.CLAUSE, LudicTokens.PHASE),
|
||||
typesOf("system Move phase FixedUpdate")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primitive types and phases are not plain identifiers`() {
|
||||
assertEquals(LudicTokens.PRIMITIVE, typesOf("int").single())
|
||||
assertEquals(LudicTokens.PRIMITIVE, typesOf("fixed").single())
|
||||
assertEquals(LudicTokens.PHASE, typesOf("LateUpdate").single())
|
||||
assertEquals(LudicTokens.BOOLEAN, typesOf("true").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `builtins are recognised, unknown calls are not`() {
|
||||
assertEquals(LudicTokens.BUILTIN, typesOf("rng_chance").single())
|
||||
assertEquals(LudicTokens.BUILTIN, typesOf("peek32").single()) // intrinsic
|
||||
assertEquals(LudicTokens.IDENTIFIER, typesOf("my_helper").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capitalised words read as type names`() {
|
||||
assertEquals(LudicTokens.TYPE_NAME, typesOf("Pos").single())
|
||||
assertEquals(LudicTokens.IDENTIFIER, typesOf("pos").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `comments run to end of line and keep their text`() {
|
||||
val toks = lex("let a = 1 # keep me\nlet b = 2")
|
||||
val comment = toks.first { it.first == LudicTokens.COMMENT }
|
||||
assertEquals("# keep me", comment.second)
|
||||
// the newline ends it: the next line is code again
|
||||
assertEquals(LudicTokens.KEYWORD, toks[toks.indexOf(comment) + 1].first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strings handle escapes and do not swallow the rest of the file`() {
|
||||
val toks = lex(""""a\"b" 42""")
|
||||
assertEquals(LudicTokens.STRING, toks[0].first)
|
||||
assertEquals(""""a\"b"""", toks[0].second)
|
||||
assertEquals(LudicTokens.NUMBER, toks[1].first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unterminated string stops at the newline`() {
|
||||
// A half-typed string must not colour the whole rest of the file.
|
||||
val toks = lex("let s = \"oops\nlet t = 1")
|
||||
assertEquals(LudicTokens.STRING, toks[3].first)
|
||||
assertEquals("\"oops", toks[3].second)
|
||||
assertEquals(LudicTokens.KEYWORD, toks[4].first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numbers cover hex, fixed-point and the range operator`() {
|
||||
assertEquals(LudicTokens.NUMBER, typesOf("0x1affff").single())
|
||||
assertEquals(LudicTokens.NUMBER, typesOf("1.5").single())
|
||||
// `0..10` is two ints and a range op, not a malformed float
|
||||
assertEquals(
|
||||
listOf(LudicTokens.NUMBER, LudicTokens.OPERATOR, LudicTokens.NUMBER),
|
||||
typesOf("0..10")
|
||||
)
|
||||
assertEquals("..", lex("0..10")[1].second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotations lex as one token including the sigil`() {
|
||||
val toks = lex("@deterministic")
|
||||
assertEquals(LudicTokens.ANNOTATION, toks.single().first)
|
||||
assertEquals("@deterministic", toks.single().second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two-character operators beat one-character ones`() {
|
||||
assertEquals(listOf("->"), lex("->").map { it.second })
|
||||
assertEquals(listOf("=>"), lex("=>").map { it.second })
|
||||
assertEquals(listOf("!="), lex("!=").map { it.second })
|
||||
assertEquals(listOf("<="), lex("<=").map { it.second })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the lexer always terminates and covers every character`() {
|
||||
// The IDE lexes half-typed text constantly; a lexer that stalls hangs
|
||||
// the editor, and one that skips bytes corrupts every offset after it.
|
||||
val nasty = "game X { component ¿ { a: int = ' } # é\n query [{Tag}] \"unclosed"
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(nasty, 0, nasty.length, 0)
|
||||
var covered = 0
|
||||
var guard = 0
|
||||
while (lexer.tokenType != null) {
|
||||
assertTrue("token must advance", lexer.tokenEnd > lexer.tokenStart)
|
||||
assertEquals("no gaps between tokens", covered, lexer.tokenStart)
|
||||
covered = lexer.tokenEnd
|
||||
lexer.advance()
|
||||
check(guard++ < 10_000) { "lexer did not terminate" }
|
||||
}
|
||||
assertEquals("every character consumed", nasty.length, covered)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a real source file lexes with no bad characters`() {
|
||||
val src = """
|
||||
game Hello {
|
||||
component Pos { x: int = 0, y: int = 0 }
|
||||
archetype Mob { Pos }
|
||||
|
||||
system Move phase FixedUpdate
|
||||
query (p) [Pos, {Player}] where p.x > 0
|
||||
{
|
||||
p.x = p.x + 1
|
||||
for i in 0 .. 10 { print_int(i) }
|
||||
}
|
||||
|
||||
ui Menu {
|
||||
panel id=Root w=288 pad=16 {
|
||||
button id=Go text="Go" size=16
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val bad = lex(src).filter { it.first == LudicTokens.BAD }
|
||||
assertTrue("unexpected bad characters: ${bad.map { it.second }}", bad.isEmpty())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lang.LanguageParserDefinitions
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
|
||||
/**
|
||||
* The IDE-side wiring, exercised in a real (headless) platform fixture. These
|
||||
* cover the things that make a file feel like a supported language rather than
|
||||
* plain text: the extension is claimed, a PSI tree is built (which is what
|
||||
* indexing and Markdown injection both stand on), folds appear, braces pair up
|
||||
* and Ctrl+/ comments.
|
||||
*/
|
||||
class LudicPlatformTest : BasePlatformTestCase() {
|
||||
|
||||
private val sample = """
|
||||
game Hello {
|
||||
component Pos { x: int = 0, y: int = 0 }
|
||||
archetype Mob { Pos }
|
||||
|
||||
# move everything that has a position
|
||||
system Move phase FixedUpdate
|
||||
query (p) [Pos, {Player}]
|
||||
{
|
||||
p.x = p.x + 1
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun ludicFile(text: String = sample): PsiFile =
|
||||
myFixture.configureByText("sample.ludic", text)
|
||||
|
||||
fun testExtensionIsClaimedByTheLudicFileType() {
|
||||
val type = FileTypeManager.getInstance().getFileTypeByExtension("ludic")
|
||||
assertEquals("Ludic", type.name)
|
||||
assertEquals(LudicFileType, type)
|
||||
}
|
||||
|
||||
fun testFileTypeIsNotBinaryAndUsesUtf8() {
|
||||
assertFalse(LudicFileType.isBinary)
|
||||
assertEquals("ludic", LudicFileType.defaultExtension)
|
||||
}
|
||||
|
||||
fun testParserDefinitionIsRegisteredForTheLanguage() {
|
||||
// Markdown fence injection needs a PSI tree, so this registration is
|
||||
// what makes ```ludic blocks highlight in a .md file.
|
||||
val def = LanguageParserDefinitions.INSTANCE.forLanguage(LudicLanguage)
|
||||
assertNotNull("no ParserDefinition registered for Ludic", def)
|
||||
}
|
||||
|
||||
fun testPsiTreeIsBuiltAndCoversTheWholeFile() {
|
||||
val file = ludicFile()
|
||||
assertEquals(LudicLanguage, file.language)
|
||||
assertEquals(sample, file.text)
|
||||
assertTrue("expected a non-empty PSI tree", file.children.isNotEmpty())
|
||||
// every character must be reachable through the tree, or offsets drift
|
||||
assertEquals(sample.length, file.textLength)
|
||||
}
|
||||
|
||||
fun testHighlighterAssignsColoursToEveryMeaningfulToken() {
|
||||
val highlighter = LudicSyntaxHighlighter()
|
||||
val coloured = listOf(
|
||||
LudicTokens.DECL_KEYWORD, LudicTokens.KEYWORD, LudicTokens.CLAUSE,
|
||||
LudicTokens.PRIMITIVE, LudicTokens.PHASE, LudicTokens.BOOLEAN,
|
||||
LudicTokens.BUILTIN, LudicTokens.TYPE_NAME, LudicTokens.STRING,
|
||||
LudicTokens.NUMBER, LudicTokens.COMMENT, LudicTokens.ANNOTATION,
|
||||
LudicTokens.OPERATOR, LudicTokens.BRACE, LudicTokens.BRACKET,
|
||||
LudicTokens.PAREN, LudicTokens.COMMA, LudicTokens.IDENTIFIER
|
||||
)
|
||||
for (token in coloured) {
|
||||
val keys = highlighter.getTokenHighlights(token)
|
||||
assertTrue("no colour mapped for $token", keys.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun testFoldingCollapsesMultiLineBlocksOnly() {
|
||||
val file = ludicFile()
|
||||
val regions = LudicFoldingBuilder().buildFoldRegions(file.node, file.viewProvider.document!!)
|
||||
|
||||
// The sample has exactly two blocks that span lines: the `game` body and
|
||||
// the `system` body. `component Pos { … }`, `archetype Mob { … }` and the
|
||||
// `{Player}` query filter all fit on one line, and folding a single line
|
||||
// gains nothing, so they are deliberately skipped.
|
||||
assertEquals("multi-line blocks only", 2, regions.size)
|
||||
|
||||
val outermost = regions.maxByOrNull { it.range.length }!!
|
||||
assertEquals("outermost fold starts at the game block's brace",
|
||||
sample.indexOf('{'), outermost.range.startOffset)
|
||||
assertEquals("outermost fold ends at the last brace",
|
||||
sample.length, outermost.range.endOffset)
|
||||
|
||||
for (r in regions) {
|
||||
assertTrue("fold region must be non-empty", r.range.length > 0)
|
||||
assertEquals("{...}", LudicFoldingBuilder().getPlaceholderText(file.node))
|
||||
}
|
||||
}
|
||||
|
||||
fun testCommenterUsesHashWithASpace() {
|
||||
// The trailing space is deliberate: Ctrl+/ should produce "# code",
|
||||
// which is how every comment in the runtime and examples is written.
|
||||
val commenter = LudicCommenter()
|
||||
assertEquals("# ", commenter.lineCommentPrefix)
|
||||
assertNull("Ludic has no block comments", commenter.blockCommentPrefix)
|
||||
}
|
||||
|
||||
fun testBraceMatcherPairsAllThreeBracketKinds() {
|
||||
val matcher = LudicBraceMatcher()
|
||||
val pairs = matcher.pairs
|
||||
val open = pairs.map { it.leftBraceType }.toSet()
|
||||
assertTrue(open.contains(LudicTokens.BRACE))
|
||||
assertTrue(open.contains(LudicTokens.BRACKET))
|
||||
assertTrue(open.contains(LudicTokens.PAREN))
|
||||
}
|
||||
|
||||
fun testHalfTypedFileStillProducesAPsiTree() {
|
||||
// The IDE must survive what a file looks like mid-keystroke; a parser
|
||||
// that throws here takes highlighting and indexing down with it.
|
||||
val broken = "game X {\n component P { x: int =\n system"
|
||||
val file = myFixture.configureByText("broken.ludic", broken)
|
||||
assertEquals(broken.length, file.textLength)
|
||||
assertEquals(LudicLanguage, file.language)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue