feat(tooling): JetBrains IDE support, LSP navigation, barrel imports, package scripts and hooks
- JetBrains plugin 1.4.0: semantic colours (builtin / vendor / own), template strings, brace handling, run configurations and a test console, package.ludic and package.lock.ludic editing (completion, docs, app preview, colour previews, asset navigation), External Libraries for the runtime and packages, doc pages for built-ins - ludic-lsp: go to definition for imports, document links, hover with inferred types, type definition, signature help with parameters, docs from docs/language - `import "dir"` resolves a barrel `dir/index.ludic` - package.ludic `entry`, `script` and `hook before|after <command>`; `ludic <script>`, `ludic script`, `ludic scripts` - `ludic test --verbose` and `--test NAME`; the test runner filters by name Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
da57b20156
commit
f92d7f89c6
75 changed files with 46802 additions and 40343 deletions
|
|
@ -1,8 +1,8 @@
|
|||
# Ludic for JetBrains IDEs
|
||||
|
||||
Works in IntelliJ IDEA (Community and Ultimate), CLion, GoLand, PyCharm, Rider,
|
||||
WebStorm — anything on the IntelliJ Platform 2023.2 through 2026.2
|
||||
(`since-build` 232, `until-build` 262.*).
|
||||
WebStorm — anything on the IntelliJ Platform 2024.2 through 2026.2
|
||||
(`since-build` 242, `until-build` 262.*).
|
||||
|
||||
`until-build` 262 is deliberately ahead of the newest released platform — 2025.3
|
||||
is build **253**, and no 262 IDE exists yet. That headroom is the point: an
|
||||
|
|
@ -17,7 +17,7 @@ packages cleanly can still fail to load:
|
|||
scheduled-for-removal API usages.
|
||||
- `./gradlew runIde` boots a real IDEA 2025.3 with the plugin installed. The
|
||||
sandbox log line to look for is
|
||||
`Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.0.0)` in
|
||||
`Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.4.0)` in
|
||||
`.intellijPlatform/sandbox/Ludic/IC-2025.3/log/idea.log`.
|
||||
|
||||
**If the plugin does not appear in Settings -> Plugins after installing**, the
|
||||
|
|
@ -35,7 +35,7 @@ as the error message, which is not obvious the first time you see it:
|
|||
```bash
|
||||
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
|
||||
cd tools/editors/jetbrains
|
||||
./gradlew buildPlugin # -> build/distributions/Ludic-1.0.0.zip
|
||||
./gradlew buildPlugin # -> build/distributions/Ludic-1.4.0.zip
|
||||
./gradlew runIde # try it in a sandbox IDE
|
||||
```
|
||||
|
||||
|
|
@ -51,8 +51,39 @@ includes the plugin build in the toolchain's own test run.
|
|||
Install the zip with **Settings -> Plugins -> ⚙ -> Install Plugin from Disk**.
|
||||
LSP4IJ is a required dependency; the IDE offers to install it for you.
|
||||
|
||||
## What you get
|
||||
|
||||
- **Editing**: highlighting (Settings | Editor | Color Scheme | Ludic), brace and
|
||||
quote pairing, folding, `#` comments, smart indent on Enter and on typing a
|
||||
closer, Code Style page (2 spaces by default), live templates (`prog`,
|
||||
`handler`, `on`, `test`, `fn`, `forq`, ...), `package.ludic` directives.
|
||||
- **Language server** (`ludic-lsp` via LSP4IJ): completion, diagnostics
|
||||
(including the compiler's own on save), hover, parameter info, go to
|
||||
definition, find/highlight usages, rename, formatting, inlay hints, semantic
|
||||
colours, Structure view, breadcrumbs, Go to Symbol.
|
||||
- **Run / test**: a *Ludic* run configuration (`ludic run`, `build`, `test` or
|
||||
any other `ludic` command); green gutter buttons on `program` and on every
|
||||
`test "name"` (which runs just that test via `ludic test --test NAME`);
|
||||
results in the test tree with navigation; `file.ludic:line` links in every
|
||||
console. Per-test results need a toolchain with `ludic test --verbose`
|
||||
(0.17+); an older one still gives per-file results.
|
||||
- **Project**: File | New | Project | Ludic (runs `ludic new`), File | New |
|
||||
Ludic File (program, module, test), Tools | Ludic (run, build, test, fmt,
|
||||
get, pack, bundle, clean, doctor).
|
||||
|
||||
There is no debugger: `ludicc` does not emit debug info yet.
|
||||
|
||||
## Settings
|
||||
|
||||
**Settings -> Languages & Frameworks -> Ludic** — the path to `ludic-lsp` and to
|
||||
`ludicc`. Both default to `bin/` under the project root, which is where
|
||||
`bin/ludic-dev tools` puts them.
|
||||
**Settings -> Languages & Frameworks -> Ludic** — the paths to `ludic`,
|
||||
`ludic-lsp` and `ludicc`. Each blank one is looked up in `bin/` under the
|
||||
project root, then on `PATH`, then in `~/.ludic/bin`. Changing a path restarts
|
||||
the language server.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
./gradlew test # ~70 headless platform tests, ~25 s
|
||||
./gradlew verifyPlugin -PlocalIde="/Applications/IntelliJ IDEA.app" # also verify against an installed IDE
|
||||
./gradlew runLocalIde -PlocalIde="/Applications/IntelliJ IDEA.app" -PopenProject=/path/to/game
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import java.time.Duration
|
||||
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
|
||||
import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
|
||||
|
||||
|
|
@ -9,6 +10,8 @@ plugins {
|
|||
id("org.jetbrains.intellij.platform") version "2.18.1"
|
||||
}
|
||||
|
||||
val localIde = providers.gradleProperty("localIde")
|
||||
|
||||
group = providers.gradleProperty("pluginGroup").get()
|
||||
version = providers.gradleProperty("pluginVersion").get()
|
||||
|
||||
|
|
@ -65,6 +68,9 @@ intellijPlatform {
|
|||
create(IntelliJPlatformType.IntellijIdeaCommunity, providers.gradleProperty("platformVersion").get()) {
|
||||
useInstaller = false
|
||||
}
|
||||
// -PlocalIde=/path/to/IDE.app also checks against an installed IDE,
|
||||
// e.g. the newer one a developer actually uses
|
||||
localIde.orNull?.let { local(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,8 +93,32 @@ tasks.named<JavaExec>("runIde") {
|
|||
args = listOf(repoRoot.absolutePath)
|
||||
}
|
||||
|
||||
// `./gradlew runLocalIde -PlocalIde=/Applications/IntelliJ IDEA.app [-PopenProject=dir]`:
|
||||
// the same sandbox as runIde, booted from an installed IDE instead of the
|
||||
// downloaded one, so the plugin can be tried on exactly that build.
|
||||
val runLocalIde by intellijPlatformTesting.runIde.registering {
|
||||
localPath = localIde.map { file(it) }
|
||||
task {
|
||||
args = listOf(providers.gradleProperty("openProject").getOrElse(repoRoot.absolutePath))
|
||||
}
|
||||
plugins {
|
||||
plugin("com.redhat.devtools.lsp4ij", providers.gradleProperty("lsp4ijVersion").get())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// The palette Color.* resolves against, straight from the docs it is generated
|
||||
// into (`ludic-dev docs-palette`), so the swatches cannot drift from the compiler.
|
||||
tasks.named<ProcessResources>("processResources") {
|
||||
from(File(repoRoot, "docs/language/colors/palette.json"))
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnit()
|
||||
// a platform test that waits on a modal dialog or an event that never
|
||||
// comes blocks forever; fail the run instead of hanging it
|
||||
timeout.set(Duration.ofMinutes(10))
|
||||
systemProperty("idea.test.timeout", "120")
|
||||
testLogging {
|
||||
events("passed", "failed", "skipped")
|
||||
showStandardStreams = false
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
pluginGroup = io.ludic.ide
|
||||
pluginName = Ludic
|
||||
pluginVersion = 1.0.0
|
||||
pluginVersion = 1.4.0
|
||||
|
||||
# 2023.2 is the first build with the platform APIs this plugin uses. LSP4IJ (not
|
||||
# the paid-IDE LSP API) supplies the language-server bridge, so Community
|
||||
# editions are supported as well as Ultimate.
|
||||
pluginSinceBuild = 232
|
||||
# 2024.2 (242) is the oldest platform LSP4IJ 0.20+ installs on, so it is the
|
||||
# floor here too: claiming older would let an IDE accept this plugin and then
|
||||
# fail to find its required dependency. LSP4IJ (not the paid-IDE LSP API)
|
||||
# supplies the language-server bridge, so the free IDEs are supported as well.
|
||||
pluginSinceBuild = 242
|
||||
# Deliberately ahead of the newest released platform (2025.3 is build 253): an
|
||||
# until-build that stops at today's IDE gets the plugin auto-disabled the moment
|
||||
# a user upgrades. 262 buys roughly three release cycles of headroom. The APIs
|
||||
|
|
@ -15,7 +16,7 @@ pluginSinceBuild = 232
|
|||
pluginUntilBuild = 262.*
|
||||
|
||||
platformVersion = 2025.3
|
||||
lsp4ijVersion = 0.20.1
|
||||
lsp4ijVersion = 0.21.0
|
||||
|
||||
# Gradle itself must run on JDK 17-21; a newer JDK reports only its version
|
||||
# number as the failure. The Kotlin toolchain below is pinned separately.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
|
|||
import com.intellij.ui.components.JBCheckBox
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.FormBuilder
|
||||
import com.redhat.devtools.lsp4ij.LanguageServerManager
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JSpinner
|
||||
|
|
@ -16,6 +17,7 @@ import javax.swing.SpinnerNumberModel
|
|||
class LudicConfigurable(private val project: Project) : Configurable {
|
||||
private val serverField = TextFieldWithBrowseButton()
|
||||
private val compilerField = TextFieldWithBrowseButton()
|
||||
private val cliField = TextFieldWithBrowseButton()
|
||||
private val diagnostics = JBCheckBox("Report compiler errors on save")
|
||||
private val indent = JSpinner(SpinnerNumberModel(2, 1, 8, 1))
|
||||
private var panel: JPanel? = null
|
||||
|
|
@ -33,6 +35,10 @@ class LudicConfigurable(private val project: Project) : Configurable {
|
|||
project,
|
||||
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludicc")
|
||||
)
|
||||
cliField.addBrowseFolderListener(
|
||||
project,
|
||||
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludic")
|
||||
)
|
||||
|
||||
panel = FormBuilder.createFormBuilder()
|
||||
.addLabeledComponent(JBLabel("Language server (ludic-lsp):"), serverField, 1, false)
|
||||
|
|
@ -43,6 +49,10 @@ class LudicConfigurable(private val project: Project) : Configurable {
|
|||
.addComponentToRightColumn(
|
||||
JBLabel("Used for authoritative diagnostics. Empty means bin/ludicc.")
|
||||
)
|
||||
.addLabeledComponent(JBLabel("Command line (ludic):"), cliField, 1, false)
|
||||
.addComponentToRightColumn(
|
||||
JBLabel("Runs, builds, tests and new projects. Empty means bin/ludic, then PATH, then ~/.ludic.")
|
||||
)
|
||||
.addComponent(diagnostics)
|
||||
.addLabeledComponent(JBLabel("Formatter indent:"), indent, 1, false)
|
||||
.addComponentFillVertically(JPanel(), 0)
|
||||
|
|
@ -57,22 +67,37 @@ class LudicConfigurable(private val project: Project) : Configurable {
|
|||
val s = settings()
|
||||
return serverField.text != s.serverPath ||
|
||||
compilerField.text != s.compilerPath ||
|
||||
cliField.text != s.cliPath ||
|
||||
diagnostics.isSelected != s.compilerDiagnostics ||
|
||||
(indent.value as Int) != s.indentSize
|
||||
}
|
||||
|
||||
override fun apply() {
|
||||
val s = settings()
|
||||
val restart = serverField.text.trim() != s.serverPath ||
|
||||
compilerField.text.trim() != s.compilerPath ||
|
||||
diagnostics.isSelected != s.compilerDiagnostics ||
|
||||
(indent.value as Int) != s.indentSize
|
||||
s.serverPath = serverField.text.trim()
|
||||
s.compilerPath = compilerField.text.trim()
|
||||
s.cliPath = cliField.text.trim()
|
||||
s.compilerDiagnostics = diagnostics.isSelected
|
||||
s.indentSize = indent.value as Int
|
||||
// the server reads all of these once, at initialize
|
||||
if (restart) restartServer()
|
||||
}
|
||||
|
||||
private fun restartServer() {
|
||||
val manager = LanguageServerManager.getInstance(project)
|
||||
manager.stop("ludic")
|
||||
manager.start("ludic")
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
val s = settings()
|
||||
serverField.text = s.serverPath
|
||||
compilerField.text = s.compilerPath
|
||||
cliField.text = s.cliPath
|
||||
diagnostics.isSelected = s.compilerDiagnostics
|
||||
indent.value = s.indentSize
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import com.intellij.psi.tree.IElementType
|
|||
* this needs the language server, so none of it waits for one.
|
||||
*/
|
||||
class LudicCommenter : Commenter {
|
||||
override fun getLineCommentPrefix() = "# "
|
||||
override fun getLineCommentPrefix() = "#"
|
||||
override fun getBlockCommentPrefix(): String? = null
|
||||
override fun getBlockCommentSuffix(): String? = null
|
||||
override fun getCommentedBlockCommentPrefix(): String? = null
|
||||
|
|
@ -37,9 +37,10 @@ class LudicBraceMatcher : PairedBraceMatcher {
|
|||
// marking them all structural would make the "matched brace" gutter
|
||||
// noisy inside `spawn Hero { Pos { x: 1 } }`.
|
||||
val PAIRS = arrayOf(
|
||||
BracePair(LudicTokens.BRACE, LudicTokens.BRACE, true),
|
||||
BracePair(LudicTokens.BRACKET, LudicTokens.BRACKET, false),
|
||||
BracePair(LudicTokens.PAREN, LudicTokens.PAREN, false)
|
||||
BracePair(LudicTokens.LBRACE, LudicTokens.RBRACE, true),
|
||||
BracePair(LudicTokens.LBRACKET, LudicTokens.RBRACKET, false),
|
||||
BracePair(LudicTokens.LPAREN, LudicTokens.RPAREN, false),
|
||||
BracePair(LudicTokens.TEMPLATE_HOLE_START, LudicTokens.TEMPLATE_HOLE_END, false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -60,17 +61,15 @@ class LudicFoldingBuilder : FoldingBuilderEx(), DumbAware {
|
|||
val node = root.node ?: return emptyArray()
|
||||
|
||||
while (lexer.tokenType != null) {
|
||||
if (lexer.tokenType == LudicTokens.BRACE) {
|
||||
when (text[lexer.tokenStart]) {
|
||||
'{' -> opens.addLast(lexer.tokenStart)
|
||||
'}' -> {
|
||||
val open = opens.removeLastOrNull()
|
||||
if (open != null) {
|
||||
val end = lexer.tokenEnd
|
||||
// only worth folding when it actually spans lines
|
||||
if (document.getLineNumber(end) > document.getLineNumber(open)) {
|
||||
regions += FoldingDescriptor(node, TextRange(open, end))
|
||||
}
|
||||
when (lexer.tokenType) {
|
||||
LudicTokens.LBRACE -> opens.addLast(lexer.tokenStart)
|
||||
LudicTokens.RBRACE -> {
|
||||
val open = opens.removeLastOrNull()
|
||||
if (open != null) {
|
||||
val end = lexer.tokenEnd
|
||||
// only worth folding when it actually spans lines
|
||||
if (document.getLineNumber(end) > document.getLineNumber(open)) {
|
||||
regions += FoldingDescriptor(node, TextRange(open, end))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,14 @@ import com.intellij.psi.tree.IElementType
|
|||
* rules are the ones the shared header already implements, and keeping the two
|
||||
* readable side by side is worth more here than a JFlex spec.
|
||||
*
|
||||
* With [manifest] set it lexes a package.ludic, where the directives
|
||||
* (`package`, `require`, `app`…) are the keywords instead.
|
||||
*
|
||||
* It must never throw and never fail to advance: the IDE lexes half-typed text
|
||||
* constantly, so anything unrecognised becomes one BAD character and the scan
|
||||
* carries on.
|
||||
*/
|
||||
class LudicLexer : LexerBase() {
|
||||
class LudicLexer(private val manifest: Boolean = false) : LexerBase() {
|
||||
private var buffer: CharSequence = ""
|
||||
private var startOffset = 0
|
||||
private var endOffset = 0
|
||||
|
|
@ -22,16 +25,35 @@ class LudicLexer : LexerBase() {
|
|||
private var tokenEnd = 0
|
||||
private var tokenType: IElementType? = null
|
||||
|
||||
/**
|
||||
* What the previous significant token was, which decides whether a
|
||||
* declaration word is a keyword here (see [isNamePosition]). It is the
|
||||
* lexer state, so an incremental restart mid-file lexes the same way.
|
||||
*/
|
||||
private var prev = PREV_START
|
||||
private var stateAtToken = PREV_START
|
||||
|
||||
/**
|
||||
* Where we are relative to a `template {hole} string`: in code, in the
|
||||
* template's text, or in a hole (with how many braces it has opened).
|
||||
* Packed into the state with [prev].
|
||||
*/
|
||||
private var mode = MODE_CODE
|
||||
private var holeDepth = 0
|
||||
|
||||
override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) {
|
||||
this.buffer = buffer
|
||||
this.startOffset = startOffset
|
||||
this.endOffset = endOffset
|
||||
this.tokenStart = startOffset
|
||||
this.tokenEnd = startOffset
|
||||
this.prev = initialState and 3
|
||||
this.mode = (initialState shr 2) and 3
|
||||
this.holeDepth = initialState shr 4
|
||||
advance()
|
||||
}
|
||||
|
||||
override fun getState() = 0
|
||||
override fun getState() = stateAtToken
|
||||
override fun getTokenType(): IElementType? = tokenType
|
||||
override fun getTokenStart() = tokenStart
|
||||
override fun getTokenEnd() = tokenEnd
|
||||
|
|
@ -44,6 +66,44 @@ class LudicLexer : LexerBase() {
|
|||
private fun isSpace(c: Char) = c == ' ' || c == '\t' || c == '\r' || c == '\n'
|
||||
|
||||
override fun advance() {
|
||||
stateAtToken = prev or (mode shl 2) or (holeDepth shl 4)
|
||||
if (mode == MODE_TEXT) { lexTemplateText(); return }
|
||||
lexOne()
|
||||
val t = tokenType ?: return
|
||||
if (t != TokenType.WHITE_SPACE && t != LudicTokens.COMMENT) prev = classOf(t)
|
||||
}
|
||||
|
||||
private fun classOf(t: IElementType): Int {
|
||||
val text = buffer.subSequence(tokenStart, tokenEnd)
|
||||
return when {
|
||||
t == LudicTokens.LBRACE || t == LudicTokens.RBRACE -> PREV_START
|
||||
t == LudicTokens.RPAREN || t == LudicTokens.RBRACKET -> PREV_OTHER
|
||||
t == LudicTokens.OPERATOR && text == ";" -> PREV_START
|
||||
t == LudicTokens.OPERATOR && text == "." -> PREV_NAME_BEFORE
|
||||
t == LudicTokens.OPERATOR || t == LudicTokens.LPAREN || t == LudicTokens.LBRACKET || t == LudicTokens.COMMA -> PREV_NAME_BEFORE
|
||||
t == LudicTokens.DECL_KEYWORD && (text == "var" || text == "const") -> PREV_NAME_BEFORE
|
||||
t == LudicTokens.KEYWORD && (text == "let" || text == "return") -> PREV_NAME_BEFORE
|
||||
else -> PREV_OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The compiler reserves declaration words only as function names, so
|
||||
* `var model = …`, `model: Model` and `actor.model` are ordinary names.
|
||||
*/
|
||||
private fun isNamePosition(end: Int): Boolean {
|
||||
if (prev == PREV_NAME_BEFORE) return true
|
||||
var j = end
|
||||
while (j < endOffset && (at(j) == ' ' || at(j) == '\t')) j++
|
||||
if (j >= endOffset) return false
|
||||
val c = at(j)
|
||||
if (c == ':' || c == '.' || c == ')' || c == ',' || c == ']') return true
|
||||
if (c == '=' && at(j + 1) != '>') return true // `x = …`, `x == …`
|
||||
if ((c == '+' || c == '-' || c == '*' || c == '/' || c == '!') && at(j + 1) == '=') return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun lexOne() {
|
||||
tokenStart = tokenEnd
|
||||
if (tokenStart >= endOffset) {
|
||||
tokenType = null
|
||||
|
|
@ -63,6 +123,21 @@ class LudicLexer : LexerBase() {
|
|||
emit(i, LudicTokens.COMMENT)
|
||||
return
|
||||
}
|
||||
if (c == '`') {
|
||||
// opens a template, or (from a hole) closes it: the compiler ends a
|
||||
// template at the next backtick whatever it is inside
|
||||
mode = if (mode == MODE_CODE) MODE_TEXT else MODE_CODE
|
||||
holeDepth = 0
|
||||
emit(i + 1, LudicTokens.TEMPLATE_QUOTE)
|
||||
return
|
||||
}
|
||||
if (mode == MODE_HOLE && c == '}' && holeDepth == 0) {
|
||||
mode = MODE_TEXT
|
||||
emit(i + 1, LudicTokens.TEMPLATE_HOLE_END)
|
||||
return
|
||||
}
|
||||
if (mode == MODE_HOLE && c == '{') holeDepth++
|
||||
if (mode == MODE_HOLE && c == '}') holeDepth--
|
||||
if (c == '"') {
|
||||
i++
|
||||
while (i < endOffset && at(i) != '"' && at(i) != '\n') {
|
||||
|
|
@ -104,9 +179,11 @@ class LudicLexer : LexerBase() {
|
|||
while (i < endOffset && isIdPart(at(i))) i++
|
||||
val word = buffer.subSequence(tokenStart, i).toString()
|
||||
val type = when {
|
||||
manifest && word in LudicVocabulary.MANIFEST -> LudicTokens.MANIFEST_KEYWORD
|
||||
word == "true" || word == "false" -> LudicTokens.BOOLEAN
|
||||
word in LudicVocabulary.PRIMITIVES -> LudicTokens.PRIMITIVE
|
||||
word in LudicVocabulary.PHASES -> LudicTokens.PHASE
|
||||
word in LudicVocabulary.CONTEXTUAL && isNamePosition(i) -> LudicTokens.IDENTIFIER
|
||||
word in LudicVocabulary.DECL -> LudicTokens.DECL_KEYWORD
|
||||
word in LudicVocabulary.CLAUSE -> LudicTokens.CLAUSE
|
||||
word in LudicVocabulary.STMT -> LudicTokens.KEYWORD
|
||||
|
|
@ -124,9 +201,12 @@ class LudicLexer : LexerBase() {
|
|||
return
|
||||
}
|
||||
when (c) {
|
||||
'{', '}' -> { emit(i + 1, LudicTokens.BRACE); return }
|
||||
'[', ']' -> { emit(i + 1, LudicTokens.BRACKET); return }
|
||||
'(', ')' -> { emit(i + 1, LudicTokens.PAREN); return }
|
||||
'{' -> { emit(i + 1, LudicTokens.LBRACE); return }
|
||||
'}' -> { emit(i + 1, LudicTokens.RBRACE); return }
|
||||
'[' -> { emit(i + 1, LudicTokens.LBRACKET); return }
|
||||
']' -> { emit(i + 1, LudicTokens.RBRACKET); return }
|
||||
'(' -> { emit(i + 1, LudicTokens.LPAREN); return }
|
||||
')' -> { emit(i + 1, LudicTokens.RPAREN); return }
|
||||
',' -> { emit(i + 1, LudicTokens.COMMA); return }
|
||||
}
|
||||
if (c in SINGLE_CHAR_OPS) {
|
||||
|
|
@ -136,12 +216,38 @@ class LudicLexer : LexerBase() {
|
|||
emit(i + 1, LudicTokens.BAD)
|
||||
}
|
||||
|
||||
/** One run of template text, an escape, a hole opener or the closing backtick. */
|
||||
private fun lexTemplateText() {
|
||||
tokenStart = tokenEnd
|
||||
if (tokenStart >= endOffset) { tokenType = null; return }
|
||||
var i = tokenStart
|
||||
val c = at(i)
|
||||
when {
|
||||
c == '`' -> { mode = MODE_CODE; emit(i + 1, LudicTokens.TEMPLATE_QUOTE); return }
|
||||
(c == '{' && at(i + 1) == '{') || (c == '}' && at(i + 1) == '}') -> { emit(i + 2, LudicTokens.TEMPLATE_ESCAPE); return }
|
||||
c == '{' -> { mode = MODE_HOLE; holeDepth = 0; emit(i + 1, LudicTokens.TEMPLATE_HOLE_START); return }
|
||||
c == '\\' && i + 1 < endOffset -> { emit(i + 2, LudicTokens.TEMPLATE_ESCAPE); return }
|
||||
}
|
||||
while (i < endOffset) {
|
||||
val d = at(i)
|
||||
if (d == '`' || d == '{' || d == '\\' || (d == '}' && at(i + 1) == '}')) break
|
||||
i++
|
||||
}
|
||||
emit(maxOf(i, tokenStart + 1), LudicTokens.TEMPLATE_TEXT)
|
||||
}
|
||||
|
||||
private fun emit(end: Int, type: IElementType) {
|
||||
tokenEnd = end
|
||||
tokenType = type
|
||||
}
|
||||
|
||||
private companion object {
|
||||
companion object {
|
||||
const val MODE_CODE = 0
|
||||
const val MODE_TEXT = 1
|
||||
const val MODE_HOLE = 2
|
||||
const val PREV_START = 0 // file start, `{`, `}`, `;`: a declaration may begin
|
||||
const val PREV_OTHER = 1
|
||||
const val PREV_NAME_BEFORE = 2 // `.`, an operator, `(`, `var`, `let`, `return`: a name follows
|
||||
val TWO_CHAR_OPS = setOf("->", "+=", "-=", "*=", "/=", "==", "!=", "<=", ">=", "<<", ">>", "..", "=>")
|
||||
const val SINGLE_CHAR_OPS = "+-*/%<>=:.!@;&|^~"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class LudicParserDefinition : ParserDefinition {
|
|||
|
||||
override fun getFileNodeType(): IFileElementType = LudicTokens.FILE
|
||||
override fun getCommentTokens(): TokenSet = TokenSet.create(LudicTokens.COMMENT)
|
||||
override fun getStringLiteralElements(): TokenSet = TokenSet.create(LudicTokens.STRING, LudicTokens.CHAR)
|
||||
override fun getStringLiteralElements(): TokenSet = TokenSet.create(LudicTokens.STRING, LudicTokens.CHAR, LudicTokens.TEMPLATE_TEXT)
|
||||
override fun getWhitespaceTokens(): TokenSet = TokenSet.create(TokenType.WHITE_SPACE)
|
||||
// Only ever reached for composite nodes, and this grammar marks none beyond
|
||||
// the file itself — but the platform requires an answer, so give it a real
|
||||
|
|
|
|||
|
|
@ -9,7 +9,18 @@ import com.intellij.openapi.project.Project
|
|||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.util.xmlb.XmlSerializerUtil
|
||||
import com.redhat.devtools.lsp4ij.LanguageServerFactory
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.redhat.devtools.lsp4ij.client.LanguageClientImpl
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPClientFeatures
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPCompletionFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPDefinitionFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPDiagnosticFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPDocumentHighlightFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPDocumentLinkFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPHoverFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPInlayHintFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPSemanticTokensFeature
|
||||
import com.redhat.devtools.lsp4ij.client.features.LSPSignatureHelpFeature
|
||||
import com.redhat.devtools.lsp4ij.server.CannotStartProcessException
|
||||
import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider
|
||||
import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider
|
||||
|
|
@ -25,6 +36,7 @@ import java.io.File
|
|||
class LudicSettings : PersistentStateComponent<LudicSettings> {
|
||||
var serverPath: String = ""
|
||||
var compilerPath: String = ""
|
||||
var cliPath: String = ""
|
||||
var compilerDiagnostics: Boolean = true
|
||||
var indentSize: Int = 2
|
||||
|
||||
|
|
@ -43,26 +55,38 @@ class LudicSettings : PersistentStateComponent<LudicSettings> {
|
|||
* tested without booting an IDE — this lookup silently returning null is the
|
||||
* difference between a working language server and one that never starts.
|
||||
*/
|
||||
internal fun findExecutable(root: String?, configured: String, names: List<String>, subdirs: List<String>): String? {
|
||||
internal fun findExecutable(
|
||||
root: String?,
|
||||
configured: String,
|
||||
names: List<String>,
|
||||
subdirs: List<String>,
|
||||
path: String? = System.getenv("PATH"),
|
||||
home: String = ludicHome(),
|
||||
): String? {
|
||||
if (configured.isNotBlank()) {
|
||||
val f = File(expandHome(configured))
|
||||
return if (f.canExecute()) f.absolutePath else null
|
||||
return if (f.isFile && f.canExecute()) f.absolutePath else null
|
||||
}
|
||||
if (root == null) return searchPath(names)
|
||||
for (dir in subdirs) {
|
||||
for (name in names) {
|
||||
val f = File(File(root, dir), name)
|
||||
if (f.isFile && f.canExecute()) return f.absolutePath
|
||||
if (root != null) {
|
||||
for (dir in subdirs) {
|
||||
for (name in names) {
|
||||
val f = File(File(root, dir), name)
|
||||
if (f.isFile && f.canExecute()) return f.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
return searchPath(names)
|
||||
return searchPath(names, path, home)
|
||||
}
|
||||
|
||||
/** The install root: `$LUDIC_HOME`, else `~/.ludic`. */
|
||||
internal fun ludicHome(): String =
|
||||
System.getenv("LUDIC_HOME") ?: (System.getProperty("user.home") + "/.ludic")
|
||||
|
||||
/** Fall back to PATH, so a system-wide install works with no configuration. */
|
||||
private fun searchPath(names: List<String>): String? {
|
||||
val path = System.getenv("PATH")
|
||||
private fun searchPath(names: List<String>, path: String?, home: String): String? {
|
||||
if (path != null) {
|
||||
for (dir in path.split(File.pathSeparator)) {
|
||||
if (dir.isEmpty()) continue
|
||||
for (name in names) {
|
||||
val f = File(dir, name)
|
||||
if (f.isFile && f.canExecute()) return f.absolutePath
|
||||
|
|
@ -72,7 +96,6 @@ private fun searchPath(names: List<String>): String? {
|
|||
// The install root, last: an IDE launched from the desktop does not inherit
|
||||
// the PATH the installer wrote to the shell profile, so a working ~/.ludic
|
||||
// install would otherwise look missing.
|
||||
val home = System.getenv("LUDIC_HOME") ?: (System.getProperty("user.home") + "/.ludic")
|
||||
for (name in names) {
|
||||
val f = File(File(home, "bin"), name)
|
||||
if (f.isFile && f.canExecute()) return f.absolutePath
|
||||
|
|
@ -80,6 +103,39 @@ private fun searchPath(names: List<String>): String? {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The three toolchain binaries, resolved the same way everywhere: the setting,
|
||||
* then the project's own bin/, then PATH, then the install root.
|
||||
*/
|
||||
object LudicToolchain {
|
||||
fun server(project: Project): String? =
|
||||
findExecutable(project.basePath, LudicSettings.of(project).serverPath, listOf("ludic-lsp", "ludic-lsp.exe"), SERVER_SUBDIRS)
|
||||
|
||||
fun compiler(project: Project): String? =
|
||||
findExecutable(project.basePath, LudicSettings.of(project).compilerPath, listOf("ludicc", "ludicc.exe"), COMPILER_SUBDIRS)
|
||||
|
||||
/**
|
||||
* The install root the toolchain runs from: $LUDIC_HOME, else the
|
||||
* directory above the `ludic` binary's bin/ (a checkout, or ~/.ludic).
|
||||
*/
|
||||
fun home(project: Project?): File? {
|
||||
System.getenv("LUDIC_HOME")?.let { return File(it) }
|
||||
val cli = (if (project != null) cli(project) else cli()) ?: return null
|
||||
val bin = File(cli).parentFile ?: return null
|
||||
return if (bin.name == "bin") bin.parentFile else bin
|
||||
}
|
||||
|
||||
fun cli(project: Project): String? =
|
||||
findExecutable(project.basePath, LudicSettings.of(project).cliPath, listOf("ludic", "ludic.exe"), COMPILER_SUBDIRS)
|
||||
|
||||
/** For places with no project yet, such as the New Project wizard. */
|
||||
fun cli(): String? = findExecutable(null, "", listOf("ludic", "ludic.exe"), COMPILER_SUBDIRS)
|
||||
|
||||
const val INSTALL_HINT =
|
||||
"Install the toolchain (curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh) " +
|
||||
"or set the paths in Settings | Languages & Frameworks | Ludic."
|
||||
}
|
||||
|
||||
private fun expandHome(p: String) =
|
||||
if (p.startsWith("~")) System.getProperty("user.home") + p.substring(1) else p
|
||||
|
||||
|
|
@ -132,11 +188,7 @@ class LudicConnectionProvider(private val project: Project) : ProcessStreamConne
|
|||
*/
|
||||
override fun getInitializationOptions(rootUri: VirtualFile?): Any {
|
||||
val settings = LudicSettings.of(project)
|
||||
val compiler = findExecutable(
|
||||
project.basePath, settings.compilerPath,
|
||||
listOf("ludicc", "ludicc.exe"),
|
||||
COMPILER_SUBDIRS
|
||||
) ?: ""
|
||||
val compiler = LudicToolchain.compiler(project) ?: ""
|
||||
return mapOf(
|
||||
"compilerPath" to compiler,
|
||||
"compilerDiagnostics" to settings.compilerDiagnostics,
|
||||
|
|
@ -160,7 +212,50 @@ internal fun serverCommand(root: String?, configured: String): List<String>? {
|
|||
internal val SERVER_SUBDIRS = listOf("bin", "build", "tools/build", ".")
|
||||
internal val COMPILER_SUBDIRS = listOf("bin", "build", ".")
|
||||
|
||||
/**
|
||||
* package.ludic is a Ludic file to the IDE but not Ludic source to the server:
|
||||
* its directives would read as unknown names, get keyword completions and have
|
||||
* their manifest colours painted over. The plugin handles it natively instead.
|
||||
*/
|
||||
private class ManifestAwareFeatures : LSPClientFeatures() {
|
||||
init {
|
||||
completionFeature = object : LSPCompletionFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
semanticTokensFeature = object : LSPSemanticTokensFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
override fun getTextAttributesKey(tokenType: String, tokenModifiers: List<String>, file: PsiFile) =
|
||||
io.ludic.ide.editor.LudicSemanticColors.keyFor(tokenType, tokenModifiers)
|
||||
}
|
||||
diagnosticFeature = object : LSPDiagnosticFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
signatureHelpFeature = object : LSPSignatureHelpFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
inlayHintFeature = object : LSPInlayHintFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
definitionFeature = object : LSPDefinitionFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
documentLinkFeature = object : LSPDocumentLinkFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
documentHighlightFeature = object : LSPDocumentHighlightFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
hoverFeature = object : LSPHoverFeature() {
|
||||
override fun isEnabled(file: PsiFile) = !isManifest(file) && super.isEnabled(file)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isManifest(file: PsiFile) = file.name == "package.ludic" || file.name == "package.lock.ludic"
|
||||
}
|
||||
|
||||
class LudicLanguageServerFactory : LanguageServerFactory {
|
||||
override fun createClientFeatures(): LSPClientFeatures = ManifestAwareFeatures()
|
||||
|
||||
override fun createConnectionProvider(project: Project): StreamConnectionProvider =
|
||||
LudicConnectionProvider(project)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ object LudicColors {
|
|||
val COMMENT = key("LUDIC_COMMENT", D.LINE_COMMENT)
|
||||
val STRING = key("LUDIC_STRING", D.STRING)
|
||||
val NUMBER = key("LUDIC_NUMBER", D.NUMBER)
|
||||
val STRING_ESCAPE = key("LUDIC_STRING_ESCAPE", D.VALID_STRING_ESCAPE)
|
||||
val TEMPLATE_BRACES = key("LUDIC_TEMPLATE_BRACES", D.KEYWORD)
|
||||
val KEYWORD = key("LUDIC_KEYWORD", D.KEYWORD)
|
||||
val DECL_KEYWORD = key("LUDIC_DECL_KEYWORD", D.KEYWORD)
|
||||
val CLAUSE = key("LUDIC_CLAUSE", D.METADATA)
|
||||
|
|
@ -36,18 +38,43 @@ object LudicColors {
|
|||
val BRACKET = key("LUDIC_BRACKET", D.BRACKETS)
|
||||
val PAREN = key("LUDIC_PAREN", D.PARENTHESES)
|
||||
val COMMA = key("LUDIC_COMMA", D.COMMA)
|
||||
val MANIFEST = key("LUDIC_MANIFEST_DIRECTIVE", D.KEYWORD)
|
||||
|
||||
// Semantic colours, painted from ludic-lsp's tokens. "Built-in" is the
|
||||
// language and its standard library, "package" a ludic.* or fetched
|
||||
// package, and the plain key the program's own code.
|
||||
val FUNCTION_DECLARATION = key("LUDIC_FUNCTION_DECLARATION", D.FUNCTION_DECLARATION)
|
||||
val FUNCTION_CALL = key("LUDIC_FUNCTION_CALL", D.FUNCTION_CALL)
|
||||
val FUNCTION_PACKAGE = key("LUDIC_FUNCTION_PACKAGE", D.FUNCTION_CALL)
|
||||
val FUNCTION_BUILTIN = key("LUDIC_FUNCTION_BUILTIN", D.PREDEFINED_SYMBOL)
|
||||
val HANDLER = key("LUDIC_HANDLER", D.FUNCTION_DECLARATION)
|
||||
val CONSTANT = key("LUDIC_CONSTANT", D.CONSTANT)
|
||||
val CONSTANT_PACKAGE = key("LUDIC_CONSTANT_PACKAGE", D.CONSTANT)
|
||||
val CONSTANT_BUILTIN = key("LUDIC_CONSTANT_BUILTIN", D.STATIC_FIELD)
|
||||
val GLOBAL_VARIABLE = key("LUDIC_GLOBAL_VARIABLE", D.GLOBAL_VARIABLE)
|
||||
val GLOBAL_VARIABLE_PACKAGE = key("LUDIC_GLOBAL_VARIABLE_PACKAGE", D.GLOBAL_VARIABLE)
|
||||
val LOCAL_VARIABLE = key("LUDIC_LOCAL_VARIABLE", D.LOCAL_VARIABLE)
|
||||
val PARAMETER = key("LUDIC_PARAMETER", D.PARAMETER)
|
||||
val FIELD = key("LUDIC_FIELD", D.INSTANCE_FIELD)
|
||||
val PROPERTY_TYPE = key("LUDIC_PROPERTY_TYPE", D.CLASS_NAME)
|
||||
val MODEL_TYPE = key("LUDIC_MODEL_TYPE", D.CLASS_NAME)
|
||||
val TYPE_PACKAGE = key("LUDIC_TYPE_PACKAGE", D.CLASS_REFERENCE)
|
||||
val NAMESPACE = key("LUDIC_NAMESPACE", D.CLASS_REFERENCE)
|
||||
val NAMESPACE_BUILTIN = key("LUDIC_NAMESPACE_BUILTIN", D.PREDEFINED_SYMBOL)
|
||||
val BAD = createTextAttributesKey("LUDIC_BAD_CHARACTER", HighlighterColors.BAD_CHARACTER)
|
||||
|
||||
private fun key(name: String, base: TextAttributesKey) = createTextAttributesKey(name, base)
|
||||
}
|
||||
|
||||
class LudicSyntaxHighlighter : SyntaxHighlighterBase() {
|
||||
override fun getHighlightingLexer(): Lexer = LudicLexer()
|
||||
class LudicSyntaxHighlighter(private val manifest: Boolean = false) : SyntaxHighlighterBase() {
|
||||
override fun getHighlightingLexer(): Lexer = LudicLexer(manifest)
|
||||
|
||||
override fun getTokenHighlights(tokenType: IElementType): Array<TextAttributesKey> = pack(
|
||||
when (tokenType) {
|
||||
LudicTokens.COMMENT -> LudicColors.COMMENT
|
||||
LudicTokens.STRING, LudicTokens.CHAR -> LudicColors.STRING
|
||||
LudicTokens.STRING, LudicTokens.CHAR, LudicTokens.TEMPLATE_QUOTE, LudicTokens.TEMPLATE_TEXT -> LudicColors.STRING
|
||||
LudicTokens.TEMPLATE_ESCAPE -> LudicColors.STRING_ESCAPE
|
||||
LudicTokens.TEMPLATE_HOLE_START, LudicTokens.TEMPLATE_HOLE_END -> LudicColors.TEMPLATE_BRACES
|
||||
LudicTokens.NUMBER -> LudicColors.NUMBER
|
||||
LudicTokens.KEYWORD -> LudicColors.KEYWORD
|
||||
LudicTokens.DECL_KEYWORD -> LudicColors.DECL_KEYWORD
|
||||
|
|
@ -60,10 +87,11 @@ class LudicSyntaxHighlighter : SyntaxHighlighterBase() {
|
|||
LudicTokens.IDENTIFIER -> LudicColors.IDENTIFIER
|
||||
LudicTokens.ANNOTATION -> LudicColors.ANNOTATION
|
||||
LudicTokens.OPERATOR -> LudicColors.OPERATOR
|
||||
LudicTokens.BRACE -> LudicColors.BRACE
|
||||
LudicTokens.BRACKET -> LudicColors.BRACKET
|
||||
LudicTokens.PAREN -> LudicColors.PAREN
|
||||
LudicTokens.LBRACE, LudicTokens.RBRACE -> LudicColors.BRACE
|
||||
LudicTokens.LBRACKET, LudicTokens.RBRACKET -> LudicColors.BRACKET
|
||||
LudicTokens.LPAREN, LudicTokens.RPAREN -> LudicColors.PAREN
|
||||
LudicTokens.COMMA -> LudicColors.COMMA
|
||||
LudicTokens.MANIFEST_KEYWORD -> LudicColors.MANIFEST
|
||||
LudicTokens.BAD -> LudicColors.BAD
|
||||
else -> null
|
||||
}
|
||||
|
|
@ -72,5 +100,5 @@ class LudicSyntaxHighlighter : SyntaxHighlighterBase() {
|
|||
|
||||
class LudicSyntaxHighlighterFactory : SyntaxHighlighterFactory() {
|
||||
override fun getSyntaxHighlighter(project: Project?, virtualFile: VirtualFile?): SyntaxHighlighter =
|
||||
LudicSyntaxHighlighter()
|
||||
LudicSyntaxHighlighter(manifest = virtualFile?.name == "package.ludic" || virtualFile?.name == "package.lock.ludic")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package io.ludic.ide
|
|||
|
||||
import com.intellij.psi.tree.IElementType
|
||||
import com.intellij.psi.tree.IFileElementType
|
||||
import com.intellij.psi.tree.TokenSet
|
||||
|
||||
/**
|
||||
* One token type per thing the highlighter needs to tell apart. This is a
|
||||
|
|
@ -16,6 +17,13 @@ object LudicTokens {
|
|||
val COMMENT = LudicTokenType("COMMENT")
|
||||
val STRING = LudicTokenType("STRING")
|
||||
val CHAR = LudicTokenType("CHAR")
|
||||
// `text {hole} text`: the pieces of a template string
|
||||
val TEMPLATE_QUOTE = LudicTokenType("TEMPLATE_QUOTE")
|
||||
val TEMPLATE_TEXT = LudicTokenType("TEMPLATE_TEXT")
|
||||
val TEMPLATE_ESCAPE = LudicTokenType("TEMPLATE_ESCAPE") // {{ }} \n
|
||||
val TEMPLATE_HOLE_START = LudicTokenType("TEMPLATE_HOLE_START")
|
||||
val TEMPLATE_HOLE_END = LudicTokenType("TEMPLATE_HOLE_END")
|
||||
val TEMPLATE_PARTS = TokenSet.create(TEMPLATE_QUOTE, TEMPLATE_TEXT, TEMPLATE_ESCAPE)
|
||||
val NUMBER = LudicTokenType("NUMBER")
|
||||
val IDENTIFIER = LudicTokenType("IDENTIFIER")
|
||||
val TYPE_NAME = LudicTokenType("TYPE_NAME") // Capitalised: component, archetype, scene
|
||||
|
|
@ -27,10 +35,21 @@ object LudicTokens {
|
|||
val BOOLEAN = LudicTokenType("BOOLEAN")
|
||||
val BUILTIN = LudicTokenType("BUILTIN") // runtime surface + compiler intrinsics
|
||||
val ANNOTATION = LudicTokenType("ANNOTATION") // @deterministic
|
||||
val MANIFEST_KEYWORD = LudicTokenType("MANIFEST_KEYWORD") // package.ludic: package, require, app, ...
|
||||
val OPERATOR = LudicTokenType("OPERATOR")
|
||||
val BRACE = LudicTokenType("BRACE")
|
||||
val BRACKET = LudicTokenType("BRACKET")
|
||||
val PAREN = LudicTokenType("PAREN")
|
||||
// Opening and closing delimiters are distinct types: the platform's brace
|
||||
// matching (and Enter's "close the unmatched brace") cannot tell `{` from
|
||||
// `}` by text, only by token type.
|
||||
val LBRACE = LudicTokenType("LBRACE")
|
||||
val RBRACE = LudicTokenType("RBRACE")
|
||||
val LBRACKET = LudicTokenType("LBRACKET")
|
||||
val RBRACKET = LudicTokenType("RBRACKET")
|
||||
val LPAREN = LudicTokenType("LPAREN")
|
||||
val RPAREN = LudicTokenType("RPAREN")
|
||||
val BRACES = TokenSet.create(LBRACE, RBRACE)
|
||||
val BRACKETS = TokenSet.create(LBRACKET, RBRACKET)
|
||||
val PARENS = TokenSet.create(LPAREN, RPAREN)
|
||||
val DELIMITERS = TokenSet.orSet(BRACES, BRACKETS, PARENS)
|
||||
val COMMA = LudicTokenType("COMMA")
|
||||
val BAD = LudicTokenType("BAD_CHARACTER")
|
||||
}
|
||||
|
|
@ -75,5 +94,25 @@ object LudicVocabulary {
|
|||
"win_running", "win_close"
|
||||
)
|
||||
|
||||
/**
|
||||
* Declaration and clause words the compiler does not reserve as names
|
||||
* (selfhost/frontend/parse.ludic is_reserved_word only guards function
|
||||
* names): they are keywords only where a declaration can start.
|
||||
*/
|
||||
val CONTEXTUAL = setOf(
|
||||
"program", "import", "property", "model", "enum", "ui", "namespace", "function", "extern",
|
||||
"handler", "entry", "event", "scene", "test", "phase", "query", "on", "cancellable", "public",
|
||||
"layer", "start"
|
||||
)
|
||||
|
||||
/**
|
||||
* The directives parse_manifest (tools/ludic-cli/pkg.ludic) understands,
|
||||
* and the words of the lock file parse_lock reads.
|
||||
*/
|
||||
val MANIFEST = setOf(
|
||||
"package", "version", "kind", "entry", "provides", "targets", "require", "pack", "app", "script", "hook",
|
||||
"lock", "module", "hash"
|
||||
)
|
||||
|
||||
fun isKeyword(w: String) = w in DECL || w in CLAUSE || w in STMT
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.openapi.command.WriteCommandAction
|
||||
import com.intellij.openapi.editor.ElementColorProvider
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.psi.PsiDocumentManager
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.util.PsiTreeUtil
|
||||
import com.intellij.psi.util.elementType
|
||||
import io.ludic.ide.LudicTokens
|
||||
import java.awt.Color
|
||||
|
||||
/**
|
||||
* Colour swatches in the gutter, with the colour picker behind them, for the
|
||||
* three ways Ludic writes a colour:
|
||||
*
|
||||
* Screen.clear(0x0d1020) a 0xRRGGBB int
|
||||
* Screen.clear(Color.Crimson) a palette name (docs/language/colors/palette.json)
|
||||
* app splash_bg "#0b1622" a CSS-style string (package.ludic)
|
||||
*
|
||||
* Picking a new colour rewrites the literal; a palette name becomes the hex int
|
||||
* it now is.
|
||||
*/
|
||||
class LudicColorPreview : ElementColorProvider {
|
||||
|
||||
override fun getColorFrom(element: PsiElement): Color? {
|
||||
if (element.firstChild != null) return null
|
||||
return when (element.elementType) {
|
||||
LudicTokens.NUMBER -> HEX_INT.matchEntire(element.text)?.let { rgb(it.groupValues[1]) }
|
||||
LudicTokens.STRING -> HEX_STRING.matchEntire(element.text)?.let { rgb(expand(it.groupValues[1])) }
|
||||
LudicTokens.IDENTIFIER, LudicTokens.TYPE_NAME -> paletteName(element)?.let { LudicPalette.lookup(it)?.let(::rgb) }
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
override fun setColorTo(element: PsiElement, color: Color) {
|
||||
val file = element.containingFile ?: return
|
||||
val doc = PsiDocumentManager.getInstance(element.project).getDocument(file) ?: return
|
||||
val hex = "%02x%02x%02x".format(color.red, color.green, color.blue)
|
||||
val (range, text) = when (element.elementType) {
|
||||
LudicTokens.NUMBER -> element.textRange to "0x$hex"
|
||||
LudicTokens.STRING -> element.textRange to "\"#$hex\""
|
||||
else -> {
|
||||
// `Color.Crimson` -> `0xrrggbb`
|
||||
val start = paletteReceiver(element)?.textRange?.startOffset ?: return
|
||||
TextRange(start, element.textRange.endOffset) to "0x$hex"
|
||||
}
|
||||
}
|
||||
WriteCommandAction.runWriteCommandAction(element.project, "Change Colour", null, {
|
||||
doc.replaceString(range.startOffset, range.endOffset, text)
|
||||
PsiDocumentManager.getInstance(element.project).commitDocument(doc)
|
||||
}, file)
|
||||
}
|
||||
|
||||
private fun paletteName(element: PsiElement): String? =
|
||||
if (paletteReceiver(element) != null) element.text else null
|
||||
|
||||
/** The `Color` of `Color.Name`, when element is the Name. */
|
||||
private fun paletteReceiver(element: PsiElement): PsiElement? {
|
||||
val dot = prevSignificant(element) ?: return null
|
||||
if (dot.elementType != LudicTokens.OPERATOR || dot.text != ".") return null
|
||||
val recv = prevSignificant(dot) ?: return null
|
||||
return if (recv.text == "Color") recv else null
|
||||
}
|
||||
|
||||
private fun prevSignificant(e: PsiElement): PsiElement? {
|
||||
var p = PsiTreeUtil.prevLeaf(e)
|
||||
while (p != null && p.elementType == TokenType.WHITE_SPACE) p = PsiTreeUtil.prevLeaf(p)
|
||||
return p
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val HEX_INT = Regex("0[xX]([0-9a-fA-F]{6})")
|
||||
val HEX_STRING = Regex("\"#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\"")
|
||||
|
||||
fun expand(h: String) = if (h.length == 3) h.map { "$it$it" }.joinToString("") else h
|
||||
fun rgb(hex: String) = Color(hex.toInt(16))
|
||||
}
|
||||
}
|
||||
|
||||
/** The named colours `Color.*` resolves, from the palette the docs are generated from. */
|
||||
object LudicPalette {
|
||||
private val names: Map<String, String> by lazy { load() }
|
||||
|
||||
fun lookup(name: String): String? = names[name]
|
||||
fun all(): Map<String, String> = names
|
||||
|
||||
private fun load(): Map<String, String> {
|
||||
val text = LudicPalette::class.java.getResourceAsStream("/palette.json")?.bufferedReader()?.readText() ?: return emptyMap()
|
||||
return parse(text)
|
||||
}
|
||||
|
||||
fun parse(json: String): Map<String, String> =
|
||||
Regex("\"name\"\\s*:\\s*\"([^\"]+)\"\\s*,\\s*\"hex\"\\s*:\\s*\"([0-9A-Fa-f]{6})\"")
|
||||
.findAll(json).associate { it.groupValues[1] to it.groupValues[2] }
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.lang.documentation.AbstractDocumentationProvider
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The language reference pages (the docs/language pages) as documentation.
|
||||
*
|
||||
* Go-to-definition on a built-in lands on its page, and the IDE then describes
|
||||
* that target — on Cmd/Ctrl-hover and in Quick Documentation — through
|
||||
* whatever element the language server handed back, which would otherwise read
|
||||
* `LSP Psi Element "---" [type-void.md]`. This renders the page's signature,
|
||||
* summary and body instead.
|
||||
*/
|
||||
class LudicDocPageDocumentation : AbstractDocumentationProvider() {
|
||||
|
||||
override fun getQuickNavigateInfo(element: PsiElement?, originalElement: PsiElement?): String? {
|
||||
val page = pageOf(element) ?: return null
|
||||
val sig = page.meta["sig"]?.takeIf { it.isNotBlank() } ?: page.meta["name"] ?: return null
|
||||
val tip = page.meta["tip"].orEmpty()
|
||||
return "<b>${esc(sig)}</b>" + if (tip.isNotEmpty()) "<br>${esc(tip)}" else ""
|
||||
}
|
||||
|
||||
override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
|
||||
val page = pageOf(element) ?: return null
|
||||
val sig = page.meta["sig"]?.takeIf { it.isNotBlank() } ?: page.meta["name"] ?: page.meta["title"] ?: return null
|
||||
val sb = StringBuilder()
|
||||
sb.append("<div class='definition'><pre>").append(esc(sig)).append("</pre></div><div class='content'>")
|
||||
page.meta["tip"]?.takeIf { it.isNotBlank() }?.let { sb.append("<p><b>").append(esc(it)).append("</b></p>") }
|
||||
sb.append(markdownToHtml(page.body))
|
||||
sb.append("</div>")
|
||||
page.meta["id"]?.let { id ->
|
||||
val url = DOCS_URL + (if (page.file.name == "_section.md") "ns-$id" else id) + ".html"
|
||||
sb.append("<div class='bottom'><a href='").append(url).append("'>").append(url).append("</a></div>")
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
override fun getUrlFor(element: PsiElement?, originalElement: PsiElement?): List<String>? {
|
||||
val page = pageOf(element) ?: return null
|
||||
val id = page.meta["id"] ?: return null
|
||||
return listOf(DOCS_URL + (if (page.file.name == "_section.md") "ns-$id" else id) + ".html")
|
||||
}
|
||||
|
||||
data class Page(val file: File, val meta: Map<String, String>, val body: String)
|
||||
|
||||
companion object {
|
||||
const val DOCS_URL = "https://workshopsoft.pages.workshopsoft.io/ludic/"
|
||||
|
||||
fun pageOf(element: PsiElement?): Page? {
|
||||
val file: PsiFile = element?.containingFile ?: (element as? PsiFile) ?: return null
|
||||
val path = file.virtualFile?.path ?: return null
|
||||
if (!path.contains("/docs/language/") || !path.endsWith(".md")) return null
|
||||
return parse(File(path), file.text)
|
||||
}
|
||||
|
||||
fun parse(file: File, text: String): Page? {
|
||||
if (!text.startsWith("---\n")) return null
|
||||
val end = text.indexOf("\n---", 4)
|
||||
if (end < 0) return null
|
||||
val meta = text.substring(4, end).lines().mapNotNull { line ->
|
||||
val c = line.indexOf(':')
|
||||
if (c <= 0) null else line.substring(0, c).trim() to line.substring(c + 1).trim()
|
||||
}.toMap()
|
||||
val body = text.substring(end + 4).trimStart('-', '\n', '\r')
|
||||
return Page(file, meta, body)
|
||||
}
|
||||
|
||||
private fun esc(s: String) = StringUtil.escapeXmlEntities(s)
|
||||
|
||||
/**
|
||||
* Just enough markdown for these pages: paragraphs, `code`, **bold**,
|
||||
* fenced ```ludic blocks and the inline HTML the pages already use.
|
||||
*/
|
||||
fun markdownToHtml(md: String): String {
|
||||
val out = StringBuilder()
|
||||
val lines = md.lines()
|
||||
var i = 0
|
||||
val para = StringBuilder()
|
||||
fun flush() {
|
||||
if (para.isNotBlank()) out.append("<p>").append(inline(para.toString().trim())).append("</p>")
|
||||
para.setLength(0)
|
||||
}
|
||||
while (i < lines.size) {
|
||||
val line = lines[i]
|
||||
if (line.trimStart().startsWith("```")) {
|
||||
flush()
|
||||
val code = StringBuilder()
|
||||
i++
|
||||
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
|
||||
if (!lines[i].trimStart().startsWith("# doc-check")) code.append(lines[i]).append('\n')
|
||||
i++
|
||||
}
|
||||
out.append("<pre><code>").append(esc(code.toString().trimEnd())).append("</code></pre>")
|
||||
} else if (line.isBlank()) {
|
||||
flush()
|
||||
} else {
|
||||
para.append(line).append(' ')
|
||||
}
|
||||
i++
|
||||
}
|
||||
flush()
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
private fun inline(s: String): String {
|
||||
// the pages mix markdown with a little inline HTML (<code>, <b>);
|
||||
// keep those tags, escape everything else
|
||||
val keep = Regex("</?(code|b|i|em|strong|br)>")
|
||||
val parts = StringBuilder()
|
||||
var last = 0
|
||||
for (m in keep.findAll(s)) {
|
||||
parts.append(mdSpans(esc(s.substring(last, m.range.first))))
|
||||
parts.append(m.value)
|
||||
last = m.range.last + 1
|
||||
}
|
||||
parts.append(mdSpans(esc(s.substring(last))))
|
||||
return parts.toString()
|
||||
}
|
||||
|
||||
private fun mdSpans(s: String): String = s
|
||||
.replace(Regex("`([^`]+)`"), "<code>$1</code>")
|
||||
.replace(Regex("\\*\\*([^*]+)\\*\\*"), "<b>$1</b>")
|
||||
.replace(Regex("\\[([^\\]]+)]\\(([^)]+)\\)"), "<a href='$2'>$1</a>")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.application.options.CodeStyle
|
||||
import com.intellij.codeInsight.editorActions.SimpleTokenSetQuoteHandler
|
||||
import com.intellij.codeInsight.editorActions.TypedHandlerDelegate
|
||||
import com.intellij.lang.Language
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.codeStyle.lineIndent.LineIndentProvider
|
||||
import io.ludic.ide.LudicFileType
|
||||
import io.ludic.ide.LudicLanguage
|
||||
import io.ludic.ide.LudicLexer
|
||||
import io.ludic.ide.LudicTokens
|
||||
|
||||
/**
|
||||
* Where a line's indentation belongs, from bracket nesting alone.
|
||||
*
|
||||
* Every open `{`, `[` or `(` before the offset is a level; a level's body is
|
||||
* indented one unit past the *line* its opener sits on, so
|
||||
*
|
||||
* handler Move phase Update {
|
||||
* for (p) in query [Pos] {
|
||||
* |
|
||||
*
|
||||
* lands where it should however the header lines were themselves indented.
|
||||
* Strings and comments go through the lexer, so a brace inside `"{"` or a
|
||||
* comment does not count. A line that starts with a closer lines up with its
|
||||
* opener's line.
|
||||
*/
|
||||
object LudicIndenter {
|
||||
|
||||
fun indentFor(text: CharSequence, offset: Int, unit: String): String {
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(text, 0, offset.coerceIn(0, text.length), 0)
|
||||
val opens = ArrayDeque<Int>()
|
||||
while (true) {
|
||||
val type = lexer.tokenType ?: break
|
||||
when (type) {
|
||||
LudicTokens.LBRACE, LudicTokens.LBRACKET, LudicTokens.LPAREN -> opens.addLast(lexer.tokenStart)
|
||||
LudicTokens.RBRACE, LudicTokens.RBRACKET, LudicTokens.RPAREN -> opens.removeLastOrNull()
|
||||
}
|
||||
lexer.advance()
|
||||
}
|
||||
val opener = opens.lastOrNull() ?: return ""
|
||||
val base = leadingWhitespace(text, lineStart(text, opener))
|
||||
return if (startsWithCloser(text, offset)) base else base + unit
|
||||
}
|
||||
|
||||
/** Does the text from [offset] to the end of its line begin with a closer? */
|
||||
fun startsWithCloser(text: CharSequence, offset: Int): Boolean {
|
||||
var i = offset
|
||||
while (i < text.length && (text[i] == ' ' || text[i] == '\t')) i++
|
||||
return i < text.length && text[i] in "}])"
|
||||
}
|
||||
|
||||
fun lineStart(text: CharSequence, offset: Int): Int {
|
||||
var i = offset.coerceAtMost(text.length)
|
||||
while (i > 0 && text[i - 1] != '\n') i--
|
||||
return i
|
||||
}
|
||||
|
||||
fun leadingWhitespace(text: CharSequence, from: Int): String {
|
||||
var i = from
|
||||
while (i < text.length && (text[i] == ' ' || text[i] == '\t')) i++
|
||||
return text.subSequence(from, i).toString()
|
||||
}
|
||||
|
||||
fun unit(project: Project): String {
|
||||
val o = CodeStyle.getSettings(project).getIndentOptions(LudicFileType)
|
||||
return if (o.USE_TAB_CHARACTER) "\t" else " ".repeat(o.INDENT_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
/** Enter: the new line starts at the right depth. */
|
||||
class LudicLineIndentProvider : LineIndentProvider {
|
||||
override fun getLineIndent(project: Project, editor: Editor, language: Language?, offset: Int): String? =
|
||||
LudicIndenter.indentFor(editor.document.charsSequence, offset, LudicIndenter.unit(project))
|
||||
|
||||
override fun isSuitableFor(language: Language?) = language == LudicLanguage
|
||||
}
|
||||
|
||||
/**
|
||||
* Typing a closer as the first thing on a line pulls it back to its opener's
|
||||
* depth: ` }` becomes ` }` the moment the brace is typed.
|
||||
*/
|
||||
class LudicCloserTypedHandler : TypedHandlerDelegate() {
|
||||
override fun charTyped(c: Char, project: Project, editor: Editor, file: PsiFile): Result {
|
||||
if (file.fileType != LudicFileType || c !in "}])") return Result.CONTINUE
|
||||
val doc = editor.document
|
||||
val caret = editor.caretModel.offset
|
||||
val closer = caret - 1
|
||||
if (closer < 0) return Result.CONTINUE
|
||||
val text = doc.charsSequence
|
||||
val start = LudicIndenter.lineStart(text, closer)
|
||||
if (text.subSequence(start, closer).isNotBlank()) return Result.CONTINUE
|
||||
val want = LudicIndenter.indentFor(text, closer, LudicIndenter.unit(project))
|
||||
reindent(doc, start, closer, want)
|
||||
return Result.CONTINUE
|
||||
}
|
||||
|
||||
private fun reindent(doc: Document, start: Int, end: Int, want: String) {
|
||||
if (doc.charsSequence.subSequence(start, end).toString() == want) return
|
||||
doc.replaceString(start, end, want)
|
||||
}
|
||||
}
|
||||
|
||||
class LudicQuoteHandler : SimpleTokenSetQuoteHandler(LudicTokens.STRING, LudicTokens.CHAR, LudicTokens.TEMPLATE_QUOTE)
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey
|
||||
import io.ludic.ide.LudicColors
|
||||
|
||||
/**
|
||||
* ludic-lsp's semantic tokens, in the plugin's own colour keys, so every
|
||||
* colour is on Settings | Editor | Color Scheme | Ludic and tells the program's
|
||||
* code, package code and the built-in language apart.
|
||||
*
|
||||
* Keywords, literals, comments and operators return null: the lexer already
|
||||
* colours those (including contextual keywords and manifest directives), and a
|
||||
* semantic colour on top would only flatten them.
|
||||
*/
|
||||
object LudicSemanticColors {
|
||||
fun keyFor(type: String, modifiers: List<String>): TextAttributesKey? {
|
||||
val builtin = "defaultLibrary" in modifiers
|
||||
val pkg = "vendor" in modifiers
|
||||
val decl = "declaration" in modifiers
|
||||
val readonly = "readonly" in modifiers
|
||||
val global = "static" in modifiers
|
||||
return when (type) {
|
||||
"function" -> when {
|
||||
readonly -> LudicColors.HANDLER // ludic-lsp marks handlers readonly
|
||||
builtin -> LudicColors.FUNCTION_BUILTIN
|
||||
pkg -> LudicColors.FUNCTION_PACKAGE
|
||||
decl -> LudicColors.FUNCTION_DECLARATION
|
||||
else -> LudicColors.FUNCTION_CALL
|
||||
}
|
||||
"variable" -> when {
|
||||
readonly && builtin -> LudicColors.CONSTANT_BUILTIN
|
||||
readonly && pkg -> LudicColors.CONSTANT_PACKAGE
|
||||
readonly -> LudicColors.CONSTANT
|
||||
global && pkg -> LudicColors.GLOBAL_VARIABLE_PACKAGE
|
||||
global -> LudicColors.GLOBAL_VARIABLE
|
||||
else -> LudicColors.LOCAL_VARIABLE
|
||||
}
|
||||
"parameter" -> LudicColors.PARAMETER
|
||||
"property" -> LudicColors.FIELD
|
||||
"struct" -> if (pkg) LudicColors.TYPE_PACKAGE else LudicColors.PROPERTY_TYPE
|
||||
"class" -> when {
|
||||
builtin -> LudicColors.KEYWORD // ui widgets: panel, button, …
|
||||
pkg -> LudicColors.TYPE_PACKAGE
|
||||
else -> LudicColors.MODEL_TYPE
|
||||
}
|
||||
"namespace" -> if (builtin) LudicColors.NAMESPACE_BUILTIN else LudicColors.NAMESPACE
|
||||
"enumMember" -> if (readonly && builtin) LudicColors.PHASE else LudicColors.CONSTANT_BUILTIN
|
||||
"decorator" -> LudicColors.ANNOTATION
|
||||
"type" -> if (builtin) null else LudicColors.TYPE_NAME
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.application.options.CodeStyleAbstractConfigurable
|
||||
import com.intellij.application.options.CodeStyleAbstractPanel
|
||||
import com.intellij.application.options.IndentOptionsEditor
|
||||
import com.intellij.application.options.SmartIndentOptionsEditor
|
||||
import com.intellij.application.options.TabbedLanguageCodeStylePanel
|
||||
import com.intellij.lang.Language
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighter
|
||||
import com.intellij.openapi.options.colors.AttributesDescriptor
|
||||
import com.intellij.openapi.options.colors.ColorDescriptor
|
||||
import com.intellij.openapi.options.colors.ColorSettingsPage
|
||||
import com.intellij.psi.codeStyle.CodeStyleConfigurable
|
||||
import com.intellij.psi.codeStyle.CodeStyleSettings
|
||||
import com.intellij.psi.codeStyle.CommonCodeStyleSettings
|
||||
import com.intellij.psi.codeStyle.LanguageCodeStyleSettingsProvider
|
||||
import io.ludic.ide.LudicColors
|
||||
import io.ludic.ide.LudicIcons
|
||||
import io.ludic.ide.LudicLanguage
|
||||
import io.ludic.ide.LudicSyntaxHighlighter
|
||||
import javax.swing.Icon
|
||||
|
||||
private val SAMPLE = """
|
||||
# Bouncing balls: properties, a handler per phase, and a test.
|
||||
program Demo {
|
||||
import "ludic.render3d/r3d.ludic"
|
||||
|
||||
property Pos { x: int = 0, y: int = 0 }
|
||||
model Score { value: int, best: int }
|
||||
enum Mode { Title, Playing }
|
||||
|
||||
const SPEED: int = 2
|
||||
var g_mode: Mode = Mode.Title
|
||||
|
||||
@deterministic
|
||||
handler Move phase FixedUpdate {
|
||||
for (p) in query [Pos] where p.x < 304 {
|
||||
p.x = p.x + SPEED
|
||||
if p.x > 0x130 { despawn(self) } else { print("moving") }
|
||||
}
|
||||
}
|
||||
|
||||
function half(f: fixed) -> fixed { return f / 2 }
|
||||
|
||||
test "half of one" {
|
||||
expect_near(half(fixed(1)), 32768, 0)
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
// the sample with semantic tags, as the server would colour it
|
||||
private val DEMO = """
|
||||
# Bouncing balls: properties, a handler per phase, and a test.
|
||||
program Demo {
|
||||
import "ludic.render3d/r3d.ludic"
|
||||
|
||||
property <prop>Pos</prop> { <field>x</field>: int = 0, <field>y</field>: int = 0 }
|
||||
model <model>Score</model> { <field>value</field>: int, <field>best</field>: int }
|
||||
|
||||
const <const>SPEED</const>: int = 2
|
||||
var <global>g_model</global>: int = 0
|
||||
|
||||
@deterministic
|
||||
handler <handler>Move</handler> phase FixedUpdate {
|
||||
for (<local>p</local>) in query [<prop>Pos</prop>] where <local>p</local>.<field>x</field> < 304 {
|
||||
let <local>model</local> = <fpkg>f_clamp</fpkg>(<local>p</local>.<field>x</field>, 0, 0x130)
|
||||
<local>p</local>.<field>x</field> = <fcall>half</fcall>(<local>model</local>) + <const>SPEED</const>
|
||||
<fbuiltin>print</fbuiltin>(`x is {<local>p</local>.<field>x</field>} of {{304}}`)
|
||||
<fbuiltin>print</fbuiltin>(`x is {<local>p</local>.<field>x</field>} of {{304}}\n`)
|
||||
<ns>Screen</ns>.<fbuiltin>clear</fbuiltin>(<ns>Color</ns>.<cbuiltin>Crimson</cbuiltin>)
|
||||
}
|
||||
}
|
||||
|
||||
function <fdecl>half</fdecl>(<param>f</param>: fixed) -> fixed { return <param>f</param> / 2 }
|
||||
|
||||
test "half of one" {
|
||||
<fbuiltin>expect_near</fbuiltin>(<fcall>half</fcall>(<fbuiltin>fixed</fbuiltin>(1)), 32768, 0)
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
/** Settings | Editor | Color Scheme | Ludic */
|
||||
class LudicColorSettingsPage : ColorSettingsPage {
|
||||
override fun getIcon(): Icon = LudicIcons.FILE
|
||||
override fun getHighlighter(): SyntaxHighlighter = LudicSyntaxHighlighter()
|
||||
override fun getDemoText() = DEMO
|
||||
override fun getAdditionalHighlightingTagToDescriptorMap(): Map<String, TextAttributesKey> = mapOf(
|
||||
"fdecl" to LudicColors.FUNCTION_DECLARATION,
|
||||
"fcall" to LudicColors.FUNCTION_CALL,
|
||||
"fpkg" to LudicColors.FUNCTION_PACKAGE,
|
||||
"fbuiltin" to LudicColors.FUNCTION_BUILTIN,
|
||||
"handler" to LudicColors.HANDLER,
|
||||
"const" to LudicColors.CONSTANT,
|
||||
"cbuiltin" to LudicColors.CONSTANT_BUILTIN,
|
||||
"global" to LudicColors.GLOBAL_VARIABLE,
|
||||
"local" to LudicColors.LOCAL_VARIABLE,
|
||||
"param" to LudicColors.PARAMETER,
|
||||
"field" to LudicColors.FIELD,
|
||||
"prop" to LudicColors.PROPERTY_TYPE,
|
||||
"model" to LudicColors.MODEL_TYPE,
|
||||
"ns" to LudicColors.NAMESPACE_BUILTIN,
|
||||
)
|
||||
override fun getColorDescriptors(): Array<ColorDescriptor> = ColorDescriptor.EMPTY_ARRAY
|
||||
override fun getDisplayName() = "Ludic"
|
||||
override fun getAttributeDescriptors(): Array<AttributesDescriptor> = arrayOf(
|
||||
AttributesDescriptor("Keywords//Declaration (program, property, handler, function…)", LudicColors.DECL_KEYWORD),
|
||||
AttributesDescriptor("Keywords//Statement (if, for, spawn, return…)", LudicColors.KEYWORD),
|
||||
AttributesDescriptor("Keywords//Clause (phase, query, on…)", LudicColors.CLAUSE),
|
||||
AttributesDescriptor("Keywords//Boolean", LudicColors.BOOLEAN),
|
||||
AttributesDescriptor("Types//Built-in type (int, fixed, entity…)", LudicColors.PRIMITIVE),
|
||||
AttributesDescriptor("Types//Type or namespace name", LudicColors.TYPE_NAME),
|
||||
AttributesDescriptor("Phase (Start, Update, Render…)", LudicColors.PHASE),
|
||||
AttributesDescriptor("Built-in function", LudicColors.BUILTIN),
|
||||
AttributesDescriptor("Annotation", LudicColors.ANNOTATION),
|
||||
AttributesDescriptor("Identifier", LudicColors.IDENTIFIER),
|
||||
AttributesDescriptor("Literals//String and char", LudicColors.STRING),
|
||||
AttributesDescriptor("Literals//Number", LudicColors.NUMBER),
|
||||
AttributesDescriptor("Literals//Escape ({{, }}, \\n)", LudicColors.STRING_ESCAPE),
|
||||
AttributesDescriptor("Literals//Template hole braces", LudicColors.TEMPLATE_BRACES),
|
||||
AttributesDescriptor("Comment", LudicColors.COMMENT),
|
||||
AttributesDescriptor("Braces and Operators//Operator", LudicColors.OPERATOR),
|
||||
AttributesDescriptor("Braces and Operators//Braces", LudicColors.BRACE),
|
||||
AttributesDescriptor("Braces and Operators//Brackets", LudicColors.BRACKET),
|
||||
AttributesDescriptor("Braces and Operators//Parentheses", LudicColors.PAREN),
|
||||
AttributesDescriptor("Braces and Operators//Comma", LudicColors.COMMA),
|
||||
AttributesDescriptor("Bad character", LudicColors.BAD),
|
||||
AttributesDescriptor("Manifest directive (package.ludic)", LudicColors.MANIFEST),
|
||||
AttributesDescriptor("Functions//Declaration", LudicColors.FUNCTION_DECLARATION),
|
||||
AttributesDescriptor("Functions//Call (this program)", LudicColors.FUNCTION_CALL),
|
||||
AttributesDescriptor("Functions//Call (package)", LudicColors.FUNCTION_PACKAGE),
|
||||
AttributesDescriptor("Functions//Call (built-in)", LudicColors.FUNCTION_BUILTIN),
|
||||
AttributesDescriptor("Functions//Handler", LudicColors.HANDLER),
|
||||
AttributesDescriptor("Constants//This program", LudicColors.CONSTANT),
|
||||
AttributesDescriptor("Constants//Package", LudicColors.CONSTANT_PACKAGE),
|
||||
AttributesDescriptor("Constants//Built-in (Color.Gold, …)", LudicColors.CONSTANT_BUILTIN),
|
||||
AttributesDescriptor("Variables//Global (this program)", LudicColors.GLOBAL_VARIABLE),
|
||||
AttributesDescriptor("Variables//Global (package)", LudicColors.GLOBAL_VARIABLE_PACKAGE),
|
||||
AttributesDescriptor("Variables//Local", LudicColors.LOCAL_VARIABLE),
|
||||
AttributesDescriptor("Variables//Parameter", LudicColors.PARAMETER),
|
||||
AttributesDescriptor("Variables//Field", LudicColors.FIELD),
|
||||
AttributesDescriptor("Types//Property (component)", LudicColors.PROPERTY_TYPE),
|
||||
AttributesDescriptor("Types//Model", LudicColors.MODEL_TYPE),
|
||||
AttributesDescriptor("Types//From a package", LudicColors.TYPE_PACKAGE),
|
||||
AttributesDescriptor("Types//Namespace", LudicColors.NAMESPACE),
|
||||
AttributesDescriptor("Types//Built-in namespace (Screen, Math, …)", LudicColors.NAMESPACE_BUILTIN),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings | Editor | Code Style | Ludic. Two-space indents, spaces not tabs:
|
||||
* the house style `ludic fmt` produces. Enter, closing braces and Reformat all
|
||||
* read the indent from here.
|
||||
*/
|
||||
class LudicCodeStyleSettingsProvider : LanguageCodeStyleSettingsProvider() {
|
||||
override fun getLanguage(): Language = LudicLanguage
|
||||
|
||||
override fun getCodeSample(settingsType: SettingsType) = SAMPLE
|
||||
|
||||
override fun getIndentOptionsEditor(): IndentOptionsEditor = SmartIndentOptionsEditor()
|
||||
|
||||
override fun customizeDefaults(common: CommonCodeStyleSettings, indentOptions: CommonCodeStyleSettings.IndentOptions) {
|
||||
indentOptions.INDENT_SIZE = 2
|
||||
indentOptions.CONTINUATION_INDENT_SIZE = 2
|
||||
indentOptions.TAB_SIZE = 2
|
||||
indentOptions.USE_TAB_CHARACTER = false
|
||||
common.LINE_COMMENT_AT_FIRST_COLUMN = false
|
||||
common.LINE_COMMENT_ADD_SPACE = true
|
||||
}
|
||||
|
||||
override fun createConfigurable(baseSettings: CodeStyleSettings, modelSettings: CodeStyleSettings): CodeStyleConfigurable =
|
||||
object : CodeStyleAbstractConfigurable(baseSettings, modelSettings, "Ludic") {
|
||||
override fun createPanel(settings: CodeStyleSettings): CodeStyleAbstractPanel =
|
||||
object : TabbedLanguageCodeStylePanel(LudicLanguage, currentSettings, settings) {
|
||||
override fun initTabs(settings: CodeStyleSettings) {
|
||||
addIndentOptionsTab(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getConfigurableDisplayName() = "Ludic"
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.lang.documentation.AbstractDocumentationProvider
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.psi.PsiDocumentManager
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.redhat.devtools.lsp4ij.LanguageServerManager
|
||||
import io.ludic.ide.LudicLanguage
|
||||
import org.eclipse.lsp4j.HoverParams
|
||||
import org.eclipse.lsp4j.Position
|
||||
import org.eclipse.lsp4j.TextDocumentIdentifier
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* What the IDE says about a declaration the language server navigated to.
|
||||
*
|
||||
* Go-to-definition hands the IDE a synthetic element for the target, which it
|
||||
* would describe on Cmd/Ctrl-hover as `LSP Symbol "dists" [valley.ludic]`.
|
||||
* This asks ludic-lsp for its hover at that spot instead, so the tooltip is
|
||||
* the declaration and its type: `let dists: words`.
|
||||
*/
|
||||
class LudicTargetDocumentation : AbstractDocumentationProvider() {
|
||||
|
||||
override fun getQuickNavigateInfo(element: PsiElement?, originalElement: PsiElement?): String? {
|
||||
val md = hoverAt(element) ?: return null
|
||||
val code = codeOf(md) ?: return null
|
||||
val where = element?.containingFile?.name?.let { " <span class='grayed'>$it</span>" } ?: ""
|
||||
return "<b>${StringUtil.escapeXmlEntities(code)}</b>$where"
|
||||
}
|
||||
|
||||
override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
|
||||
val md = hoverAt(element) ?: return null
|
||||
val code = codeOf(md)
|
||||
val rest = md.substringAfter("```", "").substringAfter("```", md).trim()
|
||||
val sb = StringBuilder()
|
||||
if (code != null) sb.append("<div class='definition'><pre>").append(StringUtil.escapeXmlEntities(code)).append("</pre></div>")
|
||||
if (rest.isNotEmpty()) sb.append("<div class='content'>").append(LudicDocPageDocumentation.markdownToHtml(rest)).append("</div>")
|
||||
return sb.toString().ifEmpty { null }
|
||||
}
|
||||
|
||||
private fun codeOf(md: String): String? {
|
||||
val m = Regex("```ludic\\n([\\s\\S]*?)\\n```").find(md) ?: return null
|
||||
return m.groupValues[1].trim()
|
||||
}
|
||||
|
||||
/** ludic-lsp's hover markdown at the element's start, or null. */
|
||||
private fun hoverAt(element: PsiElement?): String? {
|
||||
if (element == null || !element.javaClass.name.startsWith("com.redhat.devtools.lsp4ij")) return null
|
||||
val file = element.containingFile ?: return null
|
||||
if (file.language != LudicLanguage) return null
|
||||
val vf = file.virtualFile ?: return null
|
||||
val doc = PsiDocumentManager.getInstance(file.project).getDocument(file) ?: return null
|
||||
val offset = element.textOffset.coerceIn(0, doc.textLength)
|
||||
val line = doc.getLineNumber(offset)
|
||||
val pos = Position(line, offset - doc.getLineStartOffset(line))
|
||||
return runCatching {
|
||||
val item = LanguageServerManager.getInstance(file.project).getLanguageServer("ludic").get(500, TimeUnit.MILLISECONDS)
|
||||
?: return null
|
||||
val params = HoverParams(TextDocumentIdentifier(vf.toNioPath().toUri().toString()), pos)
|
||||
val hover = item.server.textDocumentService.hover(params).get(800, TimeUnit.MILLISECONDS) ?: return null
|
||||
val contents = hover.contents
|
||||
if (contents.isRight) contents.right.value else null
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package io.ludic.ide.editor
|
||||
|
||||
import com.intellij.codeInsight.template.TemplateActionContext
|
||||
import com.intellij.codeInsight.template.TemplateContextType
|
||||
import com.intellij.ide.actions.CreateFileFromTemplateAction
|
||||
import com.intellij.ide.actions.CreateFileFromTemplateDialog
|
||||
import com.intellij.ide.fileTemplates.FileTemplate
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.psi.PsiDirectory
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.util.PsiUtilCore
|
||||
import io.ludic.ide.LudicIcons
|
||||
import io.ludic.ide.LudicLanguage
|
||||
import io.ludic.ide.LudicTokens
|
||||
|
||||
/** Live templates (`prog`, `test`, `handler`, `fn`, …) apply in Ludic code, not inside strings or comments. */
|
||||
class LudicTemplateContext : TemplateContextType("Ludic") {
|
||||
override fun isInContext(ctx: TemplateActionContext): Boolean {
|
||||
val file = ctx.file
|
||||
if (!PsiUtilCore.getLanguageAtOffset(file, ctx.startOffset).isKindOf(LudicLanguage)) return false
|
||||
val at = file.findElementAt((ctx.startOffset - 1).coerceAtLeast(0)) ?: return true
|
||||
val type = at.node.elementType
|
||||
return type != LudicTokens.STRING && type != LudicTokens.COMMENT && type != LudicTokens.CHAR
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File | New | Ludic File. The kinds mirror what a project holds: a program
|
||||
* (the entry), a module imported by one, and a test program for tests/.
|
||||
*/
|
||||
class CreateLudicFileAction : CreateFileFromTemplateAction(), DumbAware {
|
||||
|
||||
override fun buildDialog(project: Project, directory: PsiDirectory, builder: CreateFileFromTemplateDialog.Builder) {
|
||||
builder.setTitle("New Ludic File")
|
||||
.addKind("Program", LudicIcons.FILE, PROGRAM)
|
||||
.addKind("Module", LudicIcons.FILE, MODULE)
|
||||
.addKind("Test", LudicIcons.FILE, TEST)
|
||||
.addKind("Empty file", LudicIcons.FILE, EMPTY)
|
||||
}
|
||||
|
||||
override fun getActionName(directory: PsiDirectory, newName: String, templateName: String) = "Create Ludic file $newName"
|
||||
|
||||
override fun createFileFromTemplate(name: String, template: FileTemplate, dir: PsiDirectory): PsiFile? {
|
||||
val base = name.removeSuffix(".ludic")
|
||||
return createFileFromTemplate(base, template, dir, null, true, emptyMap(), mapOf("PROGRAM_NAME" to programName(base)))
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PROGRAM = "Ludic Program"
|
||||
const val MODULE = "Ludic Module"
|
||||
const val TEST = "Ludic Test"
|
||||
const val EMPTY = "Ludic File"
|
||||
|
||||
/** `enemy_ai` -> `EnemyAi`: a file name as a program name. */
|
||||
fun programName(file: String): String {
|
||||
val parts = file.split('_', '-', '.', ' ').filter { it.isNotEmpty() }
|
||||
val joined = parts.joinToString("") { p -> p.replaceFirstChar { it.uppercaseChar() } }
|
||||
val clean = joined.filter { it.isLetterOrDigit() }
|
||||
return if (clean.isEmpty() || clean[0].isDigit()) "P$clean" else clean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
package io.ludic.ide.project
|
||||
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.psi.PsiFile
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* `package.ludic`, read the way `parse_manifest` (tools/ludic-cli/pkg.ludic)
|
||||
* reads it: one directive per line, `#` comments, double-quoted strings.
|
||||
*/
|
||||
data class LudicManifest(
|
||||
val module: String?,
|
||||
val version: String?,
|
||||
val kind: String?,
|
||||
val requires: List<Require>,
|
||||
val entry: String? = null,
|
||||
val app: Map<String, String> = emptyMap(),
|
||||
val packs: List<String> = emptyList(),
|
||||
val scripts: List<Script> = emptyList(),
|
||||
val hooks: List<Hook> = emptyList(),
|
||||
) {
|
||||
data class Script(val name: String, val command: String, val line: Int)
|
||||
data class Hook(val phase: String, val event: String, val command: String, val line: Int)
|
||||
|
||||
data class Require(val module: String, val version: String, val line: Int)
|
||||
|
||||
/** One `module "…" version "…" hash "…" kind "…" provides "…"` line of the lock. */
|
||||
data class Locked(val module: String, val version: String, val hash: String, val kind: String, val provides: List<String>, val line: Int)
|
||||
|
||||
companion object {
|
||||
const val FILE_NAME = "package.ludic"
|
||||
const val LOCK_NAME = "package.lock.ludic"
|
||||
|
||||
fun isManifest(file: VirtualFile?) = file?.name == FILE_NAME
|
||||
fun isManifest(file: PsiFile?) = file?.name == FILE_NAME
|
||||
|
||||
fun parse(text: CharSequence): LudicManifest {
|
||||
var module: String? = null
|
||||
var version: String? = null
|
||||
var kind: String? = null
|
||||
var entry: String? = null
|
||||
val requires = mutableListOf<Require>()
|
||||
val app = linkedMapOf<String, String>()
|
||||
val packs = mutableListOf<String>()
|
||||
val scripts = mutableListOf<Script>()
|
||||
val hooks = mutableListOf<Hook>()
|
||||
text.lines().forEachIndexed { i, raw ->
|
||||
val words = words(raw)
|
||||
when (words.firstOrNull()) {
|
||||
"package" -> module = words.getOrNull(1)
|
||||
"version" -> version = words.getOrNull(1)
|
||||
"kind" -> kind = words.getOrNull(1)
|
||||
"entry" -> entry = words.getOrNull(1)
|
||||
"require" -> if (words.size > 2) requires += Require(words[1], words[2], i)
|
||||
"pack" -> packs += words.drop(1)
|
||||
"app" -> if (words.size > 2) app[words[1]] = words[2]
|
||||
"script" -> if (words.size > 2) scripts += Script(words[1], words[2], i)
|
||||
"hook" -> if (words.size > 3) hooks += Hook(words[1], words[2], words[3], i)
|
||||
}
|
||||
}
|
||||
return LudicManifest(module, version, kind, requires, entry, app, packs, scripts, hooks)
|
||||
}
|
||||
|
||||
fun parseLock(text: CharSequence): List<Locked> = text.lines().mapIndexedNotNull { i, raw ->
|
||||
val w = words(raw)
|
||||
if (w.size < 2 || w[0] != "module") return@mapIndexedNotNull null
|
||||
val kv = mutableMapOf<String, String>()
|
||||
val provides = mutableListOf<String>()
|
||||
var k = 2
|
||||
while (k + 1 < w.size) {
|
||||
if (w[k] == "provides") provides += w[k + 1] else kv[w[k]] = w[k + 1]
|
||||
k += 2
|
||||
}
|
||||
Locked(w[1], kv["version"].orEmpty(), kv["hash"].orEmpty(), kv["kind"] ?: "source", provides, i)
|
||||
}
|
||||
|
||||
fun isLock(file: VirtualFile?) = file?.name == LOCK_NAME
|
||||
fun isLock(file: PsiFile?) = file?.name == LOCK_NAME
|
||||
|
||||
/** Directives whose values are paths, relative to the package root. */
|
||||
val PATH_DIRECTIVES = setOf("entry", "pack")
|
||||
val PATH_APP_KEYS = setOf("icon", "splash", "native", "out")
|
||||
|
||||
/** The words of a manifest line: bare words and quoted strings, comment dropped. */
|
||||
fun words(line: String): List<String> {
|
||||
val out = mutableListOf<String>()
|
||||
var i = 0
|
||||
while (i < line.length) {
|
||||
val c = line[i]
|
||||
when {
|
||||
c == '#' -> return out
|
||||
c.isWhitespace() -> i++
|
||||
c == '"' -> {
|
||||
// a quoted word, with \" and \\ escapes as the CLI reads them
|
||||
val sb = StringBuilder()
|
||||
i++
|
||||
while (i < line.length && line[i] != '"') {
|
||||
if (line[i] == '\\' && i + 1 < line.length) { sb.append(line[i + 1]); i += 2 } else { sb.append(line[i]); i++ }
|
||||
}
|
||||
out += sb.toString()
|
||||
i++
|
||||
}
|
||||
else -> {
|
||||
val s = i
|
||||
while (i < line.length && !line[i].isWhitespace() && line[i] != '#') i++
|
||||
out += line.substring(s, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the dependencies need fetching, or null when they look current:
|
||||
* no lock yet, a manifest edited after the lock, or nothing linked.
|
||||
*/
|
||||
fun staleReason(dir: File, manifest: LudicManifest): String? {
|
||||
if (manifest.requires.isEmpty()) return null
|
||||
val man = File(dir, FILE_NAME)
|
||||
val lock = File(dir, LOCK_NAME)
|
||||
if (!lock.isFile) return "There is no $LOCK_NAME yet."
|
||||
if (man.lastModified() > lock.lastModified()) return "$FILE_NAME changed after the lock was written."
|
||||
val modules = File(dir, System.getenv("LUDIC_MODULES") ?: "ludic_modules")
|
||||
if (!modules.isDirectory) return "Nothing is linked into ${modules.name}/ yet."
|
||||
return null
|
||||
}
|
||||
|
||||
/** Directive -> what it does, for completion and quick documentation. */
|
||||
val DIRECTIVES = linkedMapOf(
|
||||
"package" to "This package's import path, e.g. \"git.example.com/me/pkg\". Optional for a game.",
|
||||
"version" to "The version this checkout publishes (a git tag vX.Y.Z publishes it).",
|
||||
"kind" to "source (compiled with the game) or prebuilt (ships a dylib per target).",
|
||||
"entry" to "The program `ludic run`, `build` and `bundle` compile, e.g. \"src/game.ludic\".",
|
||||
"provides" to "The Foo.* namespace(s) this package registers. Repeatable.",
|
||||
"targets" to "For a prebuilt package: the targets it ships, e.g. \"native-arm64\" \"wasm32\".",
|
||||
"require" to "A dependency and its minimum version: require \"host/user/pkg\" \"1.0.0\".",
|
||||
"pack" to "An asset root `ludic pack` walks into the .lpak. Repeatable, in order.",
|
||||
"app" to "App bundle metadata for `ludic bundle`: app <key> \"value\".",
|
||||
"script" to "A named command: script \"dev\" \"ludic run --headless\". Run it with `ludic dev` or `ludic script dev`; extra arguments are passed on.",
|
||||
"hook" to "A command around another: hook before|after <command> \"…\". A failing before hook stops the command; after hooks run only when it succeeded.",
|
||||
)
|
||||
|
||||
val HOOK_PHASES = linkedMapOf(
|
||||
"before" to "Runs first; a non-zero exit stops the command.",
|
||||
"after" to "Runs once the command succeeded.",
|
||||
)
|
||||
|
||||
/** The built-in commands a hook can wrap (tools/ludic-cli/scripts.ludic is_hookable). */
|
||||
val HOOK_EVENTS = linkedMapOf(
|
||||
"build" to "ludic build", "run" to "ludic run", "test" to "ludic test", "bundle" to "ludic bundle",
|
||||
"pack" to "ludic pack", "clean" to "ludic clean", "fmt" to "ludic fmt", "get" to "ludic get",
|
||||
"add" to "ludic add", "update" to "ludic update", "verify" to "ludic verify", "vendor" to "ludic vendor",
|
||||
"assets" to "ludic assets", "build-lib" to "ludic build-lib",
|
||||
)
|
||||
|
||||
/** Commands a script name would be shadowed by (the CLI's own). */
|
||||
val BUILTIN_COMMANDS = setOf(
|
||||
"new", "build", "run", "test", "clean", "fmt", "lsp", "doctor", "upgrade", "script", "scripts", "add", "get",
|
||||
"update", "verify", "vendor", "assets", "pack", "bundle", "build-lib", "link-flags", "version", "help", "dev",
|
||||
)
|
||||
|
||||
val KINDS = linkedMapOf(
|
||||
"source" to "Compiled from source together with the program that imports it.",
|
||||
"prebuilt" to "Ships a compiled library per target (see targets); built with `ludic build-lib`.",
|
||||
)
|
||||
|
||||
/** What each lock-file word means. */
|
||||
val LOCK_WORDS = linkedMapOf(
|
||||
"lock" to "The lock format version.",
|
||||
"module" to "A resolved package: its import path, then the pinned facts about it.",
|
||||
"version" to "The version Minimum Version Selection picked.",
|
||||
"hash" to "The content hash of the package in the store; `ludic verify` re-checks it.",
|
||||
"kind" to "source or prebuilt, as the package declares.",
|
||||
"provides" to "A Foo.* namespace the package registers.",
|
||||
)
|
||||
|
||||
fun isSemver(v: String) = Regex("\\d+\\.\\d+\\.\\d+([-+][0-9A-Za-z.-]+)?").matches(v)
|
||||
|
||||
/** The keys `ludic bundle` reads from `app` lines (tools/ludic-cli/bundle.ludic). */
|
||||
val APP_KEYS = linkedMapOf(
|
||||
"name" to "The app's display name.",
|
||||
"id" to "The bundle identifier, e.g. io.example.game.",
|
||||
"version" to "The bundle version (defaults to the package version).",
|
||||
"icon" to "A square PNG the .icns is built from.",
|
||||
"splash" to "An image shown while the game loads; it must be inside a packed asset root.",
|
||||
"splash_bg" to "The splash background colour, \"#rrggbb\".",
|
||||
"category" to "The macOS app category, e.g. public.app-category.games.",
|
||||
"copyright" to "The copyright line shown in Finder.",
|
||||
"min_macos" to "The minimum macOS version, e.g. \"11.0\".",
|
||||
"native" to "A directory of native libraries copied beside the executable.",
|
||||
"out" to "Where the .app is written (default build/<name>.app).",
|
||||
"sign" to "The codesign identity (default \"-\": ad-hoc signing).",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,390 @@
|
|||
package io.ludic.ide.project
|
||||
|
||||
import com.intellij.codeInsight.completion.CompletionContributor
|
||||
import com.intellij.codeInsight.completion.CompletionParameters
|
||||
import com.intellij.codeInsight.completion.CompletionProvider
|
||||
import com.intellij.codeInsight.completion.CompletionResultSet
|
||||
import com.intellij.codeInsight.completion.CompletionType
|
||||
import com.intellij.codeInsight.lookup.LookupElementBuilder
|
||||
import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandler
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.lang.annotation.AnnotationHolder
|
||||
import com.intellij.lang.annotation.Annotator
|
||||
import com.intellij.lang.annotation.HighlightSeverity
|
||||
import com.intellij.lang.documentation.AbstractDocumentationProvider
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.patterns.PlatformPatterns
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.PsiManager
|
||||
import com.intellij.psi.util.elementType
|
||||
import com.intellij.util.ProcessingContext
|
||||
import io.ludic.ide.LudicLanguage
|
||||
import io.ludic.ide.LudicTokens
|
||||
import java.io.File
|
||||
|
||||
private fun isPackageFile(file: PsiFile?) = LudicManifest.isManifest(file) || LudicManifest.isLock(file)
|
||||
|
||||
private fun unquote(s: String) = s.removePrefix("\"").removeSuffix("\"")
|
||||
|
||||
/**
|
||||
* What a string on a manifest line points at: a file or directory for path
|
||||
* values (`entry`, `pack`, `app icon|splash|native|out`), a package directory
|
||||
* for `require` / lock `module` lines. Null for anything else.
|
||||
*/
|
||||
internal fun manifestTarget(element: PsiElement): VirtualFile? = manifestTargetSpec(element)?.let { spec ->
|
||||
when (spec) {
|
||||
is TargetSpec.Path -> spec.relative?.let { rel -> spec.base?.findFileByRelativePath(rel) }
|
||||
?: spec.absolute?.let { LocalFileSystem.getInstance().findFileByIoFile(it) }
|
||||
is TargetSpec.Package -> spec.dir?.let { LocalFileSystem.getInstance().findFileByIoFile(it) }
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TargetSpec {
|
||||
class Path(val base: VirtualFile?, val relative: String?, val absolute: File?) : TargetSpec()
|
||||
class Package(val dir: File?) : TargetSpec()
|
||||
}
|
||||
|
||||
private fun manifestTargetSpec(element: PsiElement): TargetSpec? {
|
||||
if (element.elementType != LudicTokens.STRING) return null
|
||||
val file = element.containingFile ?: return null
|
||||
if (!isPackageFile(file)) return null
|
||||
val line = ManifestLine.at(element) ?: return null
|
||||
val value = unquote(element.text)
|
||||
val words = line.words
|
||||
val isPath = when (line.directive) {
|
||||
in LudicManifest.PATH_DIRECTIVES -> true
|
||||
"app" -> words.getOrNull(1) in LudicManifest.PATH_APP_KEYS && words.getOrNull(2) == value
|
||||
else -> false
|
||||
}
|
||||
if (isPath && value.isNotBlank()) {
|
||||
return if (File(value).isAbsolute) TargetSpec.Path(null, null, File(value))
|
||||
else TargetSpec.Path(file.originalFile.virtualFile?.parent, value, null)
|
||||
}
|
||||
if ((line.directive == "require" || line.directive == "module") && words.getOrNull(1) == value) {
|
||||
return TargetSpec.Package(packageDir(file, value))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Where a required package is on disk: ludic_modules/, vendor/, or the toolchain. */
|
||||
internal fun packageDir(file: PsiFile, module: String): File? {
|
||||
val dir = file.virtualFile?.parent?.path ?: return null
|
||||
val modules = System.getenv("LUDIC_MODULES") ?: "ludic_modules"
|
||||
return listOfNotNull(
|
||||
File(File(dir, modules), module),
|
||||
File(File(dir, "vendor"), module),
|
||||
io.ludic.ide.LudicToolchain.home(file.project)?.let { File(File(it, "packages"), module) },
|
||||
).firstOrNull { it.exists() }
|
||||
}
|
||||
|
||||
/** Cmd/Ctrl-click on a manifest path or package opens it. */
|
||||
class LudicManifestGotoHandler : GotoDeclarationHandler {
|
||||
override fun getGotoDeclarationTargets(sourceElement: PsiElement?, offset: Int, editor: Editor?): Array<PsiElement>? {
|
||||
val el = sourceElement ?: return null
|
||||
val vf = manifestTarget(el) ?: return null
|
||||
val psi = PsiManager.getInstance(el.project)
|
||||
var node = vf
|
||||
// a package directory opens on its manifest, where its story starts
|
||||
if (vf.isDirectory) vf.findChild(LudicManifest.FILE_NAME)?.let { node = it }
|
||||
val found: PsiElement? = if (node.isDirectory) psi.findDirectory(node) else psi.findFile(node)
|
||||
return found?.let { arrayOf(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private val WORD = Regex("[A-Za-z_][A-Za-z0-9_]*")
|
||||
private val WORD_OR_DASH = Regex("[A-Za-z_][A-Za-z0-9_-]*")
|
||||
|
||||
/** Mistakes the CLI would otherwise report late, or silently ignore. */
|
||||
class LudicManifestAnnotator : Annotator {
|
||||
override fun annotate(element: PsiElement, holder: AnnotationHolder) {
|
||||
val file = element.containingFile ?: return
|
||||
if (!isPackageFile(file) || element.firstChild != null) return
|
||||
val lock = LudicManifest.isLock(file)
|
||||
val line = ManifestLine.at(element) ?: return
|
||||
when {
|
||||
element.elementType == LudicTokens.STRING -> checkString(element, line, file, lock, holder)
|
||||
line.startsWith(element) && WORD.matches(element.text) -> {
|
||||
val known = if (lock) LudicManifest.LOCK_WORDS.keys else LudicManifest.DIRECTIVES.keys
|
||||
if (element.text !in known) {
|
||||
holder.newAnnotation(HighlightSeverity.WARNING, "Unknown directive '${element.text}': ludic ignores this line")
|
||||
.range(element).create()
|
||||
}
|
||||
}
|
||||
!lock && line.directive == "hook" && !line.startsWith(element) && WORD_OR_DASH.matches(element.text) -> {
|
||||
val w = line.words
|
||||
if (w.getOrNull(1) == element.text && element.text !in LudicManifest.HOOK_PHASES) {
|
||||
holder.newAnnotation(HighlightSeverity.ERROR, "A hook runs before or after").range(element).create()
|
||||
} else if (w.getOrNull(2) == element.text && element.text !in LudicManifest.HOOK_EVENTS &&
|
||||
LudicManifest.parse(file.text).scripts.none { it.name == element.text }) {
|
||||
holder.newAnnotation(HighlightSeverity.ERROR, "No command or script named '${element.text}': hooks wrap ${LudicManifest.HOOK_EVENTS.keys.joinToString()} or a script")
|
||||
.range(element).create()
|
||||
}
|
||||
}
|
||||
!lock && line.directive == "kind" && line.words.getOrNull(1) == element.text && !line.startsWith(element) -> {
|
||||
if (element.text !in LudicManifest.KINDS) {
|
||||
holder.newAnnotation(HighlightSeverity.ERROR, "kind must be one of: ${LudicManifest.KINDS.keys.joinToString()}")
|
||||
.range(element).create()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkString(element: PsiElement, line: ManifestLine, file: PsiFile, lock: Boolean, holder: AnnotationHolder) {
|
||||
val value = unquote(element.text)
|
||||
val words = line.words
|
||||
if (!lock && line.directive == "version" && words.getOrNull(1) == value && !LudicManifest.isSemver(value)) {
|
||||
holder.newAnnotation(HighlightSeverity.WARNING, "Not a semantic version (MAJOR.MINOR.PATCH): a git tag v$value will not resolve")
|
||||
.range(element).create()
|
||||
return
|
||||
}
|
||||
if (!lock && line.directive == "require" && words.getOrNull(2) == value && !LudicManifest.isSemver(value)) {
|
||||
holder.newAnnotation(HighlightSeverity.WARNING, "A minimum version is MAJOR.MINOR.PATCH")
|
||||
.range(element).create()
|
||||
return
|
||||
}
|
||||
if (!lock && line.directive == "script" && words.getOrNull(1) == value && value in LudicManifest.BUILTIN_COMMANDS) {
|
||||
holder.newAnnotation(HighlightSeverity.WARNING, "'ludic $value' is a built-in command: run this script with `ludic script $value`")
|
||||
.range(element).create()
|
||||
return
|
||||
}
|
||||
if (!lock && line.directive == "app" && words.getOrNull(1) == "splash_bg" && words.getOrNull(2) == value &&
|
||||
!Regex("#[0-9a-fA-F]{6}").matches(value)) {
|
||||
holder.newAnnotation(HighlightSeverity.WARNING, "splash_bg is a colour: \"#rrggbb\"").range(element).create()
|
||||
return
|
||||
}
|
||||
val spec = manifestTargetSpec(element) ?: return
|
||||
if (spec is TargetSpec.Package) {
|
||||
if (spec.dir == null) {
|
||||
holder.newAnnotation(HighlightSeverity.WEAK_WARNING, "$value is not fetched yet: run `ludic get`")
|
||||
.range(element).create()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (manifestTarget(element) == null) {
|
||||
// `out` names something bundle creates
|
||||
if (line.directive == "app" && words.getOrNull(1) == "out") return
|
||||
holder.newAnnotation(HighlightSeverity.ERROR, "No such file or directory: $value")
|
||||
.range(TextRange(element.textRange.startOffset + 1, element.textRange.endOffset - 1)).create()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Completion in package.ludic: directives at the start of a line, then their values. */
|
||||
class LudicManifestCompletion : CompletionContributor() {
|
||||
init {
|
||||
extend(CompletionType.BASIC, PlatformPatterns.psiElement().withLanguage(LudicLanguage), object : CompletionProvider<CompletionParameters>() {
|
||||
override fun addCompletions(parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet) {
|
||||
val file = parameters.originalFile
|
||||
if (!LudicManifest.isManifest(file)) {
|
||||
if (LudicManifest.isLock(file)) result.stopHere()
|
||||
return
|
||||
}
|
||||
val doc = parameters.editor.document
|
||||
val offset = parameters.offset
|
||||
val line = doc.getLineNumber(offset)
|
||||
val before = doc.getText(TextRange(doc.getLineStartOffset(line), offset))
|
||||
if (before.contains('#')) return
|
||||
val words = LudicManifest.words(before)
|
||||
val typing = before.isNotEmpty() && !before.last().isWhitespace()
|
||||
val inString = before.count { it == '"' } % 2 == 1
|
||||
val done = if (typing && !inString) words.dropLast(1) else if (inString) words.dropLast(1) else words
|
||||
val dir = file.virtualFile?.parent?.path?.let(::File)
|
||||
when {
|
||||
done.isEmpty() -> for ((d, help) in LudicManifest.DIRECTIVES) {
|
||||
result.addElement(LookupElementBuilder.create("$d ").withPresentableText(d).bold().withTypeText(help, true).withIcon(LudicPackageIcons.MANIFEST))
|
||||
}
|
||||
done == listOf("app") -> for ((k, help) in LudicManifest.APP_KEYS) {
|
||||
result.addElement(LookupElementBuilder.create("$k ").withPresentableText(k).withTypeText(help, true))
|
||||
}
|
||||
done == listOf("kind") -> for ((k, help) in LudicManifest.KINDS) {
|
||||
result.addElement(LookupElementBuilder.create(k).withTypeText(help, true))
|
||||
}
|
||||
dir != null && done == listOf("entry") ->
|
||||
paths(dir, inString) { it.isFile && it.extension == "ludic" && isProgram(it) }.forEach(result::addElement)
|
||||
dir != null && done.firstOrNull() == "pack" ->
|
||||
paths(dir, inString) { it.isDirectory }.forEach(result::addElement)
|
||||
dir != null && done.size == 2 && done[0] == "app" && done[1] in setOf("icon", "splash") ->
|
||||
paths(dir, inString) { it.isFile && it.extension.lowercase() in setOf("png", "jpg", "jpeg") }.forEach(result::addElement)
|
||||
dir != null && done.size == 2 && done[0] == "app" && done[1] == "native" ->
|
||||
paths(dir, inString) { it.isDirectory }.forEach(result::addElement)
|
||||
done == listOf("hook") -> for ((p, help) in LudicManifest.HOOK_PHASES) {
|
||||
result.addElement(LookupElementBuilder.create("$p ").withPresentableText(p).withTypeText(help, true))
|
||||
}
|
||||
done.size == 2 && done[0] == "hook" -> {
|
||||
for ((ev, cmd) in LudicManifest.HOOK_EVENTS) {
|
||||
result.addElement(LookupElementBuilder.create("$ev ").withPresentableText(ev).withTypeText(cmd, true))
|
||||
}
|
||||
for (s in LudicManifest.parse(doc.charsSequence).scripts) {
|
||||
result.addElement(LookupElementBuilder.create("${s.name} ").withPresentableText(s.name).withTypeText("script: ${s.command}", true).withIcon(AllIcons.Nodes.Console))
|
||||
}
|
||||
}
|
||||
done.size == 2 && done[0] == "app" && done[1] == "category" -> CATEGORIES.forEach {
|
||||
result.addElement(LookupElementBuilder.create(if (inString) it else "\"$it\""))
|
||||
}
|
||||
}
|
||||
result.stopHere()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun isProgram(f: File) = runCatching { f.useLines { l -> l.any { it.startsWith("program ") } } }.getOrDefault(false)
|
||||
|
||||
private fun paths(root: File, inString: Boolean, keep: (File) -> Boolean) =
|
||||
root.walkTopDown()
|
||||
.onEnter { d -> d == root || !(d.name.startsWith(".") || d.name == "build" || d.name == "ludic_modules" || d.name == "node_modules") }
|
||||
.maxDepth(4)
|
||||
.filter { it != root && keep(it) }
|
||||
.take(200)
|
||||
.map { f ->
|
||||
val rel = f.relativeTo(root).path
|
||||
LookupElementBuilder.create(if (inString) rel else "\"$rel\"")
|
||||
.withPresentableText(rel)
|
||||
.withIcon(if (f.isDirectory) AllIcons.Nodes.Folder else AllIcons.FileTypes.Any_type)
|
||||
}
|
||||
.toList()
|
||||
|
||||
private companion object {
|
||||
val CATEGORIES = listOf(
|
||||
"public.app-category.games", "public.app-category.action-games", "public.app-category.adventure-games",
|
||||
"public.app-category.arcade-games", "public.app-category.puzzle-games", "public.app-category.role-playing-games",
|
||||
"public.app-category.simulation-games", "public.app-category.strategy-games", "public.app-category.sports-games",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick documentation (F1 / hover) in package.ludic and its lock: what each
|
||||
* directive and value means, and for any `app` line a preview of the app all
|
||||
* the `app` lines describe together.
|
||||
*/
|
||||
class LudicManifestDocumentation : AbstractDocumentationProvider() {
|
||||
override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
|
||||
val el = originalElement ?: element ?: return null
|
||||
val file = el.containingFile ?: return null
|
||||
if (!isPackageFile(file)) return null
|
||||
val line = ManifestLine.at(el) ?: return null
|
||||
if (LudicManifest.isLock(file)) return lockDoc(file, line)
|
||||
return when (line.directive) {
|
||||
"app" -> appPreview(file, highlight = line.words.getOrNull(1))
|
||||
null -> null
|
||||
else -> directiveDoc(file, line)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCustomDocumentationElement(editor: Editor, file: PsiFile, contextElement: PsiElement?, targetOffset: Int): PsiElement? =
|
||||
if (isPackageFile(file)) contextElement else null
|
||||
|
||||
private fun esc(s: String) = StringUtil.escapeXmlEntities(s)
|
||||
|
||||
private fun directiveDoc(file: PsiFile, line: ManifestLine): String? {
|
||||
val d = line.directive ?: return null
|
||||
val help = LudicManifest.DIRECTIVES[d] ?: return null
|
||||
val value = line.words.getOrNull(1)
|
||||
val sb = StringBuilder("<div class='definition'><pre>").append(esc(line.text.substringBefore('#').trim())).append("</pre></div>")
|
||||
sb.append("<div class='content'><p>").append(esc(help)).append("</p>")
|
||||
when (d) {
|
||||
"kind" -> LudicManifest.KINDS[value]?.let { sb.append("<p><b>").append(esc(value!!)).append("</b>: ").append(esc(it)).append("</p>") }
|
||||
"version" -> if (value != null) sb.append("<p>Published by the git tag <code>v").append(esc(value)).append("</code>.</p>")
|
||||
"package" -> if (value != null) sb.append("<p>Others depend on it with <code>require \"").append(esc(value)).append("\" \"").append(esc(LudicManifest.parse(file.text).version ?: "1.0.0")).append("\"</code>.</p>")
|
||||
"require" -> requireDetail(file, line, sb)
|
||||
"hook" -> {
|
||||
line.words.getOrNull(1)?.let { p -> LudicManifest.HOOK_PHASES[p]?.let { sb.append("<p><b>").append(esc(p)).append("</b>: ").append(esc(it)).append("</p>") } }
|
||||
line.words.getOrNull(2)?.let { ev ->
|
||||
val what = LudicManifest.HOOK_EVENTS[ev] ?: LudicManifest.parse(file.text).scripts.firstOrNull { it.name == ev }?.let { "the script ${it.name}: ${it.command}" }
|
||||
if (what != null) sb.append("<p>Wraps <code>").append(esc(what)).append("</code></p>")
|
||||
}
|
||||
sb.append("<p>Runs through the shell in the package root with <code>LUDIC_EVENT</code>, <code>LUDIC_PHASE</code> and <code>LUDIC_PACKAGE_ROOT</code> set.</p>")
|
||||
}
|
||||
"script" -> {
|
||||
val name = line.words.getOrNull(1)
|
||||
if (name != null) {
|
||||
val how = if (name in LudicManifest.BUILTIN_COMMANDS) "ludic script $name" else "ludic $name"
|
||||
sb.append("<p>Run with <code>").append(esc(how)).append(" [args]</code>.</p>")
|
||||
val hooks = LudicManifest.parse(file.text).hooks.filter { it.event == name }
|
||||
hooks.forEach { sb.append("<p>").append(esc(it.phase)).append(": <code>").append(esc(it.command)).append("</code></p>") }
|
||||
}
|
||||
}
|
||||
"entry", "pack" -> line.words.drop(1).forEach { p ->
|
||||
val f = resolveManifestPath(file, p)
|
||||
sb.append("<p><code>").append(esc(p)).append("</code> — ")
|
||||
.append(if (f?.exists() == true) (if (f.isDirectory) "directory" else "file") else "<b>missing</b>").append("</p>")
|
||||
}
|
||||
}
|
||||
sb.append("</div>")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun requireDetail(file: PsiFile, line: ManifestLine, sb: StringBuilder) {
|
||||
val module = line.words.getOrNull(1) ?: return
|
||||
val lockFile = file.virtualFile?.parent?.findChild(LudicManifest.LOCK_NAME)
|
||||
val locked = lockFile?.let { runCatching { LudicManifest.parseLock(String(it.contentsToByteArray())) }.getOrNull() }
|
||||
?.firstOrNull { it.module == module }
|
||||
if (locked == null) sb.append("<p>Not in the lock yet: run <code>ludic get</code>.</p>")
|
||||
else sb.append("<p>Locked at <b>").append(esc(locked.version)).append("</b> (").append(esc(locked.kind)).append(")<br>hash <code>")
|
||||
.append(esc(locked.hash.take(16))).append("…</code></p>")
|
||||
val dir = packageDir(file, module)
|
||||
sb.append("<p>").append(if (dir != null) "Linked at <code>${esc(dir.path)}</code>" else "Not linked into ludic_modules/").append("</p>")
|
||||
}
|
||||
|
||||
private fun lockDoc(file: PsiFile, line: ManifestLine): String? {
|
||||
if (line.directive != "module") return line.directive?.let { LudicManifest.LOCK_WORDS[it] }?.let { "<div class='content'>${esc(it)}</div>" }
|
||||
val m = LudicManifest.parseLock(line.text).firstOrNull() ?: return null
|
||||
val dir = packageDir(file, m.module)
|
||||
return buildString {
|
||||
append("<div class='definition'><pre>").append(esc(m.module)).append("</pre></div><table class='sections'>")
|
||||
row("Version", esc(m.version))
|
||||
row("Kind", esc(m.kind))
|
||||
if (m.provides.isNotEmpty()) row("Provides", m.provides.joinToString { "<code>${esc(it)}.*</code>" })
|
||||
row("Hash", "<code>${esc(m.hash)}</code>")
|
||||
row("On disk", if (dir != null) "<code>${esc(dir.path)}</code>" else "not linked — run <code>ludic get</code>")
|
||||
append("</table>")
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.row(k: String, v: String) {
|
||||
append("<tr><td valign='top' class='section'><p>").append(k).append("</p></td><td valign='top'>").append(v).append("</td></tr>")
|
||||
}
|
||||
|
||||
/** The app the `app` lines describe, as `ludic bundle` would build it. */
|
||||
private fun appPreview(file: PsiFile, highlight: String?): String {
|
||||
val m = LudicManifest.parse(file.text)
|
||||
val app = m.app
|
||||
val name = app["name"] ?: m.module?.substringAfterLast('/') ?: file.virtualFile?.parent?.name ?: "app"
|
||||
fun img(key: String, size: Int): String? {
|
||||
val f = app[key]?.let { resolveManifestPath(file, it) }?.takeIf { it.isFile } ?: return null
|
||||
return "<img src='${f.toURI()}' width='$size' height='$size'>"
|
||||
}
|
||||
fun mark(k: String, v: String) = if (k == highlight) "<b>$v</b>" else v
|
||||
return buildString {
|
||||
append("<div class='definition'><pre>").append(esc(name)).append(".app</pre></div>")
|
||||
append("<div class='content'><table><tr><td valign='top'>")
|
||||
append(img("icon", 64) ?: "")
|
||||
append("</td><td valign='top'><p><b>").append(esc(name)).append("</b></p><p>")
|
||||
append(esc(app["id"] ?: "(no bundle id)")).append("<br>version ")
|
||||
append(esc(app["version"] ?: m.version ?: "0.1.0"))
|
||||
app["category"]?.let { append("<br>").append(esc(it.removePrefix("public.app-category."))) }
|
||||
app["min_macos"]?.let { append("<br>macOS ").append(esc(it)).append("+") }
|
||||
append("</p></td></tr></table>")
|
||||
app["splash"]?.let { s ->
|
||||
val f = resolveManifestPath(file, s)
|
||||
val bg = app["splash_bg"] ?: "#000000"
|
||||
append("<p>Splash</p><table><tr><td bgcolor='").append(esc(bg)).append("'>")
|
||||
if (f?.isFile == true) append("<img src='").append(f.toURI()).append("' width='240'>") else append(" missing: ").append(esc(s))
|
||||
append("</td></tr></table>")
|
||||
}
|
||||
append("</div><table class='sections'>")
|
||||
for ((k, help) in LudicManifest.APP_KEYS) {
|
||||
val v = app[k] ?: continue
|
||||
val shown = if (k == "splash_bg") "<span style='background-color:${esc(v)}'> </span> ${esc(v)}" else esc(v)
|
||||
row(mark(k, k), mark(k, shown) + "<br><span class='grayed'>${esc(help)}</span>")
|
||||
}
|
||||
append("</table>")
|
||||
m.entry?.let { append("<div class='bottom'>entry ").append(esc(it)).append("</div>") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package io.ludic.ide.project
|
||||
|
||||
import com.intellij.execution.configurations.GeneralCommandLine
|
||||
import com.intellij.execution.util.ExecUtil
|
||||
import com.intellij.ide.util.projectWizard.WizardContext
|
||||
import com.intellij.ide.wizard.AbstractNewProjectWizardStep
|
||||
import com.intellij.ide.wizard.GeneratorNewProjectWizard
|
||||
import com.intellij.ide.wizard.GeneratorNewProjectWizardBuilderAdapter
|
||||
import com.intellij.ide.wizard.GitNewProjectWizardStep
|
||||
import com.intellij.ide.wizard.NewProjectWizardBaseData.Companion.baseData
|
||||
import com.intellij.ide.wizard.NewProjectWizardBaseStep
|
||||
import com.intellij.ide.wizard.NewProjectWizardChainStep.Companion.nextStep
|
||||
import com.intellij.ide.wizard.NewProjectWizardStep
|
||||
import com.intellij.ide.wizard.RootNewProjectWizardStep
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.module.GeneralModuleType
|
||||
import com.intellij.openapi.module.ModuleTypeManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.Messages
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.ui.dsl.builder.Panel
|
||||
import io.ludic.ide.LudicIcons
|
||||
import io.ludic.ide.LudicToolchain
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
/**
|
||||
* File | New | Project | Ludic. The project is whatever `ludic new` makes,
|
||||
* so the IDE never carries its own copy of the template to drift from the CLI.
|
||||
*/
|
||||
class LudicNewProjectWizard : GeneratorNewProjectWizard {
|
||||
override val id = "Ludic"
|
||||
override val name = "Ludic"
|
||||
override val icon = LudicIcons.FILE
|
||||
override val description = "A Ludic game: package.ludic, src/main.ludic and a test, as <code>ludic new</code> creates them."
|
||||
|
||||
override fun createStep(context: WizardContext): NewProjectWizardStep =
|
||||
RootNewProjectWizardStep(context)
|
||||
.nextStep(::NewProjectWizardBaseStep)
|
||||
.nextStep(::GitNewProjectWizardStep)
|
||||
.nextStep(::LudicProjectStep)
|
||||
}
|
||||
|
||||
/** A file a freshly created project should show first. */
|
||||
internal val OPEN_ON_START = Key.create<String>("ludic.openOnStart")
|
||||
|
||||
class LudicModuleBuilder : GeneratorNewProjectWizardBuilderAdapter(LudicNewProjectWizard())
|
||||
|
||||
private class LudicProjectStep(parent: NewProjectWizardStep) : AbstractNewProjectWizardStep(parent) {
|
||||
|
||||
override fun setupUI(builder: Panel) {
|
||||
val cli = LudicToolchain.cli()
|
||||
builder.row {
|
||||
if (cli != null) comment("Toolchain: $cli")
|
||||
else comment("The <code>ludic</code> command was not found. ${LudicToolchain.INSTALL_HINT}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun setupProject(project: Project) {
|
||||
val data = baseData ?: return
|
||||
val dir = File(data.path, data.name)
|
||||
val error = scaffold(dir, data.name)
|
||||
if (error != null) {
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
Messages.showErrorDialog(project, error, "Could Not Create the Ludic Project")
|
||||
}
|
||||
}
|
||||
// a plain module whose content root is the project directory, so the
|
||||
// Project view shows the files
|
||||
val builder = ModuleTypeManager.getInstance().findByID(GeneralModuleType.TYPE_ID).createModuleBuilder()
|
||||
builder.name = data.name
|
||||
builder.contentEntryPath = dir.path
|
||||
builder.moduleFilePath = File(File(dir, ".idea"), "${data.name}.iml").path
|
||||
builder.commit(project)
|
||||
|
||||
// LudicToolchainCheck opens it once the project window is up
|
||||
project.putUserData(OPEN_ON_START, File(dir, "src/main.ludic").path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `ludic new` in a scratch directory and move the result into [dir],
|
||||
* which the wizard may already have created (with .idea/ inside). Returns an
|
||||
* error message, or null on success.
|
||||
*/
|
||||
internal fun scaffold(dir: File, displayName: String, cli: String? = LudicToolchain.cli()): String? {
|
||||
cli ?: return "The 'ludic' command was not found.\n${LudicToolchain.INSTALL_HINT}"
|
||||
val slug = packageSlug(displayName)
|
||||
val scratch = Files.createTempDirectory("ludic-new").toFile()
|
||||
try {
|
||||
val out = ExecUtil.execAndGetOutput(
|
||||
GeneralCommandLine(cli, "new", slug).withWorkDirectory(scratch).withCharset(Charsets.UTF_8),
|
||||
60_000
|
||||
)
|
||||
val made = File(scratch, slug)
|
||||
if (out.exitCode != 0 || !made.isDirectory) {
|
||||
return "ludic new $slug failed (exit ${out.exitCode}):\n${out.stderr.ifBlank { out.stdout }}"
|
||||
}
|
||||
dir.mkdirs()
|
||||
made.walkTopDown().filter { it.isFile }.forEach { src ->
|
||||
val dst = File(dir, src.relativeTo(made).path)
|
||||
if (!dst.exists()) {
|
||||
dst.parentFile.mkdirs()
|
||||
src.copyTo(dst)
|
||||
}
|
||||
}
|
||||
// keep the empty directories (assets/) the template promises
|
||||
made.walkTopDown().filter { it.isDirectory }.forEach { File(dir, it.relativeTo(made).path).mkdirs() }
|
||||
LocalFileSystem.getInstance().refreshAndFindFileByIoFile(dir)?.refresh(false, true)
|
||||
return null
|
||||
} finally {
|
||||
scratch.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
/** A name `ludic new` accepts: letters, digits, '-', '_' and '.'. */
|
||||
internal fun packageSlug(name: String): String {
|
||||
val s = name.trim().map { if (it.isLetterOrDigit() || it in "-_.") it else '-' }.joinToString("")
|
||||
.trim('-', '.').ifEmpty { "game" }
|
||||
return s
|
||||
}
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
package io.ludic.ide.project
|
||||
|
||||
import com.intellij.execution.lineMarker.RunLineMarkerContributor
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ide.IconProvider
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.fileEditor.FileEditor
|
||||
import com.intellij.openapi.fileEditor.OpenFileDescriptor
|
||||
import com.intellij.openapi.options.ShowSettingsUtil
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.openapi.vfs.newvfs.BulkFileListener
|
||||
import com.intellij.openapi.vfs.newvfs.events.VFileEvent
|
||||
import com.intellij.psi.PsiDocumentManager
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.ui.EditorNotificationPanel
|
||||
import com.intellij.ui.EditorNotificationProvider
|
||||
import com.intellij.ui.EditorNotifications
|
||||
import io.ludic.ide.LudicConfigurable
|
||||
import io.ludic.ide.LudicToolchain
|
||||
import io.ludic.ide.run.LudicCliAction
|
||||
import io.ludic.ide.run.LudicCommand
|
||||
import java.io.File
|
||||
import java.util.function.Function
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JComponent
|
||||
|
||||
object LudicPackageIcons {
|
||||
val MANIFEST: Icon = IconLoader.getIcon("/icons/ludicPackage.svg", LudicPackageIcons::class.java)
|
||||
}
|
||||
|
||||
/** Run a `ludic` command in the package that owns [dir]. */
|
||||
internal fun runLudic(project: Project, command: LudicCommand, args: String, dir: String) {
|
||||
FileDocumentManager.getInstance().saveAllDocuments()
|
||||
LudicCliAction.run(project, command, args, dir)
|
||||
}
|
||||
|
||||
internal fun cliAction(text: String, icon: Icon?, project: Project, dir: String, command: LudicCommand, args: String = ""): AnAction =
|
||||
object : AnAction(text, null, icon), DumbAware {
|
||||
override fun actionPerformed(e: AnActionEvent) = runLudic(project, command, args, dir)
|
||||
}
|
||||
|
||||
/** The whole line an element sits on, and the words on it. */
|
||||
internal data class ManifestLine(val index: Int, val range: TextRange, val text: String, val words: List<String>) {
|
||||
val directive: String? get() = words.firstOrNull()
|
||||
|
||||
companion object {
|
||||
fun at(element: PsiElement): ManifestLine? {
|
||||
val file = element.containingFile ?: return null
|
||||
val doc = PsiDocumentManager.getInstance(file.project).getDocument(file) ?: return null
|
||||
return at(doc, element.textOffset)
|
||||
}
|
||||
|
||||
fun at(doc: Document, offset: Int): ManifestLine {
|
||||
val line = doc.getLineNumber(offset.coerceIn(0, doc.textLength))
|
||||
val range = TextRange(doc.getLineStartOffset(line), doc.getLineEndOffset(line))
|
||||
val text = doc.getText(range)
|
||||
return ManifestLine(line, range, text, LudicManifest.words(text))
|
||||
}
|
||||
}
|
||||
|
||||
/** Is the element the first word on its line? */
|
||||
fun startsWith(element: PsiElement) = text.substring(0, element.textOffset - range.startOffset).isBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar across the top of package.ludic and package.lock.ludic.
|
||||
*
|
||||
* On the manifest: the package's identity and the commands that act on it,
|
||||
* turning into a prompt to fetch when the lock is missing or older than the
|
||||
* manifest, the way a build-file sync does. On the lock: a reminder that it is
|
||||
* generated, with the commands that regenerate and check it.
|
||||
*/
|
||||
class LudicPackageNotificationProvider : EditorNotificationProvider, DumbAware {
|
||||
override fun collectNotificationData(project: Project, file: VirtualFile): Function<in FileEditor, out JComponent?>? {
|
||||
val isManifest = LudicManifest.isManifest(file)
|
||||
if (!isManifest && !LudicManifest.isLock(file)) return null
|
||||
val dir = file.parent?.path ?: return null
|
||||
val cli = LudicToolchain.cli(project)
|
||||
if (!isManifest) return lockBanner(project, dir, cli != null)
|
||||
|
||||
val text = FileDocumentManager.getInstance().getCachedDocument(file)?.charsSequence
|
||||
?: runCatching { String(file.contentsToByteArray(), Charsets.UTF_8) }.getOrNull() ?: return null
|
||||
val manifest = LudicManifest.parse(text)
|
||||
val stale = LudicManifest.staleReason(File(dir), manifest)
|
||||
return Function { editor ->
|
||||
val panel = if (stale != null) EditorNotificationPanel(editor, EditorNotificationPanel.Status.Warning)
|
||||
else EditorNotificationPanel(editor, EditorNotificationPanel.Status.Info)
|
||||
panel.icon(LudicPackageIcons.MANIFEST)
|
||||
val name = manifest.module ?: File(dir).name
|
||||
val ver = manifest.version?.let { " $it" } ?: ""
|
||||
val deps = manifest.requires.size.let { if (it == 1) "1 dependency" else "$it dependencies" }
|
||||
val entry = manifest.entry?.let { " · entry $it" } ?: ""
|
||||
panel.text = when {
|
||||
cli == null -> "Ludic package $name$ver — the 'ludic' command was not found."
|
||||
stale != null -> "$stale Fetch the dependencies so builds and code insight see them."
|
||||
else -> "Ludic package $name$ver · $deps$entry"
|
||||
}
|
||||
if (cli != null) {
|
||||
val get = if (stale != null) "Fetch Dependencies" else "Fetch"
|
||||
panel.createActionLabel(get) { runLudic(project, LudicCommand.CUSTOM, "get", dir) }
|
||||
if (manifest.requires.isNotEmpty()) {
|
||||
panel.createActionLabel("Update All") { runLudic(project, LudicCommand.CUSTOM, "update", dir) }
|
||||
panel.createActionLabel("Verify") { runLudic(project, LudicCommand.CUSTOM, "verify", dir) }
|
||||
}
|
||||
panel.createActionLabel("Run") { runLudic(project, LudicCommand.RUN, "", dir) }
|
||||
panel.createActionLabel("Test") { runLudic(project, LudicCommand.TEST, "", dir) }
|
||||
panel.createActionLabel("Build") { runLudic(project, LudicCommand.BUILD, "", dir) }
|
||||
panel.createActionLabel("Bundle") { runLudic(project, LudicCommand.CUSTOM, "bundle", dir) }
|
||||
manifest.scripts.take(4).forEach { s ->
|
||||
panel.createActionLabel("▶ ${s.name}") { runLudic(project, LudicCommand.CUSTOM, "script ${s.name}", dir) }
|
||||
}
|
||||
} else {
|
||||
panel.createActionLabel("Set Paths…") {
|
||||
ShowSettingsUtil.getInstance().showSettingsDialog(project, LudicConfigurable::class.java)
|
||||
}
|
||||
}
|
||||
panel
|
||||
}
|
||||
}
|
||||
|
||||
private fun lockBanner(project: Project, dir: String, hasCli: Boolean) = Function<FileEditor, JComponent?> { editor ->
|
||||
val panel = EditorNotificationPanel(editor, EditorNotificationPanel.Status.Info)
|
||||
panel.icon(LudicPackageIcons.MANIFEST)
|
||||
panel.text = "Generated by `ludic get` — edit package.ludic, then fetch again."
|
||||
panel.createActionLabel("Open package.ludic") {
|
||||
LocalFileSystem.getInstance().findFileByPath("$dir/${LudicManifest.FILE_NAME}")?.let {
|
||||
OpenFileDescriptor(project, it).navigate(true)
|
||||
}
|
||||
}
|
||||
if (hasCli) {
|
||||
panel.createActionLabel("Regenerate") { runLudic(project, LudicCommand.CUSTOM, "get", dir) }
|
||||
panel.createActionLabel("Verify Hashes") { runLudic(project, LudicCommand.CUSTOM, "verify", dir) }
|
||||
panel.createActionLabel("Vendor") { runLudic(project, LudicCommand.CUSTOM, "vendor", dir) }
|
||||
}
|
||||
panel
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the bars and the library list current as manifests, locks and linked packages change. */
|
||||
class LudicManifestWatcher(private val project: Project) : BulkFileListener {
|
||||
override fun after(events: List<VFileEvent>) {
|
||||
val relevant = events.any { e ->
|
||||
val n = e.path.substringAfterLast('/')
|
||||
n == LudicManifest.FILE_NAME || n == LudicManifest.LOCK_NAME || n == "ludic_modules" || n == "vendor"
|
||||
}
|
||||
if (relevant && !project.isDisposed) EditorNotifications.getInstance(project).updateAllNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gutter actions: the package line runs the package, the entry line runs the
|
||||
* entry, a require (or lock module) line updates that dependency.
|
||||
*/
|
||||
class LudicManifestLineMarker : RunLineMarkerContributor() {
|
||||
override fun getInfo(element: PsiElement): Info? {
|
||||
val file = element.containingFile ?: return null
|
||||
val manifest = LudicManifest.isManifest(file)
|
||||
if (!manifest && !LudicManifest.isLock(file)) return null
|
||||
if (element.firstChild != null) return null
|
||||
val word = element.text
|
||||
if (word != "package" && word != "require" && word != "entry" && word != "module" && word != "script") return null
|
||||
val line = ManifestLine.at(element) ?: return null
|
||||
if (!line.startsWith(element)) return null
|
||||
val dir = file.virtualFile?.parent?.path ?: return null
|
||||
val project = file.project
|
||||
return when {
|
||||
manifest && word == "package" -> Info(
|
||||
AllIcons.RunConfigurations.TestState.Run,
|
||||
arrayOf(
|
||||
cliAction("Run Package", AllIcons.Actions.Execute, project, dir, LudicCommand.RUN),
|
||||
cliAction("Test Package", AllIcons.RunConfigurations.TestState.Run_run, project, dir, LudicCommand.TEST),
|
||||
cliAction("Build Package", AllIcons.Actions.Compile, project, dir, LudicCommand.BUILD),
|
||||
cliAction("Fetch Dependencies", AllIcons.Actions.Download, project, dir, LudicCommand.CUSTOM, "get"),
|
||||
cliAction("Bundle App", AllIcons.Nodes.Artifact, project, dir, LudicCommand.CUSTOM, "bundle"),
|
||||
)
|
||||
) { "Run, test or build this package" }
|
||||
manifest && word == "entry" -> Info(
|
||||
AllIcons.RunConfigurations.TestState.Run,
|
||||
arrayOf(
|
||||
cliAction("Run Entry", AllIcons.Actions.Execute, project, dir, LudicCommand.RUN),
|
||||
cliAction("Build Entry", AllIcons.Actions.Compile, project, dir, LudicCommand.BUILD),
|
||||
)
|
||||
) { "Run the entry program" }
|
||||
manifest && word == "script" -> {
|
||||
val name = line.words.getOrNull(1) ?: return null
|
||||
Info(
|
||||
AllIcons.RunConfigurations.TestState.Run,
|
||||
arrayOf(cliAction("Run Script '$name'", AllIcons.Actions.Execute, project, dir, LudicCommand.CUSTOM, "script $name"))
|
||||
) { "ludic script $name" }
|
||||
}
|
||||
word == "require" || word == "module" -> {
|
||||
val module = line.words.getOrNull(1) ?: return null
|
||||
Info(
|
||||
AllIcons.Actions.Refresh,
|
||||
arrayOf(
|
||||
cliAction("Update $module", AllIcons.Actions.Refresh, project, dir, LudicCommand.CUSTOM, "update $module"),
|
||||
cliAction("Fetch Dependencies", AllIcons.Actions.Download, project, dir, LudicCommand.CUSTOM, "get"),
|
||||
cliAction("Verify Locked Packages", AllIcons.Actions.Checked, project, dir, LudicCommand.CUSTOM, "verify"),
|
||||
)
|
||||
) { "Update $module" }
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** package.ludic and its lock get the package icon in the Project view and on their tabs. */
|
||||
class LudicManifestIconProvider : IconProvider(), DumbAware {
|
||||
override fun getIcon(element: PsiElement, flags: Int): Icon? =
|
||||
if (element is PsiFile && (LudicManifest.isManifest(element) || LudicManifest.isLock(element))) LudicPackageIcons.MANIFEST else null
|
||||
}
|
||||
|
||||
/** A manifest path value, resolved against the package root. */
|
||||
internal fun resolveManifestPath(file: PsiFile, value: String): File? {
|
||||
val dir = file.virtualFile?.parent?.path ?: return null
|
||||
if (value.isBlank()) return null
|
||||
return File(value).let { if (it.isAbsolute) it else File(dir, value) }
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package io.ludic.ide.project
|
||||
|
||||
import com.intellij.navigation.ItemPresentation
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.roots.AdditionalLibraryRootsProvider
|
||||
import com.intellij.openapi.roots.SyntheticLibrary
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import io.ludic.ide.LudicIcons
|
||||
import io.ludic.ide.LudicToolchain
|
||||
import java.io.File
|
||||
import javax.swing.Icon
|
||||
|
||||
/**
|
||||
* External Libraries, one node per thing a build can import:
|
||||
*
|
||||
* Ludic built-in runtime <home>/runtime — the language's own library
|
||||
* Ludic language reference <home>/docs/language — what hover and go-to open
|
||||
* ludic.render3d 0.4.0 <home>/packages/<name> — packages shipped with the toolchain
|
||||
* git.example.com/me/util 1.2.0 ludic_modules/ or vendor/ — fetched packages, per the lock
|
||||
*
|
||||
* so they are indexed, searchable and browsable next to the project.
|
||||
*/
|
||||
class LudicPackagesLibraryProvider : AdditionalLibraryRootsProvider() {
|
||||
|
||||
override fun getAdditionalProjectLibraries(project: Project): Collection<SyntheticLibrary> {
|
||||
val base = project.basePath ?: return emptyList()
|
||||
val lfs = LocalFileSystem.getInstance()
|
||||
val out = mutableListOf<SyntheticLibrary>()
|
||||
fun add(name: String, kind: Kind, path: File) {
|
||||
lfs.findFileByIoFile(path)?.takeIf { it.isDirectory }?.let { out += LudicPackagesLibrary(name, kind, it) }
|
||||
}
|
||||
|
||||
val home = LudicToolchain.home(project)
|
||||
// a checkout of the toolchain already has all of this as project content
|
||||
val insideHome = home != null && File(base).canonicalPath.let { it == home.canonicalPath || it.startsWith(home.canonicalPath + "/") }
|
||||
if (home != null && !insideHome) {
|
||||
add("Ludic built-in runtime", Kind.BUILTIN, File(home, "runtime"))
|
||||
add("Ludic language reference", Kind.BUILTIN, File(home, "docs/language"))
|
||||
File(home, "packages").listFiles()?.filter { it.isDirectory }?.sortedBy { it.name }?.forEach { pkg ->
|
||||
add(labelOf(pkg, pkg.name), Kind.TOOLCHAIN, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
val lock = File(base, LudicManifest.LOCK_NAME)
|
||||
val locked = if (lock.isFile) runCatching { LudicManifest.parseLock(lock.readText()) }.getOrDefault(emptyList()) else emptyList()
|
||||
val modules = File(base, System.getenv("LUDIC_MODULES") ?: "ludic_modules")
|
||||
val vendor = File(base, "vendor")
|
||||
for (m in locked) {
|
||||
val dir = listOf(File(vendor, m.module), File(modules, m.module)).firstOrNull { it.isDirectory } ?: continue
|
||||
add("${m.module} ${m.version}", Kind.REMOTE, dir)
|
||||
}
|
||||
if (locked.isEmpty()) {
|
||||
// fetched without a lock we can read: show the link roots as they are
|
||||
add("Ludic packages (ludic_modules)", Kind.REMOTE, modules)
|
||||
add("Ludic packages (vendor)", Kind.REMOTE, vendor)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
override fun getRootsToWatch(project: Project): Collection<VirtualFile> =
|
||||
getAdditionalProjectLibraries(project).flatMap { it.sourceRoots }
|
||||
|
||||
/** `ludic.render3d 0.4.0` from the package's own manifest, when it has one. */
|
||||
private fun labelOf(dir: File, fallback: String): String {
|
||||
val man = File(dir, LudicManifest.FILE_NAME)
|
||||
if (!man.isFile) return fallback
|
||||
val m = runCatching { LudicManifest.parse(man.readText()) }.getOrNull() ?: return fallback
|
||||
return listOfNotNull(fallback, m.version).joinToString(" ")
|
||||
}
|
||||
|
||||
enum class Kind { BUILTIN, TOOLCHAIN, REMOTE }
|
||||
}
|
||||
|
||||
class LudicPackagesLibrary(private val name: String, private val kind: LudicPackagesLibraryProvider.Kind, private val root: VirtualFile) :
|
||||
SyntheticLibrary("ludic:" + root.path, ExcludeFileCondition { isDir, filename, _, _, _ ->
|
||||
// sources, manifests and docs only: packages also carry large binary assets
|
||||
!isDir && !(filename.endsWith(".ludic") || filename.endsWith(".md"))
|
||||
}), ItemPresentation {
|
||||
override fun getSourceRoots(): Collection<VirtualFile> = listOf(root)
|
||||
override fun equals(other: Any?) = other is LudicPackagesLibrary && other.root == root && other.name == name
|
||||
override fun hashCode() = root.hashCode()
|
||||
override fun getPresentableText() = name
|
||||
override fun getLocationString(): String? = when (kind) {
|
||||
LudicPackagesLibraryProvider.Kind.BUILTIN -> "built-in"
|
||||
LudicPackagesLibraryProvider.Kind.TOOLCHAIN -> "toolchain"
|
||||
LudicPackagesLibraryProvider.Kind.REMOTE -> "fetched"
|
||||
}
|
||||
override fun getIcon(unused: Boolean): Icon = if (kind == LudicPackagesLibraryProvider.Kind.BUILTIN) LudicIcons.FILE else LudicPackageIcons.MANIFEST
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package io.ludic.ide.project
|
||||
|
||||
import com.intellij.notification.NotificationAction
|
||||
import com.intellij.notification.NotificationGroupManager
|
||||
import com.intellij.notification.NotificationType
|
||||
import com.intellij.openapi.application.EDT
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.options.ShowSettingsUtil
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.startup.ProjectActivity
|
||||
import com.intellij.ide.BrowserUtil
|
||||
import io.ludic.ide.LudicConfigurable
|
||||
import io.ludic.ide.LudicToolchain
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Opening a Ludic project on a machine without the toolchain would otherwise
|
||||
* look like a plugin that half works: colours, but no completion and no run
|
||||
* button that does anything. Say so once, with the fix attached.
|
||||
*/
|
||||
class LudicToolchainCheck : ProjectActivity {
|
||||
override suspend fun execute(project: Project) {
|
||||
project.getUserData(OPEN_ON_START)?.let { path ->
|
||||
project.putUserData(OPEN_ON_START, null)
|
||||
val main = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(File(path))
|
||||
if (main != null) {
|
||||
withContext(Dispatchers.EDT) { FileEditorManager.getInstance(project).openFile(main, true) }
|
||||
}
|
||||
}
|
||||
val root = project.basePath ?: return
|
||||
if (!looksLikeLudic(File(root))) return
|
||||
val missing = buildList {
|
||||
if (LudicToolchain.cli(project) == null) add("ludic")
|
||||
if (LudicToolchain.server(project) == null) add("ludic-lsp")
|
||||
}
|
||||
if (missing.isEmpty()) return
|
||||
NotificationGroupManager.getInstance().getNotificationGroup("Ludic")
|
||||
.createNotification(
|
||||
"Ludic toolchain not found",
|
||||
"Could not find ${missing.joinToString(" or ") { "<code>$it</code>" }}. " +
|
||||
"Highlighting works without it; completion, diagnostics, run and test do not.",
|
||||
NotificationType.WARNING
|
||||
)
|
||||
.addAction(NotificationAction.createSimple("Install instructions") {
|
||||
BrowserUtil.browse("https://workshopsoft.pages.workshopsoft.io/ludic/")
|
||||
})
|
||||
.addAction(NotificationAction.createSimple("Set paths…") {
|
||||
ShowSettingsUtil.getInstance().showSettingsDialog(project, LudicConfigurable::class.java)
|
||||
})
|
||||
.notify(project)
|
||||
}
|
||||
|
||||
private fun looksLikeLudic(root: File): Boolean =
|
||||
File(root, "package.ludic").isFile ||
|
||||
File(root, "src").listFiles()?.any { it.extension == "ludic" } == true ||
|
||||
root.listFiles()?.any { it.extension == "ludic" } == true
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
package io.ludic.ide.psi
|
||||
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.util.PsiTreeUtil
|
||||
import com.intellij.psi.util.elementType
|
||||
import io.ludic.ide.LudicLanguage
|
||||
import io.ludic.ide.LudicTokens
|
||||
|
||||
/**
|
||||
* Questions about a Ludic file answered from its token stream.
|
||||
*
|
||||
* The PSI is flat (see LudicParserDefinition), so "is this a test block" is a
|
||||
* question about neighbouring leaves: a `test` declaration keyword followed by
|
||||
* a string literal. That is all the run buttons, the configuration producer and
|
||||
* the test locator need, and it holds up in a file that does not parse yet.
|
||||
*/
|
||||
object LudicPsi {
|
||||
|
||||
/** The next leaf that is not whitespace or a comment, or null. */
|
||||
fun nextSignificant(e: PsiElement): PsiElement? {
|
||||
var n = PsiTreeUtil.nextLeaf(e)
|
||||
while (n != null && (n.elementType == TokenType.WHITE_SPACE || n.elementType == LudicTokens.COMMENT)) {
|
||||
n = PsiTreeUtil.nextLeaf(n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
fun isKeyword(e: PsiElement?, word: String): Boolean =
|
||||
e != null && e.elementType == LudicTokens.DECL_KEYWORD && e.textMatches(word)
|
||||
|
||||
/** `test "name"`: the keyword leaf's test name, or null when [e] is not one. */
|
||||
fun testNameAt(e: PsiElement): String? {
|
||||
if (!isKeyword(e, "test")) return null
|
||||
val str = nextSignificant(e) ?: return null
|
||||
if (str.elementType != LudicTokens.STRING) return null
|
||||
return unquote(str.text)
|
||||
}
|
||||
|
||||
/** `program Name`: the program's name, or null when [e] is not its keyword. */
|
||||
fun programNameAt(e: PsiElement): String? {
|
||||
if (!isKeyword(e, "program")) return null
|
||||
val name = nextSignificant(e) ?: return null
|
||||
return if (name.elementType == LudicTokens.TYPE_NAME) name.text else null
|
||||
}
|
||||
|
||||
/**
|
||||
* The `test` keyword of the block around [e], when [e] sits on that block's
|
||||
* header line: the keyword itself, the name string, or the whitespace
|
||||
* between them. A caret deep inside the body is deliberately not enough;
|
||||
* "run the test I am in" is decided by the producer from the header.
|
||||
*/
|
||||
fun enclosingTestKeyword(e: PsiElement): PsiElement? {
|
||||
if (testNameAt(e) != null) return e
|
||||
if (e.elementType == LudicTokens.STRING) {
|
||||
val prev = PsiTreeUtil.prevLeaf(e)?.let { if (it.elementType == TokenType.WHITE_SPACE) PsiTreeUtil.prevLeaf(it) else it }
|
||||
if (prev != null && testNameAt(prev) != null) return prev
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Every `test "name"` keyword leaf in the file, in source order. */
|
||||
fun testKeywords(file: PsiFile): List<PsiElement> = leaves(file).filter { testNameAt(it) != null }.toList()
|
||||
|
||||
fun findTest(file: PsiFile, name: String): PsiElement? = leaves(file).firstOrNull { testNameAt(it) == name }
|
||||
|
||||
fun findProgram(file: PsiFile): PsiElement? = leaves(file).firstOrNull { programNameAt(it) != null }
|
||||
|
||||
/** A file whose only runnable content is its test blocks. */
|
||||
fun isTestFile(file: PsiFile): Boolean {
|
||||
if (file.language != LudicLanguage) return false
|
||||
val name = file.name
|
||||
if (name.endsWith("_test.ludic")) return true
|
||||
if (file.virtualFile?.parent?.name == "tests") return true
|
||||
return hasTests(file) && !hasEntry(file)
|
||||
}
|
||||
|
||||
fun hasTests(file: PsiFile) = leaves(file).any { testNameAt(it) != null }
|
||||
|
||||
/** `entry` or a phase handler: something that makes a program run as a game. */
|
||||
fun hasEntry(file: PsiFile) = leaves(file).any { isKeyword(it, "entry") || isKeyword(it, "handler") }
|
||||
|
||||
/** A file that is a program in its own right, rather than a module imported by one. */
|
||||
fun isProgramFile(file: PsiFile) = file.language == LudicLanguage && findProgram(file) != null
|
||||
|
||||
private fun leaves(file: PsiFile): Sequence<PsiElement> =
|
||||
generateSequence(PsiTreeUtil.firstChild(file)) { PsiTreeUtil.nextLeaf(it) }
|
||||
.filter { it.elementType == LudicTokens.DECL_KEYWORD }
|
||||
|
||||
/** The value of a Ludic string literal: `"a\"b"` -> `a"b`. */
|
||||
fun unquote(lit: String): String {
|
||||
val body = lit.removePrefix("\"").let { if (it.endsWith("\"") && !it.endsWith("\\\"")) it.dropLast(1) else it }
|
||||
val out = StringBuilder(body.length)
|
||||
var i = 0
|
||||
while (i < body.length) {
|
||||
val c = body[i]
|
||||
if (c == '\\' && i + 1 < body.length) {
|
||||
when (val n = body[i + 1]) {
|
||||
'n' -> out.append('\n')
|
||||
't' -> out.append('\t')
|
||||
else -> out.append(n)
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
out.append(c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.ProgramRunnerUtil
|
||||
import com.intellij.execution.RunManager
|
||||
import com.intellij.execution.configurations.ConfigurationTypeUtil
|
||||
import com.intellij.execution.executors.DefaultRunExecutor
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.AnActionEvent
|
||||
import com.intellij.openapi.actionSystem.CommonDataKeys
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Tools | Ludic. Each action is a throwaway run configuration, so its output
|
||||
* lands in the Run tool window with the same clickable locations as any run.
|
||||
*/
|
||||
abstract class LudicCliAction(private val command: LudicCommand, private val args: String = "") : AnAction(), DumbAware {
|
||||
|
||||
override fun getActionUpdateThread() = ActionUpdateThread.BGT
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
e.presentation.isEnabledAndVisible = e.project != null
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val project = e.project ?: return
|
||||
FileDocumentManager.getInstance().saveAllDocuments()
|
||||
run(project, command, args, packageRootFor(e, project))
|
||||
}
|
||||
|
||||
private fun packageRootFor(e: AnActionEvent, project: Project): String? {
|
||||
val start = e.getData(CommonDataKeys.VIRTUAL_FILE)?.let { if (it.isDirectory) it.path else it.parent?.path }
|
||||
?: project.basePath ?: return null
|
||||
return LudicRunConfiguration.packageRootOf(File(start))?.path ?: project.basePath
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun run(project: Project, command: LudicCommand, args: String, workDir: String?) {
|
||||
val type = ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java)
|
||||
val settings = RunManager.getInstance(project).createConfiguration("ludic", type.factory)
|
||||
val config = settings.configuration as LudicRunConfiguration
|
||||
config.command = command
|
||||
config.arguments = args
|
||||
workDir?.let { config.workingDirectory = it }
|
||||
settings.name = config.suggestedName()
|
||||
settings.isTemporary = true
|
||||
ProgramRunnerUtil.executeConfiguration(settings, DefaultRunExecutor.getRunExecutorInstance())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LudicRunProjectAction : LudicCliAction(LudicCommand.RUN)
|
||||
class LudicBuildProjectAction : LudicCliAction(LudicCommand.BUILD)
|
||||
class LudicTestProjectAction : LudicCliAction(LudicCommand.TEST)
|
||||
class LudicFormatProjectAction : LudicCliAction(LudicCommand.CUSTOM, "fmt")
|
||||
class LudicGetPackagesAction : LudicCliAction(LudicCommand.CUSTOM, "get")
|
||||
class LudicPackAction : LudicCliAction(LudicCommand.CUSTOM, "pack")
|
||||
class LudicBundleAction : LudicCliAction(LudicCommand.CUSTOM, "bundle")
|
||||
class LudicCleanAction : LudicCliAction(LudicCommand.CUSTOM, "clean")
|
||||
class LudicDoctorAction : LudicCliAction(LudicCommand.CUSTOM, "doctor")
|
||||
|
||||
/** Tools | Ludic | Run Script…: pick one of package.ludic's scripts. */
|
||||
class LudicRunScriptAction : AnAction(), DumbAware {
|
||||
override fun getActionUpdateThread() = ActionUpdateThread.BGT
|
||||
|
||||
override fun update(e: AnActionEvent) {
|
||||
e.presentation.isEnabledAndVisible = e.project != null
|
||||
}
|
||||
|
||||
override fun actionPerformed(e: AnActionEvent) {
|
||||
val project = e.project ?: return
|
||||
val base = project.basePath ?: return
|
||||
val start = e.getData(CommonDataKeys.VIRTUAL_FILE)?.let { if (it.isDirectory) it.path else it.parent?.path } ?: base
|
||||
val root = LudicRunConfiguration.packageRootOf(File(start)) ?: File(base)
|
||||
val manifest = File(root, "package.ludic").takeIf { it.isFile }?.let { io.ludic.ide.project.LudicManifest.parse(it.readText()) }
|
||||
val scripts = manifest?.scripts.orEmpty()
|
||||
if (scripts.isEmpty()) {
|
||||
com.intellij.openapi.ui.Messages.showInfoMessage(project,
|
||||
"package.ludic defines no scripts. Add one:\n\nscript \"dev\" \"ludic run --headless\"", "Ludic Scripts")
|
||||
return
|
||||
}
|
||||
com.intellij.openapi.ui.popup.JBPopupFactory.getInstance()
|
||||
.createPopupChooserBuilder(scripts)
|
||||
.setTitle("Run Ludic Script")
|
||||
.setRenderer(com.intellij.ui.SimpleListCellRenderer.create { label, s, _ -> label.text = "${s.name} — ${s.command}" })
|
||||
.setItemChosenCallback { s ->
|
||||
FileDocumentManager.getInstance().saveAllDocuments()
|
||||
LudicCliAction.run(project, LudicCommand.CUSTOM, "script ${s.name}", root.path)
|
||||
}
|
||||
.createPopup()
|
||||
.showCenteredInCurrentWindow(project)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.filters.ConsoleFilterProvider
|
||||
import com.intellij.execution.filters.Filter
|
||||
import com.intellij.execution.filters.OpenFileHyperlinkInfo
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.psi.search.FilenameIndex
|
||||
import com.intellij.psi.search.GlobalSearchScope
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Makes `path/to/file.ludic:42:` clickable. That is the shape of every
|
||||
* compiler error (`game.ludic:4: error: unknown function foo`) and every failed
|
||||
* assertion (`smoke.ludic:12: expect_eq failed`).
|
||||
*
|
||||
* The compiler often prints only the file's base name, so a path that does not
|
||||
* resolve against the working directory falls back to a project-wide lookup by
|
||||
* name, preferring a match under the working directory.
|
||||
*/
|
||||
class LudicConsoleFilter(private val project: Project, private val workDir: String?) : Filter {
|
||||
|
||||
override fun applyFilter(line: String, entireLength: Int): Filter.Result? {
|
||||
val m = LOCATION.find(line) ?: return null
|
||||
val path = m.groupValues[1]
|
||||
val lineNo = m.groupValues[2].toIntOrNull() ?: return null
|
||||
val col = m.groupValues[3].toIntOrNull()
|
||||
val file = resolve(path) ?: return null
|
||||
val lineStart = entireLength - line.length
|
||||
val start = lineStart + m.groups[1]!!.range.first
|
||||
val end = lineStart + m.range.last + 1
|
||||
return Filter.Result(start, end, OpenFileHyperlinkInfo(project, file, lineNo - 1, (col ?: 1) - 1))
|
||||
}
|
||||
|
||||
private fun resolve(path: String): VirtualFile? {
|
||||
val lfs = LocalFileSystem.getInstance()
|
||||
val candidates = buildList {
|
||||
val f = File(path)
|
||||
if (f.isAbsolute) add(f)
|
||||
if (workDir != null) add(File(workDir, path))
|
||||
project.basePath?.let { add(File(it, path)) }
|
||||
}
|
||||
for (c in candidates) {
|
||||
if (c.isFile) lfs.findFileByIoFile(c)?.let { return it }
|
||||
}
|
||||
val name = File(path).name
|
||||
return ApplicationManager.getApplication().runReadAction<VirtualFile?> {
|
||||
if (project.isDisposed) return@runReadAction null
|
||||
val found = FilenameIndex.getVirtualFilesByName(name, GlobalSearchScope.projectScope(project))
|
||||
.filter { it.path.endsWith("/$path") || it.name == path }
|
||||
val wd = workDir?.let { File(it).path + "/" }
|
||||
found.firstOrNull { wd != null && it.path.startsWith(wd) } ?: found.singleOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LOCATION = Regex("""((?:[A-Za-z]:)?[^\s:"'(]*\.ludic):(\d+)(?::(\d+))?""")
|
||||
}
|
||||
}
|
||||
|
||||
/** The same links in every console: Run, Terminal-launched tasks, external tools. */
|
||||
class LudicConsoleFilterProvider : ConsoleFilterProvider {
|
||||
override fun getDefaultFilters(project: Project): Array<Filter> = arrayOf(LudicConsoleFilter(project, project.basePath))
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.DefaultExecutionResult
|
||||
import com.intellij.execution.ExecutionException
|
||||
import com.intellij.execution.ExecutionResult
|
||||
import com.intellij.execution.Executor
|
||||
import com.intellij.execution.configurations.CommandLineState
|
||||
import com.intellij.execution.configurations.ConfigurationFactory
|
||||
import com.intellij.execution.configurations.ConfigurationTypeBase
|
||||
import com.intellij.execution.configurations.GeneralCommandLine
|
||||
import com.intellij.execution.configurations.LocatableConfigurationBase
|
||||
import com.intellij.execution.configurations.LocatableRunConfigurationOptions
|
||||
import com.intellij.execution.configurations.RunConfiguration
|
||||
import com.intellij.execution.configurations.RunProfileState
|
||||
import com.intellij.execution.configurations.RuntimeConfigurationError
|
||||
import com.intellij.execution.process.KillableColoredProcessHandler
|
||||
import com.intellij.execution.process.ProcessEvent
|
||||
import com.intellij.execution.process.ProcessHandler
|
||||
import com.intellij.execution.process.ProcessListener
|
||||
import com.intellij.execution.process.ProcessTerminatedListener
|
||||
import com.intellij.execution.runners.ExecutionEnvironment
|
||||
import com.intellij.execution.runners.ProgramRunner
|
||||
import com.intellij.execution.testframework.sm.SMTestRunnerConnectionUtil
|
||||
import com.intellij.openapi.components.BaseState
|
||||
import com.intellij.openapi.options.SettingsEditor
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.NotNullLazyValue
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.openapi.vfs.VfsUtil
|
||||
import com.intellij.util.execution.ParametersListUtil
|
||||
import io.ludic.ide.LudicIcons
|
||||
import io.ludic.ide.LudicToolchain
|
||||
import java.io.File
|
||||
|
||||
/** What a Ludic run configuration asks the `ludic` CLI to do. */
|
||||
enum class LudicCommand(val cli: String, val label: String) {
|
||||
RUN("run", "Run"),
|
||||
BUILD("build", "Build"),
|
||||
TEST("test", "Test"),
|
||||
CUSTOM("", "Other ludic command");
|
||||
|
||||
override fun toString() = label
|
||||
|
||||
companion object {
|
||||
fun of(name: String?) = entries.firstOrNull { it.name == name } ?: RUN
|
||||
}
|
||||
}
|
||||
|
||||
class LudicConfigurationType : ConfigurationTypeBase(
|
||||
ID, "Ludic", "Build, run or test a Ludic program with the ludic CLI",
|
||||
NotNullLazyValue.createValue { LudicIcons.FILE }
|
||||
) {
|
||||
init {
|
||||
addFactory(LudicConfigurationFactory(this))
|
||||
}
|
||||
|
||||
val factory: ConfigurationFactory get() = configurationFactories.single()
|
||||
|
||||
companion object {
|
||||
const val ID = "LudicRunConfiguration"
|
||||
}
|
||||
}
|
||||
|
||||
class LudicConfigurationFactory(type: LudicConfigurationType) : ConfigurationFactory(type) {
|
||||
override fun getId() = LudicConfigurationType.ID
|
||||
override fun createTemplateConfiguration(project: Project): RunConfiguration =
|
||||
LudicRunConfiguration(project, this, "Ludic")
|
||||
override fun getOptionsClass(): Class<out BaseState> = LudicRunOptions::class.java
|
||||
}
|
||||
|
||||
class LudicRunOptions : LocatableRunConfigurationOptions() {
|
||||
var command by string(LudicCommand.RUN.name)
|
||||
/** The .ludic file; empty means "the project" (ludic finds the entry). */
|
||||
var file by string("")
|
||||
/** One test block to run, by its exact name; empty runs every test. */
|
||||
var testName by string("")
|
||||
var headless by property(false)
|
||||
/** Extra CLI arguments, or the whole command for CUSTOM (e.g. `fmt --check`). */
|
||||
var arguments by string("")
|
||||
var workingDirectory by string("")
|
||||
var env by map<String, String>()
|
||||
var passParentEnv by property(true)
|
||||
}
|
||||
|
||||
class LudicRunConfiguration(project: Project, factory: ConfigurationFactory, name: String) :
|
||||
LocatableConfigurationBase<LudicRunOptions>(project, factory, name) {
|
||||
|
||||
public override fun getOptions(): LudicRunOptions = super.getOptions() as LudicRunOptions
|
||||
|
||||
var command: LudicCommand
|
||||
get() = LudicCommand.of(options.command)
|
||||
set(v) { options.command = v.name }
|
||||
var file: String
|
||||
get() = options.file ?: ""
|
||||
set(v) { options.file = v }
|
||||
var testName: String
|
||||
get() = options.testName ?: ""
|
||||
set(v) { options.testName = v }
|
||||
var headless: Boolean
|
||||
get() = options.headless
|
||||
set(v) { options.headless = v }
|
||||
var arguments: String
|
||||
get() = options.arguments ?: ""
|
||||
set(v) { options.arguments = v }
|
||||
var workingDirectory: String
|
||||
get() = options.workingDirectory ?: ""
|
||||
set(v) { options.workingDirectory = v }
|
||||
var env: Map<String, String>
|
||||
get() = options.env
|
||||
set(v) { options.env.clear(); options.env.putAll(v) }
|
||||
var passParentEnv: Boolean
|
||||
get() = options.passParentEnv
|
||||
set(v) { options.passParentEnv = v }
|
||||
|
||||
override fun getConfigurationEditor(): SettingsEditor<out RunConfiguration> = LudicRunConfigurationEditor(project)
|
||||
|
||||
override fun checkConfiguration() {
|
||||
if (LudicToolchain.cli(project) == null) {
|
||||
throw RuntimeConfigurationError("The 'ludic' command was not found. ${LudicToolchain.INSTALL_HINT}")
|
||||
}
|
||||
if (file.isNotBlank() && !File(file).isFile && !File(resolvedWorkingDirectory(), file).isFile) {
|
||||
throw RuntimeConfigurationError("No such file: $file")
|
||||
}
|
||||
if (command == LudicCommand.CUSTOM && arguments.isBlank()) {
|
||||
throw RuntimeConfigurationError("Name the ludic command to run, for example: fmt --check")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getState(executor: Executor, environment: ExecutionEnvironment): RunProfileState =
|
||||
LudicRunState(this, environment)
|
||||
|
||||
override fun suggestedName(): String {
|
||||
val f = file.substringAfterLast('/').ifEmpty { project.name }
|
||||
return when (command) {
|
||||
LudicCommand.TEST -> if (testName.isNotEmpty()) "Test '$testName'" else "Test $f"
|
||||
LudicCommand.BUILD -> "Build $f"
|
||||
LudicCommand.RUN -> "Run $f"
|
||||
LudicCommand.CUSTOM -> "ludic $arguments"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured directory, else the package that owns the file (the
|
||||
* nearest directory with a package.ludic, which is where `ludic` expects
|
||||
* to run), else the project root.
|
||||
*/
|
||||
fun resolvedWorkingDirectory(): String {
|
||||
if (workingDirectory.isNotBlank()) return workingDirectory
|
||||
val base = project.basePath
|
||||
if (file.isNotBlank()) {
|
||||
val f = File(file).let { if (it.isAbsolute) it else File(base ?: ".", file) }
|
||||
packageRootOf(f.parentFile)?.let { return it.path }
|
||||
return base ?: f.parent
|
||||
}
|
||||
return base ?: File(".").absolutePath
|
||||
}
|
||||
|
||||
/** The argv after `ludic`, exactly as it will be passed. */
|
||||
fun cliArguments(): List<String> {
|
||||
val args = mutableListOf<String>()
|
||||
val extra = ParametersListUtil.parse(arguments)
|
||||
if (command == LudicCommand.CUSTOM) return extra
|
||||
args += command.cli
|
||||
if (file.isNotBlank()) args += relativeToWorkingDirectory(file)
|
||||
when (command) {
|
||||
LudicCommand.TEST -> {
|
||||
args += "--verbose"
|
||||
if (testName.isNotEmpty()) { args += "--test"; args += testName }
|
||||
}
|
||||
else -> if (headless) args += "--headless"
|
||||
}
|
||||
args += extra
|
||||
return args
|
||||
}
|
||||
|
||||
private fun relativeToWorkingDirectory(path: String): String {
|
||||
val wd = File(resolvedWorkingDirectory())
|
||||
val f = File(path).let { if (it.isAbsolute) it else File(project.basePath ?: wd.path, path) }
|
||||
val rel = runCatching { f.canonicalFile.relativeTo(wd.canonicalFile).path }.getOrNull()
|
||||
return if (rel != null && !rel.startsWith("..")) rel else f.path
|
||||
}
|
||||
|
||||
fun commandLine(): GeneralCommandLine {
|
||||
val cli = LudicToolchain.cli(project)
|
||||
?: throw ExecutionException("The 'ludic' command was not found. ${LudicToolchain.INSTALL_HINT}")
|
||||
return GeneralCommandLine(cli)
|
||||
.withParameters(cliArguments())
|
||||
.withWorkDirectory(resolvedWorkingDirectory())
|
||||
.withEnvironment(env)
|
||||
.withParentEnvironmentType(
|
||||
if (passParentEnv) GeneralCommandLine.ParentEnvironmentType.CONSOLE
|
||||
else GeneralCommandLine.ParentEnvironmentType.NONE
|
||||
)
|
||||
.withCharset(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The nearest directory at or above [dir] holding a package.ludic. */
|
||||
fun packageRootOf(dir: File?): File? {
|
||||
var d = dir
|
||||
while (d != null) {
|
||||
if (File(d, "package.ludic").isFile) return d
|
||||
d = d.parentFile
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LudicRunState(private val config: LudicRunConfiguration, env: ExecutionEnvironment) : CommandLineState(env) {
|
||||
|
||||
override fun startProcess(): ProcessHandler {
|
||||
val handler = KillableColoredProcessHandler(config.commandLine())
|
||||
ProcessTerminatedListener.attach(handler)
|
||||
// get/update/pack/build write files the IDE should see straight away
|
||||
val dir = config.resolvedWorkingDirectory()
|
||||
handler.addProcessListener(object : ProcessListener {
|
||||
override fun processTerminated(event: ProcessEvent) {
|
||||
LocalFileSystem.getInstance().findFileByPath(dir)?.let { VfsUtil.markDirtyAndRefresh(true, true, true, it) }
|
||||
}
|
||||
})
|
||||
return handler
|
||||
}
|
||||
|
||||
override fun execute(executor: Executor, runner: ProgramRunner<*>): ExecutionResult {
|
||||
val filter = LudicConsoleFilter(config.project, config.resolvedWorkingDirectory())
|
||||
if (config.command != LudicCommand.TEST) {
|
||||
addConsoleFilters(filter)
|
||||
return super.execute(executor, runner)
|
||||
}
|
||||
val handler = startProcess()
|
||||
val props = LudicTestConsoleProperties(config, executor)
|
||||
val console = SMTestRunnerConnectionUtil.createAndAttachConsole(LudicTestConsoleProperties.FRAMEWORK, handler, props)
|
||||
console.addMessageFilter(filter)
|
||||
return DefaultExecutionResult(console, handler, *createActions(console, handler, executor))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.configuration.EnvironmentVariablesTextFieldWithBrowseButton
|
||||
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
|
||||
import com.intellij.openapi.options.SettingsEditor
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.ComboBox
|
||||
import com.intellij.openapi.ui.TextFieldWithBrowseButton
|
||||
import com.intellij.ui.RawCommandLineEditor
|
||||
import com.intellij.ui.components.JBCheckBox
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.ui.components.JBTextField
|
||||
import com.intellij.util.ui.FormBuilder
|
||||
import io.ludic.ide.LudicFileType
|
||||
import javax.swing.JComponent
|
||||
|
||||
class LudicRunConfigurationEditor(private val project: Project) : SettingsEditor<LudicRunConfiguration>() {
|
||||
private val command = ComboBox(LudicCommand.entries.toTypedArray())
|
||||
private val file = TextFieldWithBrowseButton()
|
||||
private val testName = JBTextField()
|
||||
private val headless = JBCheckBox("Headless (no window: frames go to build/out.ppm, keys come from stdin)")
|
||||
private val arguments = RawCommandLineEditor()
|
||||
private val workingDirectory = TextFieldWithBrowseButton()
|
||||
private val env = EnvironmentVariablesTextFieldWithBrowseButton()
|
||||
private val argumentsLabel = JBLabel("Extra arguments:")
|
||||
|
||||
init {
|
||||
file.addBrowseFolderListener(
|
||||
project,
|
||||
FileChooserDescriptorFactory.createSingleFileDescriptor(LudicFileType).withTitle("Ludic File")
|
||||
)
|
||||
workingDirectory.addBrowseFolderListener(
|
||||
project,
|
||||
FileChooserDescriptorFactory.createSingleFolderDescriptor().withTitle("Working Directory")
|
||||
)
|
||||
testName.emptyText.text = "all tests in the file (or the project's tests/)"
|
||||
file.textField.let { (it as? JBTextField)?.emptyText?.text = "the project's entry program" }
|
||||
workingDirectory.textField.let { (it as? JBTextField)?.emptyText?.text = "the package root that owns the file" }
|
||||
command.addActionListener { updateEnabled() }
|
||||
}
|
||||
|
||||
private fun updateEnabled() {
|
||||
val c = command.item ?: LudicCommand.RUN
|
||||
testName.isEnabled = c == LudicCommand.TEST
|
||||
headless.isEnabled = c == LudicCommand.RUN || c == LudicCommand.BUILD
|
||||
file.isEnabled = c != LudicCommand.CUSTOM
|
||||
argumentsLabel.text = if (c == LudicCommand.CUSTOM) "ludic" else "Extra arguments:"
|
||||
}
|
||||
|
||||
override fun createEditor(): JComponent = FormBuilder.createFormBuilder()
|
||||
.addLabeledComponent("Command:", command)
|
||||
.addLabeledComponent("File:", file)
|
||||
.addLabeledComponent("Test name:", testName)
|
||||
.addComponent(headless)
|
||||
.addLabeledComponent(argumentsLabel, arguments)
|
||||
.addLabeledComponent("Working directory:", workingDirectory)
|
||||
.addLabeledComponent("Environment variables:", env)
|
||||
.panel
|
||||
|
||||
override fun resetEditorFrom(c: LudicRunConfiguration) {
|
||||
command.item = c.command
|
||||
file.text = c.file
|
||||
testName.text = c.testName
|
||||
headless.isSelected = c.headless
|
||||
arguments.text = c.arguments
|
||||
workingDirectory.text = c.workingDirectory
|
||||
env.envs = c.env
|
||||
env.isPassParentEnvs = c.passParentEnv
|
||||
updateEnabled()
|
||||
}
|
||||
|
||||
override fun applyEditorTo(c: LudicRunConfiguration) {
|
||||
c.command = command.item ?: LudicCommand.RUN
|
||||
c.file = file.text.trim()
|
||||
c.testName = testName.text
|
||||
c.headless = headless.isSelected
|
||||
c.arguments = arguments.text
|
||||
c.workingDirectory = workingDirectory.text.trim()
|
||||
c.env = env.envs
|
||||
c.passParentEnv = env.isPassParentEnvs
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.actions.ConfigurationContext
|
||||
import com.intellij.execution.actions.LazyRunConfigurationProducer
|
||||
import com.intellij.execution.configurations.ConfigurationFactory
|
||||
import com.intellij.execution.configurations.ConfigurationTypeUtil
|
||||
import com.intellij.openapi.util.Ref
|
||||
import com.intellij.psi.PsiDirectory
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import io.ludic.ide.LudicFileType
|
||||
import io.ludic.ide.psi.LudicPsi
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Right-click -> Run, and the gutter buttons. What gets run follows from
|
||||
* where the user is:
|
||||
*
|
||||
* - a `test "name"` header -> ludic test <file> --test name
|
||||
* - a test file (tests/, *_test, or
|
||||
* a program made only of tests) -> ludic test <file>
|
||||
* - a file declaring a `program` -> ludic run <file>
|
||||
* - any other .ludic file (a module) -> ludic run, for the package that owns it
|
||||
* - the `tests` directory -> ludic test
|
||||
* - a package root directory -> ludic run
|
||||
*/
|
||||
class LudicRunConfigurationProducer : LazyRunConfigurationProducer<LudicRunConfiguration>() {
|
||||
|
||||
override fun getConfigurationFactory(): ConfigurationFactory =
|
||||
ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java).factory
|
||||
|
||||
private data class Target(val command: LudicCommand, val file: String, val testName: String)
|
||||
|
||||
private fun targetFor(element: PsiElement): Target? {
|
||||
if (element is PsiDirectory) {
|
||||
val dir = File(element.virtualFile.path)
|
||||
return when {
|
||||
dir.name == "tests" && LudicRunConfiguration.packageRootOf(dir.parentFile) == dir.parentFile ->
|
||||
Target(LudicCommand.TEST, "", "")
|
||||
File(dir, "package.ludic").isFile -> Target(LudicCommand.RUN, "", "")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
val file: PsiFile = element.containingFile ?: return null
|
||||
if (file.fileType != LudicFileType) return null
|
||||
val vf = file.virtualFile ?: return null
|
||||
if (file.name == "package.ludic") return Target(LudicCommand.RUN, "", "")
|
||||
|
||||
LudicPsi.enclosingTestKeyword(element)?.let { kw ->
|
||||
return Target(LudicCommand.TEST, vf.path, LudicPsi.testNameAt(kw)!!)
|
||||
}
|
||||
if (LudicPsi.isTestFile(file)) return Target(LudicCommand.TEST, vf.path, "")
|
||||
if (LudicPsi.isProgramFile(file)) return Target(LudicCommand.RUN, vf.path, "")
|
||||
// a module on its own does not run; its package does
|
||||
return if (LudicRunConfiguration.packageRootOf(File(vf.path).parentFile) != null) Target(LudicCommand.RUN, "", "") else null
|
||||
}
|
||||
|
||||
override fun setupConfigurationFromContext(
|
||||
configuration: LudicRunConfiguration,
|
||||
context: ConfigurationContext,
|
||||
sourceElement: Ref<PsiElement>,
|
||||
): Boolean {
|
||||
val element = context.psiLocation ?: return false
|
||||
val t = targetFor(element) ?: return false
|
||||
configuration.command = t.command
|
||||
configuration.file = t.file
|
||||
configuration.testName = t.testName
|
||||
if (t.file.isEmpty()) {
|
||||
// a project-level run executes from the package root
|
||||
val start = (element as? PsiDirectory)?.virtualFile?.path ?: element.containingFile?.virtualFile?.parent?.path
|
||||
start?.let { LudicRunConfiguration.packageRootOf(File(it)) }?.let { configuration.workingDirectory = it.path }
|
||||
}
|
||||
configuration.setGeneratedName()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun isConfigurationFromContext(configuration: LudicRunConfiguration, context: ConfigurationContext): Boolean {
|
||||
val element = context.psiLocation ?: return false
|
||||
val t = targetFor(element) ?: return false
|
||||
return configuration.command == t.command &&
|
||||
configuration.file == t.file &&
|
||||
configuration.testName == t.testName &&
|
||||
configuration.arguments.isBlank()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.lineMarker.ExecutorAction
|
||||
import com.intellij.execution.lineMarker.RunLineMarkerContributor
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.psi.PsiElement
|
||||
import io.ludic.ide.psi.LudicPsi
|
||||
|
||||
/**
|
||||
* The green gutter buttons: one on every `test "name"` (showing that test's
|
||||
* last result), and one on `program` — run it, or run all of its tests when
|
||||
* the file is a test program.
|
||||
*/
|
||||
class LudicRunLineMarker : RunLineMarkerContributor() {
|
||||
|
||||
override fun getInfo(element: PsiElement): Info? {
|
||||
LudicPsi.testNameAt(element)?.let { name ->
|
||||
val path = element.containingFile?.virtualFile?.path ?: return null
|
||||
val icon = getTestStateIcon(LudicTestUrls.test(path, name), element.project, false)
|
||||
return Info(icon, ExecutorAction.getActions(0)) { "Run test '$name'" }
|
||||
}
|
||||
if (LudicPsi.programNameAt(element) != null) {
|
||||
val file = element.containingFile ?: return null
|
||||
if (LudicPsi.isTestFile(file)) {
|
||||
val path = file.virtualFile?.path ?: return null
|
||||
val icon = getTestStateIcon(LudicTestUrls.file(path), element.project, true)
|
||||
return Info(icon, ExecutorAction.getActions(0)) { "Run all tests in ${file.name}" }
|
||||
}
|
||||
return Info(AllIcons.RunConfigurations.TestState.Run, ExecutorAction.getActions(0)) { "Run ${file.name}" }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
package io.ludic.ide.run
|
||||
|
||||
import com.intellij.execution.Executor
|
||||
import com.intellij.execution.Location
|
||||
import com.intellij.execution.PsiLocation
|
||||
import com.intellij.execution.process.ProcessOutputTypes
|
||||
import com.intellij.execution.testframework.TestConsoleProperties
|
||||
import com.intellij.execution.testframework.sm.ServiceMessageBuilder
|
||||
import com.intellij.execution.testframework.sm.SMCustomMessagesParsing
|
||||
import com.intellij.execution.testframework.sm.runner.OutputToGeneralTestEventsConverter
|
||||
import com.intellij.execution.testframework.sm.runner.SMTRunnerConsoleProperties
|
||||
import com.intellij.execution.testframework.sm.runner.SMTestLocator
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.psi.PsiManager
|
||||
import com.intellij.psi.search.FilenameIndex
|
||||
import com.intellij.psi.search.GlobalSearchScope
|
||||
import io.ludic.ide.psi.LudicPsi
|
||||
import java.io.File
|
||||
|
||||
class LudicTestConsoleProperties(config: LudicRunConfiguration, executor: Executor) :
|
||||
SMTRunnerConsoleProperties(config, FRAMEWORK, executor), SMCustomMessagesParsing {
|
||||
|
||||
private val workDir = config.resolvedWorkingDirectory()
|
||||
|
||||
init {
|
||||
isIdBasedTestTree = false
|
||||
isPrintTestingStartedTime = false
|
||||
}
|
||||
|
||||
override fun getTestLocator(): SMTestLocator = LudicTestLocator
|
||||
|
||||
override fun createTestEventsConverter(testFrameworkName: String, props: TestConsoleProperties): OutputToGeneralTestEventsConverter =
|
||||
LudicTestEventsConverter(testFrameworkName, props, workDir)
|
||||
|
||||
companion object {
|
||||
const val FRAMEWORK = "Ludic"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test URLs: `ludic://<absolute file>` for a file's suite and
|
||||
* `ludic://<absolute file>#<test name>` for one test block. The gutter icons
|
||||
* look test state up by the same string, so the two must agree exactly.
|
||||
*/
|
||||
object LudicTestUrls {
|
||||
const val PROTOCOL = "ludic"
|
||||
|
||||
fun file(path: String) = "$PROTOCOL://$path"
|
||||
fun test(path: String, name: String) = "$PROTOCOL://$path#$name"
|
||||
|
||||
/** (file, test name or null) from the part after `ludic://`. */
|
||||
fun split(path: String): Pair<String, String?> {
|
||||
val at = path.indexOf(".ludic#")
|
||||
return if (at < 0) path to null else path.substring(0, at + 6) to path.substring(at + 7)
|
||||
}
|
||||
}
|
||||
|
||||
object LudicTestLocator : SMTestLocator {
|
||||
override fun getLocation(protocol: String, path: String, project: Project, scope: GlobalSearchScope): List<Location<*>> {
|
||||
if (protocol != LudicTestUrls.PROTOCOL) return emptyList()
|
||||
val (filePath, name) = LudicTestUrls.split(path)
|
||||
val vf = LocalFileSystem.getInstance().findFileByPath(filePath)
|
||||
?: FilenameIndex.getVirtualFilesByName(File(filePath).name, scope).firstOrNull { it.path == filePath }
|
||||
?: return emptyList()
|
||||
val psi = PsiManager.getInstance(project).findFile(vf) ?: return emptyList()
|
||||
if (name == null) return listOf(PsiLocation(psi))
|
||||
val el = LudicPsi.findTest(psi, name) ?: return listOf(PsiLocation(psi))
|
||||
return listOf(PsiLocation(el))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns `ludic test --verbose` output into test events.
|
||||
*
|
||||
* The runner's lines, as the CLI frames them:
|
||||
*
|
||||
* RUN tests/smoke.ludic
|
||||
* smoke.ludic:12: expect_eq failed (got 1, want 2) <- assertion, before its result
|
||||
* ok - adds
|
||||
* FAIL - subtracts
|
||||
* == 1 passed, 1 failed ==
|
||||
* FAIL tests/smoke.ludic (exit 1)
|
||||
*
|
||||
* Older toolchains have no --verbose: they print only `PASS file` for a
|
||||
* passing file and dump the runner output *after* `FAIL file`. Both shapes are
|
||||
* accepted, so a file-level result still shows with an old install.
|
||||
*/
|
||||
open class LudicTestEventsConverter(
|
||||
name: String,
|
||||
props: TestConsoleProperties,
|
||||
private val workDir: String,
|
||||
) : OutputToGeneralTestEventsConverter(name, props) {
|
||||
|
||||
private var suite: String? = null
|
||||
private var suitePath: String? = null
|
||||
private var testsInSuite = 0
|
||||
private var framed = false // saw a RUN line: this CLI speaks --verbose
|
||||
private val pending = StringBuilder() // assertion lines awaiting their test's result
|
||||
private val suiteOutput = StringBuilder() // everything else inside the suite (compile errors)
|
||||
|
||||
override fun processConsistentText(text: String, outputType: Key<*>) {
|
||||
val line = ANSI.replace(text, "").trimEnd('\n', '\r')
|
||||
val t = line.trim()
|
||||
// runner lines start in column 0; the CLI's own lines are indented,
|
||||
// which is what keeps a test named "x.ludic" from reading as a file
|
||||
TEST_RESULT.matchEntire(line)?.let { m ->
|
||||
val (verdict, testName) = m.destructured
|
||||
if (suite == null) openSuite("tests", outputType)
|
||||
reportTest(testName, verdict == "ok", outputType)
|
||||
return
|
||||
}
|
||||
RUN.matchEntire(t)?.let { m ->
|
||||
framed = true
|
||||
closeSuite(outputType)
|
||||
openSuite(m.groupValues[1], outputType)
|
||||
return
|
||||
}
|
||||
FILE_RESULT.matchEntire(t)?.let { m ->
|
||||
val (verdict, file, why) = m.destructured
|
||||
if (!framed) closeSuite(outputType)
|
||||
if (suite == null) {
|
||||
openSuite(file, outputType)
|
||||
// legacy: a passing file prints nothing more; a failing one
|
||||
// dumps its runner output next, so keep the suite open for it
|
||||
if (verdict == "PASS") closeSuite(outputType)
|
||||
else if (why.contains("did not compile")) reportCompileFailure(outputType, why)
|
||||
return
|
||||
}
|
||||
if (verdict == "FAIL" && testsInSuite == 0) reportCompileFailure(outputType, why)
|
||||
closeSuite(outputType)
|
||||
return
|
||||
}
|
||||
NO_SUCH_TEST.matchEntire(t)?.let { m ->
|
||||
if (suite == null) openSuite("tests", outputType)
|
||||
pending.append(line).append('\n')
|
||||
reportTest(m.groupValues[1], false, outputType)
|
||||
return
|
||||
}
|
||||
if (t.startsWith("== ") && t.endsWith(" ==")) {
|
||||
if (FILES_SUMMARY.matches(t)) closeSuite(outputType)
|
||||
forward(text, outputType)
|
||||
return
|
||||
}
|
||||
if (suite != null) {
|
||||
if (ASSERTION.containsMatchIn(t)) pending.append(line).append('\n')
|
||||
else if (t.isNotEmpty()) suiteOutput.append(line).append('\n')
|
||||
}
|
||||
forward(text, outputType)
|
||||
}
|
||||
|
||||
override fun flushBufferOnProcessTermination(code: Int) {
|
||||
closeSuite(ProcessOutputTypes.STDOUT)
|
||||
super.flushBufferOnProcessTermination(code)
|
||||
}
|
||||
|
||||
private fun openSuite(file: String, outputType: Key<*>) {
|
||||
val abs = File(file).let { if (it.isAbsolute) it else File(workDir, file) }.path
|
||||
suite = file
|
||||
suitePath = abs
|
||||
testsInSuite = 0
|
||||
pending.setLength(0)
|
||||
suiteOutput.setLength(0)
|
||||
emit(ServiceMessageBuilder.testSuiteStarted(file).addAttribute("locationHint", LudicTestUrls.file(abs)), outputType)
|
||||
}
|
||||
|
||||
private fun closeSuite(outputType: Key<*>) {
|
||||
val s = suite ?: return
|
||||
suite = null
|
||||
emit(ServiceMessageBuilder.testSuiteFinished(s), outputType)
|
||||
}
|
||||
|
||||
private fun reportTest(name: String, passed: Boolean, outputType: Key<*>) {
|
||||
testsInSuite++
|
||||
val path = suitePath ?: return
|
||||
emit(ServiceMessageBuilder.testStarted(name).addAttribute("locationHint", LudicTestUrls.test(path, name)), outputType)
|
||||
if (!passed) {
|
||||
val message = pending.lines().firstOrNull { it.isNotBlank() }?.let { ASSERTION_PREFIX.replace(it, "") } ?: "test failed"
|
||||
emit(ServiceMessageBuilder.testFailed(name).addAttribute("message", message).addAttribute("details", pending.toString()), outputType)
|
||||
}
|
||||
emit(ServiceMessageBuilder.testFinished(name), outputType)
|
||||
pending.setLength(0)
|
||||
}
|
||||
|
||||
/** A file that never ran shows as one failed node carrying the compiler's output. */
|
||||
private fun reportCompileFailure(outputType: Key<*>, why: String) {
|
||||
val path = suitePath ?: return
|
||||
val node = "(compile)"
|
||||
testsInSuite++
|
||||
emit(ServiceMessageBuilder.testStarted(node).addAttribute("locationHint", LudicTestUrls.file(path)), outputType)
|
||||
val details = suiteOutput.toString().ifBlank { why }
|
||||
val first = suiteOutput.lines().firstOrNull { it.isNotBlank() } ?: why.trim().removeSurrounding("(", ")")
|
||||
emit(ServiceMessageBuilder.testFailed(node).addAttribute("message", first).addAttribute("details", details), outputType)
|
||||
emit(ServiceMessageBuilder.testFinished(node), outputType)
|
||||
}
|
||||
|
||||
private fun emit(msg: ServiceMessageBuilder, outputType: Key<*>) {
|
||||
forward("$msg\n", outputType)
|
||||
}
|
||||
|
||||
/** Hands text on to the platform's parser; the tests intercept it here. */
|
||||
internal open fun forward(text: String, outputType: Key<*>) {
|
||||
super.processConsistentText(text, outputType)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val ANSI = Regex("\\[[0-9;]*m")
|
||||
val RUN = Regex("RUN\\s+(.+\\.ludic)")
|
||||
val FILE_RESULT = Regex("(PASS|FAIL)\\s+(\\S.*?\\.ludic)(\\s+\\(.*\\))?")
|
||||
val TEST_RESULT = Regex("(ok|FAIL) +- (.*)")
|
||||
val NO_SUCH_TEST = Regex("no test named \"(.*)\"")
|
||||
val FILES_SUMMARY = Regex("== .*test files (passed|failed) ==")
|
||||
val ASSERTION = Regex("\\.ludic:\\d+: .*failed")
|
||||
val ASSERTION_PREFIX = Regex("^\\S+\\.ludic:\\d+: ")
|
||||
}
|
||||
}
|
||||
|
|
@ -6,17 +6,25 @@
|
|||
<description><![CDATA[
|
||||
<p>Language support for <b>Ludic</b>, the AI-first, ECS-native game language.</p>
|
||||
<ul>
|
||||
<li>Syntax highlighting, brace matching, commenting and folding, implemented natively so a
|
||||
<code>.ludic</code> file is coloured with no external process.</li>
|
||||
<li>Completion, diagnostics, hover, go-to-definition, find usages, rename and formatting via
|
||||
the <code>ludic-lsp</code> language server, through LSP4IJ, so this works in Community
|
||||
editions as well as the paid IDEs.</li>
|
||||
<li>Syntax highlighting (customisable under Color Scheme), brace matching and auto-closing,
|
||||
quote pairing, commenting, folding and smart indentation, implemented natively so a
|
||||
<code>.ludic</code> file is edited well with no external process.</li>
|
||||
<li>Completion, diagnostics, hover docs, parameter info, go-to-definition, find usages,
|
||||
highlight usages, rename, formatting, inlay hints, semantic highlighting, the Structure
|
||||
view, breadcrumbs and Go to Symbol via the <code>ludic-lsp</code> language server,
|
||||
through LSP4IJ, so this works in the free IDEs as well as the paid ones.</li>
|
||||
<li>Errors from the Ludic compiler itself, reported on save.</li>
|
||||
<li>Highlighting of <code>```ludic</code> fenced blocks in Markdown.</li>
|
||||
<li>Run configurations for <code>ludic run</code>, <code>build</code> and <code>test</code>,
|
||||
with gutter run buttons on programs and on every <code>test "name"</code> block,
|
||||
results in the test runner tree, and clickable <code>file.ludic:line</code> locations
|
||||
in every console.</li>
|
||||
<li>New Project wizard (<code>ludic new</code>), New Ludic File templates, live templates,
|
||||
and a Tools | Ludic menu (format, fetch packages, pack, bundle, doctor).</li>
|
||||
<li><code>package.ludic</code> manifest highlighting, and <code>```ludic</code> fences in Markdown.</li>
|
||||
</ul>
|
||||
<p>Set the paths under <b>Settings → Languages & Frameworks → Ludic</b>, or leave
|
||||
them blank to use <code>bin/ludic-lsp</code> and <code>bin/ludicc</code> under the
|
||||
project root.</p>
|
||||
them blank to use <code>bin/</code> under the project root, then <code>PATH</code>, then
|
||||
<code>~/.ludic/bin</code>.</p>
|
||||
]]></description>
|
||||
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
|
|
@ -54,6 +62,60 @@
|
|||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicFoldingBuilder"/>
|
||||
|
||||
<lang.quoteHandler
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.editor.LudicQuoteHandler"/>
|
||||
|
||||
<lineIndentProvider implementation="io.ludic.ide.editor.LudicLineIndentProvider"/>
|
||||
<typedHandler implementation="io.ludic.ide.editor.LudicCloserTypedHandler" id="ludicCloser"/>
|
||||
|
||||
<colorSettingsPage implementation="io.ludic.ide.editor.LudicColorSettingsPage"/>
|
||||
<langCodeStyleSettingsProvider implementation="io.ludic.ide.editor.LudicCodeStyleSettingsProvider"/>
|
||||
|
||||
<liveTemplateContext contextId="Ludic" implementation="io.ludic.ide.editor.LudicTemplateContext"/>
|
||||
<defaultLiveTemplates file="/liveTemplates/Ludic.xml"/>
|
||||
<internalFileTemplate name="Ludic Program"/>
|
||||
<internalFileTemplate name="Ludic Module"/>
|
||||
<internalFileTemplate name="Ludic Test"/>
|
||||
<internalFileTemplate name="Ludic File"/>
|
||||
|
||||
<!-- Language-server features LSP4IJ only wires up by itself for TEXT and
|
||||
TextMate files; a language with its own parser opts in per feature. -->
|
||||
<lang.psiStructureViewFactory
|
||||
language="Ludic"
|
||||
implementationClass="com.redhat.devtools.lsp4ij.features.documentSymbol.LSPDocumentSymbolStructureViewFactory"/>
|
||||
<codeInsight.parameterInfo
|
||||
language="Ludic"
|
||||
implementationClass="com.redhat.devtools.lsp4ij.features.signatureHelp.LSPParameterInfoHandler"/>
|
||||
<codeBlockProvider
|
||||
language="Ludic"
|
||||
implementationClass="com.redhat.devtools.lsp4ij.features.codeBlockProvider.LSPCodeBlockProvider"/>
|
||||
|
||||
<configurationType implementation="io.ludic.ide.run.LudicConfigurationType"/>
|
||||
<runConfigurationProducer implementation="io.ludic.ide.run.LudicRunConfigurationProducer"/>
|
||||
<runLineMarkerContributor language="Ludic" implementationClass="io.ludic.ide.run.LudicRunLineMarker"/>
|
||||
<consoleFilterProvider implementation="io.ludic.ide.run.LudicConsoleFilterProvider"/>
|
||||
|
||||
<moduleBuilder builderClass="io.ludic.ide.project.LudicModuleBuilder"/>
|
||||
|
||||
<!-- package.ludic: the package manifest -->
|
||||
<editorNotificationProvider implementation="io.ludic.ide.project.LudicPackageNotificationProvider"/>
|
||||
<runLineMarkerContributor language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestLineMarker"/>
|
||||
<completion.contributor language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestCompletion" order="first"/>
|
||||
<lang.documentationProvider language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestDocumentation" order="first"/>
|
||||
<gotoDeclarationHandler implementation="io.ludic.ide.project.LudicManifestGotoHandler" order="first"/>
|
||||
<annotator language="Ludic" implementationClass="io.ludic.ide.project.LudicManifestAnnotator"/>
|
||||
<iconProvider implementation="io.ludic.ide.project.LudicManifestIconProvider" order="first"/>
|
||||
<additionalLibraryRootsProvider implementation="io.ludic.ide.project.LudicPackagesLibraryProvider"/>
|
||||
<notificationGroup id="Ludic" displayType="BALLOON"/>
|
||||
<postStartupActivity implementation="io.ludic.ide.project.LudicToolchainCheck"/>
|
||||
|
||||
<colorProvider implementation="io.ludic.ide.editor.LudicColorPreview"/>
|
||||
<!-- built-in reference pages as documentation, whatever element points at them -->
|
||||
<documentationProvider implementation="io.ludic.ide.editor.LudicDocPageDocumentation" order="first"/>
|
||||
<!-- a declaration the language server navigated to, described by the server -->
|
||||
<documentationProvider implementation="io.ludic.ide.editor.LudicTargetDocumentation" order="first"/>
|
||||
|
||||
<projectConfigurable
|
||||
parentId="language"
|
||||
instance="io.ludic.ide.LudicConfigurable"
|
||||
|
|
@ -61,6 +123,43 @@
|
|||
displayName="Ludic"/>
|
||||
</extensions>
|
||||
|
||||
<projectListeners>
|
||||
<listener class="io.ludic.ide.project.LudicManifestWatcher"
|
||||
topic="com.intellij.openapi.vfs.newvfs.BulkFileListener"/>
|
||||
</projectListeners>
|
||||
|
||||
<actions>
|
||||
<action id="Ludic.NewFile" class="io.ludic.ide.editor.CreateLudicFileAction"
|
||||
text="Ludic File" description="Create a Ludic program, module or test" icon="/icons/ludic.svg">
|
||||
<add-to-group group-id="NewGroup" anchor="before" relative-to-action="NewFromTemplate"/>
|
||||
</action>
|
||||
<group id="Ludic.Tools" text="Ludic" popup="true" icon="/icons/ludic.svg">
|
||||
<add-to-group group-id="ToolsMenu" anchor="last"/>
|
||||
<action id="Ludic.Run" class="io.ludic.ide.run.LudicRunProjectAction"
|
||||
text="Run Project" description="ludic run"/>
|
||||
<action id="Ludic.Build" class="io.ludic.ide.run.LudicBuildProjectAction"
|
||||
text="Build Project" description="ludic build"/>
|
||||
<action id="Ludic.Test" class="io.ludic.ide.run.LudicTestProjectAction"
|
||||
text="Run All Tests" description="ludic test"/>
|
||||
<action id="Ludic.RunScript" class="io.ludic.ide.run.LudicRunScriptAction"
|
||||
text="Run Script…" description="Run one of package.ludic's scripts"/>
|
||||
<separator/>
|
||||
<action id="Ludic.Format" class="io.ludic.ide.run.LudicFormatProjectAction"
|
||||
text="Format Project" description="ludic fmt: format src/ and tests/ in place"/>
|
||||
<action id="Ludic.Get" class="io.ludic.ide.run.LudicGetPackagesAction"
|
||||
text="Fetch Packages" description="ludic get: resolve and fetch package.ludic dependencies"/>
|
||||
<action id="Ludic.Pack" class="io.ludic.ide.run.LudicPackAction"
|
||||
text="Pack Assets" description="ludic pack: build the .lpak asset pack"/>
|
||||
<action id="Ludic.Bundle" class="io.ludic.ide.run.LudicBundleAction"
|
||||
text="Bundle App" description="ludic bundle: build a macOS .app"/>
|
||||
<action id="Ludic.Clean" class="io.ludic.ide.run.LudicCleanAction"
|
||||
text="Clean" description="ludic clean: remove build/"/>
|
||||
<separator/>
|
||||
<action id="Ludic.Doctor" class="io.ludic.ide.run.LudicDoctorAction"
|
||||
text="Check Toolchain (Doctor)" description="ludic doctor"/>
|
||||
</group>
|
||||
</actions>
|
||||
|
||||
<extensions defaultExtensionNs="com.redhat.devtools.lsp4ij">
|
||||
<server
|
||||
id="ludic"
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
<html><body>Creates a Ludic File. <code>${PROGRAM_NAME}</code> is the file name in UpperCamelCase.</body></html>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# ${NAME}.ludic - import it from a program with: import "${NAME}.ludic"
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
<html><body>Creates a Ludic Module. <code>${PROGRAM_NAME}</code> is the file name in UpperCamelCase.</body></html>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# ${NAME}.ludic
|
||||
program ${PROGRAM_NAME} {
|
||||
|
||||
handler Boot phase Start {
|
||||
print("${PROGRAM_NAME} started")
|
||||
}
|
||||
|
||||
handler Draw phase Render {
|
||||
Screen.clear(0x101820)
|
||||
Screen.show()
|
||||
}
|
||||
|
||||
handler Keys phase Input {
|
||||
if Input.key() == 'q' { quit() }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<html><body>Creates a Ludic Program. <code>${PROGRAM_NAME}</code> is the file name in UpperCamelCase.</body></html>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# ${NAME}.ludic - run with: ludic test ${NAME}.ludic
|
||||
program ${PROGRAM_NAME}Spec {
|
||||
test "${NAME} works" {
|
||||
expect_eq(2 + 2, 4)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<html><body>Creates a Ludic Test. <code>${PROGRAM_NAME}</code> is the file name in UpperCamelCase.</body></html>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2c"/>
|
||||
<path d="M16 5 L27 10.5 L27 21.5 L16 27 L5 21.5 L5 10.5 Z" fill="#4a4a72"/>
|
||||
<path d="M16 5 L27 10.5 L16 16 L5 10.5 Z" fill="#ffe060"/>
|
||||
<path d="M16 16 L27 10.5 L27 21.5 L16 27 Z" fill="#9aa0c8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
|
|
@ -0,0 +1,85 @@
|
|||
<templateSet group="Ludic">
|
||||
<template name="prog" value="program $NAME$ { $END$ }" description="program block" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="capitalize(camelCase(fileNameWithoutExtension()))" defaultValue=""Game"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="entry" value="entry { $END$ }" description="entry block (runs once, for a program with no phases)" toReformat="false" toShortenFQNames="false">
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="handler" value="handler $NAME$ phase $PHASE$ { $END$ }" description="phase handler" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""Tick"" alwaysStopAt="true" />
|
||||
<variable name="PHASE" expression="enum("Update", "FixedUpdate", "LateUpdate", "Start", "Input", "Render", "Overlay")" defaultValue=""Update"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="on" value="@On($EVENT$) handler $NAME$ { $END$ }" description="event handler" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="EVENT" expression="" defaultValue=""Hit"" alwaysStopAt="true" />
|
||||
<variable name="NAME" expression="" defaultValue=""OnHit"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="event" value="event $NAME$ { $FIELD$: int = 0 }" description="event declaration" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""Hit"" alwaysStopAt="true" />
|
||||
<variable name="FIELD" expression="" defaultValue=""amount"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="prop" value="property $NAME$ { $FIELD$: $TYPE$ = $VALUE$ }" description="property (a component)" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""Pos"" alwaysStopAt="true" />
|
||||
<variable name="FIELD" expression="" defaultValue=""x"" alwaysStopAt="true" />
|
||||
<variable name="TYPE" expression="enum("int", "fixed", "bool", "string", "entity")" defaultValue=""int"" alwaysStopAt="true" />
|
||||
<variable name="VALUE" expression="" defaultValue=""0"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="model" value="model $NAME$ { $FIELD$: $TYPE$$END$ }" description="model (a record type)" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""Stats"" alwaysStopAt="true" />
|
||||
<variable name="FIELD" expression="" defaultValue=""hp"" alwaysStopAt="true" />
|
||||
<variable name="TYPE" expression="enum("int", "fixed", "bool", "string", "entity")" defaultValue=""int"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="fn" value="function $NAME$($PARAMS$) -> $RET$ { $END$ }" description="function" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""helper"" alwaysStopAt="true" />
|
||||
<variable name="PARAMS" expression="" defaultValue="""" alwaysStopAt="true" />
|
||||
<variable name="RET" expression="enum("void", "int", "fixed", "bool", "string")" defaultValue=""void"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="test" value="test "$NAME$" { $END$ }" description="test block" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""it works"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="expeq" value="expect_eq($ACTUAL$, $EXPECTED$)" description="expect_eq assertion" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="ACTUAL" expression="" defaultValue="" alwaysStopAt="true" />
|
||||
<variable name="EXPECTED" expression="" defaultValue="" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="forq" value="for ($VARS$) in query [$PROPS$] { $END$ }" description="for over an entity query" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="VARS" expression="" defaultValue=""p"" alwaysStopAt="true" />
|
||||
<variable name="PROPS" expression="" defaultValue=""Pos"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="fori" value="for $I$ in $FROM$ .. $TO$ { $END$ }" description="for over a range" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="I" expression="" defaultValue=""i"" alwaysStopAt="true" />
|
||||
<variable name="FROM" expression="" defaultValue=""0"" alwaysStopAt="true" />
|
||||
<variable name="TO" expression="" defaultValue=""10"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="if" value="if $COND$ { $END$ }" description="if block" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="COND" expression="" defaultValue="" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="ife" value="if $COND$ { $THEN$ } else { $END$ }" description="if / else" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="COND" expression="" defaultValue="" alwaysStopAt="true" />
|
||||
<variable name="THEN" expression="" defaultValue="" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="while" value="while $COND$ { $END$ }" description="while loop" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="COND" expression="" defaultValue="" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="spawn" value="spawn $NAME$ { $PROP$ { $END$ } }" description="spawn an entity" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="NAME" expression="" defaultValue=""Thing"" alwaysStopAt="true" />
|
||||
<variable name="PROP" expression="" defaultValue=""Pos"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
<template name="imp" value="import "$PATH$"" description="import a module" toReformat="false" toShortenFQNames="false">
|
||||
<variable name="PATH" expression="" defaultValue=""module.ludic"" alwaysStopAt="true" />
|
||||
<context><option name="Ludic" value="true" /></context>
|
||||
</template>
|
||||
</templateSet>
|
||||
|
|
@ -21,6 +21,10 @@ class FindExecutableTest {
|
|||
|
||||
private val names = listOf("ludic-lsp", "ludic-lsp.exe")
|
||||
|
||||
// Every lookup below is cut off from the developer's PATH and ~/.ludic:
|
||||
// a real install on the machine running the tests must not decide them.
|
||||
private val noHome: String get() = File(tmp.root, "no-home").path
|
||||
|
||||
private fun executableAt(vararg segments: String): File {
|
||||
val f = File(tmp.root, segments.joinToString(File.separator))
|
||||
f.parentFile.mkdirs()
|
||||
|
|
@ -30,9 +34,9 @@ class FindExecutableTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `finds the binary that build-tools_sh produces`() {
|
||||
val exe = executableAt("build", "ludic-lsp")
|
||||
assertEquals(exe.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
fun `finds the binary that ludic-dev tools produces`() {
|
||||
val exe = executableAt("bin", "ludic-lsp")
|
||||
assertEquals(exe.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -40,19 +44,19 @@ class FindExecutableTest {
|
|||
// The bug that shipped: the IDE was opened on <repo>/tools, so the
|
||||
// lookup searched <repo>/tools/build and came back empty while
|
||||
// <repo>/build/ludic-lsp sat there the whole time.
|
||||
executableAt("build", "ludic-lsp")
|
||||
executableAt("bin", "ludic-lsp")
|
||||
val subdir = File(tmp.root, "tools").apply { mkdirs() }
|
||||
assertNull(
|
||||
"must not silently resolve from an unrelated root",
|
||||
findExecutable(subdir.path, "", names, SERVER_SUBDIRS)
|
||||
findExecutable(subdir.path, "", names, SERVER_SUBDIRS, path = null, home = noHome)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search order prefers build over the other candidates`() {
|
||||
executableAt("bin", "ludic-lsp")
|
||||
val preferred = executableAt("build", "ludic-lsp")
|
||||
assertEquals(preferred.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
fun `search order prefers bin over the other candidates`() {
|
||||
executableAt("build", "ludic-lsp")
|
||||
val preferred = executableAt("bin", "ludic-lsp")
|
||||
assertEquals(preferred.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -66,7 +70,7 @@ class FindExecutableTest {
|
|||
assertEquals(
|
||||
"subdir '$dir' is advertised but not searched",
|
||||
f.canonicalPath,
|
||||
File(findExecutable(fresh.root.path, "", names, SERVER_SUBDIRS)!!).canonicalPath
|
||||
File(findExecutable(fresh.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome)!!).canonicalPath
|
||||
)
|
||||
fresh.delete()
|
||||
}
|
||||
|
|
@ -76,7 +80,7 @@ class FindExecutableTest {
|
|||
fun `an explicitly configured path wins over anything found by search`() {
|
||||
executableAt("build", "ludic-lsp")
|
||||
val custom = executableAt("elsewhere", "my-lsp")
|
||||
assertEquals(custom.absolutePath, findExecutable(tmp.root.path, custom.absolutePath, names, SERVER_SUBDIRS))
|
||||
assertEquals(custom.absolutePath, findExecutable(tmp.root.path, custom.absolutePath, names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -84,7 +88,7 @@ class FindExecutableTest {
|
|||
// Silently ignoring the setting and using a different binary would be
|
||||
// worse than failing: the user asked for a specific server.
|
||||
executableAt("build", "ludic-lsp")
|
||||
assertNull(findExecutable(tmp.root.path, File(tmp.root, "nope").path, names, SERVER_SUBDIRS))
|
||||
assertNull(findExecutable(tmp.root.path, File(tmp.root, "nope").path, names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -93,30 +97,47 @@ class FindExecutableTest {
|
|||
f.parentFile.mkdirs()
|
||||
f.writeText("not executable")
|
||||
f.setExecutable(false)
|
||||
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a directory named like the binary is not accepted`() {
|
||||
File(tmp.root, "build/ludic-lsp").mkdirs()
|
||||
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the install root is the last resort`() {
|
||||
val installed = executableAt("no-home", "bin", "ludic-lsp")
|
||||
assertEquals(installed.absolutePath, findExecutable(null, "", names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
// ...and a project-local binary still beats it
|
||||
val local = executableAt("bin", "ludic-lsp")
|
||||
assertEquals(local.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS, path = null, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PATH is searched before the install root`() {
|
||||
executableAt("no-home", "bin", "ludic-lsp")
|
||||
val onPath = executableAt("somewhere", "ludic-lsp")
|
||||
val path = listOf("", File(tmp.root, "empty").path, onPath.parent).joinToString(File.pathSeparator)
|
||||
assertEquals(onPath.absolutePath, findExecutable(null, "", names, SERVER_SUBDIRS, path = path, home = noHome))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null project root still allows a PATH lookup`() {
|
||||
// No exception, no crash: the default project has no basePath.
|
||||
findExecutable(null, "", names, SERVER_SUBDIRS)
|
||||
findExecutable(null, "", names, SERVER_SUBDIRS, path = null, home = noHome)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the real repository layout resolves`() {
|
||||
// Guards the arrangement the plugin actually ships against: run
|
||||
// ./tools/build-tools.sh and opening the repo root must just work.
|
||||
// `bin/ludic-dev tools` and opening the repo root must just work.
|
||||
val repoRoot = File(System.getProperty("user.dir")).parentFile.parentFile.parentFile
|
||||
val built = File(repoRoot, "build/ludic-lsp")
|
||||
val built = File(repoRoot, "bin/ludic-lsp")
|
||||
if (!built.isFile) return // toolchain not built in this checkout
|
||||
val found = findExecutable(repoRoot.path, "", names, SERVER_SUBDIRS)
|
||||
assertTrue("repo root must resolve to build/ludic-lsp", found != null)
|
||||
val found = findExecutable(repoRoot.path, "", names, SERVER_SUBDIRS, path = null, home = noHome)
|
||||
assertTrue("repo root must resolve to bin/ludic-lsp", found != null)
|
||||
assertEquals(built.canonicalPath, File(found!!).canonicalPath)
|
||||
}
|
||||
}
|
||||
|
|
@ -132,17 +153,23 @@ class ServerCommandTest {
|
|||
|
||||
@Test
|
||||
fun `the repository root produces a runnable stdio command`() {
|
||||
val built = File(repoRoot, "build/ludic-lsp")
|
||||
val built = File(repoRoot, "bin/ludic-lsp")
|
||||
if (!built.isFile) return // toolchain not built in this checkout
|
||||
|
||||
val cmd = serverCommand(repoRoot.path, "")
|
||||
assertEquals(listOf(built.canonicalPath, "--stdio"), cmd!!.let { listOf(File(it[0]).canonicalPath, it[1]) })
|
||||
|
||||
// and it must actually be executable, not merely present
|
||||
val proc = ProcessBuilder(cmd[0], "--version").redirectErrorStream(true).start()
|
||||
val out = proc.inputStream.bufferedReader().readText().trim()
|
||||
proc.waitFor()
|
||||
assertTrue("expected a version banner, got '$out'", out.startsWith("ludic-lsp"))
|
||||
// and it must actually start, not merely be present. The server reads
|
||||
// stdin until EOF whatever its arguments, so stdin is closed and the
|
||||
// wait is bounded: an open stdin here once hung the whole test run.
|
||||
val proc = ProcessBuilder(cmd)
|
||||
.redirectInput(ProcessBuilder.Redirect.from(File("/dev/null")))
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val exited = proc.waitFor(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||
if (!exited) proc.destroyForcibly()
|
||||
assertTrue("ludic-lsp did not exit on EOF", exited)
|
||||
assertEquals("ludic-lsp exit code on EOF", 0, proc.exitValue())
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import io.ludic.ide.editor.LudicColorPreview
|
||||
import io.ludic.ide.editor.LudicDocPageDocumentation
|
||||
import io.ludic.ide.editor.LudicPalette
|
||||
import io.ludic.ide.editor.LudicSemanticColors
|
||||
import java.awt.Color
|
||||
import java.io.File
|
||||
|
||||
/** Colour swatches, semantic colours and the reference-page documentation. */
|
||||
class LudicEditorExtrasTest : BasePlatformTestCase() {
|
||||
|
||||
fun testHexIntsHexStringsAndPaletteNamesHaveSwatches() {
|
||||
val file = myFixture.configureByText("c.ludic", "Screen.clear(0x0d1020)\nScreen.clear(Color.Crimson)\nlet mask = 0xff\n")
|
||||
val provider = LudicColorPreview()
|
||||
val hex = file.findElementAt(file.text.indexOf("0x0d"))!!
|
||||
assertEquals(Color(0x0d1020), provider.getColorFrom(hex))
|
||||
val named = file.findElementAt(file.text.indexOf("Crimson"))!!
|
||||
assertEquals(Color(LudicPalette.lookup("Crimson")!!.toInt(16)), provider.getColorFrom(named))
|
||||
assertNull("a short hex is a mask, not a colour", provider.getColorFrom(file.findElementAt(file.text.indexOf("0xff"))!!))
|
||||
val man = myFixture.configureByText("package.ludic", "app splash_bg \"#0b1622\"\n")
|
||||
assertEquals(Color(0x0b1622), provider.getColorFrom(man.findElementAt(man.text.indexOf("\"#"))!!))
|
||||
}
|
||||
|
||||
fun testPickingAColourRewritesTheLiteral() {
|
||||
val file = myFixture.configureByText("c.ludic", "Screen.clear(Color.Crimson)\n")
|
||||
LudicColorPreview().setColorTo(file.findElementAt(file.text.indexOf("Crimson"))!!, Color(0x123456))
|
||||
assertEquals("Screen.clear(0x123456)\n", myFixture.editor.document.text)
|
||||
}
|
||||
|
||||
fun testPaletteIsBundled() {
|
||||
assertTrue(LudicPalette.all().size > 200)
|
||||
}
|
||||
|
||||
fun testSemanticTokensMapToLudicKeys() {
|
||||
assertSame(LudicColors.FUNCTION_BUILTIN, LudicSemanticColors.keyFor("function", listOf("defaultLibrary")))
|
||||
assertSame(LudicColors.FUNCTION_PACKAGE, LudicSemanticColors.keyFor("function", listOf("vendor")))
|
||||
assertSame(LudicColors.FUNCTION_DECLARATION, LudicSemanticColors.keyFor("function", listOf("declaration")))
|
||||
assertSame(LudicColors.FUNCTION_CALL, LudicSemanticColors.keyFor("function", emptyList()))
|
||||
assertSame(LudicColors.HANDLER, LudicSemanticColors.keyFor("function", listOf("readonly", "declaration")))
|
||||
assertSame(LudicColors.CONSTANT_PACKAGE, LudicSemanticColors.keyFor("variable", listOf("readonly", "static", "vendor")))
|
||||
assertSame(LudicColors.GLOBAL_VARIABLE, LudicSemanticColors.keyFor("variable", listOf("static")))
|
||||
assertSame(LudicColors.LOCAL_VARIABLE, LudicSemanticColors.keyFor("variable", emptyList()))
|
||||
assertSame(LudicColors.NAMESPACE_BUILTIN, LudicSemanticColors.keyFor("namespace", listOf("defaultLibrary")))
|
||||
assertNull("keywords keep the lexer's colour", LudicSemanticColors.keyFor("keyword", emptyList()))
|
||||
}
|
||||
|
||||
fun testReferencePagesRenderAsDocumentation() {
|
||||
val md = "---\nid: type-void\nname: void\nkind: type\nsig: void\ntip: No value.\n---\n\n`void` is what a function returns when it returns nothing.\n\n```ludic\n# doc-check: skip\nfunction f() -> void { }\n```\n"
|
||||
val page = LudicDocPageDocumentation.parse(File("/x/docs/language/types/type-void.md"), md)!!
|
||||
assertEquals("void", page.meta["sig"])
|
||||
val html = LudicDocPageDocumentation.markdownToHtml(page.body)
|
||||
assertTrue(html, html.contains("<code>void</code> is what a function returns"))
|
||||
assertTrue(html, html.contains("function f() -> void"))
|
||||
assertFalse(html, html.contains("doc-check"))
|
||||
assertFalse(html, html.contains("---"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.application.options.CodeStyle
|
||||
import com.intellij.codeInsight.template.impl.TemplateManagerImpl
|
||||
import com.intellij.codeInsight.template.impl.TemplateSettings
|
||||
import com.intellij.ide.fileTemplates.FileTemplateManager
|
||||
import com.intellij.openapi.actionSystem.IdeActions
|
||||
import com.intellij.openapi.options.colors.ColorSettingsPages
|
||||
import com.intellij.psi.search.PsiTodoSearchHelper
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import io.ludic.ide.editor.CreateLudicFileAction
|
||||
import io.ludic.ide.editor.LudicColorSettingsPage
|
||||
import io.ludic.ide.editor.LudicIndenter
|
||||
|
||||
/**
|
||||
* Typing in a .ludic file: indentation, closers, quotes, comments, templates.
|
||||
* Each test drives the real editor actions in a headless fixture, so these
|
||||
* fail when an extension is registered wrongly, not only when the logic is.
|
||||
*/
|
||||
class LudicEditorTest : BasePlatformTestCase() {
|
||||
|
||||
private fun type(before: String, typed: String, after: String) {
|
||||
myFixture.configureByText("t.ludic", before)
|
||||
myFixture.type(typed)
|
||||
val doc = myFixture.editor.document.text
|
||||
val caret = myFixture.editor.caretModel.offset
|
||||
assertEquals(after, doc.substring(0, caret) + "<caret>" + doc.substring(caret))
|
||||
}
|
||||
|
||||
fun testEnterAfterAnOpeningBraceIndentsOneLevel() {
|
||||
type(
|
||||
"program X {<caret>\n}",
|
||||
"\n",
|
||||
"program X {\n <caret>\n}"
|
||||
)
|
||||
}
|
||||
|
||||
fun testEnterInsideNestedBlocksFollowsTheOpenersLine() {
|
||||
type(
|
||||
"program X {\n handler H phase Update {\n for (p) in query [Pos] {<caret>\n }\n }\n}",
|
||||
"\n",
|
||||
"program X {\n handler H phase Update {\n for (p) in query [Pos] {\n <caret>\n }\n }\n}"
|
||||
)
|
||||
}
|
||||
|
||||
fun testEnterAfterAPlainStatementKeepsTheDepth() {
|
||||
type(
|
||||
"program X {\n entry {\n let a = 1<caret>\n }\n}",
|
||||
"\n",
|
||||
"program X {\n entry {\n let a = 1\n <caret>\n }\n}"
|
||||
)
|
||||
}
|
||||
|
||||
fun testBracesInStringsAndCommentsDoNotCount() {
|
||||
val text = "program X {\n let s = \"{{{\" # {\n"
|
||||
assertEquals(" ", LudicIndenter.indentFor(text, text.length, " "))
|
||||
}
|
||||
|
||||
fun testTypingAClosingBraceDedentsTheLine() {
|
||||
type(
|
||||
"program X {\n entry {\n let a = 1\n <caret>\n}",
|
||||
"}",
|
||||
"program X {\n entry {\n let a = 1\n }<caret>\n}"
|
||||
)
|
||||
}
|
||||
|
||||
fun testAClosingBraceAfterCodeIsLeftAlone() {
|
||||
type(
|
||||
"program X {\n entry { let a = 1 <caret>\n}",
|
||||
"}",
|
||||
"program X {\n entry { let a = 1 }<caret>\n}"
|
||||
)
|
||||
}
|
||||
|
||||
fun testQuotesAndBracketsPairUp() {
|
||||
type("program X {\n let s = <caret>\n}", "\"", "program X {\n let s = \"<caret>\"\n}")
|
||||
type("program X {\n f<caret>\n}", "(", "program X {\n f(<caret>)\n}")
|
||||
type("program X {\n let a = <caret>\n}", "[", "program X {\n let a = [<caret>]\n}")
|
||||
}
|
||||
|
||||
fun testBackticksPairAndHolesMatch() {
|
||||
type("program X {\n let s = <caret>\n}", "`", "program X {\n let s = `<caret>`\n}")
|
||||
}
|
||||
|
||||
fun testLineCommentActionUsesHash() {
|
||||
myFixture.configureByText("t.ludic", "let a = 1<caret>\n")
|
||||
myFixture.performEditorAction(IdeActions.ACTION_COMMENT_LINE)
|
||||
assertEquals("# let a = 1\n", myFixture.editor.document.text)
|
||||
}
|
||||
|
||||
fun testTodoCommentsAreIndexed() {
|
||||
val file = myFixture.configureByText("t.ludic", "# TODO: make it faster\nlet a = 1\n")
|
||||
val todos = PsiTodoSearchHelper.getInstance(project).findTodoItems(file)
|
||||
assertEquals(1, todos.size)
|
||||
}
|
||||
|
||||
fun testManifestDirectivesHighlightOnlyInPackageLudic() {
|
||||
val manifest = LudicSyntaxHighlighterFactory().getSyntaxHighlighter(project, myFixture.configureByText("package.ludic", "require \"x\" \"1\"").virtualFile)
|
||||
val lexer = manifest.highlightingLexer
|
||||
lexer.start("require \"x\" \"1\"")
|
||||
assertEquals(LudicTokens.MANIFEST_KEYWORD, lexer.tokenType)
|
||||
|
||||
val plain = LudicSyntaxHighlighterFactory().getSyntaxHighlighter(project, myFixture.configureByText("main.ludic", "require").virtualFile)
|
||||
val plainLexer = plain.highlightingLexer
|
||||
plainLexer.start("require")
|
||||
assertEquals(LudicTokens.IDENTIFIER, plainLexer.tokenType)
|
||||
}
|
||||
|
||||
fun testColorSettingsPageIsRegisteredAndCoversEveryKey() {
|
||||
val page = ColorSettingsPages.getInstance().registeredPages.filterIsInstance<LudicColorSettingsPage>().single()
|
||||
val keys = page.attributeDescriptors.map { it.key }.toSet()
|
||||
for (field in LudicColors::class.java.declaredFields) {
|
||||
val value = runCatching { field.isAccessible = true; field.get(LudicColors) }.getOrNull()
|
||||
if (value is com.intellij.openapi.editor.colors.TextAttributesKey) {
|
||||
assertTrue("color key ${value.externalName} missing from the settings page", value in keys)
|
||||
}
|
||||
}
|
||||
// the demo text must lex cleanly, or the preview shows red
|
||||
val lexer = page.highlighter.highlightingLexer
|
||||
lexer.start(page.demoText)
|
||||
while (lexer.tokenType != null) {
|
||||
assertNotSame("bad character in demo text at ${lexer.tokenStart}", LudicTokens.BAD, lexer.tokenType)
|
||||
lexer.advance()
|
||||
}
|
||||
}
|
||||
|
||||
fun testCodeStyleDefaultsToTwoSpaces() {
|
||||
val opts = CodeStyle.getSettings(project).getIndentOptions(LudicFileType)
|
||||
assertEquals(2, opts.INDENT_SIZE)
|
||||
assertFalse(opts.USE_TAB_CHARACTER)
|
||||
}
|
||||
|
||||
fun testLiveTemplatesAreRegisteredAndExpand() {
|
||||
val template = TemplateSettings.getInstance().getTemplate("test", "Ludic")
|
||||
assertNotNull("the `test` live template is not registered", template)
|
||||
TemplateManagerImpl.setTemplateTesting(testRootDisposable)
|
||||
myFixture.configureByText("t.ludic", "program X {\n test<caret>\n}")
|
||||
myFixture.performEditorAction(IdeActions.ACTION_EXPAND_LIVE_TEMPLATE_BY_TAB)
|
||||
assertTrue(myFixture.editor.document.text, myFixture.editor.document.text.contains("test \"it works\" {"))
|
||||
}
|
||||
|
||||
fun testLiveTemplatesStayOutOfStringsAndComments() {
|
||||
myFixture.configureByText("t.ludic", "# test<caret>\n")
|
||||
myFixture.performEditorAction(IdeActions.ACTION_EXPAND_LIVE_TEMPLATE_BY_TAB)
|
||||
assertFalse(myFixture.editor.document.text.contains("it works"))
|
||||
}
|
||||
|
||||
fun testNewFileTemplatesExistAndRenderAProgramName() {
|
||||
val mgr = FileTemplateManager.getInstance(project)
|
||||
for (name in listOf(CreateLudicFileAction.PROGRAM, CreateLudicFileAction.MODULE, CreateLudicFileAction.TEST, CreateLudicFileAction.EMPTY)) {
|
||||
val t = mgr.getInternalTemplate(name)
|
||||
assertEquals("ludic", t.extension)
|
||||
}
|
||||
val props = mgr.defaultProperties.apply {
|
||||
setProperty("NAME", "enemy_ai")
|
||||
setProperty("PROGRAM_NAME", CreateLudicFileAction.programName("enemy_ai"))
|
||||
}
|
||||
val text = mgr.getInternalTemplate(CreateLudicFileAction.PROGRAM).getText(props)
|
||||
assertTrue(text, text.contains("program EnemyAi {"))
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(text)
|
||||
while (lexer.tokenType != null) {
|
||||
assertNotSame(LudicTokens.BAD, lexer.tokenType)
|
||||
lexer.advance()
|
||||
}
|
||||
}
|
||||
|
||||
fun testProgramNamesFromFileNames() {
|
||||
assertEquals("EnemyAi", CreateLudicFileAction.programName("enemy_ai"))
|
||||
assertEquals("Hud", CreateLudicFileAction.programName("hud"))
|
||||
assertEquals("P2d", CreateLudicFileAction.programName("2d"))
|
||||
assertEquals("MyGame", CreateLudicFileAction.programName("my-game"))
|
||||
}
|
||||
}
|
||||
|
|
@ -33,8 +33,8 @@ class LudicLexerTest {
|
|||
@Test
|
||||
fun `declaration keywords are distinct from statement keywords`() {
|
||||
assertEquals(
|
||||
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.BRACE),
|
||||
typesOf("game Hello {")
|
||||
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.LBRACE),
|
||||
typesOf("program Hello {")
|
||||
)
|
||||
assertEquals(LudicTokens.KEYWORD, typesOf("if x { }").first())
|
||||
}
|
||||
|
|
@ -42,10 +42,10 @@ class LudicLexerTest {
|
|||
@Test
|
||||
fun `clause keywords are their own class`() {
|
||||
// `phase` and `query` colour differently from `if`/`let`, which is the
|
||||
// whole reason a system signature reads as a signature.
|
||||
// whole reason a handler signature reads as a signature.
|
||||
assertEquals(
|
||||
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.CLAUSE, LudicTokens.PHASE),
|
||||
typesOf("system Move phase FixedUpdate")
|
||||
typesOf("handler Move phase FixedUpdate")
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -60,10 +60,87 @@ class LudicLexerTest {
|
|||
@Test
|
||||
fun `builtins are recognised, unknown calls are not`() {
|
||||
assertEquals(LudicTokens.BUILTIN, typesOf("rng_chance").single())
|
||||
assertEquals(LudicTokens.BUILTIN, typesOf("peek32").single()) // intrinsic
|
||||
assertEquals(LudicTokens.BUILTIN, typesOf("arg_count").single()) // intrinsic
|
||||
assertEquals(LudicTokens.IDENTIFIER, typesOf("my_helper").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `declaration words used as names are names`() {
|
||||
// the compiler reserves these only as function names
|
||||
assertEquals(LudicTokens.IDENTIFIER, lex("var model = look_model(x)")[1].first)
|
||||
assertEquals(LudicTokens.IDENTIFIER, lex(" model: Model,")[0].first)
|
||||
assertEquals(LudicTokens.IDENTIFIER, lex("a.model = m")[2].first)
|
||||
assertEquals(LudicTokens.IDENTIFIER, lex("if model == null { }")[1].first)
|
||||
assertEquals(LudicTokens.IDENTIFIER, lex("f(model, 1)")[2].first)
|
||||
assertEquals(LudicTokens.DECL_KEYWORD, lex("model Actor {")[0].first)
|
||||
assertEquals(LudicTokens.DECL_KEYWORD, lex("{\n model Score { a: int }")[1].first)
|
||||
assertEquals(LudicTokens.CLAUSE, lex("for (p) in query [Pos] { }").first { it.second == "query" }.first)
|
||||
assertEquals(LudicTokens.DECL_KEYWORD, lex("@On(Hit) handler Armor { }").first { it.second == "handler" }.first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restarting mid-file with the saved state lexes the same`() {
|
||||
val text = "program X {\n var model = 1\n model Foo { model: int }\n}"
|
||||
val full = LudicLexer()
|
||||
full.start(text, 0, text.length, 0)
|
||||
val states = mutableListOf<Triple<Int, Int, IElementType>>()
|
||||
while (full.tokenType != null) { states += Triple(full.tokenStart, full.state, full.tokenType!!); full.advance() }
|
||||
for ((start, state, _) in states) {
|
||||
val part = LudicLexer()
|
||||
part.start(text, start, text.length, state)
|
||||
val rest = states.filter { it.first >= start }.map { it.third }
|
||||
val got = mutableListOf<IElementType>()
|
||||
while (part.tokenType != null) { got += part.tokenType!!; part.advance() }
|
||||
assertEquals("restart at $start", rest, got)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `template strings lex their holes as code`() {
|
||||
val toks = lex("push(lods, gltf_load(dir, \"rock.gltf\", `rock_{variant}_LOD{k}`))")
|
||||
val t = toks.dropWhile { it.first != LudicTokens.TEMPLATE_QUOTE }
|
||||
assertEquals(
|
||||
listOf(
|
||||
LudicTokens.TEMPLATE_QUOTE to "`", LudicTokens.TEMPLATE_TEXT to "rock_",
|
||||
LudicTokens.TEMPLATE_HOLE_START to "{", LudicTokens.IDENTIFIER to "variant", LudicTokens.TEMPLATE_HOLE_END to "}",
|
||||
LudicTokens.TEMPLATE_TEXT to "_LOD",
|
||||
LudicTokens.TEMPLATE_HOLE_START to "{", LudicTokens.IDENTIFIER to "k", LudicTokens.TEMPLATE_HOLE_END to "}",
|
||||
LudicTokens.TEMPLATE_QUOTE to "`", LudicTokens.RPAREN to ")", LudicTokens.RPAREN to ")",
|
||||
),
|
||||
t
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `template escapes, nested braces and strings in holes`() {
|
||||
val toks = lex("`a {{b}} \\n {Pos { x: 1 }.x} {f(\"}\")}`")
|
||||
assertEquals(2, toks.count { it.first == LudicTokens.TEMPLATE_QUOTE })
|
||||
assertEquals(listOf("{{", "}}", "\\n"), toks.filter { it.first == LudicTokens.TEMPLATE_ESCAPE }.map { it.second })
|
||||
assertEquals(listOf(LudicTokens.LBRACE, LudicTokens.RBRACE), toks.filter { it.first == LudicTokens.LBRACE || it.first == LudicTokens.RBRACE }.map { it.first })
|
||||
assertEquals(2, toks.count { it.first == LudicTokens.TEMPLATE_HOLE_END })
|
||||
assertEquals("\"}\"", toks.single { it.first == LudicTokens.STRING }.second)
|
||||
// code after the template is code again
|
||||
val after = lex("`x {y}` let z = 1")
|
||||
assertEquals(LudicTokens.KEYWORD, after.first { it.second == "let" }.first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-line template restarts correctly mid-text`() {
|
||||
val text = "let s = `one {a}\ntwo {b} three`\nlet t = 1"
|
||||
val full = LudicLexer()
|
||||
full.start(text, 0, text.length, 0)
|
||||
val all = mutableListOf<Triple<Int, Int, IElementType>>()
|
||||
while (full.tokenType != null) { all += Triple(full.tokenStart, full.state, full.tokenType!!); full.advance() }
|
||||
for ((start, state, _) in all) {
|
||||
val part = LudicLexer()
|
||||
part.start(text, start, text.length, state)
|
||||
val got = mutableListOf<IElementType>()
|
||||
while (part.tokenType != null) { got += part.tokenType!!; part.advance() }
|
||||
assertEquals("restart at $start", all.filter { it.first >= start }.map { it.third }, got)
|
||||
}
|
||||
assertEquals(LudicTokens.KEYWORD, lex(text).last { it.second == "let" }.first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capitalised words read as type names`() {
|
||||
assertEquals(LudicTokens.TYPE_NAME, typesOf("Pos").single())
|
||||
|
|
@ -127,7 +204,7 @@ class LudicLexerTest {
|
|||
fun `the lexer always terminates and covers every character`() {
|
||||
// The IDE lexes half-typed text constantly; a lexer that stalls hangs
|
||||
// the editor, and one that skips bytes corrupts every offset after it.
|
||||
val nasty = "game X { component ¿ { a: int = ' } # é\n query [{Tag}] \"unclosed"
|
||||
val nasty = "program X { property ¿ { a: int = ' } # é\n query [{Tag}] \"unclosed"
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(nasty, 0, nasty.length, 0)
|
||||
var covered = 0
|
||||
|
|
@ -145,17 +222,20 @@ class LudicLexerTest {
|
|||
@Test
|
||||
fun `a real source file lexes with no bad characters`() {
|
||||
val src = """
|
||||
game Hello {
|
||||
component Pos { x: int = 0, y: int = 0 }
|
||||
archetype Mob { Pos }
|
||||
program Hello {
|
||||
property Pos { x: int = 0, y: int = 0 }
|
||||
model Mob { a: int, b: fixed }
|
||||
|
||||
system Move phase FixedUpdate
|
||||
query (p) [Pos, {Player}] where p.x > 0
|
||||
{
|
||||
p.x = p.x + 1
|
||||
for i in 0 .. 10 { print_int(i) }
|
||||
handler Move phase FixedUpdate {
|
||||
for (p) in query [Pos] where p.x > 0 {
|
||||
p.x = p.x + 1
|
||||
Screen.fill_rectangle(x: p.x, y: 0, width: 4, height: 4, color: Color.White)
|
||||
}
|
||||
for i in 0 .. 10 { print(string(i)) }
|
||||
}
|
||||
|
||||
test "adds" { expect_eq(1 + 1, 2) }
|
||||
|
||||
ui Menu {
|
||||
panel id=Root w=288 pad=16 {
|
||||
button id=Go text="Go" size=16
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.execution.lineMarker.RunLineMarkerContributor
|
||||
import com.intellij.ide.IconProvider
|
||||
import com.intellij.psi.util.PsiTreeUtil
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import io.ludic.ide.project.LudicManifest
|
||||
import io.ludic.ide.project.LudicManifestDocumentation
|
||||
import io.ludic.ide.project.LudicPackageIcons
|
||||
import io.ludic.ide.project.LudicPackageNotificationProvider
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
/** package.ludic as a package file: the banner, gutter actions, completion, docs. */
|
||||
class LudicManifestTest : BasePlatformTestCase() {
|
||||
|
||||
private val manifest = """
|
||||
package "git.example.com/me/game" # the import path
|
||||
version "0.2.0"
|
||||
kind source
|
||||
|
||||
require "git.example.com/me/util" "1.0.0"
|
||||
require "git.example.com/me/fx" "0.3.1"
|
||||
|
||||
app name "My Game"
|
||||
pack "assets/kit"
|
||||
""".trimIndent()
|
||||
|
||||
fun testParsingFollowsTheCli() {
|
||||
val m = LudicManifest.parse(manifest)
|
||||
assertEquals("git.example.com/me/game", m.module)
|
||||
assertEquals("0.2.0", m.version)
|
||||
assertEquals("source", m.kind)
|
||||
assertEquals(listOf("git.example.com/me/util", "git.example.com/me/fx"), m.requires.map { it.module })
|
||||
assertEquals(listOf(4, 5), m.requires.map { it.line })
|
||||
assertEquals(listOf("app", "name", "My Game"), LudicManifest.words("app name \"My Game\" # comment"))
|
||||
}
|
||||
|
||||
fun testStaleWhenTheLockIsMissingOrOlder() {
|
||||
val dir = Files.createTempDirectory("ludic-manifest").toFile()
|
||||
try {
|
||||
val m = LudicManifest.parse(manifest)
|
||||
File(dir, "package.ludic").writeText(manifest)
|
||||
assertNotNull("no lock yet", LudicManifest.staleReason(dir, m))
|
||||
val lock = File(dir, "package.lock.ludic").apply { writeText("lock 1\n") }
|
||||
File(dir, "ludic_modules").mkdirs()
|
||||
lock.setLastModified(File(dir, "package.ludic").lastModified() + 5_000)
|
||||
assertNull("lock newer than the manifest", LudicManifest.staleReason(dir, m))
|
||||
File(dir, "package.ludic").setLastModified(lock.lastModified() + 5_000)
|
||||
assertNotNull("manifest edited after the lock", LudicManifest.staleReason(dir, m))
|
||||
assertNull("no requires, nothing to fetch", LudicManifest.staleReason(dir, LudicManifest.parse("package \"x\"")))
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
fun testDirectivesCompleteAtLineStart() {
|
||||
myFixture.configureByText("package.ludic", "package \"x\"\nre<caret>")
|
||||
val items = myFixture.completeBasic()?.map { it.lookupString } ?: listOf(myFixture.editor.document.text)
|
||||
assertTrue(items.toString(), items.any { it.startsWith("require") } || myFixture.editor.document.text.contains("require "))
|
||||
assertFalse("no Ludic keywords in a manifest", items.contains("let"))
|
||||
}
|
||||
|
||||
fun testAppKeysAndKindsComplete() {
|
||||
myFixture.configureByText("package.ludic", "app <caret>")
|
||||
val keys = myFixture.completeBasic().map { it.lookupString.trim() }
|
||||
assertTrue(keys.containsAll(listOf("name", "id", "icon", "splash_bg")))
|
||||
myFixture.configureByText("package.ludic", "kind <caret>")
|
||||
assertEquals(setOf("source", "prebuilt"), myFixture.completeBasic().map { it.lookupString }.toSet())
|
||||
}
|
||||
|
||||
fun testManifestCompletionStaysOutOfSource() {
|
||||
myFixture.configureByText("main.ludic", "program X {\n re<caret>\n}")
|
||||
val items = myFixture.completeBasic()?.map { it.lookupString } ?: emptyList()
|
||||
assertFalse(items.any { it.startsWith("require ") })
|
||||
}
|
||||
|
||||
fun testGutterActionsOnPackageAndRequireLines() {
|
||||
myFixture.configureByText("package.ludic", manifest)
|
||||
val marked = PsiTreeUtil.collectElements(myFixture.file) { it.firstChild == null }
|
||||
.mapNotNull { e ->
|
||||
RunLineMarkerContributor.EXTENSION.allForLanguage(LudicLanguage)
|
||||
.firstNotNullOfOrNull { it.getInfo(e) }?.let { e.text to it.actions.size }
|
||||
}
|
||||
assertEquals(listOf("package" to 5, "require" to 3, "require" to 3), marked)
|
||||
}
|
||||
|
||||
fun testDirectivesHaveQuickDocumentation() {
|
||||
myFixture.configureByText("package.ludic", manifest)
|
||||
val file = myFixture.file
|
||||
val req = file.findElementAt(file.text.indexOf("require"))!!
|
||||
val doc = LudicManifestDocumentation().generateDoc(req, req)
|
||||
assertNotNull(doc)
|
||||
assertTrue(doc!!.contains("minimum version"))
|
||||
val key = file.findElementAt(file.text.indexOf("name \"My"))!!
|
||||
assertTrue(LudicManifestDocumentation().generateDoc(key, key)!!.contains("display name"))
|
||||
val str = file.findElementAt(file.text.indexOf("0.2.0"))!!
|
||||
assertTrue("a version value explains its tag", LudicManifestDocumentation().generateDoc(str, str)!!.contains("v0.2.0"))
|
||||
val comment = file.findElementAt(file.text.indexOf("the import path"))!!
|
||||
assertNotNull(LudicManifestDocumentation().generateDoc(comment, comment))
|
||||
}
|
||||
|
||||
fun testBannerAppearsOnlyOnTheManifest() {
|
||||
val provider = LudicPackageNotificationProvider()
|
||||
val man = myFixture.configureByText("package.ludic", manifest).virtualFile
|
||||
assertNotNull(provider.collectNotificationData(project, man))
|
||||
val src = myFixture.configureByText("main.ludic", "program X {}").virtualFile
|
||||
assertNull(provider.collectNotificationData(project, src))
|
||||
}
|
||||
|
||||
fun testPathValuesNavigateAndMissingOnesAreErrors() {
|
||||
myFixture.addFileToProject("assets/app/icon.png", "png")
|
||||
myFixture.addFileToProject("src/game.ludic", "program G {}")
|
||||
val file = myFixture.configureByText("package.ludic", "entry \"src/game.ludic\"\napp icon \"assets/app/icon.png\"\npack \"assets/nope\"\nkind sauce\nbogus 1\n")
|
||||
val entry = file.findElementAt(file.text.indexOf("\"src/"))!!
|
||||
val targets = io.ludic.ide.project.LudicManifestGotoHandler().getGotoDeclarationTargets(entry, entry.textOffset, myFixture.editor)
|
||||
assertEquals("game.ludic", (targets!!.single() as com.intellij.psi.PsiFile).name)
|
||||
val icon = file.findElementAt(file.text.indexOf("\"assets/app"))!!
|
||||
assertNotNull(io.ludic.ide.project.manifestTarget(icon))
|
||||
val problems = myFixture.doHighlighting().map { it.description }
|
||||
assertTrue(problems.toString(), problems.any { it?.contains("No such file or directory: assets/nope") == true })
|
||||
assertTrue(problems.toString(), problems.any { it?.startsWith("kind must be one of") == true })
|
||||
assertTrue(problems.toString(), problems.any { it?.contains("Unknown directive 'bogus'") == true })
|
||||
assertFalse(problems.toString(), problems.any { it?.contains("icon.png") == true })
|
||||
}
|
||||
|
||||
fun testAppLinesPreviewTheWholeApp() {
|
||||
val file = myFixture.configureByText("package.ludic", "app name \"Maroon Lake\"\napp id \"io.x.lake\"\napp splash_bg \"#0b1622\"\nversion \"0.1.7\"\n")
|
||||
val el = file.findElementAt(file.text.indexOf("io.x"))!!
|
||||
val doc = LudicManifestDocumentation().generateDoc(el, el)!!
|
||||
assertTrue(doc, doc.contains("Maroon Lake.app"))
|
||||
assertTrue(doc, doc.contains("io.x.lake"))
|
||||
assertTrue(doc, doc.contains("0.1.7"))
|
||||
assertTrue(doc, doc.contains("#0b1622"))
|
||||
val kind = myFixture.configureByText("package.ludic", "kind source\n")
|
||||
val src = kind.findElementAt(kind.text.indexOf("source"))!!
|
||||
assertTrue(LudicManifestDocumentation().generateDoc(src, src)!!.contains("Compiled from source"))
|
||||
}
|
||||
|
||||
fun testLockFileIsReadAsALock() {
|
||||
val text = "# generated\nlock 1\nmodule \"git.x/me/util\" version \"1.2.0\" hash \"abc\" kind \"source\" provides \"Util\"\n"
|
||||
val locked = LudicManifest.parseLock(text).single()
|
||||
assertEquals("git.x/me/util", locked.module)
|
||||
assertEquals("1.2.0", locked.version)
|
||||
assertEquals(listOf("Util"), locked.provides)
|
||||
val file = myFixture.configureByText("package.lock.ludic", text)
|
||||
assertNotNull(LudicPackageNotificationProvider().collectNotificationData(project, file.virtualFile))
|
||||
val mod = file.findElementAt(file.text.indexOf("module"))!!
|
||||
val doc = LudicManifestDocumentation().generateDoc(mod, mod)!!
|
||||
assertTrue(doc, doc.contains("1.2.0") && doc.contains("Util.*"))
|
||||
val problems = myFixture.doHighlighting().map { it.description }
|
||||
assertTrue(problems.toString(), problems.any { it?.contains("not fetched yet") == true })
|
||||
}
|
||||
|
||||
fun testScriptsAndHooks() {
|
||||
val text = "script \"dev\" \"ludic run --headless\"\nscript \"build\" \"echo \\\"x\\\"\"\nhook before build \"tools/prep.sh\"\nhook after dev \"echo done\"\nhook during build \"x\"\nhook before deploy \"x\"\n"
|
||||
val m = LudicManifest.parse(text)
|
||||
assertEquals(listOf("dev", "build"), m.scripts.map { it.name })
|
||||
assertEquals("echo \"x\"", m.scripts[1].command)
|
||||
assertEquals(listOf("before build", "after dev", "during build", "before deploy"), m.hooks.map { "${it.phase} ${it.event}" })
|
||||
|
||||
myFixture.configureByText("package.ludic", text)
|
||||
val problems = myFixture.doHighlighting().mapNotNull { it.description }
|
||||
assertTrue(problems.toString(), problems.any { it.startsWith("A hook runs before or after") })
|
||||
assertTrue(problems.toString(), problems.any { it.startsWith("No command or script named 'deploy'") })
|
||||
assertTrue(problems.toString(), problems.any { it.contains("'ludic build' is a built-in command") })
|
||||
assertFalse(problems.toString(), problems.any { it.contains("'dev'") })
|
||||
|
||||
val marked = PsiTreeUtil.collectElements(myFixture.file) { it.firstChild == null }
|
||||
.mapNotNull { e -> RunLineMarkerContributor.EXTENSION.allForLanguage(LudicLanguage).firstNotNullOfOrNull { it.getInfo(e) }?.let { e.text } }
|
||||
assertEquals(listOf("script", "script"), marked)
|
||||
|
||||
val hook = myFixture.file.findElementAt(text.indexOf("hook after"))!!
|
||||
assertTrue(LudicManifestDocumentation().generateDoc(hook, hook)!!.contains("the script dev"))
|
||||
}
|
||||
|
||||
fun testHookEventsComplete() {
|
||||
myFixture.configureByText("package.ludic", "script \"dev\" \"x\"\nhook before <caret>")
|
||||
val items = myFixture.completeBasic().map { it.lookupString.trim() }
|
||||
assertTrue(items.toString(), items.containsAll(listOf("build", "bundle", "test", "dev")))
|
||||
myFixture.configureByText("package.ludic", "hook <caret>")
|
||||
assertEquals(setOf("before", "after"), myFixture.completeBasic().map { it.lookupString.trim() }.toSet())
|
||||
}
|
||||
|
||||
fun testManifestHasItsOwnIcon() {
|
||||
val file = myFixture.configureByText("package.ludic", manifest)
|
||||
val icon = IconProvider.EXTENSION_POINT_NAME.extensionList.firstNotNullOfOrNull { it.getIcon(file, 0) }
|
||||
assertSame(LudicPackageIcons.MANIFEST, icon)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import io.ludic.ide.project.packageSlug
|
||||
import io.ludic.ide.project.scaffold
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
class LudicNewProjectTest {
|
||||
|
||||
@get:Rule
|
||||
val tmp = TemporaryFolder()
|
||||
|
||||
private val repoCli: File =
|
||||
File(System.getProperty("user.dir")).parentFile.parentFile.parentFile.resolve("bin/ludic")
|
||||
|
||||
@Test
|
||||
fun `project names become names ludic new accepts`() {
|
||||
assertEquals("my-game", packageSlug("my game"))
|
||||
assertEquals("Maroon-Lake", packageSlug("Maroon Lake!"))
|
||||
assertEquals("space_1.0", packageSlug("space_1.0"))
|
||||
assertEquals("game", packageSlug("!!!"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scaffold fills a directory the wizard already created`() {
|
||||
if (!repoCli.canExecute()) return // toolchain not built in this checkout
|
||||
val dir = File(tmp.root, "My Game").apply { mkdirs() }
|
||||
File(dir, ".idea").mkdirs()
|
||||
File(dir, "README.md").writeText("mine")
|
||||
|
||||
assertNull(scaffold(dir, "My Game", repoCli.path))
|
||||
|
||||
assertTrue(File(dir, "package.ludic").readText().contains("My-Game"))
|
||||
assertTrue(File(dir, "src/main.ludic").readText().contains("program "))
|
||||
assertTrue(File(dir, "tests/smoke.ludic").isFile)
|
||||
assertTrue(File(dir, "assets").isDirectory)
|
||||
assertTrue(File(dir, ".idea").isDirectory)
|
||||
assertEquals("an existing file is never overwritten", "mine", File(dir, "README.md").readText())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scaffold without a toolchain explains itself`() {
|
||||
val msg = scaffold(File(tmp.root, "x"), "x", cli = null)
|
||||
assertNotNull(msg)
|
||||
assertTrue(msg!!.contains("install", ignoreCase = true))
|
||||
}
|
||||
}
|
||||
|
|
@ -15,15 +15,15 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
|||
class LudicPlatformTest : BasePlatformTestCase() {
|
||||
|
||||
private val sample = """
|
||||
game Hello {
|
||||
component Pos { x: int = 0, y: int = 0 }
|
||||
archetype Mob { Pos }
|
||||
program Hello {
|
||||
property Pos { x: int = 0, y: int = 0 }
|
||||
model Mob { hp: int }
|
||||
|
||||
# move everything that has a position
|
||||
system Move phase FixedUpdate
|
||||
query (p) [Pos, {Player}]
|
||||
{
|
||||
p.x = p.x + 1
|
||||
handler Move phase FixedUpdate {
|
||||
for (p) in query [Pos] {
|
||||
p.x = p.x + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
|
@ -65,8 +65,9 @@ class LudicPlatformTest : BasePlatformTestCase() {
|
|||
LudicTokens.PRIMITIVE, LudicTokens.PHASE, LudicTokens.BOOLEAN,
|
||||
LudicTokens.BUILTIN, LudicTokens.TYPE_NAME, LudicTokens.STRING,
|
||||
LudicTokens.NUMBER, LudicTokens.COMMENT, LudicTokens.ANNOTATION,
|
||||
LudicTokens.OPERATOR, LudicTokens.BRACE, LudicTokens.BRACKET,
|
||||
LudicTokens.PAREN, LudicTokens.COMMA, LudicTokens.IDENTIFIER
|
||||
LudicTokens.OPERATOR, LudicTokens.LBRACE, LudicTokens.RBRACE, LudicTokens.LBRACKET,
|
||||
LudicTokens.RBRACKET, LudicTokens.LPAREN, LudicTokens.RPAREN, LudicTokens.COMMA,
|
||||
LudicTokens.IDENTIFIER, LudicTokens.MANIFEST_KEYWORD
|
||||
)
|
||||
for (token in coloured) {
|
||||
val keys = highlighter.getTokenHighlights(token)
|
||||
|
|
@ -78,14 +79,14 @@ class LudicPlatformTest : BasePlatformTestCase() {
|
|||
val file = ludicFile()
|
||||
val regions = LudicFoldingBuilder().buildFoldRegions(file.node, file.viewProvider.document!!)
|
||||
|
||||
// The sample has exactly two blocks that span lines: the `game` body and
|
||||
// the `system` body. `component Pos { … }`, `archetype Mob { … }` and the
|
||||
// `{Player}` query filter all fit on one line, and folding a single line
|
||||
// gains nothing, so they are deliberately skipped.
|
||||
assertEquals("multi-line blocks only", 2, regions.size)
|
||||
// The sample has exactly three blocks that span lines: the `program`
|
||||
// body, the `handler` body and the `for` loop. `property Pos { … }` and
|
||||
// `model Mob { … }` fit on one line, and folding a single line gains
|
||||
// nothing, so they are deliberately skipped.
|
||||
assertEquals("multi-line blocks only", 3, regions.size)
|
||||
|
||||
val outermost = regions.maxByOrNull { it.range.length }!!
|
||||
assertEquals("outermost fold starts at the game block's brace",
|
||||
assertEquals("outermost fold starts at the program block's brace",
|
||||
sample.indexOf('{'), outermost.range.startOffset)
|
||||
assertEquals("outermost fold ends at the last brace",
|
||||
sample.length, outermost.range.endOffset)
|
||||
|
|
@ -97,26 +98,25 @@ class LudicPlatformTest : BasePlatformTestCase() {
|
|||
}
|
||||
|
||||
fun testCommenterUsesHashWithASpace() {
|
||||
// The trailing space is deliberate: Ctrl+/ should produce "# code",
|
||||
// which is how every comment in the runtime and examples is written.
|
||||
// No trailing space in the prefix: the code style's "add a space"
|
||||
// option supplies it, so Ctrl+/ gives "# code" and not "# code".
|
||||
val commenter = LudicCommenter()
|
||||
assertEquals("# ", commenter.lineCommentPrefix)
|
||||
assertEquals("#", commenter.lineCommentPrefix)
|
||||
assertNull("Ludic has no block comments", commenter.blockCommentPrefix)
|
||||
}
|
||||
|
||||
fun testBraceMatcherPairsAllThreeBracketKinds() {
|
||||
fun testBraceMatcherPairsEveryBracketKind() {
|
||||
val matcher = LudicBraceMatcher()
|
||||
val pairs = matcher.pairs
|
||||
val open = pairs.map { it.leftBraceType }.toSet()
|
||||
assertTrue(open.contains(LudicTokens.BRACE))
|
||||
assertTrue(open.contains(LudicTokens.BRACKET))
|
||||
assertTrue(open.contains(LudicTokens.PAREN))
|
||||
assertEquals(setOf(LudicTokens.LBRACE, LudicTokens.LBRACKET, LudicTokens.LPAREN, LudicTokens.TEMPLATE_HOLE_START), open)
|
||||
assertEquals(setOf(LudicTokens.RBRACE, LudicTokens.RBRACKET, LudicTokens.RPAREN, LudicTokens.TEMPLATE_HOLE_END), pairs.map { it.rightBraceType }.toSet())
|
||||
}
|
||||
|
||||
fun testHalfTypedFileStillProducesAPsiTree() {
|
||||
// The IDE must survive what a file looks like mid-keystroke; a parser
|
||||
// that throws here takes highlighting and indexing down with it.
|
||||
val broken = "game X {\n component P { x: int =\n system"
|
||||
val broken = "program X {\n property P { x: int =\n handler"
|
||||
val file = myFixture.configureByText("broken.ludic", broken)
|
||||
assertEquals(broken.length, file.textLength)
|
||||
assertEquals(LudicLanguage, file.language)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.execution.actions.ConfigurationContext
|
||||
import com.intellij.execution.executors.DefaultRunExecutor
|
||||
import com.intellij.execution.filters.OpenFileHyperlinkInfo
|
||||
import com.intellij.execution.lineMarker.RunLineMarkerContributor
|
||||
import com.intellij.execution.process.ProcessOutputTypes
|
||||
import com.intellij.execution.testframework.sm.runner.SMTestLocator
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.search.GlobalSearchScope
|
||||
import com.intellij.psi.util.PsiTreeUtil
|
||||
import com.intellij.testFramework.MapDataContext
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import com.intellij.openapi.actionSystem.CommonDataKeys
|
||||
import com.intellij.openapi.actionSystem.PlatformCoreDataKeys
|
||||
import com.intellij.execution.Location
|
||||
import com.intellij.execution.PsiLocation
|
||||
import io.ludic.ide.psi.LudicPsi
|
||||
import io.ludic.ide.run.LudicCommand
|
||||
import io.ludic.ide.run.LudicConfigurationType
|
||||
import io.ludic.ide.run.LudicConsoleFilter
|
||||
import io.ludic.ide.run.LudicRunConfiguration
|
||||
import io.ludic.ide.run.LudicRunConfigurationProducer
|
||||
import io.ludic.ide.run.LudicTestConsoleProperties
|
||||
import io.ludic.ide.run.LudicTestEventsConverter
|
||||
import io.ludic.ide.run.LudicTestLocator
|
||||
import io.ludic.ide.run.LudicTestUrls
|
||||
import com.intellij.execution.configurations.ConfigurationTypeUtil
|
||||
|
||||
/**
|
||||
* Run configurations, gutter buttons, the test tree and console links —
|
||||
* everything between "click the green arrow" and "see which test failed".
|
||||
*/
|
||||
class LudicRunTest : BasePlatformTestCase() {
|
||||
|
||||
private val spec = """
|
||||
program MathSpec {
|
||||
function add(a: int, b: int) -> int { return a + b }
|
||||
|
||||
test "adds" {
|
||||
expect_eq(add(1, 1), 2)
|
||||
}
|
||||
|
||||
test "it's \"quoted\"" { expect(true) }
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private val game = """
|
||||
program Game {
|
||||
handler Boot phase Start { print("hi") }
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun leafAt(marker: String): PsiElement {
|
||||
val offset = myFixture.file.text.indexOf(marker)
|
||||
assertTrue("marker '$marker' not found", offset >= 0)
|
||||
return myFixture.file.findElementAt(offset)!!
|
||||
}
|
||||
|
||||
private fun configFor(element: PsiElement): LudicRunConfiguration? {
|
||||
val ctx = ConfigurationContext.getFromContext(
|
||||
MapDataContext().apply {
|
||||
put(CommonDataKeys.PROJECT, project)
|
||||
put(PlatformCoreDataKeys.MODULE, myFixture.module)
|
||||
put(Location.DATA_KEY, PsiLocation(element))
|
||||
},
|
||||
"test"
|
||||
)
|
||||
val producer = LudicRunConfigurationProducer()
|
||||
val from = producer.createConfigurationFromContext(ctx) ?: return null
|
||||
val config = from.configuration as LudicRunConfiguration
|
||||
assertTrue("the producer must recognise its own configuration", producer.isConfigurationFromContext(config, ctx))
|
||||
return config
|
||||
}
|
||||
|
||||
fun testTestHeaderRunsJustThatTest() {
|
||||
myFixture.configureByText("math_test.ludic", spec)
|
||||
val c = configFor(leafAt("test \"adds\""))!!
|
||||
c.workingDirectory = "/src"
|
||||
assertEquals(LudicCommand.TEST, c.command)
|
||||
assertEquals("adds", c.testName)
|
||||
assertEquals("Test 'adds'", c.name)
|
||||
assertEquals(listOf("test", "math_test.ludic", "--verbose", "--test", "adds"), c.cliArguments())
|
||||
}
|
||||
|
||||
fun testEscapedTestNamesAreUnescaped() {
|
||||
myFixture.configureByText("math_test.ludic", spec)
|
||||
val kw = LudicPsi.testKeywords(myFixture.file)
|
||||
assertEquals(listOf("adds", "it's \"quoted\""), kw.map { LudicPsi.testNameAt(it) })
|
||||
assertEquals("it's \"quoted\"", configFor(kw[1].nextSibling.nextSibling)!!.testName)
|
||||
}
|
||||
|
||||
fun testTestFileRunsAllItsTests() {
|
||||
myFixture.configureByText("math_test.ludic", spec)
|
||||
val c = configFor(leafAt("function"))!!
|
||||
assertEquals(LudicCommand.TEST, c.command)
|
||||
assertEquals("", c.testName)
|
||||
assertEquals("Test math_test.ludic", c.name)
|
||||
}
|
||||
|
||||
fun testProgramFileRuns() {
|
||||
myFixture.configureByText("game.ludic", game)
|
||||
val c = configFor(leafAt("print"))!!
|
||||
c.workingDirectory = "/src"
|
||||
assertEquals(LudicCommand.RUN, c.command)
|
||||
assertEquals(listOf("run", "game.ludic"), c.cliArguments())
|
||||
c.headless = true
|
||||
assertEquals(listOf("run", "game.ludic", "--headless"), c.cliArguments())
|
||||
}
|
||||
|
||||
fun testCustomCommandPassesArgumentsThrough() {
|
||||
val type = ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java)
|
||||
val c = type.factory.createTemplateConfiguration(project) as LudicRunConfiguration
|
||||
c.command = LudicCommand.CUSTOM
|
||||
c.arguments = "fmt --check \"src/a b.ludic\""
|
||||
assertEquals(listOf("fmt", "--check", "src/a b.ludic"), c.cliArguments())
|
||||
}
|
||||
|
||||
fun testConfigurationSurvivesSerialization() {
|
||||
val type = ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java)
|
||||
val c = type.factory.createTemplateConfiguration(project) as LudicRunConfiguration
|
||||
c.command = LudicCommand.TEST
|
||||
c.file = "/tmp/x.ludic"
|
||||
c.testName = "adds"
|
||||
c.env = mapOf("R3D_GAME" to "aimprobe")
|
||||
val element = org.jdom.Element("configuration")
|
||||
c.writeExternal(element)
|
||||
val back = type.factory.createTemplateConfiguration(project) as LudicRunConfiguration
|
||||
back.readExternal(element)
|
||||
assertEquals(LudicCommand.TEST, back.command)
|
||||
assertEquals("/tmp/x.ludic", back.file)
|
||||
assertEquals("adds", back.testName)
|
||||
assertEquals(mapOf("R3D_GAME" to "aimprobe"), back.env)
|
||||
}
|
||||
|
||||
fun testGutterMarksEveryTestAndTheProgram() {
|
||||
myFixture.configureByText("math_test.ludic", spec)
|
||||
val marked = PsiTreeUtil.collectElements(myFixture.file) { it.firstChild == null }
|
||||
.filter { e -> RunLineMarkerContributor.EXTENSION.allForLanguage(LudicLanguage).any { it.getInfo(e) != null } }
|
||||
.map { it.text }
|
||||
assertEquals(listOf("program", "test", "test"), marked)
|
||||
}
|
||||
|
||||
fun testLocatorFindsTheTestBlock() {
|
||||
val file = myFixture.configureByText("math_test.ludic", spec)
|
||||
val path = file.virtualFile.path
|
||||
val url = LudicTestUrls.test(path, "adds").removePrefix("ludic://")
|
||||
val loc = LudicTestLocator.getLocation("ludic", url, project, GlobalSearchScope.allScope(project)).single()
|
||||
assertEquals("test", loc.psiElement.text)
|
||||
assertEquals(file.text.indexOf("test \"adds\""), loc.psiElement.textOffset)
|
||||
val fileLoc = (LudicTestLocator as SMTestLocator).getLocation("ludic", path, project, GlobalSearchScope.allScope(project)).single()
|
||||
assertEquals(file, fileLoc.psiElement)
|
||||
}
|
||||
|
||||
fun testUrlSplitKeepsHashesInTestNames() {
|
||||
assertEquals("/a/b.ludic" to "x # y", LudicTestUrls.split("/a/b.ludic#x # y"))
|
||||
assertEquals("/a/b.ludic" to null, LudicTestUrls.split("/a/b.ludic"))
|
||||
}
|
||||
|
||||
// ---- the output converter ---------------------------------------------
|
||||
|
||||
private fun convert(vararg lines: String): List<String> {
|
||||
val type = ConfigurationTypeUtil.findConfigurationType(LudicConfigurationType::class.java)
|
||||
val c = type.factory.createTemplateConfiguration(project) as LudicRunConfiguration
|
||||
c.command = LudicCommand.TEST
|
||||
c.workingDirectory = "/proj"
|
||||
val props = LudicTestConsoleProperties(c, DefaultRunExecutor.getRunExecutorInstance())
|
||||
val out = mutableListOf<String>()
|
||||
val conv = object : LudicTestEventsConverter("Ludic", props, "/proj") {
|
||||
override fun forward(text: String, outputType: Key<*>) {
|
||||
if (text.startsWith("##teamcity")) out += text.trim()
|
||||
}
|
||||
fun feed(l: String) = processConsistentText(l + "\n", ProcessOutputTypes.STDOUT)
|
||||
}
|
||||
lines.forEach { conv.feed(it) }
|
||||
conv.flushBufferOnProcessTermination(0)
|
||||
return out.map { it.removePrefix("##teamcity[").substringBefore(" ") + " " + (Regex("name='([^']*)'").find(it)?.groupValues?.get(1) ?: "") }
|
||||
}
|
||||
|
||||
fun testVerboseOutputBecomesATestTree() {
|
||||
val events = convert(
|
||||
" RUN tests/smoke.ludic",
|
||||
"ok - adds",
|
||||
"smoke.ludic:12: expect_eq failed (got 1, want 2)",
|
||||
"FAIL - subtracts",
|
||||
"== 1 passed, 1 failed ==",
|
||||
" [31mFAIL[0m tests/smoke.ludic (exit 1)",
|
||||
" RUN tests/other.ludic",
|
||||
"ok - x.ludic",
|
||||
"== 1 passed, 0 failed ==",
|
||||
" [32mPASS[0m tests/other.ludic",
|
||||
"",
|
||||
"== 1 of 2 test files failed ==",
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"testSuiteStarted tests/smoke.ludic",
|
||||
"testStarted adds", "testFinished adds",
|
||||
"testStarted subtracts", "testFailed subtracts", "testFinished subtracts",
|
||||
"testSuiteFinished tests/smoke.ludic",
|
||||
"testSuiteStarted tests/other.ludic",
|
||||
"testStarted x.ludic", "testFinished x.ludic",
|
||||
"testSuiteFinished tests/other.ludic",
|
||||
),
|
||||
events
|
||||
)
|
||||
}
|
||||
|
||||
fun testCompileFailureShowsAsAFailedNode() {
|
||||
val events = convert(
|
||||
" RUN tests/broken.ludic",
|
||||
"broken.ludic:2: error: unknown identifier zz",
|
||||
" FAIL tests/broken.ludic (did not compile)",
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"testSuiteStarted tests/broken.ludic",
|
||||
"testStarted (compile)", "testFailed (compile)", "testFinished (compile)",
|
||||
"testSuiteFinished tests/broken.ludic",
|
||||
),
|
||||
events
|
||||
)
|
||||
}
|
||||
|
||||
fun testUnknownTestNameIsAFailure() {
|
||||
val events = convert(" RUN tests/a.ludic", "no test named \"nope\"", " FAIL tests/a.ludic (exit 1)")
|
||||
assertEquals(
|
||||
listOf("testSuiteStarted tests/a.ludic", "testStarted nope", "testFailed nope", "testFinished nope", "testSuiteFinished tests/a.ludic"),
|
||||
events
|
||||
)
|
||||
}
|
||||
|
||||
fun testOldToolchainOutputStillGivesFileResults() {
|
||||
// an install without --verbose: PASS lines alone, and a failing
|
||||
// file's runner output after its FAIL line
|
||||
val events = convert(
|
||||
" PASS tests/a.ludic",
|
||||
" FAIL tests/b.ludic (exit 1)",
|
||||
"ok - one",
|
||||
"FAIL - two",
|
||||
"== 1 passed, 1 failed ==",
|
||||
" PASS tests/c.ludic",
|
||||
"",
|
||||
"== 1 of 3 test files failed ==",
|
||||
)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"testSuiteStarted tests/a.ludic", "testSuiteFinished tests/a.ludic",
|
||||
"testSuiteStarted tests/b.ludic",
|
||||
"testStarted one", "testFinished one",
|
||||
"testStarted two", "testFailed two", "testFinished two",
|
||||
"testSuiteFinished tests/b.ludic",
|
||||
"testSuiteStarted tests/c.ludic", "testSuiteFinished tests/c.ludic",
|
||||
),
|
||||
events
|
||||
)
|
||||
}
|
||||
|
||||
// ---- console links -----------------------------------------------------
|
||||
|
||||
fun testConsoleLinksResolveBaseNamesAcrossTheProject() {
|
||||
val file = myFixture.addFileToProject("tests/smoke.ludic", "program S {\n test \"t\" { expect(false) }\n}\n")
|
||||
val filter = LudicConsoleFilter(project, "/nonexistent")
|
||||
val line = "smoke.ludic:2: expect failed\n"
|
||||
val result = filter.applyFilter(line, line.length)!!
|
||||
val item = result.resultItems.single()
|
||||
assertEquals(0, item.highlightStartOffset)
|
||||
assertEquals("smoke.ludic:2".length, item.highlightEndOffset)
|
||||
val info = item.hyperlinkInfo as OpenFileHyperlinkInfo
|
||||
assertEquals(file.virtualFile, info.descriptor!!.file)
|
||||
assertEquals(file.text.indexOf(" test"), info.descriptor!!.offset)
|
||||
}
|
||||
|
||||
fun testConsoleLinksIgnoreOtherText() {
|
||||
val filter = LudicConsoleFilter(project, null)
|
||||
assertNull(filter.applyFilter("built build/demo\n", 17))
|
||||
assertNull(filter.applyFilter("missing.ludic:3: error\n", 23))
|
||||
}
|
||||
}
|
||||
|
|
@ -289,6 +289,54 @@ function cmd_test_lsp() -> int {
|
|||
# ---- workspace-wide symbol search ---------------------------------------
|
||||
let ws = lsp_request("workspace/symbol", `{{"query":"Stats"}}`)
|
||||
|
||||
# ---- navigation an IDE leans on --------------------------------------------
|
||||
# package imports resolve from the toolchain's packages/, import strings are
|
||||
# links, locals of both kinds have a type, built-ins lead to their reference
|
||||
# page, and a call's argument list completes its parameter names.
|
||||
let nsrc = "program Nav {\n import \"ludic.render3d/fmath.ludic\"\n function area(width: int, height: int) -> int { return width * height }\n entry {\n var total = 0\n let name = \"x\"\n total = total + area(width: 2, )\n Screen.fill_circle(x: 1, y: f_clamp(1, 0, 2), )\n let s = Screen.\n }\n}\n"
|
||||
let nfile = tmp_path("lsp_nav.ludic")
|
||||
write_file(nfile, nsrc)
|
||||
lsp_open(nfile, "ludic")
|
||||
let nuri = lsp_doc(nfile)
|
||||
let links = lsp_request("textDocument/documentLink", `{{"textDocument":{nuri}}}`)
|
||||
let pkgdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "ludic.render3d", 3)}}}`)
|
||||
let xdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "f_clamp", 2)}}}`)
|
||||
let vhov = lsp_request("textDocument/hover", `{{"textDocument":{nuri},"position":{position_of(nsrc, "total = total", 10)}}}`)
|
||||
let lhov = lsp_request("textDocument/hover", `{{"textDocument":{nuri},"position":{position_of(nsrc, "name = ", 1)}}}`)
|
||||
let vrefs = lsp_request("textDocument/references", `{{"textDocument":{nuri},"position":{position_of(nsrc, "var total", 5)},"context":{{"includeDeclaration":true}}}}`)
|
||||
let bdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "fill_circle", 2)}}}`)
|
||||
let bhov = lsp_request("textDocument/hover", `{{"textDocument":{nuri},"position":{position_of(nsrc, "fill_circle", 2)}}}`)
|
||||
let tdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "int, height", 1)}}}`)
|
||||
let acomp = lsp_request("textDocument/completion", `{{"textDocument":{nuri},"position":{position_of(nsrc, "2, )", 3)}}}`)
|
||||
let nscomp = lsp_request("textDocument/completion", `{{"textDocument":{nuri},"position":{position_of(nsrc, "= Screen.", 9)}}}`)
|
||||
let ahelp = lsp_request("textDocument/signatureHelp", `{{"textDocument":{nuri},"position":{position_of(nsrc, "2), )", 4)}}}`)
|
||||
|
||||
# contextual keywords, highlights, barrels, semantic modifiers
|
||||
let kdir = tmp_path("lsp_barrel")
|
||||
shell(`mkdir -p {kdir}/camp`)
|
||||
write_file(`{kdir}/camp/index.ludic`, "import \"tent.ludic\"\n")
|
||||
write_file(`{kdir}/camp/tent.ludic`, "function pitch(size: int) -> int { return size }\n")
|
||||
let ksrc = "program K {\n import \"camp\"\n import \"ludic.render3d/fmath.ludic\"\n function go() -> int {\n var model = pitch(2)\n model = model + f_clamp(1, 0, 2)\n Screen.clear(0)\n return model\n }\n}\n"
|
||||
let kfile = `{kdir}/k.ludic`
|
||||
write_file(kfile, ksrc)
|
||||
lsp_open(kfile, "ludic")
|
||||
let kuri = lsp_doc(kfile)
|
||||
let khov = lsp_request("textDocument/hover", `{{"textDocument":{kuri},"position":{position_of(ksrc, "var model", 5)}}}`)
|
||||
let krefs = lsp_request("textDocument/references", `{{"textDocument":{kuri},"position":{position_of(ksrc, "var model", 5)},"context":{{"includeDeclaration":true}}}}`)
|
||||
let kbarrel = lsp_request("textDocument/definition", `{{"textDocument":{kuri},"position":{position_of(ksrc, "pitch(2)", 1)}}}`)
|
||||
let khl = lsp_request("textDocument/documentHighlight", `{{"textDocument":{kuri},"position":{position_of(ksrc, "f_clamp", 1)}}}`)
|
||||
let ktoks = lsp_request("textDocument/semanticTokens/full", `{{"textDocument":{kuri}}}`)
|
||||
|
||||
# names inside `template {holes}`, and positions after a multi-line template
|
||||
let tsrc = "program TT {\n function tag(variant: int) -> pointer {\n let multi = `a\nb {variant}`\n return `rock_{variant}` + multi\n }\n}\n"
|
||||
let tfile = tmp_path("lsp_tmpl.ludic")
|
||||
write_file(tfile, tsrc)
|
||||
lsp_open(tfile, "ludic")
|
||||
let turi = lsp_doc(tfile)
|
||||
let thov = lsp_request("textDocument/hover", `{{"textDocument":{turi},"position":{position_of(tsrc, "rock_{variant", 7)}}}`)
|
||||
let trefs = lsp_request("textDocument/references", `{{"textDocument":{turi},"position":{position_of(tsrc, "tag(variant", 5)},"context":{{"includeDeclaration":true}}}}`)
|
||||
let tafter = lsp_request("textDocument/hover", `{{"textDocument":{turi},"position":{position_of(tsrc, "+ multi", 3)}}}`)
|
||||
|
||||
lsp_request("shutdown", "null")
|
||||
lsp_notify("exit", "null")
|
||||
|
||||
|
|
@ -322,7 +370,10 @@ function cmd_test_lsp() -> int {
|
|||
check_true("find-usages spans the compilation unit", distinct_files(lsp_result(refs)) >= 3)
|
||||
check_true("inlay hints name query binding types", j_any_field(lsp_result(hints), "label", ": Stats"))
|
||||
let sigs = j_get(lsp_result(sig), "signatures")
|
||||
check_true("signature help for a namespaced builtin", len(sigs.kids) > 0 and Text.starts_with(j_get(sigs.kids[0], "label").s, "rng_chance("))
|
||||
# the documented, namespaced spelling (docs/language) when the reference is
|
||||
# present, else the builtin the method lowers to
|
||||
check_true("signature help for a namespaced builtin", len(sigs.kids) > 0 and (Text.starts_with(j_get(sigs.kids[0], "label").s, "Random.chance(") or Text.starts_with(j_get(sigs.kids[0], "label").s, "rng_chance(")))
|
||||
check_true("signature help splits the parameters", len(sigs.kids) > 0 and len(j_get(sigs.kids[0], "parameters").kids) == 1)
|
||||
let tdata = j_get(lsp_result(toks), "data")
|
||||
check_true("semantic tokens produced", len(tdata.kids) / 5 > 100)
|
||||
check_true("semantic token stream is well formed", len(tdata.kids) % 5 == 0)
|
||||
|
|
@ -369,5 +420,41 @@ function cmd_test_lsp() -> int {
|
|||
while ci < len(ccmsgs) { if Text.contains(ccmsgs[ci], "expected expression") { cc_seen = true }; ci += 1 }
|
||||
check_true("ludicc's own error is published on save (compilerDiagnostics)", cc_seen)
|
||||
|
||||
let link0 = lsp_result(links)
|
||||
check_true("an import string is a link to its file", len(link0.kids) == 1 and Text.ends_with(j_get(link0.kids[0], "target").s, "packages/ludic.render3d/fmath.ludic"))
|
||||
let pd = lsp_result(pkgdef)
|
||||
check_true("go-to-definition on a package import opens the package file", len(pd.kids) == 1 and Text.ends_with(j_get(pd.kids[0], "uri").s, "packages/ludic.render3d/fmath.ludic"))
|
||||
check_true("a name from an imported package resolves into it", Text.ends_with(j_get(lsp_result(xdef), "uri").s, "fmath.ludic"))
|
||||
check_true("hover gives a var local its inferred type", Text.contains(j_get(j_get(lsp_result(vhov), "contents"), "value").s, "var total: int"))
|
||||
check_true("hover gives a let local its inferred type", Text.contains(j_get(j_get(lsp_result(lhov), "contents"), "value").s, "let name: string"))
|
||||
check("find-usages of a var local", loc_lines_key(lsp_result(vrefs)), "4,6")
|
||||
check_true("go-to-definition on a namespace method opens its reference page", Text.ends_with(j_get(lsp_result(bdef), "uri").s, "screen-fill_circle.md"))
|
||||
check_true("hover on a namespace method links its documentation", Text.contains(j_get(j_get(lsp_result(bhov), "contents"), "value").s, "screen-fill_circle.html"))
|
||||
check_true("go-to-definition on a built-in type opens its reference page", Text.ends_with(j_get(lsp_result(tdef), "uri").s, "type-int.md"))
|
||||
let aitems = j_get(lsp_result(acomp), "items")
|
||||
check_true("an argument list completes the parameters not given yet", j_any_field(aitems, "label", "height:") and not j_any_field(aitems, "label", "width:") and not j_any_field(aitems, "label", "let"))
|
||||
check_true("Namespace. completes its members", j_any_field(j_get(lsp_result(nscomp), "items"), "label", "fill_circle"))
|
||||
let ah = lsp_result(ahelp)
|
||||
check_true("signature help tracks the argument after a named one", j_get(ah, "activeParameter").num == 2 and len(j_get(j_get(ah, "signatures").kids[0], "parameters").kids) == 4)
|
||||
|
||||
check_true("a declaration word used as a variable is a variable", Text.contains(j_get(j_get(lsp_result(khov), "contents"), "value").s, "var model: int"))
|
||||
check("find-usages of a local named like a keyword", loc_lines_key(lsp_result(krefs)), "4,5,7")
|
||||
check_true("an import of a directory reads its index.ludic barrel", Text.ends_with(j_get(lsp_result(kbarrel), "uri").s, "camp/tent.ludic"))
|
||||
let hl = lsp_result(khl)
|
||||
check_true("highlight usages stays inside the document", len(hl.kids) == 1 and j_get(j_get(j_get(hl.kids[0], "range"), "start"), "line").num == 5)
|
||||
let kd = j_get(lsp_result(ktoks), "data")
|
||||
var vendor_seen = false
|
||||
var lib_seen = false
|
||||
var ti = 0
|
||||
while ti + 4 < len(kd.kids) {
|
||||
if (kd.kids[ti + 4].num & 8) != 0 { vendor_seen = true }
|
||||
if (kd.kids[ti + 4].num & 4) != 0 and kd.kids[ti + 3].num == 5 { lib_seen = true }
|
||||
ti += 5
|
||||
}
|
||||
check_true("semantic tokens mark package symbols as vendor", vendor_seen)
|
||||
check_true("semantic tokens mark library calls as defaultLibrary", lib_seen)
|
||||
check_true("a name inside a template hole resolves", Text.contains(j_get(j_get(lsp_result(thov), "contents"), "value").s, "param variant: int"))
|
||||
check("find-usages reaches into template holes", loc_lines_key(lsp_result(trefs)), "1,3,4")
|
||||
check_true("positions after a multi-line template stay on the right line", Text.contains(j_get(j_get(lsp_result(tafter), "contents"), "value").s, "let multi"))
|
||||
return report()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ program Ludic {
|
|||
import "packignore.ludic"
|
||||
import "pack.ludic"
|
||||
import "bundle.ludic"
|
||||
import "scripts.ludic"
|
||||
|
||||
function usage() -> void {
|
||||
print("ludic — the toolchain for the Ludic language")
|
||||
|
|
@ -39,7 +40,8 @@ program Ludic {
|
|||
print(" run [file] [--headless] build the project and run it")
|
||||
print(" build [file] [--headless] [-o out]")
|
||||
print(" compile to build/<name> (a native binary, nothing to ship beside it)")
|
||||
print(" test [file...] compile and run the project's tests")
|
||||
print(" test [file...] [-v] [--test NAME]")
|
||||
print(" compile and run the project's tests (-v: every test's line)")
|
||||
print(" clean remove build/")
|
||||
print("")
|
||||
print("packages:")
|
||||
|
|
@ -67,6 +69,11 @@ program Ludic {
|
|||
print(" lsp run the language server on stdio (what editors spawn)")
|
||||
print(" help this message")
|
||||
print("")
|
||||
print("package scripts (package.ludic):")
|
||||
print(" script <name> [args] run a `script \"name\" \"command\"` line (or just: ludic <name>)")
|
||||
print(" scripts list the package's scripts and hooks")
|
||||
print(" `hook before|after <command> \"…\"` wraps build, run, test, bundle, …")
|
||||
print("")
|
||||
print("A file argument runs it: ludic mygame.ludic")
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +98,8 @@ program Ludic {
|
|||
if (cmd == "lsp") { return cmd_lsp() }
|
||||
if (cmd == "doctor") { return cmd_doctor() }
|
||||
if (cmd == "upgrade") { return cmd_upgrade() }
|
||||
if (cmd == "script") { return cmd_script() }
|
||||
if (cmd == "scripts") { return cmd_scripts() }
|
||||
|
||||
if (cmd == "add") { return cmd_pkg_add() }
|
||||
if (cmd == "get") { return cmd_pkg_get() }
|
||||
|
|
@ -128,7 +137,16 @@ program Ludic {
|
|||
tmp_cleanup()
|
||||
exit(rc)
|
||||
}
|
||||
let code = dispatch(cmd)
|
||||
# package.ludic hooks wrap the built-in commands: `before` can veto,
|
||||
# `after` runs only when the command succeeded
|
||||
if is_hookable(cmd) {
|
||||
let pre = run_hooks("before", cmd)
|
||||
if pre != 0 { tmp_cleanup(); exit(pre) }
|
||||
}
|
||||
var code = dispatch(cmd)
|
||||
if code == 0 and is_hookable(cmd) { code = run_hooks("after", cmd) }
|
||||
# an unknown command may be one of the package's scripts
|
||||
if code == -1 { code = run_script(cmd, 2) }
|
||||
tmp_cleanup()
|
||||
if code >= 0 { exit(code) }
|
||||
err(`ludic: unknown command '{cmd}'\n`)
|
||||
|
|
|
|||
|
|
@ -33,8 +33,11 @@ property Manifest {
|
|||
provides: []pointer, # the Foo.* namespace(s) this package registers
|
||||
targets: []pointer, # prebuilt: the targets it ships (native-arm64, wasm32, …)
|
||||
deps: []Dep,
|
||||
entry: pointer = "", # `entry "src/game.ludic"` — the program run/build/bundle compile
|
||||
packs: []pointer, # `pack "<dir>"` — asset roots that go into the .lpak
|
||||
app: []pointer # `app <key> "<value>"` — flattened key, value, key, value…
|
||||
app: []pointer, # `app <key> "<value>"` — flattened key, value, key, value…
|
||||
scripts: []pointer, # `script "<name>" "<command>"` — flattened name, command, …
|
||||
hooks: []pointer # `hook <before|after> <event> "<command>"` — flattened triples
|
||||
}
|
||||
|
||||
function manifest_new() -> Manifest {
|
||||
|
|
@ -43,11 +46,14 @@ function manifest_new() -> Manifest {
|
|||
m.ver = ""
|
||||
m.kind = "source"
|
||||
m.hash = ""
|
||||
m.entry = ""
|
||||
m.provides = new []pointer
|
||||
m.targets = new []pointer
|
||||
m.deps = new []Dep
|
||||
m.packs = new []pointer
|
||||
m.app = new []pointer
|
||||
m.scripts = new []pointer
|
||||
m.hooks = new []pointer
|
||||
return m
|
||||
}
|
||||
|
||||
|
|
@ -114,11 +120,18 @@ function tok_line(line: pointer) -> []pointer {
|
|||
let c = line[i]
|
||||
if c == '#' { i = n } # '#' comment
|
||||
else if c == ' ' or c == '\t' { i += 1 } # whitespace
|
||||
else if c == '"' { # "quoted"
|
||||
else if c == '"' { # "quoted", with \" and \\ escapes
|
||||
i += 1
|
||||
let start = i
|
||||
while i < n and line[i] != '"' { i += 1 }
|
||||
push(toks, sslice(line, start, i))
|
||||
var word = ""
|
||||
var start = i
|
||||
while i < n and line[i] != '"' {
|
||||
if line[i] == 92 and i + 1 < n {
|
||||
word = word + sslice(line, start, i) + sslice(line, i + 1, i + 2)
|
||||
i += 2
|
||||
start = i
|
||||
} else { i += 1 }
|
||||
}
|
||||
push(toks, word + sslice(line, start, i))
|
||||
if i < n { i += 1 }
|
||||
} else { # bare word
|
||||
let start = i
|
||||
|
|
@ -158,6 +171,7 @@ function parse_manifest(text: pointer) -> Manifest {
|
|||
if head == "package" { if len(ts) > 1 { m.module = ts[1] } }
|
||||
else if head == "version" { if len(ts) > 1 { m.ver = ts[1] } }
|
||||
else if head == "kind" { if len(ts) > 1 { m.kind = ts[1] } }
|
||||
else if head == "entry" { if len(ts) > 1 { m.entry = ts[1] } }
|
||||
else if head == "provides" { var k = 1; while k < len(ts) { push(m.provides, ts[k]); k += 1 } }
|
||||
else if head == "targets" { var k = 1; while k < len(ts) { push(m.targets, ts[k]); k += 1 } }
|
||||
else if head == "require" {
|
||||
|
|
@ -169,6 +183,12 @@ function parse_manifest(text: pointer) -> Manifest {
|
|||
# `app name "Maroon Lake"` — the metadata a macOS bundle is built from.
|
||||
# Held as a flat key/value list rather than a property per key, so a new
|
||||
# Info.plist field is a line in the bundler and nothing here.
|
||||
else if head == "script" {
|
||||
if len(ts) > 2 { push(m.scripts, ts[1]); push(m.scripts, ts[2]) }
|
||||
}
|
||||
else if head == "hook" {
|
||||
if len(ts) > 3 { push(m.hooks, ts[1]); push(m.hooks, ts[2]); push(m.hooks, ts[3]) }
|
||||
}
|
||||
else if head == "app" {
|
||||
if len(ts) > 2 { push(m.app, ts[1]); push(m.app, ts[2]) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,14 +47,25 @@ function project_name(entry: pointer) -> pointer {
|
|||
return capture_line(`basename {entry} .ludic`)
|
||||
}
|
||||
|
||||
# The program to compile. An explicit path always wins; otherwise the
|
||||
# conventional entry points are tried in order, and finally a lone .ludic file in
|
||||
# the current directory (so a scratch directory needs no ceremony). "" when
|
||||
# nothing was found.
|
||||
# The program to compile. An explicit path always wins; then package.ludic's
|
||||
# `entry "…"`; then the conventional entry points in order; then the one file
|
||||
# under src/ that declares a `program` (the rest being modules it imports); and
|
||||
# finally a lone .ludic file in the current directory (so a scratch directory
|
||||
# needs no ceremony). "" when nothing was found.
|
||||
function find_entry(explicit: pointer) -> pointer {
|
||||
if explicit != "" { return explicit }
|
||||
if file_exists("package.ludic") {
|
||||
let m = read_root_manifest()
|
||||
if m.entry != "" {
|
||||
if file_exists(m.entry) { return m.entry }
|
||||
err(`ludic: package.ludic names entry "{m.entry}", which does not exist\n`)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if file_exists("src/main.ludic") { return "src/main.ludic" }
|
||||
if file_exists("main.ludic") { return "main.ludic" }
|
||||
let progs = split_lines(capture("grep -rlE --include='*.ludic' '^program[[:space:]]' src 2>/dev/null | sort"))
|
||||
if len(progs) == 1 { return progs[0] }
|
||||
let one = capture_line("ls *.ludic 2>/dev/null")
|
||||
if one != "" and not shq("ls *.ludic 2>/dev/null | tail -n +2 | grep -q .") { return one }
|
||||
return ""
|
||||
|
|
@ -63,7 +74,8 @@ function find_entry(explicit: pointer) -> pointer {
|
|||
# report the "no entry point" case the same way everywhere
|
||||
function no_entry() -> int {
|
||||
err("ludic: no program to build here.\n")
|
||||
err(" expected src/main.ludic (or main.ludic), or name a file: ludic build path/to/game.ludic\n")
|
||||
err(" expected `entry \"src/game.ludic\"` in package.ludic, src/main.ludic (or main.ludic),\n")
|
||||
err(" a single program under src/, or name a file: ludic build path/to/game.ludic\n")
|
||||
err(" start a new project with: ludic new <name>\n")
|
||||
return 1
|
||||
}
|
||||
|
|
@ -337,13 +349,35 @@ function test_files() -> []pointer {
|
|||
return files
|
||||
}
|
||||
|
||||
# ludic test [file...] — compile each test program headlessly and run it. A test
|
||||
# program's own runner prints ok/FAIL per test block and exits non-zero if any
|
||||
# failed, so this reports one line per file and forwards the failure.
|
||||
# ludic test [file...] [--verbose] [--test NAME] — compile each test program
|
||||
# headlessly and run it. A test program's own runner prints ok/FAIL per test
|
||||
# block and exits non-zero if any failed, so this reports one line per file and
|
||||
# forwards the failure.
|
||||
#
|
||||
# --verbose streams every runner line, framed by `RUN <file>` ... `PASS|FAIL
|
||||
# <file>`: the shape an IDE test view parses. --test NAME runs only the test
|
||||
# block with exactly that name (the runner's argv[1]).
|
||||
function cmd_test() -> int {
|
||||
var files = new []pointer
|
||||
var verbose = false
|
||||
var only = ""
|
||||
var ai = 2
|
||||
while ai < arg_count() { if arg(ai)[0] != '-' { push(files, arg(ai)) }; ai += 1 }
|
||||
while ai < arg_count() {
|
||||
let a = arg(ai)
|
||||
if (a == "--verbose") or (a == "-v") { verbose = true }
|
||||
else if a == "--test" {
|
||||
if ai + 1 >= arg_count() { err("ludic test: --test needs a test name\n"); return 1 }
|
||||
ai += 1
|
||||
only = arg(ai)
|
||||
}
|
||||
else if a[0] == '-' {
|
||||
err(`ludic test: unknown option {a}\n`)
|
||||
err(" usage: ludic test [file...] [--verbose] [--test NAME]\n")
|
||||
return 1
|
||||
}
|
||||
else { push(files, a) }
|
||||
ai += 1
|
||||
}
|
||||
if len(files) == 0 { files = test_files() }
|
||||
if len(files) == 0 {
|
||||
err("ludic test: no tests found (expected tests/*.ludic or src/**/*_test.ludic)\n")
|
||||
|
|
@ -355,19 +389,27 @@ function cmd_test() -> int {
|
|||
mi += 1
|
||||
}
|
||||
shell("mkdir -p build")
|
||||
var filter = ""
|
||||
if only != "" { filter = ` {sh_single(only)}` }
|
||||
var failed = 0
|
||||
var i = 0
|
||||
while i < len(files) {
|
||||
let f = files[i]
|
||||
let bin = `{tmp_dir()}/{flat(strip_ext(f))}`
|
||||
if verbose { say(` RUN {f}`) }
|
||||
if not compile_app(f, bin, 2, false) {
|
||||
print(` {c_red()}FAIL{c_reset()} {f} (did not compile)`)
|
||||
let line = ` {c_red()}FAIL{c_reset()} {f} (did not compile)`
|
||||
if verbose { say(line) } else { print(line) }
|
||||
failed += 1
|
||||
} else if verbose {
|
||||
let rc = sh(`{bin}{filter} < /dev/null 2>&1`)
|
||||
if rc == 0 { say(` {c_green()}PASS{c_reset()} {f}`) }
|
||||
else { say(` {c_red()}FAIL{c_reset()} {f} (exit {string(rc)})`); failed += 1 }
|
||||
} else {
|
||||
# a test program prints one line per test block; that belongs on screen
|
||||
# when something failed and nowhere when everything passed.
|
||||
let log = `{bin}.out`
|
||||
let rc = sh(`{bin} < /dev/null > {log} 2>&1`)
|
||||
let rc = sh(`{bin}{filter} < /dev/null > {log} 2>&1`)
|
||||
if rc == 0 { print(` {c_green()}PASS{c_reset()} {f}`) }
|
||||
else {
|
||||
print(` {c_red()}FAIL{c_reset()} {f} (exit {string(rc)})`)
|
||||
|
|
@ -387,6 +429,22 @@ function cmd_test() -> int {
|
|||
return 1
|
||||
}
|
||||
|
||||
# print a line through the shell. The runner's own stdout is buffered, so a
|
||||
# print() made between two child processes would surface after both of them;
|
||||
# under --verbose the framing has to interleave with the runner output exactly.
|
||||
function say(line: pointer) -> void { shell(`printf '%s\\n' {sh_single(line)}`) }
|
||||
|
||||
# one shell word, whatever it contains: 'it'\''s' for it's
|
||||
function sh_single(s: pointer) -> pointer {
|
||||
var o = "'"
|
||||
var i = 0
|
||||
while i < len(s) {
|
||||
if s[i] == 39 { o = o + "'\\''" } else { o = o + s[i..i + 1] }
|
||||
i += 1
|
||||
}
|
||||
return o + "'"
|
||||
}
|
||||
|
||||
# drop the extension from a path ("tests/combat.ludic" -> "tests/combat")
|
||||
function strip_ext(p: pointer) -> pointer {
|
||||
let n = len(p)
|
||||
|
|
|
|||
|
|
@ -330,6 +330,9 @@ function build_artifacts(ver: pointer) -> bool {
|
|||
}
|
||||
shell(`cp -R runtime {stage}/runtime`)
|
||||
shell(`cp -R packages {stage}/packages`)
|
||||
# the language reference: ludic-lsp indexes it for hover and go-to-definition
|
||||
# on built-ins (docs/language/**.md, ~4 MB of markdown)
|
||||
shell(`mkdir -p {stage}/docs && cp -R docs/language {stage}/docs/language`)
|
||||
shell(`cp VERSION LICENSE README.md {stage}/`)
|
||||
# the Linux link needs the Darwin-stdio shim injected through $LUDIC_CC; it
|
||||
# travels with the toolchain that needs it (see tools/ci/linux_stdio_shim.ll).
|
||||
|
|
|
|||
127
tools/ludic-cli/scripts.ludic
Normal file
127
tools/ludic-cli/scripts.ludic
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# scripts.ludic — package.ludic scripts and lifecycle hooks.
|
||||
#
|
||||
# script "dev" "ludic run --headless" # ludic dev / ludic script dev
|
||||
# script "shots" "tools/shots.sh" # extra arguments are passed on
|
||||
# hook before build "tools/gen_atlas.sh" # runs first; failing stops the build
|
||||
# hook after bundle "tools/notarize.sh" # runs only when the bundle succeeded
|
||||
# hook before dev "ludic fmt --check" # a script is an event too
|
||||
#
|
||||
# Commands run through the shell in the package root, with the toolchain's bin/
|
||||
# first on PATH and LUDIC_PACKAGE_ROOT, LUDIC_EVENT and LUDIC_PHASE set. Hooks of
|
||||
# one event run in the order written.
|
||||
|
||||
# the built-in commands a hook can wrap (every user command that does work)
|
||||
function is_hookable(cmd: pointer) -> bool {
|
||||
return (cmd == "build") or (cmd == "run") or (cmd == "test") or (cmd == "bundle") or (cmd == "pack") or (cmd == "clean") or (cmd == "fmt") or (cmd == "get") or (cmd == "add") or (cmd == "update") or (cmd == "verify") or (cmd == "vendor") or (cmd == "assets") or (cmd == "build-lib")
|
||||
}
|
||||
|
||||
# the directory this `ludic` binary lives in, with no trailing slash
|
||||
function ludic_bin_dir() -> pointer {
|
||||
let me = arg(0)
|
||||
var i = slen(me)
|
||||
while i > 0 and me[i - 1] != '/' { i -= 1 }
|
||||
if i > 0 { return capture_line(`cd {sh_single(sslice(me, 0, i))} && pwd`) }
|
||||
return capture_line(`dirname "$(command -v {sh_single(me)})"`)
|
||||
}
|
||||
|
||||
# the shell prelude every script and hook runs under
|
||||
function script_env(event: pointer, phase: pointer) -> pointer {
|
||||
let bin = ludic_bin_dir()
|
||||
var env = `export LUDIC_PACKAGE_ROOT="$(pwd)"; export LUDIC_EVENT={sh_single(event)}; export LUDIC_PHASE={sh_single(phase)}; `
|
||||
if bin != "" { env = env + `export PATH={sh_single(bin)}:"$PATH"; ` }
|
||||
return env
|
||||
}
|
||||
|
||||
# run every `hook <phase> <event>` in order; the first failure's exit code, or 0
|
||||
function run_hooks(phase: pointer, event: pointer) -> int {
|
||||
if not file_exists("package.ludic") { return 0 }
|
||||
let m = read_root_manifest()
|
||||
var i = 0
|
||||
while i + 2 < len(m.hooks) {
|
||||
if m.hooks[i] == phase and m.hooks[i + 1] == event {
|
||||
let cmd = m.hooks[i + 2]
|
||||
say(`> {phase} {event}: {cmd}`)
|
||||
let rc = sh(`{script_env(event, phase)}{cmd}`)
|
||||
if rc != 0 {
|
||||
err(`ludic: the {phase} {event} hook failed (exit {string(rc)}): {cmd}\n`)
|
||||
return rc
|
||||
}
|
||||
}
|
||||
i += 3
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
# the command a script names, or "" when package.ludic has no such script
|
||||
function script_command(name: pointer) -> pointer {
|
||||
if not file_exists("package.ludic") { return "" }
|
||||
let m = read_root_manifest()
|
||||
var i = 0
|
||||
while i + 1 < len(m.scripts) {
|
||||
if m.scripts[i] == name { return m.scripts[i + 1] }
|
||||
i += 2
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# run script `name` with the arguments from argv[first] on, wrapped in its hooks.
|
||||
# -1 when there is no such script.
|
||||
function run_script(name: pointer, first: int) -> int {
|
||||
let cmd = script_command(name)
|
||||
if cmd == "" { return -1 }
|
||||
var extra = ""
|
||||
var ai = first
|
||||
while ai < arg_count() { extra = extra + " " + sh_single(arg(ai)); ai += 1 }
|
||||
let pre = run_hooks("before", name)
|
||||
if pre != 0 { return pre }
|
||||
say(`> {name}: {cmd}{extra}`)
|
||||
let rc = sh(`{script_env(name, "run")}{cmd}{extra}`)
|
||||
if rc != 0 { return rc }
|
||||
return run_hooks("after", name)
|
||||
}
|
||||
|
||||
# ludic script [name [args…]] — run a script, or list them
|
||||
function cmd_script() -> int {
|
||||
if arg_count() < 3 { return cmd_scripts() }
|
||||
let rc = run_script(arg(2), 3)
|
||||
if rc == -1 {
|
||||
err(`ludic script: package.ludic has no script '{arg(2)}'\n`)
|
||||
cmd_scripts()
|
||||
return 1
|
||||
}
|
||||
return rc
|
||||
}
|
||||
|
||||
# ludic scripts — what package.ludic defines
|
||||
function cmd_scripts() -> int {
|
||||
if not file_exists("package.ludic") {
|
||||
err("ludic scripts: no package.ludic here\n")
|
||||
return 1
|
||||
}
|
||||
let m = read_root_manifest()
|
||||
if len(m.scripts) == 0 and len(m.hooks) == 0 {
|
||||
print("package.ludic defines no scripts or hooks")
|
||||
print(" add one: script \"dev\" \"ludic run --headless\"")
|
||||
print(" hook before build \"tools/prepare.sh\"")
|
||||
return 0
|
||||
}
|
||||
if len(m.scripts) > 0 { print("scripts:") }
|
||||
var i = 0
|
||||
while i + 1 < len(m.scripts) {
|
||||
print(` {pad_right(m.scripts[i], 14)} {m.scripts[i + 1]}`)
|
||||
i += 2
|
||||
}
|
||||
if len(m.hooks) > 0 { print("hooks:") }
|
||||
i = 0
|
||||
while i + 2 < len(m.hooks) {
|
||||
print(` {pad_right(m.hooks[i] + " " + m.hooks[i + 1], 20)} {m.hooks[i + 2]}`)
|
||||
i += 3
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function pad_right(s: pointer, w: int) -> pointer {
|
||||
var o = s
|
||||
while slen(o) < w { o = o + " " }
|
||||
return o
|
||||
}
|
||||
|
|
@ -400,6 +400,35 @@ function scaffold_names_case() -> void {
|
|||
ok(lbl)
|
||||
}
|
||||
|
||||
# package.ludic drives the CLI: `entry` (or the one program under src/) is what
|
||||
# build compiles, scripts run by name, and hooks wrap commands — a failing
|
||||
# `before` stops the command, `after` runs only on success.
|
||||
function package_scripts_case() -> void {
|
||||
let lbl = "package.ludic entry, scripts and hooks"
|
||||
let work = `{tmp_dir()}/pkgscripts`
|
||||
shell(`rm -rf {work} && mkdir -p {work}`)
|
||||
let root = capture_line("pwd")
|
||||
let L = `{root}/bin/ludic`
|
||||
if not shq(`cd {work} && {L} new g > /dev/null 2>&1 && mv g/src/main.ludic g/src/game.ludic`) { bad2(lbl, "setup failed"); return }
|
||||
# no src/main.ludic: the single program under src/ is found
|
||||
if not shq(`cd {work}/g && {L} build --headless > {work}/b1.out 2>&1`) { bad2(lbl, "the lone src/ program was not found"); return }
|
||||
# a second program makes it ambiguous; `entry` settles it
|
||||
write_file(`{work}/g/src/other.ludic`, "program Other {\n entry { }\n}\n")
|
||||
if shq(`cd {work}/g && {L} build --headless > {work}/b2.out 2>&1`) { bad2(lbl, "two programs and no entry should not build"); return }
|
||||
var man = read_file(`{work}/g/package.ludic`)
|
||||
man = man + "entry \"src/game.ludic\"\n"
|
||||
man = man + "script \"greet\" \"echo hi-$LUDIC_EVENT\"\n"
|
||||
man = man + "hook before build \"echo pre > hook.log\"\n"
|
||||
man = man + "hook after build \"echo post >> hook.log\"\n"
|
||||
man = man + "hook before test \"exit 7\"\n"
|
||||
write_file(`{work}/g/package.ludic`, man)
|
||||
if not shq(`cd {work}/g && {L} build --headless > {work}/b3.out 2>&1`) { bad2(lbl, "entry was not honoured"); return }
|
||||
if not (capture_line(`tr '\n' ' ' < {work}/g/hook.log`) == "pre post") { bad2(lbl, "build hooks did not run in order"); return }
|
||||
if not (capture_line(`cd {work}/g && {L} greet | tail -1`) == "hi-greet") { bad2(lbl, "a script did not run by name"); return }
|
||||
if not (capture_line(`cd {work}/g && {L} test > /dev/null 2>&1; echo $?`) == "7") { bad2(lbl, "a failing before hook did not stop the command"); return }
|
||||
ok(lbl)
|
||||
}
|
||||
|
||||
# a "does it still compile" smoke test (parse -> lower -> link), no run
|
||||
function qsmoke(path: pointer) -> void {
|
||||
let nm = flat(path)
|
||||
|
|
@ -762,6 +791,7 @@ function cmd_dev_test() -> int {
|
|||
# from its own location. This is the shape `curl … | sh` produces.
|
||||
install_layout_case()
|
||||
scaffold_names_case()
|
||||
package_scripts_case()
|
||||
pack_roundtrip_case()
|
||||
packignore_case()
|
||||
os_dirs_case()
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ function cmd_test_tools() -> int {
|
|||
# IntelliJ SDK). One env var away: LUDIC_TEST_JETBRAINS=1 ludic-dev test-tools
|
||||
if (getenv_or("LUDIC_TEST_JETBRAINS", "0") == "1") {
|
||||
if shq("test -x tools/editors/jetbrains/gradlew") {
|
||||
if shq("cd tools/editors/jetbrains && ./gradlew buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.0.0.zip") { ok("jetbrains plugin builds and verifies") } else { bad("jetbrains plugin build") }
|
||||
if shq("cd tools/editors/jetbrains && ./gradlew buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.4.0.zip") { ok("jetbrains plugin builds and verifies") } else { bad("jetbrains plugin build") }
|
||||
if shq("cd tools/editors/jetbrains && ./gradlew test --console=plain -q >/dev/null 2>&1") { ok("jetbrains platform tests (lexer, file type, PSI, folding)") } else { bad("jetbrains platform tests") }
|
||||
} else { print(" skip jetbrains plugin (no gradle wrapper)") }
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@
|
|||
# whole compilation unit. Ground-truth compilation stays with ludicc; this layer
|
||||
# supplies structure, not judgement.
|
||||
program LudicLsp {
|
||||
import "lsp/imports.ludic"
|
||||
import "lsp/docs.ludic"
|
||||
import "lsp/types.ludic"
|
||||
import "lsp/sighelp.ludic"
|
||||
|
||||
# ---- token kinds ----
|
||||
const LT_EOF: int = 0
|
||||
const LT_NL: int = 1
|
||||
|
|
@ -70,6 +75,13 @@ program LudicLsp {
|
|||
const SC_OPERATOR: int = 22
|
||||
const SC_ANNOTATION: int = 23
|
||||
const SC_UNKNOWN: int = 24
|
||||
const SC_NAMESPACE: int = 25
|
||||
|
||||
# semantic token modifier bits (the legend's order)
|
||||
const SEM_DECLARATION: int = 1
|
||||
const SEM_READONLY: int = 2
|
||||
const SEM_DEFAULT_LIBRARY: int = 4
|
||||
const SEM_VENDOR: int = 8
|
||||
|
||||
# ---- tiny helpers ----
|
||||
function read_file(path: pointer) -> pointer {
|
||||
|
|
@ -161,6 +173,8 @@ program LudicLsp {
|
|||
bend: int = 0
|
||||
parent: int = 0
|
||||
exported: int = 0
|
||||
init: int = 0 # first token of the initializer (let/var/const), or -1
|
||||
mutable: int = 0 # a `var` local
|
||||
}
|
||||
property Doc {
|
||||
path: pointer = null
|
||||
|
|
@ -179,6 +193,7 @@ program LudicLsp {
|
|||
linestart: []int
|
||||
tmatch: []int
|
||||
cls: []int
|
||||
clsmods: []int # extra semantic modifiers per token (declaration, vendor, …)
|
||||
syms: []Sym
|
||||
imports: []pointer
|
||||
had_diags: int = 0
|
||||
|
|
@ -236,6 +251,8 @@ program LudicLsp {
|
|||
D.linestart = new []int
|
||||
push(D.linestart, 0)
|
||||
var i = 0; var line = 0
|
||||
var tmode = 0 # 0 code, 1 template text, 2 inside a template hole
|
||||
var tdepth = 0 # braces opened inside the current hole
|
||||
while s[i] != 0 {
|
||||
let c = s[i]
|
||||
if c == '\n' { push(D.tk_kind, LT_NL); push(D.tk_start, i); push(D.tk_end, i + 1); push(D.tk_line, line); i += 1; line += 1; push(D.linestart, i); continue }
|
||||
|
|
@ -243,7 +260,33 @@ program LudicLsp {
|
|||
var st = i
|
||||
if c == '#' { while s[i] != 0 and s[i] != '\n' { i += 1 }; push(D.tk_kind, LT_COMMENT); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, line); continue }
|
||||
if c == '"' { i += 1; while s[i] != 0 and s[i] != '"' and s[i] != '\n' { if s[i] == '\\' and s[i + 1] != 0 { i += 2 } else { i += 1 } }; if s[i] == '"' { i += 1 }; push(D.tk_kind, LT_STR); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, line); continue }
|
||||
if c == '`' { i += 1; while s[i] != 0 and s[i] != '`' { if s[i] == '\\' and s[i + 1] != 0 { i += 2 } else { i += 1 } }; if s[i] == '`' { i += 1 }; push(D.tk_kind, LT_STR); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, line); continue }
|
||||
# `text {hole} text`: the text runs are string tokens (the hole's braces
|
||||
# ride on them), the hole is lexed as code, so its names resolve
|
||||
if c == '`' or (tmode == 2 and c == '}' and tdepth == 0) {
|
||||
if c == '`' and tmode == 2 { i += 1; tmode = 0; push(D.tk_kind, LT_STR); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, line); continue }
|
||||
i += 1
|
||||
tmode = 1
|
||||
var seg_line = line
|
||||
while s[i] != 0 and tmode == 1 {
|
||||
if s[i] == '`' { i += 1; tmode = 0 }
|
||||
else if s[i] == '{' and s[i + 1] == '{' { i += 2 }
|
||||
else if s[i] == '}' and s[i + 1] == '}' { i += 2 }
|
||||
else if s[i] == '{' { i += 1; tmode = 2; tdepth = 0 }
|
||||
else if s[i] == '\\' and s[i + 1] != 0 { i += 2 }
|
||||
else if s[i] == '\n' {
|
||||
# a template may span lines: close this run, keep the line table right
|
||||
if i > st { push(D.tk_kind, LT_STR); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, seg_line) }
|
||||
i += 1; line += 1; push(D.linestart, i)
|
||||
st = i; seg_line = line
|
||||
}
|
||||
else { i += 1 }
|
||||
}
|
||||
if tmode == 1 { tmode = 0 }
|
||||
if i > st { push(D.tk_kind, LT_STR); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, seg_line) }
|
||||
continue
|
||||
}
|
||||
if tmode == 2 and c == '{' { tdepth += 1 }
|
||||
if tmode == 2 and c == '}' { tdepth -= 1 }
|
||||
if c == '\'' { i += 1; if s[i] == '\\' and s[i + 1] != 0 { i += 2 } else { if s[i] != 0 and s[i] != '\n' { i += 1 } }; if s[i] == '\'' { i += 1 }; push(D.tk_kind, LT_CHAR); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, line); continue }
|
||||
if char_is_digit(c) {
|
||||
if c == '0' and (s[i + 1] == 'x' or s[i + 1] == 'X') { i += 2; while char_is_hex(s[i]) { i += 1 }; push(D.tk_kind, LT_INT); push(D.tk_start, st); push(D.tk_end, i); push(D.tk_line, line); continue }
|
||||
|
|
@ -271,6 +314,7 @@ program LudicLsp {
|
|||
push(D.tk_kind, LT_ERR); push(D.tk_start, i); push(D.tk_end, i + ln); push(D.tk_line, line); i += ln
|
||||
}
|
||||
push(D.tk_kind, LT_EOF); push(D.tk_start, i); push(D.tk_end, i); push(D.tk_line, line)
|
||||
demote_contextual_keywords(D)
|
||||
}
|
||||
|
||||
function ntok(D: Doc) -> int { return len(D.tk_kind) }
|
||||
|
|
@ -373,8 +417,13 @@ program LudicLsp {
|
|||
if D.tk_kind[i] == LT_COMMENT {
|
||||
var s = D.tk_start[i] + 1
|
||||
while s < D.tk_end[i] and (D.text[s] == ' ' or D.text[s] == '\t' or D.text[s] == '#' or D.text[s] == '*') { s += 1 }
|
||||
buf_addrange(out, D.text, s, D.tk_end[i])
|
||||
buf_putc(out, '\n')
|
||||
var rule = true
|
||||
var r = s
|
||||
while r < D.tk_end[i] { let rc = D.text[r]; if not (rc == '=' or rc == '-' or rc == '~' or rc == ' ') { rule = false }; r += 1 }
|
||||
if not rule or s == D.tk_end[i] {
|
||||
buf_addrange(out, D.text, s, D.tk_end[i])
|
||||
buf_putc(out, '\n')
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
|
@ -400,7 +449,7 @@ program LudicLsp {
|
|||
function sym_add(kind: int, nametok: int, parent: int) -> int {
|
||||
let s = new Sym
|
||||
s.kind = kind; s.tok = nametok; s.parent = parent
|
||||
s.name = ""; s.ty = ""; s.detail = ""; s.doc = ""
|
||||
s.name = ""; s.ty = ""; s.detail = ""; s.doc = ""; s.init = -1; s.mutable = 0
|
||||
if nametok >= 0 { s.start = pD.tk_start[nametok]; s.end = pD.tk_end[nametok]; s.name = ttext(pD, nametok) }
|
||||
s.bstart = 0; s.bend = cstr_len(pD.text)
|
||||
push(pD.syms, s)
|
||||
|
|
@ -425,6 +474,7 @@ program LudicLsp {
|
|||
if pis(":") { padv(); let tt = pname(); if tt >= 0 { ty = ttext(pD, tt) } }
|
||||
let sidx = sym_add(kind, nt, owner)
|
||||
pD.syms[sidx].ty = ty
|
||||
pD.syms[sidx].detail = concat3("param ", pD.syms[sidx].name, concat3(": ", ty, ""))
|
||||
if not first { buf_puts(sig, ", ") }
|
||||
buf_puts(sig, pD.syms[sidx].name); buf_puts(sig, ": ")
|
||||
if ty == "" { buf_puts(sig, "?") } else { buf_puts(sig, ty) }
|
||||
|
|
@ -435,6 +485,17 @@ program LudicLsp {
|
|||
buf_putc(sig, ')') # ')'
|
||||
}
|
||||
|
||||
# the type written at pi (`int`, `[]Doc`, `?Actor`), consumed; "" when none
|
||||
function lp_type_text() -> pointer {
|
||||
var pre = ""
|
||||
while pis("[") or pis("?") {
|
||||
if pis("[") { let m = cur_match(); if m < 0 { return "" }; pi = m + 1; pre = pre + "[]" } else { pi += 1; pre = pre + "?" }
|
||||
}
|
||||
let tt = pname()
|
||||
if tt < 0 { return "" }
|
||||
return pre + ttext(pD, tt)
|
||||
}
|
||||
|
||||
# (a, b) variable list -> token indices into out; returns count
|
||||
function lp_varlist(out: []int) -> int {
|
||||
var n = 0
|
||||
|
|
@ -496,12 +557,15 @@ program LudicLsp {
|
|||
pskipnl()
|
||||
}
|
||||
function lp_stmt(owner: int, scope_end: int) -> void {
|
||||
if pis("let") {
|
||||
if pis("let") or pis("var") {
|
||||
let is_var = pis("var")
|
||||
padv(); let nt = pname(); if nt < 0 { return }
|
||||
var ty = ""
|
||||
if pis(":") { padv(); let tt = pname(); if tt >= 0 { ty = ttext(pD, tt) } }
|
||||
if pis(":") { padv(); ty = lp_type_text() }
|
||||
let sidx = sym_add(LS_LOCAL, nt, owner)
|
||||
pD.syms[sidx].ty = ty
|
||||
if is_var { pD.syms[sidx].mutable = 1 }
|
||||
if pis("=") { padv(); pD.syms[sidx].init = pi }
|
||||
pD.syms[sidx].bstart = pD.tk_start[nt]; pD.syms[sidx].bend = scope_end
|
||||
return
|
||||
}
|
||||
|
|
@ -612,19 +676,14 @@ program LudicLsp {
|
|||
if pis(",") { padv(); continue }
|
||||
while pk() == LT_ANNO { padv() } # @Sync / @Computed on a field
|
||||
let ft = pname(); if ft < 0 { padv(); continue }
|
||||
if is_comp {
|
||||
let f = sym_add(LS_FIELD, ft, sidx)
|
||||
var fty = ""
|
||||
if pis(":") { padv(); let tt = pname(); if tt >= 0 { fty = ttext(pD, tt) } }
|
||||
pD.syms[f].ty = fty
|
||||
pD.syms[f].detail = concat3(pD.syms[sidx].name, ".", concat3(pD.syms[f].name, ": ", fty))
|
||||
if pis("=") { padv(); while pi < ntok(pD) and not pis(",") and not pis("}") and pk() != LT_EOF { if pis("(") or pis("{") or pis("[") { skip_group() } else { padv() } } }
|
||||
if not first { buf_putc(det, ',') }
|
||||
buf_putc(det, ' '); buf_puts(det, pD.syms[f].name); buf_puts(det, ": "); buf_puts(det, fty)
|
||||
} else {
|
||||
if not first { buf_putc(det, ',') }
|
||||
buf_putc(det, ' '); buf_puts(det, ttext(pD, ft))
|
||||
}
|
||||
let f = sym_add(LS_FIELD, ft, sidx)
|
||||
var fty = ""
|
||||
if pis(":") { padv(); fty = lp_type_text() }
|
||||
pD.syms[f].ty = fty
|
||||
pD.syms[f].detail = concat3(pD.syms[sidx].name, ".", concat3(pD.syms[f].name, ": ", fty))
|
||||
if pis("=") { padv(); while pi < ntok(pD) and not pis(",") and not pis("}") and pk() != LT_EOF { if pis("(") or pis("{") or pis("[") { skip_group() } else { padv() } } }
|
||||
if not first { buf_putc(det, ',') }
|
||||
buf_putc(det, ' '); buf_puts(det, pD.syms[f].name); buf_puts(det, ": "); buf_puts(det, fty)
|
||||
first = false
|
||||
}
|
||||
buf_puts(det, " }")
|
||||
|
|
@ -639,7 +698,8 @@ program LudicLsp {
|
|||
padv(); let nt = pname(); if nt < 0 { return }
|
||||
let sidx = sym_add(kind, nt, parent)
|
||||
pD.syms[sidx].doc = collect_doc(pD, decl_tok)
|
||||
if pis(":") { padv(); let tt = pname(); if tt >= 0 { pD.syms[sidx].ty = ttext(pD, tt) } }
|
||||
if pis(":") { padv(); pD.syms[sidx].ty = lp_type_text() }
|
||||
if pis("=") { padv(); pD.syms[sidx].init = pi }
|
||||
pD.syms[sidx].detail = concat3(w, " ", concat3(pD.syms[sidx].name, ": ", pD.syms[sidx].ty))
|
||||
while pi < ntok(pD) and pk() != LT_NL and pk() != LT_EOF { if pis("(") or pis("{") or pis("[") { skip_group() } else { pi += 1 } }
|
||||
pskipnl()
|
||||
|
|
@ -1052,13 +1112,17 @@ program LudicLsp {
|
|||
function imports_of(D: Doc, out: []Doc) -> void {
|
||||
var i = 0
|
||||
while i < len(D.imports) {
|
||||
let full = join_path(dirname(D.path), D.imports[i])
|
||||
let I = ensure_doc(full)
|
||||
if (I != null) {
|
||||
var seen = false
|
||||
var k = 0
|
||||
while k < len(out) { if (out[k].path == I.path) { seen = true }; k += 1 }
|
||||
if not seen { push(out, I); imports_of(I, out) }
|
||||
let files = resolve_import_all(D.path, D.imports[i])
|
||||
var f = 0
|
||||
while f < len(files) {
|
||||
let I = ensure_doc(files[f])
|
||||
if (I != null) {
|
||||
var seen = false
|
||||
var k = 0
|
||||
while k < len(out) { if (out[k].path == I.path) { seen = true }; k += 1 }
|
||||
if not seen { push(out, I); imports_of(I, out) }
|
||||
}
|
||||
f += 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
|
@ -1585,6 +1649,9 @@ program LudicLsp {
|
|||
function classify(D: Doc) -> void {
|
||||
let N = ntok(D)
|
||||
if len(D.cls) < N { D.cls = new []int; var z = 0; while z < N { push(D.cls, SC_NONE); z += 1 } }
|
||||
D.clsmods = new []int
|
||||
var zm = 0
|
||||
while zm < N { push(D.clsmods, 0); zm += 1 }
|
||||
var i = 0
|
||||
while i < N {
|
||||
let k = D.tk_kind[i]
|
||||
|
|
@ -1603,9 +1670,26 @@ program LudicLsp {
|
|||
var decl = -1
|
||||
var s = 0
|
||||
while s < len(D.syms) { if D.syms[s].tok == i { decl = s; break }; s += 1 }
|
||||
if decl >= 0 { D.cls[i] = class_of_symkind(D.syms[decl].kind); i += 1; continue }
|
||||
if decl >= 0 {
|
||||
D.cls[i] = class_of_symkind(D.syms[decl].kind)
|
||||
D.clsmods[i] = SEM_DECLARATION | vendor_mod(D)
|
||||
i += 1; continue
|
||||
}
|
||||
let prev = prev_sig(D, i)
|
||||
if prev >= 0 and tis(D, prev, ".") { D.cls[i] = SC_FIELD; i += 1; continue }
|
||||
if prev >= 0 and tis(D, prev, ".") {
|
||||
# Namespace.member: a standard-library call or constant
|
||||
let recv = prev_sig(D, prev)
|
||||
if recv >= 0 and D.tk_kind[recv] == LT_ID and is_namespace_name(ttext(D, recv)) {
|
||||
let nx = next_sig(D, i)
|
||||
if nx >= 0 and tis(D, nx, "(") { D.cls[i] = SC_BUILTIN } else { D.cls[i] = SC_CONST; D.clsmods[i] = SEM_DEFAULT_LIBRARY }
|
||||
i += 1; continue
|
||||
}
|
||||
D.cls[i] = SC_FIELD; i += 1; continue
|
||||
}
|
||||
if is_namespace_name(name) and find_top(D, name) < 0 {
|
||||
let nx = next_sig(D, i)
|
||||
if nx >= 0 and tis(D, nx, ".") { D.cls[i] = SC_NAMESPACE; i += 1; continue }
|
||||
}
|
||||
let nxt = next_sig(D, i)
|
||||
var followed_eq = false
|
||||
if nxt >= 0 and tis(D, nxt, "=") { followed_eq = true }
|
||||
|
|
@ -1619,8 +1703,8 @@ program LudicLsp {
|
|||
let l = find_local(D, name, D.tk_start[i])
|
||||
if l >= 0 { if D.syms[l].kind == LS_PARAM { D.cls[i] = SC_PARAM } else { D.cls[i] = SC_VARIABLE }; i += 1; continue }
|
||||
let top = find_top(D, name)
|
||||
if top >= 0 { D.cls[i] = class_of_symkind(g_owner.syms[top].kind); i += 1; continue }
|
||||
if is_builtin_name(name) { D.cls[i] = SC_BUILTIN; i += 1; continue }
|
||||
if top >= 0 { D.cls[i] = class_of_symkind(g_owner.syms[top].kind); D.clsmods[i] = vendor_mod(g_owner); i += 1; continue }
|
||||
if is_builtin_name(name) or docs_index(name) >= 0 { D.cls[i] = SC_BUILTIN; i += 1; continue }
|
||||
if followed_eq { D.cls[i] = SC_FIELD; i += 1; continue }
|
||||
D.cls[i] = SC_UNKNOWN
|
||||
i += 1
|
||||
|
|
@ -1639,13 +1723,13 @@ program LudicLsp {
|
|||
if cls == SC_LAYER { return 4 }
|
||||
if cls == SC_UI { return 3 }
|
||||
if cls == SC_WIDGET { g_semmods = 4; return 3 }
|
||||
if cls == SC_SYSTEM { return 5 }
|
||||
if cls == SC_SYSTEM { g_semmods = 2; return 5 } # a handler: readonly function
|
||||
if cls == SC_FUNCTION { return 5 }
|
||||
if cls == SC_BUILTIN { g_semmods = 4; return 5 }
|
||||
if cls == SC_PARAM { return 6 }
|
||||
if cls == SC_VARIABLE { return 7 }
|
||||
if cls == SC_MODVAR { return 7 }
|
||||
if cls == SC_CONST { g_semmods = 2; return 7 }
|
||||
if cls == SC_MODVAR { g_semmods = 16; return 7 }
|
||||
if cls == SC_CONST { g_semmods = 18; return 7 }
|
||||
if cls == SC_PROP { return 8 }
|
||||
if cls == SC_FIELD { return 8 }
|
||||
if cls == SC_PHASE { g_semmods = 6; return 9 }
|
||||
|
|
@ -1654,6 +1738,7 @@ program LudicLsp {
|
|||
if cls == SC_COMMENT { return 12 }
|
||||
if cls == SC_OPERATOR { return 13 }
|
||||
if cls == SC_ANNOTATION { return 14 }
|
||||
if cls == SC_NAMESPACE { g_semmods = 4; return 4 }
|
||||
return -1
|
||||
}
|
||||
|
||||
|
|
@ -2275,16 +2360,28 @@ program LudicLsp {
|
|||
let off = off_of(msg, D, "params.position")
|
||||
let tok = tok_at(D, off)
|
||||
if tok < 0 { send_result(id, "null"); return }
|
||||
if is_import_string(D, tok) {
|
||||
let files = resolve_import_all(D.path, import_rel_of(D, tok))
|
||||
let o = buf_new()
|
||||
if len(files) == 0 { hover_md(o, "import " + ttext(D, tok), "_not found: not beside this file, in ludic_modules/, or in the toolchain's packages/_") }
|
||||
else { hover_md(o, "import " + ttext(D, tok), "`" + files[0] + "`") }
|
||||
send_result(id, buf_str(o)); return
|
||||
}
|
||||
resolve_at(D, off)
|
||||
if (g_owner != null) and g_symidx >= 0 {
|
||||
let ow = g_owner
|
||||
let y = ow.syms[g_symidx]
|
||||
var code = y.detail
|
||||
if code == "" { code = y.name }
|
||||
let si = g_symidx
|
||||
let y = ow.syms[si]
|
||||
let code = sym_hover_code(ow, si)
|
||||
var docstr = y.doc
|
||||
if not (ow.path == D.path) { docstr = concat3(docstr, "\n\n_declared in ", concat3(basename(ow.path), "_", "")) }
|
||||
if not (ow.path == D.path) {
|
||||
let where = concat3("_declared in ", basename(ow.path), "_")
|
||||
if (docstr == "") { docstr = where } else { docstr = concat3(docstr, "\n\n", where) }
|
||||
}
|
||||
let o = buf_new(); hover_md(o, code, docstr); send_result(id, buf_str(o)); return
|
||||
}
|
||||
let dh = docs_hover(D, tok)
|
||||
if (dh != null) { send_result(id, dh); return }
|
||||
let name = ttext(D, tok)
|
||||
let sig = builtin_sig(name)
|
||||
if (sig != null) { let o = buf_new(); hover_md(o, sig, ""); send_result(id, buf_str(o)); return }
|
||||
|
|
@ -2299,18 +2396,17 @@ program LudicLsp {
|
|||
if (D == null) { send_result(id, "null"); return }
|
||||
let off = off_of(msg, D, "params.position")
|
||||
let tok = tok_at(D, off)
|
||||
if tok >= 0 and D.tk_kind[tok] == LT_STR {
|
||||
let p = prev_sig(D, tok)
|
||||
if p >= 0 and tis(D, p, "import") {
|
||||
var n = tlen(D, tok) - 2; if n < 0 { n = 0 }
|
||||
let rel = D.src[D.tk_start[tok] + 1..D.tk_start[tok] + 1 + n]
|
||||
let full = join_path(dirname(D.path), rel)
|
||||
let T = ensure_doc(full)
|
||||
if (T != null) { let o = buf_new(); put_location(o, T, 0, 0); send_result(id, buf_str(o)); return }
|
||||
}
|
||||
if is_import_string(D, tok) {
|
||||
let r = import_definition(D, tok)
|
||||
if (r != null) { send_result(id, r); return }
|
||||
send_result(id, "null"); return
|
||||
}
|
||||
resolve_at(D, off)
|
||||
if (g_owner == null) or g_symidx < 0 { send_result(id, "null"); return }
|
||||
if (g_owner == null) or g_symidx < 0 {
|
||||
# a built-in, a built-in type or a namespace: its reference page
|
||||
if tok >= 0 { let dd = docs_definition(D, tok); if (dd != null) { send_result(id, dd); return } }
|
||||
send_result(id, "null"); return
|
||||
}
|
||||
let ow = g_owner
|
||||
let y = ow.syms[g_symidx]
|
||||
let o = buf_new(); put_location(o, ow, y.start, y.end); send_result(id, buf_str(o))
|
||||
|
|
@ -2356,6 +2452,35 @@ program LudicLsp {
|
|||
}
|
||||
buf_putc(o, ']'); send_result(id, buf_str(o))
|
||||
}
|
||||
# highlight usages: only the occurrences in THIS document — a highlight has no
|
||||
# uri, so a range from another file would be painted onto this one
|
||||
function on_document_highlight(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "[]"); return }
|
||||
let off = off_of(msg, D, "params.position")
|
||||
let tok = resolve_at(D, off)
|
||||
if tok < 0 or (g_owner == null) or g_symidx < 0 { send_result(id, "[]"); return }
|
||||
let tdoc = g_owner; let tsym = g_symidx
|
||||
find_occurrences(D, tdoc, tsym)
|
||||
let o = buf_new(); buf_putc(o, '[')
|
||||
var first = true
|
||||
var i = 0
|
||||
while i < len(g_occ_doc) {
|
||||
if (g_occ_doc[i].path == D.path) {
|
||||
let k = g_occ_tok[i]
|
||||
if not first { buf_putc(o, ',') }
|
||||
first = false
|
||||
buf_puts(o, "{\"range\":"); put_range(o, D, D.tk_start[k], D.tk_end[k])
|
||||
var kind = 1
|
||||
let nx = next_sig(D, k)
|
||||
if D.tk_start[k] == tdoc.syms[tsym].start and (tdoc.path == D.path) { kind = 3 }
|
||||
else if nx >= 0 and (tis(D, nx, "=") or tis(D, nx, "+=") or tis(D, nx, "-=") or tis(D, nx, "*=") or tis(D, nx, "/=")) { kind = 3 }
|
||||
buf_puts(o, ",\"kind\":"); buf_puti(o, kind); buf_putc(o, '}')
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
buf_putc(o, ']'); send_result(id, buf_str(o))
|
||||
}
|
||||
function on_rename(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
let newname = jstr(jpath(msg, "params.newName"), null)
|
||||
|
|
@ -2397,25 +2522,34 @@ program LudicLsp {
|
|||
|
||||
# ---- completion ----
|
||||
var g_first: int = 1
|
||||
# the rank prefix for the next items: in-scope names before globals before
|
||||
# builtins before keywords
|
||||
var g_rank: pointer = "5"
|
||||
function comp_item(o: Buf, label: pointer, kind: int, detail: pointer) -> void {
|
||||
comp_item_full(o, label, kind, detail, g_rank + label, "")
|
||||
}
|
||||
function comp_item_full(o: Buf, label: pointer, kind: int, detail: pointer, sort: pointer, insert: pointer) -> void {
|
||||
if g_first == 0 { buf_putc(o, ',') }
|
||||
g_first = 0
|
||||
buf_puts(o, "{\"label\":"); jstr_out(o, label); buf_puts(o, ",\"kind\":"); buf_puti(o, kind)
|
||||
if (detail != null) and (detail[0] != 0) { buf_puts(o, ",\"detail\":"); jstr_out(o, detail) }
|
||||
buf_puts(o, ",\"sortText\":"); jstr_out(o, sort)
|
||||
if (insert != null) and (insert[0] != 0) { buf_puts(o, ",\"insertText\":"); jstr_out(o, insert) }
|
||||
buf_putc(o, '}')
|
||||
}
|
||||
function comp_scope(o: Buf, D: Doc, off: int, types_only: int) -> void {
|
||||
if types_only == 0 {
|
||||
g_rank = "1"
|
||||
var i = 0
|
||||
while i < len(D.syms) {
|
||||
let y = D.syms[i]
|
||||
if is_local_kind(y.kind) and off >= y.bstart and off <= y.bend {
|
||||
var det = y.detail; if det == "" { det = y.ty }
|
||||
comp_item(o, y.name, 6, det)
|
||||
if is_local_kind(y.kind) and off >= y.bstart and off <= y.bend and y.bstart <= off {
|
||||
comp_item(o, y.name, 6, sym_type(D, i, 0))
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
g_rank = "2"
|
||||
let rel = related(D)
|
||||
var r = 0
|
||||
while r < len(rel) {
|
||||
|
|
@ -2436,7 +2570,7 @@ program LudicLsp {
|
|||
comp_item(o, "int", 14, "built-in type"); comp_item(o, "long", 14, "built-in type"); comp_item(o, "fixed", 14, "built-in type"); comp_item(o, "bool", 14, "built-in type")
|
||||
comp_item(o, "entity", 14, "built-in type"); comp_item(o, "string", 14, "built-in type"); comp_item(o, "pointer", 14, "built-in type"); comp_item(o, "Vector", 14, "built-in type"); comp_item(o, "IVec2", 14, "built-in type"); comp_item(o, "Rect", 14, "built-in type"); comp_item(o, "void", 14, "built-in type")
|
||||
}
|
||||
function comp_builtins(o: Buf) -> void { var i = 0; while i < len(g_builtins) { comp_item(o, g_builtins[i], 3, g_bsigs[i]); i += 1 } }
|
||||
function comp_builtins(o: Buf) -> void { g_rank = "3"; var i = 0; while i < len(g_builtins) { comp_item(o, g_builtins[i], 3, g_bsigs[i]); i += 1 } }
|
||||
function on_completion(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "{\"isIncomplete\":false,\"items\":[]}"); return }
|
||||
|
|
@ -2469,6 +2603,8 @@ program LudicLsp {
|
|||
if in_qt == 1 { comp_scope(o, D, off, 1); done = 1 }
|
||||
else { if anchor >= 0 {
|
||||
if tis(D, anchor, ".") {
|
||||
let recv = prev_sig(D, anchor)
|
||||
if recv >= 0 and D.tk_kind[recv] == LT_ID { if comp_ns_members(o, ttext(D, recv)) { done = 1 } }
|
||||
let comp = receiver_component(D, anchor)
|
||||
if comp >= 0 and (g_owner != null) {
|
||||
let co = g_owner
|
||||
|
|
@ -2486,11 +2622,23 @@ program LudicLsp {
|
|||
if done == 0 and in_ui_body(D, off) {
|
||||
comp_item(o, "panel", 7, "widget"); comp_item(o, "col", 7, "widget"); comp_item(o, "row", 7, "widget"); comp_item(o, "label", 7, "widget"); comp_item(o, "button", 7, "widget"); comp_item(o, "image", 7, "widget"); comp_item(o, "spacer", 7, "widget")
|
||||
}
|
||||
var in_args = false
|
||||
if done == 0 and find_call_at(D, off) {
|
||||
in_args = true
|
||||
if anchor >= 0 and (tis(D, anchor, "(") or tis(D, anchor, ",")) { if comp_call_args(o, D, off) { } }
|
||||
}
|
||||
if done == 0 and in_args {
|
||||
comp_builtins(o)
|
||||
comp_scope(o, D, off, 0)
|
||||
done = 1
|
||||
}
|
||||
if done == 0 {
|
||||
comp_item(o, "let", 14, "statement"); comp_item(o, "if", 14, "statement"); comp_item(o, "for", 14, "statement"); comp_item(o, "while", 14, "statement"); comp_item(o, "return", 14, "statement"); comp_item(o, "spawn", 14, "statement"); comp_item(o, "handler", 14, "declaration"); comp_item(o, "property", 14, "declaration"); comp_item(o, "model", 14, "declaration")
|
||||
g_rank = "4"
|
||||
comp_item(o, "let", 14, "statement"); comp_item(o, "var", 14, "statement"); comp_item(o, "if", 14, "statement"); comp_item(o, "for", 14, "statement"); comp_item(o, "while", 14, "statement"); comp_item(o, "return", 14, "statement"); comp_item(o, "spawn", 14, "statement"); comp_item(o, "handler", 14, "declaration"); comp_item(o, "property", 14, "declaration"); comp_item(o, "model", 14, "declaration")
|
||||
comp_types(o)
|
||||
comp_builtins(o)
|
||||
comp_scope(o, D, off, 0)
|
||||
g_rank = "5"
|
||||
}
|
||||
buf_puts(o, "]}")
|
||||
send_result(id, buf_str(o))
|
||||
|
|
@ -2514,51 +2662,6 @@ program LudicLsp {
|
|||
}
|
||||
buf_putc(o, ']'); send_result(id, buf_str(o))
|
||||
}
|
||||
function on_signature_help(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "null"); return }
|
||||
let off = off_of(msg, D, "params.position")
|
||||
var depth = 0; var callee = -1; var arg = 0
|
||||
var i = ntok(D) - 1
|
||||
while i >= 0 {
|
||||
let t = i
|
||||
if D.tk_start[t] >= off { i -= 1; continue }
|
||||
if D.tk_kind[t] == LT_OP and tlen(D, t) == 1 {
|
||||
let c = D.src[D.tk_start[t]]
|
||||
if c == ')' { depth += 1 }
|
||||
else { if c == '(' {
|
||||
if depth == 0 {
|
||||
callee = prev_sig(D, t)
|
||||
var k = t + 1
|
||||
while k < ntok(D) and D.tk_start[k] < off { if tis(D, k, ",") { arg += 1 }; k += 1 }
|
||||
i = -1
|
||||
} else { depth -= 1 }
|
||||
} }
|
||||
}
|
||||
i -= 1
|
||||
}
|
||||
if callee < 0 or D.tk_kind[callee] != LT_ID { send_result(id, "null"); return }
|
||||
let name = ttext(D, callee)
|
||||
var sig: pointer = null
|
||||
# a namespaced call `Namespace.method(` resolves through the ns surface first
|
||||
let dot = prev_sig(D, callee)
|
||||
if dot >= 0 and tis(D, dot, ".") {
|
||||
let nsi = prev_sig(D, dot)
|
||||
if nsi >= 0 and D.tk_kind[nsi] == LT_ID { sig = ns_method_sig(ttext(D, nsi), name) }
|
||||
}
|
||||
if (sig == null) { sig = builtin_sig(name) }
|
||||
var docstr = ""
|
||||
if (sig == null) {
|
||||
let t = find_top(D, name)
|
||||
if t >= 0 and (g_owner.syms[t].kind == LS_FN or g_owner.syms[t].kind == LS_EXTERN) { sig = g_owner.syms[t].detail; docstr = g_owner.syms[t].doc }
|
||||
}
|
||||
if (sig == null) { send_result(id, "null"); return }
|
||||
let o = buf_new()
|
||||
buf_puts(o, "{\"signatures\":[{\"label\":"); jstr_out(o, sig)
|
||||
if (docstr != null) and (docstr[0] != 0) { buf_puts(o, ",\"documentation\":"); jstr_out(o, docstr) }
|
||||
buf_puts(o, "}],\"activeSignature\":0,\"activeParameter\":"); buf_puti(o, arg); buf_putc(o, '}')
|
||||
send_result(id, buf_str(o))
|
||||
}
|
||||
function on_semantic_tokens(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "null"); return }
|
||||
|
|
@ -2580,7 +2683,9 @@ program LudicLsp {
|
|||
if dl == 0 { dc = ch - prev_char }
|
||||
if first == 0 { buf_putc(o, ',') }
|
||||
first = 0
|
||||
buf_puti(o, dl); buf_putc(o, ','); buf_puti(o, dc); buf_putc(o, ','); buf_puti(o, ln); buf_putc(o, ','); buf_puti(o, ty); buf_putc(o, ','); buf_puti(o, g_semmods)
|
||||
var mods = g_semmods
|
||||
if i < len(D.clsmods) { mods = mods | D.clsmods[i] }
|
||||
buf_puti(o, dl); buf_putc(o, ','); buf_puti(o, dc); buf_putc(o, ','); buf_puti(o, ln); buf_putc(o, ','); buf_puti(o, ty); buf_putc(o, ','); buf_puti(o, mods)
|
||||
prev_line = line; prev_char = ch
|
||||
}
|
||||
}
|
||||
|
|
@ -2703,6 +2808,7 @@ program LudicLsp {
|
|||
buf_puts(o, ",\"documentSymbolProvider\":true")
|
||||
buf_puts(o, ",\"workspaceSymbolProvider\":true")
|
||||
buf_puts(o, ",\"definitionProvider\":true")
|
||||
buf_puts(o, ",\"typeDefinitionProvider\":true")
|
||||
buf_puts(o, ",\"declarationProvider\":true")
|
||||
buf_puts(o, ",\"referencesProvider\":true")
|
||||
buf_puts(o, ",\"documentHighlightProvider\":true")
|
||||
|
|
@ -2715,7 +2821,7 @@ program LudicLsp {
|
|||
buf_puts(o, ",\"inlayHintProvider\":true")
|
||||
buf_puts(o, ",\"completionProvider\":{\"triggerCharacters\":[\".\",\"@\",\"[\",\" \"],\"resolveProvider\":false}")
|
||||
buf_puts(o, ",\"signatureHelpProvider\":{\"triggerCharacters\":[\"(\",\",\"]}")
|
||||
buf_puts(o, ",\"semanticTokensProvider\":{\"legend\":{\"tokenTypes\":[\"keyword\",\"type\",\"struct\",\"class\",\"namespace\",\"function\",\"parameter\",\"variable\",\"property\",\"enumMember\",\"number\",\"string\",\"comment\",\"operator\",\"decorator\"],\"tokenModifiers\":[\"declaration\",\"readonly\",\"defaultLibrary\"]},\"full\":true,\"range\":true}")
|
||||
buf_puts(o, ",\"semanticTokensProvider\":{\"legend\":{\"tokenTypes\":[\"keyword\",\"type\",\"struct\",\"class\",\"namespace\",\"function\",\"parameter\",\"variable\",\"property\",\"enumMember\",\"number\",\"string\",\"comment\",\"operator\",\"decorator\"],\"tokenModifiers\":[\"declaration\",\"readonly\",\"defaultLibrary\",\"vendor\",\"static\"]},\"full\":true,\"range\":true}")
|
||||
buf_puts(o, "},\"serverInfo\":{\"name\":\"ludic-lsp\",\"version\":\"1.0.0\"}}")
|
||||
return buf_str(o)
|
||||
}
|
||||
|
|
@ -2754,18 +2860,19 @@ program LudicLsp {
|
|||
if (method == "textDocument/didSave") { on_did_save(msg); return }
|
||||
if (method == "textDocument/documentSymbol") { let D = doc_of(msg); if (D == null) { send_result(id, "[]"); return }; let o = buf_new(); put_doc_symbols(o, D, -1); send_result(id, buf_str(o)); return }
|
||||
if (method == "workspace/symbol") { on_workspace_symbol(msg, id); return }
|
||||
if (method == "textDocument/definition") or (method == "textDocument/declaration") or (method == "textDocument/typeDefinition") { on_definition(msg, id); return }
|
||||
if (method == "textDocument/definition") or (method == "textDocument/declaration") { on_definition(msg, id); return }
|
||||
if (method == "textDocument/typeDefinition") { on_type_definition(msg, id); return }
|
||||
if (method == "textDocument/hover") { on_hover(msg, id); return }
|
||||
if (method == "textDocument/references") { on_references(msg, id); return }
|
||||
if (method == "textDocument/documentHighlight") { on_references(msg, id); return }
|
||||
if (method == "textDocument/documentHighlight") { on_document_highlight(msg, id); return }
|
||||
if (method == "textDocument/rename") { on_rename(msg, id); return }
|
||||
if (method == "textDocument/prepareRename") { let D = doc_of(msg); if (D == null) { send_result(id, "null"); return }; let off = off_of(msg, D, "params.position"); let tok = resolve_at(D, off); if tok < 0 or (g_owner == null) or g_symidx < 0 or D.tk_kind[tok] != LT_ID { send_result(id, "null"); return }; let o = buf_new(); buf_puts(o, "{\"range\":"); put_range(o, D, D.tk_start[tok], D.tk_end[tok]); buf_puts(o, ",\"placeholder\":"); jstr_out(o, ttext(D, tok)); buf_putc(o, '}'); send_result(id, buf_str(o)); return }
|
||||
if (method == "textDocument/formatting") or (method == "textDocument/rangeFormatting") { on_formatting(msg, id); return }
|
||||
if (method == "textDocument/foldingRange") { on_folding(msg, id); return }
|
||||
if (method == "textDocument/inlayHint") { on_inlay_hint(msg, id); return }
|
||||
if (method == "textDocument/signatureHelp") { on_signature_help(msg, id); return }
|
||||
if (method == "textDocument/signatureHelp") { on_signature_help2(msg, id); return }
|
||||
if (method == "textDocument/completion") { on_completion(msg, id); return }
|
||||
if (method == "textDocument/documentLink") { send_result(id, "[]"); return }
|
||||
if (method == "textDocument/documentLink") { on_document_link(msg, id); return }
|
||||
if (method == "textDocument/semanticTokens/full") or (method == "textDocument/semanticTokens/range") { on_semantic_tokens(msg, id); return }
|
||||
if (id != null) { send_result(id, "null") }
|
||||
}
|
||||
|
|
|
|||
253
tools/ludic-tools/lsp/docs.ludic
Normal file
253
tools/ludic-tools/lsp/docs.ludic
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
# lsp/docs.ludic — the language reference, indexed for hover and go-to.
|
||||
#
|
||||
# docs/language/**.md (shipped in the install root) holds one page per symbol,
|
||||
# with front matter:
|
||||
#
|
||||
# id: screen-fill_circle -> <DOCS_URL><id>.html
|
||||
# tokens: Screen.fill_circle the source spellings it documents
|
||||
# sig: / tip: / kind: / ns:
|
||||
#
|
||||
# Built-ins, built-in types and namespace methods therefore get a signature, a
|
||||
# one-line summary and a link in hover, and go-to-definition opens the page.
|
||||
# Loaded once, on first use; a toolchain without docs simply has no entries.
|
||||
|
||||
var DOCS_URL: pointer = "https://workshopsoft.pages.workshopsoft.io/ludic/"
|
||||
|
||||
var g_docs_loaded: bool = false
|
||||
var g_dx_token: []pointer
|
||||
var g_dx_file: []pointer
|
||||
var g_dx_id: []pointer
|
||||
var g_dx_sig: []pointer
|
||||
var g_dx_tip: []pointer
|
||||
var g_dx_kind: []pointer
|
||||
# namespace name -> its section page
|
||||
var g_dxn_name: []pointer
|
||||
var g_dxn_file: []pointer
|
||||
var g_dxn_id: []pointer
|
||||
|
||||
# the value of `key: …` in a front-matter block, or ""
|
||||
function fm_get(fm: pointer, key: pointer) -> pointer {
|
||||
let n = cstr_len(fm)
|
||||
let kl = cstr_len(key)
|
||||
var i = 0
|
||||
while i < n {
|
||||
var e = i
|
||||
while e < n and fm[e] != '\n' { e += 1 }
|
||||
if e - i > kl and fm[i..i + kl] == key and fm[i + kl] == ':' {
|
||||
var s = i + kl + 1
|
||||
while s < e and fm[s] == ' ' { s += 1 }
|
||||
var t = e
|
||||
while t > s and (fm[t - 1] == ' ' or fm[t - 1] == '\r') { t -= 1 }
|
||||
return fm[s..t]
|
||||
}
|
||||
i = e + 1
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# the front matter of a page (between the leading `---` lines), or ""
|
||||
function fm_of(text: pointer) -> pointer {
|
||||
if not starts_with(text, "---\n") { return "" }
|
||||
let n = cstr_len(text)
|
||||
var i = 4
|
||||
while i < n {
|
||||
if text[i] == '-' and text[i - 1] == '\n' and i + 3 <= n and text[i..i + 3] == "---" { return text[4..i] }
|
||||
i += 1
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# the body after the front matter
|
||||
function fm_body(text: pointer) -> pointer {
|
||||
let fm = fm_of(text)
|
||||
if (fm == "") { return text }
|
||||
var i = 4 + cstr_len(fm) + 3
|
||||
while text[i] == '\n' or text[i] == '\r' { i += 1 }
|
||||
return text[i..cstr_len(text)]
|
||||
}
|
||||
|
||||
function docs_add(tok: pointer, file: pointer, fm: pointer) -> void {
|
||||
push(g_dx_token, tok)
|
||||
push(g_dx_file, file)
|
||||
push(g_dx_id, fm_get(fm, "id"))
|
||||
push(g_dx_sig, fm_get(fm, "sig"))
|
||||
push(g_dx_tip, fm_get(fm, "tip"))
|
||||
push(g_dx_kind, fm_get(fm, "kind"))
|
||||
}
|
||||
|
||||
function docs_load() -> void {
|
||||
if g_docs_loaded { return }
|
||||
g_docs_loaded = true
|
||||
g_dx_token = new []pointer; g_dx_file = new []pointer; g_dx_id = new []pointer
|
||||
g_dx_sig = new []pointer; g_dx_tip = new []pointer; g_dx_kind = new []pointer
|
||||
g_dxn_name = new []pointer; g_dxn_file = new []pointer; g_dxn_id = new []pointer
|
||||
let root = lsp_home() + "docs/language"
|
||||
if not Fs.is_dir(root) { return }
|
||||
let listfile = `{Os.temp_dir()}/ludic_lsp_docs_{Os.pid()}.txt`
|
||||
run(`find '{root}' -name '*.md' > '{listfile}' 2>/dev/null`)
|
||||
let list = read_file(listfile)
|
||||
run(`rm -f '{listfile}'`)
|
||||
if (list == null) { return }
|
||||
var i = 0
|
||||
var start = 0
|
||||
while list[i] != 0 {
|
||||
if list[i] == '\n' {
|
||||
if i > start { docs_index_file(list[start..i]) }
|
||||
start = i + 1
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
if i > start { docs_index_file(list[start..i]) }
|
||||
}
|
||||
|
||||
function docs_index_file(path: pointer) -> void {
|
||||
if ends_with(path, "/_section.md") { return }
|
||||
let text = read_file(path)
|
||||
if (text == null) { return }
|
||||
let fm = fm_of(text)
|
||||
if (fm == "") { return }
|
||||
let toks = fm_get(fm, "tokens")
|
||||
var i = 0
|
||||
let n = cstr_len(toks)
|
||||
while i < n {
|
||||
while i < n and toks[i] == ' ' { i += 1 }
|
||||
let s = i
|
||||
while i < n and toks[i] != ' ' { i += 1 }
|
||||
if i > s { docs_add(toks[s..i], path, fm) }
|
||||
}
|
||||
# a namespace method also tells us where its namespace's section page is
|
||||
let ns = fm_get(fm, "ns")
|
||||
if not (ns == "") and docs_ns_index(ns) < 0 {
|
||||
let sec = dirname(path) + "/_section.md"
|
||||
if Fs.exists(sec) {
|
||||
push(g_dxn_name, ns)
|
||||
push(g_dxn_file, sec)
|
||||
push(g_dxn_id, "ns-" + lower_str(ns))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function lower_str(s: pointer) -> pointer {
|
||||
let n = cstr_len(s)
|
||||
let b = bytes(n + 1)
|
||||
var i = 0
|
||||
while i < n { b[i] = lower(s[i]); i += 1 }
|
||||
b[n] = 0
|
||||
return b
|
||||
}
|
||||
|
||||
function docs_index(tok: pointer) -> int {
|
||||
docs_load()
|
||||
var i = 0
|
||||
while i < len(g_dx_token) { if (g_dx_token[i] == tok) { return i }; i += 1 }
|
||||
return -1
|
||||
}
|
||||
|
||||
function docs_ns_index(ns: pointer) -> int {
|
||||
var i = 0
|
||||
while i < len(g_dxn_name) { if (g_dxn_name[i] == ns) { return i }; i += 1 }
|
||||
return -1
|
||||
}
|
||||
|
||||
function docs_url(i: int) -> pointer { return DOCS_URL + g_dx_id[i] + ".html" }
|
||||
|
||||
# the spelling a token is documented under: `Screen.fill_circle` for the
|
||||
# method name in `Screen.fill_circle(…)`, else the word itself
|
||||
function doc_key_at(D: Doc, tok: int) -> pointer {
|
||||
let name = ttext(D, tok)
|
||||
let dot = prev_sig(D, tok)
|
||||
if dot >= 0 and tis(D, dot, ".") {
|
||||
let recv = prev_sig(D, dot)
|
||||
if recv >= 0 { return ttext(D, recv) + "." + name }
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
# a hover for a documented name, or null
|
||||
function docs_hover(D: Doc, tok: int) -> pointer {
|
||||
let key = doc_key_at(D, tok)
|
||||
let i = docs_index(key)
|
||||
if i >= 0 {
|
||||
var sig = g_dx_sig[i]
|
||||
if (sig == "") { sig = key }
|
||||
let m = buf_new()
|
||||
buf_puts(m, g_dx_tip[i])
|
||||
let body = docs_first_paragraph(g_dx_file[i])
|
||||
if not (body == "") and not (body == g_dx_tip[i]) { buf_puts(m, "\n\n"); buf_puts(m, body) }
|
||||
buf_puts(m, "\n\n[Documentation](")
|
||||
buf_puts(m, docs_url(i))
|
||||
buf_puts(m, ")")
|
||||
let o = buf_new()
|
||||
hover_md(o, sig, buf_str(m))
|
||||
return buf_str(o)
|
||||
}
|
||||
let n = docs_ns_index(ttext(D, tok))
|
||||
if n >= 0 {
|
||||
let o = buf_new()
|
||||
let body = docs_first_paragraph(g_dxn_file[n])
|
||||
hover_md(o, "namespace " + g_dxn_name[n], body + "\n\n[Documentation](" + DOCS_URL + g_dxn_id[n] + ".html)")
|
||||
return buf_str(o)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
# go-to-definition for a documented name: its page
|
||||
function docs_definition(D: Doc, tok: int) -> pointer {
|
||||
var file: pointer = null
|
||||
let i = docs_index(doc_key_at(D, tok))
|
||||
if i >= 0 { file = g_dx_file[i] }
|
||||
else {
|
||||
let n = docs_ns_index(ttext(D, tok))
|
||||
if n >= 0 { file = g_dxn_file[n] }
|
||||
}
|
||||
if (file == null) { return null }
|
||||
let o = buf_new()
|
||||
buf_puts(o, "{\"uri\":")
|
||||
jstr_out(o, path_to_uri(file))
|
||||
buf_puts(o, ",\"range\":{\"start\":{\"line\":0,\"character\":0},\"end\":{\"line\":0,\"character\":0}}}")
|
||||
return buf_str(o)
|
||||
}
|
||||
|
||||
# the first prose paragraph of a page, markup kept (the editor renders markdown)
|
||||
function docs_first_paragraph(path: pointer) -> pointer {
|
||||
let text = read_file(path)
|
||||
if (text == null) { return "" }
|
||||
let body = fm_body(text)
|
||||
let n = cstr_len(body)
|
||||
var i = 0
|
||||
while i < n and (body[i] == '\n' or body[i] == ' ') { i += 1 }
|
||||
if i + 2 < n and body[i..i + 3] == "```" { return "" }
|
||||
let s = i
|
||||
while i < n {
|
||||
if body[i] == '\n' and i + 1 < n and body[i + 1] == '\n' { break }
|
||||
i += 1
|
||||
}
|
||||
var p = body[s..i]
|
||||
if cstr_len(p) > 600 { p = p[0..600] + "…" }
|
||||
return p
|
||||
}
|
||||
|
||||
# the documented signature of a namespace method or builtin, or null
|
||||
function docs_sig(key: pointer) -> pointer {
|
||||
let i = docs_index(key)
|
||||
if i < 0 { return null }
|
||||
if (g_dx_sig[i] == "") { return null }
|
||||
return g_dx_sig[i]
|
||||
}
|
||||
|
||||
function docs_tip(key: pointer) -> pointer {
|
||||
let i = docs_index(key)
|
||||
if i < 0 { return "" }
|
||||
return g_dx_tip[i]
|
||||
}
|
||||
|
||||
# every documented member of a namespace: `Screen` -> fill_circle, show, …
|
||||
function docs_ns_members(ns: pointer, out: []int) -> void {
|
||||
docs_load()
|
||||
let pre = ns + "."
|
||||
var i = 0
|
||||
while i < len(g_dx_token) {
|
||||
if starts_with(g_dx_token[i], pre) { push(out, i) }
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
215
tools/ludic-tools/lsp/imports.ludic
Normal file
215
tools/ludic-tools/lsp/imports.ludic
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
# lsp/imports.ludic — where an `import "…"` string points, resolved the way the
|
||||
# compiler's do_import (selfhost/frontend/parse.ludic) resolves it:
|
||||
#
|
||||
# 1. relative to the importing file import "camp/save.ludic"
|
||||
# 2. the package's ludic_modules/ (or $LUDIC_MODULES) third-party packages
|
||||
# 3. the install root: runtime/… and packages/… import "ludic.render3d/r3d.ludic"
|
||||
#
|
||||
# `dir/*.ludic` imports every .ludic in the directory, sorted by name.
|
||||
|
||||
var g_home: pointer = null
|
||||
|
||||
# the install root with a trailing '/': $LUDIC_HOME, else the directory above
|
||||
# this binary's bin/ (a checkout's bin/ludic-lsp gives the checkout itself)
|
||||
function lsp_home() -> pointer {
|
||||
if (g_home != null) { return g_home }
|
||||
var h = Os.env_or("LUDIC_HOME", "")
|
||||
if not (h == "") {
|
||||
if not ends_with(h, "/") { h = h + "/" }
|
||||
g_home = h
|
||||
return h
|
||||
}
|
||||
let me = arg(0)
|
||||
var i = cstr_len(me)
|
||||
while i > 0 and me[i - 1] != '/' { i -= 1 }
|
||||
var d = ""
|
||||
if i > 0 { d = me[0..i] }
|
||||
else { d = path_dir_holding(me) } # started by name, from $PATH
|
||||
if not (d == "") and d[0] != '/' { # started by a relative path
|
||||
let cwd = Os.env_or("PWD", "")
|
||||
if not (cwd == "") { d = cwd + "/" + d }
|
||||
}
|
||||
if not (d == "") { d = normpath(d) + "/" }
|
||||
if ends_with(d, "/bin/") { d = d[0..len(d) - 4] }
|
||||
g_home = d
|
||||
return d
|
||||
}
|
||||
|
||||
# the first $PATH directory holding `name`, with a trailing '/', or ""
|
||||
function path_dir_holding(name: pointer) -> pointer {
|
||||
let p = Os.env_or("PATH", "")
|
||||
let n = cstr_len(p)
|
||||
var a = 0
|
||||
while a < n {
|
||||
var b = a
|
||||
while b < n and p[b] != ':' { b += 1 }
|
||||
if b > a {
|
||||
let dir = p[a..b]
|
||||
if Fs.exists(dir + "/" + name) { return dir + "/" }
|
||||
}
|
||||
a = b + 1
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# the nearest directory at or above dir holding a package.ludic, or null
|
||||
function package_root_of(dir: pointer) -> pointer {
|
||||
var d = dir
|
||||
var guard = 0
|
||||
while guard < 64 {
|
||||
if Fs.exists(d + "/package.ludic") { return d }
|
||||
if (d == "/") or (d == ".") or (d == "") { return null }
|
||||
d = dirname(d)
|
||||
guard += 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function modules_dir_for(from: pointer) -> pointer {
|
||||
let env = Os.env_or("LUDIC_MODULES", "")
|
||||
if not (env == "") {
|
||||
if env[0] == '/' { return env }
|
||||
let r = package_root_of(dirname(from))
|
||||
if (r != null) { return join_path(r, env) }
|
||||
return null
|
||||
}
|
||||
let root = package_root_of(dirname(from))
|
||||
if (root == null) { return null }
|
||||
return root + "/ludic_modules"
|
||||
}
|
||||
|
||||
# one file an import names, or null when it cannot be found anywhere
|
||||
function resolve_import(from: pointer, rel: pointer) -> pointer {
|
||||
if (rel == "") { return null }
|
||||
# `import "camp"`: the directory's barrel
|
||||
if not ends_with(rel, ".ludic") {
|
||||
if ends_with(rel, "/") { return resolve_import(from, rel + "index.ludic") }
|
||||
return resolve_import(from, rel + "/index.ludic")
|
||||
}
|
||||
let direct = join_path(dirname(from), rel)
|
||||
if Fs.exists(direct) and not Fs.is_dir(direct) { return direct }
|
||||
if rel[0] == '/' { return null }
|
||||
let mods = modules_dir_for(from)
|
||||
if (mods != null) {
|
||||
let m = join_path(mods, rel)
|
||||
if Fs.exists(m) and not Fs.is_dir(m) { return m }
|
||||
}
|
||||
let home = lsp_home()
|
||||
if not (home == "") {
|
||||
if starts_with(rel, "runtime/") {
|
||||
let rt = join_path(home, rel)
|
||||
if Fs.exists(rt) { return rt }
|
||||
}
|
||||
let pk = join_path(home + "packages", rel)
|
||||
if Fs.exists(pk) and not Fs.is_dir(pk) { return pk }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function is_glob_import(rel: pointer) -> bool { return ends_with(rel, "/*.ludic") }
|
||||
|
||||
# every file an import names: one for a plain import, the sorted directory
|
||||
# listing for `dir/*.ludic`
|
||||
function resolve_import_all(from: pointer, rel: pointer) -> []pointer {
|
||||
let out = new []pointer
|
||||
if not is_glob_import(rel) {
|
||||
let one = resolve_import(from, rel)
|
||||
if (one != null) { push(out, one) }
|
||||
return out
|
||||
}
|
||||
let dirrel = rel[0..len(rel) - 8]
|
||||
var dir = join_path(dirname(from), dirrel)
|
||||
if not Fs.is_dir(dir) {
|
||||
let home = lsp_home()
|
||||
dir = join_path(home + "packages", dirrel)
|
||||
if not Fs.is_dir(dir) { return out }
|
||||
}
|
||||
let names = Fs.list(dir)
|
||||
if (names == null) { return out }
|
||||
var i = 0
|
||||
while i < len(names) {
|
||||
var best = i
|
||||
var j = i + 1
|
||||
while j < len(names) { if str_before(names[j], names[best]) { best = j }; j += 1 }
|
||||
let t = names[i]; names[i] = names[best]; names[best] = t
|
||||
i += 1
|
||||
}
|
||||
i = 0
|
||||
while i < len(names) {
|
||||
if ends_with(names[i], ".ludic") { push(out, join_path(dir, names[i])) }
|
||||
i += 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function str_before(a: pointer, b: pointer) -> bool {
|
||||
var i = 0
|
||||
while a[i] != 0 and b[i] != 0 {
|
||||
if a[i] != b[i] { return a[i] < b[i] }
|
||||
i += 1
|
||||
}
|
||||
return a[i] == 0 and b[i] != 0
|
||||
}
|
||||
|
||||
# the text inside an import's string token (no quotes)
|
||||
function import_rel_of(D: Doc, tok: int) -> pointer {
|
||||
var n = tlen(D, tok) - 2
|
||||
if n < 0 { n = 0 }
|
||||
return D.src[D.tk_start[tok] + 1..D.tk_start[tok] + 1 + n]
|
||||
}
|
||||
|
||||
# is tok the string of an `import "…"`?
|
||||
function is_import_string(D: Doc, tok: int) -> bool {
|
||||
if tok < 0 or D.tk_kind[tok] != LT_STR { return false }
|
||||
let p = prev_sig(D, tok)
|
||||
return p >= 0 and tis(D, p, "import")
|
||||
}
|
||||
|
||||
# textDocument/documentLink: every import string links to its file, so the
|
||||
# editor underlines it and Cmd/Ctrl-click opens it
|
||||
function on_document_link(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "[]"); return }
|
||||
let o = buf_new()
|
||||
buf_putc(o, '[')
|
||||
var first = true
|
||||
var t = 0
|
||||
while t < ntok(D) {
|
||||
if is_import_string(D, t) {
|
||||
let files = resolve_import_all(D.path, import_rel_of(D, t))
|
||||
if len(files) > 0 and tlen(D, t) > 2 {
|
||||
if not first { buf_putc(o, ',') }
|
||||
first = false
|
||||
buf_puts(o, "{\"range\":")
|
||||
put_range(o, D, D.tk_start[t] + 1, D.tk_end[t] - 1)
|
||||
buf_puts(o, ",\"target\":")
|
||||
jstr_out(o, path_to_uri(files[0]))
|
||||
buf_puts(o, ",\"tooltip\":")
|
||||
jstr_out(o, files[0])
|
||||
buf_putc(o, '}')
|
||||
}
|
||||
}
|
||||
t += 1
|
||||
}
|
||||
buf_putc(o, ']')
|
||||
send_result(id, buf_str(o))
|
||||
}
|
||||
|
||||
# go-to-definition on an import string: the file (every file, for a glob)
|
||||
function import_definition(D: Doc, tok: int) -> pointer {
|
||||
let files = resolve_import_all(D.path, import_rel_of(D, tok))
|
||||
if len(files) == 0 { return null }
|
||||
let o = buf_new()
|
||||
buf_putc(o, '[')
|
||||
var i = 0
|
||||
while i < len(files) {
|
||||
let T = ensure_doc(files[i])
|
||||
if (T != null) {
|
||||
if o.len > 1 { buf_putc(o, ',') }
|
||||
put_location(o, T, 0, 0)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
buf_putc(o, ']')
|
||||
return buf_str(o)
|
||||
}
|
||||
226
tools/ludic-tools/lsp/sighelp.ludic
Normal file
226
tools/ludic-tools/lsp/sighelp.ludic
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
# lsp/sighelp.ludic — the call the cursor is inside: signature help with
|
||||
# per-parameter ranges (so the editor bolds the active one), and named-argument
|
||||
# completion (`Screen.fill_circle(x: …, radius: …)`).
|
||||
|
||||
# the call around off: sets g_call_* and returns true when there is one
|
||||
var g_call_callee: int = -1 # token of the function name
|
||||
var g_call_open: int = -1 # token of its `(`
|
||||
var g_call_arg: int = 0 # which argument (0-based) the cursor is in
|
||||
var g_call_named: pointer = "" # `name:` written at the start of that argument, if any
|
||||
var g_call_used: []pointer # names already given in earlier arguments
|
||||
|
||||
function find_call_at(D: Doc, off: int) -> bool {
|
||||
g_call_callee = -1; g_call_open = -1; g_call_arg = 0; g_call_named = ""
|
||||
g_call_used = new []pointer
|
||||
var depth = 0
|
||||
var i = ntok(D) - 1
|
||||
while i >= 0 and D.tk_start[i] >= off { i -= 1 }
|
||||
while i >= 0 {
|
||||
if D.tk_kind[i] == LT_OP and tlen(D, i) == 1 {
|
||||
let c = D.src[D.tk_start[i]]
|
||||
if c == ')' or c == ']' { depth += 1 }
|
||||
else if c == '[' { if depth == 0 { return false }; depth -= 1 }
|
||||
else if c == '{' or c == '}' { if depth == 0 { return false } }
|
||||
else if c == '(' {
|
||||
if depth == 0 {
|
||||
let callee = prev_sig(D, i)
|
||||
if callee < 0 or D.tk_kind[callee] != LT_ID { return false }
|
||||
g_call_callee = callee
|
||||
g_call_open = i
|
||||
call_args_before(D, i, off)
|
||||
return true
|
||||
}
|
||||
depth -= 1
|
||||
}
|
||||
}
|
||||
i -= 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
# count the arguments before off and collect `name:` labels
|
||||
function call_args_before(D: Doc, open: int, off: int) -> void {
|
||||
var depth = 0
|
||||
var arg_start = open + 1
|
||||
var k = open + 1
|
||||
while k < ntok(D) and D.tk_start[k] < off {
|
||||
if D.tk_kind[k] == LT_OP and tlen(D, k) == 1 {
|
||||
let c = D.src[D.tk_start[k]]
|
||||
if c == '(' or c == '[' or c == '{' { depth += 1 }
|
||||
else if c == ')' or c == ']' or c == '}' { depth -= 1 }
|
||||
else if c == ',' and depth == 0 {
|
||||
let lbl = arg_label(D, arg_start)
|
||||
if not (lbl == "") { push(g_call_used, lbl) }
|
||||
g_call_arg += 1
|
||||
arg_start = k + 1
|
||||
}
|
||||
}
|
||||
k += 1
|
||||
}
|
||||
let here = arg_label(D, arg_start)
|
||||
if not (here == "") and D.tk_end[arg_start] <= off { g_call_named = here }
|
||||
}
|
||||
|
||||
# `name:` at the start of an argument -> name, else ""
|
||||
function arg_label(D: Doc, t: int) -> pointer {
|
||||
var s = t
|
||||
while s < ntok(D) and (D.tk_kind[s] == LT_NL or D.tk_kind[s] == LT_COMMENT) { s += 1 }
|
||||
if s >= ntok(D) or D.tk_kind[s] != LT_ID { return "" }
|
||||
let c = next_sig(D, s)
|
||||
if c >= 0 and tis(D, c, ":") { return ttext(D, s) }
|
||||
return ""
|
||||
}
|
||||
|
||||
# the signature label and documentation for the call's callee
|
||||
var g_call_doc: pointer = ""
|
||||
function call_signature(D: Doc) -> pointer {
|
||||
g_call_doc = ""
|
||||
let name = ttext(D, g_call_callee)
|
||||
let dot = prev_sig(D, g_call_callee)
|
||||
if dot >= 0 and tis(D, dot, ".") {
|
||||
let nsi = prev_sig(D, dot)
|
||||
if nsi >= 0 and D.tk_kind[nsi] == LT_ID {
|
||||
let key = ttext(D, nsi) + "." + name
|
||||
let ds = docs_sig(key)
|
||||
if (ds != null) { g_call_doc = docs_tip(key); return ds }
|
||||
let ns = ns_method_sig(ttext(D, nsi), name)
|
||||
if (ns != null) { return ns }
|
||||
}
|
||||
return null
|
||||
}
|
||||
let t = find_top(D, name)
|
||||
if t >= 0 and (g_owner.syms[t].kind == LS_FN or g_owner.syms[t].kind == LS_EXTERN) {
|
||||
g_call_doc = g_owner.syms[t].doc
|
||||
return g_owner.syms[t].detail
|
||||
}
|
||||
let ds = docs_sig(name)
|
||||
if (ds != null) { g_call_doc = docs_tip(name); return ds }
|
||||
return builtin_sig(name)
|
||||
}
|
||||
|
||||
# [start, end) of each parameter inside the label's outermost ( )
|
||||
var g_ps: []int
|
||||
var g_pe: []int
|
||||
function split_params(label: pointer) -> void {
|
||||
g_ps = new []int; g_pe = new []int
|
||||
let n = cstr_len(label)
|
||||
var open = 0
|
||||
while open < n and label[open] != '(' { open += 1 }
|
||||
if open >= n { return }
|
||||
var depth = 0
|
||||
var s = open + 1
|
||||
var i = open + 1
|
||||
while i < n {
|
||||
let c = label[i]
|
||||
if c == '(' or c == '[' { depth += 1 }
|
||||
else if (c == ')' and depth == 0) or (c == ',' and depth == 0) {
|
||||
var a = s
|
||||
while a < i and label[a] == ' ' { a += 1 }
|
||||
var b = i
|
||||
while b > a and label[b - 1] == ' ' { b -= 1 }
|
||||
if b > a { push(g_ps, a); push(g_pe, b) }
|
||||
if c == ')' { return }
|
||||
s = i + 1
|
||||
}
|
||||
else if c == ')' or c == ']' { depth -= 1 }
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
# the parameter name in label[a..b): `radius`, `sk: Skin` -> sk
|
||||
function param_name(label: pointer, a: int, b: int) -> pointer {
|
||||
var e = a
|
||||
while e < b and label[e] != ':' and label[e] != ' ' and label[e] != '=' { e += 1 }
|
||||
return label[a..e]
|
||||
}
|
||||
|
||||
function param_type(label: pointer, a: int, b: int) -> pointer {
|
||||
var c = a
|
||||
while c < b and label[c] != ':' { c += 1 }
|
||||
if c >= b { return "" }
|
||||
c += 1
|
||||
while c < b and label[c] == ' ' { c += 1 }
|
||||
return label[c..b]
|
||||
}
|
||||
|
||||
function on_signature_help2(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "null"); return }
|
||||
let off = off_of(msg, D, "params.position")
|
||||
if not find_call_at(D, off) { send_result(id, "null"); return }
|
||||
let sig = call_signature(D)
|
||||
if (sig == null) { send_result(id, "null"); return }
|
||||
split_params(sig)
|
||||
var active = g_call_arg
|
||||
if not (g_call_named == "") {
|
||||
var p = 0
|
||||
while p < len(g_ps) { if (param_name(sig, g_ps[p], g_pe[p]) == g_call_named) { active = p }; p += 1 }
|
||||
}
|
||||
let o = buf_new()
|
||||
buf_puts(o, "{\"signatures\":[{\"label\":")
|
||||
jstr_out(o, sig)
|
||||
if (g_call_doc != null) and not (g_call_doc == "") {
|
||||
buf_puts(o, ",\"documentation\":{\"kind\":\"markdown\",\"value\":")
|
||||
jstr_out(o, g_call_doc)
|
||||
buf_putc(o, '}')
|
||||
}
|
||||
buf_puts(o, ",\"parameters\":[")
|
||||
var i = 0
|
||||
while i < len(g_ps) {
|
||||
if i > 0 { buf_putc(o, ',') }
|
||||
buf_puts(o, "{\"label\":[")
|
||||
buf_puti(o, utf16_len(sig, 0, g_ps[i]))
|
||||
buf_putc(o, ',')
|
||||
buf_puti(o, utf16_len(sig, 0, g_pe[i]))
|
||||
buf_puts(o, "]")
|
||||
let ty = param_type(sig, g_ps[i], g_pe[i])
|
||||
if not (ty == "") { buf_puts(o, ",\"documentation\":"); jstr_out(o, ty) }
|
||||
buf_putc(o, '}')
|
||||
i += 1
|
||||
}
|
||||
buf_puts(o, "]}],\"activeSignature\":0,\"activeParameter\":")
|
||||
buf_puti(o, active)
|
||||
buf_putc(o, '}')
|
||||
send_result(id, buf_str(o))
|
||||
}
|
||||
|
||||
# completion inside a call's argument list: `name: ` for every parameter not
|
||||
# given yet, ranked first. Returns true when it added anything.
|
||||
function comp_call_args(o: Buf, D: Doc, off: int) -> bool {
|
||||
if not find_call_at(D, off) { return false }
|
||||
if not (g_call_named == "") { return false }
|
||||
let sig = call_signature(D)
|
||||
if (sig == null) { return false }
|
||||
split_params(sig)
|
||||
var added = false
|
||||
var i = 0
|
||||
while i < len(g_ps) {
|
||||
let pn = param_name(sig, g_ps[i], g_pe[i])
|
||||
var used = false
|
||||
var u = 0
|
||||
while u < len(g_call_used) { if (g_call_used[u] == pn) { used = true }; u += 1 }
|
||||
if not used and cstr_len(pn) > 0 {
|
||||
comp_item_full(o, pn + ":", 5, param_type(sig, g_ps[i], g_pe[i]), "0" + itoa(i + 10), pn + ": ")
|
||||
added = true
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return added
|
||||
}
|
||||
|
||||
# completion for `Namespace.`: its documented members
|
||||
function comp_ns_members(o: Buf, ns: pointer) -> bool {
|
||||
let idx = new []int
|
||||
docs_ns_members(ns, idx)
|
||||
var i = 0
|
||||
while i < len(idx) {
|
||||
let k = idx[i]
|
||||
let full = g_dx_token[k]
|
||||
let member = full[cstr_len(ns) + 1..cstr_len(full)]
|
||||
var sig = g_dx_sig[k]
|
||||
if (sig == "") { sig = g_dx_tip[k] }
|
||||
comp_item_full(o, member, 2, sig, "0" + member, "")
|
||||
i += 1
|
||||
}
|
||||
return len(idx) > 0
|
||||
}
|
||||
277
tools/ludic-tools/lsp/types.ludic
Normal file
277
tools/ludic-tools/lsp/types.ludic
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
# lsp/types.ludic — what type a name has, for hover and go-to-type.
|
||||
#
|
||||
# Declared types are taken as written (`var hp: int`, `sk: Skin`). An
|
||||
# undeclared one (`let i = skin_joint(sk, "hip")`) is inferred from its
|
||||
# initializer, shallowly: literals, `new T`, calls to functions and builtins
|
||||
# with a known return type, other names, fields, and comparisons. Anything
|
||||
# cleverer is the compiler's job; an unknown type is simply left out.
|
||||
|
||||
# the return type written after the last `->` of a signature label, or ""
|
||||
function sig_return(sig: pointer) -> pointer {
|
||||
if (sig == null) { return "" }
|
||||
let n = cstr_len(sig)
|
||||
var at = -1
|
||||
var i = 0
|
||||
while i + 1 < n {
|
||||
if sig[i] == '-' and sig[i + 1] == '>' { at = i }
|
||||
i += 1
|
||||
}
|
||||
if at < 0 { return "" }
|
||||
var s = at + 2
|
||||
while s < n and sig[s] == ' ' { s += 1 }
|
||||
var e = n
|
||||
while e > s and sig[e - 1] == ' ' { e -= 1 }
|
||||
return sig[s..e]
|
||||
}
|
||||
|
||||
function is_compare_op(D: Doc, t: int) -> bool {
|
||||
return tis(D, t, "==") or tis(D, t, "!=") or tis(D, t, "<") or tis(D, t, ">") or tis(D, t, "<=") or tis(D, t, ">=") or tis(D, t, "and") or tis(D, t, "or")
|
||||
}
|
||||
|
||||
# does the expression starting at t contain a top-level comparison before it ends?
|
||||
function expr_is_boolean(D: Doc, t: int) -> bool {
|
||||
var depth = 0
|
||||
var k = t
|
||||
let line = D.tk_line[t]
|
||||
while k >= 0 and k < ntok(D) {
|
||||
let kind = D.tk_kind[k]
|
||||
if kind == LT_NL or kind == LT_EOF or kind == LT_COMMENT { return false }
|
||||
if tis(D, k, "(") or tis(D, k, "[") { depth += 1 }
|
||||
else if tis(D, k, ")") or tis(D, k, "]") { if depth == 0 { return false }; depth -= 1 }
|
||||
else if tis(D, k, "{") or tis(D, k, "}") or tis(D, k, ";") or tis(D, k, ",") { if depth == 0 { return false } }
|
||||
else if depth == 0 and is_compare_op(D, k) { return true }
|
||||
if D.tk_line[k] != line { return false }
|
||||
k += 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
# the declared or inferred type of symbol y (which lives in doc O), or ""
|
||||
function sym_type(O: Doc, si: int, depth: int) -> pointer {
|
||||
let y = O.syms[si]
|
||||
if (y.ty != null) and not (y.ty == "") { return y.ty }
|
||||
if y.init >= 0 and depth < 8 { return infer_type(O, y.init, depth + 1) }
|
||||
return ""
|
||||
}
|
||||
|
||||
# the type of the expression whose first token is t, or ""
|
||||
function infer_type(D: Doc, t: int, depth: int) -> pointer {
|
||||
if t < 0 or t >= ntok(D) or depth > 8 { return "" }
|
||||
if expr_is_boolean(D, t) { return "bool" }
|
||||
let k = D.tk_kind[t]
|
||||
if k == LT_INT { return "int" }
|
||||
if k == LT_FLOAT { return "fixed" }
|
||||
if k == LT_STR { return "string" }
|
||||
if k == LT_CHAR { return "int" }
|
||||
if k == LT_BOOL { return "bool" }
|
||||
if tis(D, t, "not") { return "bool" }
|
||||
if tis(D, t, "(") { return infer_type(D, t + 1, depth + 1) }
|
||||
if tis(D, t, "-") { return infer_type(D, next_sig(D, t), depth + 1) }
|
||||
if tis(D, t, "new") {
|
||||
let n = next_sig(D, t)
|
||||
if n < 0 { return "" }
|
||||
if tis(D, n, "[") {
|
||||
let close = D.tmatch[n]
|
||||
if close >= 0 { let el = next_sig(D, close); if el >= 0 { return "[]" + ttext(D, el) } }
|
||||
return ""
|
||||
}
|
||||
return ttext(D, n)
|
||||
}
|
||||
if tis(D, t, "[") {
|
||||
let first = next_sig(D, t)
|
||||
if first >= 0 and not tis(D, first, "]") { let et = infer_type(D, first, depth + 1); if not (et == "") { return "[]" + et } }
|
||||
return ""
|
||||
}
|
||||
if k != LT_ID and k != LT_TYPE { return "" }
|
||||
let name = ttext(D, t)
|
||||
let nx = next_sig(D, t)
|
||||
# a call: its return type
|
||||
if nx >= 0 and tis(D, nx, "(") {
|
||||
if k == LT_TYPE { return name } # int(x), string(n)
|
||||
let top = find_top(D, name)
|
||||
if top >= 0 {
|
||||
let ow = g_owner
|
||||
if ow.syms[top].kind == LS_FN or ow.syms[top].kind == LS_EXTERN {
|
||||
let r = ow.syms[top].ty
|
||||
if (r == "") { return "void" }
|
||||
return r
|
||||
}
|
||||
}
|
||||
let r2 = sig_return(docs_sig(name))
|
||||
if not (r2 == "") { return r2 }
|
||||
return sig_return(builtin_sig(name))
|
||||
}
|
||||
# Name.member: a namespace call, or a field of a typed receiver
|
||||
if nx >= 0 and tis(D, nx, ".") {
|
||||
let m = next_sig(D, nx)
|
||||
if m < 0 { return "" }
|
||||
let mn = next_sig(D, m)
|
||||
if mn >= 0 and tis(D, mn, "(") {
|
||||
let r = sig_return(docs_sig(name + "." + ttext(D, m)))
|
||||
if not (r == "") { return r }
|
||||
return sig_return(ns_method_sig(name, ttext(D, m)))
|
||||
}
|
||||
let comp = receiver_component(D, nx)
|
||||
if comp >= 0 and (g_owner != null) {
|
||||
let co = g_owner
|
||||
var f = 0
|
||||
while f < len(co.syms) {
|
||||
if co.syms[f].kind == LS_FIELD and co.syms[f].parent == comp and (co.syms[f].name == ttext(D, m)) { return co.syms[f].ty }
|
||||
f += 1
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
# a record literal: `Pos { x: 1 }`
|
||||
if nx >= 0 and tis(D, nx, "{") and name[0] >= 'A' and name[0] <= 'Z' { return name }
|
||||
if k == LT_TYPE { return name }
|
||||
# another name
|
||||
let l = find_local(D, name, D.tk_start[t])
|
||||
if l >= 0 {
|
||||
if D.syms[l].init == t { return "" }
|
||||
return sym_type(D, l, depth + 1)
|
||||
}
|
||||
let top = find_top(D, name)
|
||||
if top >= 0 {
|
||||
let ow = g_owner
|
||||
let yk = ow.syms[top].kind
|
||||
if yk == LS_VAR or yk == LS_CONST { return sym_type(ow, top, depth + 1) }
|
||||
if yk == LS_FN or yk == LS_EXTERN { return ow.syms[top].detail }
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
# the hover code line for a symbol: `var hp: int`, `param sk: Skin`, …
|
||||
function sym_hover_code(O: Doc, si: int) -> pointer {
|
||||
let y = O.syms[si]
|
||||
let k = y.kind
|
||||
if k == LS_QUERYVAR and not (y.detail == "") { return y.detail }
|
||||
if k == LS_LOCAL or k == LS_PARAM or k == LS_VAR or k == LS_CONST or k == LS_QUERYVAR {
|
||||
let ty = sym_type(O, si, 0)
|
||||
var head = "let "
|
||||
if k == LS_LOCAL and y.mutable == 1 { head = "var " }
|
||||
if k == LS_PARAM { head = "param " }
|
||||
if k == LS_VAR { head = "var " }
|
||||
if k == LS_CONST { head = "const " }
|
||||
if k == LS_QUERYVAR { head = "query " }
|
||||
if (ty == "") { return head + y.name }
|
||||
return head + y.name + ": " + ty
|
||||
}
|
||||
if k == LS_COMPONENT or k == LS_ARCHETYPE { return record_hover_code(O, si) }
|
||||
if (y.detail == "") { return y.name }
|
||||
return y.detail
|
||||
}
|
||||
|
||||
# `property Actor {` with one field per line, the way it is written
|
||||
function record_hover_code(O: Doc, si: int) -> pointer {
|
||||
let y = O.syms[si]
|
||||
let b = buf_new()
|
||||
if y.kind == LS_COMPONENT { buf_puts(b, "property ") } else { buf_puts(b, "model ") }
|
||||
buf_puts(b, y.name)
|
||||
buf_puts(b, " {")
|
||||
var n = 0
|
||||
var f = 0
|
||||
while f < len(O.syms) {
|
||||
let fy = O.syms[f]
|
||||
if fy.kind == LS_FIELD and fy.parent == si {
|
||||
buf_puts(b, "\n ")
|
||||
buf_puts(b, fy.name)
|
||||
if not (fy.ty == "") { buf_puts(b, ": "); buf_puts(b, fy.ty) }
|
||||
n += 1
|
||||
}
|
||||
f += 1
|
||||
}
|
||||
if n == 0 { buf_puts(b, " }") } else { buf_puts(b, "\n}") }
|
||||
return buf_str(b)
|
||||
}
|
||||
|
||||
# the declaration of a symbol's type: a model/property in the workspace, else
|
||||
# the reference page of a built-in type. Location JSON or null.
|
||||
function type_definition_of(O: Doc, si: int, from: Doc) -> pointer {
|
||||
var ty = sym_type(O, si, 0)
|
||||
if (ty == "") { return null }
|
||||
while starts_with(ty, "[]") { ty = ty[2..cstr_len(ty)] }
|
||||
let top = find_top(from, ty)
|
||||
if top >= 0 {
|
||||
let ow = g_owner
|
||||
let o = buf_new()
|
||||
put_location(o, ow, ow.syms[top].start, ow.syms[top].end)
|
||||
return buf_str(o)
|
||||
}
|
||||
let i = docs_index(ty)
|
||||
if i < 0 { return null }
|
||||
let o = buf_new()
|
||||
buf_puts(o, "{\"uri\":")
|
||||
jstr_out(o, path_to_uri(g_dx_file[i]))
|
||||
buf_puts(o, ",\"range\":{\"start\":{\"line\":0,\"character\":0},\"end\":{\"line\":0,\"character\":0}}}")
|
||||
return buf_str(o)
|
||||
}
|
||||
|
||||
function on_type_definition(msg: JVal, id: JVal) -> void {
|
||||
let D = doc_of(msg)
|
||||
if (D == null) { send_result(id, "null"); return }
|
||||
let off = off_of(msg, D, "params.position")
|
||||
resolve_at(D, off)
|
||||
if (g_owner == null) or g_symidx < 0 { on_definition(msg, id); return }
|
||||
let r = type_definition_of(g_owner, g_symidx, D)
|
||||
if (r == null) { on_definition(msg, id); return }
|
||||
send_result(id, r)
|
||||
}
|
||||
|
||||
# ---- contextual keywords ------------------------------------------------------
|
||||
# The compiler reserves its declaration words only as function names
|
||||
# (is_reserved_word in selfhost/frontend/parse.ludic): `var model = …`,
|
||||
# `model: Model` and `a.model` are all ordinary names. A word is a keyword only
|
||||
# where it could start or continue a declaration.
|
||||
|
||||
function is_contextual_word(w: pointer) -> bool {
|
||||
if (w == "program") or (w == "import") or (w == "property") or (w == "model") or (w == "enum") or (w == "ui") { return true }
|
||||
if (w == "function") or (w == "extern") or (w == "handler") or (w == "entry") or (w == "event") or (w == "scene") { return true }
|
||||
if (w == "phase") or (w == "query") or (w == "on") or (w == "cancellable") or (w == "public") or (w == "layer") or (w == "start") { return true }
|
||||
if (w == "state") or (w == "machine") or (w == "prefab") or (w == "namespace") or (w == "test") { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
function demote_contextual_keywords(D: Doc) -> void {
|
||||
let N = ntok(D)
|
||||
var i = 0
|
||||
while i < N {
|
||||
if D.tk_kind[i] == LT_KW and is_contextual_word(ttext(D, i)) and used_as_name(D, i) { D.tk_kind[i] = LT_ID }
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
function used_as_name(D: Doc, i: int) -> bool {
|
||||
let p = prev_sig(D, i)
|
||||
if p >= 0 {
|
||||
if tis(D, p, ".") or tis(D, p, "var") or tis(D, p, "let") or tis(D, p, "const") or tis(D, p, "return") { return true }
|
||||
if D.tk_kind[p] == LT_OP and not (tis(D, p, "{") or tis(D, p, "}") or tis(D, p, ";") or tis(D, p, ")") or tis(D, p, "]")) {
|
||||
# `import` after `(` never happens; `query` after `in` is not an op
|
||||
return true
|
||||
}
|
||||
}
|
||||
let n = next_sig(D, i)
|
||||
if n >= 0 and D.tk_kind[n] == LT_OP {
|
||||
if tis(D, n, ":") or tis(D, n, ".") or tis(D, n, "=") or tis(D, n, ")") or tis(D, n, ",") or tis(D, n, "]") { return true }
|
||||
if tis(D, n, "+=") or tis(D, n, "-=") or tis(D, n, "==") or tis(D, n, "!=") { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
# is this a documented standard-library namespace (Screen, Color, Math, …)?
|
||||
function is_namespace_name(w: pointer) -> bool {
|
||||
if cstr_len(w) == 0 or w[0] < 'A' or w[0] > 'Z' { return false }
|
||||
docs_load()
|
||||
if docs_ns_index(w) >= 0 { return true }
|
||||
return ns_method_sig(w, "") != null
|
||||
}
|
||||
|
||||
# SEM_VENDOR when a symbol lives in a package (the toolchain's or ludic_modules/)
|
||||
function vendor_mod(O: Doc) -> int {
|
||||
if (O == null) { return 0 }
|
||||
if Text.contains(O.path, "/ludic_modules/") { return SEM_VENDOR }
|
||||
let home = lsp_home()
|
||||
if not (home == "") and starts_with(O.path, home + "packages/") { return SEM_VENDOR }
|
||||
if not (home == "") and starts_with(O.path, home + "runtime/") { return SEM_DEFAULT_LIBRARY }
|
||||
return 0
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue