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
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue