feat(tooling): JetBrains IDE support, LSP navigation, barrel imports, package scripts and hooks

- JetBrains plugin 1.4.0: semantic colours (builtin / vendor / own), template strings,
  brace handling, run configurations and a test console, package.ludic and
  package.lock.ludic editing (completion, docs, app preview, colour previews, asset
  navigation), External Libraries for the runtime and packages, doc pages for built-ins
- ludic-lsp: go to definition for imports, document links, hover with inferred types,
  type definition, signature help with parameters, docs from docs/language
- `import "dir"` resolves a barrel `dir/index.ludic`
- package.ludic `entry`, `script` and `hook before|after <command>`; `ludic <script>`,
  `ludic script`, `ludic scripts`
- `ludic test --verbose` and `--test NAME`; the test runner filters by name

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-16 16:00:28 +03:00
parent da57b20156
commit f92d7f89c6
75 changed files with 46802 additions and 40343 deletions

View file

@ -1,8 +1,8 @@
# Ludic for JetBrains IDEs
Works in IntelliJ IDEA (Community and Ultimate), CLion, GoLand, PyCharm, Rider,
WebStorm — anything on the IntelliJ Platform 2023.2 through 2026.2
(`since-build` 232, `until-build` 262.*).
WebStorm — anything on the IntelliJ Platform 2024.2 through 2026.2
(`since-build` 242, `until-build` 262.*).
`until-build` 262 is deliberately ahead of the newest released platform — 2025.3
is build **253**, and no 262 IDE exists yet. That headroom is the point: an
@ -17,7 +17,7 @@ packages cleanly can still fail to load:
scheduled-for-removal API usages.
- `./gradlew runIde` boots a real IDEA 2025.3 with the plugin installed. The
sandbox log line to look for is
`Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.0.0)` in
`Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.4.0)` in
`.intellijPlatform/sandbox/Ludic/IC-2025.3/log/idea.log`.
**If the plugin does not appear in Settings -> Plugins after installing**, the
@ -35,7 +35,7 @@ as the error message, which is not obvious the first time you see it:
```bash
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
cd tools/editors/jetbrains
./gradlew buildPlugin # -> build/distributions/Ludic-1.0.0.zip
./gradlew buildPlugin # -> build/distributions/Ludic-1.4.0.zip
./gradlew runIde # try it in a sandbox IDE
```
@ -51,8 +51,39 @@ includes the plugin build in the toolchain's own test run.
Install the zip with **Settings -> Plugins -> ⚙ -> Install Plugin from Disk**.
LSP4IJ is a required dependency; the IDE offers to install it for you.
## What you get
- **Editing**: highlighting (Settings | Editor | Color Scheme | Ludic), brace and
quote pairing, folding, `#` comments, smart indent on Enter and on typing a
closer, Code Style page (2 spaces by default), live templates (`prog`,
`handler`, `on`, `test`, `fn`, `forq`, ...), `package.ludic` directives.
- **Language server** (`ludic-lsp` via LSP4IJ): completion, diagnostics
(including the compiler's own on save), hover, parameter info, go to
definition, find/highlight usages, rename, formatting, inlay hints, semantic
colours, Structure view, breadcrumbs, Go to Symbol.
- **Run / test**: a *Ludic* run configuration (`ludic run`, `build`, `test` or
any other `ludic` command); green gutter buttons on `program` and on every
`test "name"` (which runs just that test via `ludic test --test NAME`);
results in the test tree with navigation; `file.ludic:line` links in every
console. Per-test results need a toolchain with `ludic test --verbose`
(0.17+); an older one still gives per-file results.
- **Project**: File | New | Project | Ludic (runs `ludic new`), File | New |
Ludic File (program, module, test), Tools | Ludic (run, build, test, fmt,
get, pack, bundle, clean, doctor).
There is no debugger: `ludicc` does not emit debug info yet.
## Settings
**Settings -> Languages & Frameworks -> Ludic** — the path to `ludic-lsp` and to
`ludicc`. Both default to `bin/` under the project root, which is where
`bin/ludic-dev tools` puts them.
**Settings -> Languages & Frameworks -> Ludic** — the paths to `ludic`,
`ludic-lsp` and `ludicc`. Each blank one is looked up in `bin/` under the
project root, then on `PATH`, then in `~/.ludic/bin`. Changing a path restarts
the language server.
## Testing
```bash
./gradlew test # ~70 headless platform tests, ~25 s
./gradlew verifyPlugin -PlocalIde="/Applications/IntelliJ IDEA.app" # also verify against an installed IDE
./gradlew runLocalIde -PlocalIde="/Applications/IntelliJ IDEA.app" -PopenProject=/path/to/game
```

View file

@ -1,3 +1,4 @@
import java.time.Duration
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
@ -9,6 +10,8 @@ plugins {
id("org.jetbrains.intellij.platform") version "2.18.1"
}
val localIde = providers.gradleProperty("localIde")
group = providers.gradleProperty("pluginGroup").get()
version = providers.gradleProperty("pluginVersion").get()
@ -65,6 +68,9 @@ intellijPlatform {
create(IntelliJPlatformType.IntellijIdeaCommunity, providers.gradleProperty("platformVersion").get()) {
useInstaller = false
}
// -PlocalIde=/path/to/IDE.app also checks against an installed IDE,
// e.g. the newer one a developer actually uses
localIde.orNull?.let { local(it) }
}
}
@ -87,8 +93,32 @@ tasks.named<JavaExec>("runIde") {
args = listOf(repoRoot.absolutePath)
}
// `./gradlew runLocalIde -PlocalIde=/Applications/IntelliJ IDEA.app [-PopenProject=dir]`:
// the same sandbox as runIde, booted from an installed IDE instead of the
// downloaded one, so the plugin can be tried on exactly that build.
val runLocalIde by intellijPlatformTesting.runIde.registering {
localPath = localIde.map { file(it) }
task {
args = listOf(providers.gradleProperty("openProject").getOrElse(repoRoot.absolutePath))
}
plugins {
plugin("com.redhat.devtools.lsp4ij", providers.gradleProperty("lsp4ijVersion").get())
}
}
// The palette Color.* resolves against, straight from the docs it is generated
// into (`ludic-dev docs-palette`), so the swatches cannot drift from the compiler.
tasks.named<ProcessResources>("processResources") {
from(File(repoRoot, "docs/language/colors/palette.json"))
}
tasks.test {
useJUnit()
// a platform test that waits on a modal dialog or an event that never
// comes blocks forever; fail the run instead of hanging it
timeout.set(Duration.ofMinutes(10))
systemProperty("idea.test.timeout", "120")
testLogging {
events("passed", "failed", "skipped")
showStandardStreams = false

View file

@ -1,11 +1,12 @@
pluginGroup = io.ludic.ide
pluginName = Ludic
pluginVersion = 1.0.0
pluginVersion = 1.4.0
# 2023.2 is the first build with the platform APIs this plugin uses. LSP4IJ (not
# the paid-IDE LSP API) supplies the language-server bridge, so Community
# editions are supported as well as Ultimate.
pluginSinceBuild = 232
# 2024.2 (242) is the oldest platform LSP4IJ 0.20+ installs on, so it is the
# floor here too: claiming older would let an IDE accept this plugin and then
# fail to find its required dependency. LSP4IJ (not the paid-IDE LSP API)
# supplies the language-server bridge, so the free IDEs are supported as well.
pluginSinceBuild = 242
# Deliberately ahead of the newest released platform (2025.3 is build 253): an
# until-build that stops at today's IDE gets the plugin auto-disabled the moment
# a user upgrades. 262 buys roughly three release cycles of headroom. The APIs
@ -15,7 +16,7 @@ pluginSinceBuild = 232
pluginUntilBuild = 262.*
platformVersion = 2025.3
lsp4ijVersion = 0.20.1
lsp4ijVersion = 0.21.0
# Gradle itself must run on JDK 17-21; a newer JDK reports only its version
# number as the failure. The Kotlin toolchain below is pinned separately.

View file

@ -7,6 +7,7 @@ import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.FormBuilder
import com.redhat.devtools.lsp4ij.LanguageServerManager
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JSpinner
@ -16,6 +17,7 @@ import javax.swing.SpinnerNumberModel
class LudicConfigurable(private val project: Project) : Configurable {
private val serverField = TextFieldWithBrowseButton()
private val compilerField = TextFieldWithBrowseButton()
private val cliField = TextFieldWithBrowseButton()
private val diagnostics = JBCheckBox("Report compiler errors on save")
private val indent = JSpinner(SpinnerNumberModel(2, 1, 8, 1))
private var panel: JPanel? = null
@ -33,6 +35,10 @@ class LudicConfigurable(private val project: Project) : Configurable {
project,
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludicc")
)
cliField.addBrowseFolderListener(
project,
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludic")
)
panel = FormBuilder.createFormBuilder()
.addLabeledComponent(JBLabel("Language server (ludic-lsp):"), serverField, 1, false)
@ -43,6 +49,10 @@ class LudicConfigurable(private val project: Project) : Configurable {
.addComponentToRightColumn(
JBLabel("Used for authoritative diagnostics. Empty means bin/ludicc.")
)
.addLabeledComponent(JBLabel("Command line (ludic):"), cliField, 1, false)
.addComponentToRightColumn(
JBLabel("Runs, builds, tests and new projects. Empty means bin/ludic, then PATH, then ~/.ludic.")
)
.addComponent(diagnostics)
.addLabeledComponent(JBLabel("Formatter indent:"), indent, 1, false)
.addComponentFillVertically(JPanel(), 0)
@ -57,22 +67,37 @@ class LudicConfigurable(private val project: Project) : Configurable {
val s = settings()
return serverField.text != s.serverPath ||
compilerField.text != s.compilerPath ||
cliField.text != s.cliPath ||
diagnostics.isSelected != s.compilerDiagnostics ||
(indent.value as Int) != s.indentSize
}
override fun apply() {
val s = settings()
val restart = serverField.text.trim() != s.serverPath ||
compilerField.text.trim() != s.compilerPath ||
diagnostics.isSelected != s.compilerDiagnostics ||
(indent.value as Int) != s.indentSize
s.serverPath = serverField.text.trim()
s.compilerPath = compilerField.text.trim()
s.cliPath = cliField.text.trim()
s.compilerDiagnostics = diagnostics.isSelected
s.indentSize = indent.value as Int
// the server reads all of these once, at initialize
if (restart) restartServer()
}
private fun restartServer() {
val manager = LanguageServerManager.getInstance(project)
manager.stop("ludic")
manager.start("ludic")
}
override fun reset() {
val s = settings()
serverField.text = s.serverPath
compilerField.text = s.compilerPath
cliField.text = s.cliPath
diagnostics.isSelected = s.compilerDiagnostics
indent.value = s.indentSize
}

View file

@ -18,7 +18,7 @@ import com.intellij.psi.tree.IElementType
* this needs the language server, so none of it waits for one.
*/
class LudicCommenter : Commenter {
override fun getLineCommentPrefix() = "# "
override fun getLineCommentPrefix() = "#"
override fun getBlockCommentPrefix(): String? = null
override fun getBlockCommentSuffix(): String? = null
override fun getCommentedBlockCommentPrefix(): String? = null
@ -37,9 +37,10 @@ class LudicBraceMatcher : PairedBraceMatcher {
// marking them all structural would make the "matched brace" gutter
// noisy inside `spawn Hero { Pos { x: 1 } }`.
val PAIRS = arrayOf(
BracePair(LudicTokens.BRACE, LudicTokens.BRACE, true),
BracePair(LudicTokens.BRACKET, LudicTokens.BRACKET, false),
BracePair(LudicTokens.PAREN, LudicTokens.PAREN, false)
BracePair(LudicTokens.LBRACE, LudicTokens.RBRACE, true),
BracePair(LudicTokens.LBRACKET, LudicTokens.RBRACKET, false),
BracePair(LudicTokens.LPAREN, LudicTokens.RPAREN, false),
BracePair(LudicTokens.TEMPLATE_HOLE_START, LudicTokens.TEMPLATE_HOLE_END, false)
)
}
}
@ -60,17 +61,15 @@ class LudicFoldingBuilder : FoldingBuilderEx(), DumbAware {
val node = root.node ?: return emptyArray()
while (lexer.tokenType != null) {
if (lexer.tokenType == LudicTokens.BRACE) {
when (text[lexer.tokenStart]) {
'{' -> opens.addLast(lexer.tokenStart)
'}' -> {
val open = opens.removeLastOrNull()
if (open != null) {
val end = lexer.tokenEnd
// only worth folding when it actually spans lines
if (document.getLineNumber(end) > document.getLineNumber(open)) {
regions += FoldingDescriptor(node, TextRange(open, end))
}
when (lexer.tokenType) {
LudicTokens.LBRACE -> opens.addLast(lexer.tokenStart)
LudicTokens.RBRACE -> {
val open = opens.removeLastOrNull()
if (open != null) {
val end = lexer.tokenEnd
// only worth folding when it actually spans lines
if (document.getLineNumber(end) > document.getLineNumber(open)) {
regions += FoldingDescriptor(node, TextRange(open, end))
}
}
}

View file

@ -10,11 +10,14 @@ import com.intellij.psi.tree.IElementType
* rules are the ones the shared header already implements, and keeping the two
* readable side by side is worth more here than a JFlex spec.
*
* With [manifest] set it lexes a package.ludic, where the directives
* (`package`, `require`, `app`…) are the keywords instead.
*
* It must never throw and never fail to advance: the IDE lexes half-typed text
* constantly, so anything unrecognised becomes one BAD character and the scan
* carries on.
*/
class LudicLexer : LexerBase() {
class LudicLexer(private val manifest: Boolean = false) : LexerBase() {
private var buffer: CharSequence = ""
private var startOffset = 0
private var endOffset = 0
@ -22,16 +25,35 @@ class LudicLexer : LexerBase() {
private var tokenEnd = 0
private var tokenType: IElementType? = null
/**
* What the previous significant token was, which decides whether a
* declaration word is a keyword here (see [isNamePosition]). It is the
* lexer state, so an incremental restart mid-file lexes the same way.
*/
private var prev = PREV_START
private var stateAtToken = PREV_START
/**
* Where we are relative to a `template {hole} string`: in code, in the
* template's text, or in a hole (with how many braces it has opened).
* Packed into the state with [prev].
*/
private var mode = MODE_CODE
private var holeDepth = 0
override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) {
this.buffer = buffer
this.startOffset = startOffset
this.endOffset = endOffset
this.tokenStart = startOffset
this.tokenEnd = startOffset
this.prev = initialState and 3
this.mode = (initialState shr 2) and 3
this.holeDepth = initialState shr 4
advance()
}
override fun getState() = 0
override fun getState() = stateAtToken
override fun getTokenType(): IElementType? = tokenType
override fun getTokenStart() = tokenStart
override fun getTokenEnd() = tokenEnd
@ -44,6 +66,44 @@ class LudicLexer : LexerBase() {
private fun isSpace(c: Char) = c == ' ' || c == '\t' || c == '\r' || c == '\n'
override fun advance() {
stateAtToken = prev or (mode shl 2) or (holeDepth shl 4)
if (mode == MODE_TEXT) { lexTemplateText(); return }
lexOne()
val t = tokenType ?: return
if (t != TokenType.WHITE_SPACE && t != LudicTokens.COMMENT) prev = classOf(t)
}
private fun classOf(t: IElementType): Int {
val text = buffer.subSequence(tokenStart, tokenEnd)
return when {
t == LudicTokens.LBRACE || t == LudicTokens.RBRACE -> PREV_START
t == LudicTokens.RPAREN || t == LudicTokens.RBRACKET -> PREV_OTHER
t == LudicTokens.OPERATOR && text == ";" -> PREV_START
t == LudicTokens.OPERATOR && text == "." -> PREV_NAME_BEFORE
t == LudicTokens.OPERATOR || t == LudicTokens.LPAREN || t == LudicTokens.LBRACKET || t == LudicTokens.COMMA -> PREV_NAME_BEFORE
t == LudicTokens.DECL_KEYWORD && (text == "var" || text == "const") -> PREV_NAME_BEFORE
t == LudicTokens.KEYWORD && (text == "let" || text == "return") -> PREV_NAME_BEFORE
else -> PREV_OTHER
}
}
/**
* The compiler reserves declaration words only as function names, so
* `var model = …`, `model: Model` and `actor.model` are ordinary names.
*/
private fun isNamePosition(end: Int): Boolean {
if (prev == PREV_NAME_BEFORE) return true
var j = end
while (j < endOffset && (at(j) == ' ' || at(j) == '\t')) j++
if (j >= endOffset) return false
val c = at(j)
if (c == ':' || c == '.' || c == ')' || c == ',' || c == ']') return true
if (c == '=' && at(j + 1) != '>') return true // `x = …`, `x == …`
if ((c == '+' || c == '-' || c == '*' || c == '/' || c == '!') && at(j + 1) == '=') return true
return false
}
private fun lexOne() {
tokenStart = tokenEnd
if (tokenStart >= endOffset) {
tokenType = null
@ -63,6 +123,21 @@ class LudicLexer : LexerBase() {
emit(i, LudicTokens.COMMENT)
return
}
if (c == '`') {
// opens a template, or (from a hole) closes it: the compiler ends a
// template at the next backtick whatever it is inside
mode = if (mode == MODE_CODE) MODE_TEXT else MODE_CODE
holeDepth = 0
emit(i + 1, LudicTokens.TEMPLATE_QUOTE)
return
}
if (mode == MODE_HOLE && c == '}' && holeDepth == 0) {
mode = MODE_TEXT
emit(i + 1, LudicTokens.TEMPLATE_HOLE_END)
return
}
if (mode == MODE_HOLE && c == '{') holeDepth++
if (mode == MODE_HOLE && c == '}') holeDepth--
if (c == '"') {
i++
while (i < endOffset && at(i) != '"' && at(i) != '\n') {
@ -104,9 +179,11 @@ class LudicLexer : LexerBase() {
while (i < endOffset && isIdPart(at(i))) i++
val word = buffer.subSequence(tokenStart, i).toString()
val type = when {
manifest && word in LudicVocabulary.MANIFEST -> LudicTokens.MANIFEST_KEYWORD
word == "true" || word == "false" -> LudicTokens.BOOLEAN
word in LudicVocabulary.PRIMITIVES -> LudicTokens.PRIMITIVE
word in LudicVocabulary.PHASES -> LudicTokens.PHASE
word in LudicVocabulary.CONTEXTUAL && isNamePosition(i) -> LudicTokens.IDENTIFIER
word in LudicVocabulary.DECL -> LudicTokens.DECL_KEYWORD
word in LudicVocabulary.CLAUSE -> LudicTokens.CLAUSE
word in LudicVocabulary.STMT -> LudicTokens.KEYWORD
@ -124,9 +201,12 @@ class LudicLexer : LexerBase() {
return
}
when (c) {
'{', '}' -> { emit(i + 1, LudicTokens.BRACE); return }
'[', ']' -> { emit(i + 1, LudicTokens.BRACKET); return }
'(', ')' -> { emit(i + 1, LudicTokens.PAREN); return }
'{' -> { emit(i + 1, LudicTokens.LBRACE); return }
'}' -> { emit(i + 1, LudicTokens.RBRACE); return }
'[' -> { emit(i + 1, LudicTokens.LBRACKET); return }
']' -> { emit(i + 1, LudicTokens.RBRACKET); return }
'(' -> { emit(i + 1, LudicTokens.LPAREN); return }
')' -> { emit(i + 1, LudicTokens.RPAREN); return }
',' -> { emit(i + 1, LudicTokens.COMMA); return }
}
if (c in SINGLE_CHAR_OPS) {
@ -136,12 +216,38 @@ class LudicLexer : LexerBase() {
emit(i + 1, LudicTokens.BAD)
}
/** One run of template text, an escape, a hole opener or the closing backtick. */
private fun lexTemplateText() {
tokenStart = tokenEnd
if (tokenStart >= endOffset) { tokenType = null; return }
var i = tokenStart
val c = at(i)
when {
c == '`' -> { mode = MODE_CODE; emit(i + 1, LudicTokens.TEMPLATE_QUOTE); return }
(c == '{' && at(i + 1) == '{') || (c == '}' && at(i + 1) == '}') -> { emit(i + 2, LudicTokens.TEMPLATE_ESCAPE); return }
c == '{' -> { mode = MODE_HOLE; holeDepth = 0; emit(i + 1, LudicTokens.TEMPLATE_HOLE_START); return }
c == '\\' && i + 1 < endOffset -> { emit(i + 2, LudicTokens.TEMPLATE_ESCAPE); return }
}
while (i < endOffset) {
val d = at(i)
if (d == '`' || d == '{' || d == '\\' || (d == '}' && at(i + 1) == '}')) break
i++
}
emit(maxOf(i, tokenStart + 1), LudicTokens.TEMPLATE_TEXT)
}
private fun emit(end: Int, type: IElementType) {
tokenEnd = end
tokenType = type
}
private companion object {
companion object {
const val MODE_CODE = 0
const val MODE_TEXT = 1
const val MODE_HOLE = 2
const val PREV_START = 0 // file start, `{`, `}`, `;`: a declaration may begin
const val PREV_OTHER = 1
const val PREV_NAME_BEFORE = 2 // `.`, an operator, `(`, `var`, `let`, `return`: a name follows
val TWO_CHAR_OPS = setOf("->", "+=", "-=", "*=", "/=", "==", "!=", "<=", ">=", "<<", ">>", "..", "=>")
const val SINGLE_CHAR_OPS = "+-*/%<>=:.!@;&|^~"
}

View file

@ -37,7 +37,7 @@ class LudicParserDefinition : ParserDefinition {
override fun getFileNodeType(): IFileElementType = LudicTokens.FILE
override fun getCommentTokens(): TokenSet = TokenSet.create(LudicTokens.COMMENT)
override fun getStringLiteralElements(): TokenSet = TokenSet.create(LudicTokens.STRING, LudicTokens.CHAR)
override fun getStringLiteralElements(): TokenSet = TokenSet.create(LudicTokens.STRING, LudicTokens.CHAR, LudicTokens.TEMPLATE_TEXT)
override fun getWhitespaceTokens(): TokenSet = TokenSet.create(TokenType.WHITE_SPACE)
// Only ever reached for composite nodes, and this grammar marks none beyond
// the file itself — but the platform requires an answer, so give it a real

View file

@ -9,7 +9,18 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.util.xmlb.XmlSerializerUtil
import com.redhat.devtools.lsp4ij.LanguageServerFactory
import com.intellij.psi.PsiFile
import com.redhat.devtools.lsp4ij.client.LanguageClientImpl
import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures
import com.redhat.devtools.lsp4ij.client.features.LSPCompletionFeature
import com.redhat.devtools.lsp4ij.client.features.LSPDefinitionFeature
import com.redhat.devtools.lsp4ij.client.features.LSPDiagnosticFeature
import com.redhat.devtools.lsp4ij.client.features.LSPDocumentHighlightFeature
import com.redhat.devtools.lsp4ij.client.features.LSPDocumentLinkFeature
import com.redhat.devtools.lsp4ij.client.features.LSPHoverFeature
import com.redhat.devtools.lsp4ij.client.features.LSPInlayHintFeature
import com.redhat.devtools.lsp4ij.client.features.LSPSemanticTokensFeature
import com.redhat.devtools.lsp4ij.client.features.LSPSignatureHelpFeature
import com.redhat.devtools.lsp4ij.server.CannotStartProcessException
import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider
import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider
@ -25,6 +36,7 @@ import java.io.File
class LudicSettings : PersistentStateComponent<LudicSettings> {
var serverPath: String = ""
var compilerPath: String = ""
var cliPath: String = ""
var compilerDiagnostics: Boolean = true
var indentSize: Int = 2
@ -43,26 +55,38 @@ class LudicSettings : PersistentStateComponent<LudicSettings> {
* tested without booting an IDE — this lookup silently returning null is the
* difference between a working language server and one that never starts.
*/
internal fun findExecutable(root: String?, configured: String, names: List<String>, subdirs: List<String>): String? {
internal fun findExecutable(
root: String?,
configured: String,
names: List<String>,
subdirs: List<String>,
path: String? = System.getenv("PATH"),
home: String = ludicHome(),
): String? {
if (configured.isNotBlank()) {
val f = File(expandHome(configured))
return if (f.canExecute()) f.absolutePath else null
return if (f.isFile && f.canExecute()) f.absolutePath else null
}
if (root == null) return searchPath(names)
for (dir in subdirs) {
for (name in names) {
val f = File(File(root, dir), name)
if (f.isFile && f.canExecute()) return f.absolutePath
if (root != null) {
for (dir in subdirs) {
for (name in names) {
val f = File(File(root, dir), name)
if (f.isFile && f.canExecute()) return f.absolutePath
}
}
}
return searchPath(names)
return searchPath(names, path, home)
}
/** The install root: `$LUDIC_HOME`, else `~/.ludic`. */
internal fun ludicHome(): String =
System.getenv("LUDIC_HOME") ?: (System.getProperty("user.home") + "/.ludic")
/** Fall back to PATH, so a system-wide install works with no configuration. */
private fun searchPath(names: List<String>): String? {
val path = System.getenv("PATH")
private fun searchPath(names: List<String>, path: String?, home: String): String? {
if (path != null) {
for (dir in path.split(File.pathSeparator)) {
if (dir.isEmpty()) continue
for (name in names) {
val f = File(dir, name)
if (f.isFile && f.canExecute()) return f.absolutePath
@ -72,7 +96,6 @@ private fun searchPath(names: List<String>): String? {
// The install root, last: an IDE launched from the desktop does not inherit
// the PATH the installer wrote to the shell profile, so a working ~/.ludic
// install would otherwise look missing.
val home = System.getenv("LUDIC_HOME") ?: (System.getProperty("user.home") + "/.ludic")
for (name in names) {
val f = File(File(home, "bin"), name)
if (f.isFile && f.canExecute()) return f.absolutePath
@ -80,6 +103,39 @@ private fun searchPath(names: List<String>): String? {
return null
}
/**
* The three toolchain binaries, resolved the same way everywhere: the setting,
* then the project's own bin/, then PATH, then the install root.
*/
object LudicToolchain {
fun server(project: Project): String? =
findExecutable(project.basePath, LudicSettings.of(project).serverPath, listOf("ludic-lsp", "ludic-lsp.exe"), SERVER_SUBDIRS)
fun compiler(project: Project): String? =
findExecutable(project.basePath, LudicSettings.of(project).compilerPath, listOf("ludicc", "ludicc.exe"), COMPILER_SUBDIRS)
/**
* The install root the toolchain runs from: $LUDIC_HOME, else the
* directory above the `ludic` binary's bin/ (a checkout, or ~/.ludic).
*/
fun home(project: Project?): File? {
System.getenv("LUDIC_HOME")?.let { return File(it) }
val cli = (if (project != null) cli(project) else cli()) ?: return null
val bin = File(cli).parentFile ?: return null
return if (bin.name == "bin") bin.parentFile else bin
}
fun cli(project: Project): String? =
findExecutable(project.basePath, LudicSettings.of(project).cliPath, listOf("ludic", "ludic.exe"), COMPILER_SUBDIRS)
/** For places with no project yet, such as the New Project wizard. */
fun cli(): String? = findExecutable(null, "", listOf("ludic", "ludic.exe"), COMPILER_SUBDIRS)
const val INSTALL_HINT =
"Install the toolchain (curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh) " +
"or set the paths in Settings | Languages & Frameworks | Ludic."
}
private fun expandHome(p: String) =
if (p.startsWith("~")) System.getProperty("user.home") + p.substring(1) else p
@ -132,11 +188,7 @@ class LudicConnectionProvider(private val project: Project) : ProcessStreamConne
*/
override fun getInitializationOptions(rootUri: VirtualFile?): Any {
val settings = LudicSettings.of(project)
val compiler = findExecutable(
project.basePath, settings.compilerPath,
listOf("ludicc", "ludicc.exe"),
COMPILER_SUBDIRS
) ?: ""
val compiler = LudicToolchain.compiler(project) ?: ""
return mapOf(
"compilerPath" to compiler,
"compilerDiagnostics" to settings.compilerDiagnostics,
@ -160,7 +212,50 @@ internal fun serverCommand(root: String?, configured: String): List<String>? {
internal val SERVER_SUBDIRS = listOf("bin", "build", "tools/build", ".")
internal val COMPILER_SUBDIRS = listOf("bin", "build", ".")
/**
* package.ludic is a Ludic file to the IDE but not Ludic source to the server:
* its directives would read as unknown names, get keyword completions and have
* their manifest colours painted over. The plugin handles it natively instead.
*/
private class ManifestAwareFeatures : LSPClientFeatures() {
init {
completionFeature = object : LSPCompletionFeature() {
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
}
semanticTokensFeature = object : LSPSemanticTokensFeature() {
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
override fun getTextAttributesKey(tokenType: String, tokenModifiers: List<String>, file: PsiFile) =
io.ludic.ide.editor.LudicSemanticColors.keyFor(tokenType, tokenModifiers)
}
diagnosticFeature = object : LSPDiagnosticFeature() {
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
}
signatureHelpFeature = object : LSPSignatureHelpFeature() {
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
}
inlayHintFeature = object : LSPInlayHintFeature() {
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
}
definitionFeature = object : LSPDefinitionFeature() {
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
}
documentLinkFeature = object : LSPDocumentLinkFeature() {
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
}
documentHighlightFeature = object : LSPDocumentHighlightFeature() {
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
}
hoverFeature = object : LSPHoverFeature() {
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
}
}
private fun isManifest(file: PsiFile) = file.name == "package.ludic" || file.name == "package.lock.ludic"
}
class LudicLanguageServerFactory : LanguageServerFactory {
override fun createClientFeatures(): LSPClientFeatures = ManifestAwareFeatures()
override fun createConnectionProvider(project: Project): StreamConnectionProvider =
LudicConnectionProvider(project)

View file

@ -21,6 +21,8 @@ object LudicColors {
val COMMENT = key("LUDIC_COMMENT", D.LINE_COMMENT)
val STRING = key("LUDIC_STRING", D.STRING)
val NUMBER = key("LUDIC_NUMBER", D.NUMBER)
val STRING_ESCAPE = key("LUDIC_STRING_ESCAPE", D.VALID_STRING_ESCAPE)
val TEMPLATE_BRACES = key("LUDIC_TEMPLATE_BRACES", D.KEYWORD)
val KEYWORD = key("LUDIC_KEYWORD", D.KEYWORD)
val DECL_KEYWORD = key("LUDIC_DECL_KEYWORD", D.KEYWORD)
val CLAUSE = key("LUDIC_CLAUSE", D.METADATA)
@ -36,18 +38,43 @@ object LudicColors {
val BRACKET = key("LUDIC_BRACKET", D.BRACKETS)
val PAREN = key("LUDIC_PAREN", D.PARENTHESES)
val COMMA = key("LUDIC_COMMA", D.COMMA)
val MANIFEST = key("LUDIC_MANIFEST_DIRECTIVE", D.KEYWORD)
// Semantic colours, painted from ludic-lsp's tokens. "Built-in" is the
// language and its standard library, "package" a ludic.* or fetched
// package, and the plain key the program's own code.
val FUNCTION_DECLARATION = key("LUDIC_FUNCTION_DECLARATION", D.FUNCTION_DECLARATION)
val FUNCTION_CALL = key("LUDIC_FUNCTION_CALL", D.FUNCTION_CALL)
val FUNCTION_PACKAGE = key("LUDIC_FUNCTION_PACKAGE", D.FUNCTION_CALL)
val FUNCTION_BUILTIN = key("LUDIC_FUNCTION_BUILTIN", D.PREDEFINED_SYMBOL)
val HANDLER = key("LUDIC_HANDLER", D.FUNCTION_DECLARATION)
val CONSTANT = key("LUDIC_CONSTANT", D.CONSTANT)
val CONSTANT_PACKAGE = key("LUDIC_CONSTANT_PACKAGE", D.CONSTANT)
val CONSTANT_BUILTIN = key("LUDIC_CONSTANT_BUILTIN", D.STATIC_FIELD)
val GLOBAL_VARIABLE = key("LUDIC_GLOBAL_VARIABLE", D.GLOBAL_VARIABLE)
val GLOBAL_VARIABLE_PACKAGE = key("LUDIC_GLOBAL_VARIABLE_PACKAGE", D.GLOBAL_VARIABLE)
val LOCAL_VARIABLE = key("LUDIC_LOCAL_VARIABLE", D.LOCAL_VARIABLE)
val PARAMETER = key("LUDIC_PARAMETER", D.PARAMETER)
val FIELD = key("LUDIC_FIELD", D.INSTANCE_FIELD)
val PROPERTY_TYPE = key("LUDIC_PROPERTY_TYPE", D.CLASS_NAME)
val MODEL_TYPE = key("LUDIC_MODEL_TYPE", D.CLASS_NAME)
val TYPE_PACKAGE = key("LUDIC_TYPE_PACKAGE", D.CLASS_REFERENCE)
val NAMESPACE = key("LUDIC_NAMESPACE", D.CLASS_REFERENCE)
val NAMESPACE_BUILTIN = key("LUDIC_NAMESPACE_BUILTIN", D.PREDEFINED_SYMBOL)
val BAD = createTextAttributesKey("LUDIC_BAD_CHARACTER", HighlighterColors.BAD_CHARACTER)
private fun key(name: String, base: TextAttributesKey) = createTextAttributesKey(name, base)
}
class LudicSyntaxHighlighter : SyntaxHighlighterBase() {
override fun getHighlightingLexer(): Lexer = LudicLexer()
class LudicSyntaxHighlighter(private val manifest: Boolean = false) : SyntaxHighlighterBase() {
override fun getHighlightingLexer(): Lexer = LudicLexer(manifest)
override fun getTokenHighlights(tokenType: IElementType): Array<TextAttributesKey> = pack(
when (tokenType) {
LudicTokens.COMMENT -> LudicColors.COMMENT
LudicTokens.STRING, LudicTokens.CHAR -> LudicColors.STRING
LudicTokens.STRING, LudicTokens.CHAR, LudicTokens.TEMPLATE_QUOTE, LudicTokens.TEMPLATE_TEXT -> LudicColors.STRING
LudicTokens.TEMPLATE_ESCAPE -> LudicColors.STRING_ESCAPE
LudicTokens.TEMPLATE_HOLE_START, LudicTokens.TEMPLATE_HOLE_END -> LudicColors.TEMPLATE_BRACES
LudicTokens.NUMBER -> LudicColors.NUMBER
LudicTokens.KEYWORD -> LudicColors.KEYWORD
LudicTokens.DECL_KEYWORD -> LudicColors.DECL_KEYWORD
@ -60,10 +87,11 @@ class LudicSyntaxHighlighter : SyntaxHighlighterBase() {
LudicTokens.IDENTIFIER -> LudicColors.IDENTIFIER
LudicTokens.ANNOTATION -> LudicColors.ANNOTATION
LudicTokens.OPERATOR -> LudicColors.OPERATOR
LudicTokens.BRACE -> LudicColors.BRACE
LudicTokens.BRACKET -> LudicColors.BRACKET
LudicTokens.PAREN -> LudicColors.PAREN
LudicTokens.LBRACE, LudicTokens.RBRACE -> LudicColors.BRACE
LudicTokens.LBRACKET, LudicTokens.RBRACKET -> LudicColors.BRACKET
LudicTokens.LPAREN, LudicTokens.RPAREN -> LudicColors.PAREN
LudicTokens.COMMA -> LudicColors.COMMA
LudicTokens.MANIFEST_KEYWORD -> LudicColors.MANIFEST
LudicTokens.BAD -> LudicColors.BAD
else -> null
}
@ -72,5 +100,5 @@ class LudicSyntaxHighlighter : SyntaxHighlighterBase() {
class LudicSyntaxHighlighterFactory : SyntaxHighlighterFactory() {
override fun getSyntaxHighlighter(project: Project?, virtualFile: VirtualFile?): SyntaxHighlighter =
LudicSyntaxHighlighter()
LudicSyntaxHighlighter(manifest = virtualFile?.name == "package.ludic" || virtualFile?.name == "package.lock.ludic")
}

View file

@ -2,6 +2,7 @@ package io.ludic.ide
import com.intellij.psi.tree.IElementType
import com.intellij.psi.tree.IFileElementType
import com.intellij.psi.tree.TokenSet
/**
* One token type per thing the highlighter needs to tell apart. This is a
@ -16,6 +17,13 @@ object LudicTokens {
val COMMENT = LudicTokenType("COMMENT")
val STRING = LudicTokenType("STRING")
val CHAR = LudicTokenType("CHAR")
// `text {hole} text`: the pieces of a template string
val TEMPLATE_QUOTE = LudicTokenType("TEMPLATE_QUOTE")
val TEMPLATE_TEXT = LudicTokenType("TEMPLATE_TEXT")
val TEMPLATE_ESCAPE = LudicTokenType("TEMPLATE_ESCAPE") // {{ }} \n
val TEMPLATE_HOLE_START = LudicTokenType("TEMPLATE_HOLE_START")
val TEMPLATE_HOLE_END = LudicTokenType("TEMPLATE_HOLE_END")
val TEMPLATE_PARTS = TokenSet.create(TEMPLATE_QUOTE, TEMPLATE_TEXT, TEMPLATE_ESCAPE)
val NUMBER = LudicTokenType("NUMBER")
val IDENTIFIER = LudicTokenType("IDENTIFIER")
val TYPE_NAME = LudicTokenType("TYPE_NAME") // Capitalised: component, archetype, scene
@ -27,10 +35,21 @@ object LudicTokens {
val BOOLEAN = LudicTokenType("BOOLEAN")
val BUILTIN = LudicTokenType("BUILTIN") // runtime surface + compiler intrinsics
val ANNOTATION = LudicTokenType("ANNOTATION") // @deterministic
val MANIFEST_KEYWORD = LudicTokenType("MANIFEST_KEYWORD") // package.ludic: package, require, app, ...
val OPERATOR = LudicTokenType("OPERATOR")
val BRACE = LudicTokenType("BRACE")
val BRACKET = LudicTokenType("BRACKET")
val PAREN = LudicTokenType("PAREN")
// Opening and closing delimiters are distinct types: the platform's brace
// matching (and Enter's "close the unmatched brace") cannot tell `{` from
// `}` by text, only by token type.
val LBRACE = LudicTokenType("LBRACE")
val RBRACE = LudicTokenType("RBRACE")
val LBRACKET = LudicTokenType("LBRACKET")
val RBRACKET = LudicTokenType("RBRACKET")
val LPAREN = LudicTokenType("LPAREN")
val RPAREN = LudicTokenType("RPAREN")
val BRACES = TokenSet.create(LBRACE, RBRACE)
val BRACKETS = TokenSet.create(LBRACKET, RBRACKET)
val PARENS = TokenSet.create(LPAREN, RPAREN)
val DELIMITERS = TokenSet.orSet(BRACES, BRACKETS, PARENS)
val COMMA = LudicTokenType("COMMA")
val BAD = LudicTokenType("BAD_CHARACTER")
}
@ -75,5 +94,25 @@ object LudicVocabulary {
"win_running", "win_close"
)
/**
* Declaration and clause words the compiler does not reserve as names
* (selfhost/frontend/parse.ludic is_reserved_word only guards function
* names): they are keywords only where a declaration can start.
*/
val CONTEXTUAL = setOf(
"program", "import", "property", "model", "enum", "ui", "namespace", "function", "extern",
"handler", "entry", "event", "scene", "test", "phase", "query", "on", "cancellable", "public",
"layer", "start"
)
/**
* The directives parse_manifest (tools/ludic-cli/pkg.ludic) understands,
* and the words of the lock file parse_lock reads.
*/
val MANIFEST = setOf(
"package", "version", "kind", "entry", "provides", "targets", "require", "pack", "app", "script", "hook",
"lock", "module", "hash"
)
fun isKeyword(w: String) = w in DECL || w in CLAUSE || w in STMT
}

View file

@ -0,0 +1,97 @@
package io.ludic.ide.editor
import com.intellij.openapi.command.WriteCommandAction
import com.intellij.openapi.editor.ElementColorProvider
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiElement
import com.intellij.psi.TokenType
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.psi.util.elementType
import io.ludic.ide.LudicTokens
import java.awt.Color
/**
* Colour swatches in the gutter, with the colour picker behind them, for the
* three ways Ludic writes a colour:
*
* Screen.clear(0x0d1020) a 0xRRGGBB int
* Screen.clear(Color.Crimson) a palette name (docs/language/colors/palette.json)
* app splash_bg "#0b1622" a CSS-style string (package.ludic)
*
* Picking a new colour rewrites the literal; a palette name becomes the hex int
* it now is.
*/
class LudicColorPreview : ElementColorProvider {
override fun getColorFrom(element: PsiElement): Color? {
if (element.firstChild != null) return null
return when (element.elementType) {
LudicTokens.NUMBER -> HEX_INT.matchEntire(element.text)?.let { rgb(it.groupValues[1]) }
LudicTokens.STRING -> HEX_STRING.matchEntire(element.text)?.let { rgb(expand(it.groupValues[1])) }
LudicTokens.IDENTIFIER, LudicTokens.TYPE_NAME -> paletteName(element)?.let { LudicPalette.lookup(it)?.let(::rgb) }
else -> null
}
}
override fun setColorTo(element: PsiElement, color: Color) {
val file = element.containingFile ?: return
val doc = PsiDocumentManager.getInstance(element.project).getDocument(file) ?: return
val hex = "%02x%02x%02x".format(color.red, color.green, color.blue)
val (range, text) = when (element.elementType) {
LudicTokens.NUMBER -> element.textRange to "0x$hex"
LudicTokens.STRING -> element.textRange to "\"#$hex\""
else -> {
// `Color.Crimson` -> `0xrrggbb`
val start = paletteReceiver(element)?.textRange?.startOffset ?: return
TextRange(start, element.textRange.endOffset) to "0x$hex"
}
}
WriteCommandAction.runWriteCommandAction(element.project, "Change Colour", null, {
doc.replaceString(range.startOffset, range.endOffset, text)
PsiDocumentManager.getInstance(element.project).commitDocument(doc)
}, file)
}
private fun paletteName(element: PsiElement): String? =
if (paletteReceiver(element) != null) element.text else null
/** The `Color` of `Color.Name`, when element is the Name. */
private fun paletteReceiver(element: PsiElement): PsiElement? {
val dot = prevSignificant(element) ?: return null
if (dot.elementType != LudicTokens.OPERATOR || dot.text != ".") return null
val recv = prevSignificant(dot) ?: return null
return if (recv.text == "Color") recv else null
}
private fun prevSignificant(e: PsiElement): PsiElement? {
var p = PsiTreeUtil.prevLeaf(e)
while (p != null && p.elementType == TokenType.WHITE_SPACE) p = PsiTreeUtil.prevLeaf(p)
return p
}
private companion object {
val HEX_INT = Regex("0[xX]([0-9a-fA-F]{6})")
val HEX_STRING = Regex("\"#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\"")
fun expand(h: String) = if (h.length == 3) h.map { "$it$it" }.joinToString("") else h
fun rgb(hex: String) = Color(hex.toInt(16))
}
}
/** The named colours `Color.*` resolves, from the palette the docs are generated from. */
object LudicPalette {
private val names: Map<String, String> by lazy { load() }
fun lookup(name: String): String? = names[name]
fun all(): Map<String, String> = names
private fun load(): Map<String, String> {
val text = LudicPalette::class.java.getResourceAsStream("/palette.json")?.bufferedReader()?.readText() ?: return emptyMap()
return parse(text)
}
fun parse(json: String): Map<String, String> =
Regex("\"name\"\\s*:\\s*\"([^\"]+)\"\\s*,\\s*\"hex\"\\s*:\\s*\"([0-9A-Fa-f]{6})\"")
.findAll(json).associate { it.groupValues[1] to it.groupValues[2] }
}

View file

@ -0,0 +1,129 @@
package io.ludic.ide.editor
import com.intellij.lang.documentation.AbstractDocumentationProvider
import com.intellij.openapi.util.text.StringUtil
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import java.io.File
/**
* The language reference pages (the docs/language pages) as documentation.
*
* Go-to-definition on a built-in lands on its page, and the IDE then describes
* that target — on Cmd/Ctrl-hover and in Quick Documentation — through
* whatever element the language server handed back, which would otherwise read
* `LSP Psi Element "---" [type-void.md]`. This renders the page's signature,
* summary and body instead.
*/
class LudicDocPageDocumentation : AbstractDocumentationProvider() {
override fun getQuickNavigateInfo(element: PsiElement?, originalElement: PsiElement?): String? {
val page = pageOf(element) ?: return null
val sig = page.meta["sig"]?.takeIf { it.isNotBlank() } ?: page.meta["name"] ?: return null
val tip = page.meta["tip"].orEmpty()
return "<b>${esc(sig)}</b>" + if (tip.isNotEmpty()) "<br>${esc(tip)}" else ""
}
override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
val page = pageOf(element) ?: return null
val sig = page.meta["sig"]?.takeIf { it.isNotBlank() } ?: page.meta["name"] ?: page.meta["title"] ?: return null
val sb = StringBuilder()
sb.append("<div class='definition'><pre>").append(esc(sig)).append("</pre></div><div class='content'>")
page.meta["tip"]?.takeIf { it.isNotBlank() }?.let { sb.append("<p><b>").append(esc(it)).append("</b></p>") }
sb.append(markdownToHtml(page.body))
sb.append("</div>")
page.meta["id"]?.let { id ->
val url = DOCS_URL + (if (page.file.name == "_section.md") "ns-$id" else id) + ".html"
sb.append("<div class='bottom'><a href='").append(url).append("'>").append(url).append("</a></div>")
}
return sb.toString()
}
override fun getUrlFor(element: PsiElement?, originalElement: PsiElement?): List<String>? {
val page = pageOf(element) ?: return null
val id = page.meta["id"] ?: return null
return listOf(DOCS_URL + (if (page.file.name == "_section.md") "ns-$id" else id) + ".html")
}
data class Page(val file: File, val meta: Map<String, String>, val body: String)
companion object {
const val DOCS_URL = "https://workshopsoft.pages.workshopsoft.io/ludic/"
fun pageOf(element: PsiElement?): Page? {
val file: PsiFile = element?.containingFile ?: (element as? PsiFile) ?: return null
val path = file.virtualFile?.path ?: return null
if (!path.contains("/docs/language/") || !path.endsWith(".md")) return null
return parse(File(path), file.text)
}
fun parse(file: File, text: String): Page? {
if (!text.startsWith("---\n")) return null
val end = text.indexOf("\n---", 4)
if (end < 0) return null
val meta = text.substring(4, end).lines().mapNotNull { line ->
val c = line.indexOf(':')
if (c <= 0) null else line.substring(0, c).trim() to line.substring(c + 1).trim()
}.toMap()
val body = text.substring(end + 4).trimStart('-', '\n', '\r')
return Page(file, meta, body)
}
private fun esc(s: String) = StringUtil.escapeXmlEntities(s)
/**
* Just enough markdown for these pages: paragraphs, `code`, **bold**,
* fenced ```ludic blocks and the inline HTML the pages already use.
*/
fun markdownToHtml(md: String): String {
val out = StringBuilder()
val lines = md.lines()
var i = 0
val para = StringBuilder()
fun flush() {
if (para.isNotBlank()) out.append("<p>").append(inline(para.toString().trim())).append("</p>")
para.setLength(0)
}
while (i < lines.size) {
val line = lines[i]
if (line.trimStart().startsWith("```")) {
flush()
val code = StringBuilder()
i++
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
if (!lines[i].trimStart().startsWith("# doc-check")) code.append(lines[i]).append('\n')
i++
}
out.append("<pre><code>").append(esc(code.toString().trimEnd())).append("</code></pre>")
} else if (line.isBlank()) {
flush()
} else {
para.append(line).append(' ')
}
i++
}
flush()
return out.toString()
}
private fun inline(s: String): String {
// the pages mix markdown with a little inline HTML (<code>, <b>);
// keep those tags, escape everything else
val keep = Regex("</?(code|b|i|em|strong|br)>")
val parts = StringBuilder()
var last = 0
for (m in keep.findAll(s)) {
parts.append(mdSpans(esc(s.substring(last, m.range.first))))
parts.append(m.value)
last = m.range.last + 1
}
parts.append(mdSpans(esc(s.substring(last))))
return parts.toString()
}
private fun mdSpans(s: String): String = s
.replace(Regex("`([^`]+)`"), "<code>$1</code>")
.replace(Regex("\\*\\*([^*]+)\\*\\*"), "<b>$1</b>")
.replace(Regex("\\[([^\\]]+)]\\(([^)]+)\\)"), "<a href='$2'>$1</a>")
}
}

View file

@ -0,0 +1,109 @@
package io.ludic.ide.editor
import com.intellij.application.options.CodeStyle
import com.intellij.codeInsight.editorActions.SimpleTokenSetQuoteHandler
import com.intellij.codeInsight.editorActions.TypedHandlerDelegate
import com.intellij.lang.Language
import com.intellij.openapi.editor.Document
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiFile
import com.intellij.psi.codeStyle.lineIndent.LineIndentProvider
import io.ludic.ide.LudicFileType
import io.ludic.ide.LudicLanguage
import io.ludic.ide.LudicLexer
import io.ludic.ide.LudicTokens
/**
* Where a line's indentation belongs, from bracket nesting alone.
*
* Every open `{`, `[` or `(` before the offset is a level; a level's body is
* indented one unit past the *line* its opener sits on, so
*
* handler Move phase Update {
* for (p) in query [Pos] {
* |
*
* lands where it should however the header lines were themselves indented.
* Strings and comments go through the lexer, so a brace inside `"{"` or a
* comment does not count. A line that starts with a closer lines up with its
* opener's line.
*/
object LudicIndenter {
fun indentFor(text: CharSequence, offset: Int, unit: String): String {
val lexer = LudicLexer()
lexer.start(text, 0, offset.coerceIn(0, text.length), 0)
val opens = ArrayDeque<Int>()
while (true) {
val type = lexer.tokenType ?: break
when (type) {
LudicTokens.LBRACE, LudicTokens.LBRACKET, LudicTokens.LPAREN -> opens.addLast(lexer.tokenStart)
LudicTokens.RBRACE, LudicTokens.RBRACKET, LudicTokens.RPAREN -> opens.removeLastOrNull()
}
lexer.advance()
}
val opener = opens.lastOrNull() ?: return ""
val base = leadingWhitespace(text, lineStart(text, opener))
return if (startsWithCloser(text, offset)) base else base + unit
}
/** Does the text from [offset] to the end of its line begin with a closer? */
fun startsWithCloser(text: CharSequence, offset: Int): Boolean {
var i = offset
while (i < text.length && (text[i] == ' ' || text[i] == '\t')) i++
return i < text.length && text[i] in "}])"
}
fun lineStart(text: CharSequence, offset: Int): Int {
var i = offset.coerceAtMost(text.length)
while (i > 0 && text[i - 1] != '\n') i--
return i
}
fun leadingWhitespace(text: CharSequence, from: Int): String {
var i = from
while (i < text.length && (text[i] == ' ' || text[i] == '\t')) i++
return text.subSequence(from, i).toString()
}
fun unit(project: Project): String {
val o = CodeStyle.getSettings(project).getIndentOptions(LudicFileType)
return if (o.USE_TAB_CHARACTER) "\t" else " ".repeat(o.INDENT_SIZE)
}
}
/** Enter: the new line starts at the right depth. */
class LudicLineIndentProvider : LineIndentProvider {
override fun getLineIndent(project: Project, editor: Editor, language: Language?, offset: Int): String? =
LudicIndenter.indentFor(editor.document.charsSequence, offset, LudicIndenter.unit(project))
override fun isSuitableFor(language: Language?) = language == LudicLanguage
}
/**
* Typing a closer as the first thing on a line pulls it back to its opener's
* depth: ` }` becomes ` }` the moment the brace is typed.
*/
class LudicCloserTypedHandler : TypedHandlerDelegate() {
override fun charTyped(c: Char, project: Project, editor: Editor, file: PsiFile): Result {
if (file.fileType != LudicFileType || c !in "}])") return Result.CONTINUE
val doc = editor.document
val caret = editor.caretModel.offset
val closer = caret - 1
if (closer < 0) return Result.CONTINUE
val text = doc.charsSequence
val start = LudicIndenter.lineStart(text, closer)
if (text.subSequence(start, closer).isNotBlank()) return Result.CONTINUE
val want = LudicIndenter.indentFor(text, closer, LudicIndenter.unit(project))
reindent(doc, start, closer, want)
return Result.CONTINUE
}
private fun reindent(doc: Document, start: Int, end: Int, want: String) {
if (doc.charsSequence.subSequence(start, end).toString() == want) return
doc.replaceString(start, end, want)
}
}
class LudicQuoteHandler : SimpleTokenSetQuoteHandler(LudicTokens.STRING, LudicTokens.CHAR, LudicTokens.TEMPLATE_QUOTE)

View file

@ -0,0 +1,53 @@
package io.ludic.ide.editor
import com.intellij.openapi.editor.colors.TextAttributesKey
import io.ludic.ide.LudicColors
/**
* ludic-lsp's semantic tokens, in the plugin's own colour keys, so every
* colour is on Settings | Editor | Color Scheme | Ludic and tells the program's
* code, package code and the built-in language apart.
*
* Keywords, literals, comments and operators return null: the lexer already
* colours those (including contextual keywords and manifest directives), and a
* semantic colour on top would only flatten them.
*/
object LudicSemanticColors {
fun keyFor(type: String, modifiers: List<String>): TextAttributesKey? {
val builtin = "defaultLibrary" in modifiers
val pkg = "vendor" in modifiers
val decl = "declaration" in modifiers
val readonly = "readonly" in modifiers
val global = "static" in modifiers
return when (type) {
"function" -> when {
readonly -> LudicColors.HANDLER // ludic-lsp marks handlers readonly
builtin -> LudicColors.FUNCTION_BUILTIN
pkg -> LudicColors.FUNCTION_PACKAGE
decl -> LudicColors.FUNCTION_DECLARATION
else -> LudicColors.FUNCTION_CALL
}
"variable" -> when {
readonly && builtin -> LudicColors.CONSTANT_BUILTIN
readonly && pkg -> LudicColors.CONSTANT_PACKAGE
readonly -> LudicColors.CONSTANT
global && pkg -> LudicColors.GLOBAL_VARIABLE_PACKAGE
global -> LudicColors.GLOBAL_VARIABLE
else -> LudicColors.LOCAL_VARIABLE
}
"parameter" -> LudicColors.PARAMETER
"property" -> LudicColors.FIELD
"struct" -> if (pkg) LudicColors.TYPE_PACKAGE else LudicColors.PROPERTY_TYPE
"class" -> when {
builtin -> LudicColors.KEYWORD // ui widgets: panel, button, …
pkg -> LudicColors.TYPE_PACKAGE
else -> LudicColors.MODEL_TYPE
}
"namespace" -> if (builtin) LudicColors.NAMESPACE_BUILTIN else LudicColors.NAMESPACE
"enumMember" -> if (readonly && builtin) LudicColors.PHASE else LudicColors.CONSTANT_BUILTIN
"decorator" -> LudicColors.ANNOTATION
"type" -> if (builtin) null else LudicColors.TYPE_NAME
else -> null
}
}
}

View file

@ -0,0 +1,182 @@
package io.ludic.ide.editor
import com.intellij.application.options.CodeStyleAbstractConfigurable
import com.intellij.application.options.CodeStyleAbstractPanel
import com.intellij.application.options.IndentOptionsEditor
import com.intellij.application.options.SmartIndentOptionsEditor
import com.intellij.application.options.TabbedLanguageCodeStylePanel
import com.intellij.lang.Language
import com.intellij.openapi.editor.colors.TextAttributesKey
import com.intellij.openapi.fileTypes.SyntaxHighlighter
import com.intellij.openapi.options.colors.AttributesDescriptor
import com.intellij.openapi.options.colors.ColorDescriptor
import com.intellij.openapi.options.colors.ColorSettingsPage
import com.intellij.psi.codeStyle.CodeStyleConfigurable
import com.intellij.psi.codeStyle.CodeStyleSettings
import com.intellij.psi.codeStyle.CommonCodeStyleSettings
import com.intellij.psi.codeStyle.LanguageCodeStyleSettingsProvider
import io.ludic.ide.LudicColors
import io.ludic.ide.LudicIcons
import io.ludic.ide.LudicLanguage
import io.ludic.ide.LudicSyntaxHighlighter
import javax.swing.Icon
private val SAMPLE = """
# Bouncing balls: properties, a handler per phase, and a test.
program Demo {
import "ludic.render3d/r3d.ludic"
property Pos { x: int = 0, y: int = 0 }
model Score { value: int, best: int }
enum Mode { Title, Playing }
const SPEED: int = 2
var g_mode: Mode = Mode.Title
@deterministic
handler Move phase FixedUpdate {
for (p) in query [Pos] where p.x < 304 {
p.x = p.x + SPEED
if p.x > 0x130 { despawn(self) } else { print("moving") }
}
}
function half(f: fixed) -> fixed { return f / 2 }
test "half of one" {
expect_near(half(fixed(1)), 32768, 0)
}
}
""".trimIndent()
// the sample with semantic tags, as the server would colour it
private val DEMO = """
# Bouncing balls: properties, a handler per phase, and a test.
program Demo {
import "ludic.render3d/r3d.ludic"
property <prop>Pos</prop> { <field>x</field>: int = 0, <field>y</field>: int = 0 }
model <model>Score</model> { <field>value</field>: int, <field>best</field>: int }
const <const>SPEED</const>: int = 2
var <global>g_model</global>: int = 0
@deterministic
handler <handler>Move</handler> phase FixedUpdate {
for (<local>p</local>) in query [<prop>Pos</prop>] where <local>p</local>.<field>x</field> < 304 {
let <local>model</local> = <fpkg>f_clamp</fpkg>(<local>p</local>.<field>x</field>, 0, 0x130)
<local>p</local>.<field>x</field> = <fcall>half</fcall>(<local>model</local>) + <const>SPEED</const>
<fbuiltin>print</fbuiltin>(`x is {<local>p</local>.<field>x</field>} of {{304}}`)
<fbuiltin>print</fbuiltin>(`x is {<local>p</local>.<field>x</field>} of {{304}}\n`)
<ns>Screen</ns>.<fbuiltin>clear</fbuiltin>(<ns>Color</ns>.<cbuiltin>Crimson</cbuiltin>)
}
}
function <fdecl>half</fdecl>(<param>f</param>: fixed) -> fixed { return <param>f</param> / 2 }
test "half of one" {
<fbuiltin>expect_near</fbuiltin>(<fcall>half</fcall>(<fbuiltin>fixed</fbuiltin>(1)), 32768, 0)
}
}
""".trimIndent()
/** Settings | Editor | Color Scheme | Ludic */
class LudicColorSettingsPage : ColorSettingsPage {
override fun getIcon(): Icon = LudicIcons.FILE
override fun getHighlighter(): SyntaxHighlighter = LudicSyntaxHighlighter()
override fun getDemoText() = DEMO
override fun getAdditionalHighlightingTagToDescriptorMap(): Map<String, TextAttributesKey> = mapOf(
"fdecl" to LudicColors.FUNCTION_DECLARATION,
"fcall" to LudicColors.FUNCTION_CALL,
"fpkg" to LudicColors.FUNCTION_PACKAGE,
"fbuiltin" to LudicColors.FUNCTION_BUILTIN,
"handler" to LudicColors.HANDLER,
"const" to LudicColors.CONSTANT,
"cbuiltin" to LudicColors.CONSTANT_BUILTIN,
"global" to LudicColors.GLOBAL_VARIABLE,
"local" to LudicColors.LOCAL_VARIABLE,
"param" to LudicColors.PARAMETER,
"field" to LudicColors.FIELD,
"prop" to LudicColors.PROPERTY_TYPE,
"model" to LudicColors.MODEL_TYPE,
"ns" to LudicColors.NAMESPACE_BUILTIN,
)
override fun getColorDescriptors(): Array<ColorDescriptor> = ColorDescriptor.EMPTY_ARRAY
override fun getDisplayName() = "Ludic"
override fun getAttributeDescriptors(): Array<AttributesDescriptor> = arrayOf(
AttributesDescriptor("Keywords//Declaration (program, property, handler, function…)", LudicColors.DECL_KEYWORD),
AttributesDescriptor("Keywords//Statement (if, for, spawn, return…)", LudicColors.KEYWORD),
AttributesDescriptor("Keywords//Clause (phase, query, on…)", LudicColors.CLAUSE),
AttributesDescriptor("Keywords//Boolean", LudicColors.BOOLEAN),
AttributesDescriptor("Types//Built-in type (int, fixed, entity…)", LudicColors.PRIMITIVE),
AttributesDescriptor("Types//Type or namespace name", LudicColors.TYPE_NAME),
AttributesDescriptor("Phase (Start, Update, Render…)", LudicColors.PHASE),
AttributesDescriptor("Built-in function", LudicColors.BUILTIN),
AttributesDescriptor("Annotation", LudicColors.ANNOTATION),
AttributesDescriptor("Identifier", LudicColors.IDENTIFIER),
AttributesDescriptor("Literals//String and char", LudicColors.STRING),
AttributesDescriptor("Literals//Number", LudicColors.NUMBER),
AttributesDescriptor("Literals//Escape ({{, }}, \\n)", LudicColors.STRING_ESCAPE),
AttributesDescriptor("Literals//Template hole braces", LudicColors.TEMPLATE_BRACES),
AttributesDescriptor("Comment", LudicColors.COMMENT),
AttributesDescriptor("Braces and Operators//Operator", LudicColors.OPERATOR),
AttributesDescriptor("Braces and Operators//Braces", LudicColors.BRACE),
AttributesDescriptor("Braces and Operators//Brackets", LudicColors.BRACKET),
AttributesDescriptor("Braces and Operators//Parentheses", LudicColors.PAREN),
AttributesDescriptor("Braces and Operators//Comma", LudicColors.COMMA),
AttributesDescriptor("Bad character", LudicColors.BAD),
AttributesDescriptor("Manifest directive (package.ludic)", LudicColors.MANIFEST),
AttributesDescriptor("Functions//Declaration", LudicColors.FUNCTION_DECLARATION),
AttributesDescriptor("Functions//Call (this program)", LudicColors.FUNCTION_CALL),
AttributesDescriptor("Functions//Call (package)", LudicColors.FUNCTION_PACKAGE),
AttributesDescriptor("Functions//Call (built-in)", LudicColors.FUNCTION_BUILTIN),
AttributesDescriptor("Functions//Handler", LudicColors.HANDLER),
AttributesDescriptor("Constants//This program", LudicColors.CONSTANT),
AttributesDescriptor("Constants//Package", LudicColors.CONSTANT_PACKAGE),
AttributesDescriptor("Constants//Built-in (Color.Gold, …)", LudicColors.CONSTANT_BUILTIN),
AttributesDescriptor("Variables//Global (this program)", LudicColors.GLOBAL_VARIABLE),
AttributesDescriptor("Variables//Global (package)", LudicColors.GLOBAL_VARIABLE_PACKAGE),
AttributesDescriptor("Variables//Local", LudicColors.LOCAL_VARIABLE),
AttributesDescriptor("Variables//Parameter", LudicColors.PARAMETER),
AttributesDescriptor("Variables//Field", LudicColors.FIELD),
AttributesDescriptor("Types//Property (component)", LudicColors.PROPERTY_TYPE),
AttributesDescriptor("Types//Model", LudicColors.MODEL_TYPE),
AttributesDescriptor("Types//From a package", LudicColors.TYPE_PACKAGE),
AttributesDescriptor("Types//Namespace", LudicColors.NAMESPACE),
AttributesDescriptor("Types//Built-in namespace (Screen, Math, …)", LudicColors.NAMESPACE_BUILTIN),
)
}
/**
* Settings | Editor | Code Style | Ludic. Two-space indents, spaces not tabs:
* the house style `ludic fmt` produces. Enter, closing braces and Reformat all
* read the indent from here.
*/
class LudicCodeStyleSettingsProvider : LanguageCodeStyleSettingsProvider() {
override fun getLanguage(): Language = LudicLanguage
override fun getCodeSample(settingsType: SettingsType) = SAMPLE
override fun getIndentOptionsEditor(): IndentOptionsEditor = SmartIndentOptionsEditor()
override fun customizeDefaults(common: CommonCodeStyleSettings, indentOptions: CommonCodeStyleSettings.IndentOptions) {
indentOptions.INDENT_SIZE = 2
indentOptions.CONTINUATION_INDENT_SIZE = 2
indentOptions.TAB_SIZE = 2
indentOptions.USE_TAB_CHARACTER = false
common.LINE_COMMENT_AT_FIRST_COLUMN = false
common.LINE_COMMENT_ADD_SPACE = true
}
override fun createConfigurable(baseSettings: CodeStyleSettings, modelSettings: CodeStyleSettings): CodeStyleConfigurable =
object : CodeStyleAbstractConfigurable(baseSettings, modelSettings, "Ludic") {
override fun createPanel(settings: CodeStyleSettings): CodeStyleAbstractPanel =
object : TabbedLanguageCodeStylePanel(LudicLanguage, currentSettings, settings) {
override fun initTabs(settings: CodeStyleSettings) {
addIndentOptionsTab(settings)
}
}
}
override fun getConfigurableDisplayName() = "Ludic"
}

View file

@ -0,0 +1,65 @@
package io.ludic.ide.editor
import com.intellij.lang.documentation.AbstractDocumentationProvider
import com.intellij.openapi.util.text.StringUtil
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiElement
import com.redhat.devtools.lsp4ij.LanguageServerManager
import io.ludic.ide.LudicLanguage
import org.eclipse.lsp4j.HoverParams
import org.eclipse.lsp4j.Position
import org.eclipse.lsp4j.TextDocumentIdentifier
import java.util.concurrent.TimeUnit
/**
* What the IDE says about a declaration the language server navigated to.
*
* Go-to-definition hands the IDE a synthetic element for the target, which it
* would describe on Cmd/Ctrl-hover as `LSP Symbol "dists" [valley.ludic]`.
* This asks ludic-lsp for its hover at that spot instead, so the tooltip is
* the declaration and its type: `let dists: words`.
*/
class LudicTargetDocumentation : AbstractDocumentationProvider() {
override fun getQuickNavigateInfo(element: PsiElement?, originalElement: PsiElement?): String? {
val md = hoverAt(element) ?: return null
val code = codeOf(md) ?: return null
val where = element?.containingFile?.name?.let { " <span class='grayed'>$it</span>" } ?: ""
return "<b>${StringUtil.escapeXmlEntities(code)}</b>$where"
}
override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
val md = hoverAt(element) ?: return null
val code = codeOf(md)
val rest = md.substringAfter("```", "").substringAfter("```", md).trim()
val sb = StringBuilder()
if (code != null) sb.append("<div class='definition'><pre>").append(StringUtil.escapeXmlEntities(code)).append("</pre></div>")
if (rest.isNotEmpty()) sb.append("<div class='content'>").append(LudicDocPageDocumentation.markdownToHtml(rest)).append("</div>")
return sb.toString().ifEmpty { null }
}
private fun codeOf(md: String): String? {
val m = Regex("```ludic\\n([\\s\\S]*?)\\n```").find(md) ?: return null
return m.groupValues[1].trim()
}
/** ludic-lsp's hover markdown at the element's start, or null. */
private fun hoverAt(element: PsiElement?): String? {
if (element == null || !element.javaClass.name.startsWith("com.redhat.devtools.lsp4ij")) return null
val file = element.containingFile ?: return null
if (file.language != LudicLanguage) return null
val vf = file.virtualFile ?: return null
val doc = PsiDocumentManager.getInstance(file.project).getDocument(file) ?: return null
val offset = element.textOffset.coerceIn(0, doc.textLength)
val line = doc.getLineNumber(offset)
val pos = Position(line, offset - doc.getLineStartOffset(line))
return runCatching {
val item = LanguageServerManager.getInstance(file.project).getLanguageServer("ludic").get(500, TimeUnit.MILLISECONDS)
?: return null
val params = HoverParams(TextDocumentIdentifier(vf.toNioPath().toUri().toString()), pos)
val hover = item.server.textDocumentService.hover(params).get(800, TimeUnit.MILLISECONDS) ?: return null
val contents = hover.contents
if (contents.isRight) contents.right.value else null
}.getOrNull()
}
}

View file

@ -0,0 +1,63 @@
package io.ludic.ide.editor
import com.intellij.codeInsight.template.TemplateActionContext
import com.intellij.codeInsight.template.TemplateContextType
import com.intellij.ide.actions.CreateFileFromTemplateAction
import com.intellij.ide.actions.CreateFileFromTemplateDialog
import com.intellij.ide.fileTemplates.FileTemplate
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiDirectory
import com.intellij.psi.PsiFile
import com.intellij.psi.util.PsiUtilCore
import io.ludic.ide.LudicIcons
import io.ludic.ide.LudicLanguage
import io.ludic.ide.LudicTokens
/** Live templates (`prog`, `test`, `handler`, `fn`, …) apply in Ludic code, not inside strings or comments. */
class LudicTemplateContext : TemplateContextType("Ludic") {
override fun isInContext(ctx: TemplateActionContext): Boolean {
val file = ctx.file
if (!PsiUtilCore.getLanguageAtOffset(file, ctx.startOffset).isKindOf(LudicLanguage)) return false
val at = file.findElementAt((ctx.startOffset - 1).coerceAtLeast(0)) ?: return true
val type = at.node.elementType
return type != LudicTokens.STRING && type != LudicTokens.COMMENT && type != LudicTokens.CHAR
}
}
/**
* File | New | Ludic File. The kinds mirror what a project holds: a program
* (the entry), a module imported by one, and a test program for tests/.
*/
class CreateLudicFileAction : CreateFileFromTemplateAction(), DumbAware {
override fun buildDialog(project: Project, directory: PsiDirectory, builder: CreateFileFromTemplateDialog.Builder) {
builder.setTitle("New Ludic File")
.addKind("Program", LudicIcons.FILE, PROGRAM)
.addKind("Module", LudicIcons.FILE, MODULE)
.addKind("Test", LudicIcons.FILE, TEST)
.addKind("Empty file", LudicIcons.FILE, EMPTY)
}
override fun getActionName(directory: PsiDirectory, newName: String, templateName: String) = "Create Ludic file $newName"
override fun createFileFromTemplate(name: String, template: FileTemplate, dir: PsiDirectory): PsiFile? {
val base = name.removeSuffix(".ludic")
return createFileFromTemplate(base, template, dir, null, true, emptyMap(), mapOf("PROGRAM_NAME" to programName(base)))
}
companion object {
const val PROGRAM = "Ludic Program"
const val MODULE = "Ludic Module"
const val TEST = "Ludic Test"
const val EMPTY = "Ludic File"
/** `enemy_ai` -> `EnemyAi`: a file name as a program name. */
fun programName(file: String): String {
val parts = file.split('_', '-', '.', ' ').filter { it.isNotEmpty() }
val joined = parts.joinToString("") { p -> p.replaceFirstChar { it.uppercaseChar() } }
val clean = joined.filter { it.isLetterOrDigit() }
return if (clean.isEmpty() || clean[0].isDigit()) "P$clean" else clean
}
}
}

View file

@ -0,0 +1,195 @@
package io.ludic.ide.project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.PsiFile
import java.io.File
/**
* `package.ludic`, read the way `parse_manifest` (tools/ludic-cli/pkg.ludic)
* reads it: one directive per line, `#` comments, double-quoted strings.
*/
data class LudicManifest(
val module: String?,
val version: String?,
val kind: String?,
val requires: List<Require>,
val entry: String? = null,
val app: Map<String, String> = emptyMap(),
val packs: List<String> = emptyList(),
val scripts: List<Script> = emptyList(),
val hooks: List<Hook> = emptyList(),
) {
data class Script(val name: String, val command: String, val line: Int)
data class Hook(val phase: String, val event: String, val command: String, val line: Int)
data class Require(val module: String, val version: String, val line: Int)
/** One `module "…" version "…" hash "…" kind "…" provides "…"` line of the lock. */
data class Locked(val module: String, val version: String, val hash: String, val kind: String, val provides: List<String>, val line: Int)
companion object {
const val FILE_NAME = "package.ludic"
const val LOCK_NAME = "package.lock.ludic"
fun isManifest(file: VirtualFile?) = file?.name == FILE_NAME
fun isManifest(file: PsiFile?) = file?.name == FILE_NAME
fun parse(text: CharSequence): LudicManifest {
var module: String? = null
var version: String? = null
var kind: String? = null
var entry: String? = null
val requires = mutableListOf<Require>()
val app = linkedMapOf<String, String>()
val packs = mutableListOf<String>()
val scripts = mutableListOf<Script>()
val hooks = mutableListOf<Hook>()
text.lines().forEachIndexed { i, raw ->
val words = words(raw)
when (words.firstOrNull()) {
"package" -> module = words.getOrNull(1)
"version" -> version = words.getOrNull(1)
"kind" -> kind = words.getOrNull(1)
"entry" -> entry = words.getOrNull(1)
"require" -> if (words.size > 2) requires += Require(words[1], words[2], i)
"pack" -> packs += words.drop(1)
"app" -> if (words.size > 2) app[words[1]] = words[2]
"script" -> if (words.size > 2) scripts += Script(words[1], words[2], i)
"hook" -> if (words.size > 3) hooks += Hook(words[1], words[2], words[3], i)
}
}
return LudicManifest(module, version, kind, requires, entry, app, packs, scripts, hooks)
}
fun parseLock(text: CharSequence): List<Locked> = text.lines().mapIndexedNotNull { i, raw ->
val w = words(raw)
if (w.size < 2 || w[0] != "module") return@mapIndexedNotNull null
val kv = mutableMapOf<String, String>()
val provides = mutableListOf<String>()
var k = 2
while (k + 1 < w.size) {
if (w[k] == "provides") provides += w[k + 1] else kv[w[k]] = w[k + 1]
k += 2
}
Locked(w[1], kv["version"].orEmpty(), kv["hash"].orEmpty(), kv["kind"] ?: "source", provides, i)
}
fun isLock(file: VirtualFile?) = file?.name == LOCK_NAME
fun isLock(file: PsiFile?) = file?.name == LOCK_NAME
/** Directives whose values are paths, relative to the package root. */
val PATH_DIRECTIVES = setOf("entry", "pack")
val PATH_APP_KEYS = setOf("icon", "splash", "native", "out")
/** The words of a manifest line: bare words and quoted strings, comment dropped. */
fun words(line: String): List<String> {
val out = mutableListOf<String>()
var i = 0
while (i < line.length) {
val c = line[i]
when {
c == '#' -> return out
c.isWhitespace() -> i++
c == '"' -> {
// a quoted word, with \" and \\ escapes as the CLI reads them
val sb = StringBuilder()
i++
while (i < line.length && line[i] != '"') {
if (line[i] == '\\' && i + 1 < line.length) { sb.append(line[i + 1]); i += 2 } else { sb.append(line[i]); i++ }
}
out += sb.toString()
i++
}
else -> {
val s = i
while (i < line.length && !line[i].isWhitespace() && line[i] != '#') i++
out += line.substring(s, i)
}
}
}
return out
}
/**
* Why the dependencies need fetching, or null when they look current:
* no lock yet, a manifest edited after the lock, or nothing linked.
*/
fun staleReason(dir: File, manifest: LudicManifest): String? {
if (manifest.requires.isEmpty()) return null
val man = File(dir, FILE_NAME)
val lock = File(dir, LOCK_NAME)
if (!lock.isFile) return "There is no $LOCK_NAME yet."
if (man.lastModified() > lock.lastModified()) return "$FILE_NAME changed after the lock was written."
val modules = File(dir, System.getenv("LUDIC_MODULES") ?: "ludic_modules")
if (!modules.isDirectory) return "Nothing is linked into ${modules.name}/ yet."
return null
}
/** Directive -> what it does, for completion and quick documentation. */
val DIRECTIVES = linkedMapOf(
"package" to "This package's import path, e.g. \"git.example.com/me/pkg\". Optional for a game.",
"version" to "The version this checkout publishes (a git tag vX.Y.Z publishes it).",
"kind" to "source (compiled with the game) or prebuilt (ships a dylib per target).",
"entry" to "The program `ludic run`, `build` and `bundle` compile, e.g. \"src/game.ludic\".",
"provides" to "The Foo.* namespace(s) this package registers. Repeatable.",
"targets" to "For a prebuilt package: the targets it ships, e.g. \"native-arm64\" \"wasm32\".",
"require" to "A dependency and its minimum version: require \"host/user/pkg\" \"1.0.0\".",
"pack" to "An asset root `ludic pack` walks into the .lpak. Repeatable, in order.",
"app" to "App bundle metadata for `ludic bundle`: app <key> \"value\".",
"script" to "A named command: script \"dev\" \"ludic run --headless\". Run it with `ludic dev` or `ludic script dev`; extra arguments are passed on.",
"hook" to "A command around another: hook before|after <command> \"…\". A failing before hook stops the command; after hooks run only when it succeeded.",
)
val HOOK_PHASES = linkedMapOf(
"before" to "Runs first; a non-zero exit stops the command.",
"after" to "Runs once the command succeeded.",
)
/** The built-in commands a hook can wrap (tools/ludic-cli/scripts.ludic is_hookable). */
val HOOK_EVENTS = linkedMapOf(
"build" to "ludic build", "run" to "ludic run", "test" to "ludic test", "bundle" to "ludic bundle",
"pack" to "ludic pack", "clean" to "ludic clean", "fmt" to "ludic fmt", "get" to "ludic get",
"add" to "ludic add", "update" to "ludic update", "verify" to "ludic verify", "vendor" to "ludic vendor",
"assets" to "ludic assets", "build-lib" to "ludic build-lib",
)
/** Commands a script name would be shadowed by (the CLI's own). */
val BUILTIN_COMMANDS = setOf(
"new", "build", "run", "test", "clean", "fmt", "lsp", "doctor", "upgrade", "script", "scripts", "add", "get",
"update", "verify", "vendor", "assets", "pack", "bundle", "build-lib", "link-flags", "version", "help", "dev",
)
val KINDS = linkedMapOf(
"source" to "Compiled from source together with the program that imports it.",
"prebuilt" to "Ships a compiled library per target (see targets); built with `ludic build-lib`.",
)
/** What each lock-file word means. */
val LOCK_WORDS = linkedMapOf(
"lock" to "The lock format version.",
"module" to "A resolved package: its import path, then the pinned facts about it.",
"version" to "The version Minimum Version Selection picked.",
"hash" to "The content hash of the package in the store; `ludic verify` re-checks it.",
"kind" to "source or prebuilt, as the package declares.",
"provides" to "A Foo.* namespace the package registers.",
)
fun isSemver(v: String) = Regex("\\d+\\.\\d+\\.\\d+([-+][0-9A-Za-z.-]+)?").matches(v)
/** The keys `ludic bundle` reads from `app` lines (tools/ludic-cli/bundle.ludic). */
val APP_KEYS = linkedMapOf(
"name" to "The app's display name.",
"id" to "The bundle identifier, e.g. io.example.game.",
"version" to "The bundle version (defaults to the package version).",
"icon" to "A square PNG the .icns is built from.",
"splash" to "An image shown while the game loads; it must be inside a packed asset root.",
"splash_bg" to "The splash background colour, \"#rrggbb\".",
"category" to "The macOS app category, e.g. public.app-category.games.",
"copyright" to "The copyright line shown in Finder.",
"min_macos" to "The minimum macOS version, e.g. \"11.0\".",
"native" to "A directory of native libraries copied beside the executable.",
"out" to "Where the .app is written (default build/<name>.app).",
"sign" to "The codesign identity (default \"-\": ad-hoc signing).",
)
}
}

View file

@ -0,0 +1,390 @@
package io.ludic.ide.project
import com.intellij.codeInsight.completion.CompletionContributor
import com.intellij.codeInsight.completion.CompletionParameters
import com.intellij.codeInsight.completion.CompletionProvider
import com.intellij.codeInsight.completion.CompletionResultSet
import com.intellij.codeInsight.completion.CompletionType
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandler
import com.intellij.icons.AllIcons
import com.intellij.lang.annotation.AnnotationHolder
import com.intellij.lang.annotation.Annotator
import com.intellij.lang.annotation.HighlightSeverity
import com.intellij.lang.documentation.AbstractDocumentationProvider
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.util.text.StringUtil
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.patterns.PlatformPatterns
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiManager
import com.intellij.psi.util.elementType
import com.intellij.util.ProcessingContext
import io.ludic.ide.LudicLanguage
import io.ludic.ide.LudicTokens
import java.io.File
private fun isPackageFile(file: PsiFile?) = LudicManifest.isManifest(file) || LudicManifest.isLock(file)
private fun unquote(s: String) = s.removePrefix("\"").removeSuffix("\"")
/**
* What a string on a manifest line points at: a file or directory for path
* values (`entry`, `pack`, `app icon|splash|native|out`), a package directory
* for `require` / lock `module` lines. Null for anything else.
*/
internal fun manifestTarget(element: PsiElement): VirtualFile? = manifestTargetSpec(element)?.let { spec ->
when (spec) {
is TargetSpec.Path -> spec.relative?.let { rel -> spec.base?.findFileByRelativePath(rel) }
?: spec.absolute?.let { LocalFileSystem.getInstance().findFileByIoFile(it) }
is TargetSpec.Package -> spec.dir?.let { LocalFileSystem.getInstance().findFileByIoFile(it) }
}
}
internal sealed class TargetSpec {
class Path(val base: VirtualFile?, val relative: String?, val absolute: File?) : TargetSpec()
class Package(val dir: File?) : TargetSpec()
}
private fun manifestTargetSpec(element: PsiElement): TargetSpec? {
if (element.elementType != LudicTokens.STRING) return null
val file = element.containingFile ?: return null
if (!isPackageFile(file)) return null
val line = ManifestLine.at(element) ?: return null
val value = unquote(element.text)
val words = line.words
val isPath = when (line.directive) {
in LudicManifest.PATH_DIRECTIVES -> true
"app" -> words.getOrNull(1) in LudicManifest.PATH_APP_KEYS && words.getOrNull(2) == value
else -> false
}
if (isPath && value.isNotBlank()) {
return if (File(value).isAbsolute) TargetSpec.Path(null, null, File(value))
else TargetSpec.Path(file.originalFile.virtualFile?.parent, value, null)
}
if ((line.directive == "require" || line.directive == "module") && words.getOrNull(1) == value) {
return TargetSpec.Package(packageDir(file, value))
}
return null
}
/** Where a required package is on disk: ludic_modules/, vendor/, or the toolchain. */
internal fun packageDir(file: PsiFile, module: String): File? {
val dir = file.virtualFile?.parent?.path ?: return null
val modules = System.getenv("LUDIC_MODULES") ?: "ludic_modules"
return listOfNotNull(
File(File(dir, modules), module),
File(File(dir, "vendor"), module),
io.ludic.ide.LudicToolchain.home(file.project)?.let { File(File(it, "packages"), module) },
).firstOrNull { it.exists() }
}
/** Cmd/Ctrl-click on a manifest path or package opens it. */
class LudicManifestGotoHandler : GotoDeclarationHandler {
override fun getGotoDeclarationTargets(sourceElement: PsiElement?, offset: Int, editor: Editor?): Array<PsiElement>? {
val el = sourceElement ?: return null
val vf = manifestTarget(el) ?: return null
val psi = PsiManager.getInstance(el.project)
var node = vf
// a package directory opens on its manifest, where its story starts
if (vf.isDirectory) vf.findChild(LudicManifest.FILE_NAME)?.let { node = it }
val found: PsiElement? = if (node.isDirectory) psi.findDirectory(node) else psi.findFile(node)
return found?.let { arrayOf(it) }
}
}
private val WORD = Regex("[A-Za-z_][A-Za-z0-9_]*")
private val WORD_OR_DASH = Regex("[A-Za-z_][A-Za-z0-9_-]*")
/** Mistakes the CLI would otherwise report late, or silently ignore. */
class LudicManifestAnnotator : Annotator {
override fun annotate(element: PsiElement, holder: AnnotationHolder) {
val file = element.containingFile ?: return
if (!isPackageFile(file) || element.firstChild != null) return
val lock = LudicManifest.isLock(file)
val line = ManifestLine.at(element) ?: return
when {
element.elementType == LudicTokens.STRING -> checkString(element, line, file, lock, holder)
line.startsWith(element) && WORD.matches(element.text) -> {
val known = if (lock) LudicManifest.LOCK_WORDS.keys else LudicManifest.DIRECTIVES.keys
if (element.text !in known) {
holder.newAnnotation(HighlightSeverity.WARNING, "Unknown directive '${element.text}': ludic ignores this line")
.range(element).create()
}
}
!lock && line.directive == "hook" && !line.startsWith(element) && WORD_OR_DASH.matches(element.text) -> {
val w = line.words
if (w.getOrNull(1) == element.text && element.text !in LudicManifest.HOOK_PHASES) {
holder.newAnnotation(HighlightSeverity.ERROR, "A hook runs before or after").range(element).create()
} else if (w.getOrNull(2) == element.text && element.text !in LudicManifest.HOOK_EVENTS &&
LudicManifest.parse(file.text).scripts.none { it.name == element.text }) {
holder.newAnnotation(HighlightSeverity.ERROR, "No command or script named '${element.text}': hooks wrap ${LudicManifest.HOOK_EVENTS.keys.joinToString()} or a script")
.range(element).create()
}
}
!lock && line.directive == "kind" && line.words.getOrNull(1) == element.text && !line.startsWith(element) -> {
if (element.text !in LudicManifest.KINDS) {
holder.newAnnotation(HighlightSeverity.ERROR, "kind must be one of: ${LudicManifest.KINDS.keys.joinToString()}")
.range(element).create()
}
}
}
}
private fun checkString(element: PsiElement, line: ManifestLine, file: PsiFile, lock: Boolean, holder: AnnotationHolder) {
val value = unquote(element.text)
val words = line.words
if (!lock && line.directive == "version" && words.getOrNull(1) == value && !LudicManifest.isSemver(value)) {
holder.newAnnotation(HighlightSeverity.WARNING, "Not a semantic version (MAJOR.MINOR.PATCH): a git tag v$value will not resolve")
.range(element).create()
return
}
if (!lock && line.directive == "require" && words.getOrNull(2) == value && !LudicManifest.isSemver(value)) {
holder.newAnnotation(HighlightSeverity.WARNING, "A minimum version is MAJOR.MINOR.PATCH")
.range(element).create()
return
}
if (!lock && line.directive == "script" && words.getOrNull(1) == value && value in LudicManifest.BUILTIN_COMMANDS) {
holder.newAnnotation(HighlightSeverity.WARNING, "'ludic $value' is a built-in command: run this script with `ludic script $value`")
.range(element).create()
return
}
if (!lock && line.directive == "app" && words.getOrNull(1) == "splash_bg" && words.getOrNull(2) == value &&
!Regex("#[0-9a-fA-F]{6}").matches(value)) {
holder.newAnnotation(HighlightSeverity.WARNING, "splash_bg is a colour: \"#rrggbb\"").range(element).create()
return
}
val spec = manifestTargetSpec(element) ?: return
if (spec is TargetSpec.Package) {
if (spec.dir == null) {
holder.newAnnotation(HighlightSeverity.WEAK_WARNING, "$value is not fetched yet: run `ludic get`")
.range(element).create()
}
return
}
if (manifestTarget(element) == null) {
// `out` names something bundle creates
if (line.directive == "app" && words.getOrNull(1) == "out") return
holder.newAnnotation(HighlightSeverity.ERROR, "No such file or directory: $value")
.range(TextRange(element.textRange.startOffset + 1, element.textRange.endOffset - 1)).create()
}
}
}
/** Completion in package.ludic: directives at the start of a line, then their values. */
class LudicManifestCompletion : CompletionContributor() {
init {
extend(CompletionType.BASIC, PlatformPatterns.psiElement().withLanguage(LudicLanguage), object : CompletionProvider<CompletionParameters>() {
override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) {
val file = parameters.originalFile
if (!LudicManifest.isManifest(file)) {
if (LudicManifest.isLock(file)) result.stopHere()
return
}
val doc = parameters.editor.document
val offset = parameters.offset
val line = doc.getLineNumber(offset)
val before = doc.getText(TextRange(doc.getLineStartOffset(line), offset))
if (before.contains('#')) return
val words = LudicManifest.words(before)
val typing = before.isNotEmpty() && !before.last().isWhitespace()
val inString = before.count { it == '"' } % 2 == 1
val done = if (typing && !inString) words.dropLast(1) else if (inString) words.dropLast(1) else words
val dir = file.virtualFile?.parent?.path?.let(::File)
when {
done.isEmpty() -> for ((d, help) in LudicManifest.DIRECTIVES) {
result.addElement(LookupElementBuilder.create("$d ").withPresentableText(d).bold().withTypeText(help, true).withIcon(LudicPackageIcons.MANIFEST))
}
done == listOf("app") -> for ((k, help) in LudicManifest.APP_KEYS) {
result.addElement(LookupElementBuilder.create("$k ").withPresentableText(k).withTypeText(help, true))
}
done == listOf("kind") -> for ((k, help) in LudicManifest.KINDS) {
result.addElement(LookupElementBuilder.create(k).withTypeText(help, true))
}
dir != null && done == listOf("entry") ->
paths(dir, inString) { it.isFile && it.extension == "ludic" && isProgram(it) }.forEach(result::addElement)
dir != null && done.firstOrNull() == "pack" ->
paths(dir, inString) { it.isDirectory }.forEach(result::addElement)
dir != null && done.size == 2 && done[0] == "app" && done[1] in setOf("icon", "splash") ->
paths(dir, inString) { it.isFile && it.extension.lowercase() in setOf("png", "jpg", "jpeg") }.forEach(result::addElement)
dir != null && done.size == 2 && done[0] == "app" && done[1] == "native" ->
paths(dir, inString) { it.isDirectory }.forEach(result::addElement)
done == listOf("hook") -> for ((p, help) in LudicManifest.HOOK_PHASES) {
result.addElement(LookupElementBuilder.create("$p ").withPresentableText(p).withTypeText(help, true))
}
done.size == 2 && done[0] == "hook" -> {
for ((ev, cmd) in LudicManifest.HOOK_EVENTS) {
result.addElement(LookupElementBuilder.create("$ev ").withPresentableText(ev).withTypeText(cmd, true))
}
for (s in LudicManifest.parse(doc.charsSequence).scripts) {
result.addElement(LookupElementBuilder.create("${s.name} ").withPresentableText(s.name).withTypeText("script: ${s.command}", true).withIcon(AllIcons.Nodes.Console))
}
}
done.size == 2 && done[0] == "app" && done[1] == "category" -> CATEGORIES.forEach {
result.addElement(LookupElementBuilder.create(if (inString) it else "\"$it\""))
}
}
result.stopHere()
}
})
}
private fun isProgram(f: File) = runCatching { f.useLines { l -> l.any { it.startsWith("program ") } } }.getOrDefault(false)
private fun paths(root: File, inString: Boolean, keep: (File) -> Boolean) =
root.walkTopDown()
.onEnter { d -> d == root || !(d.name.startsWith(".") || d.name == "build" || d.name == "ludic_modules" || d.name == "node_modules") }
.maxDepth(4)
.filter { it != root && keep(it) }
.take(200)
.map { f ->
val rel = f.relativeTo(root).path
LookupElementBuilder.create(if (inString) rel else "\"$rel\"")
.withPresentableText(rel)
.withIcon(if (f.isDirectory) AllIcons.Nodes.Folder else AllIcons.FileTypes.Any_type)
}
.toList()
private companion object {
val CATEGORIES = listOf(
"public.app-category.games", "public.app-category.action-games", "public.app-category.adventure-games",
"public.app-category.arcade-games", "public.app-category.puzzle-games", "public.app-category.role-playing-games",
"public.app-category.simulation-games", "public.app-category.strategy-games", "public.app-category.sports-games",
)
}
}
/**
* Quick documentation (F1 / hover) in package.ludic and its lock: what each
* directive and value means, and for any `app` line a preview of the app all
* the `app` lines describe together.
*/
class LudicManifestDocumentation : AbstractDocumentationProvider() {
override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
val el = originalElement ?: element ?: return null
val file = el.containingFile ?: return null
if (!isPackageFile(file)) return null
val line = ManifestLine.at(el) ?: return null
if (LudicManifest.isLock(file)) return lockDoc(file, line)
return when (line.directive) {
"app" -> appPreview(file, highlight = line.words.getOrNull(1))
null -> null
else -> directiveDoc(file, line)
}
}
override fun getCustomDocumentationElement(editor: Editor, file: PsiFile, contextElement: PsiElement?, targetOffset: Int): PsiElement? =
if (isPackageFile(file)) contextElement else null
private fun esc(s: String) = StringUtil.escapeXmlEntities(s)
private fun directiveDoc(file: PsiFile, line: ManifestLine): String? {
val d = line.directive ?: return null
val help = LudicManifest.DIRECTIVES[d] ?: return null
val value = line.words.getOrNull(1)
val sb = StringBuilder("<div class='definition'><pre>").append(esc(line.text.substringBefore('#').trim())).append("</pre></div>")
sb.append("<div class='content'><p>").append(esc(help)).append("</p>")
when (d) {
"kind" -> LudicManifest.KINDS[value]?.let { sb.append("<p><b>").append(esc(value!!)).append("</b>: ").append(esc(it)).append("</p>") }
"version" -> if (value != null) sb.append("<p>Published by the git tag <code>v").append(esc(value)).append("</code>.</p>")
"package" -> if (value != null) sb.append("<p>Others depend on it with <code>require \"").append(esc(value)).append("\" \"").append(esc(LudicManifest.parse(file.text).version ?: "1.0.0")).append("\"</code>.</p>")
"require" -> requireDetail(file, line, sb)
"hook" -> {
line.words.getOrNull(1)?.let { p -> LudicManifest.HOOK_PHASES[p]?.let { sb.append("<p><b>").append(esc(p)).append("</b>: ").append(esc(it)).append("</p>") } }
line.words.getOrNull(2)?.let { ev ->
val what = LudicManifest.HOOK_EVENTS[ev] ?: LudicManifest.parse(file.text).scripts.firstOrNull { it.name == ev }?.let { "the script ${it.name}: ${it.command}" }
if (what != null) sb.append("<p>Wraps <code>").append(esc(what)).append("</code></p>")
}
sb.append("<p>Runs through the shell in the package root with <code>LUDIC_EVENT</code>, <code>LUDIC_PHASE</code> and <code>LUDIC_PACKAGE_ROOT</code> set.</p>")
}
"script" -> {
val name = line.words.getOrNull(1)
if (name != null) {
val how = if (name in LudicManifest.BUILTIN_COMMANDS) "ludic script $name" else "ludic $name"
sb.append("<p>Run with <code>").append(esc(how)).append(" [args]</code>.</p>")
val hooks = LudicManifest.parse(file.text).hooks.filter { it.event == name }
hooks.forEach { sb.append("<p>").append(esc(it.phase)).append(": <code>").append(esc(it.command)).append("</code></p>") }
}
}
"entry", "pack" -> line.words.drop(1).forEach { p ->
val f = resolveManifestPath(file, p)
sb.append("<p><code>").append(esc(p)).append("</code> — ")
.append(if (f?.exists() == true) (if (f.isDirectory) "directory" else "file") else "<b>missing</b>").append("</p>")
}
}
sb.append("</div>")
return sb.toString()
}
private fun requireDetail(file: PsiFile, line: ManifestLine, sb: StringBuilder) {
val module = line.words.getOrNull(1) ?: return
val lockFile = file.virtualFile?.parent?.findChild(LudicManifest.LOCK_NAME)
val locked = lockFile?.let { runCatching { LudicManifest.parseLock(String(it.contentsToByteArray())) }.getOrNull() }
?.firstOrNull { it.module == module }
if (locked == null) sb.append("<p>Not in the lock yet: run <code>ludic get</code>.</p>")
else sb.append("<p>Locked at <b>").append(esc(locked.version)).append("</b> (").append(esc(locked.kind)).append(")<br>hash <code>")
.append(esc(locked.hash.take(16))).append("…</code></p>")
val dir = packageDir(file, module)
sb.append("<p>").append(if (dir != null) "Linked at <code>${esc(dir.path)}</code>" else "Not linked into ludic_modules/").append("</p>")
}
private fun lockDoc(file: PsiFile, line: ManifestLine): String? {
if (line.directive != "module") return line.directive?.let { LudicManifest.LOCK_WORDS[it] }?.let { "<div class='content'>${esc(it)}</div>" }
val m = LudicManifest.parseLock(line.text).firstOrNull() ?: return null
val dir = packageDir(file, m.module)
return buildString {
append("<div class='definition'><pre>").append(esc(m.module)).append("</pre></div><table class='sections'>")
row("Version", esc(m.version))
row("Kind", esc(m.kind))
if (m.provides.isNotEmpty()) row("Provides", m.provides.joinToString { "<code>${esc(it)}.*</code>" })
row("Hash", "<code>${esc(m.hash)}</code>")
row("On disk", if (dir != null) "<code>${esc(dir.path)}</code>" else "not linked — run <code>ludic get</code>")
append("</table>")
}
}
private fun StringBuilder.row(k: String, v: String) {
append("<tr><td valign='top' class='section'><p>").append(k).append("</p></td><td valign='top'>").append(v).append("</td></tr>")
}
/** The app the `app` lines describe, as `ludic bundle` would build it. */
private fun appPreview(file: PsiFile, highlight: String?): String {
val m = LudicManifest.parse(file.text)
val app = m.app
val name = app["name"] ?: m.module?.substringAfterLast('/') ?: file.virtualFile?.parent?.name ?: "app"
fun img(key: String, size: Int): String? {
val f = app[key]?.let { resolveManifestPath(file, it) }?.takeIf { it.isFile } ?: return null
return "<img src='${f.toURI()}' width='$size' height='$size'>"
}
fun mark(k: String, v: String) = if (k == highlight) "<b>$v</b>" else v
return buildString {
append("<div class='definition'><pre>").append(esc(name)).append(".app</pre></div>")
append("<div class='content'><table><tr><td valign='top'>")
append(img("icon", 64) ?: "")
append("</td><td valign='top'><p><b>").append(esc(name)).append("</b></p><p>")
append(esc(app["id"] ?: "(no bundle id)")).append("<br>version ")
append(esc(app["version"] ?: m.version ?: "0.1.0"))
app["category"]?.let { append("<br>").append(esc(it.removePrefix("public.app-category."))) }
app["min_macos"]?.let { append("<br>macOS ").append(esc(it)).append("+") }
append("</p></td></tr></table>")
app["splash"]?.let { s ->
val f = resolveManifestPath(file, s)
val bg = app["splash_bg"] ?: "#000000"
append("<p>Splash</p><table><tr><td bgcolor='").append(esc(bg)).append("'>")
if (f?.isFile == true) append("<img src='").append(f.toURI()).append("' width='240'>") else append("&nbsp;missing: ").append(esc(s))
append("</td></tr></table>")
}
append("</div><table class='sections'>")
for ((k, help) in LudicManifest.APP_KEYS) {
val v = app[k] ?: continue
val shown = if (k == "splash_bg") "<span style='background-color:${esc(v)}'>&nbsp;&nbsp;&nbsp;&nbsp;</span> ${esc(v)}" else esc(v)
row(mark(k, k), mark(k, shown) + "<br><span class='grayed'>${esc(help)}</span>")
}
append("</table>")
m.entry?.let { append("<div class='bottom'>entry ").append(esc(it)).append("</div>") }
}
}
}

View file

@ -0,0 +1,122 @@
package io.ludic.ide.project
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.util.ExecUtil
import com.intellij.ide.util.projectWizard.WizardContext
import com.intellij.ide.wizard.AbstractNewProjectWizardStep
import com.intellij.ide.wizard.GeneratorNewProjectWizard
import com.intellij.ide.wizard.GeneratorNewProjectWizardBuilderAdapter
import com.intellij.ide.wizard.GitNewProjectWizardStep
import com.intellij.ide.wizard.NewProjectWizardBaseData.Companion.baseData
import com.intellij.ide.wizard.NewProjectWizardBaseStep
import com.intellij.ide.wizard.NewProjectWizardChainStep.Companion.nextStep
import com.intellij.ide.wizard.NewProjectWizardStep
import com.intellij.ide.wizard.RootNewProjectWizardStep
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.module.GeneralModuleType
import com.intellij.openapi.module.ModuleTypeManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.util.Key
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.ui.dsl.builder.Panel
import io.ludic.ide.LudicIcons
import io.ludic.ide.LudicToolchain
import java.io.File
import java.nio.file.Files
/**
* File | New | Project | Ludic. The project is whatever `ludic new` makes,
* so the IDE never carries its own copy of the template to drift from the CLI.
*/
class LudicNewProjectWizard : GeneratorNewProjectWizard {
override val id = "Ludic"
override val name = "Ludic"
override val icon = LudicIcons.FILE
override val description = "A Ludic game: package.ludic, src/main.ludic and a test, as <code>ludic new</code> creates them."
override fun createStep(context: WizardContext): NewProjectWizardStep =
RootNewProjectWizardStep(context)
.nextStep(::NewProjectWizardBaseStep)
.nextStep(::GitNewProjectWizardStep)
.nextStep(::LudicProjectStep)
}
/** A file a freshly created project should show first. */
internal val OPEN_ON_START = Key.create<String>("ludic.openOnStart")
class LudicModuleBuilder : GeneratorNewProjectWizardBuilderAdapter(LudicNewProjectWizard())
private class LudicProjectStep(parent: NewProjectWizardStep) : AbstractNewProjectWizardStep(parent) {
override fun setupUI(builder: Panel) {
val cli = LudicToolchain.cli()
builder.row {
if (cli != null) comment("Toolchain: $cli")
else comment("The <code>ludic</code> command was not found. ${LudicToolchain.INSTALL_HINT}")
}
}
override fun setupProject(project: Project) {
val data = baseData ?: return
val dir = File(data.path, data.name)
val error = scaffold(dir, data.name)
if (error != null) {
ApplicationManager.getApplication().invokeLater {
Messages.showErrorDialog(project, error, "Could Not Create the Ludic Project")
}
}
// a plain module whose content root is the project directory, so the
// Project view shows the files
val builder = ModuleTypeManager.getInstance().findByID(GeneralModuleType.TYPE_ID).createModuleBuilder()
builder.name = data.name
builder.contentEntryPath = dir.path
builder.moduleFilePath = File(File(dir, ".idea"), "${data.name}.iml").path
builder.commit(project)
// LudicToolchainCheck opens it once the project window is up
project.putUserData(OPEN_ON_START, File(dir, "src/main.ludic").path)
}
}
/**
* Run `ludic new` in a scratch directory and move the result into [dir],
* which the wizard may already have created (with .idea/ inside). Returns an
* error message, or null on success.
*/
internal fun scaffold(dir: File, displayName: String, cli: String? = LudicToolchain.cli()): String? {
cli ?: return "The 'ludic' command was not found.\n${LudicToolchain.INSTALL_HINT}"
val slug = packageSlug(displayName)
val scratch = Files.createTempDirectory("ludic-new").toFile()
try {
val out = ExecUtil.execAndGetOutput(
GeneralCommandLine(cli, "new", slug).withWorkDirectory(scratch).withCharset(Charsets.UTF_8),
60_000
)
val made = File(scratch, slug)
if (out.exitCode != 0 || !made.isDirectory) {
return "ludic new $slug failed (exit ${out.exitCode}):\n${out.stderr.ifBlank { out.stdout }}"
}
dir.mkdirs()
made.walkTopDown().filter { it.isFile }.forEach { src ->
val dst = File(dir, src.relativeTo(made).path)
if (!dst.exists()) {
dst.parentFile.mkdirs()
src.copyTo(dst)
}
}
// keep the empty directories (assets/) the template promises
made.walkTopDown().filter { it.isDirectory }.forEach { File(dir, it.relativeTo(made).path).mkdirs() }
LocalFileSystem.getInstance().refreshAndFindFileByIoFile(dir)?.refresh(false, true)
return null
} finally {
scratch.deleteRecursively()
}
}
/** A name `ludic new` accepts: letters, digits, '-', '_' and '.'. */
internal fun packageSlug(name: String): String {
val s = name.trim().map { if (it.isLetterOrDigit() || it in "-_.") it else '-' }.joinToString("")
.trim('-', '.').ifEmpty { "game" }
return s
}

View file

@ -0,0 +1,227 @@
package io.ludic.ide.project
import com.intellij.execution.lineMarker.RunLineMarkerContributor
import com.intellij.icons.AllIcons
import com.intellij.ide.IconProvider
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.editor.Document
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.fileEditor.FileEditor
import com.intellij.openapi.fileEditor.OpenFileDescriptor
import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.IconLoader
import com.intellij.openapi.util.TextRange
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.vfs.newvfs.BulkFileListener
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
import com.intellij.psi.PsiDocumentManager
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.ui.EditorNotificationPanel
import com.intellij.ui.EditorNotificationProvider
import com.intellij.ui.EditorNotifications
import io.ludic.ide.LudicConfigurable
import io.ludic.ide.LudicToolchain
import io.ludic.ide.run.LudicCliAction
import io.ludic.ide.run.LudicCommand
import java.io.File
import java.util.function.Function
import javax.swing.Icon
import javax.swing.JComponent
object LudicPackageIcons {
val MANIFEST: Icon = IconLoader.getIcon("/icons/ludicPackage.svg", LudicPackageIcons::class.java)
}
/** Run a `ludic` command in the package that owns [dir]. */
internal fun runLudic(project: Project, command: LudicCommand, args: String, dir: String) {
FileDocumentManager.getInstance().saveAllDocuments()
LudicCliAction.run(project, command, args, dir)
}
internal fun cliAction(text: String, icon: Icon?, project: Project, dir: String, command: LudicCommand, args: String = ""): AnAction =
object : AnAction(text, null, icon), DumbAware {
override fun actionPerformed(e: AnActionEvent) = runLudic(project, command, args, dir)
}
/** The whole line an element sits on, and the words on it. */
internal data class ManifestLine(val index: Int, val range: TextRange, val text: String, val words: List<String>) {
val directive: String? get() = words.firstOrNull()
companion object {
fun at(element: PsiElement): ManifestLine? {
val file = element.containingFile ?: return null
val doc = PsiDocumentManager.getInstance(file.project).getDocument(file) ?: return null
return at(doc, element.textOffset)
}
fun at(doc: Document, offset: Int): ManifestLine {
val line = doc.getLineNumber(offset.coerceIn(0, doc.textLength))
val range = TextRange(doc.getLineStartOffset(line), doc.getLineEndOffset(line))
val text = doc.getText(range)
return ManifestLine(line, range, text, LudicManifest.words(text))
}
}
/** Is the element the first word on its line? */
fun startsWith(element: PsiElement) = text.substring(0, element.textOffset - range.startOffset).isBlank()
}
/**
* The bar across the top of package.ludic and package.lock.ludic.
*
* On the manifest: the package's identity and the commands that act on it,
* turning into a prompt to fetch when the lock is missing or older than the
* manifest, the way a build-file sync does. On the lock: a reminder that it is
* generated, with the commands that regenerate and check it.
*/
class LudicPackageNotificationProvider : EditorNotificationProvider, DumbAware {
override fun collectNotificationData(project: Project, file: VirtualFile): Function<in FileEditor, out JComponent?>? {
val isManifest = LudicManifest.isManifest(file)
if (!isManifest && !LudicManifest.isLock(file)) return null
val dir = file.parent?.path ?: return null
val cli = LudicToolchain.cli(project)
if (!isManifest) return lockBanner(project, dir, cli != null)
val text = FileDocumentManager.getInstance().getCachedDocument(file)?.charsSequence
?: runCatching { String(file.contentsToByteArray(), Charsets.UTF_8) }.getOrNull() ?: return null
val manifest = LudicManifest.parse(text)
val stale = LudicManifest.staleReason(File(dir), manifest)
return Function { editor ->
val panel = if (stale != null) EditorNotificationPanel(editor, EditorNotificationPanel.Status.Warning)
else EditorNotificationPanel(editor, EditorNotificationPanel.Status.Info)
panel.icon(LudicPackageIcons.MANIFEST)
val name = manifest.module ?: File(dir).name
val ver = manifest.version?.let { " $it" } ?: ""
val deps = manifest.requires.size.let { if (it == 1) "1 dependency" else "$it dependencies" }
val entry = manifest.entry?.let { " · entry $it" } ?: ""
panel.text = when {
cli == null -> "Ludic package $name$ver — the 'ludic' command was not found."
stale != null -> "$stale Fetch the dependencies so builds and code insight see them."
else -> "Ludic package $name$ver · $deps$entry"
}
if (cli != null) {
val get = if (stale != null) "Fetch Dependencies" else "Fetch"
panel.createActionLabel(get) { runLudic(project, LudicCommand.CUSTOM, "get", dir) }
if (manifest.requires.isNotEmpty()) {
panel.createActionLabel("Update All") { runLudic(project, LudicCommand.CUSTOM, "update", dir) }
panel.createActionLabel("Verify") { runLudic(project, LudicCommand.CUSTOM, "verify", dir) }
}
panel.createActionLabel("Run") { runLudic(project, LudicCommand.RUN, "", dir) }
panel.createActionLabel("Test") { runLudic(project, LudicCommand.TEST, "", dir) }
panel.createActionLabel("Build") { runLudic(project, LudicCommand.BUILD, "", dir) }
panel.createActionLabel("Bundle") { runLudic(project, LudicCommand.CUSTOM, "bundle", dir) }
manifest.scripts.take(4).forEach { s ->
panel.createActionLabel("▶ ${s.name}") { runLudic(project, LudicCommand.CUSTOM, "script ${s.name}", dir) }
}
} else {
panel.createActionLabel("Set Paths…") {
ShowSettingsUtil.getInstance().showSettingsDialog(project, LudicConfigurable::class.java)
}
}
panel
}
}
private fun lockBanner(project: Project, dir: String, hasCli: Boolean) = Function<FileEditor, JComponent?> { editor ->
val panel = EditorNotificationPanel(editor, EditorNotificationPanel.Status.Info)
panel.icon(LudicPackageIcons.MANIFEST)
panel.text = "Generated by `ludic get` — edit package.ludic, then fetch again."
panel.createActionLabel("Open package.ludic") {
LocalFileSystem.getInstance().findFileByPath("$dir/${LudicManifest.FILE_NAME}")?.let {
OpenFileDescriptor(project, it).navigate(true)
}
}
if (hasCli) {
panel.createActionLabel("Regenerate") { runLudic(project, LudicCommand.CUSTOM, "get", dir) }
panel.createActionLabel("Verify Hashes") { runLudic(project, LudicCommand.CUSTOM, "verify", dir) }
panel.createActionLabel("Vendor") { runLudic(project, LudicCommand.CUSTOM, "vendor", dir) }
}
panel
}
}
/** Keeps the bars and the library list current as manifests, locks and linked packages change. */
class LudicManifestWatcher(private val project: Project) : BulkFileListener {
override fun after(events: List<VFileEvent>) {
val relevant = events.any { e ->
val n = e.path.substringAfterLast('/')
n == LudicManifest.FILE_NAME || n == LudicManifest.LOCK_NAME || n == "ludic_modules" || n == "vendor"
}
if (relevant && !project.isDisposed) EditorNotifications.getInstance(project).updateAllNotifications()
}
}
/**
* Gutter actions: the package line runs the package, the entry line runs the
* entry, a require (or lock module) line updates that dependency.
*/
class LudicManifestLineMarker : RunLineMarkerContributor() {
override fun getInfo(element: PsiElement): Info? {
val file = element.containingFile ?: return null
val manifest = LudicManifest.isManifest(file)
if (!manifest && !LudicManifest.isLock(file)) return null
if (element.firstChild != null) return null
val word = element.text
if (word != "package" && word != "require" && word != "entry" && word != "module" && word != "script") return null
val line = ManifestLine.at(element) ?: return null
if (!line.startsWith(element)) return null
val dir = file.virtualFile?.parent?.path ?: return null
val project = file.project
return when {
manifest && word == "package" -> Info(
AllIcons.RunConfigurations.TestState.Run,
arrayOf(
cliAction("Run Package", AllIcons.Actions.Execute, project, dir, LudicCommand.RUN),
cliAction("Test Package", AllIcons.RunConfigurations.TestState.Run_run, project, dir, LudicCommand.TEST),
cliAction("Build Package", AllIcons.Actions.Compile, project, dir, LudicCommand.BUILD),
cliAction("Fetch Dependencies", AllIcons.Actions.Download, project, dir, LudicCommand.CUSTOM, "get"),
cliAction("Bundle App", AllIcons.Nodes.Artifact, project, dir, LudicCommand.CUSTOM, "bundle"),
)
) { "Run, test or build this package" }
manifest && word == "entry" -> Info(
AllIcons.RunConfigurations.TestState.Run,
arrayOf(
cliAction("Run Entry", AllIcons.Actions.Execute, project, dir, LudicCommand.RUN),
cliAction("Build Entry", AllIcons.Actions.Compile, project, dir, LudicCommand.BUILD),
)
) { "Run the entry program" }
manifest && word == "script" -> {
val name = line.words.getOrNull(1) ?: return null
Info(
AllIcons.RunConfigurations.TestState.Run,
arrayOf(cliAction("Run Script '$name'", AllIcons.Actions.Execute, project, dir, LudicCommand.CUSTOM, "script $name"))
) { "ludic script $name" }
}
word == "require" || word == "module" -> {
val module = line.words.getOrNull(1) ?: return null
Info(
AllIcons.Actions.Refresh,
arrayOf(
cliAction("Update $module", AllIcons.Actions.Refresh, project, dir, LudicCommand.CUSTOM, "update $module"),
cliAction("Fetch Dependencies", AllIcons.Actions.Download, project, dir, LudicCommand.CUSTOM, "get"),
cliAction("Verify Locked Packages", AllIcons.Actions.Checked, project, dir, LudicCommand.CUSTOM, "verify"),
)
) { "Update $module" }
}
else -> null
}
}
}
/** package.ludic and its lock get the package icon in the Project view and on their tabs. */
class LudicManifestIconProvider : IconProvider(), DumbAware {
override fun getIcon(element: PsiElement, flags: Int): Icon? =
if (element is PsiFile && (LudicManifest.isManifest(element) || LudicManifest.isLock(element))) LudicPackageIcons.MANIFEST else null
}
/** A manifest path value, resolved against the package root. */
internal fun resolveManifestPath(file: PsiFile, value: String): File? {
val dir = file.virtualFile?.parent?.path ?: return null
if (value.isBlank()) return null
return File(value).let { if (it.isAbsolute) it else File(dir, value) }
}

View file

@ -0,0 +1,90 @@
package io.ludic.ide.project
import com.intellij.navigation.ItemPresentation
import com.intellij.openapi.project.Project
import com.intellij.openapi.roots.AdditionalLibraryRootsProvider
import com.intellij.openapi.roots.SyntheticLibrary
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import io.ludic.ide.LudicIcons
import io.ludic.ide.LudicToolchain
import java.io.File
import javax.swing.Icon
/**
* External Libraries, one node per thing a build can import:
*
* Ludic built-in runtime <home>/runtime — the language's own library
* Ludic language reference <home>/docs/language — what hover and go-to open
* ludic.render3d 0.4.0 <home>/packages/<name> — packages shipped with the toolchain
* git.example.com/me/util 1.2.0 ludic_modules/ or vendor/ — fetched packages, per the lock
*
* so they are indexed, searchable and browsable next to the project.
*/
class LudicPackagesLibraryProvider : AdditionalLibraryRootsProvider() {
override fun getAdditionalProjectLibraries(project: Project): Collection<SyntheticLibrary> {
val base = project.basePath ?: return emptyList()
val lfs = LocalFileSystem.getInstance()
val out = mutableListOf<SyntheticLibrary>()
fun add(name: String, kind: Kind, path: File) {
lfs.findFileByIoFile(path)?.takeIf { it.isDirectory }?.let { out += LudicPackagesLibrary(name, kind, it) }
}
val home = LudicToolchain.home(project)
// a checkout of the toolchain already has all of this as project content
val insideHome = home != null && File(base).canonicalPath.let { it == home.canonicalPath || it.startsWith(home.canonicalPath + "/") }
if (home != null && !insideHome) {
add("Ludic built-in runtime", Kind.BUILTIN, File(home, "runtime"))
add("Ludic language reference", Kind.BUILTIN, File(home, "docs/language"))
File(home, "packages").listFiles()?.filter { it.isDirectory }?.sortedBy { it.name }?.forEach { pkg ->
add(labelOf(pkg, pkg.name), Kind.TOOLCHAIN, pkg)
}
}
val lock = File(base, LudicManifest.LOCK_NAME)
val locked = if (lock.isFile) runCatching { LudicManifest.parseLock(lock.readText()) }.getOrDefault(emptyList()) else emptyList()
val modules = File(base, System.getenv("LUDIC_MODULES") ?: "ludic_modules")
val vendor = File(base, "vendor")
for (m in locked) {
val dir = listOf(File(vendor, m.module), File(modules, m.module)).firstOrNull { it.isDirectory } ?: continue
add("${m.module} ${m.version}", Kind.REMOTE, dir)
}
if (locked.isEmpty()) {
// fetched without a lock we can read: show the link roots as they are
add("Ludic packages (ludic_modules)", Kind.REMOTE, modules)
add("Ludic packages (vendor)", Kind.REMOTE, vendor)
}
return out
}
override fun getRootsToWatch(project: Project): Collection<VirtualFile> =
getAdditionalProjectLibraries(project).flatMap { it.sourceRoots }
/** `ludic.render3d 0.4.0` from the package's own manifest, when it has one. */
private fun labelOf(dir: File, fallback: String): String {
val man = File(dir, LudicManifest.FILE_NAME)
if (!man.isFile) return fallback
val m = runCatching { LudicManifest.parse(man.readText()) }.getOrNull() ?: return fallback
return listOfNotNull(fallback, m.version).joinToString(" ")
}
enum class Kind { BUILTIN, TOOLCHAIN, REMOTE }
}
class LudicPackagesLibrary(private val name: String, private val kind: LudicPackagesLibraryProvider.Kind, private val root: VirtualFile) :
SyntheticLibrary("ludic:" + root.path, ExcludeFileCondition { isDir, filename, _, _, _ ->
// sources, manifests and docs only: packages also carry large binary assets
!isDir && !(filename.endsWith(".ludic") || filename.endsWith(".md"))
}), ItemPresentation {
override fun getSourceRoots(): Collection<VirtualFile> = listOf(root)
override fun equals(other: Any?) = other is LudicPackagesLibrary && other.root == root && other.name == name
override fun hashCode() = root.hashCode()
override fun getPresentableText() = name
override fun getLocationString(): String? = when (kind) {
LudicPackagesLibraryProvider.Kind.BUILTIN -> "built-in"
LudicPackagesLibraryProvider.Kind.TOOLCHAIN -> "toolchain"
LudicPackagesLibraryProvider.Kind.REMOTE -> "fetched"
}
override fun getIcon(unused: Boolean): Icon = if (kind == LudicPackagesLibraryProvider.Kind.BUILTIN) LudicIcons.FILE else LudicPackageIcons.MANIFEST
}

View file

@ -0,0 +1,60 @@
package io.ludic.ide.project
import com.intellij.notification.NotificationAction
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
import com.intellij.openapi.application.EDT
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.openapi.vfs.LocalFileSystem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import com.intellij.ide.BrowserUtil
import io.ludic.ide.LudicConfigurable
import io.ludic.ide.LudicToolchain
import java.io.File
/**
* Opening a Ludic project on a machine without the toolchain would otherwise
* look like a plugin that half works: colours, but no completion and no run
* button that does anything. Say so once, with the fix attached.
*/
class LudicToolchainCheck : ProjectActivity {
override suspend fun execute(project: Project) {
project.getUserData(OPEN_ON_START)?.let { path ->
project.putUserData(OPEN_ON_START, null)
val main = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(File(path))
if (main != null) {
withContext(Dispatchers.EDT) { FileEditorManager.getInstance(project).openFile(main, true) }
}
}
val root = project.basePath ?: return
if (!looksLikeLudic(File(root))) return
val missing = buildList {
if (LudicToolchain.cli(project) == null) add("ludic")
if (LudicToolchain.server(project) == null) add("ludic-lsp")
}
if (missing.isEmpty()) return
NotificationGroupManager.getInstance().getNotificationGroup("Ludic")
.createNotification(
"Ludic toolchain not found",
"Could not find ${missing.joinToString(" or ") { "<code>$it</code>" }}. " +
"Highlighting works without it; completion, diagnostics, run and test do not.",
NotificationType.WARNING
)
.addAction(NotificationAction.createSimple("Install instructions") {
BrowserUtil.browse("https://workshopsoft.pages.workshopsoft.io/ludic/")
})
.addAction(NotificationAction.createSimple("Set paths…") {
ShowSettingsUtil.getInstance().showSettingsDialog(project, LudicConfigurable::class.java)
})
.notify(project)
}
private fun looksLikeLudic(root: File): Boolean =
File(root, "package.ludic").isFile ||
File(root, "src").listFiles()?.any { it.extension == "ludic" } == true ||
root.listFiles()?.any { it.extension == "ludic" } == true
}

View file

@ -0,0 +1,112 @@
package io.ludic.ide.psi
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.TokenType
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.psi.util.elementType
import io.ludic.ide.LudicLanguage
import io.ludic.ide.LudicTokens
/**
* Questions about a Ludic file answered from its token stream.
*
* The PSI is flat (see LudicParserDefinition), so "is this a test block" is a
* question about neighbouring leaves: a `test` declaration keyword followed by
* a string literal. That is all the run buttons, the configuration producer and
* the test locator need, and it holds up in a file that does not parse yet.
*/
object LudicPsi {
/** The next leaf that is not whitespace or a comment, or null. */
fun nextSignificant(e: PsiElement): PsiElement? {
var n = PsiTreeUtil.nextLeaf(e)
while (n != null && (n.elementType == TokenType.WHITE_SPACE || n.elementType == LudicTokens.COMMENT)) {
n = PsiTreeUtil.nextLeaf(n)
}
return n
}
fun isKeyword(e: PsiElement?, word: String): Boolean =
e != null && e.elementType == LudicTokens.DECL_KEYWORD && e.textMatches(word)
/** `test "name"`: the keyword leaf's test name, or null when [e] is not one. */
fun testNameAt(e: PsiElement): String? {
if (!isKeyword(e, "test")) return null
val str = nextSignificant(e) ?: return null
if (str.elementType != LudicTokens.STRING) return null
return unquote(str.text)
}
/** `program Name`: the program's name, or null when [e] is not its keyword. */
fun programNameAt(e: PsiElement): String? {
if (!isKeyword(e, "program")) return null
val name = nextSignificant(e) ?: return null
return if (name.elementType == LudicTokens.TYPE_NAME) name.text else null
}
/**
* The `test` keyword of the block around [e], when [e] sits on that block's
* header line: the keyword itself, the name string, or the whitespace
* between them. A caret deep inside the body is deliberately not enough;
* "run the test I am in" is decided by the producer from the header.
*/
fun enclosingTestKeyword(e: PsiElement): PsiElement? {
if (testNameAt(e) != null) return e
if (e.elementType == LudicTokens.STRING) {
val prev = PsiTreeUtil.prevLeaf(e)?.let { if (it.elementType == TokenType.WHITE_SPACE) PsiTreeUtil.prevLeaf(it) else it }
if (prev != null && testNameAt(prev) != null) return prev
}
return null
}
/** Every `test "name"` keyword leaf in the file, in source order. */
fun testKeywords(file: PsiFile): List<PsiElement> = leaves(file).filter { testNameAt(it) != null }.toList()
fun findTest(file: PsiFile, name: String): PsiElement? = leaves(file).firstOrNull { testNameAt(it) == name }
fun findProgram(file: PsiFile): PsiElement? = leaves(file).firstOrNull { programNameAt(it) != null }
/** A file whose only runnable content is its test blocks. */
fun isTestFile(file: PsiFile): Boolean {
if (file.language != LudicLanguage) return false
val name = file.name
if (name.endsWith("_test.ludic")) return true
if (file.virtualFile?.parent?.name == "tests") return true
return hasTests(file) && !hasEntry(file)
}
fun hasTests(file: PsiFile) = leaves(file).any { testNameAt(it) != null }
/** `entry` or a phase handler: something that makes a program run as a game. */
fun hasEntry(file: PsiFile) = leaves(file).any { isKeyword(it, "entry") || isKeyword(it, "handler") }
/** A file that is a program in its own right, rather than a module imported by one. */
fun isProgramFile(file: PsiFile) = file.language == LudicLanguage && findProgram(file) != null
private fun leaves(file: PsiFile): Sequence<PsiElement> =
generateSequence(PsiTreeUtil.firstChild(file)) { PsiTreeUtil.nextLeaf(it) }
.filter { it.elementType == LudicTokens.DECL_KEYWORD }
/** The value of a Ludic string literal: `"a\"b"` -> `a"b`. */
fun unquote(lit: String): String {
val body = lit.removePrefix("\"").let { if (it.endsWith("\"") && !it.endsWith("\\\"")) it.dropLast(1) else it }
val out = StringBuilder(body.length)
var i = 0
while (i < body.length) {
val c = body[i]
if (c == '\\' && i + 1 < body.length) {
when (val n = body[i + 1]) {
'n' -> out.append('\n')
't' -> out.append('\t')
else -> out.append(n)
}
i += 2
} else {
out.append(c)
i++
}
}
return out.toString()
}
}

View file

@ -0,0 +1,96 @@
package io.ludic.ide.run
import com.intellij.execution.ProgramRunnerUtil
import com.intellij.execution.RunManager
import com.intellij.execution.configurations.ConfigurationTypeUtil
import com.intellij.execution.executors.DefaultRunExecutor
import com.intellij.openapi.actionSystem.ActionUpdateThread
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import java.io.File
/**
* Tools | Ludic. Each action is a throwaway run configuration, so its output
* lands in the Run tool window with the same clickable locations as any run.
*/
abstract class LudicCliAction(private val command: LudicCommand, private val args: String = "") : AnAction(), DumbAware {
override fun getActionUpdateThread() = ActionUpdateThread.BGT
override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = e.project != null
}
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
FileDocumentManager.getInstance().saveAllDocuments()
run(project, command, args, packageRootFor(e, project))
}
private fun packageRootFor(e: AnActionEvent, project: Project): String? {
val start = e.getData(CommonDataKeys.VIRTUAL_FILE)?.let { if (it.isDirectory) it.path else it.parent?.path }
?: project.basePath ?: return null
return LudicRunConfiguration.packageRootOf(File(start))?.path ?: project.basePath
}
companion object {
fun run(project: Project, command: LudicCommand, args: String, workDir: String?) {
val type = ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java)
val settings = RunManager.getInstance(project).createConfiguration("ludic", type.factory)
val config = settings.configuration as LudicRunConfiguration
config.command = command
config.arguments = args
workDir?.let { config.workingDirectory = it }
settings.name = config.suggestedName()
settings.isTemporary = true
ProgramRunnerUtil.executeConfiguration(settings, DefaultRunExecutor.getRunExecutorInstance())
}
}
}
class LudicRunProjectAction : LudicCliAction(LudicCommand.RUN)
class LudicBuildProjectAction : LudicCliAction(LudicCommand.BUILD)
class LudicTestProjectAction : LudicCliAction(LudicCommand.TEST)
class LudicFormatProjectAction : LudicCliAction(LudicCommand.CUSTOM, "fmt")
class LudicGetPackagesAction : LudicCliAction(LudicCommand.CUSTOM, "get")
class LudicPackAction : LudicCliAction(LudicCommand.CUSTOM, "pack")
class LudicBundleAction : LudicCliAction(LudicCommand.CUSTOM, "bundle")
class LudicCleanAction : LudicCliAction(LudicCommand.CUSTOM, "clean")
class LudicDoctorAction : LudicCliAction(LudicCommand.CUSTOM, "doctor")
/** Tools | Ludic | Run Script…: pick one of package.ludic's scripts. */
class LudicRunScriptAction : AnAction(), DumbAware {
override fun getActionUpdateThread() = ActionUpdateThread.BGT
override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = e.project != null
}
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
val base = project.basePath ?: return
val start = e.getData(CommonDataKeys.VIRTUAL_FILE)?.let { if (it.isDirectory) it.path else it.parent?.path } ?: base
val root = LudicRunConfiguration.packageRootOf(File(start)) ?: File(base)
val manifest = File(root, "package.ludic").takeIf { it.isFile }?.let { io.ludic.ide.project.LudicManifest.parse(it.readText()) }
val scripts = manifest?.scripts.orEmpty()
if (scripts.isEmpty()) {
com.intellij.openapi.ui.Messages.showInfoMessage(project,
"package.ludic defines no scripts. Add one:\n\nscript \"dev\" \"ludic run --headless\"", "Ludic Scripts")
return
}
com.intellij.openapi.ui.popup.JBPopupFactory.getInstance()
.createPopupChooserBuilder(scripts)
.setTitle("Run Ludic Script")
.setRenderer(com.intellij.ui.SimpleListCellRenderer.create { label, s, _ -> label.text = "${s.name} — ${s.command}" })
.setItemChosenCallback { s ->
FileDocumentManager.getInstance().saveAllDocuments()
LudicCliAction.run(project, LudicCommand.CUSTOM, "script ${s.name}", root.path)
}
.createPopup()
.showCenteredInCurrentWindow(project)
}
}

View file

@ -0,0 +1,66 @@
package io.ludic.ide.run
import com.intellij.execution.filters.ConsoleFilterProvider
import com.intellij.execution.filters.Filter
import com.intellij.execution.filters.OpenFileHyperlinkInfo
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.search.FilenameIndex
import com.intellij.psi.search.GlobalSearchScope
import java.io.File
/**
* Makes `path/to/file.ludic:42:` clickable. That is the shape of every
* compiler error (`game.ludic:4: error: unknown function foo`) and every failed
* assertion (`smoke.ludic:12: expect_eq failed`).
*
* The compiler often prints only the file's base name, so a path that does not
* resolve against the working directory falls back to a project-wide lookup by
* name, preferring a match under the working directory.
*/
class LudicConsoleFilter(private val project: Project, private val workDir: String?) : Filter {
override fun applyFilter(line: String, entireLength: Int): Filter.Result? {
val m = LOCATION.find(line) ?: return null
val path = m.groupValues[1]
val lineNo = m.groupValues[2].toIntOrNull() ?: return null
val col = m.groupValues[3].toIntOrNull()
val file = resolve(path) ?: return null
val lineStart = entireLength - line.length
val start = lineStart + m.groups[1]!!.range.first
val end = lineStart + m.range.last + 1
return Filter.Result(start, end, OpenFileHyperlinkInfo(project, file, lineNo - 1, (col ?: 1) - 1))
}
private fun resolve(path: String): VirtualFile? {
val lfs = LocalFileSystem.getInstance()
val candidates = buildList {
val f = File(path)
if (f.isAbsolute) add(f)
if (workDir != null) add(File(workDir, path))
project.basePath?.let { add(File(it, path)) }
}
for (c in candidates) {
if (c.isFile) lfs.findFileByIoFile(c)?.let { return it }
}
val name = File(path).name
return ApplicationManager.getApplication().runReadAction<VirtualFile?> {
if (project.isDisposed) return@runReadAction null
val found = FilenameIndex.getVirtualFilesByName(name, GlobalSearchScope.projectScope(project))
.filter { it.path.endsWith("/$path") || it.name == path }
val wd = workDir?.let { File(it).path + "/" }
found.firstOrNull { wd != null && it.path.startsWith(wd) } ?: found.singleOrNull()
}
}
private companion object {
val LOCATION = Regex("""((?:[A-Za-z]:)?[^\s:"'(]*\.ludic):(\d+)(?::(\d+))?""")
}
}
/** The same links in every console: Run, Terminal-launched tasks, external tools. */
class LudicConsoleFilterProvider : ConsoleFilterProvider {
override fun getDefaultFilters(project: Project): Array<Filter> = arrayOf(LudicConsoleFilter(project, project.basePath))
}

View file

@ -0,0 +1,237 @@
package io.ludic.ide.run
import com.intellij.execution.DefaultExecutionResult
import com.intellij.execution.ExecutionException
import com.intellij.execution.ExecutionResult
import com.intellij.execution.Executor
import com.intellij.execution.configurations.CommandLineState
import com.intellij.execution.configurations.ConfigurationFactory
import com.intellij.execution.configurations.ConfigurationTypeBase
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.execution.configurations.LocatableConfigurationBase
import com.intellij.execution.configurations.LocatableRunConfigurationOptions
import com.intellij.execution.configurations.RunConfiguration
import com.intellij.execution.configurations.RunProfileState
import com.intellij.execution.configurations.RuntimeConfigurationError
import com.intellij.execution.process.KillableColoredProcessHandler
import com.intellij.execution.process.ProcessEvent
import com.intellij.execution.process.ProcessHandler
import com.intellij.execution.process.ProcessListener
import com.intellij.execution.process.ProcessTerminatedListener
import com.intellij.execution.runners.ExecutionEnvironment
import com.intellij.execution.runners.ProgramRunner
import com.intellij.execution.testframework.sm.SMTestRunnerConnectionUtil
import com.intellij.openapi.components.BaseState
import com.intellij.openapi.options.SettingsEditor
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.NotNullLazyValue
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.openapi.vfs.VfsUtil
import com.intellij.util.execution.ParametersListUtil
import io.ludic.ide.LudicIcons
import io.ludic.ide.LudicToolchain
import java.io.File
/** What a Ludic run configuration asks the `ludic` CLI to do. */
enum class LudicCommand(val cli: String, val label: String) {
RUN("run", "Run"),
BUILD("build", "Build"),
TEST("test", "Test"),
CUSTOM("", "Other ludic command");
override fun toString() = label
companion object {
fun of(name: String?) = entries.firstOrNull { it.name == name } ?: RUN
}
}
class LudicConfigurationType : ConfigurationTypeBase(
ID, "Ludic", "Build, run or test a Ludic program with the ludic CLI",
NotNullLazyValue.createValue { LudicIcons.FILE }
) {
init {
addFactory(LudicConfigurationFactory(this))
}
val factory: ConfigurationFactory get() = configurationFactories.single()
companion object {
const val ID = "LudicRunConfiguration"
}
}
class LudicConfigurationFactory(type: LudicConfigurationType) : ConfigurationFactory(type) {
override fun getId() = LudicConfigurationType.ID
override fun createTemplateConfiguration(project: Project): RunConfiguration =
LudicRunConfiguration(project, this, "Ludic")
override fun getOptionsClass(): Class<out BaseState> = LudicRunOptions::class.java
}
class LudicRunOptions : LocatableRunConfigurationOptions() {
var command by string(LudicCommand.RUN.name)
/** The .ludic file; empty means "the project" (ludic finds the entry). */
var file by string("")
/** One test block to run, by its exact name; empty runs every test. */
var testName by string("")
var headless by property(false)
/** Extra CLI arguments, or the whole command for CUSTOM (e.g. `fmt --check`). */
var arguments by string("")
var workingDirectory by string("")
var env by map<String, String>()
var passParentEnv by property(true)
}
class LudicRunConfiguration(project: Project, factory: ConfigurationFactory, name: String) :
LocatableConfigurationBase<LudicRunOptions>(project, factory, name) {
public override fun getOptions(): LudicRunOptions = super.getOptions() as LudicRunOptions
var command: LudicCommand
get() = LudicCommand.of(options.command)
set(v) { options.command = v.name }
var file: String
get() = options.file ?: ""
set(v) { options.file = v }
var testName: String
get() = options.testName ?: ""
set(v) { options.testName = v }
var headless: Boolean
get() = options.headless
set(v) { options.headless = v }
var arguments: String
get() = options.arguments ?: ""
set(v) { options.arguments = v }
var workingDirectory: String
get() = options.workingDirectory ?: ""
set(v) { options.workingDirectory = v }
var env: Map<String, String>
get() = options.env
set(v) { options.env.clear(); options.env.putAll(v) }
var passParentEnv: Boolean
get() = options.passParentEnv
set(v) { options.passParentEnv = v }
override fun getConfigurationEditor(): SettingsEditor<out RunConfiguration> = LudicRunConfigurationEditor(project)
override fun checkConfiguration() {
if (LudicToolchain.cli(project) == null) {
throw RuntimeConfigurationError("The 'ludic' command was not found. ${LudicToolchain.INSTALL_HINT}")
}
if (file.isNotBlank() && !File(file).isFile && !File(resolvedWorkingDirectory(), file).isFile) {
throw RuntimeConfigurationError("No such file: $file")
}
if (command == LudicCommand.CUSTOM && arguments.isBlank()) {
throw RuntimeConfigurationError("Name the ludic command to run, for example: fmt --check")
}
}
override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState =
LudicRunState(this, environment)
override fun suggestedName(): String {
val f = file.substringAfterLast('/').ifEmpty { project.name }
return when (command) {
LudicCommand.TEST -> if (testName.isNotEmpty()) "Test '$testName'" else "Test $f"
LudicCommand.BUILD -> "Build $f"
LudicCommand.RUN -> "Run $f"
LudicCommand.CUSTOM -> "ludic $arguments"
}
}
/**
* The configured directory, else the package that owns the file (the
* nearest directory with a package.ludic, which is where `ludic` expects
* to run), else the project root.
*/
fun resolvedWorkingDirectory(): String {
if (workingDirectory.isNotBlank()) return workingDirectory
val base = project.basePath
if (file.isNotBlank()) {
val f = File(file).let { if (it.isAbsolute) it else File(base ?: ".", file) }
packageRootOf(f.parentFile)?.let { return it.path }
return base ?: f.parent
}
return base ?: File(".").absolutePath
}
/** The argv after `ludic`, exactly as it will be passed. */
fun cliArguments(): List<String> {
val args = mutableListOf<String>()
val extra = ParametersListUtil.parse(arguments)
if (command == LudicCommand.CUSTOM) return extra
args += command.cli
if (file.isNotBlank()) args += relativeToWorkingDirectory(file)
when (command) {
LudicCommand.TEST -> {
args += "--verbose"
if (testName.isNotEmpty()) { args += "--test"; args += testName }
}
else -> if (headless) args += "--headless"
}
args += extra
return args
}
private fun relativeToWorkingDirectory(path: String): String {
val wd = File(resolvedWorkingDirectory())
val f = File(path).let { if (it.isAbsolute) it else File(project.basePath ?: wd.path, path) }
val rel = runCatching { f.canonicalFile.relativeTo(wd.canonicalFile).path }.getOrNull()
return if (rel != null && !rel.startsWith("..")) rel else f.path
}
fun commandLine(): GeneralCommandLine {
val cli = LudicToolchain.cli(project)
?: throw ExecutionException("The 'ludic' command was not found. ${LudicToolchain.INSTALL_HINT}")
return GeneralCommandLine(cli)
.withParameters(cliArguments())
.withWorkDirectory(resolvedWorkingDirectory())
.withEnvironment(env)
.withParentEnvironmentType(
if (passParentEnv) GeneralCommandLine.ParentEnvironmentType.CONSOLE
else GeneralCommandLine.ParentEnvironmentType.NONE
)
.withCharset(Charsets.UTF_8)
}
companion object {
/** The nearest directory at or above [dir] holding a package.ludic. */
fun packageRootOf(dir: File?): File? {
var d = dir
while (d != null) {
if (File(d, "package.ludic").isFile) return d
d = d.parentFile
}
return null
}
}
}
class LudicRunState(private val config: LudicRunConfiguration, env: ExecutionEnvironment) : CommandLineState(env) {
override fun startProcess(): ProcessHandler {
val handler = KillableColoredProcessHandler(config.commandLine())
ProcessTerminatedListener.attach(handler)
// get/update/pack/build write files the IDE should see straight away
val dir = config.resolvedWorkingDirectory()
handler.addProcessListener(object : ProcessListener {
override fun processTerminated(event: ProcessEvent) {
LocalFileSystem.getInstance().findFileByPath(dir)?.let { VfsUtil.markDirtyAndRefresh(true, true, true, it) }
}
})
return handler
}
override fun execute(executor: Executor, runner: ProgramRunner<*>): ExecutionResult {
val filter = LudicConsoleFilter(config.project, config.resolvedWorkingDirectory())
if (config.command != LudicCommand.TEST) {
addConsoleFilters(filter)
return super.execute(executor, runner)
}
val handler = startProcess()
val props = LudicTestConsoleProperties(config, executor)
val console = SMTestRunnerConnectionUtil.createAndAttachConsole(LudicTestConsoleProperties.FRAMEWORK, handler, props)
console.addMessageFilter(filter)
return DefaultExecutionResult(console, handler, *createActions(console, handler, executor))
}
}

View file

@ -0,0 +1,82 @@
package io.ludic.ide.run
import com.intellij.execution.configuration.EnvironmentVariablesTextFieldWithBrowseButton
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.options.SettingsEditor
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.ComboBox
import com.intellij.openapi.ui.TextFieldWithBrowseButton
import com.intellij.ui.RawCommandLineEditor
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextField
import com.intellij.util.ui.FormBuilder
import io.ludic.ide.LudicFileType
import javax.swing.JComponent
class LudicRunConfigurationEditor(private val project: Project) : SettingsEditor<LudicRunConfiguration>() {
private val command = ComboBox(LudicCommand.entries.toTypedArray())
private val file = TextFieldWithBrowseButton()
private val testName = JBTextField()
private val headless = JBCheckBox("Headless (no window: frames go to build/out.ppm, keys come from stdin)")
private val arguments = RawCommandLineEditor()
private val workingDirectory = TextFieldWithBrowseButton()
private val env = EnvironmentVariablesTextFieldWithBrowseButton()
private val argumentsLabel = JBLabel("Extra arguments:")
init {
file.addBrowseFolderListener(
project,
FileChooserDescriptorFactory.createSingleFileDescriptor(LudicFileType).withTitle("Ludic File")
)
workingDirectory.addBrowseFolderListener(
project,
FileChooserDescriptorFactory.createSingleFolderDescriptor().withTitle("Working Directory")
)
testName.emptyText.text = "all tests in the file (or the project's tests/)"
file.textField.let { (it as? JBTextField)?.emptyText?.text = "the project's entry program" }
workingDirectory.textField.let { (it as? JBTextField)?.emptyText?.text = "the package root that owns the file" }
command.addActionListener { updateEnabled() }
}
private fun updateEnabled() {
val c = command.item ?: LudicCommand.RUN
testName.isEnabled = c == LudicCommand.TEST
headless.isEnabled = c == LudicCommand.RUN || c == LudicCommand.BUILD
file.isEnabled = c != LudicCommand.CUSTOM
argumentsLabel.text = if (c == LudicCommand.CUSTOM) "ludic" else "Extra arguments:"
}
override fun createEditor(): JComponent = FormBuilder.createFormBuilder()
.addLabeledComponent("Command:", command)
.addLabeledComponent("File:", file)
.addLabeledComponent("Test name:", testName)
.addComponent(headless)
.addLabeledComponent(argumentsLabel, arguments)
.addLabeledComponent("Working directory:", workingDirectory)
.addLabeledComponent("Environment variables:", env)
.panel
override fun resetEditorFrom(c: LudicRunConfiguration) {
command.item = c.command
file.text = c.file
testName.text = c.testName
headless.isSelected = c.headless
arguments.text = c.arguments
workingDirectory.text = c.workingDirectory
env.envs = c.env
env.isPassParentEnvs = c.passParentEnv
updateEnabled()
}
override fun applyEditorTo(c: LudicRunConfiguration) {
c.command = command.item ?: LudicCommand.RUN
c.file = file.text.trim()
c.testName = testName.text
c.headless = headless.isSelected
c.arguments = arguments.text
c.workingDirectory = workingDirectory.text.trim()
c.env = env.envs
c.passParentEnv = env.isPassParentEnvs
}
}

View file

@ -0,0 +1,85 @@
package io.ludic.ide.run
import com.intellij.execution.actions.ConfigurationContext
import com.intellij.execution.actions.LazyRunConfigurationProducer
import com.intellij.execution.configurations.ConfigurationFactory
import com.intellij.execution.configurations.ConfigurationTypeUtil
import com.intellij.openapi.util.Ref
import com.intellij.psi.PsiDirectory
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import io.ludic.ide.LudicFileType
import io.ludic.ide.psi.LudicPsi
import java.io.File
/**
* Right-click -> Run, and the gutter buttons. What gets run follows from
* where the user is:
*
* - a `test "name"` header -> ludic test <file> --test name
* - a test file (tests/, *_test, or
* a program made only of tests) -> ludic test <file>
* - a file declaring a `program` -> ludic run <file>
* - any other .ludic file (a module) -> ludic run, for the package that owns it
* - the `tests` directory -> ludic test
* - a package root directory -> ludic run
*/
class LudicRunConfigurationProducer : LazyRunConfigurationProducer<LudicRunConfiguration>() {
override fun getConfigurationFactory(): ConfigurationFactory =
ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java).factory
private data class Target(val command: LudicCommand, val file: String, val testName: String)
private fun targetFor(element: PsiElement): Target? {
if (element is PsiDirectory) {
val dir = File(element.virtualFile.path)
return when {
dir.name == "tests" && LudicRunConfiguration.packageRootOf(dir.parentFile) == dir.parentFile ->
Target(LudicCommand.TEST, "", "")
File(dir, "package.ludic").isFile -> Target(LudicCommand.RUN, "", "")
else -> null
}
}
val file: PsiFile = element.containingFile ?: return null
if (file.fileType != LudicFileType) return null
val vf = file.virtualFile ?: return null
if (file.name == "package.ludic") return Target(LudicCommand.RUN, "", "")
LudicPsi.enclosingTestKeyword(element)?.let { kw ->
return Target(LudicCommand.TEST, vf.path, LudicPsi.testNameAt(kw)!!)
}
if (LudicPsi.isTestFile(file)) return Target(LudicCommand.TEST, vf.path, "")
if (LudicPsi.isProgramFile(file)) return Target(LudicCommand.RUN, vf.path, "")
// a module on its own does not run; its package does
return if (LudicRunConfiguration.packageRootOf(File(vf.path).parentFile) != null) Target(LudicCommand.RUN, "", "") else null
}
override fun setupConfigurationFromContext(
configuration: LudicRunConfiguration,
context: ConfigurationContext,
sourceElement: Ref<PsiElement>,
): Boolean {
val element = context.psiLocation ?: return false
val t = targetFor(element) ?: return false
configuration.command = t.command
configuration.file = t.file
configuration.testName = t.testName
if (t.file.isEmpty()) {
// a project-level run executes from the package root
val start = (element as? PsiDirectory)?.virtualFile?.path ?: element.containingFile?.virtualFile?.parent?.path
start?.let { LudicRunConfiguration.packageRootOf(File(it)) }?.let { configuration.workingDirectory = it.path }
}
configuration.setGeneratedName()
return true
}
override fun isConfigurationFromContext(configuration: LudicRunConfiguration, context: ConfigurationContext): Boolean {
val element = context.psiLocation ?: return false
val t = targetFor(element) ?: return false
return configuration.command == t.command &&
configuration.file == t.file &&
configuration.testName == t.testName &&
configuration.arguments.isBlank()
}
}

View file

@ -0,0 +1,33 @@
package io.ludic.ide.run
import com.intellij.execution.lineMarker.ExecutorAction
import com.intellij.execution.lineMarker.RunLineMarkerContributor
import com.intellij.icons.AllIcons
import com.intellij.psi.PsiElement
import io.ludic.ide.psi.LudicPsi
/**
* The green gutter buttons: one on every `test "name"` (showing that test's
* last result), and one on `program` — run it, or run all of its tests when
* the file is a test program.
*/
class LudicRunLineMarker : RunLineMarkerContributor() {
override fun getInfo(element: PsiElement): Info? {
LudicPsi.testNameAt(element)?.let { name ->
val path = element.containingFile?.virtualFile?.path ?: return null
val icon = getTestStateIcon(LudicTestUrls.test(path, name), element.project, false)
return Info(icon, ExecutorAction.getActions(0)) { "Run test '$name'" }
}
if (LudicPsi.programNameAt(element) != null) {
val file = element.containingFile ?: return null
if (LudicPsi.isTestFile(file)) {
val path = file.virtualFile?.path ?: return null
val icon = getTestStateIcon(LudicTestUrls.file(path), element.project, true)
return Info(icon, ExecutorAction.getActions(0)) { "Run all tests in ${file.name}" }
}
return Info(AllIcons.RunConfigurations.TestState.Run, ExecutorAction.getActions(0)) { "Run ${file.name}" }
}
return null
}
}

View file

@ -0,0 +1,217 @@
package io.ludic.ide.run
import com.intellij.execution.Executor
import com.intellij.execution.Location
import com.intellij.execution.PsiLocation
import com.intellij.execution.process.ProcessOutputTypes
import com.intellij.execution.testframework.TestConsoleProperties
import com.intellij.execution.testframework.sm.ServiceMessageBuilder
import com.intellij.execution.testframework.sm.SMCustomMessagesParsing
import com.intellij.execution.testframework.sm.runner.OutputToGeneralTestEventsConverter
import com.intellij.execution.testframework.sm.runner.SMTRunnerConsoleProperties
import com.intellij.execution.testframework.sm.runner.SMTestLocator
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Key
import com.intellij.openapi.vfs.LocalFileSystem
import com.intellij.psi.PsiManager
import com.intellij.psi.search.FilenameIndex
import com.intellij.psi.search.GlobalSearchScope
import io.ludic.ide.psi.LudicPsi
import java.io.File
class LudicTestConsoleProperties(config: LudicRunConfiguration, executor: Executor) :
SMTRunnerConsoleProperties(config, FRAMEWORK, executor), SMCustomMessagesParsing {
private val workDir = config.resolvedWorkingDirectory()
init {
isIdBasedTestTree = false
isPrintTestingStartedTime = false
}
override fun getTestLocator(): SMTestLocator = LudicTestLocator
override fun createTestEventsConverter(testFrameworkName: String, props: TestConsoleProperties): OutputToGeneralTestEventsConverter =
LudicTestEventsConverter(testFrameworkName, props, workDir)
companion object {
const val FRAMEWORK = "Ludic"
}
}
/**
* Test URLs: `ludic://<absolute file>` for a file's suite and
* `ludic://<absolute file>#<test name>` for one test block. The gutter icons
* look test state up by the same string, so the two must agree exactly.
*/
object LudicTestUrls {
const val PROTOCOL = "ludic"
fun file(path: String) = "$PROTOCOL://$path"
fun test(path: String, name: String) = "$PROTOCOL://$path#$name"
/** (file, test name or null) from the part after `ludic://`. */
fun split(path: String): Pair<String, String?> {
val at = path.indexOf(".ludic#")
return if (at < 0) path to null else path.substring(0, at + 6) to path.substring(at + 7)
}
}
object LudicTestLocator : SMTestLocator {
override fun getLocation(protocol: String, path: String, project: Project, scope: GlobalSearchScope): List<Location<*>> {
if (protocol != LudicTestUrls.PROTOCOL) return emptyList()
val (filePath, name) = LudicTestUrls.split(path)
val vf = LocalFileSystem.getInstance().findFileByPath(filePath)
?: FilenameIndex.getVirtualFilesByName(File(filePath).name, scope).firstOrNull { it.path == filePath }
?: return emptyList()
val psi = PsiManager.getInstance(project).findFile(vf) ?: return emptyList()
if (name == null) return listOf(PsiLocation(psi))
val el = LudicPsi.findTest(psi, name) ?: return listOf(PsiLocation(psi))
return listOf(PsiLocation(el))
}
}
/**
* Turns `ludic test --verbose` output into test events.
*
* The runner's lines, as the CLI frames them:
*
* RUN tests/smoke.ludic
* smoke.ludic:12: expect_eq failed (got 1, want 2) <- assertion, before its result
* ok - adds
* FAIL - subtracts
* == 1 passed, 1 failed ==
* FAIL tests/smoke.ludic (exit 1)
*
* Older toolchains have no --verbose: they print only `PASS file` for a
* passing file and dump the runner output *after* `FAIL file`. Both shapes are
* accepted, so a file-level result still shows with an old install.
*/
open class LudicTestEventsConverter(
name: String,
props: TestConsoleProperties,
private val workDir: String,
) : OutputToGeneralTestEventsConverter(name, props) {
private var suite: String? = null
private var suitePath: String? = null
private var testsInSuite = 0
private var framed = false // saw a RUN line: this CLI speaks --verbose
private val pending = StringBuilder() // assertion lines awaiting their test's result
private val suiteOutput = StringBuilder() // everything else inside the suite (compile errors)
override fun processConsistentText(text: String, outputType: Key<*>) {
val line = ANSI.replace(text, "").trimEnd('\n', '\r')
val t = line.trim()
// runner lines start in column 0; the CLI's own lines are indented,
// which is what keeps a test named "x.ludic" from reading as a file
TEST_RESULT.matchEntire(line)?.let { m ->
val (verdict, testName) = m.destructured
if (suite == null) openSuite("tests", outputType)
reportTest(testName, verdict == "ok", outputType)
return
}
RUN.matchEntire(t)?.let { m ->
framed = true
closeSuite(outputType)
openSuite(m.groupValues[1], outputType)
return
}
FILE_RESULT.matchEntire(t)?.let { m ->
val (verdict, file, why) = m.destructured
if (!framed) closeSuite(outputType)
if (suite == null) {
openSuite(file, outputType)
// legacy: a passing file prints nothing more; a failing one
// dumps its runner output next, so keep the suite open for it
if (verdict == "PASS") closeSuite(outputType)
else if (why.contains("did not compile")) reportCompileFailure(outputType, why)
return
}
if (verdict == "FAIL" && testsInSuite == 0) reportCompileFailure(outputType, why)
closeSuite(outputType)
return
}
NO_SUCH_TEST.matchEntire(t)?.let { m ->
if (suite == null) openSuite("tests", outputType)
pending.append(line).append('\n')
reportTest(m.groupValues[1], false, outputType)
return
}
if (t.startsWith("== ") && t.endsWith(" ==")) {
if (FILES_SUMMARY.matches(t)) closeSuite(outputType)
forward(text, outputType)
return
}
if (suite != null) {
if (ASSERTION.containsMatchIn(t)) pending.append(line).append('\n')
else if (t.isNotEmpty()) suiteOutput.append(line).append('\n')
}
forward(text, outputType)
}
override fun flushBufferOnProcessTermination(code: Int) {
closeSuite(ProcessOutputTypes.STDOUT)
super.flushBufferOnProcessTermination(code)
}
private fun openSuite(file: String, outputType: Key<*>) {
val abs = File(file).let { if (it.isAbsolute) it else File(workDir, file) }.path
suite = file
suitePath = abs
testsInSuite = 0
pending.setLength(0)
suiteOutput.setLength(0)
emit(ServiceMessageBuilder.testSuiteStarted(file).addAttribute("locationHint", LudicTestUrls.file(abs)), outputType)
}
private fun closeSuite(outputType: Key<*>) {
val s = suite ?: return
suite = null
emit(ServiceMessageBuilder.testSuiteFinished(s), outputType)
}
private fun reportTest(name: String, passed: Boolean, outputType: Key<*>) {
testsInSuite++
val path = suitePath ?: return
emit(ServiceMessageBuilder.testStarted(name).addAttribute("locationHint", LudicTestUrls.test(path, name)), outputType)
if (!passed) {
val message = pending.lines().firstOrNull { it.isNotBlank() }?.let { ASSERTION_PREFIX.replace(it, "") } ?: "test failed"
emit(ServiceMessageBuilder.testFailed(name).addAttribute("message", message).addAttribute("details", pending.toString()), outputType)
}
emit(ServiceMessageBuilder.testFinished(name), outputType)
pending.setLength(0)
}
/** A file that never ran shows as one failed node carrying the compiler's output. */
private fun reportCompileFailure(outputType: Key<*>, why: String) {
val path = suitePath ?: return
val node = "(compile)"
testsInSuite++
emit(ServiceMessageBuilder.testStarted(node).addAttribute("locationHint", LudicTestUrls.file(path)), outputType)
val details = suiteOutput.toString().ifBlank { why }
val first = suiteOutput.lines().firstOrNull { it.isNotBlank() } ?: why.trim().removeSurrounding("(", ")")
emit(ServiceMessageBuilder.testFailed(node).addAttribute("message", first).addAttribute("details", details), outputType)
emit(ServiceMessageBuilder.testFinished(node), outputType)
}
private fun emit(msg: ServiceMessageBuilder, outputType: Key<*>) {
forward("$msg\n", outputType)
}
/** Hands text on to the platform's parser; the tests intercept it here. */
internal open fun forward(text: String, outputType: Key<*>) {
super.processConsistentText(text, outputType)
}
private companion object {
val ANSI = Regex("\\[[0-9;]*m")
val RUN = Regex("RUN\\s+(.+\\.ludic)")
val FILE_RESULT = Regex("(PASS|FAIL)\\s+(\\S.*?\\.ludic)(\\s+\\(.*\\))?")
val TEST_RESULT = Regex("(ok|FAIL) +- (.*)")
val NO_SUCH_TEST = Regex("no test named \"(.*)\"")
val FILES_SUMMARY = Regex("== .*test files (passed|failed) ==")
val ASSERTION = Regex("\\.ludic:\\d+: .*failed")
val ASSERTION_PREFIX = Regex("^\\S+\\.ludic:\\d+: ")
}
}

View file

@ -6,17 +6,25 @@
<description><![CDATA[
<p>Language support for <b>Ludic</b>, the AI-first, ECS-native game language.</p>
<ul>
<li>Syntax highlighting, brace matching, commenting and folding, implemented natively so a
<code>.ludic</code> file is coloured with no external process.</li>
<li>Completion, diagnostics, hover, go-to-definition, find usages, rename and formatting via
the <code>ludic-lsp</code> language server, through LSP4IJ, so this works in Community
editions as well as the paid IDEs.</li>
<li>Syntax highlighting (customisable under Color Scheme), brace matching and auto-closing,
quote pairing, commenting, folding and smart indentation, implemented natively so a
<code>.ludic</code> file is edited well with no external process.</li>
<li>Completion, diagnostics, hover docs, parameter info, go-to-definition, find usages,
highlight usages, rename, formatting, inlay hints, semantic highlighting, the Structure
view, breadcrumbs and Go to Symbol via the <code>ludic-lsp</code> language server,
through LSP4IJ, so this works in the free IDEs as well as the paid ones.</li>
<li>Errors from the Ludic compiler itself, reported on save.</li>
<li>Highlighting of <code>```ludic</code> fenced blocks in Markdown.</li>
<li>Run configurations for <code>ludic run</code>, <code>build</code> and <code>test</code>,
with gutter run buttons on programs and on every <code>test "name"</code> block,
results in the test runner tree, and clickable <code>file.ludic:line</code> locations
in every console.</li>
<li>New Project wizard (<code>ludic new</code>), New Ludic File templates, live templates,
and a Tools | Ludic menu (format, fetch packages, pack, bundle, doctor).</li>
<li><code>package.ludic</code> manifest highlighting, and <code>```ludic</code> fences in Markdown.</li>
</ul>
<p>Set the paths under <b>Settings &rarr; Languages &amp; Frameworks &rarr; Ludic</b>, or leave
them blank to use <code>bin/ludic-lsp</code> and <code>bin/ludicc</code> under the
project root.</p>
them blank to use <code>bin/</code> under the project root, then <code>PATH</code>, then
<code>~/.ludic/bin</code>.</p>
]]></description>
<depends>com.intellij.modules.platform</depends>
@ -54,6 +62,60 @@
language="Ludic"
implementationClass="io.ludic.ide.LudicFoldingBuilder"/>
<lang.quoteHandler
language="Ludic"
implementationClass="io.ludic.ide.editor.LudicQuoteHandler"/>
<lineIndentProvider implementation="io.ludic.ide.editor.LudicLineIndentProvider"/>
<typedHandler implementation="io.ludic.ide.editor.LudicCloserTypedHandler" id="ludicCloser"/>
<colorSettingsPage implementation="io.ludic.ide.editor.LudicColorSettingsPage"/>
<langCodeStyleSettingsProvider implementation="io.ludic.ide.editor.LudicCodeStyleSettingsProvider"/>
<liveTemplateContext contextId="Ludic" implementation="io.ludic.ide.editor.LudicTemplateContext"/>
<defaultLiveTemplates file="/liveTemplates/Ludic.xml"/>
<internalFileTemplate name="Ludic Program"/>
<internalFileTemplate name="Ludic Module"/>
<internalFileTemplate name="Ludic Test"/>
<internalFileTemplate name="Ludic File"/>
<!-- Language-server features LSP4IJ only wires up by itself for TEXT and
TextMate files; a language with its own parser opts in per feature. -->
<lang.psiStructureViewFactory
language="Ludic"
implementationClass="com.redhat.devtools.lsp4ij.features.documentSymbol.LSPDocumentSymbolStructureViewFactory"/>
<codeInsight.parameterInfo
language="Ludic"
implementationClass="com.redhat.devtools.lsp4ij.features.signatureHelp.LSPParameterInfoHandler"/>
<codeBlockProvider
language="Ludic"
implementationClass="com.redhat.devtools.lsp4ij.features.codeBlockProvider.LSPCodeBlockProvider"/>
<configurationType implementation="io.ludic.ide.run.LudicConfigurationType"/>
<runConfigurationProducer implementation="io.ludic.ide.run.LudicRunConfigurationProducer"/>
<runLineMarkerContributor language="Ludic" implementationClass="io.ludic.ide.run.LudicRunLineMarker"/>
<consoleFilterProvider implementation="io.ludic.ide.run.LudicConsoleFilterProvider"/>
<moduleBuilder builderClass="io.ludic.ide.project.LudicModuleBuilder"/>
<!-- package.ludic: the package manifest -->
<editorNotificationProvider implementation="io.ludic.ide.project.LudicPackageNotificationProvider"/>
<runLineMarkerContributor language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestLineMarker"/>
<completion.contributor language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestCompletion" order="first"/>
<lang.documentationProvider language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestDocumentation" order="first"/>
<gotoDeclarationHandler implementation="io.ludic.ide.project.LudicManifestGotoHandler" order="first"/>
<annotator language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestAnnotator"/>
<iconProvider implementation="io.ludic.ide.project.LudicManifestIconProvider" order="first"/>
<additionalLibraryRootsProvider implementation="io.ludic.ide.project.LudicPackagesLibraryProvider"/>
<notificationGroup id="Ludic" displayType="BALLOON"/>
<postStartupActivity implementation="io.ludic.ide.project.LudicToolchainCheck"/>
<colorProvider implementation="io.ludic.ide.editor.LudicColorPreview"/>
<!-- built-in reference pages as documentation, whatever element points at them -->
<documentationProvider implementation="io.ludic.ide.editor.LudicDocPageDocumentation" order="first"/>
<!-- a declaration the language server navigated to, described by the server -->
<documentationProvider implementation="io.ludic.ide.editor.LudicTargetDocumentation" order="first"/>
<projectConfigurable
parentId="language"
instance="io.ludic.ide.LudicConfigurable"
@ -61,6 +123,43 @@
displayName="Ludic"/>
</extensions>
<projectListeners>
<listener class="io.ludic.ide.project.LudicManifestWatcher"
topic="com.intellij.openapi.vfs.newvfs.BulkFileListener"/>
</projectListeners>
<actions>
<action id="Ludic.NewFile" class="io.ludic.ide.editor.CreateLudicFileAction"
text="Ludic File" description="Create a Ludic program, module or test" icon="/icons/ludic.svg">
<add-to-group group-id="NewGroup" anchor="before" relative-to-action="NewFromTemplate"/>
</action>
<group id="Ludic.Tools" text="Ludic" popup="true" icon="/icons/ludic.svg">
<add-to-group group-id="ToolsMenu" anchor="last"/>
<action id="Ludic.Run" class="io.ludic.ide.run.LudicRunProjectAction"
text="Run Project" description="ludic run"/>
<action id="Ludic.Build" class="io.ludic.ide.run.LudicBuildProjectAction"
text="Build Project" description="ludic build"/>
<action id="Ludic.Test" class="io.ludic.ide.run.LudicTestProjectAction"
text="Run All Tests" description="ludic test"/>
<action id="Ludic.RunScript" class="io.ludic.ide.run.LudicRunScriptAction"
text="Run Script…" description="Run one of package.ludic's scripts"/>
<separator/>
<action id="Ludic.Format" class="io.ludic.ide.run.LudicFormatProjectAction"
text="Format Project" description="ludic fmt: format src/ and tests/ in place"/>
<action id="Ludic.Get" class="io.ludic.ide.run.LudicGetPackagesAction"
text="Fetch Packages" description="ludic get: resolve and fetch package.ludic dependencies"/>
<action id="Ludic.Pack" class="io.ludic.ide.run.LudicPackAction"
text="Pack Assets" description="ludic pack: build the .lpak asset pack"/>
<action id="Ludic.Bundle" class="io.ludic.ide.run.LudicBundleAction"
text="Bundle App" description="ludic bundle: build a macOS .app"/>
<action id="Ludic.Clean" class="io.ludic.ide.run.LudicCleanAction"
text="Clean" description="ludic clean: remove build/"/>
<separator/>
<action id="Ludic.Doctor" class="io.ludic.ide.run.LudicDoctorAction"
text="Check Toolchain (Doctor)" description="ludic doctor"/>
</group>
</actions>
<extensions defaultExtensionNs="com.redhat.devtools.lsp4ij">
<server
id="ludic"

View file

@ -0,0 +1 @@
<html><body>Creates a Ludic File. <code>${PROGRAM_NAME}</code> is the file name in UpperCamelCase.</body></html>

View file

@ -0,0 +1,2 @@
# ${NAME}.ludic - import it from a program with: import "${NAME}.ludic"

View file

@ -0,0 +1 @@
<html><body>Creates a Ludic Module. <code>${PROGRAM_NAME}</code> is the file name in UpperCamelCase.</body></html>

View file

@ -0,0 +1,16 @@
# ${NAME}.ludic
program ${PROGRAM_NAME} {
handler Boot phase Start {
print("${PROGRAM_NAME} started")
}
handler Draw phase Render {
Screen.clear(0x101820)
Screen.show()
}
handler Keys phase Input {
if Input.key() == 'q' { quit() }
}
}

View file

@ -0,0 +1 @@
<html><body>Creates a Ludic Program. <code>${PROGRAM_NAME}</code> is the file name in UpperCamelCase.</body></html>

View file

@ -0,0 +1,6 @@
# ${NAME}.ludic - run with: ludic test ${NAME}.ludic
program ${PROGRAM_NAME}Spec {
test "${NAME} works" {
expect_eq(2 + 2, 4)
}
}

View file

@ -0,0 +1 @@
<html><body>Creates a Ludic Test. <code>${PROGRAM_NAME}</code> is the file name in UpperCamelCase.</body></html>

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" rx="6" fill="#1a1a2c"/>
<path d="M16 5 L27 10.5 L27 21.5 L16 27 L5 21.5 L5 10.5 Z" fill="#4a4a72"/>
<path d="M16 5 L27 10.5 L16 16 L5 10.5 Z" fill="#ffe060"/>
<path d="M16 16 L27 10.5 L27 21.5 L16 27 Z" fill="#9aa0c8"/>
</svg>

After

Width:  |  Height:  |  Size: 348 B

View file

@ -0,0 +1,85 @@
<templateSet group="Ludic">
<template name="prog" value="program $NAME$ {&#10; $END$&#10;}" description="program block" toReformat="false" toShortenFQNames="false">
<variable name="NAME" expression="capitalize(camelCase(fileNameWithoutExtension()))" defaultValue="&quot;Game&quot;" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="entry" value="entry {&#10; $END$&#10;}" description="entry block (runs once, for a program with no phases)" toReformat="false" toShortenFQNames="false">
<context><option name="Ludic" value="true" /></context>
</template>
<template name="handler" value="handler $NAME$ phase $PHASE$ {&#10; $END$&#10;}" description="phase handler" toReformat="false" toShortenFQNames="false">
<variable name="NAME" expression="" defaultValue="&quot;Tick&quot;" alwaysStopAt="true" />
<variable name="PHASE" expression="enum(&quot;Update&quot;, &quot;FixedUpdate&quot;, &quot;LateUpdate&quot;, &quot;Start&quot;, &quot;Input&quot;, &quot;Render&quot;, &quot;Overlay&quot;)" defaultValue="&quot;Update&quot;" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="on" value="@On($EVENT$) handler $NAME$ {&#10; $END$&#10;}" description="event handler" toReformat="false" toShortenFQNames="false">
<variable name="EVENT" expression="" defaultValue="&quot;Hit&quot;" alwaysStopAt="true" />
<variable name="NAME" expression="" defaultValue="&quot;OnHit&quot;" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="event" value="event $NAME$ { $FIELD$: int = 0 }" description="event declaration" toReformat="false" toShortenFQNames="false">
<variable name="NAME" expression="" defaultValue="&quot;Hit&quot;" alwaysStopAt="true" />
<variable name="FIELD" expression="" defaultValue="&quot;amount&quot;" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="prop" value="property $NAME$ { $FIELD$: $TYPE$ = $VALUE$ }" description="property (a component)" toReformat="false" toShortenFQNames="false">
<variable name="NAME" expression="" defaultValue="&quot;Pos&quot;" alwaysStopAt="true" />
<variable name="FIELD" expression="" defaultValue="&quot;x&quot;" alwaysStopAt="true" />
<variable name="TYPE" expression="enum(&quot;int&quot;, &quot;fixed&quot;, &quot;bool&quot;, &quot;string&quot;, &quot;entity&quot;)" defaultValue="&quot;int&quot;" alwaysStopAt="true" />
<variable name="VALUE" expression="" defaultValue="&quot;0&quot;" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="model" value="model $NAME$ {&#10; $FIELD$: $TYPE$$END$&#10;}" description="model (a record type)" toReformat="false" toShortenFQNames="false">
<variable name="NAME" expression="" defaultValue="&quot;Stats&quot;" alwaysStopAt="true" />
<variable name="FIELD" expression="" defaultValue="&quot;hp&quot;" alwaysStopAt="true" />
<variable name="TYPE" expression="enum(&quot;int&quot;, &quot;fixed&quot;, &quot;bool&quot;, &quot;string&quot;, &quot;entity&quot;)" defaultValue="&quot;int&quot;" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="fn" value="function $NAME$($PARAMS$) -> $RET$ {&#10; $END$&#10;}" description="function" toReformat="false" toShortenFQNames="false">
<variable name="NAME" expression="" defaultValue="&quot;helper&quot;" alwaysStopAt="true" />
<variable name="PARAMS" expression="" defaultValue="&quot;&quot;" alwaysStopAt="true" />
<variable name="RET" expression="enum(&quot;void&quot;, &quot;int&quot;, &quot;fixed&quot;, &quot;bool&quot;, &quot;string&quot;)" defaultValue="&quot;void&quot;" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="test" value="test &quot;$NAME$&quot; {&#10; $END$&#10;}" description="test block" toReformat="false" toShortenFQNames="false">
<variable name="NAME" expression="" defaultValue="&quot;it works&quot;" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="expeq" value="expect_eq($ACTUAL$, $EXPECTED$)" description="expect_eq assertion" toReformat="false" toShortenFQNames="false">
<variable name="ACTUAL" expression="" defaultValue="" alwaysStopAt="true" />
<variable name="EXPECTED" expression="" defaultValue="" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="forq" value="for ($VARS$) in query [$PROPS$] {&#10; $END$&#10;}" description="for over an entity query" toReformat="false" toShortenFQNames="false">
<variable name="VARS" expression="" defaultValue="&quot;p&quot;" alwaysStopAt="true" />
<variable name="PROPS" expression="" defaultValue="&quot;Pos&quot;" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="fori" value="for $I$ in $FROM$ .. $TO$ {&#10; $END$&#10;}" description="for over a range" toReformat="false" toShortenFQNames="false">
<variable name="I" expression="" defaultValue="&quot;i&quot;" alwaysStopAt="true" />
<variable name="FROM" expression="" defaultValue="&quot;0&quot;" alwaysStopAt="true" />
<variable name="TO" expression="" defaultValue="&quot;10&quot;" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="if" value="if $COND$ {&#10; $END$&#10;}" description="if block" toReformat="false" toShortenFQNames="false">
<variable name="COND" expression="" defaultValue="" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="ife" value="if $COND$ {&#10; $THEN$&#10;} else {&#10; $END$&#10;}" description="if / else" toReformat="false" toShortenFQNames="false">
<variable name="COND" expression="" defaultValue="" alwaysStopAt="true" />
<variable name="THEN" expression="" defaultValue="" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="while" value="while $COND$ {&#10; $END$&#10;}" description="while loop" toReformat="false" toShortenFQNames="false">
<variable name="COND" expression="" defaultValue="" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="spawn" value="spawn $NAME$ { $PROP$ { $END$ } }" description="spawn an entity" toReformat="false" toShortenFQNames="false">
<variable name="NAME" expression="" defaultValue="&quot;Thing&quot;" alwaysStopAt="true" />
<variable name="PROP" expression="" defaultValue="&quot;Pos&quot;" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
<template name="imp" value="import &quot;$PATH$&quot;" description="import a module" toReformat="false" toShortenFQNames="false">
<variable name="PATH" expression="" defaultValue="&quot;module.ludic&quot;" alwaysStopAt="true" />
<context><option name="Ludic" value="true" /></context>
</template>
</templateSet>

View file

@ -21,6 +21,10 @@ class FindExecutableTest {
private val names = listOf("ludic-lsp", "ludic-lsp.exe")
// Every lookup below is cut off from the developer's PATH and ~/.ludic:
// a real install on the machine running the tests must not decide them.
private val noHome: String get() = File(tmp.root, "no-home").path
private fun executableAt(vararg segments: String): File {
val f = File(tmp.root, segments.joinToString(File.separator))
f.parentFile.mkdirs()
@ -30,9 +34,9 @@ class FindExecutableTest {
}
@Test
fun `finds the binary that build-tools_sh produces`() {
val exe = executableAt("build", "ludic-lsp")
assertEquals(exe.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
fun `finds the binary that ludic-dev tools produces`() {
val exe = executableAt("bin", "ludic-lsp")
assertEquals(exe.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
}
@Test
@ -40,19 +44,19 @@ class FindExecutableTest {
// The bug that shipped: the IDE was opened on <repo>/tools, so the
// lookup searched <repo>/tools/build and came back empty while
// <repo>/build/ludic-lsp sat there the whole time.
executableAt("build", "ludic-lsp")
executableAt("bin", "ludic-lsp")
val subdir = File(tmp.root, "tools").apply { mkdirs() }
assertNull(
"must not silently resolve from an unrelated root",
findExecutable(subdir.path, "", names, SERVER_SUBDIRS)
findExecutable(subdir.path, "", names, SERVER_SUBDIRS, path = null, home = noHome)
)
}
@Test
fun `search order prefers build over the other candidates`() {
executableAt("bin", "ludic-lsp")
val preferred = executableAt("build", "ludic-lsp")
assertEquals(preferred.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
fun `search order prefers bin over the other candidates`() {
executableAt("build", "ludic-lsp")
val preferred = executableAt("bin", "ludic-lsp")
assertEquals(preferred.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
}
@Test
@ -66,7 +70,7 @@ class FindExecutableTest {
assertEquals(
"subdir '$dir' is advertised but not searched",
f.canonicalPath,
File(findExecutable(fresh.root.path, "", names, SERVER_SUBDIRS)!!).canonicalPath
File(findExecutable(fresh.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome)!!).canonicalPath
)
fresh.delete()
}
@ -76,7 +80,7 @@ class FindExecutableTest {
fun `an explicitly configured path wins over anything found by search`() {
executableAt("build", "ludic-lsp")
val custom = executableAt("elsewhere", "my-lsp")
assertEquals(custom.absolutePath, findExecutable(tmp.root.path, custom.absolutePath, names, SERVER_SUBDIRS))
assertEquals(custom.absolutePath, findExecutable(tmp.root.path, custom.absolutePath, names, SERVER_SUBDIRS, path = null, home = noHome))
}
@Test
@ -84,7 +88,7 @@ class FindExecutableTest {
// Silently ignoring the setting and using a different binary would be
// worse than failing: the user asked for a specific server.
executableAt("build", "ludic-lsp")
assertNull(findExecutable(tmp.root.path, File(tmp.root, "nope").path, names, SERVER_SUBDIRS))
assertNull(findExecutable(tmp.root.path, File(tmp.root, "nope").path, names, SERVER_SUBDIRS, path = null, home = noHome))
}
@Test
@ -93,30 +97,47 @@ class FindExecutableTest {
f.parentFile.mkdirs()
f.writeText("not executable")
f.setExecutable(false)
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
}
@Test
fun `a directory named like the binary is not accepted`() {
File(tmp.root, "build/ludic-lsp").mkdirs()
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
}
@Test
fun `the install root is the last resort`() {
val installed = executableAt("no-home", "bin", "ludic-lsp")
assertEquals(installed.absolutePath, findExecutable(null, "", names, SERVER_SUBDIRS, path = null, home = noHome))
// ...and a project-local binary still beats it
val local = executableAt("bin", "ludic-lsp")
assertEquals(local.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
}
@Test
fun `PATH is searched before the install root`() {
executableAt("no-home", "bin", "ludic-lsp")
val onPath = executableAt("somewhere", "ludic-lsp")
val path = listOf("", File(tmp.root, "empty").path, onPath.parent).joinToString(File.pathSeparator)
assertEquals(onPath.absolutePath, findExecutable(null, "", names, SERVER_SUBDIRS, path = path, home = noHome))
}
@Test
fun `a null project root still allows a PATH lookup`() {
// No exception, no crash: the default project has no basePath.
findExecutable(null, "", names, SERVER_SUBDIRS)
findExecutable(null, "", names, SERVER_SUBDIRS, path = null, home = noHome)
}
@Test
fun `the real repository layout resolves`() {
// Guards the arrangement the plugin actually ships against: run
// ./tools/build-tools.sh and opening the repo root must just work.
// `bin/ludic-dev tools` and opening the repo root must just work.
val repoRoot = File(System.getProperty("user.dir")).parentFile.parentFile.parentFile
val built = File(repoRoot, "build/ludic-lsp")
val built = File(repoRoot, "bin/ludic-lsp")
if (!built.isFile) return // toolchain not built in this checkout
val found = findExecutable(repoRoot.path, "", names, SERVER_SUBDIRS)
assertTrue("repo root must resolve to build/ludic-lsp", found != null)
val found = findExecutable(repoRoot.path, "", names, SERVER_SUBDIRS, path = null, home = noHome)
assertTrue("repo root must resolve to bin/ludic-lsp", found != null)
assertEquals(built.canonicalPath, File(found!!).canonicalPath)
}
}
@ -132,17 +153,23 @@ class ServerCommandTest {
@Test
fun `the repository root produces a runnable stdio command`() {
val built = File(repoRoot, "build/ludic-lsp")
val built = File(repoRoot, "bin/ludic-lsp")
if (!built.isFile) return // toolchain not built in this checkout
val cmd = serverCommand(repoRoot.path, "")
assertEquals(listOf(built.canonicalPath, "--stdio"), cmd!!.let { listOf(File(it[0]).canonicalPath, it[1]) })
// and it must actually be executable, not merely present
val proc = ProcessBuilder(cmd[0], "--version").redirectErrorStream(true).start()
val out = proc.inputStream.bufferedReader().readText().trim()
proc.waitFor()
assertTrue("expected a version banner, got '$out'", out.startsWith("ludic-lsp"))
// and it must actually start, not merely be present. The server reads
// stdin until EOF whatever its arguments, so stdin is closed and the
// wait is bounded: an open stdin here once hung the whole test run.
val proc = ProcessBuilder(cmd)
.redirectInput(ProcessBuilder.Redirect.from(File("/dev/null")))
.redirectErrorStream(true)
.start()
val exited = proc.waitFor(20, java.util.concurrent.TimeUnit.SECONDS)
if (!exited) proc.destroyForcibly()
assertTrue("ludic-lsp did not exit on EOF", exited)
assertEquals("ludic-lsp exit code on EOF", 0, proc.exitValue())
}
@Test

View file

@ -0,0 +1,59 @@
package io.ludic.ide
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import io.ludic.ide.editor.LudicColorPreview
import io.ludic.ide.editor.LudicDocPageDocumentation
import io.ludic.ide.editor.LudicPalette
import io.ludic.ide.editor.LudicSemanticColors
import java.awt.Color
import java.io.File
/** Colour swatches, semantic colours and the reference-page documentation. */
class LudicEditorExtrasTest : BasePlatformTestCase() {
fun testHexIntsHexStringsAndPaletteNamesHaveSwatches() {
val file = myFixture.configureByText("c.ludic", "Screen.clear(0x0d1020)\nScreen.clear(Color.Crimson)\nlet mask = 0xff\n")
val provider = LudicColorPreview()
val hex = file.findElementAt(file.text.indexOf("0x0d"))!!
assertEquals(Color(0x0d1020), provider.getColorFrom(hex))
val named = file.findElementAt(file.text.indexOf("Crimson"))!!
assertEquals(Color(LudicPalette.lookup("Crimson")!!.toInt(16)), provider.getColorFrom(named))
assertNull("a short hex is a mask, not a colour", provider.getColorFrom(file.findElementAt(file.text.indexOf("0xff"))!!))
val man = myFixture.configureByText("package.ludic", "app splash_bg \"#0b1622\"\n")
assertEquals(Color(0x0b1622), provider.getColorFrom(man.findElementAt(man.text.indexOf("\"#"))!!))
}
fun testPickingAColourRewritesTheLiteral() {
val file = myFixture.configureByText("c.ludic", "Screen.clear(Color.Crimson)\n")
LudicColorPreview().setColorTo(file.findElementAt(file.text.indexOf("Crimson"))!!, Color(0x123456))
assertEquals("Screen.clear(0x123456)\n", myFixture.editor.document.text)
}
fun testPaletteIsBundled() {
assertTrue(LudicPalette.all().size > 200)
}
fun testSemanticTokensMapToLudicKeys() {
assertSame(LudicColors.FUNCTION_BUILTIN, LudicSemanticColors.keyFor("function", listOf("defaultLibrary")))
assertSame(LudicColors.FUNCTION_PACKAGE, LudicSemanticColors.keyFor("function", listOf("vendor")))
assertSame(LudicColors.FUNCTION_DECLARATION, LudicSemanticColors.keyFor("function", listOf("declaration")))
assertSame(LudicColors.FUNCTION_CALL, LudicSemanticColors.keyFor("function", emptyList()))
assertSame(LudicColors.HANDLER, LudicSemanticColors.keyFor("function", listOf("readonly", "declaration")))
assertSame(LudicColors.CONSTANT_PACKAGE, LudicSemanticColors.keyFor("variable", listOf("readonly", "static", "vendor")))
assertSame(LudicColors.GLOBAL_VARIABLE, LudicSemanticColors.keyFor("variable", listOf("static")))
assertSame(LudicColors.LOCAL_VARIABLE, LudicSemanticColors.keyFor("variable", emptyList()))
assertSame(LudicColors.NAMESPACE_BUILTIN, LudicSemanticColors.keyFor("namespace", listOf("defaultLibrary")))
assertNull("keywords keep the lexer's colour", LudicSemanticColors.keyFor("keyword", emptyList()))
}
fun testReferencePagesRenderAsDocumentation() {
val md = "---\nid: type-void\nname: void\nkind: type\nsig: void\ntip: No value.\n---\n\n`void` is what a function returns when it returns nothing.\n\n```ludic\n# doc-check: skip\nfunction f() -> void { }\n```\n"
val page = LudicDocPageDocumentation.parse(File("/x/docs/language/types/type-void.md"), md)!!
assertEquals("void", page.meta["sig"])
val html = LudicDocPageDocumentation.markdownToHtml(page.body)
assertTrue(html, html.contains("<code>void</code> is what a function returns"))
assertTrue(html, html.contains("function f() -&gt; void"))
assertFalse(html, html.contains("doc-check"))
assertFalse(html, html.contains("---"))
}
}

View file

@ -0,0 +1,174 @@
package io.ludic.ide
import com.intellij.application.options.CodeStyle
import com.intellij.codeInsight.template.impl.TemplateManagerImpl
import com.intellij.codeInsight.template.impl.TemplateSettings
import com.intellij.ide.fileTemplates.FileTemplateManager
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.options.colors.ColorSettingsPages
import com.intellij.psi.search.PsiTodoSearchHelper
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import io.ludic.ide.editor.CreateLudicFileAction
import io.ludic.ide.editor.LudicColorSettingsPage
import io.ludic.ide.editor.LudicIndenter
/**
* Typing in a .ludic file: indentation, closers, quotes, comments, templates.
* Each test drives the real editor actions in a headless fixture, so these
* fail when an extension is registered wrongly, not only when the logic is.
*/
class LudicEditorTest : BasePlatformTestCase() {
private fun type(before: String, typed: String, after: String) {
myFixture.configureByText("t.ludic", before)
myFixture.type(typed)
val doc = myFixture.editor.document.text
val caret = myFixture.editor.caretModel.offset
assertEquals(after, doc.substring(0, caret) + "<caret>" + doc.substring(caret))
}
fun testEnterAfterAnOpeningBraceIndentsOneLevel() {
type(
"program X {<caret>\n}",
"\n",
"program X {\n <caret>\n}"
)
}
fun testEnterInsideNestedBlocksFollowsTheOpenersLine() {
type(
"program X {\n handler H phase Update {\n for (p) in query [Pos] {<caret>\n }\n }\n}",
"\n",
"program X {\n handler H phase Update {\n for (p) in query [Pos] {\n <caret>\n }\n }\n}"
)
}
fun testEnterAfterAPlainStatementKeepsTheDepth() {
type(
"program X {\n entry {\n let a = 1<caret>\n }\n}",
"\n",
"program X {\n entry {\n let a = 1\n <caret>\n }\n}"
)
}
fun testBracesInStringsAndCommentsDoNotCount() {
val text = "program X {\n let s = \"{{{\" # {\n"
assertEquals(" ", LudicIndenter.indentFor(text, text.length, " "))
}
fun testTypingAClosingBraceDedentsTheLine() {
type(
"program X {\n entry {\n let a = 1\n <caret>\n}",
"}",
"program X {\n entry {\n let a = 1\n }<caret>\n}"
)
}
fun testAClosingBraceAfterCodeIsLeftAlone() {
type(
"program X {\n entry { let a = 1 <caret>\n}",
"}",
"program X {\n entry { let a = 1 }<caret>\n}"
)
}
fun testQuotesAndBracketsPairUp() {
type("program X {\n let s = <caret>\n}", "\"", "program X {\n let s = \"<caret>\"\n}")
type("program X {\n f<caret>\n}", "(", "program X {\n f(<caret>)\n}")
type("program X {\n let a = <caret>\n}", "[", "program X {\n let a = [<caret>]\n}")
}
fun testBackticksPairAndHolesMatch() {
type("program X {\n let s = <caret>\n}", "`", "program X {\n let s = `<caret>`\n}")
}
fun testLineCommentActionUsesHash() {
myFixture.configureByText("t.ludic", "let a = 1<caret>\n")
myFixture.performEditorAction(IdeActions.ACTION_COMMENT_LINE)
assertEquals("# let a = 1\n", myFixture.editor.document.text)
}
fun testTodoCommentsAreIndexed() {
val file = myFixture.configureByText("t.ludic", "# TODO: make it faster\nlet a = 1\n")
val todos = PsiTodoSearchHelper.getInstance(project).findTodoItems(file)
assertEquals(1, todos.size)
}
fun testManifestDirectivesHighlightOnlyInPackageLudic() {
val manifest = LudicSyntaxHighlighterFactory().getSyntaxHighlighter(project, myFixture.configureByText("package.ludic", "require \"x\" \"1\"").virtualFile)
val lexer = manifest.highlightingLexer
lexer.start("require \"x\" \"1\"")
assertEquals(LudicTokens.MANIFEST_KEYWORD, lexer.tokenType)
val plain = LudicSyntaxHighlighterFactory().getSyntaxHighlighter(project, myFixture.configureByText("main.ludic", "require").virtualFile)
val plainLexer = plain.highlightingLexer
plainLexer.start("require")
assertEquals(LudicTokens.IDENTIFIER, plainLexer.tokenType)
}
fun testColorSettingsPageIsRegisteredAndCoversEveryKey() {
val page = ColorSettingsPages.getInstance().registeredPages.filterIsInstance<LudicColorSettingsPage>().single()
val keys = page.attributeDescriptors.map { it.key }.toSet()
for (field in LudicColors::class.java.declaredFields) {
val value = runCatching { field.isAccessible = true; field.get(LudicColors) }.getOrNull()
if (value is com.intellij.openapi.editor.colors.TextAttributesKey) {
assertTrue("color key ${value.externalName} missing from the settings page", value in keys)
}
}
// the demo text must lex cleanly, or the preview shows red
val lexer = page.highlighter.highlightingLexer
lexer.start(page.demoText)
while (lexer.tokenType != null) {
assertNotSame("bad character in demo text at ${lexer.tokenStart}", LudicTokens.BAD, lexer.tokenType)
lexer.advance()
}
}
fun testCodeStyleDefaultsToTwoSpaces() {
val opts = CodeStyle.getSettings(project).getIndentOptions(LudicFileType)
assertEquals(2, opts.INDENT_SIZE)
assertFalse(opts.USE_TAB_CHARACTER)
}
fun testLiveTemplatesAreRegisteredAndExpand() {
val template = TemplateSettings.getInstance().getTemplate("test", "Ludic")
assertNotNull("the `test` live template is not registered", template)
TemplateManagerImpl.setTemplateTesting(testRootDisposable)
myFixture.configureByText("t.ludic", "program X {\n test<caret>\n}")
myFixture.performEditorAction(IdeActions.ACTION_EXPAND_LIVE_TEMPLATE_BY_TAB)
assertTrue(myFixture.editor.document.text, myFixture.editor.document.text.contains("test \"it works\" {"))
}
fun testLiveTemplatesStayOutOfStringsAndComments() {
myFixture.configureByText("t.ludic", "# test<caret>\n")
myFixture.performEditorAction(IdeActions.ACTION_EXPAND_LIVE_TEMPLATE_BY_TAB)
assertFalse(myFixture.editor.document.text.contains("it works"))
}
fun testNewFileTemplatesExistAndRenderAProgramName() {
val mgr = FileTemplateManager.getInstance(project)
for (name in listOf(CreateLudicFileAction.PROGRAM, CreateLudicFileAction.MODULE, CreateLudicFileAction.TEST, CreateLudicFileAction.EMPTY)) {
val t = mgr.getInternalTemplate(name)
assertEquals("ludic", t.extension)
}
val props = mgr.defaultProperties.apply {
setProperty("NAME", "enemy_ai")
setProperty("PROGRAM_NAME", CreateLudicFileAction.programName("enemy_ai"))
}
val text = mgr.getInternalTemplate(CreateLudicFileAction.PROGRAM).getText(props)
assertTrue(text, text.contains("program EnemyAi {"))
val lexer = LudicLexer()
lexer.start(text)
while (lexer.tokenType != null) {
assertNotSame(LudicTokens.BAD, lexer.tokenType)
lexer.advance()
}
}
fun testProgramNamesFromFileNames() {
assertEquals("EnemyAi", CreateLudicFileAction.programName("enemy_ai"))
assertEquals("Hud", CreateLudicFileAction.programName("hud"))
assertEquals("P2d", CreateLudicFileAction.programName("2d"))
assertEquals("MyGame", CreateLudicFileAction.programName("my-game"))
}
}

View file

@ -33,8 +33,8 @@ class LudicLexerTest {
@Test
fun `declaration keywords are distinct from statement keywords`() {
assertEquals(
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.BRACE),
typesOf("game Hello {")
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.LBRACE),
typesOf("program Hello {")
)
assertEquals(LudicTokens.KEYWORD, typesOf("if x { }").first())
}
@ -42,10 +42,10 @@ class LudicLexerTest {
@Test
fun `clause keywords are their own class`() {
// `phase` and `query` colour differently from `if`/`let`, which is the
// whole reason a system signature reads as a signature.
// whole reason a handler signature reads as a signature.
assertEquals(
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.CLAUSE, LudicTokens.PHASE),
typesOf("system Move phase FixedUpdate")
typesOf("handler Move phase FixedUpdate")
)
}
@ -60,10 +60,87 @@ class LudicLexerTest {
@Test
fun `builtins are recognised, unknown calls are not`() {
assertEquals(LudicTokens.BUILTIN, typesOf("rng_chance").single())
assertEquals(LudicTokens.BUILTIN, typesOf("peek32").single()) // intrinsic
assertEquals(LudicTokens.BUILTIN, typesOf("arg_count").single()) // intrinsic
assertEquals(LudicTokens.IDENTIFIER, typesOf("my_helper").single())
}
@Test
fun `declaration words used as names are names`() {
// the compiler reserves these only as function names
assertEquals(LudicTokens.IDENTIFIER, lex("var model = look_model(x)")[1].first)
assertEquals(LudicTokens.IDENTIFIER, lex(" model: Model,")[0].first)
assertEquals(LudicTokens.IDENTIFIER, lex("a.model = m")[2].first)
assertEquals(LudicTokens.IDENTIFIER, lex("if model == null { }")[1].first)
assertEquals(LudicTokens.IDENTIFIER, lex("f(model, 1)")[2].first)
assertEquals(LudicTokens.DECL_KEYWORD, lex("model Actor {")[0].first)
assertEquals(LudicTokens.DECL_KEYWORD, lex("{\n model Score { a: int }")[1].first)
assertEquals(LudicTokens.CLAUSE, lex("for (p) in query [Pos] { }").first { it.second == "query" }.first)
assertEquals(LudicTokens.DECL_KEYWORD, lex("@On(Hit) handler Armor { }").first { it.second == "handler" }.first)
}
@Test
fun `restarting mid-file with the saved state lexes the same`() {
val text = "program X {\n var model = 1\n model Foo { model: int }\n}"
val full = LudicLexer()
full.start(text, 0, text.length, 0)
val states = mutableListOf<Triple<Int, Int, IElementType>>()
while (full.tokenType != null) { states += Triple(full.tokenStart, full.state, full.tokenType!!); full.advance() }
for ((start, state, _) in states) {
val part = LudicLexer()
part.start(text, start, text.length, state)
val rest = states.filter { it.first >= start }.map { it.third }
val got = mutableListOf<IElementType>()
while (part.tokenType != null) { got += part.tokenType!!; part.advance() }
assertEquals("restart at $start", rest, got)
}
}
@Test
fun `template strings lex their holes as code`() {
val toks = lex("push(lods, gltf_load(dir, \"rock.gltf\", `rock_{variant}_LOD{k}`))")
val t = toks.dropWhile { it.first != LudicTokens.TEMPLATE_QUOTE }
assertEquals(
listOf(
LudicTokens.TEMPLATE_QUOTE to "`", LudicTokens.TEMPLATE_TEXT to "rock_",
LudicTokens.TEMPLATE_HOLE_START to "{", LudicTokens.IDENTIFIER to "variant", LudicTokens.TEMPLATE_HOLE_END to "}",
LudicTokens.TEMPLATE_TEXT to "_LOD",
LudicTokens.TEMPLATE_HOLE_START to "{", LudicTokens.IDENTIFIER to "k", LudicTokens.TEMPLATE_HOLE_END to "}",
LudicTokens.TEMPLATE_QUOTE to "`", LudicTokens.RPAREN to ")", LudicTokens.RPAREN to ")",
),
t
)
}
@Test
fun `template escapes, nested braces and strings in holes`() {
val toks = lex("`a {{b}} \\n {Pos { x: 1 }.x} {f(\"}\")}`")
assertEquals(2, toks.count { it.first == LudicTokens.TEMPLATE_QUOTE })
assertEquals(listOf("{{", "}}", "\\n"), toks.filter { it.first == LudicTokens.TEMPLATE_ESCAPE }.map { it.second })
assertEquals(listOf(LudicTokens.LBRACE, LudicTokens.RBRACE), toks.filter { it.first == LudicTokens.LBRACE || it.first == LudicTokens.RBRACE }.map { it.first })
assertEquals(2, toks.count { it.first == LudicTokens.TEMPLATE_HOLE_END })
assertEquals("\"}\"", toks.single { it.first == LudicTokens.STRING }.second)
// code after the template is code again
val after = lex("`x {y}` let z = 1")
assertEquals(LudicTokens.KEYWORD, after.first { it.second == "let" }.first)
}
@Test
fun `a multi-line template restarts correctly mid-text`() {
val text = "let s = `one {a}\ntwo {b} three`\nlet t = 1"
val full = LudicLexer()
full.start(text, 0, text.length, 0)
val all = mutableListOf<Triple<Int, Int, IElementType>>()
while (full.tokenType != null) { all += Triple(full.tokenStart, full.state, full.tokenType!!); full.advance() }
for ((start, state, _) in all) {
val part = LudicLexer()
part.start(text, start, text.length, state)
val got = mutableListOf<IElementType>()
while (part.tokenType != null) { got += part.tokenType!!; part.advance() }
assertEquals("restart at $start", all.filter { it.first >= start }.map { it.third }, got)
}
assertEquals(LudicTokens.KEYWORD, lex(text).last { it.second == "let" }.first)
}
@Test
fun `capitalised words read as type names`() {
assertEquals(LudicTokens.TYPE_NAME, typesOf("Pos").single())
@ -127,7 +204,7 @@ class LudicLexerTest {
fun `the lexer always terminates and covers every character`() {
// The IDE lexes half-typed text constantly; a lexer that stalls hangs
// the editor, and one that skips bytes corrupts every offset after it.
val nasty = "game X { component ¿ { a: int = ' } # é\n query [{Tag}] \"unclosed"
val nasty = "program X { property ¿ { a: int = ' } # é\n query [{Tag}] \"unclosed"
val lexer = LudicLexer()
lexer.start(nasty, 0, nasty.length, 0)
var covered = 0
@ -145,17 +222,20 @@ class LudicLexerTest {
@Test
fun `a real source file lexes with no bad characters`() {
val src = """
game Hello {
component Pos { x: int = 0, y: int = 0 }
archetype Mob { Pos }
program Hello {
property Pos { x: int = 0, y: int = 0 }
model Mob { a: int, b: fixed }
system Move phase FixedUpdate
query (p) [Pos, {Player}] where p.x > 0
{
p.x = p.x + 1
for i in 0 .. 10 { print_int(i) }
handler Move phase FixedUpdate {
for (p) in query [Pos] where p.x > 0 {
p.x = p.x + 1
Screen.fill_rectangle(x: p.x, y: 0, width: 4, height: 4, color: Color.White)
}
for i in 0 .. 10 { print(string(i)) }
}
test "adds" { expect_eq(1 + 1, 2) }
ui Menu {
panel id=Root w=288 pad=16 {
button id=Go text="Go" size=16

View file

@ -0,0 +1,190 @@
package io.ludic.ide
import com.intellij.execution.lineMarker.RunLineMarkerContributor
import com.intellij.ide.IconProvider
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import io.ludic.ide.project.LudicManifest
import io.ludic.ide.project.LudicManifestDocumentation
import io.ludic.ide.project.LudicPackageIcons
import io.ludic.ide.project.LudicPackageNotificationProvider
import java.io.File
import java.nio.file.Files
/** package.ludic as a package file: the banner, gutter actions, completion, docs. */
class LudicManifestTest : BasePlatformTestCase() {
private val manifest = """
package "git.example.com/me/game" # the import path
version "0.2.0"
kind source
require "git.example.com/me/util" "1.0.0"
require "git.example.com/me/fx" "0.3.1"
app name "My Game"
pack "assets/kit"
""".trimIndent()
fun testParsingFollowsTheCli() {
val m = LudicManifest.parse(manifest)
assertEquals("git.example.com/me/game", m.module)
assertEquals("0.2.0", m.version)
assertEquals("source", m.kind)
assertEquals(listOf("git.example.com/me/util", "git.example.com/me/fx"), m.requires.map { it.module })
assertEquals(listOf(4, 5), m.requires.map { it.line })
assertEquals(listOf("app", "name", "My Game"), LudicManifest.words("app name \"My Game\" # comment"))
}
fun testStaleWhenTheLockIsMissingOrOlder() {
val dir = Files.createTempDirectory("ludic-manifest").toFile()
try {
val m = LudicManifest.parse(manifest)
File(dir, "package.ludic").writeText(manifest)
assertNotNull("no lock yet", LudicManifest.staleReason(dir, m))
val lock = File(dir, "package.lock.ludic").apply { writeText("lock 1\n") }
File(dir, "ludic_modules").mkdirs()
lock.setLastModified(File(dir, "package.ludic").lastModified() + 5_000)
assertNull("lock newer than the manifest", LudicManifest.staleReason(dir, m))
File(dir, "package.ludic").setLastModified(lock.lastModified() + 5_000)
assertNotNull("manifest edited after the lock", LudicManifest.staleReason(dir, m))
assertNull("no requires, nothing to fetch", LudicManifest.staleReason(dir, LudicManifest.parse("package \"x\"")))
} finally {
dir.deleteRecursively()
}
}
fun testDirectivesCompleteAtLineStart() {
myFixture.configureByText("package.ludic", "package \"x\"\nre<caret>")
val items = myFixture.completeBasic()?.map { it.lookupString } ?: listOf(myFixture.editor.document.text)
assertTrue(items.toString(), items.any { it.startsWith("require") } || myFixture.editor.document.text.contains("require "))
assertFalse("no Ludic keywords in a manifest", items.contains("let"))
}
fun testAppKeysAndKindsComplete() {
myFixture.configureByText("package.ludic", "app <caret>")
val keys = myFixture.completeBasic().map { it.lookupString.trim() }
assertTrue(keys.containsAll(listOf("name", "id", "icon", "splash_bg")))
myFixture.configureByText("package.ludic", "kind <caret>")
assertEquals(setOf("source", "prebuilt"), myFixture.completeBasic().map { it.lookupString }.toSet())
}
fun testManifestCompletionStaysOutOfSource() {
myFixture.configureByText("main.ludic", "program X {\n re<caret>\n}")
val items = myFixture.completeBasic()?.map { it.lookupString } ?: emptyList()
assertFalse(items.any { it.startsWith("require ") })
}
fun testGutterActionsOnPackageAndRequireLines() {
myFixture.configureByText("package.ludic", manifest)
val marked = PsiTreeUtil.collectElements(myFixture.file) { it.firstChild == null }
.mapNotNull { e ->
RunLineMarkerContributor.EXTENSION.allForLanguage(LudicLanguage)
.firstNotNullOfOrNull { it.getInfo(e) }?.let { e.text to it.actions.size }
}
assertEquals(listOf("package" to 5, "require" to 3, "require" to 3), marked)
}
fun testDirectivesHaveQuickDocumentation() {
myFixture.configureByText("package.ludic", manifest)
val file = myFixture.file
val req = file.findElementAt(file.text.indexOf("require"))!!
val doc = LudicManifestDocumentation().generateDoc(req, req)
assertNotNull(doc)
assertTrue(doc!!.contains("minimum version"))
val key = file.findElementAt(file.text.indexOf("name \"My"))!!
assertTrue(LudicManifestDocumentation().generateDoc(key, key)!!.contains("display name"))
val str = file.findElementAt(file.text.indexOf("0.2.0"))!!
assertTrue("a version value explains its tag", LudicManifestDocumentation().generateDoc(str, str)!!.contains("v0.2.0"))
val comment = file.findElementAt(file.text.indexOf("the import path"))!!
assertNotNull(LudicManifestDocumentation().generateDoc(comment, comment))
}
fun testBannerAppearsOnlyOnTheManifest() {
val provider = LudicPackageNotificationProvider()
val man = myFixture.configureByText("package.ludic", manifest).virtualFile
assertNotNull(provider.collectNotificationData(project, man))
val src = myFixture.configureByText("main.ludic", "program X {}").virtualFile
assertNull(provider.collectNotificationData(project, src))
}
fun testPathValuesNavigateAndMissingOnesAreErrors() {
myFixture.addFileToProject("assets/app/icon.png", "png")
myFixture.addFileToProject("src/game.ludic", "program G {}")
val file = myFixture.configureByText("package.ludic", "entry \"src/game.ludic\"\napp icon \"assets/app/icon.png\"\npack \"assets/nope\"\nkind sauce\nbogus 1\n")
val entry = file.findElementAt(file.text.indexOf("\"src/"))!!
val targets = io.ludic.ide.project.LudicManifestGotoHandler().getGotoDeclarationTargets(entry, entry.textOffset, myFixture.editor)
assertEquals("game.ludic", (targets!!.single() as com.intellij.psi.PsiFile).name)
val icon = file.findElementAt(file.text.indexOf("\"assets/app"))!!
assertNotNull(io.ludic.ide.project.manifestTarget(icon))
val problems = myFixture.doHighlighting().map { it.description }
assertTrue(problems.toString(), problems.any { it?.contains("No such file or directory: assets/nope") == true })
assertTrue(problems.toString(), problems.any { it?.startsWith("kind must be one of") == true })
assertTrue(problems.toString(), problems.any { it?.contains("Unknown directive 'bogus'") == true })
assertFalse(problems.toString(), problems.any { it?.contains("icon.png") == true })
}
fun testAppLinesPreviewTheWholeApp() {
val file = myFixture.configureByText("package.ludic", "app name \"Maroon Lake\"\napp id \"io.x.lake\"\napp splash_bg \"#0b1622\"\nversion \"0.1.7\"\n")
val el = file.findElementAt(file.text.indexOf("io.x"))!!
val doc = LudicManifestDocumentation().generateDoc(el, el)!!
assertTrue(doc, doc.contains("Maroon Lake.app"))
assertTrue(doc, doc.contains("io.x.lake"))
assertTrue(doc, doc.contains("0.1.7"))
assertTrue(doc, doc.contains("#0b1622"))
val kind = myFixture.configureByText("package.ludic", "kind source\n")
val src = kind.findElementAt(kind.text.indexOf("source"))!!
assertTrue(LudicManifestDocumentation().generateDoc(src, src)!!.contains("Compiled from source"))
}
fun testLockFileIsReadAsALock() {
val text = "# generated\nlock 1\nmodule \"git.x/me/util\" version \"1.2.0\" hash \"abc\" kind \"source\" provides \"Util\"\n"
val locked = LudicManifest.parseLock(text).single()
assertEquals("git.x/me/util", locked.module)
assertEquals("1.2.0", locked.version)
assertEquals(listOf("Util"), locked.provides)
val file = myFixture.configureByText("package.lock.ludic", text)
assertNotNull(LudicPackageNotificationProvider().collectNotificationData(project, file.virtualFile))
val mod = file.findElementAt(file.text.indexOf("module"))!!
val doc = LudicManifestDocumentation().generateDoc(mod, mod)!!
assertTrue(doc, doc.contains("1.2.0") && doc.contains("Util.*"))
val problems = myFixture.doHighlighting().map { it.description }
assertTrue(problems.toString(), problems.any { it?.contains("not fetched yet") == true })
}
fun testScriptsAndHooks() {
val text = "script \"dev\" \"ludic run --headless\"\nscript \"build\" \"echo \\\"x\\\"\"\nhook before build \"tools/prep.sh\"\nhook after dev \"echo done\"\nhook during build \"x\"\nhook before deploy \"x\"\n"
val m = LudicManifest.parse(text)
assertEquals(listOf("dev", "build"), m.scripts.map { it.name })
assertEquals("echo \"x\"", m.scripts[1].command)
assertEquals(listOf("before build", "after dev", "during build", "before deploy"), m.hooks.map { "${it.phase} ${it.event}" })
myFixture.configureByText("package.ludic", text)
val problems = myFixture.doHighlighting().mapNotNull { it.description }
assertTrue(problems.toString(), problems.any { it.startsWith("A hook runs before or after") })
assertTrue(problems.toString(), problems.any { it.startsWith("No command or script named 'deploy'") })
assertTrue(problems.toString(), problems.any { it.contains("'ludic build' is a built-in command") })
assertFalse(problems.toString(), problems.any { it.contains("'dev'") })
val marked = PsiTreeUtil.collectElements(myFixture.file) { it.firstChild == null }
.mapNotNull { e -> RunLineMarkerContributor.EXTENSION.allForLanguage(LudicLanguage).firstNotNullOfOrNull { it.getInfo(e) }?.let { e.text } }
assertEquals(listOf("script", "script"), marked)
val hook = myFixture.file.findElementAt(text.indexOf("hook after"))!!
assertTrue(LudicManifestDocumentation().generateDoc(hook, hook)!!.contains("the script dev"))
}
fun testHookEventsComplete() {
myFixture.configureByText("package.ludic", "script \"dev\" \"x\"\nhook before <caret>")
val items = myFixture.completeBasic().map { it.lookupString.trim() }
assertTrue(items.toString(), items.containsAll(listOf("build", "bundle", "test", "dev")))
myFixture.configureByText("package.ludic", "hook <caret>")
assertEquals(setOf("before", "after"), myFixture.completeBasic().map { it.lookupString.trim() }.toSet())
}
fun testManifestHasItsOwnIcon() {
val file = myFixture.configureByText("package.ludic", manifest)
val icon = IconProvider.EXTENSION_POINT_NAME.extensionList.firstNotNullOfOrNull { it.getIcon(file, 0) }
assertSame(LudicPackageIcons.MANIFEST, icon)
}
}

View file

@ -0,0 +1,53 @@
package io.ludic.ide
import io.ludic.ide.project.packageSlug
import io.ludic.ide.project.scaffold
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class LudicNewProjectTest {
@get:Rule
val tmp = TemporaryFolder()
private val repoCli: File =
File(System.getProperty("user.dir")).parentFile.parentFile.parentFile.resolve("bin/ludic")
@Test
fun `project names become names ludic new accepts`() {
assertEquals("my-game", packageSlug("my game"))
assertEquals("Maroon-Lake", packageSlug("Maroon Lake!"))
assertEquals("space_1.0", packageSlug("space_1.0"))
assertEquals("game", packageSlug("!!!"))
}
@Test
fun `scaffold fills a directory the wizard already created`() {
if (!repoCli.canExecute()) return // toolchain not built in this checkout
val dir = File(tmp.root, "My Game").apply { mkdirs() }
File(dir, ".idea").mkdirs()
File(dir, "README.md").writeText("mine")
assertNull(scaffold(dir, "My Game", repoCli.path))
assertTrue(File(dir, "package.ludic").readText().contains("My-Game"))
assertTrue(File(dir, "src/main.ludic").readText().contains("program "))
assertTrue(File(dir, "tests/smoke.ludic").isFile)
assertTrue(File(dir, "assets").isDirectory)
assertTrue(File(dir, ".idea").isDirectory)
assertEquals("an existing file is never overwritten", "mine", File(dir, "README.md").readText())
}
@Test
fun `scaffold without a toolchain explains itself`() {
val msg = scaffold(File(tmp.root, "x"), "x", cli = null)
assertNotNull(msg)
assertTrue(msg!!.contains("install", ignoreCase = true))
}
}

View file

@ -15,15 +15,15 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
class LudicPlatformTest : BasePlatformTestCase() {
private val sample = """
game Hello {
component Pos { x: int = 0, y: int = 0 }
archetype Mob { Pos }
program Hello {
property Pos { x: int = 0, y: int = 0 }
model Mob { hp: int }
# move everything that has a position
system Move phase FixedUpdate
query (p) [Pos, {Player}]
{
p.x = p.x + 1
handler Move phase FixedUpdate {
for (p) in query [Pos] {
p.x = p.x + 1
}
}
}
""".trimIndent()
@ -65,8 +65,9 @@ class LudicPlatformTest : BasePlatformTestCase() {
LudicTokens.PRIMITIVE, LudicTokens.PHASE, LudicTokens.BOOLEAN,
LudicTokens.BUILTIN, LudicTokens.TYPE_NAME, LudicTokens.STRING,
LudicTokens.NUMBER, LudicTokens.COMMENT, LudicTokens.ANNOTATION,
LudicTokens.OPERATOR, LudicTokens.BRACE, LudicTokens.BRACKET,
LudicTokens.PAREN, LudicTokens.COMMA, LudicTokens.IDENTIFIER
LudicTokens.OPERATOR, LudicTokens.LBRACE, LudicTokens.RBRACE, LudicTokens.LBRACKET,
LudicTokens.RBRACKET, LudicTokens.LPAREN, LudicTokens.RPAREN, LudicTokens.COMMA,
LudicTokens.IDENTIFIER, LudicTokens.MANIFEST_KEYWORD
)
for (token in coloured) {
val keys = highlighter.getTokenHighlights(token)
@ -78,14 +79,14 @@ class LudicPlatformTest : BasePlatformTestCase() {
val file = ludicFile()
val regions = LudicFoldingBuilder().buildFoldRegions(file.node, file.viewProvider.document!!)
// The sample has exactly two blocks that span lines: the `game` body and
// the `system` body. `component Pos { … }`, `archetype Mob { … }` and the
// `{Player}` query filter all fit on one line, and folding a single line
// gains nothing, so they are deliberately skipped.
assertEquals("multi-line blocks only", 2, regions.size)
// The sample has exactly three blocks that span lines: the `program`
// body, the `handler` body and the `for` loop. `property Pos { … }` and
// `model Mob { … }` fit on one line, and folding a single line gains
// nothing, so they are deliberately skipped.
assertEquals("multi-line blocks only", 3, regions.size)
val outermost = regions.maxByOrNull { it.range.length }!!
assertEquals("outermost fold starts at the game block's brace",
assertEquals("outermost fold starts at the program block's brace",
sample.indexOf('{'), outermost.range.startOffset)
assertEquals("outermost fold ends at the last brace",
sample.length, outermost.range.endOffset)
@ -97,26 +98,25 @@ class LudicPlatformTest : BasePlatformTestCase() {
}
fun testCommenterUsesHashWithASpace() {
// The trailing space is deliberate: Ctrl+/ should produce "# code",
// which is how every comment in the runtime and examples is written.
// No trailing space in the prefix: the code style's "add a space"
// option supplies it, so Ctrl+/ gives "# code" and not "# code".
val commenter = LudicCommenter()
assertEquals("# ", commenter.lineCommentPrefix)
assertEquals("#", commenter.lineCommentPrefix)
assertNull("Ludic has no block comments", commenter.blockCommentPrefix)
}
fun testBraceMatcherPairsAllThreeBracketKinds() {
fun testBraceMatcherPairsEveryBracketKind() {
val matcher = LudicBraceMatcher()
val pairs = matcher.pairs
val open = pairs.map { it.leftBraceType }.toSet()
assertTrue(open.contains(LudicTokens.BRACE))
assertTrue(open.contains(LudicTokens.BRACKET))
assertTrue(open.contains(LudicTokens.PAREN))
assertEquals(setOf(LudicTokens.LBRACE, LudicTokens.LBRACKET, LudicTokens.LPAREN, LudicTokens.TEMPLATE_HOLE_START), open)
assertEquals(setOf(LudicTokens.RBRACE, LudicTokens.RBRACKET, LudicTokens.RPAREN, LudicTokens.TEMPLATE_HOLE_END), pairs.map { it.rightBraceType }.toSet())
}
fun testHalfTypedFileStillProducesAPsiTree() {
// The IDE must survive what a file looks like mid-keystroke; a parser
// that throws here takes highlighting and indexing down with it.
val broken = "game X {\n component P { x: int =\n system"
val broken = "program X {\n property P { x: int =\n handler"
val file = myFixture.configureByText("broken.ludic", broken)
assertEquals(broken.length, file.textLength)
assertEquals(LudicLanguage, file.language)

View file

@ -0,0 +1,280 @@
package io.ludic.ide
import com.intellij.execution.actions.ConfigurationContext
import com.intellij.execution.executors.DefaultRunExecutor
import com.intellij.execution.filters.OpenFileHyperlinkInfo
import com.intellij.execution.lineMarker.RunLineMarkerContributor
import com.intellij.execution.process.ProcessOutputTypes
import com.intellij.execution.testframework.sm.runner.SMTestLocator
import com.intellij.openapi.util.Key
import com.intellij.psi.PsiElement
import com.intellij.psi.search.GlobalSearchScope
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.testFramework.MapDataContext
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.openapi.actionSystem.CommonDataKeys
import com.intellij.openapi.actionSystem.PlatformCoreDataKeys
import com.intellij.execution.Location
import com.intellij.execution.PsiLocation
import io.ludic.ide.psi.LudicPsi
import io.ludic.ide.run.LudicCommand
import io.ludic.ide.run.LudicConfigurationType
import io.ludic.ide.run.LudicConsoleFilter
import io.ludic.ide.run.LudicRunConfiguration
import io.ludic.ide.run.LudicRunConfigurationProducer
import io.ludic.ide.run.LudicTestConsoleProperties
import io.ludic.ide.run.LudicTestEventsConverter
import io.ludic.ide.run.LudicTestLocator
import io.ludic.ide.run.LudicTestUrls
import com.intellij.execution.configurations.ConfigurationTypeUtil
/**
* Run configurations, gutter buttons, the test tree and console links —
* everything between "click the green arrow" and "see which test failed".
*/
class LudicRunTest : BasePlatformTestCase() {
private val spec = """
program MathSpec {
function add(a: int, b: int) -> int { return a + b }
test "adds" {
expect_eq(add(1, 1), 2)
}
test "it's \"quoted\"" { expect(true) }
}
""".trimIndent()
private val game = """
program Game {
handler Boot phase Start { print("hi") }
}
""".trimIndent()
private fun leafAt(marker: String): PsiElement {
val offset = myFixture.file.text.indexOf(marker)
assertTrue("marker '$marker' not found", offset >= 0)
return myFixture.file.findElementAt(offset)!!
}
private fun configFor(element: PsiElement): LudicRunConfiguration? {
val ctx = ConfigurationContext.getFromContext(
MapDataContext().apply {
put(CommonDataKeys.PROJECT, project)
put(PlatformCoreDataKeys.MODULE, myFixture.module)
put(Location.DATA_KEY, PsiLocation(element))
},
"test"
)
val producer = LudicRunConfigurationProducer()
val from = producer.createConfigurationFromContext(ctx) ?: return null
val config = from.configuration as LudicRunConfiguration
assertTrue("the producer must recognise its own configuration", producer.isConfigurationFromContext(config, ctx))
return config
}
fun testTestHeaderRunsJustThatTest() {
myFixture.configureByText("math_test.ludic", spec)
val c = configFor(leafAt("test \"adds\""))!!
c.workingDirectory = "/src"
assertEquals(LudicCommand.TEST, c.command)
assertEquals("adds", c.testName)
assertEquals("Test 'adds'", c.name)
assertEquals(listOf("test", "math_test.ludic", "--verbose", "--test", "adds"), c.cliArguments())
}
fun testEscapedTestNamesAreUnescaped() {
myFixture.configureByText("math_test.ludic", spec)
val kw = LudicPsi.testKeywords(myFixture.file)
assertEquals(listOf("adds", "it's \"quoted\""), kw.map { LudicPsi.testNameAt(it) })
assertEquals("it's \"quoted\"", configFor(kw[1].nextSibling.nextSibling)!!.testName)
}
fun testTestFileRunsAllItsTests() {
myFixture.configureByText("math_test.ludic", spec)
val c = configFor(leafAt("function"))!!
assertEquals(LudicCommand.TEST, c.command)
assertEquals("", c.testName)
assertEquals("Test math_test.ludic", c.name)
}
fun testProgramFileRuns() {
myFixture.configureByText("game.ludic", game)
val c = configFor(leafAt("print"))!!
c.workingDirectory = "/src"
assertEquals(LudicCommand.RUN, c.command)
assertEquals(listOf("run", "game.ludic"), c.cliArguments())
c.headless = true
assertEquals(listOf("run", "game.ludic", "--headless"), c.cliArguments())
}
fun testCustomCommandPassesArgumentsThrough() {
val type = ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java)
val c = type.factory.createTemplateConfiguration(project) as LudicRunConfiguration
c.command = LudicCommand.CUSTOM
c.arguments = "fmt --check \"src/a b.ludic\""
assertEquals(listOf("fmt", "--check", "src/a b.ludic"), c.cliArguments())
}
fun testConfigurationSurvivesSerialization() {
val type = ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java)
val c = type.factory.createTemplateConfiguration(project) as LudicRunConfiguration
c.command = LudicCommand.TEST
c.file = "/tmp/x.ludic"
c.testName = "adds"
c.env = mapOf("R3D_GAME" to "aimprobe")
val element = org.jdom.Element("configuration")
c.writeExternal(element)
val back = type.factory.createTemplateConfiguration(project) as LudicRunConfiguration
back.readExternal(element)
assertEquals(LudicCommand.TEST, back.command)
assertEquals("/tmp/x.ludic", back.file)
assertEquals("adds", back.testName)
assertEquals(mapOf("R3D_GAME" to "aimprobe"), back.env)
}
fun testGutterMarksEveryTestAndTheProgram() {
myFixture.configureByText("math_test.ludic", spec)
val marked = PsiTreeUtil.collectElements(myFixture.file) { it.firstChild == null }
.filter { e -> RunLineMarkerContributor.EXTENSION.allForLanguage(LudicLanguage).any { it.getInfo(e) != null } }
.map { it.text }
assertEquals(listOf("program", "test", "test"), marked)
}
fun testLocatorFindsTheTestBlock() {
val file = myFixture.configureByText("math_test.ludic", spec)
val path = file.virtualFile.path
val url = LudicTestUrls.test(path, "adds").removePrefix("ludic://")
val loc = LudicTestLocator.getLocation("ludic", url, project, GlobalSearchScope.allScope(project)).single()
assertEquals("test", loc.psiElement.text)
assertEquals(file.text.indexOf("test \"adds\""), loc.psiElement.textOffset)
val fileLoc = (LudicTestLocator as SMTestLocator).getLocation("ludic", path, project, GlobalSearchScope.allScope(project)).single()
assertEquals(file, fileLoc.psiElement)
}
fun testUrlSplitKeepsHashesInTestNames() {
assertEquals("/a/b.ludic" to "x # y", LudicTestUrls.split("/a/b.ludic#x # y"))
assertEquals("/a/b.ludic" to null, LudicTestUrls.split("/a/b.ludic"))
}
// ---- the output converter ---------------------------------------------
private fun convert(vararg lines: String): List<String> {
val type = ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java)
val c = type.factory.createTemplateConfiguration(project) as LudicRunConfiguration
c.command = LudicCommand.TEST
c.workingDirectory = "/proj"
val props = LudicTestConsoleProperties(c, DefaultRunExecutor.getRunExecutorInstance())
val out = mutableListOf<String>()
val conv = object : LudicTestEventsConverter("Ludic", props, "/proj") {
override fun forward(text: String, outputType: Key<*>) {
if (text.startsWith("##teamcity")) out += text.trim()
}
fun feed(l: String) = processConsistentText(l + "\n", ProcessOutputTypes.STDOUT)
}
lines.forEach { conv.feed(it) }
conv.flushBufferOnProcessTermination(0)
return out.map { it.removePrefix("##teamcity[").substringBefore(" ") + " " + (Regex("name='([^']*)'").find(it)?.groupValues?.get(1) ?: "") }
}
fun testVerboseOutputBecomesATestTree() {
val events = convert(
" RUN tests/smoke.ludic",
"ok - adds",
"smoke.ludic:12: expect_eq failed (got 1, want 2)",
"FAIL - subtracts",
"== 1 passed, 1 failed ==",
" FAIL tests/smoke.ludic (exit 1)",
" RUN tests/other.ludic",
"ok - x.ludic",
"== 1 passed, 0 failed ==",
" PASS tests/other.ludic",
"",
"== 1 of 2 test files failed ==",
)
assertEquals(
listOf(
"testSuiteStarted tests/smoke.ludic",
"testStarted adds", "testFinished adds",
"testStarted subtracts", "testFailed subtracts", "testFinished subtracts",
"testSuiteFinished tests/smoke.ludic",
"testSuiteStarted tests/other.ludic",
"testStarted x.ludic", "testFinished x.ludic",
"testSuiteFinished tests/other.ludic",
),
events
)
}
fun testCompileFailureShowsAsAFailedNode() {
val events = convert(
" RUN tests/broken.ludic",
"broken.ludic:2: error: unknown identifier zz",
" FAIL tests/broken.ludic (did not compile)",
)
assertEquals(
listOf(
"testSuiteStarted tests/broken.ludic",
"testStarted (compile)", "testFailed (compile)", "testFinished (compile)",
"testSuiteFinished tests/broken.ludic",
),
events
)
}
fun testUnknownTestNameIsAFailure() {
val events = convert(" RUN tests/a.ludic", "no test named \"nope\"", " FAIL tests/a.ludic (exit 1)")
assertEquals(
listOf("testSuiteStarted tests/a.ludic", "testStarted nope", "testFailed nope", "testFinished nope", "testSuiteFinished tests/a.ludic"),
events
)
}
fun testOldToolchainOutputStillGivesFileResults() {
// an install without --verbose: PASS lines alone, and a failing
// file's runner output after its FAIL line
val events = convert(
" PASS tests/a.ludic",
" FAIL tests/b.ludic (exit 1)",
"ok - one",
"FAIL - two",
"== 1 passed, 1 failed ==",
" PASS tests/c.ludic",
"",
"== 1 of 3 test files failed ==",
)
assertEquals(
listOf(
"testSuiteStarted tests/a.ludic", "testSuiteFinished tests/a.ludic",
"testSuiteStarted tests/b.ludic",
"testStarted one", "testFinished one",
"testStarted two", "testFailed two", "testFinished two",
"testSuiteFinished tests/b.ludic",
"testSuiteStarted tests/c.ludic", "testSuiteFinished tests/c.ludic",
),
events
)
}
// ---- console links -----------------------------------------------------
fun testConsoleLinksResolveBaseNamesAcrossTheProject() {
val file = myFixture.addFileToProject("tests/smoke.ludic", "program S {\n test \"t\" { expect(false) }\n}\n")
val filter = LudicConsoleFilter(project, "/nonexistent")
val line = "smoke.ludic:2: expect failed\n"
val result = filter.applyFilter(line, line.length)!!
val item = result.resultItems.single()
assertEquals(0, item.highlightStartOffset)
assertEquals("smoke.ludic:2".length, item.highlightEndOffset)
val info = item.hyperlinkInfo as OpenFileHyperlinkInfo
assertEquals(file.virtualFile, info.descriptor!!.file)
assertEquals(file.text.indexOf(" test"), info.descriptor!!.offset)
}
fun testConsoleLinksIgnoreOtherText() {
val filter = LudicConsoleFilter(project, null)
assertNull(filter.applyFilter("built build/demo\n", 17))
assertNull(filter.applyFilter("missing.ludic:3: error\n", 23))
}
}