Baseline: Ludic compiler + toolchain, Phase 1 syntax fixes complete
Self-hosted compiler (selfhost/*.ludic), runtime, examples, editor tooling, and docs. Phase 1 of the syntax-redesign cohesion pass has landed: edge-system fix, signature-query, when-alias, and the documentation truth-pass. Suite green (14/14), C-free bootstrap fixpoint holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
985f9ad8f2
418 changed files with 39065 additions and 0 deletions
|
|
@ -0,0 +1,83 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.openapi.options.Configurable
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.ui.TextFieldWithBrowseButton
|
||||
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
|
||||
import com.intellij.ui.components.JBCheckBox
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.FormBuilder
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JSpinner
|
||||
import javax.swing.SpinnerNumberModel
|
||||
|
||||
/** Settings -> Languages and Frameworks -> Ludic */
|
||||
class LudicConfigurable(private val project: Project) : Configurable {
|
||||
private val serverField = TextFieldWithBrowseButton()
|
||||
private val compilerField = TextFieldWithBrowseButton()
|
||||
private val diagnostics = JBCheckBox("Report compiler errors on save")
|
||||
private val indent = JSpinner(SpinnerNumberModel(2, 1, 8, 1))
|
||||
private var panel: JPanel? = null
|
||||
|
||||
override fun getDisplayName() = "Ludic"
|
||||
|
||||
override fun createComponent(): JComponent {
|
||||
// The title travels on the descriptor now; the four-argument overload
|
||||
// that took it separately is scheduled for removal.
|
||||
serverField.addBrowseFolderListener(
|
||||
project,
|
||||
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludic-lsp")
|
||||
)
|
||||
compilerField.addBrowseFolderListener(
|
||||
project,
|
||||
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludicc")
|
||||
)
|
||||
|
||||
panel = FormBuilder.createFormBuilder()
|
||||
.addLabeledComponent(JBLabel("Language server (ludic-lsp):"), serverField, 1, false)
|
||||
.addComponentToRightColumn(
|
||||
JBLabel("Leave empty to use build/ludic-lsp under the project, then PATH.")
|
||||
)
|
||||
.addLabeledComponent(JBLabel("Compiler (ludicc):"), compilerField, 1, false)
|
||||
.addComponentToRightColumn(
|
||||
JBLabel("Used for authoritative diagnostics. Empty means build/ludicc.")
|
||||
)
|
||||
.addComponent(diagnostics)
|
||||
.addLabeledComponent(JBLabel("Formatter indent:"), indent, 1, false)
|
||||
.addComponentFillVertically(JPanel(), 0)
|
||||
.panel
|
||||
reset()
|
||||
return panel!!
|
||||
}
|
||||
|
||||
private fun settings() = LudicSettings.of(project)
|
||||
|
||||
override fun isModified(): Boolean {
|
||||
val s = settings()
|
||||
return serverField.text != s.serverPath ||
|
||||
compilerField.text != s.compilerPath ||
|
||||
diagnostics.isSelected != s.compilerDiagnostics ||
|
||||
(indent.value as Int) != s.indentSize
|
||||
}
|
||||
|
||||
override fun apply() {
|
||||
val s = settings()
|
||||
s.serverPath = serverField.text.trim()
|
||||
s.compilerPath = compilerField.text.trim()
|
||||
s.compilerDiagnostics = diagnostics.isSelected
|
||||
s.indentSize = indent.value as Int
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
val s = settings()
|
||||
serverField.text = s.serverPath
|
||||
compilerField.text = s.compilerPath
|
||||
diagnostics.isSelected = s.compilerDiagnostics
|
||||
indent.value = s.indentSize
|
||||
}
|
||||
|
||||
override fun disposeUIResources() {
|
||||
panel = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lang.BracePair
|
||||
import com.intellij.lang.Commenter
|
||||
import com.intellij.lang.PairedBraceMatcher
|
||||
import com.intellij.lang.folding.FoldingBuilderEx
|
||||
import com.intellij.lang.folding.FoldingDescriptor
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.tree.IElementType
|
||||
|
||||
/**
|
||||
* The small editor mechanics the platform expects a language to provide: how to
|
||||
* comment a line, which delimiters pair up, and what can be folded. None of
|
||||
* this needs the language server, so none of it waits for one.
|
||||
*/
|
||||
class LudicCommenter : Commenter {
|
||||
override fun getLineCommentPrefix() = "# "
|
||||
override fun getBlockCommentPrefix(): String? = null
|
||||
override fun getBlockCommentSuffix(): String? = null
|
||||
override fun getCommentedBlockCommentPrefix(): String? = null
|
||||
override fun getCommentedBlockCommentSuffix(): String? = null
|
||||
}
|
||||
|
||||
class LudicBraceMatcher : PairedBraceMatcher {
|
||||
override fun getPairs(): Array<BracePair> = PAIRS
|
||||
|
||||
override fun isPairedBracesAllowedBeforeType(lbraceType: IElementType, next: IElementType?): Boolean = true
|
||||
|
||||
override fun getCodeConstructStart(file: PsiFile?, openingBraceOffset: Int): Int = openingBraceOffset
|
||||
|
||||
private companion object {
|
||||
// Structural braces only: a record literal shares the token type, so
|
||||
// marking them all structural would make the "matched brace" gutter
|
||||
// noisy inside `spawn Hero { Pos = { x = 1 } }`.
|
||||
val PAIRS = arrayOf(
|
||||
BracePair(LudicTokens.BRACE, LudicTokens.BRACE, true),
|
||||
BracePair(LudicTokens.BRACKET, LudicTokens.BRACKET, false),
|
||||
BracePair(LudicTokens.PAREN, LudicTokens.PAREN, false)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds every braced block. Working off the lexer's token stream rather than a
|
||||
* PSI tree keeps this correct for a flat PSI, and it degrades gracefully on a
|
||||
* file with unbalanced braces, which is most files while they are being typed.
|
||||
*/
|
||||
class LudicFoldingBuilder : FoldingBuilderEx(), DumbAware {
|
||||
override fun buildFoldRegions(root: PsiElement, document: Document, quick: Boolean): Array<FoldingDescriptor> {
|
||||
val text = root.containingFile?.text ?: return emptyArray()
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(text, 0, text.length, 0)
|
||||
|
||||
val opens = ArrayDeque<Int>()
|
||||
val regions = mutableListOf<FoldingDescriptor>()
|
||||
val node = root.node ?: return emptyArray()
|
||||
|
||||
while (lexer.tokenType != null) {
|
||||
if (lexer.tokenType == LudicTokens.BRACE) {
|
||||
when (text[lexer.tokenStart]) {
|
||||
'{' -> opens.addLast(lexer.tokenStart)
|
||||
'}' -> {
|
||||
val open = opens.removeLastOrNull()
|
||||
if (open != null) {
|
||||
val end = lexer.tokenEnd
|
||||
// only worth folding when it actually spans lines
|
||||
if (document.getLineNumber(end) > document.getLineNumber(open)) {
|
||||
regions += FoldingDescriptor(node, TextRange(open, end))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lexer.advance()
|
||||
}
|
||||
return regions.toTypedArray()
|
||||
}
|
||||
|
||||
override fun getPlaceholderText(node: com.intellij.lang.ASTNode) = "{...}"
|
||||
override fun isCollapsedByDefault(node: com.intellij.lang.ASTNode) = false
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lang.Language
|
||||
import com.intellij.openapi.fileTypes.LanguageFileType
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import javax.swing.Icon
|
||||
|
||||
/**
|
||||
* The language ID matters more than it looks: the IDE's Markdown support
|
||||
* resolves a fenced block's info string ("```ludic") against registered
|
||||
* language IDs, so registering as "Ludic" is what makes fences in .md files
|
||||
* highlight, with no Markdown-specific code on our side.
|
||||
*/
|
||||
object LudicLanguage : Language("Ludic") {
|
||||
private fun readResolve(): Any = LudicLanguage
|
||||
override fun getDisplayName() = "Ludic"
|
||||
override fun isCaseSensitive() = true
|
||||
}
|
||||
|
||||
object LudicIcons {
|
||||
val FILE: Icon = IconLoader.getIcon("/icons/ludic.svg", LudicIcons::class.java)
|
||||
}
|
||||
|
||||
object LudicFileType : LanguageFileType(LudicLanguage) {
|
||||
private fun readResolve(): Any = LudicFileType
|
||||
override fun getName() = "Ludic"
|
||||
override fun getDescription() = "Ludic source"
|
||||
override fun getDefaultExtension() = "ludic"
|
||||
override fun getIcon(): Icon = LudicIcons.FILE
|
||||
override fun getCharset(file: VirtualFile, content: ByteArray): String = "UTF-8"
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lexer.LexerBase
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.tree.IElementType
|
||||
|
||||
/**
|
||||
* A hand-written lexer matching tools/ludic-tools/ludic_syntax.h token for
|
||||
* token. Hand-written rather than generated because the grammar is small, the
|
||||
* rules are the ones the C lexer already implements, and keeping the two
|
||||
* readable side by side is worth more here than a JFlex spec.
|
||||
*
|
||||
* It must never throw and never fail to advance: the IDE lexes half-typed text
|
||||
* constantly, so anything unrecognised becomes one BAD character and the scan
|
||||
* carries on.
|
||||
*/
|
||||
class LudicLexer : LexerBase() {
|
||||
private var buffer: CharSequence = ""
|
||||
private var startOffset = 0
|
||||
private var endOffset = 0
|
||||
private var tokenStart = 0
|
||||
private var tokenEnd = 0
|
||||
private var tokenType: IElementType? = null
|
||||
|
||||
override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) {
|
||||
this.buffer = buffer
|
||||
this.startOffset = startOffset
|
||||
this.endOffset = endOffset
|
||||
this.tokenStart = startOffset
|
||||
this.tokenEnd = startOffset
|
||||
advance()
|
||||
}
|
||||
|
||||
override fun getState() = 0
|
||||
override fun getTokenType(): IElementType? = tokenType
|
||||
override fun getTokenStart() = tokenStart
|
||||
override fun getTokenEnd() = tokenEnd
|
||||
override fun getBufferSequence() = buffer
|
||||
override fun getBufferEnd() = endOffset
|
||||
|
||||
private fun at(i: Int): Char = if (i in startOffset until endOffset) buffer[i] else ' '
|
||||
private fun isIdStart(c: Char) = c.isLetter() || c == '_'
|
||||
private fun isIdPart(c: Char) = c.isLetterOrDigit() || c == '_'
|
||||
private fun isSpace(c: Char) = c == ' ' || c == '\t' || c == '\r' || c == '\n'
|
||||
|
||||
override fun advance() {
|
||||
tokenStart = tokenEnd
|
||||
if (tokenStart >= endOffset) {
|
||||
tokenType = null
|
||||
return
|
||||
}
|
||||
|
||||
var i = tokenStart
|
||||
val c = at(i)
|
||||
|
||||
if (isSpace(c)) {
|
||||
while (i < endOffset && isSpace(at(i))) i++
|
||||
emit(i, TokenType.WHITE_SPACE)
|
||||
return
|
||||
}
|
||||
if (c == '#') {
|
||||
while (i < endOffset && at(i) != '\n') i++
|
||||
emit(i, LudicTokens.COMMENT)
|
||||
return
|
||||
}
|
||||
if (c == '"') {
|
||||
i++
|
||||
while (i < endOffset && at(i) != '"' && at(i) != '\n') {
|
||||
if (at(i) == '\\' && i + 1 < endOffset) i += 2 else i++
|
||||
}
|
||||
if (i < endOffset && at(i) == '"') i++
|
||||
emit(i, LudicTokens.STRING)
|
||||
return
|
||||
}
|
||||
if (c == '\'') {
|
||||
i++
|
||||
if (at(i) == '\\' && i + 1 < endOffset) i += 2 else if (i < endOffset && at(i) != '\n') i++
|
||||
if (i < endOffset && at(i) == '\'') i++
|
||||
emit(i, LudicTokens.CHAR)
|
||||
return
|
||||
}
|
||||
if (c.isDigit()) {
|
||||
if (c == '0' && (at(i + 1) == 'x' || at(i + 1) == 'X')) {
|
||||
i += 2
|
||||
while (i < endOffset && (at(i).isDigit() || at(i) in 'a'..'f' || at(i) in 'A'..'F')) i++
|
||||
} else {
|
||||
while (i < endOffset && at(i).isDigit()) i++
|
||||
if (at(i) == '.' && at(i + 1).isDigit()) {
|
||||
i++
|
||||
while (i < endOffset && at(i).isDigit()) i++
|
||||
}
|
||||
}
|
||||
emit(i, LudicTokens.NUMBER)
|
||||
return
|
||||
}
|
||||
// @deterministic is one token: the sigil never stands alone
|
||||
if (c == '@' && isIdStart(at(i + 1))) {
|
||||
i++
|
||||
while (i < endOffset && isIdPart(at(i))) i++
|
||||
emit(i, LudicTokens.ANNOTATION)
|
||||
return
|
||||
}
|
||||
if (isIdStart(c)) {
|
||||
while (i < endOffset && isIdPart(at(i))) i++
|
||||
val word = buffer.subSequence(tokenStart, i).toString()
|
||||
val type = when {
|
||||
word == "true" || word == "false" -> LudicTokens.BOOLEAN
|
||||
word in LudicVocabulary.PRIMITIVES -> LudicTokens.PRIMITIVE
|
||||
word in LudicVocabulary.PHASES -> LudicTokens.PHASE
|
||||
word in LudicVocabulary.DECL -> LudicTokens.DECL_KEYWORD
|
||||
word in LudicVocabulary.CLAUSE -> LudicTokens.CLAUSE
|
||||
word in LudicVocabulary.STMT -> LudicTokens.KEYWORD
|
||||
word in LudicVocabulary.BUILTINS -> LudicTokens.BUILTIN
|
||||
word[0].isUpperCase() -> LudicTokens.TYPE_NAME
|
||||
else -> LudicTokens.IDENTIFIER
|
||||
}
|
||||
emit(i, type)
|
||||
return
|
||||
}
|
||||
// two-character operators before one-character ones
|
||||
val two = if (i + 1 < endOffset) buffer.subSequence(i, i + 2).toString() else ""
|
||||
if (two in TWO_CHAR_OPS) {
|
||||
emit(i + 2, LudicTokens.OPERATOR)
|
||||
return
|
||||
}
|
||||
when (c) {
|
||||
'{', '}' -> { emit(i + 1, LudicTokens.BRACE); return }
|
||||
'[', ']' -> { emit(i + 1, LudicTokens.BRACKET); return }
|
||||
'(', ')' -> { emit(i + 1, LudicTokens.PAREN); return }
|
||||
',' -> { emit(i + 1, LudicTokens.COMMA); return }
|
||||
}
|
||||
if (c in SINGLE_CHAR_OPS) {
|
||||
emit(i + 1, LudicTokens.OPERATOR)
|
||||
return
|
||||
}
|
||||
emit(i + 1, LudicTokens.BAD)
|
||||
}
|
||||
|
||||
private fun emit(end: Int, type: IElementType) {
|
||||
tokenEnd = end
|
||||
tokenType = type
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val TWO_CHAR_OPS = setOf("->", "+=", "-=", "*=", "/=", "==", "!=", "<=", ">=", "&&", "||", "..", "=>")
|
||||
const val SINGLE_CHAR_OPS = "+-*/%<>=:.!@;"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lang.ASTNode
|
||||
import com.intellij.lang.ParserDefinition
|
||||
import com.intellij.lang.PsiParser
|
||||
import com.intellij.lexer.Lexer
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.psi.FileViewProvider
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.tree.IFileElementType
|
||||
import com.intellij.psi.tree.TokenSet
|
||||
import com.intellij.extapi.psi.ASTWrapperPsiElement
|
||||
import com.intellij.extapi.psi.PsiFileBase
|
||||
|
||||
/**
|
||||
* A flat PSI: every token becomes a leaf under the file node, with no tree
|
||||
* structure above it.
|
||||
*
|
||||
* That is enough for the two things the platform genuinely needs a parser for —
|
||||
* injecting Ludic into Markdown fences, and letting the editor treat a .ludic
|
||||
* file as a real language rather than plain text — while all actual analysis
|
||||
* stays in ludic-lsp, where it is shared with every other editor. Building a
|
||||
* second, divergent parser in Kotlin would mean two grammars to keep honest
|
||||
* against one compiler.
|
||||
*/
|
||||
class LudicParserDefinition : ParserDefinition {
|
||||
override fun createLexer(project: Project?): Lexer = LudicLexer()
|
||||
|
||||
override fun createParser(project: Project?): PsiParser = PsiParser { root, builder ->
|
||||
val mark = builder.mark()
|
||||
while (!builder.eof()) builder.advanceLexer()
|
||||
mark.done(root)
|
||||
builder.treeBuilt
|
||||
}
|
||||
|
||||
override fun getFileNodeType(): IFileElementType = LudicTokens.FILE
|
||||
override fun getCommentTokens(): TokenSet = TokenSet.create(LudicTokens.COMMENT)
|
||||
override fun getStringLiteralElements(): TokenSet = TokenSet.create(LudicTokens.STRING, LudicTokens.CHAR)
|
||||
override fun getWhitespaceTokens(): TokenSet = TokenSet.create(TokenType.WHITE_SPACE)
|
||||
// Only ever reached for composite nodes, and this grammar marks none beyond
|
||||
// the file itself — but the platform requires an answer, so give it a real
|
||||
// PSI element rather than something that would surprise it later.
|
||||
override fun createElement(node: ASTNode): PsiElement = ASTWrapperPsiElement(node)
|
||||
override fun createFile(viewProvider: FileViewProvider): PsiFile = LudicPsiFile(viewProvider)
|
||||
}
|
||||
|
||||
class LudicPsiFile(viewProvider: FileViewProvider) : PsiFileBase(viewProvider, LudicLanguage) {
|
||||
override fun getFileType() = LudicFileType
|
||||
override fun toString() = "Ludic file"
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.openapi.components.PersistentStateComponent
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.State
|
||||
import com.intellij.openapi.components.Storage
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.util.xmlb.XmlSerializerUtil
|
||||
import com.redhat.devtools.lsp4ij.LanguageServerFactory
|
||||
import com.redhat.devtools.lsp4ij.client.LanguageClientImpl
|
||||
import com.redhat.devtools.lsp4ij.server.CannotStartProcessException
|
||||
import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider
|
||||
import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Where to find the toolchain. Both paths may be left blank, in which case the
|
||||
* plugin looks under the project root for what tools/build-tools.sh produces —
|
||||
* which is the case for anyone working in the Ludic repository itself.
|
||||
*/
|
||||
@Service(Service.Level.PROJECT)
|
||||
@State(name = "LudicSettings", storages = [Storage("ludic.xml")])
|
||||
class LudicSettings : PersistentStateComponent<LudicSettings> {
|
||||
var serverPath: String = ""
|
||||
var compilerPath: String = ""
|
||||
var compilerDiagnostics: Boolean = true
|
||||
var indentSize: Int = 2
|
||||
|
||||
override fun getState(): LudicSettings = this
|
||||
override fun loadState(state: LudicSettings) = XmlSerializerUtil.copyBean(state, this)
|
||||
|
||||
companion object {
|
||||
fun of(project: Project): LudicSettings = project.service()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First existing executable among the usual places, or null.
|
||||
*
|
||||
* Takes the project root as a plain path rather than a Project so it can be
|
||||
* tested without booting an IDE — this lookup silently returning null is the
|
||||
* difference between a working language server and one that never starts.
|
||||
*/
|
||||
internal fun findExecutable(root: String?, configured: String, names: List<String>, subdirs: List<String>): String? {
|
||||
if (configured.isNotBlank()) {
|
||||
val f = File(expandHome(configured))
|
||||
return if (f.canExecute()) f.absolutePath else null
|
||||
}
|
||||
if (root == null) return searchPath(names)
|
||||
for (dir in subdirs) {
|
||||
for (name in names) {
|
||||
val f = File(File(root, dir), name)
|
||||
if (f.isFile && f.canExecute()) return f.absolutePath
|
||||
}
|
||||
}
|
||||
return searchPath(names)
|
||||
}
|
||||
|
||||
/** Fall back to PATH, so a system-wide install works with no configuration. */
|
||||
private fun searchPath(names: List<String>): String? {
|
||||
val path = System.getenv("PATH") ?: return null
|
||||
for (dir in path.split(File.pathSeparator)) {
|
||||
for (name in names) {
|
||||
val f = File(dir, name)
|
||||
if (f.isFile && f.canExecute()) return f.absolutePath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun expandHome(p: String) =
|
||||
if (p.startsWith("~")) System.getProperty("user.home") + p.substring(1) else p
|
||||
|
||||
class LudicConnectionProvider(private val project: Project) : ProcessStreamConnectionProvider() {
|
||||
|
||||
/**
|
||||
* Resolve at start(), not at construction.
|
||||
*
|
||||
* The provider is built when the project opens, which is usually before the
|
||||
* toolchain has been compiled. Resolving in an `init` block froze that
|
||||
* answer forever: build ludic-lsp afterwards and the server stayed dead
|
||||
* until the IDE was restarted, with `commands=[]` as the only clue.
|
||||
*/
|
||||
override fun start() {
|
||||
val settings = LudicSettings.of(project)
|
||||
val root = project.basePath
|
||||
setCommands(serverCommand(root, settings.serverPath)
|
||||
?: throw CannotStartProcessException(notFoundMessage(root, settings.serverPath)))
|
||||
root?.let { setWorkingDirectory(it) }
|
||||
super.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* What went wrong, in terms of the user's filesystem. "commands=[]" tells
|
||||
* nobody anything; this names every path that was tried.
|
||||
*/
|
||||
private fun notFoundMessage(root: String?, configured: String): String = buildString {
|
||||
if (configured.isNotBlank()) {
|
||||
append("The Ludic language server configured at '")
|
||||
append(configured)
|
||||
append("' is missing or not executable.")
|
||||
} else {
|
||||
append("Could not find the 'ludic-lsp' executable. Looked in:\n")
|
||||
SERVER_SUBDIRS.forEach { d ->
|
||||
append(" ")
|
||||
append(if (root != null) File(File(root, d), "ludic-lsp").path else "<no project root>/$d/ludic-lsp")
|
||||
append('\n')
|
||||
}
|
||||
append(" and on PATH.\n")
|
||||
}
|
||||
append("\nBuild it with ./tools/build-tools.sh from the repository root, ")
|
||||
append("or set the path in Settings | Languages & Frameworks | Ludic. ")
|
||||
append("Syntax highlighting works without it; completion and diagnostics do not.")
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to the server's `initialize`. The server can find `ludicc` on its
|
||||
* own, but telling it explicitly means the IDE's setting wins.
|
||||
*/
|
||||
override fun getInitializationOptions(rootUri: VirtualFile?): Any {
|
||||
val settings = LudicSettings.of(project)
|
||||
val compiler = findExecutable(
|
||||
project.basePath, settings.compilerPath,
|
||||
listOf("ludicc", "ludicc.exe"),
|
||||
COMPILER_SUBDIRS
|
||||
) ?: ""
|
||||
return mapOf(
|
||||
"compilerPath" to compiler,
|
||||
"compilerDiagnostics" to settings.compilerDiagnostics,
|
||||
"indentSize" to settings.indentSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact argv the language server is launched with, or null when the binary
|
||||
* cannot be found. Split out from start() so the command line itself is
|
||||
* testable — an empty or wrong command here is invisible until LSP4IJ reports
|
||||
* `commands=[]`, which says nothing about why.
|
||||
*/
|
||||
internal fun serverCommand(root: String?, configured: String): List<String>? {
|
||||
val exe = findExecutable(root, configured, listOf("ludic-lsp", "ludic-lsp.exe"), SERVER_SUBDIRS)
|
||||
return exe?.let { listOf(it, "--stdio") }
|
||||
}
|
||||
|
||||
/** Where the toolchain is looked for, relative to the project root. */
|
||||
internal val SERVER_SUBDIRS = listOf("build", "tools/build", "bin", ".")
|
||||
internal val COMPILER_SUBDIRS = listOf("build", "bin", ".")
|
||||
|
||||
class LudicLanguageServerFactory : LanguageServerFactory {
|
||||
override fun createConnectionProvider(project: Project): StreamConnectionProvider =
|
||||
LudicConnectionProvider(project)
|
||||
|
||||
override fun createLanguageClient(project: Project): LanguageClientImpl =
|
||||
LanguageClientImpl(project)
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lexer.Lexer
|
||||
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors as D
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey.createTextAttributesKey
|
||||
import com.intellij.openapi.editor.HighlighterColors
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighter
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighterBase
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighterFactory
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.psi.tree.IElementType
|
||||
|
||||
/**
|
||||
* Colours are all derived from the IDE's own semantic keys rather than hard
|
||||
* coded, so Ludic looks native in whatever theme the user has, dark or light,
|
||||
* without shipping a colour scheme.
|
||||
*/
|
||||
object LudicColors {
|
||||
val COMMENT = key("LUDIC_COMMENT", D.LINE_COMMENT)
|
||||
val STRING = key("LUDIC_STRING", D.STRING)
|
||||
val NUMBER = key("LUDIC_NUMBER", D.NUMBER)
|
||||
val KEYWORD = key("LUDIC_KEYWORD", D.KEYWORD)
|
||||
val DECL_KEYWORD = key("LUDIC_DECL_KEYWORD", D.KEYWORD)
|
||||
val CLAUSE = key("LUDIC_CLAUSE", D.METADATA)
|
||||
val PRIMITIVE = key("LUDIC_PRIMITIVE", D.KEYWORD)
|
||||
val PHASE = key("LUDIC_PHASE", D.STATIC_FIELD)
|
||||
val BOOLEAN = key("LUDIC_BOOLEAN", D.KEYWORD)
|
||||
val BUILTIN = key("LUDIC_BUILTIN", D.PREDEFINED_SYMBOL)
|
||||
val TYPE_NAME = key("LUDIC_TYPE", D.CLASS_NAME)
|
||||
val IDENTIFIER = key("LUDIC_IDENTIFIER", D.IDENTIFIER)
|
||||
val ANNOTATION = key("LUDIC_ANNOTATION", D.METADATA)
|
||||
val OPERATOR = key("LUDIC_OPERATOR", D.OPERATION_SIGN)
|
||||
val BRACE = key("LUDIC_BRACE", D.BRACES)
|
||||
val BRACKET = key("LUDIC_BRACKET", D.BRACKETS)
|
||||
val PAREN = key("LUDIC_PAREN", D.PARENTHESES)
|
||||
val COMMA = key("LUDIC_COMMA", D.COMMA)
|
||||
val BAD = createTextAttributesKey("LUDIC_BAD_CHARACTER", HighlighterColors.BAD_CHARACTER)
|
||||
|
||||
private fun key(name: String, base: TextAttributesKey) = createTextAttributesKey(name, base)
|
||||
}
|
||||
|
||||
class LudicSyntaxHighlighter : SyntaxHighlighterBase() {
|
||||
override fun getHighlightingLexer(): Lexer = LudicLexer()
|
||||
|
||||
override fun getTokenHighlights(tokenType: IElementType): Array<TextAttributesKey> = pack(
|
||||
when (tokenType) {
|
||||
LudicTokens.COMMENT -> LudicColors.COMMENT
|
||||
LudicTokens.STRING, LudicTokens.CHAR -> LudicColors.STRING
|
||||
LudicTokens.NUMBER -> LudicColors.NUMBER
|
||||
LudicTokens.KEYWORD -> LudicColors.KEYWORD
|
||||
LudicTokens.DECL_KEYWORD -> LudicColors.DECL_KEYWORD
|
||||
LudicTokens.CLAUSE -> LudicColors.CLAUSE
|
||||
LudicTokens.PRIMITIVE -> LudicColors.PRIMITIVE
|
||||
LudicTokens.PHASE -> LudicColors.PHASE
|
||||
LudicTokens.BOOLEAN -> LudicColors.BOOLEAN
|
||||
LudicTokens.BUILTIN -> LudicColors.BUILTIN
|
||||
LudicTokens.TYPE_NAME -> LudicColors.TYPE_NAME
|
||||
LudicTokens.IDENTIFIER -> LudicColors.IDENTIFIER
|
||||
LudicTokens.ANNOTATION -> LudicColors.ANNOTATION
|
||||
LudicTokens.OPERATOR -> LudicColors.OPERATOR
|
||||
LudicTokens.BRACE -> LudicColors.BRACE
|
||||
LudicTokens.BRACKET -> LudicColors.BRACKET
|
||||
LudicTokens.PAREN -> LudicColors.PAREN
|
||||
LudicTokens.COMMA -> LudicColors.COMMA
|
||||
LudicTokens.BAD -> LudicColors.BAD
|
||||
else -> null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
class LudicSyntaxHighlighterFactory : SyntaxHighlighterFactory() {
|
||||
override fun getSyntaxHighlighter(project: Project?, virtualFile: VirtualFile?): SyntaxHighlighter =
|
||||
LudicSyntaxHighlighter()
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.psi.tree.IElementType
|
||||
import com.intellij.psi.tree.IFileElementType
|
||||
|
||||
/**
|
||||
* One token type per thing the highlighter needs to tell apart. This is a
|
||||
* lexer-only language on the JetBrains side: the token set is the whole grammar
|
||||
* the platform sees, and ludic-lsp supplies everything structural.
|
||||
*/
|
||||
class LudicTokenType(debugName: String) : IElementType(debugName, LudicLanguage)
|
||||
|
||||
object LudicTokens {
|
||||
val FILE = IFileElementType(LudicLanguage)
|
||||
|
||||
val COMMENT = LudicTokenType("COMMENT")
|
||||
val STRING = LudicTokenType("STRING")
|
||||
val CHAR = LudicTokenType("CHAR")
|
||||
val NUMBER = LudicTokenType("NUMBER")
|
||||
val IDENTIFIER = LudicTokenType("IDENTIFIER")
|
||||
val TYPE_NAME = LudicTokenType("TYPE_NAME") // Capitalised: component, archetype, scene
|
||||
val KEYWORD = LudicTokenType("KEYWORD")
|
||||
val DECL_KEYWORD = LudicTokenType("DECL_KEYWORD") // game, component, system, fn, ...
|
||||
val CLAUSE = LudicTokenType("CLAUSE") // phase, query, reads, writes, ...
|
||||
val PRIMITIVE = LudicTokenType("PRIMITIVE") // int, fixed, bool, entity, str, ptr, void
|
||||
val PHASE = LudicTokenType("PHASE") // Start, Update, Render, ...
|
||||
val BOOLEAN = LudicTokenType("BOOLEAN")
|
||||
val BUILTIN = LudicTokenType("BUILTIN") // runtime surface + compiler intrinsics
|
||||
val ANNOTATION = LudicTokenType("ANNOTATION") // @deterministic
|
||||
val OPERATOR = LudicTokenType("OPERATOR")
|
||||
val BRACE = LudicTokenType("BRACE")
|
||||
val BRACKET = LudicTokenType("BRACKET")
|
||||
val PAREN = LudicTokenType("PAREN")
|
||||
val COMMA = LudicTokenType("COMMA")
|
||||
val BAD = LudicTokenType("BAD_CHARACTER")
|
||||
}
|
||||
|
||||
/**
|
||||
* The vocabulary, mirroring tools/ludic-tools/ludic_syntax.h. When the language
|
||||
* grows a keyword both sides change together; that header is the reference.
|
||||
*/
|
||||
object LudicVocabulary {
|
||||
val DECL = setOf(
|
||||
"game", "module", "import", "component", "struct", "archetype", "ui", "scene", "layer",
|
||||
"const", "var", "export", "fn", "pure", "extern", "system", "edge", "start", "on"
|
||||
)
|
||||
val CLAUSE = setOf(
|
||||
"phase", "query", "reads", "writes", "needs", "uses",
|
||||
"requires", "ensures", "invariant", "effects"
|
||||
)
|
||||
val STMT = setOf(
|
||||
"let", "return", "if", "else", "when", "while", "for", "in", "spawn", "despawn",
|
||||
"match", "machine", "state", "become", "enter", "where", "and", "or", "not",
|
||||
"break", "continue", "new"
|
||||
)
|
||||
val PRIMITIVES = setOf("int", "fixed", "bool", "entity", "str", "ptr", "void")
|
||||
val PHASES = setOf("Start", "Input", "FixedUpdate", "Update", "LateUpdate", "Render")
|
||||
val WIDGETS = setOf("panel", "col", "row", "label", "button", "image", "spacer")
|
||||
|
||||
val BUILTINS = setOf(
|
||||
"min", "max", "abs", "clamp", "seed", "rng_range", "rng_chance", "fx", "flr",
|
||||
"map_size", "map_row", "tile", "clear", "present", "fill_rect", "frame_rect",
|
||||
"put_px", "text", "text_int", "font_load", "text_ttf", "text_w", "text_h",
|
||||
"image_load", "draw_image", "draw_image_scaled", "draw_9slice", "load_png",
|
||||
"load_sprites", "draw_sprite", "draw_sprite_scaled", "ui_build", "ui_open",
|
||||
"ui_tick", "ui_render", "ui_clicked", "ui_set_text", "ui_set_int", "ui_focus",
|
||||
"ui_focused", "ui_visible", "key", "reg", "setreg", "self", "save", "load",
|
||||
"status", "print_int", "quit",
|
||||
// compiler intrinsics: the floor the Ludic-written runtime stands on
|
||||
"mem_alloc", "mem_realloc", "mem_free", "mem_copy", "mem_set", "peek8", "poke8", "peek32",
|
||||
"os_argc", "os_arg", "file_stderr",
|
||||
"poke32", "peekp", "pokep", "peekf", "pokef", "ptr_add", "ptr_null",
|
||||
"ptr_is_null", "as_fixed", "as_int", "file_open", "file_read", "file_write",
|
||||
"file_seek", "file_tell", "file_close", "read_byte", "write_byte", "print_str",
|
||||
"str_len", "shl", "shr", "band", "bor", "bxor", "bnot", "os_exit", "os_time",
|
||||
"is_windowed", "game_title", "win_open", "win_poll", "win_present",
|
||||
"win_running", "win_close"
|
||||
)
|
||||
|
||||
fun isKeyword(w: String) = w in DECL || w in CLAUSE || w in STMT
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<idea-plugin>
|
||||
<!--
|
||||
Loaded only when the Markdown plugin is present.
|
||||
|
||||
Nothing needs to be declared for ```ludic fences to highlight: the
|
||||
Markdown plugin resolves a fence's info string against registered language
|
||||
IDs, and io.ludic.ide.LudicLanguage registers as "Ludic". This file exists
|
||||
so that the dependency is expressed honestly — the feature does depend on
|
||||
the Markdown plugin — and as the place to hang Markdown-specific behaviour
|
||||
if the fence syntax ever needs more than the default.
|
||||
-->
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<idea-plugin>
|
||||
<id>io.ludic.ide</id>
|
||||
<name>Ludic</name>
|
||||
<vendor>Ludic</vendor>
|
||||
|
||||
<description><![CDATA[
|
||||
<p>Language support for <b>Ludic</b>, the AI-first, ECS-native game language.</p>
|
||||
<ul>
|
||||
<li>Syntax highlighting, brace matching, commenting and folding, implemented natively so a
|
||||
<code>.ludic</code> file is coloured with no external process.</li>
|
||||
<li>Completion, diagnostics, hover, go-to-definition, find usages, rename and formatting via
|
||||
the <code>ludic-lsp</code> language server, through LSP4IJ, so this works in Community
|
||||
editions as well as the paid IDEs.</li>
|
||||
<li>Errors from the Ludic compiler itself, reported on save.</li>
|
||||
<li>Highlighting of <code>```ludic</code> fenced blocks in Markdown.</li>
|
||||
</ul>
|
||||
<p>Set the paths under <b>Settings → Languages & Frameworks → Ludic</b>, or leave
|
||||
them blank to use <code>build/ludic-lsp</code> and <code>build/ludicc</code> under the
|
||||
project root.</p>
|
||||
]]></description>
|
||||
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
<depends>com.redhat.devtools.lsp4ij</depends>
|
||||
<!-- Optional: without it everything still works, we just do not get the
|
||||
Markdown fence injection that the platform's own Markdown plugin does
|
||||
for any registered language. -->
|
||||
<depends optional="true" config-file="ludic-markdown.xml">org.intellij.plugins.markdown</depends>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<fileType
|
||||
name="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicFileType"
|
||||
fieldName="INSTANCE"
|
||||
language="Ludic"
|
||||
extensions="ludic"/>
|
||||
|
||||
<lang.parserDefinition
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicParserDefinition"/>
|
||||
|
||||
<lang.syntaxHighlighterFactory
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicSyntaxHighlighterFactory"/>
|
||||
|
||||
<lang.commenter
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicCommenter"/>
|
||||
|
||||
<lang.braceMatcher
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicBraceMatcher"/>
|
||||
|
||||
<lang.foldingBuilder
|
||||
language="Ludic"
|
||||
implementationClass="io.ludic.ide.LudicFoldingBuilder"/>
|
||||
|
||||
<projectConfigurable
|
||||
parentId="language"
|
||||
instance="io.ludic.ide.LudicConfigurable"
|
||||
id="io.ludic.ide.settings"
|
||||
displayName="Ludic"/>
|
||||
</extensions>
|
||||
|
||||
<extensions defaultExtensionNs="com.redhat.devtools.lsp4ij">
|
||||
<server
|
||||
id="ludic"
|
||||
name="Ludic"
|
||||
factoryClass="io.ludic.ide.LudicLanguageServerFactory">
|
||||
<description><![CDATA[
|
||||
The Ludic language server. Built by <code>tools/build-tools.sh</code>.
|
||||
]]></description>
|
||||
</server>
|
||||
<languageMapping language="Ludic" serverId="ludic"/>
|
||||
<!-- Markdown is mapped too: the server understands ```ludic fences in a
|
||||
.md and reports diagnostics and semantic tokens for them. -->
|
||||
<languageMapping language="Markdown" serverId="ludic"/>
|
||||
</extensions>
|
||||
</idea-plugin>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2c"/>
|
||||
<rect x="7" y="7" width="8" height="8" rx="1.5" fill="#ffe060"/>
|
||||
<rect x="17" y="7" width="8" height="8" rx="1.5" fill="#4a4a72"/>
|
||||
<rect x="7" y="17" width="8" height="8" rx="1.5" fill="#4a4a72"/>
|
||||
<rect x="17" y="17" width="8" height="8" rx="1.5" fill="#9aa0c8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 418 B |
|
|
@ -0,0 +1,155 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Locating `ludic-lsp` is the single point where the whole language server can
|
||||
* fail silently: if this returns null the provider starts with an empty command
|
||||
* list, LSP4IJ reports `commands=[]`, and nothing in that message points at the
|
||||
* cause. It shipped broken once for exactly that reason, so it gets tests.
|
||||
*/
|
||||
class FindExecutableTest {
|
||||
|
||||
@get:Rule
|
||||
val tmp = TemporaryFolder()
|
||||
|
||||
private val names = listOf("ludic-lsp", "ludic-lsp.exe")
|
||||
|
||||
private fun executableAt(vararg segments: String): File {
|
||||
val f = File(tmp.root, segments.joinToString(File.separator))
|
||||
f.parentFile.mkdirs()
|
||||
f.writeText("#!/bin/sh\n")
|
||||
check(f.setExecutable(true))
|
||||
return f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finds the binary that build-tools_sh produces`() {
|
||||
val exe = executableAt("build", "ludic-lsp")
|
||||
assertEquals(exe.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a project root that is a subdirectory of the repo finds nothing`() {
|
||||
// The bug that shipped: the IDE was opened on <repo>/tools, so the
|
||||
// lookup searched <repo>/tools/build and came back empty while
|
||||
// <repo>/build/ludic-lsp sat there the whole time.
|
||||
executableAt("build", "ludic-lsp")
|
||||
val subdir = File(tmp.root, "tools").apply { mkdirs() }
|
||||
assertNull(
|
||||
"must not silently resolve from an unrelated root",
|
||||
findExecutable(subdir.path, "", names, SERVER_SUBDIRS)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search order prefers build over the other candidates`() {
|
||||
executableAt("bin", "ludic-lsp")
|
||||
val preferred = executableAt("build", "ludic-lsp")
|
||||
assertEquals(preferred.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every documented subdirectory is actually searched`() {
|
||||
for (dir in SERVER_SUBDIRS) {
|
||||
val fresh = TemporaryFolder().apply { create() }
|
||||
val f = File(fresh.root, "$dir${File.separator}ludic-lsp")
|
||||
f.parentFile.mkdirs()
|
||||
f.writeText("#!/bin/sh\n")
|
||||
f.setExecutable(true)
|
||||
assertEquals(
|
||||
"subdir '$dir' is advertised but not searched",
|
||||
f.canonicalPath,
|
||||
File(findExecutable(fresh.root.path, "", names, SERVER_SUBDIRS)!!).canonicalPath
|
||||
)
|
||||
fresh.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicitly configured path wins over anything found by search`() {
|
||||
executableAt("build", "ludic-lsp")
|
||||
val custom = executableAt("elsewhere", "my-lsp")
|
||||
assertEquals(custom.absolutePath, findExecutable(tmp.root.path, custom.absolutePath, names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a configured path that does not exist fails instead of falling back`() {
|
||||
// Silently ignoring the setting and using a different binary would be
|
||||
// worse than failing: the user asked for a specific server.
|
||||
executableAt("build", "ludic-lsp")
|
||||
assertNull(findExecutable(tmp.root.path, File(tmp.root, "nope").path, names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-executable file is not accepted`() {
|
||||
val f = File(tmp.root, "build/ludic-lsp")
|
||||
f.parentFile.mkdirs()
|
||||
f.writeText("not executable")
|
||||
f.setExecutable(false)
|
||||
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a directory named like the binary is not accepted`() {
|
||||
File(tmp.root, "build/ludic-lsp").mkdirs()
|
||||
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null project root still allows a PATH lookup`() {
|
||||
// No exception, no crash: the default project has no basePath.
|
||||
findExecutable(null, "", names, SERVER_SUBDIRS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the real repository layout resolves`() {
|
||||
// Guards the arrangement the plugin actually ships against: run
|
||||
// ./tools/build-tools.sh and opening the repo root must just work.
|
||||
val repoRoot = File(System.getProperty("user.dir")).parentFile.parentFile.parentFile
|
||||
val built = File(repoRoot, "build/ludic-lsp")
|
||||
if (!built.isFile) return // toolchain not built in this checkout
|
||||
val found = findExecutable(repoRoot.path, "", names, SERVER_SUBDIRS)
|
||||
assertTrue("repo root must resolve to build/ludic-lsp", found != null)
|
||||
assertEquals(built.canonicalPath, File(found!!).canonicalPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The launch command itself. LSP4IJ only ever reports `commands=[...]`, so if
|
||||
* this list is wrong the failure surfaces with no explanation at all.
|
||||
*/
|
||||
class ServerCommandTest {
|
||||
|
||||
private val repoRoot: File =
|
||||
File(System.getProperty("user.dir")).parentFile.parentFile.parentFile
|
||||
|
||||
@Test
|
||||
fun `the repository root produces a runnable stdio command`() {
|
||||
val built = File(repoRoot, "build/ludic-lsp")
|
||||
if (!built.isFile) return // toolchain not built in this checkout
|
||||
|
||||
val cmd = serverCommand(repoRoot.path, "")
|
||||
assertEquals(listOf(built.canonicalPath, "--stdio"), cmd!!.let { listOf(File(it[0]).canonicalPath, it[1]) })
|
||||
|
||||
// and it must actually be executable, not merely present
|
||||
val proc = ProcessBuilder(cmd[0], "--version").redirectErrorStream(true).start()
|
||||
val out = proc.inputStream.bufferedReader().readText().trim()
|
||||
proc.waitFor()
|
||||
assertTrue("expected a version banner, got '$out'", out.startsWith("ludic-lsp"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a root without the toolchain yields no command rather than an empty one`() {
|
||||
// The distinction that matters: null lets start() throw an explanatory
|
||||
// error; an empty list is what produced the useless `commands=[]`.
|
||||
val cmd = serverCommand(File(repoRoot, "tools/editors").path, "")
|
||||
assertTrue("must be null, not an empty list", cmd == null || cmd.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.tree.IElementType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Syntax highlighting is the lexer plus a colour map, so this is where a
|
||||
* highlighting regression actually shows up. These are plain JUnit tests: the
|
||||
* lexer has no platform dependencies, so they run in milliseconds without
|
||||
* booting an IDE.
|
||||
*/
|
||||
class LudicLexerTest {
|
||||
|
||||
private fun lex(text: String): List<Pair<IElementType, String>> {
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(text, 0, text.length, 0)
|
||||
val out = mutableListOf<Pair<IElementType, String>>()
|
||||
while (true) {
|
||||
val t = lexer.tokenType ?: break
|
||||
if (t != TokenType.WHITE_SPACE) {
|
||||
out += t to text.substring(lexer.tokenStart, lexer.tokenEnd)
|
||||
}
|
||||
lexer.advance()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun typesOf(text: String) = lex(text).map { it.first }
|
||||
|
||||
@Test
|
||||
fun `declaration keywords are distinct from statement keywords`() {
|
||||
assertEquals(
|
||||
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.BRACE),
|
||||
typesOf("game Hello {")
|
||||
)
|
||||
assertEquals(LudicTokens.KEYWORD, typesOf("if x { }").first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clause keywords are their own class`() {
|
||||
// `phase` and `query` colour differently from `if`/`let`, which is the
|
||||
// whole reason a system signature reads as a signature.
|
||||
assertEquals(
|
||||
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.CLAUSE, LudicTokens.PHASE),
|
||||
typesOf("system Move phase FixedUpdate")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `primitive types and phases are not plain identifiers`() {
|
||||
assertEquals(LudicTokens.PRIMITIVE, typesOf("int").single())
|
||||
assertEquals(LudicTokens.PRIMITIVE, typesOf("fixed").single())
|
||||
assertEquals(LudicTokens.PHASE, typesOf("LateUpdate").single())
|
||||
assertEquals(LudicTokens.BOOLEAN, typesOf("true").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `builtins are recognised, unknown calls are not`() {
|
||||
assertEquals(LudicTokens.BUILTIN, typesOf("rng_chance").single())
|
||||
assertEquals(LudicTokens.BUILTIN, typesOf("peek32").single()) // intrinsic
|
||||
assertEquals(LudicTokens.IDENTIFIER, typesOf("my_helper").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capitalised words read as type names`() {
|
||||
assertEquals(LudicTokens.TYPE_NAME, typesOf("Pos").single())
|
||||
assertEquals(LudicTokens.IDENTIFIER, typesOf("pos").single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `comments run to end of line and keep their text`() {
|
||||
val toks = lex("let a = 1 # keep me\nlet b = 2")
|
||||
val comment = toks.first { it.first == LudicTokens.COMMENT }
|
||||
assertEquals("# keep me", comment.second)
|
||||
// the newline ends it: the next line is code again
|
||||
assertEquals(LudicTokens.KEYWORD, toks[toks.indexOf(comment) + 1].first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strings handle escapes and do not swallow the rest of the file`() {
|
||||
val toks = lex(""""a\"b" 42""")
|
||||
assertEquals(LudicTokens.STRING, toks[0].first)
|
||||
assertEquals(""""a\"b"""", toks[0].second)
|
||||
assertEquals(LudicTokens.NUMBER, toks[1].first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unterminated string stops at the newline`() {
|
||||
// A half-typed string must not colour the whole rest of the file.
|
||||
val toks = lex("let s = \"oops\nlet t = 1")
|
||||
assertEquals(LudicTokens.STRING, toks[3].first)
|
||||
assertEquals("\"oops", toks[3].second)
|
||||
assertEquals(LudicTokens.KEYWORD, toks[4].first)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `numbers cover hex, fixed-point and the range operator`() {
|
||||
assertEquals(LudicTokens.NUMBER, typesOf("0x1affff").single())
|
||||
assertEquals(LudicTokens.NUMBER, typesOf("1.5").single())
|
||||
// `0..10` is two ints and a range op, not a malformed float
|
||||
assertEquals(
|
||||
listOf(LudicTokens.NUMBER, LudicTokens.OPERATOR, LudicTokens.NUMBER),
|
||||
typesOf("0..10")
|
||||
)
|
||||
assertEquals("..", lex("0..10")[1].second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `annotations lex as one token including the sigil`() {
|
||||
val toks = lex("@deterministic")
|
||||
assertEquals(LudicTokens.ANNOTATION, toks.single().first)
|
||||
assertEquals("@deterministic", toks.single().second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two-character operators beat one-character ones`() {
|
||||
assertEquals(listOf("->"), lex("->").map { it.second })
|
||||
assertEquals(listOf("=>"), lex("=>").map { it.second })
|
||||
assertEquals(listOf("!="), lex("!=").map { it.second })
|
||||
assertEquals(listOf("<="), lex("<=").map { it.second })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the lexer always terminates and covers every character`() {
|
||||
// The IDE lexes half-typed text constantly; a lexer that stalls hangs
|
||||
// the editor, and one that skips bytes corrupts every offset after it.
|
||||
val nasty = "game X { component ¿ { a: int = ' } # é\n query [{Tag}] \"unclosed"
|
||||
val lexer = LudicLexer()
|
||||
lexer.start(nasty, 0, nasty.length, 0)
|
||||
var covered = 0
|
||||
var guard = 0
|
||||
while (lexer.tokenType != null) {
|
||||
assertTrue("token must advance", lexer.tokenEnd > lexer.tokenStart)
|
||||
assertEquals("no gaps between tokens", covered, lexer.tokenStart)
|
||||
covered = lexer.tokenEnd
|
||||
lexer.advance()
|
||||
check(guard++ < 10_000) { "lexer did not terminate" }
|
||||
}
|
||||
assertEquals("every character consumed", nasty.length, covered)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a real source file lexes with no bad characters`() {
|
||||
val src = """
|
||||
game Hello {
|
||||
component Pos { x: int = 0, y: int = 0 }
|
||||
archetype Mob { Pos }
|
||||
|
||||
system Move phase FixedUpdate
|
||||
query (p) [Pos, {Player}] where p.x > 0
|
||||
{
|
||||
p.x = p.x + 1
|
||||
for i in 0 .. 10 { print_int(i) }
|
||||
}
|
||||
|
||||
ui Menu {
|
||||
panel id=Root w=288 pad=16 {
|
||||
button id=Go text="Go" size=16
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val bad = lex(src).filter { it.first == LudicTokens.BAD }
|
||||
assertTrue("unexpected bad characters: ${bad.map { it.second }}", bad.isEmpty())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package io.ludic.ide
|
||||
|
||||
import com.intellij.lang.LanguageParserDefinitions
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
|
||||
/**
|
||||
* The IDE-side wiring, exercised in a real (headless) platform fixture. These
|
||||
* cover the things that make a file feel like a supported language rather than
|
||||
* plain text: the extension is claimed, a PSI tree is built (which is what
|
||||
* indexing and Markdown injection both stand on), folds appear, braces pair up
|
||||
* and Ctrl+/ comments.
|
||||
*/
|
||||
class LudicPlatformTest : BasePlatformTestCase() {
|
||||
|
||||
private val sample = """
|
||||
game Hello {
|
||||
component Pos { x: int = 0, y: int = 0 }
|
||||
archetype Mob { Pos }
|
||||
|
||||
# move everything that has a position
|
||||
system Move phase FixedUpdate
|
||||
query (p) [Pos, {Player}]
|
||||
{
|
||||
p.x = p.x + 1
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun ludicFile(text: String = sample): PsiFile =
|
||||
myFixture.configureByText("sample.ludic", text)
|
||||
|
||||
fun testExtensionIsClaimedByTheLudicFileType() {
|
||||
val type = FileTypeManager.getInstance().getFileTypeByExtension("ludic")
|
||||
assertEquals("Ludic", type.name)
|
||||
assertEquals(LudicFileType, type)
|
||||
}
|
||||
|
||||
fun testFileTypeIsNotBinaryAndUsesUtf8() {
|
||||
assertFalse(LudicFileType.isBinary)
|
||||
assertEquals("ludic", LudicFileType.defaultExtension)
|
||||
}
|
||||
|
||||
fun testParserDefinitionIsRegisteredForTheLanguage() {
|
||||
// Markdown fence injection needs a PSI tree, so this registration is
|
||||
// what makes ```ludic blocks highlight in a .md file.
|
||||
val def = LanguageParserDefinitions.INSTANCE.forLanguage(LudicLanguage)
|
||||
assertNotNull("no ParserDefinition registered for Ludic", def)
|
||||
}
|
||||
|
||||
fun testPsiTreeIsBuiltAndCoversTheWholeFile() {
|
||||
val file = ludicFile()
|
||||
assertEquals(LudicLanguage, file.language)
|
||||
assertEquals(sample, file.text)
|
||||
assertTrue("expected a non-empty PSI tree", file.children.isNotEmpty())
|
||||
// every character must be reachable through the tree, or offsets drift
|
||||
assertEquals(sample.length, file.textLength)
|
||||
}
|
||||
|
||||
fun testHighlighterAssignsColoursToEveryMeaningfulToken() {
|
||||
val highlighter = LudicSyntaxHighlighter()
|
||||
val coloured = listOf(
|
||||
LudicTokens.DECL_KEYWORD, LudicTokens.KEYWORD, LudicTokens.CLAUSE,
|
||||
LudicTokens.PRIMITIVE, LudicTokens.PHASE, LudicTokens.BOOLEAN,
|
||||
LudicTokens.BUILTIN, LudicTokens.TYPE_NAME, LudicTokens.STRING,
|
||||
LudicTokens.NUMBER, LudicTokens.COMMENT, LudicTokens.ANNOTATION,
|
||||
LudicTokens.OPERATOR, LudicTokens.BRACE, LudicTokens.BRACKET,
|
||||
LudicTokens.PAREN, LudicTokens.COMMA, LudicTokens.IDENTIFIER
|
||||
)
|
||||
for (token in coloured) {
|
||||
val keys = highlighter.getTokenHighlights(token)
|
||||
assertTrue("no colour mapped for $token", keys.isNotEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
fun testFoldingCollapsesMultiLineBlocksOnly() {
|
||||
val file = ludicFile()
|
||||
val regions = LudicFoldingBuilder().buildFoldRegions(file.node, file.viewProvider.document!!)
|
||||
|
||||
// The sample has exactly two blocks that span lines: the `game` body and
|
||||
// the `system` body. `component Pos { … }`, `archetype Mob { … }` and the
|
||||
// `{Player}` query filter all fit on one line, and folding a single line
|
||||
// gains nothing, so they are deliberately skipped.
|
||||
assertEquals("multi-line blocks only", 2, regions.size)
|
||||
|
||||
val outermost = regions.maxByOrNull { it.range.length }!!
|
||||
assertEquals("outermost fold starts at the game block's brace",
|
||||
sample.indexOf('{'), outermost.range.startOffset)
|
||||
assertEquals("outermost fold ends at the last brace",
|
||||
sample.length, outermost.range.endOffset)
|
||||
|
||||
for (r in regions) {
|
||||
assertTrue("fold region must be non-empty", r.range.length > 0)
|
||||
assertEquals("{...}", LudicFoldingBuilder().getPlaceholderText(file.node))
|
||||
}
|
||||
}
|
||||
|
||||
fun testCommenterUsesHashWithASpace() {
|
||||
// The trailing space is deliberate: Ctrl+/ should produce "# code",
|
||||
// which is how every comment in the runtime and examples is written.
|
||||
val commenter = LudicCommenter()
|
||||
assertEquals("# ", commenter.lineCommentPrefix)
|
||||
assertNull("Ludic has no block comments", commenter.blockCommentPrefix)
|
||||
}
|
||||
|
||||
fun testBraceMatcherPairsAllThreeBracketKinds() {
|
||||
val matcher = LudicBraceMatcher()
|
||||
val pairs = matcher.pairs
|
||||
val open = pairs.map { it.leftBraceType }.toSet()
|
||||
assertTrue(open.contains(LudicTokens.BRACE))
|
||||
assertTrue(open.contains(LudicTokens.BRACKET))
|
||||
assertTrue(open.contains(LudicTokens.PAREN))
|
||||
}
|
||||
|
||||
fun testHalfTypedFileStillProducesAPsiTree() {
|
||||
// The IDE must survive what a file looks like mid-keystroke; a parser
|
||||
// that throws here takes highlighting and indexing down with it.
|
||||
val broken = "game X {\n component P { x: int =\n system"
|
||||
val file = myFixture.configureByText("broken.ludic", broken)
|
||||
assertEquals(broken.length, file.textLength)
|
||||
assertEquals(LudicLanguage, file.language)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue