fix(tools): formatter bracket rules, LSP compiler diagnostics, vocabulary sync

- ludic-fmt: `rows[i]`, `new []int`, `s[a..b]`, `emit(`, `~x`, list-literal
  and query-tag braces stay tight; member calls hug their paren
  (`Date.new(`, `Prefab.spawn(`); `<< >> & | ^ ~` are operators and
  `&& ||` are not — mirrored in ludic_syntax.h, LudicLexer.kt and the
  TextMate grammar. 149 of 274 tracked sources failed --check before.
- ludic-lsp: `initializationOptions.compilerDiagnostics` / `compilerPath`
  are honoured — on save the document's compilation unit is compiled and
  `file:line: error: msg` is published as a "ludicc" diagnostic (the
  option had been documented but never implemented); the workspace scan
  file is per process; bracket codes spelled as char literals
- Overlay added to LUDIC_PHASES, LudicTokens.kt, ludic-mode.el and the
  grammar (the game uses it; check-vocabulary flagged the drift)
- editors: VS Code snippets/package.json/extension.js (`${workspaceFolder}`
  and `~` expansion, PATH lookup, Apache-2.0, real repo URL), Neovim root
  markers, Helix/Zed/Sublime bin/ paths, Emacs vocabulary, JetBrains
  comments; tools/editors/README.md no longer describes C tooling
- .forgejo workflows clone `${{ github.server_url }}/${{ github.repository }}`
  so a fork or mirror tests itself; the docs publish pushes the same way

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-05 01:12:26 +03:00
parent 8fed9add66
commit e3e1bc7784
26 changed files with 746 additions and 586 deletions

View file

@ -25,10 +25,13 @@ jobs:
clang-16 --version | head -1
- name: Check out the triggering commit
env:
# the repository that triggered the run, so a fork or a mirror tests itself
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: |
set -eu
git config --global --add safe.directory '*'
git clone https://git.workshopsoft.io/workshopsoft/ludic.git .
git clone "$REPO_URL" .
git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}"
git log --oneline -1
# See ci.yml for why the Linux build injects the stdio shim via LUDIC_CC.

View file

@ -33,10 +33,13 @@ jobs:
clang-16 --version | head -1
- name: Check out the triggering commit
env:
# the repository that triggered the run, so a fork or a mirror tests itself
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: |
set -eu
git config --global --add safe.directory '*'
git clone https://git.workshopsoft.io/workshopsoft/ludic.git .
git clone "$REPO_URL" .
git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}"
git log --oneline -1
# The toolchain is macOS-first; on this Linux runner it links against a

View file

@ -17,13 +17,12 @@ jobs:
steps:
- name: Check out with history
env:
BEFORE: ${{ github.event.before }}
BASE: ${{ github.base_ref }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: |
set -eu
git config --global --add safe.directory '*'
# Full clone so both endpoints of the range are present.
git clone https://git.workshopsoft.io/workshopsoft/ludic.git .
git clone "$REPO_URL" .
git checkout "${GITHUB_SHA}" 2>/dev/null || git checkout "${GITHUB_REF_NAME:-main}"
- name: Lint the new commits

View file

@ -41,11 +41,11 @@ jobs:
- name: Generate the documentation site
env:
SOURCE_REF: ${{ github.ref_name }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}.git
run: |
set -eu
git config --global --add safe.directory '*'
git clone --depth 1 --branch "${SOURCE_REF:-main}" \
https://git.workshopsoft.io/workshopsoft/ludic.git src
git clone --depth 1 --branch "${SOURCE_REF:-main}" "$REPO_URL" src
cd src
# The toolchain is macOS-first; on this Linux runner it links against a
# tiny C-free IR shim supplying the Darwin stdout/stderr globals over
@ -67,6 +67,8 @@ jobs:
PAGES_TOKEN: ${{ secrets.PAGES_TOKEN }}
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SOURCE_SHA: ${{ github.sha }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
run: |
set -eu
TOKEN="${PAGES_TOKEN:-${AUTO_TOKEN:-}}"
@ -80,5 +82,6 @@ jobs:
git config user.email "docs@workshopsoft.io"
git add -A
git commit -q -m "docs: regenerate site from ${SOURCE_SHA}"
git push -f "https://ludic-docs-bot:${TOKEN}@git.workshopsoft.io/workshopsoft/ludic.git" pages
# the same server and repository the run came from, with the token spliced in
git push -f "${SERVER_URL%%://*}://ludic-docs-bot:${TOKEN}@${SERVER_URL#*://}/${REPO}.git" pages
echo "published $(git rev-parse --short HEAD) to pages"

View file

@ -1,20 +1,18 @@
# Ludic in your editor
Everything here is built on one idea: **write the language knowledge once, in C,
and let every editor talk to it.** There is one lexer, one vocabulary, one
Everything here is built on one idea: **write the language knowledge once, in
Ludic, and let every editor talk to it.** There is one vocabulary, one
formatter and one language server. VS Code and JetBrains get first-class plugins
because those are the two that were asked for; every other editor gets the same
capabilities by pointing at the same binary.
```
tools/ludic-tools/ the actual language knowledge, in C, no dependencies
ludic_syntax.h lexer + the vocabulary — the source of truth
ludic_fmt.h comment-preserving formatter
ludic_index.h error-tolerant reader: declarations, bindings, scopes
ludic_workspace.h multi-file model: imports, compilation units, Markdown
ludic_json.h just enough JSON for LSP
ludic_fmt_main.c -> bin/ludic-fmt
ludic_lsp.c -> bin/ludic-lsp
tools/ludic-tools/ the actual language knowledge, in Ludic
ludic_syntax.h the vocabulary tables (keywords, operators, builtins)
that the formatter, the server and the vocabulary
check all read — the source of truth
fmt.ludic -> bin/ludic-fmt token-based, comment-preserving formatter
lsp.ludic -> bin/ludic-lsp the language server (LSP 3.17)
tools/editors/
shared/ TextMate grammar + Markdown injection + language config

View file

@ -10,19 +10,23 @@
;;; Code:
;; The vocabulary mirrors tools/ludic-tools/ludic_syntax.h; that header is the reference.
(defconst ludic--declaration-keywords
'("program" "import" "property" "struct" "model" "enum" "ui"
"const" "var" "fn" "extern" "handler" "entry"))
'("program" "import" "property" "model" "enum" "ui" "namespace"
"const" "var" "function" "extern" "handler" "entry" "event" "scene" "test" "prefab"))
(defconst ludic--clause-keywords
'("phase" "query" "reads" "writes"))
'("phase" "query" "on" "cancellable" "public" "layer" "start" "shows" "lasts" "loads" "then" "export" "internal"))
(defconst ludic--statement-keywords
'("let" "return" "if" "else" "when" "while" "for" "in" "spawn" "despawn"
"match" "machine" "state" "become" "enter" "where" "and" "or"))
'("let" "return" "if" "else" "while" "for" "in" "spawn" "despawn"
"enable" "disable" "attach" "detach" "match" "machine" "state" "become" "where"
"and" "or" "not" "break" "continue" "new" "emit" "cancel" "try"))
(defconst ludic--types '("int" "fixed" "bool" "entity" "str" "ptr" "void"))
(defconst ludic--phases '("Start" "Input" "FixedUpdate" "Update" "LateUpdate" "Render"))
(defconst ludic--types
'("int" "long" "fixed" "countdown" "bool" "entity" "string" "pointer" "byte"
"words" "fixeds" "pointers" "Vector" "IVec2" "Rect" "void"))
(defconst ludic--phases '("Start" "Input" "FixedUpdate" "Update" "LateUpdate" "Render" "Overlay"))
(defconst ludic-font-lock-keywords
(list
@ -31,7 +35,7 @@
(cons (regexp-opt ludic--statement-keywords 'symbols) 'font-lock-keyword-face)
(cons (regexp-opt ludic--types 'symbols) 'font-lock-type-face)
(cons (regexp-opt ludic--phases 'symbols) 'font-lock-builtin-face)
(cons "\\_<\\(true\\|false\\)\\_>" 'font-lock-constant-face)
(cons "\\_<\\(true\\|false\\|null\\)\\_>" 'font-lock-constant-face)
(cons "@[A-Za-z_][A-Za-z0-9_]*" 'font-lock-preprocessor-face)
;; components, archetypes, scenes and UI handles are Capitalised by convention
(cons "\\_<[A-Z][A-Za-z0-9_]*\\_>" 'font-lock-type-face)

View file

@ -10,7 +10,7 @@ comment-token = "#"
indent = { tab-width = 2, unit = " " }
language-servers = ["ludic-lsp"]
auto-format = true
roots = ["build.sh", "LANGUAGE.md", ".git"]
roots = ["package.ludic", "LANGUAGE.md", ".git"]
[language-server.ludic-lsp]
command = "ludic-lsp"

View file

@ -35,7 +35,7 @@ class LudicBraceMatcher : PairedBraceMatcher {
private companion object {
// Structural braces only: a record literal shares the token type, so
// marking them all structural would make the "matched brace" gutter
// noisy inside `spawn Hero { Pos = { x = 1 } }`.
// noisy inside `spawn Hero { Pos { x: 1 } }`.
val PAIRS = arrayOf(
BracePair(LudicTokens.BRACE, LudicTokens.BRACE, true),
BracePair(LudicTokens.BRACKET, LudicTokens.BRACKET, false),

View file

@ -7,7 +7,7 @@ import com.intellij.psi.tree.IElementType
/**
* A hand-written lexer matching tools/ludic-tools/ludic_syntax.h token for
* token. Hand-written rather than generated because the grammar is small, the
* rules are the ones the C lexer already implements, and keeping the two
* 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.
*
* It must never throw and never fail to advance: the IDE lexes half-typed text
@ -142,7 +142,7 @@ class LudicLexer : LexerBase() {
}
private companion object {
val TWO_CHAR_OPS = setOf("->", "+=", "-=", "*=", "/=", "==", "!=", "<=", ">=", "&&", "||", "..", "=>")
const val SINGLE_CHAR_OPS = "+-*/%<>=:.!@;"
val TWO_CHAR_OPS = setOf("->", "+=", "-=", "*=", "/=", "==", "!=", "<=", ">=", "<<", ">>", "..", "=>")
const val SINGLE_CHAR_OPS = "+-*/%<>=:.!@;&|^~"
}
}

View file

@ -17,7 +17,7 @@ import java.io.File
/**
* Where to find the toolchain. Both paths may be left blank, in which case the
* plugin looks under the project root for what tools/build-tools.sh produces —
* plugin looks under the project root for what `bin/x tools` produces —
* which is the case for anyone working in the Ludic repository itself.
*/
@Service(Service.Level.PROJECT)
@ -110,7 +110,7 @@ class LudicConnectionProvider(private val project: Project) : ProcessStreamConne
}
append(" and on PATH.\n")
}
append("\nBuild it with ./tools/build-tools.sh from the repository root, ")
append("\nBuild it with `bin/x tools` from the repository root, ")
append("or set the path in Settings | Languages & Frameworks | Ludic. ")
append("Syntax highlighting works without it; completion and diagnostics do not.")
}

View file

@ -20,8 +20,8 @@ object LudicTokens {
val IDENTIFIER = LudicTokenType("IDENTIFIER")
val TYPE_NAME = LudicTokenType("TYPE_NAME") // Capitalised: component, archetype, scene
val KEYWORD = LudicTokenType("KEYWORD")
val DECL_KEYWORD = LudicTokenType("DECL_KEYWORD") // game, component, system, function, ...
val CLAUSE = LudicTokenType("CLAUSE") // phase, query, reads, writes, ...
val DECL_KEYWORD = LudicTokenType("DECL_KEYWORD") // program, property, model, handler, function, ...
val CLAUSE = LudicTokenType("CLAUSE") // phase, query, on, layer, ...
val PRIMITIVE = LudicTokenType("PRIMITIVE") // int, fixed, bool, entity, string, pointer, void
val PHASE = LudicTokenType("PHASE") // Start, Update, Render, ...
val BOOLEAN = LudicTokenType("BOOLEAN")
@ -53,7 +53,7 @@ object LudicVocabulary {
"and", "or", "not", "break", "continue", "new", "emit", "cancel", "try"
)
val PRIMITIVES = setOf("int", "long", "fixed", "countdown", "bool", "entity", "string", "pointer", "byte", "words", "fixeds", "pointers", "Vector", "IVec2", "Rect", "void")
val PHASES = setOf("Start", "Input", "FixedUpdate", "Update", "LateUpdate", "Render")
val PHASES = setOf("Start", "Input", "FixedUpdate", "Update", "LateUpdate", "Render", "Overlay")
val WIDGETS = setOf("panel", "col", "row", "label", "button", "image", "spacer")
val BUILTINS = setOf(

View file

@ -67,7 +67,7 @@
name="Ludic"
factoryClass="io.ludic.ide.LudicLanguageServerFactory">
<description><![CDATA[
The Ludic language server. Built by <code>tools/build-tools.sh</code>.
The Ludic language server. Built by <code>bin/x tools</code>.
]]></description>
</server>
<languageMapping language="Ludic" serverId="ludic"/>

View file

@ -3,7 +3,7 @@
-- :lua require('ludic').setup()
-- -- or, from init.lua:
-- vim.opt.runtimepath:append('/path/to/gpp/tools/editors/neovim')
-- require('ludic').setup({ server = '/path/to/gpp/build/ludic-lsp' })
-- require('ludic').setup({ server = '/path/to/gpp/bin/ludic-lsp' })
--
-- Highlighting here comes from the language server's semantic tokens, which
-- Neovim applies natively -- there is no tree-sitter grammar to install and no
@ -43,7 +43,7 @@ function M.setup(opts)
end,
})
local root_markers = { 'build.sh', 'LANGUAGE.md', '.git' }
local root_markers = { 'package.ludic', 'LANGUAGE.md', '.git' }
local function root_dir(fname)
local found = vim.fs.find(root_markers, { path = vim.fs.dirname(fname), upward = true })[1]
@ -57,10 +57,10 @@ function M.setup(opts)
callback = function(args)
local root = root_dir(vim.api.nvim_buf_get_name(args.buf))
local server = opts.server or find_executable(
{ 'ludic-lsp' }, { root }, { 'build', 'tools/build', 'bin', '.' })
{ 'ludic-lsp' }, { root }, { 'bin', 'build', '.' })
if not server then return end
local compiler = opts.compiler or find_executable(
{ 'ludicc' }, { root }, { 'build', 'bin', '.' }) or ''
{ 'ludicc' }, { root }, { 'bin', 'build', '.' }) or ''
vim.lsp.start({
name = 'ludic-lsp',

View file

@ -12,7 +12,7 @@
],
"surroundingPairs": [["{", "}"], ["[", "]"], ["(", ")"], ["\"", "\""]],
"indentationRules": {
"increaseIndentPattern": "^((?!//).)*(\\{[^}\"']*|\\([^)\"']*|\\[[^\\]\"']*)$",
"increaseIndentPattern": "^((?!#).)*(\\{[^}\"']*|\\([^)\"']*|\\[[^\\]\"']*)$",
"decreaseIndentPattern": "^\\s*[}\\])].*$"
},
"onEnterRules": [

View file

@ -133,12 +133,20 @@
}
},
{
"match": "\\b(enter)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"match": "\\b(become)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.enter.ludic" },
"1": { "name": "keyword.control.become.ludic" },
"2": { "name": "entity.name.type.scene.ludic" }
}
},
{
"match": "\\b(scene|prefab|event)\\s+(cancellable\\s+)?([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.ludic" },
"2": { "name": "keyword.other.clause.ludic" },
"3": { "name": "entity.name.type.ludic" }
}
},
{
"match": "\\b(spawn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
@ -172,29 +180,29 @@
},
"keyword": {
"patterns": [
{ "name": "keyword.control.ludic", "match": "\\b(if|else|while|for|in|match|machine|prefab|become|return|spawn|despawn|enable|disable|where|break|continue|new|try)\\b" },
{ "name": "keyword.control.ludic", "match": "\\b(if|else|while|for|in|match|machine|become|return|spawn|despawn|enable|disable|attach|detach|emit|cancel|where|break|continue|new|try)\\b" },
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on|cancellable|public|layer|start|shows|lasts|loads|then|export|internal)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(program|property|model|namespace|enum|ui|const|var|let|function|handler|entry|state|test)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(program|property|model|prefab|namespace|enum|ui|const|var|let|function|handler|entry|event|scene|state|test)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|long|fixed|countdown|bool|entity|string|pointer|byte|words|fixeds|pointers|Vector|IVec2|Rect|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false|null)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render|Overlay)\\b" }
]
},
"widget": {
"patterns": [
{
"comment": "widget types, recognised by the props or the child block that follow them",
"comment": "widget types, recognised by the `key: value` props or the child block that follow them",
"name": "support.class.widget.ludic",
"match": "\\b(panel|col|row|label|button|image|spacer)\\b(?=\\s*(\\{|[a-z][A-Za-z0-9_]*\\s*=|$))"
"match": "\\b(panel|col|row|label|button|image|spacer)\\b(?=\\s*(\\{|[a-z][A-Za-z0-9_]*\\s*:|$))"
},
{
"comment": "widget props are written tight (id=Root); an assignment is written x = 1",
"match": "\\b([a-z][A-Za-z0-9_]*)(=)(?!=)",
"comment": "a widget prop or a named argument: `id: Root`, `x: 10`",
"match": "\\b([a-z][A-Za-z0-9_]*)(:)(?=\\s)",
"captures": {
"1": { "name": "variable.parameter.widget.ludic" },
"2": { "name": "keyword.operator.assignment.ludic" }
"1": { "name": "variable.parameter.ludic" },
"2": { "name": "punctuation.separator.ludic" }
}
}
]

View file

@ -20,10 +20,10 @@ Sublime reads TextMate grammars directly, so the shared grammar works as-is.
"clients": {
"ludic": {
"enabled": true,
"command": ["/path/to/gpp/build/ludic-lsp", "--stdio"],
"command": ["/path/to/gpp/bin/ludic-lsp", "--stdio"],
"selector": "source.ludic | text.html.markdown",
"initializationOptions": {
"compilerPath": "/path/to/gpp/build/ludicc",
"compilerPath": "/path/to/gpp/bin/ludicc",
"compilerDiagnostics": true,
"indentSize": 2
}

View file

@ -37,9 +37,9 @@ Format on save:
## Settings
- `ludic.server.path` — where `ludic-lsp` lives. Empty = search `bin/`, then `PATH`.
- `ludic.server.path` — where `ludic-lsp` lives (`~` and `${workspaceFolder}` expand). Empty = search `bin/`, then `PATH`.
- `ludic.server.enabled` — turn the server off and keep only static highlighting.
- `ludic.compilerPath` — where `ludicc` lives, for on-save diagnostics.
- `ludic.compilerPath` — where `ludicc` lives, for on-save diagnostics (`~` and `${workspaceFolder}` expand).
- `ludic.compilerDiagnostics` — whether to run the compiler at all.
- `ludic.indentSize` — spaces per level for the formatter.
- `ludic.trace.server` — log LSP traffic (`Ludic: Show Language Server Log`).

View file

@ -12,7 +12,7 @@
],
"surroundingPairs": [["{", "}"], ["[", "]"], ["(", ")"], ["\"", "\""]],
"indentationRules": {
"increaseIndentPattern": "^((?!//).)*(\\{[^}\"']*|\\([^)\"']*|\\[[^\\]\"']*)$",
"increaseIndentPattern": "^((?!#).)*(\\{[^}\"']*|\\([^)\"']*|\\[[^\\]\"']*)$",
"decreaseIndentPattern": "^\\s*[}\\])].*$"
},
"onEnterRules": [

View file

@ -4,7 +4,7 @@
"description": "Ludic language support: syntax highlighting, formatting, and the ludic-lsp language server. Also highlights ```ludic fences in Markdown.",
"version": "1.0.0",
"publisher": "ludic",
"license": "MIT",
"license": "Apache-2.0",
"engines": {
"vscode": "^1.75.0"
},
@ -90,7 +90,7 @@
"ludic.server.path": {
"type": "string",
"default": "",
"markdownDescription": "Path to the `ludic-lsp` binary. Empty means: look in `build/ludic-lsp` under each workspace folder, then on `PATH`."
"markdownDescription": "Path to the `ludic-lsp` binary; `~` and `${workspaceFolder}` are expanded. Empty means: look in `bin/ludic-lsp` under each workspace folder, then on `PATH`."
},
"ludic.server.enabled": {
"type": "boolean",
@ -100,7 +100,7 @@
"ludic.compilerPath": {
"type": "string",
"default": "",
"markdownDescription": "Path to `ludicc`. The server shells out to it for authoritative diagnostics on save. Empty means `build/ludicc` under the workspace."
"markdownDescription": "Path to `ludicc`; `~` and `${workspaceFolder}` are expanded. The server runs it on save for the compiler's own diagnostics. Empty means `bin/ludicc` under the workspace, then `PATH`."
},
"ludic.compilerDiagnostics": {
"type": "boolean",
@ -153,6 +153,6 @@
},
"repository": {
"type": "git",
"url": "https://example.invalid/ludic"
"url": "https://git.workshopsoft.io/workshopsoft/ludic"
}
}

View file

@ -1,53 +1,52 @@
{
"game": {
"prefix": "game",
"body": ["game ${1:Name} {", "\t$0", "}"],
"description": "A game unit — compiles to an executable."
"program": {
"prefix": "program",
"body": ["program ${1:Name} {", "\t$0", "}"],
"description": "A program: one block of declarations that compiles to an executable."
},
"module": {
"prefix": "module",
"body": ["module ${1:Name} {", "\t$0", "}"],
"description": "A library unit — compiles to a shared library, no frame loop."
"property": {
"prefix": "property",
"body": ["property ${1:Name} { ${2:x}: ${3:int} = ${4:0} }"],
"description": "A property: typed fields with defaults — a per-entity component, or a record made with `new`."
},
"component": {
"prefix": "component",
"body": ["component ${1:Name} { ${2:x}: ${3:int} = ${4:0} }"],
"description": "A component: typed per-entity fields."
"model": {
"prefix": "model",
"body": ["model ${1:Name} { ${2:Position} }"],
"description": "An entity kind bundling a fixed set of properties."
},
"archetype": {
"prefix": "archetype",
"body": ["archetype ${1:Name} {", "\t${2:Pos}", "}"],
"description": "An entity kind bundling a fixed set of components."
"prefab": {
"prefix": "prefab",
"body": ["prefab ${1:Name}: ${2:Model} { ${3:Stats} { ${4:hp}: ${5:30} } }"],
"description": "A model with preset property fields; `spawn` adds only what differs."
},
"system": {
"prefix": "system",
"handler": {
"prefix": "handler",
"body": [
"system ${1:Name} phase ${2|Start,Input,FixedUpdate,Update,LateUpdate,Render|} {",
"handler ${1:Name} phase ${2|Start,Input,FixedUpdate,Update,LateUpdate,Render,Overlay|} {",
"\t$0",
"}"
],
"description": "A system that runs once per tick in its phase."
"description": "A handler that runs once per tick in its phase."
},
"system with query": {
"prefix": "systemq",
"handler with query": {
"prefix": "handlerq",
"body": [
"system ${1:Name} phase ${2|Update,FixedUpdate,LateUpdate,Render,Input|}",
" query (${3:p}) [${4:Pos}]",
"{",
"@Queries(these: [${1:Position}])",
"handler ${2:Name} phase ${3|Update,FixedUpdate,LateUpdate,Render,Input|} {",
"\t$0",
"}"
],
"description": "A system whose body runs once per matching entity."
"description": "A handler whose body runs once per matching entity, each property bound by name."
},
"for query": {
"prefix": "forq",
"body": ["for (${1:p}) in query [${2:Pos}] {", "\t$0", "}"],
"body": ["for (${1:p}) in query [${2:Position}] {", "\t$0", "}"],
"description": "Iterate every entity matching a query."
},
"spawn": {
"prefix": "spawn",
"body": ["spawn ${1:Kind} {", "\t${2:Pos} = { ${3:x} = ${4:0} }", "}"],
"description": "Create an entity of an archetype."
"body": ["spawn ${1:Kind} { ${2:Position} { ${3:x}: ${4:0} } }"],
"description": "Create an entity of a model or prefab."
},
"scene": {
"prefix": "scene",
@ -59,18 +58,18 @@
"\t}",
"}"
],
"description": "A scene with lifecycle hooks and one layer of systems."
"description": "A scene with lifecycle hooks and one layer of handlers."
},
"ui": {
"prefix": "ui",
"body": [
"ui ${1:Name} {",
"\tpanel id=${2:Root} pad=16 gap=6 align=center {",
"\tpanel id: ${2:Root} pad: 16 gap: 6 align: center {",
"\t\t$0",
"\t}",
"}"
],
"description": "A retained UI tree."
"description": "A retained UI tree, declared as data."
},
"match": {
"prefix": "match",
@ -80,17 +79,32 @@
"machine": {
"prefix": "machine",
"body": [
"machine reg(${1:R_PHASE}) {",
"\tstate ${2:Idle} = 0 {",
"machine ${1:state} {",
"\tstate ${2:Idle} {",
"\t\t$0",
"\t}",
"}"
],
"description": "A state machine over a register."
"description": "A state machine over an enum-typed variable; `become` transitions."
},
"extern fn": {
"event": {
"prefix": "event",
"body": ["event ${1:Name} { ${2:amount}: int = 0 }"],
"description": "An event payload; fire it with `emit`, listen with `@On`."
},
"listener": {
"prefix": "on",
"body": ["@On(${1:Event}) handler ${2:Name} { $0 }"],
"description": "A listener on an event, its payload fields bound by name."
},
"namespace": {
"prefix": "namespace",
"body": ["namespace ${1:Name} {", "\texport function ${2:method}() -> ${3:void} { $0 }", "}"],
"description": "A block of functions callable as Name.method(); `internal` keeps a helper private."
},
"extern function": {
"prefix": "extern",
"body": ["extern fn ${1:name}(${2:a}: ${3:int}) -> ${4:int} = \"${5:c_symbol}\""],
"body": ["extern function ${1:name}(${2:a}: ${3:int}) -> ${4:int} = \"${5:c_symbol}\""],
"description": "Bind a C symbol through the FFI."
}
}

View file

@ -19,7 +19,7 @@ let output = null;
/** Where the language server might be, in the order we should look. */
function resolveServer(folders) {
const configured = vscode.workspace.getConfiguration('ludic').get('server.path');
if (configured) return expand(configured);
if (configured) return expand(configured, folders);
const names = ['ludic-lsp', 'ludic-lsp.exe'];
for (const folder of folders) {
@ -43,18 +43,26 @@ function resolveServer(folders) {
function resolveCompiler(folders) {
const configured = vscode.workspace.getConfiguration('ludic').get('compilerPath');
if (configured) return expand(configured);
if (configured) return expand(configured, folders);
for (const folder of folders) {
for (const rel of ['bin', 'build']) {
const p = path.join(folder.uri.fsPath, rel, 'ludicc');
if (isExecutable(p)) return p;
}
}
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
const p = path.join(dir, 'ludicc');
if (isExecutable(p)) return p;
}
return '';
}
function expand(p) {
return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
/** A configured path may use `~` and `${workspaceFolder}`, so a project can point
* at a sibling checkout without hard-coding one machine's home directory. */
function expand(p, folders) {
const root = folders && folders.length ? folders[0].uri.fsPath : '';
const withRoot = p.replace(/\$\{workspaceFolder\}/g, root);
return withRoot.startsWith('~') ? path.join(os.homedir(), withRoot.slice(1)) : withRoot;
}
function isExecutable(p) {

View file

@ -133,12 +133,20 @@
}
},
{
"match": "\\b(enter)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"match": "\\b(become)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.enter.ludic" },
"1": { "name": "keyword.control.become.ludic" },
"2": { "name": "entity.name.type.scene.ludic" }
}
},
{
"match": "\\b(scene|prefab|event)\\s+(cancellable\\s+)?([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.ludic" },
"2": { "name": "keyword.other.clause.ludic" },
"3": { "name": "entity.name.type.ludic" }
}
},
{
"match": "\\b(spawn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
@ -172,29 +180,29 @@
},
"keyword": {
"patterns": [
{ "name": "keyword.control.ludic", "match": "\\b(if|else|while|for|in|match|machine|prefab|become|return|spawn|despawn|enable|disable|where|break|continue|new|try)\\b" },
{ "name": "keyword.control.ludic", "match": "\\b(if|else|while|for|in|match|machine|become|return|spawn|despawn|enable|disable|attach|detach|emit|cancel|where|break|continue|new|try)\\b" },
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on|cancellable|public|layer|start|shows|lasts|loads|then|export|internal)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(program|property|model|namespace|enum|ui|const|var|let|function|handler|entry|state|test)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(program|property|model|prefab|namespace|enum|ui|const|var|let|function|handler|entry|event|scene|state|test)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|long|fixed|countdown|bool|entity|string|pointer|byte|words|fixeds|pointers|Vector|IVec2|Rect|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false|null)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render|Overlay)\\b" }
]
},
"widget": {
"patterns": [
{
"comment": "widget types, recognised by the props or the child block that follow them",
"comment": "widget types, recognised by the `key: value` props or the child block that follow them",
"name": "support.class.widget.ludic",
"match": "\\b(panel|col|row|label|button|image|spacer)\\b(?=\\s*(\\{|[a-z][A-Za-z0-9_]*\\s*=|$))"
"match": "\\b(panel|col|row|label|button|image|spacer)\\b(?=\\s*(\\{|[a-z][A-Za-z0-9_]*\\s*:|$))"
},
{
"comment": "widget props are written tight (id=Root); an assignment is written x = 1",
"match": "\\b([a-z][A-Za-z0-9_]*)(=)(?!=)",
"comment": "a widget prop or a named argument: `id: Root`, `x: 10`",
"match": "\\b([a-z][A-Za-z0-9_]*)(:)(?=\\s)",
"captures": {
"1": { "name": "variable.parameter.widget.ludic" },
"2": { "name": "keyword.operator.assignment.ludic" }
"1": { "name": "variable.parameter.ludic" },
"2": { "name": "punctuation.separator.ludic" }
}
}
]

View file

@ -1,9 +1,9 @@
# Ludic for Zed
Zed extensions want a Tree-sitter grammar for highlighting, which this toolchain
deliberately does not ship — the vocabulary lives in one C header and is exposed
through the language server's semantic tokens instead, so there is no second
grammar to keep honest.
deliberately does not ship — the vocabulary lives in one shared header and is
exposed through the language server's semantic tokens instead, so there is no
second grammar to keep honest.
That is enough for Zed: point it at `ludic-lsp` and highlighting, completion,
diagnostics and formatting all arrive over LSP.
@ -14,9 +14,9 @@ Add to `~/.config/zed/settings.json`:
{
"lsp": {
"ludic-lsp": {
"binary": { "path": "/path/to/gpp/build/ludic-lsp", "arguments": ["--stdio"] },
"binary": { "path": "/path/to/gpp/bin/ludic-lsp", "arguments": ["--stdio"] },
"initialization_options": {
"compilerPath": "/path/to/gpp/build/ludicc",
"compilerPath": "/path/to/gpp/bin/ludicc",
"compilerDiagnostics": true,
"indentSize": 2
}

View file

@ -40,27 +40,27 @@ program LudicFmt {
file_close(f)
return buf
}
function cstr_len(s: pointer) -> int { var n = 0; while s[n] != 0 { n = n + 1 }; return n }
function char_is_digit(c: int) -> bool { return c >= 48 and c <= 57 }
function cstr_len(s: pointer) -> int { var n = 0; while s[n] != 0 { n += 1 }; return n }
function char_is_digit(c: int) -> bool { return c >= '0' and c <= '9' }
function char_is_alpha(c: int) -> bool {
if c >= 65 and c <= 90 { return true }
if c >= 97 and c <= 122 { return true }
return c == 95
if c >= 'A' and c <= 'Z' { return true }
if c >= 'a' and c <= 'z' { return true }
return c == '_'
}
function char_is_alnum(c: int) -> bool { return char_is_alpha(c) or char_is_digit(c) }
function char_is_hex(c: int) -> bool { return char_is_digit(c) or (c >= 97 and c <= 102) or (c >= 65 and c <= 70) }
function char_is_hex(c: int) -> bool { return char_is_digit(c) or (c >= 'a' and c <= 'f') or (c >= 'A' and c <= 'F') }
function itoa(v: int) -> pointer {
if v == 0 { let z = bytes(2); z[0] = 48; z[1] = 0; return z }
if v == 0 { let z = bytes(2); z[0] = '0'; z[1] = 0; return z }
var neg = false; var x = v
if x < 0 { neg = true; x = 0 - x }
if x < 0 { neg = true; x = -x }
let tmp = bytes(16); var n = 0
while x > 0 { tmp[n] = 48 + x % 10; x = x / 10; n = n + 1 }
while x > 0 { tmp[n] = 48 + x % 10; x /= 10; n += 1 }
var total = n
if neg { total = total + 1 }
if neg { total += 1 }
let out = bytes(total + 1); var k = 0
if neg { out[0] = 45; k = 1 }
if neg { out[0] = '-'; k = 1 }
var i = 0
while i < n { out[k + i] = tmp[n - 1 - i]; i = i + 1 }
while i < n { out[k + i] = tmp[n - 1 - i]; i += 1 }
out[total] = 0
return out
}
@ -70,15 +70,15 @@ program LudicFmt {
function buf_new() -> Buf { let b = new Buf; b.cap = 256; b.data = bytes(b.cap); b.len = 0; return b }
function buf_ensure(b: Buf, extra: int) -> void {
if b.len + extra + 1 <= b.cap { return }
while b.len + extra + 1 > b.cap { b.cap = b.cap * 2 }
while b.len + extra + 1 > b.cap { b.cap *= 2 }
b.data = resize(b.data, b.cap)
}
function buf_putc(b: Buf, c: int) -> void { buf_ensure(b, 1); b.data[b.len] = c; b.len = b.len + 1 }
function buf_puts(b: Buf, s: pointer) -> void { var i = 0; while s[i] != 0 { buf_putc(b, s[i]); i = i + 1 } }
function buf_indent(b: Buf, n: int) -> void { var i = 0; while i < n { buf_putc(b, 32); i = i + 1 } }
function buf_putc(b: Buf, c: int) -> void { buf_ensure(b, 1); b.data[b.len] = c; b.len += 1 }
function buf_puts(b: Buf, s: pointer) -> void { var i = 0; while s[i] != 0 { buf_putc(b, s[i]); i += 1 } }
function buf_indent(b: Buf, n: int) -> void { var i = 0; while i < n { buf_putc(b, ' '); i += 1 } }
function buf_str(b: Buf) -> pointer { b.data[b.len] = 0; return b.data }
# append src[a..b) raw
function buf_addrange(b: Buf, s: pointer, a: int, e: int) -> void { var k = a; while k < e { buf_putc(b, s[k]); k = k + 1 } }
function buf_addrange(b: Buf, s: pointer, a: int, e: int) -> void { var k = a; while k < e { buf_putc(b, s[k]); k += 1 } }
# ---- the token stream (parallel slices) ----
var src: pointer = null
@ -117,16 +117,19 @@ program LudicFmt {
# ---- the lexer: keeps comments, newlines, byte spans; never exits on bad input ----
function is_op2(c0: int, c1: int) -> bool {
if c0 == 45 and c1 == 62 { return true } # ->
if c1 == 61 and (c0 == 43 or c0 == 45 or c0 == 42 or c0 == 47 or c0 == 61 or c0 == 33 or c0 == 60 or c0 == 62) { return true } # += -= *= /= == != <= >=
if c0 == 38 and c1 == 38 { return true } # &&
if c0 == 124 and c1 == 124 { return true } # ||
if c0 == 46 and c1 == 46 { return true } # ..
if c0 == 61 and c1 == 62 { return true } # =>
if c0 == '-' and c1 == '>' { return true } # ->
if c1 == '=' and (c0 == '+' or c0 == '-' or c0 == '*' or c0 == '/' or c0 == '=' or c0 == '!' or c0 == '<' or c0 == '>') { return true } # += -= *= /= == != <= >=
if c0 == '<' and c1 == '<' { return true } # <<
if c0 == '>' and c1 == '>' { return true } # >>
if c0 == '.' and c1 == '.' { return true } # ..
if c0 == '=' and c1 == '>' { return true } # =>
return false
}
function is_op1(c: int) -> bool {
return c == 43 or c == 45 or c == 42 or c == 47 or c == 37 or c == 60 or c == 62 or c == 61 or c == 40 or c == 41 or c == 123 or c == 125 or c == 91 or c == 93 or c == 44 or c == 58 or c == 46 or c == 33 or c == 64 or c == 59
if c == '+' or c == '-' or c == '*' or c == '/' or c == '%' or c == '<' or c == '>' or c == '=' { return true } # + - * / % < > =
if c == '(' or c == ')' or c == '{' or c == '}' or c == '[' or c == ']' { return true } # ( ) { } [ ]
if c == ',' or c == ':' or c == '.' or c == '!' or c == '@' or c == ';' { return true } # , : . ! @ ;
return c == '&' or c == '|' or c == '^' or c == '~' # & | ^ ~
}
function lex(s: pointer) -> void {
@ -137,45 +140,45 @@ program LudicFmt {
var i = 0; var line = 0
while s[i] != 0 {
let c = s[i]
if c == 10 { push_tok(LT_NL, i, i + 1, line); i = i + 1; line = line + 1; push(linestart, i); continue }
if c == 32 or c == 9 or c == 13 { i = i + 1; continue }
if c == 35 { # '#' comment to end of line
let st = i; while s[i] != 0 and s[i] != 10 { i = i + 1 }; push_tok(LT_COMMENT, st, i, line); continue
if c == '\n' { push_tok(LT_NL, i, i + 1, line); i += 1; line += 1; push(linestart, i); continue }
if c == ' ' or c == '\t' or c == '\r' { i += 1; continue }
if c == '#' { # '#' comment to end of line
let st = i; while s[i] != 0 and s[i] != '\n' { i += 1 }; push_tok(LT_COMMENT, st, i, line); continue
}
if c == 34 { # "string"
let st = i; i = i + 1
while s[i] != 0 and s[i] != 34 and s[i] != 10 { if s[i] == 92 and s[i + 1] != 0 { i = i + 2 } else { i = i + 1 } }
if s[i] == 34 { i = i + 1 }
if c == '"' { # "string"
let st = i; i += 1
while s[i] != 0 and s[i] != '"' and s[i] != '\n' { if s[i] == '\\' and s[i + 1] != 0 { i += 2 } else { i += 1 } }
if s[i] == '"' { i += 1 }
push_tok(LT_STR, st, i, line); continue
}
if c == 96 { # `interpolated`
let st = i; i = i + 1
while s[i] != 0 and s[i] != 96 { if s[i] == 92 and s[i + 1] != 0 { i = i + 2 } else { i = i + 1 } }
if s[i] == 96 { i = i + 1 }
if c == '`' { # `interpolated`
let st = i; i += 1
while s[i] != 0 and s[i] != '`' { if s[i] == '\\' and s[i + 1] != 0 { i += 2 } else { i += 1 } }
if s[i] == '`' { i += 1 }
push_tok(LT_STR, st, i, line); continue
}
if c == 39 { # 'c'
let st = i; i = i + 1
if s[i] == 92 and s[i + 1] != 0 { i = i + 2 } else { if s[i] != 0 and s[i] != 10 { i = i + 1 } }
if s[i] == 39 { i = i + 1 }
if c == '\'' { # 'c'
let st = i; i += 1
if s[i] == '\\' and s[i + 1] != 0 { i += 2 } else { if s[i] != 0 and s[i] != '\n' { i += 1 } }
if s[i] == '\'' { i += 1 }
push_tok(LT_CHAR, st, i, line); continue
}
if char_is_digit(c) {
let st = i
if c == 48 and (s[i + 1] == 120 or s[i + 1] == 88) { # 0x hex
i = i + 2; while char_is_hex(s[i]) { i = i + 1 }; push_tok(LT_INT, st, i, line); continue
if c == '0' and (s[i + 1] == 'x' or s[i + 1] == 'X') { # 0x hex
i += 2; while char_is_hex(s[i]) { i += 1 }; push_tok(LT_INT, st, i, line); continue
}
while char_is_digit(s[i]) { i = i + 1 }
if s[i] == 46 and char_is_digit(s[i + 1]) {
i = i + 1; while char_is_digit(s[i]) { i = i + 1 }; push_tok(LT_FLOAT, st, i, line); continue
while char_is_digit(s[i]) { i += 1 }
if s[i] == '.' and char_is_digit(s[i + 1]) {
i += 1; while char_is_digit(s[i]) { i += 1 }; push_tok(LT_FLOAT, st, i, line); continue
}
push_tok(LT_INT, st, i, line); continue
}
if c == 64 and (char_is_alpha(s[i + 1]) or s[i + 1] == 95) { # @name annotation
let st = i; i = i + 1; while char_is_alnum(s[i]) { i = i + 1 }; push_tok(LT_ANNO, st, i, line); continue
if c == '@' and (char_is_alpha(s[i + 1]) or s[i + 1] == '_') { # @name annotation
let st = i; i += 1; while char_is_alnum(s[i]) { i += 1 }; push_tok(LT_ANNO, st, i, line); continue
}
if char_is_alpha(c) {
let st = i; while char_is_alnum(s[i]) { i = i + 1 }
let st = i; while char_is_alnum(s[i]) { i += 1 }
let w = s[st..i]
var k = LT_ID
if (w == "true") or (w == "false") or (w == "null") { k = LT_BOOL }
@ -184,14 +187,14 @@ program LudicFmt {
else { if is_keyword_word(w) { k = LT_KW } } } }
push_tok(k, st, i, line); continue
}
if is_op2(c, s[i + 1]) { push_tok(LT_OP, i, i + 2, line); i = i + 2; continue }
if is_op1(c) { push_tok(LT_OP, i, i + 1, line); i = i + 1; continue }
if is_op2(c, s[i + 1]) { push_tok(LT_OP, i, i + 2, line); i += 2; continue }
if is_op1(c) { push_tok(LT_OP, i, i + 1, line); i += 1; continue }
# anything else: one UTF-8 character's worth as an LT_ERR token
var ln = 1
if c >= 240 { ln = 4 } else { if c >= 224 { ln = 3 } else { if c >= 128 { ln = 2 } } }
var kk = 1
while kk < ln { if s[i + kk] == 0 or (s[i + kk] & 192) != 128 { ln = kk }; kk = kk + 1 }
push_tok(LT_ERR, i, i + ln, line); i = i + ln
while kk < ln { if s[i + kk] == 0 or (s[i + kk] & 192) != 128 { ln = kk }; kk += 1 }
push_tok(LT_ERR, i, i + ln, line); i += ln
}
push_tok(LT_EOF, i, i, line)
}
@ -199,36 +202,41 @@ program LudicFmt {
# ---- token-stream helpers ----
function next_sig(i: int) -> int {
var j = i + 1
while j < ntok() { let k = tk_kind[j]; if k != LT_NL and k != LT_COMMENT { return j }; j = j + 1 }
return 0 - 1
while j < ntok() { let k = tk_kind[j]; if k != LT_NL and k != LT_COMMENT { return j }; j += 1 }
return -1
}
function prev_sig(i: int) -> int {
var j = i - 1
while j >= 0 { let k = tk_kind[j]; if k != LT_NL and k != LT_COMMENT { return j }; j = j - 1 }
return 0 - 1
while j >= 0 { let k = tk_kind[j]; if k != LT_NL and k != LT_COMMENT { return j }; j -= 1 }
return -1
}
function name_like(k: int) -> bool { return k == LT_ID or k == LT_KW or k == LT_TYPE or k == LT_PHASE or k == LT_BOOL }
# is token i written right after a '.', i.e. a member/method name?
function is_member(i: int) -> bool {
let p = prev_sig(i)
return p >= 0 and tk_kind[p] == LT_OP and tok_len(p) == 1 and src[tk_start[p]] == '.'
}
# a '.' hugs an operand, a closing bracket, and another dot
function dot_tight(t: int) -> bool {
if name_like(tk_kind[t]) { return true }
if tk_kind[t] != LT_OP { return false }
let n = tok_len(t); let c0 = src[tk_start[t]]
if n == 1 and (c0 == 41 or c0 == 93 or c0 == 46) { return true }
if n == 2 and c0 == 46 and src[tk_start[t] + 1] == 46 { return true }
if n == 1 and (c0 == ')' or c0 == ']' or c0 == '.') { return true }
if n == 2 and c0 == '.' and src[tk_start[t] + 1] == 46 { return true }
return false
}
# is the token at index i a unary '-'/'!' rather than a binary operator?
# is the token at index i a unary '-' / '!' / '~' rather than a binary operator?
function is_unary(i: int) -> bool {
if tk_kind[i] != LT_OP { return false }
let n = tok_len(i)
if not (n == 1 and (src[tk_start[i]] == 45 or src[tk_start[i]] == 33)) { return false }
if not (n == 1 and (src[tk_start[i]] == 45 or src[tk_start[i]] == 33 or src[tk_start[i]] == 126)) { return false }
let p = prev_sig(i)
if p < 0 { return true }
let pk = tk_kind[p]
if pk == LT_ID or pk == LT_INT or pk == LT_FLOAT or pk == LT_STR or pk == LT_CHAR or pk == LT_BOOL or pk == LT_TYPE or pk == LT_PHASE { return false }
if pk == LT_OP {
let c = src[tk_start[p]]
return not (tok_len(p) == 1 and (c == 41 or c == 93 or c == 125)) # a closing bracket ends an operand
return not (tok_len(p) == 1 and (c == ')' or c == ']' or c == '}')) # a closing bracket ends an operand
}
return true # keyword/annotation/comment: operand starts here
}
@ -241,14 +249,26 @@ program LudicFmt {
let p0 = src[tk_start[prev]]; let c0 = src[tk_start[cur]]
let p1 = (plen == 1); let c1 = (clen == 1)
if pk == LT_ERR or ck == LT_ERR { return tk_start[cur] - tk_end[prev] } # preserve an error token's spacing
if c1 and (c0 == 41 or c0 == 93 or c0 == 44 or c0 == 58 or c0 == 59) { return 0 } # ) ] , : ;
if c1 and c0 == 46 and ck == LT_OP and dot_tight(prev) { return 0 }
if p1 and p0 == 46 and pk == LT_OP and dot_tight(cur) { return 0 }
if p1 and (p0 == 40 or p0 == 91) and pk == LT_OP { return 0 } # nothing hugs an opener from the right
if c1 and (c0 == ')' or c0 == ']' or c0 == ',' or c0 == ':' or c0 == ';') { return 0 } # ) ] , : ;
if c1 and c0 == '.' and ck == LT_OP and dot_tight(prev) { return 0 }
if p1 and p0 == '.' and pk == LT_OP and dot_tight(cur) { return 0 }
if p1 and (p0 == '(' or p0 == '[') and pk == LT_OP { return 0 } # nothing hugs an opener from the right
# an index or slice hugs its operand: a[i], f()[0], a[i][j]; `query [` and `= [` keep their space
if c1 and c0 == '[' and ck == LT_OP {
if pk == LT_ID or pk == LT_STR { return 0 }
if pk == LT_OP and p1 and (p0 == ')' or p0 == ']') { return 0 }
}
# the element type hugs an empty pair of brackets: `[]int`, `[]Node`
if p1 and p0 == ']' and pk == LT_OP and (ck == LT_ID or ck == LT_TYPE) {
let pp = prev_sig(prev)
if pp >= 0 and tk_kind[pp] == LT_OP and tok_len(pp) == 1 and src[tk_start[pp]] == 91 { return 0 }
}
if pk == LT_OP and is_unary(prev) { return 0 }
if pk == LT_ANNO and c1 and c0 == 40 { return 0 } # @anno(
if c1 and c0 == 40 and ck == LT_OP {
if pk == LT_ANNO and c1 and c0 == '(' { return 0 } # @anno(
if c1 and c0 == '(' and ck == LT_OP {
if pk == LT_ID or pk == LT_TYPE or pk == LT_PHASE { return 0 } # function move( / clear(
if pk == LT_KW and (tok_text(prev) == "emit") { return 0 } # emit(...) is a call, `emit E(...)` an event
if pk == LT_KW and is_member(prev) { return 0 } # Prefab.spawn( / Date.new( — a member name is never a keyword
if pk == LT_OP { return 1 } # `= (`, `+ (`, `) (` keep a space
return 1
}
@ -264,7 +284,7 @@ program LudicFmt {
var cur = 0
var open = 0
var brack = 0
var ui_depth = 0 - 1
var ui_depth = -1
var pending_blank = 0
var wrote_any = 0
var prev_line_had_comment = 0
@ -272,9 +292,9 @@ program LudicFmt {
var i = 0
while i < N and tk_kind[i] != LT_EOF {
let a = i
while i < N and tk_kind[i] != LT_NL and tk_kind[i] != LT_EOF { i = i + 1 }
while i < N and tk_kind[i] != LT_NL and tk_kind[i] != LT_EOF { i += 1 }
let b = i
if i < N and tk_kind[i] == LT_NL { i = i + 1 }
if i < N and tk_kind[i] == LT_NL { i += 1 }
if a == b { # a blank line
if wrote_any == 1 { pending_blank = 1 }
@ -286,13 +306,13 @@ program LudicFmt {
var tsp = sp
var t = a
while t < b and tk_kind[t] == LT_OP and tok_len(t) == 1 and src[tk_start[t]] == 125 { # leading '}'
if tsp > 0 { tsp = tsp - 1; line_level = stack[tsp] }
t = t + 1
if tsp > 0 { tsp -= 1; line_level = stack[tsp] }
t += 1
}
# original column of this line
var orig_ind = 0
var kk = linestart[tk_line[a]]
while kk < tk_start[a] { if src[kk] == 9 { orig_ind = orig_ind + 4 } else { orig_ind = orig_ind + 1 }; kk = kk + 1 }
while kk < tk_start[a] { if src[kk] == '\t' { orig_ind += 4 } else { orig_ind += 1 }; kk += 1 }
# a comment continuing the previous comment line keeps the author's column
var comment_run = 0
if tk_kind[a] == LT_COMMENT and b == a + 1 and prev_line_had_comment == 1 { comment_run = 1 }
@ -304,7 +324,7 @@ program LudicFmt {
let ls = linestart[tk_line[a]]
ind = 0
var k2 = ls
while k2 < tk_start[a] { if src[k2] == 9 { ind = ind + 4 } else { ind = ind + 1 }; k2 = k2 + 1 }
while k2 < tk_start[a] { if src[k2] == '\t' { ind += 4 } else { ind += 1 }; k2 += 1 }
} else {
var extra = 0
if is_clause_word(tok_text(a)) and tk_kind[a] == LT_KW { extra = indent_width }
@ -312,13 +332,14 @@ program LudicFmt {
if comment_run == 1 and orig_ind > ind { ind = orig_ind }
}
if pending_blank == 1 and wrote_any == 1 { buf_putc(o, 10) }
if pending_blank == 1 and wrote_any == 1 { buf_putc(o, '\n') }
pending_blank = 0
buf_indent(o, ind)
# emit the tokens
var prev = 0 - 1
var prev = -1
var line_brack = brack
var in_tag = 0 # inside a query's {Tag} filter
var line_ui_open = 0
if ui_depth >= 0 and sp > ui_depth { line_ui_open = 1 }
t = a
@ -327,9 +348,18 @@ program LudicFmt {
if tk_kind[t] == LT_COMMENT { if prev >= 0 { want = 2 } else { want = 0 } }
else {
want = space_before(prev, t)
if line_brack > 0 { # query {Tag} filter is one word
if tk_kind[t] == LT_OP and tok_len(t) == 1 and src[tk_start[t]] == 125 { want = 0 }
if prev >= 0 and tk_kind[prev] == LT_OP and tok_len(prev) == 1 and src[tk_start[prev]] == 123 { want = 0 }
# a query's {Tag} filter is one word: a `{` straight after `[` or `,` opens one
# (a record literal `new R { … }` inside a list keeps its spaces)
if line_brack > 0 and tk_kind[t] == LT_OP and tok_len(t) == 1 {
let c = src[tk_start[t]]
if c == '{' and prev >= 0 and tk_kind[prev] == LT_OP and tok_len(prev) == 1 and (src[tk_start[prev]] == '[' or src[tk_start[prev]] == ',') { in_tag = 1 }
if c == '}' and in_tag == 1 { want = 0; in_tag = 0 }
}
if in_tag == 1 and prev >= 0 and tk_kind[prev] == LT_OP and tok_len(prev) == 1 and src[tk_start[prev]] == '{' { want = 0 }
# a slice range hugs its bounds, `s[a..b]`; a `for i in a .. b` range keeps its spaces
if line_brack > 0 {
if tk_kind[t] == LT_OP and tok_len(t) == 2 and src[tk_start[t]] == 46 and src[tk_start[t] + 1] == 46 { want = 0 }
if prev >= 0 and tk_kind[prev] == LT_OP and tok_len(prev) == 2 and src[tk_start[prev]] == 46 and src[tk_start[prev] + 1] == 46 { want = 0 }
}
if line_ui_open == 1 { # widget props are k=v
var eq_here = 0
@ -344,17 +374,17 @@ program LudicFmt {
if gap >= 2 and want >= 1 { if gap > 60 { gap = 60 }; want = gap } # hand alignment wins
if want < 0 { want = 0 }
var w2 = 0
while w2 < want { buf_putc(o, 32); w2 = w2 + 1 }
while w2 < want { buf_putc(o, ' '); w2 += 1 }
buf_addrange(o, src, tk_start[t], tk_end[t])
prev = t
if tk_kind[t] == LT_OP and tok_len(t) == 1 {
let c = src[tk_start[t]]
if c == 91 { line_brack = line_brack + 1 }
else { if c == 93 { line_brack = line_brack - 1; if line_brack < 0 { line_brack = 0 } } }
if c == '[' { line_brack += 1 }
else { if c == ']' { line_brack -= 1; if line_brack < 0 { line_brack = 0 } } }
}
t = t + 1
t += 1
}
buf_putc(o, 10)
buf_putc(o, '\n')
wrote_any = 1
prev_line_had_comment = 0
if prev >= 0 and tk_kind[prev] == LT_COMMENT { prev_line_had_comment = 1 }
@ -366,25 +396,25 @@ program LudicFmt {
while t < b {
if tk_kind[t] == LT_OP and tok_len(t) == 1 {
let c = src[tk_start[t]]
if c == 123 { # '{'
if c == '{' { # '{'
if sp < 512 {
let nxt = next_sig(t)
stack[sp] = line_level
var h = 0
if nxt >= 0 and nxt < b { h = 1 }
hang[sp] = h
sp = sp + 1
sp += 1
}
level = line_level + 1
}
else { if c == 125 { if sp > 0 { sp = sp - 1; level = stack[sp] } }
else { if c == 40 or c == 91 { open = open + 1; if c == 91 { brack = brack + 1 } }
else { if c == 41 or c == 93 { open = open - 1; if open < 0 { open = 0 }; if c == 93 { brack = brack - 1; if brack < 0 { brack = 0 } } } } } }
else { if c == '}' { if sp > 0 { sp -= 1; level = stack[sp] } }
else { if c == '(' or c == '[' { open += 1; if c == '[' { brack += 1 } }
else { if c == ')' or c == ']' { open -= 1; if open < 0 { open = 0 }; if c == ']' { brack -= 1; if brack < 0 { brack = 0 } } } } } }
}
t = t + 1
t += 1
}
cur = level
if ui_depth >= 0 and sp <= ui_depth { ui_depth = 0 - 1 }
if ui_depth >= 0 and sp <= ui_depth { ui_depth = -1 }
}
return o
}
@ -395,22 +425,22 @@ program LudicFmt {
var g_marker: int = 0
# detect a fence at line offset i; sets g_flen/g_info/g_marker; returns bool
function md_fence_at(s: pointer, i: int) -> bool {
var j = i; while s[j] == 32 { j = j + 1 }
var j = i; while s[j] == ' ' { j += 1 }
let m = s[j]
if m != 96 and m != 126 { return false } # ` or ~
var n = 0; while s[j] == m { j = j + 1; n = n + 1 }
if m != '`' and m != '~' { return false } # ` or ~
var n = 0; while s[j] == m { j += 1; n += 1 }
if n < 3 { return false }
g_flen = n; g_info = j; g_marker = m
return true
}
function md_info_is_ludic(s: pointer, at: int) -> bool {
var a = at; while s[a] == 32 or s[a] == 9 { a = a + 1 }
var a = at; while s[a] == ' ' or s[a] == '\t' { a += 1 }
# case-insensitive "ludic"
if not (((s[a] == 108 or s[a] == 76)) and ((s[a + 1] == 117 or s[a + 1] == 85)) and ((s[a + 2] == 100 or s[a + 2] == 68)) and ((s[a + 3] == 105 or s[a + 3] == 73)) and ((s[a + 4] == 99 or s[a + 4] == 67))) { return false }
if not (((s[a] == 'l' or s[a] == 'L')) and ((s[a + 1] == 'u' or s[a + 1] == 'U')) and ((s[a + 2] == 'd' or s[a + 2] == 'D')) and ((s[a + 3] == 'i' or s[a + 3] == 'I')) and ((s[a + 4] == 'c' or s[a + 4] == 'C'))) { return false }
let af = s[a + 5]
return af == 0 or af == 10 or af == 32 or af == 9 or af == 13
return af == 0 or af == '\n' or af == ' ' or af == '\t' or af == '\r'
}
function line_end(s: pointer, i: int) -> int { var e = i; while s[e] != 0 and s[e] != 10 { e = e + 1 }; return e }
function line_end(s: pointer, i: int) -> int { var e = i; while s[e] != 0 and s[e] != '\n' { e += 1 }; return e }
function format_markdown(s: pointer, indent_width: int) -> Buf {
let o = buf_new()
@ -418,7 +448,7 @@ program LudicFmt {
while s[i] != 0 {
let ls = i
let le = line_end(s, ls)
var indent = 0; while s[ls + indent] == 32 { indent = indent + 1 }
var indent = 0; while s[ls + indent] == ' ' { indent += 1 }
if md_fence_at(s, ls) and md_info_is_ludic(s, g_info) {
# copy the opening fence line verbatim (with its newline)
var e0 = le; if s[le] != 0 { e0 = le + 1 }
@ -437,7 +467,7 @@ program LudicFmt {
if g_marker == cmark and g_flen >= clen {
var only = 1
var k = g_info
while k < pe { if s[k] != 32 and s[k] != 13 { only = 0; break }; k = k + 1 }
while k < pe { if s[k] != ' ' and s[k] != '\r' { only = 0; break }; k += 1 }
if only == 1 { be = ps; break }
}
}
@ -450,9 +480,9 @@ program LudicFmt {
while p < be {
var q = line_end(s, p)
var skip = 0
while skip < indent and (p + skip) < q and s[p + skip] == 32 { skip = skip + 1 }
while skip < indent and (p + skip) < q and s[p + skip] == ' ' { skip += 1 }
buf_addrange(body, s, p + skip, q)
buf_putc(body, 10)
buf_putc(body, '\n')
if s[q] != 0 { p = q + 1 } else { p = q }
}
# feed the body through the ludic formatter
@ -461,10 +491,10 @@ program LudicFmt {
let ftext = buf_str(f)
var fp = 0
while ftext[fp] != 0 {
var fq = fp; while ftext[fq] != 0 and ftext[fq] != 10 { fq = fq + 1 }
var fq = fp; while ftext[fq] != 0 and ftext[fq] != '\n' { fq += 1 }
if fq > fp { buf_indent(o, indent) }
buf_addrange(o, ftext, fp, fq)
buf_putc(o, 10)
buf_putc(o, '\n')
if ftext[fq] != 0 { fp = fq + 1 } else { fp = fq }
}
i = be
@ -516,10 +546,10 @@ program LudicFmt {
if (a == "-w") or (a == "--write") { write = true }
else { if (a == "--check") or (a == "-l") { check = true }
else { if (a == "-q") or (a == "--quiet") { quiet = true }
else { if (a == "--indent") { ai = ai + 1; if ai < arg_count() { indent = 0; let d = arg(ai); var di = 0; while d[di] != 0 { indent = indent * 10 + (d[di] - 48); di = di + 1 } } }
else { if (a == "--indent") { ai += 1; if ai < arg_count() { indent = 0; let d = arg(ai); var di = 0; while d[di] != 0 { indent = indent * 10 + (d[di] - 48); di += 1 } } }
else { if (a == "-h") or (a == "--help") { print("ludic-fmt — format Ludic source"); return }
else { push(files, a) } } } } }
ai = ai + 1
ai += 1
}
if indent < 1 or indent > 8 { indent = 2 }
@ -555,7 +585,7 @@ program LudicFmt {
file_write(file_stdout(), out, cstr_len(out))
} }
}
fi = fi + 1
fi += 1
}
if failed { exit(2) }
if check and changed { exit(1) }

File diff suppressed because it is too large Load diff

View file

@ -6,8 +6,8 @@
* addition to the language shows up in every editor at once instead of drifting
* across six hand-maintained copies.
*
* Unlike the compiler's lexer (compiler/ludicc.c) this one is written for
* editors: it keeps comments, keeps newlines, records byte spans for every
* Unlike the compiler's lexer (selfhost/frontend/lex.ludic) this one is written
* for editors: it keeps comments, keeps newlines, records byte spans for every
* token, and never exits on bad input — a stray character becomes an LT_ERR
* token and lexing continues, because a file being typed into is malformed most
* of the time.
@ -74,7 +74,7 @@ static const char* LUDIC_TYPES[] = {
"int","long","fixed","countdown","bool","entity","string","pointer","byte","words","fixeds","pointers","Vector","IVec2","Rect","void", 0
};
static const char* LUDIC_PHASES[] = {
"Start","Input","FixedUpdate","Update","LateUpdate","Render", 0
"Start","Input","FixedUpdate","Update","LateUpdate","Render","Overlay", 0
};
static const char* LUDIC_WIDGETS[] = {
"panel","col","row","label","button","image","spacer", 0
@ -208,7 +208,7 @@ static void lline_push(LLex* L, int off){
L->linestart[L->nline++] = off;
}
static const char* LUDIC_OPS2[] = { "->","+=","-=","*=","/=","==","!=","<=",">=","&&","||","..","=>", 0 };
static const char* LUDIC_OPS2[] = { "->","+=","-=","*=","/=","==","!=","<=",">=","<<",">>","..","=>", 0 };
/* Lex the whole buffer. Comments and newlines are kept — the formatter needs
* both, and a highlighter needs the comments. */
@ -279,7 +279,7 @@ static void lud_lex(LLex* L, const char* src){
if (matched) continue;
}
if (c == ';'){ ltok_push(L, LT_OP, i, i + 1, line, 0); i++; continue; }
if (strchr("+-*/%<>=(){}[],:.!@", c)){ ltok_push(L, LT_OP, i, i + 1, line, 0); i++; continue; }
if (strchr("+-*/%<>=(){}[],:.!@&|^~", c)){ ltok_push(L, LT_OP, i, i + 1, line, 0); i++; continue; }
/* Anything else is an error token — but a whole UTF-8 character's worth
* at a time. Splitting a multi-byte character into one token per byte
* would let a consumer that re-emits tokens (the formatter) put spaces