Merge branch 'lang/foundations' into lang/i18n-data

This commit is contained in:
Orkun ÇAKILKAYA 2026-09-30 00:35:56 +03:00
commit 9f1273fb32
38 changed files with 122587 additions and 100240 deletions

View file

@ -75,6 +75,7 @@ point at a different LLVM toolchain if you have one.
| the IR, to read | `ludicc src.ludic --emit-llvm -o src.ll` | | the IR, to read | `ludicc src.ludic --emit-llvm -o src.ll` |
| the schema an editor reads (records, registries and their entries, consts) | `ludicc src.ludic --emit-schema schema.json` | | the schema an editor reads (records, registries and their entries, consts) | `ludicc src.ludic --emit-schema schema.json` |
| every error, as a JSON array on stdout | `ludicc src.ludic --check --diagnostics=json` | | every error, as a JSON array on stdout | `ludicc src.ludic --check --diagnostics=json` |
| the same, with an unsaved buffer on stdin standing for one of its files | `ludicc src.ludic --check --diagnostics=json --stdin-file lib/a.ludic < buf` |
| a shared library † | `ludicc lib.ludic --shared -o build/liblib.dylib` | | a shared library † | `ludicc lib.ludic --shared -o build/liblib.dylib` |
| a game that runs in a browser † | `ludicc game.ludic --target wasm32-unknown-unknown -o build/web/game.wasm` | | a game that runs in a browser † | `ludicc game.ludic --target wasm32-unknown-unknown -o build/web/game.wasm` |
| an object file † | `ludicc src.ludic -c -o src.o` | | an object file † | `ludicc src.ludic -c -o src.o` |

View file

@ -801,6 +801,43 @@ object with `"schema_version": 1`:
and is left out. and is left out.
- `units` - `@Unit`'s canonical spellings. - `units` - `@Unit`'s canonical spellings.
- `lang` - the text keys and the languages (see Text keys), or null without a `lang` line. - `lang` - the text keys and the languages (see Text keys), or null without a `lang` line.
- `code` - the program's code map, so an editor needs no scan of its own. Every place is
`"file:line:col"` (`"at"`); what the runtime declares and what the compiler writes itself are left
out. It holds:
- `modules` - `{"name", "package", "layer", "uses", "uses_at", "friend", "files"}`: `uses` null
without a `uses` line, `friend` null, or `{"of": null}` for a friend of every module and
`{"of": [...]}` for `friend module lab of a, b`; `files` its source files and the resource files
read into its registries;
- `states`, `actions` (`{"name", "module", "at"}`), `reducers` (`{"state", "action", "module",
"at"}`) and `dispatch` (every `dispatch`: `{"action", "module", "at"}`);
- `events` - `{"name", "module", "at", "cancellable", "net"}` (`net` `"toserver"` for
`@ToServer`, `"toclients"` for `@ToClients`, else null), its `emits` (every `emit`'s place) and
its `listeners` (the `@On` handlers: `{"handler", "module", "at"}`);
- `ports` - `{"name", "module", "at", "bound", "members"}`, each member `{"name", "type", "default",
"required"}` (its `fn` type as a field's is spelled, its default as written); and `binds` - one
row per member a `bind` gives: `{"port", "member", "fn", "value", "module", "at"}`, `fn` the
function a `fn name` names (null for a variable bound to a member that takes nothing) and
`value` as written;
- `handlers` - `{"name", "module", "at", "phase", "hook", "target", "public", "net", "queries",
"scene", "layer"}`: `phase` null for a hook, `hook` null or `"On"`, `"OnSpawn"`, `"OnDespawn"`,
`"OnAttach"`, `"OnDetach"`, `"OnEnable"`, `"OnDisable"`, `"OnStart"`, `"OnQuit"` with `target`
the event, model or property it names; `net` `"server"` (`@Server`), `"predicted"`
(`@Predicted`) or null; `queries` null or `{"these": [{"property", "filter"}], "on"}` (a
filter as written, or null); a scene's handler has its name as written and its `scene` and
`layer`;
- `models` (`{"name", "module", "at", "owned", "properties": [{"name", "sync"}]}`), `prefabs`
(`{"name", "model", "module", "at", "components": [{"property", "fields": [{"name",
"value"}]}]}`) and `scenes` (`{"name", "module", "at", "start", "public", "shows", "lasts",
"then", "loads", "enter", "exit", "layers"}`: `lasts` as written, `then` the scene it goes on to,
`loads` the scene a `loads then` one goes on to, `enter` / `exit` whether it has the block);
- `fn_refs` - every `fn name` written, in code or in a resource file: `{"fn", "module", "at",
"slot_kind", "slot", "entry"}`, the slot it fills: `"registry"` (`"Registry.field"`, `entry` the
row's key), `"port"` (a bind's `"Port.member"`), `"port_default"`, `"default"` (a record
field's default, `"Type.field"`), `"record"` (a `new` or a spawn's `"Type.field"`), `"event"`
(an `emit`'s `"Event.field"`), `"arg"` (`"callee(i)"`, the i-th argument written), `"assign"`
(`s.tick = fn f`: `"s.tick"`), `"let"` (the name), or `"value"` with a null slot.
Named lists are sorted by name (then file and line), sites by place.
Each list is sorted by name (then file and line), a registry's entries are in index order, and the Each list is sorted by name (then file and line), a registry's entries are in index order, and the
paths are the ones the compiler read, so two runs over the same source write the same file. The paths are the ones the compiler read, so two runs over the same source write the same file. The
@ -2323,7 +2360,8 @@ ludic test -j 4 # four tests at once (default: one per
ludic test packages/ludic.base # the test programs under a directory (a package's) ludic test packages/ludic.base # the test programs under a directory (a package's)
ludic build --check # every check a build makes, nothing written ludic build --check # every check a build makes, nothing written
ludic build --check --diagnostics=json # the same, every error as a JSON array on stdout ludic build --check --diagnostics=json # the same, every error as a JSON array on stdout
ludic schema -o build/schema.json # records, registries, entries, consts, components and natives, for editors ludic build --check --diagnostics=json --stdin-file src/a.ludic < buf # stdin in place of src/a.ludic
ludic schema -o build/schema.json # records, registries, entries, consts, components, natives, lang and the code map, for editors
ludic syntax --json # the language's vocabulary: keywords, declarations, types, phases, attributes, operators ludic syntax --json # the language's vocabulary: keywords, declarations, types, phases, attributes, operators
ludic deps # how tangled the modules are, as the compiler resolved them ludic deps # how tangled the modules are, as the compiler resolved them
ludic deps --check tests/deps-baseline.txt # fail when a number rose (--baseline FILE writes them) ludic deps --check tests/deps-baseline.txt # fail when a number rose (--baseline FILE writes them)
@ -2346,6 +2384,17 @@ type error is in it, every `@Ref` naming no registry, and every module rule brok
unknown name while lowering, a def into a registry that does not exist - ends the array, and type unknown name while lowering, a def into a registry that does not exist - ends the array, and type
errors end it before the module rules are looked at. errors end it before the module rules are looked at.
`--stdin-file <path>` (`ludicc --check --stdin-file <path>`; on `ludic build` it implies `--check`)
checks an unsaved buffer: the program is the usual one - the package's entry, or the file named - but
wherever the compiler would open `<path>` it reads the text on stdin instead, read once and whole (an
empty stdin is an empty file). That is any file it opens: the entry, an import reached through a
barrel, a component's `.xml` or `.lss`, an `.lres`. The two paths are compared after normalising
both - `\` made `/`, a relative path put under the directory the compiler was started in, `.`, `..`
and doubled `/` folded, and case on Windows - so `./src/foo/../foo/bar.ludic` is `src/foo/bar.ludic`.
Diagnostics name the file as a check of the saved file would, with lines and columns in the
buffer's text. A `<path>` the program never opens is one warning on that path, rather than a clean
report on the saved files.
`ludic deps` compiles the program (the package's entry, or a file) with the compiler recording every `ludic deps` compiles the program (the package's entry, or a file) with the compiler recording every
reference its visibility pass resolves - from the module it is written in to the module of what it reference its visibility pass resolves - from the module it is written in to the module of what it
names - and every assignment to another module's global. It prints five numbers: `modules` (the names - and every assignment to another module's global. It prints five numbers: `modules` (the
@ -2420,6 +2469,28 @@ had when a rule came in, which it may keep but not add to, lowered automatically
fixed - so a rule can arrive in a codebase that breaks it today (`ludic-fmt --init-baseline` fixed - so a rule can arrive in a codebase that breaks it today (`ludic-fmt --init-baseline`
writes it). A `;` or a `#` inside a string does not count. writes it). A `;` or a `#` inside a string does not count.
For editors and tools (`ludic fmt` finds the project the way these describe - the nearest
`package.ludic` upwards from the working directory, or from the directory of `--stdin-name` - and runs
the formatter from there):
```bash
ludic fmt --lint --json # the violations --lint prints, as one JSON array
ludic fmt - --stdin-name src/a.ludic < buf # a buffer formatted to stdout, as if it were src/a.ludic
ludic fmt - --lint --json --stdin-name src/a.ludic < buf # a buffer linted as src/a.ludic
```
`--lint --json` writes `[{"file", "line", "col", "rule", "message"}]` on stdout - every violation
`--lint` would print, ordered by file, line and column (`col`, 1-based, is left out for a rule with no
column: a file's length) - and everything else on stderr; the exit status is `--lint`'s (1 when a file
has more than its baseline allows). Unlike `--lint`, it never rewrites the baseline. `-` reads the
whole of stdin: formatting writes the result to stdout, and a buffer that does not read as Ludic - a
string, template or key literal left open, a bracket never closed or closed by the wrong one - is
refused with exit 2 and `<name>:<line>:<col>: error: ...` on stderr, nothing on stdout. With `--lint`,
the buffer is judged as the file `--stdin-name` names, relative to the project: its baseline
allowance applies, a name outside the `lint paths` breaks no rule (`[]`), and the report names it as
given (or `-`). A project with no `lint` lines lints a buffer clean. `--stdin-name` ending `.md`
formats the buffer as Markdown.
### Editors ### Editors
```bash ```bash

View file

@ -144,6 +144,7 @@ cached in a content-addressed store:
```bash ```bash
ludic add git.workshopsoft.io/user/pkg # resolve, fetch, link into ludic_modules/ ludic add git.workshopsoft.io/user/pkg # resolve, fetch, link into ludic_modules/
ludic get # install from package.ludic, write the lock ludic get # install from package.ludic, write the lock
ludic remove git.workshopsoft.io/user/pkg # the inverse of add
ludic verify # check locked packages against the store ludic verify # check locked packages against the store
``` ```

View file

@ -0,0 +1,9 @@
bump: minor
type: feat
**Check an unsaved buffer.** `ludic build --check --diagnostics=json --stdin-file <path>` (and
`ludicc --check --stdin-file <path>`) checks the program as usual, but wherever the compiler would open
`<path>` - the entry, an import reached through a barrel, a component's `.xml` / `.lss`, an `.lres` -
it reads the text on stdin instead, so an editor's diagnostics follow typing without a save. Paths are
matched after normalising both (separators, relative to the working directory, `.` / `..` folded).
Diagnostics carry the file's usual name with lines and columns in the buffer; a `<path>` the program
never opens is reported as one warning.

View file

@ -0,0 +1,12 @@
bump: minor
type: feat
**`ludic fmt` for editors.** `ludic fmt --lint --json` prints the violations `--lint` reports as one JSON
array on stdout, `[{"file", "line", "col", "rule", "message"}]` ordered by file, line and column (the
summary on stderr, `--lint`'s exit status, and the baseline never rewritten). `ludic fmt -` formats
stdin to stdout under the project found from the working directory (the nearest `package.ludic`
upwards), and refuses a buffer that does not read as Ludic - an open string, a bracket never closed or
closed by the wrong one - with exit 2 and `<name>:<line>:<col>: error: ...` on stderr.
`--stdin-name <path>` makes the buffer that file: the project is found from its directory, and
`ludic fmt - --lint --json --stdin-name <path>` judges it against that file's baseline and `lint
paths`, reporting it under the name given. Hooks around `fmt` and `get` read nothing from stdin and
write to stderr when the command's stdout is a program's (`--json`, `-`).

View file

@ -0,0 +1,10 @@
bump: minor
type: feat
**`ludic remove <module>` and `ludic get --json`.** `remove` is the inverse of `add`: the `require`
leaves `package.ludic`, the lock keeps what the remaining requires still reach (read from the store's
copies of each package's manifest, no network), and each package leaving the lock loses the
`ludic_modules/` link `add` made - never the shared store entry. A module that is not required is
refused; source still importing a removed package is a warning. `ludic get --json` prints what the run
changed in the lock as one JSON object on stdout - `{"added", "removed", "changed", "unchanged"}`, an
entry as the lock records it (`name`, `version`, `hash`, `kind`, `provides`), a change as `{name, from,
to, from_hash, to_hash}` - with the resolver's own lines on stderr.

12
changes/schema-code.md Normal file
View file

@ -0,0 +1,12 @@
bump: minor
type: feature
**`ludic schema` carries the code map: a `"code"` section.** Beside the records, registries and
`lang`, the schema now lists what an editor's code map needs, from the compiler's own reading:
`modules` (uses, layer, friend, files), `states`, `actions`, `reducers` and every `dispatch`, `events`
(cancellable, net) with every `emit` and their `@On` listeners, `ports` (members with their `fn`
types and defaults) and `binds` (port, member, the function named, where), `handlers` (phase, hook
and what it names, `@Public`, `@Server` / `@Predicted`, `@Queries` with their filters, the scene and
layer), `models` (`@Owned`, `@Sync`), `prefabs`, `scenes` (start, shows, lasts / loads and what
follows, layers) and `fn_refs` - every `fn name` in code or in a resource file, with the slot it
fills (a registry's field and row, a port's member, a record field, an argument, ...). Every place
is `"file:line:col"`; the rest of the schema is unchanged, so `schema_version` stays 1.

View file

@ -19,7 +19,8 @@ This is the v1 implementation of the direction decided in issue #63.
``` ```
ludic add <module>[@version] add a dependency to package.ludic, then resolve + fetch + link ludic add <module>[@version] add a dependency to package.ludic, then resolve + fetch + link
ludic get resolve every dependency in package.ludic, link them, write the lock ludic remove <module> the inverse of add: drop the require, what only it locked, its links
ludic get [--json] resolve every dependency in package.ludic, link them, write the lock
ludic update [module] bump a dependency (or all) to its latest published version, then relock ludic update [module] bump a dependency (or all) to its latest published version, then relock
ludic verify check every locked package against the store by content hash ludic verify check every locked package against the store by content hash
ludic vendor copy the resolved packages into ./vendor for hermetic/offline builds ludic vendor copy the resolved packages into ./vendor for hermetic/offline builds
@ -29,6 +30,23 @@ ludic vendor copy the resolved packages into ./vendor for herm
minimum. All the install commands print the resolved build list and write minimum. All the install commands print the resolved build list and write
`package.lock.ludic`. `package.lock.ludic`.
`ludic get --json` prints what the run changed in the lock as one JSON object on stdout (the
resolver's lines go to stderr): `{"added": [...], "removed": [...], "changed": [...], "unchanged": n}`,
an added or removed entry being the lock's line - `{"name", "version", "hash", "kind", "provides"}` -
and a changed one `{"name", "from", "to", "from_hash", "to_hash"}` (the versions, and the content
hashes beside them). The lock is compared before and after, in memory.
`ludic remove <module>` takes the `require` line out of `package.ludic` and re-reads the dependency
graph from the store's own copy of each locked package's manifest - no network - so the lock keeps
exactly what the remaining requires still reach: a package another one still requires stays locked
(at the version it had; `ludic get` settles versions), and what only the removed one brought in
leaves the lock with it. Each package leaving the lock loses its `ludic_modules/` link - the link
`add` made, never the store entry it points at, which other projects share; anything there that is
not such a link is left and reported, as is a copy under `vendor/`. A module `package.ludic` does
not require is refused (exit 1). Source that still imports a removed package is a warning, not a
failure - the caller may be about to delete it. If the store has no copy of a locked package, only
the named module leaves the lock, and it says so.
## The manifest — `package.ludic` ## The manifest — `package.ludic`
A line-oriented manifest. `#` starts a comment; strings are double-quoted. A line-oriented manifest. `#` starts a comment; strings are double-quoted.

View file

@ -51,7 +51,7 @@ function kr_fill(i18n_st: mut I18nState, t: string, args: []string) -> string {
if h >= 1 { if h >= 1 {
var v = "" var v = ""
if h < len(args) { v = args[h] } if h < len(args) { v = args[h] }
if i18n_is_key(v) { v = keyed(i18n_st, v) } if i18n_is_key(v) { v = keyed(i18n_st, v) } else if i18n_st.on and kr_words(v) { v = L(i18n_st, v) }
r = r + t[seg..i] + v r = r + t[seg..i] + v
i = hole_end(t, i) i = hole_end(t, i)
seg = i seg = i
@ -62,6 +62,13 @@ function kr_fill(i18n_st: mut I18nState, t: string, args: []string) -> string {
return r + t[seg..len(t)] return r + t[seg..len(t)]
} }
# an argument with letters in it (not a number, a time, a sign): while the game still hands English
# into a key's holes (until its code and data are keys, phase 26.9), it gets the old English lookup
function kr_words(s: string) -> bool {
for i in 0 .. len(s) { if (s[i] >= 65 and s[i] <= 90) or (s[i] >= 97 and s[i] <= 122) { return true } }
return false
}
# a count written as decimal digits (a leading - allowed); anything else is 0 # a count written as decimal digits (a leading - allowed); anything else is 0
function kr_int(s: string) -> int { function kr_int(s: string) -> int {
var v = 0 var v = 0

View file

@ -21,7 +21,7 @@ program I18nKeysTest {
function tt() -> string { function tt() -> string {
var s = "msgid \"\"\nmsgstr \"\"\n\"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : 2);\\n\"\n\n" var s = "msgid \"\"\nmsgstr \"\"\n\"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : 2);\\n\"\n\n"
s = s + po_line("pause.resume", "Devam") + po_line("hud.day", "{1}. Gun") + po_line("species.elk.a_name", "bir geyik") s = s + po_line("pause.resume", "Devam") + po_line("hud.day", "{1}. Gun") + po_line("species.elk.a_name", "bir geyik")
s = s + po_line("catch.saw", "{2} saatinde {1} gordun.") + po_line("Settings", "Ayarlar") s = s + po_line("catch.saw", "{2} saatinde {1} gordun.") + po_line("Settings", "Ayarlar") + po_line("Day {1}", "{1}. gun")
return s + plural("pack.items", "{1} esya", "{1} esyaa", "{1} esyalar") return s + plural("pack.items", "{1} esya", "{1} esyaa", "{1} esyalar")
} }
function start (i18n_st: mut I18nState) -> void { function start (i18n_st: mut I18nState) -> void {
@ -74,4 +74,13 @@ program I18nKeysTest {
expect(L(i18n_st, tr(k"pause.resume")) == "Devam") expect(L(i18n_st, tr(k"pause.resume")) == "Devam")
expect(not i18n_is_key("Settings") and i18n_is_key(i18n_key("x")) and i18n_is_key(i18n_key_plural("x"))) expect(not i18n_is_key("Settings") and i18n_is_key(i18n_key("x")) and i18n_is_key(i18n_key_plural("x")))
} }
test "an English value handed into a key's hole still gets its old English line, until it is a key itself" (i18n_st: mut I18nState) {
start(i18n_st)
expect(L(i18n_st, trf(k"catch.saw", "Settings", "5:30", "", "")) == "You saw Settings at 5:30.")
i18n_load_text(i18n_st, tt())
expect(L(i18n_st, trf(k"catch.saw", "Settings", "5:30", "", "")) == "5:30 saatinde Ayarlar gordun.") # exact
expect(L(i18n_st, trf(k"catch.saw", "Day 3", "5:30", "", "")) == "5:30 saatinde 3. gun gordun.") # a pattern
expect(L(i18n_st, trf(k"catch.saw", "Nowhere known", "7", "", "")) == "7 saatinde Nowhere known gordun.") # no line: as it is
}
} }

View file

@ -12,6 +12,9 @@
# stripped (the runtime supplies them), written as a field's type is ("fn(A,B)->R") # stripped (the runtime supplies them), written as a field's type is ("fn(A,B)->R")
# components every UI component: props, state, derived fields, functions, events (emit_schema_ui) # components every UI component: props, state, derived fields, functions, events (emit_schema_ui)
# natives every native tag registered with ui_native / ui_native_input by a literal # natives every native tag registered with ui_native / ui_native_input by a literal
# units, lang @Unit's spellings; the text keys and the languages (emit_schema_lang)
# code the code map: modules, states, actions, reducers, dispatch, events, ports, binds,
# handlers, models, prefabs, scenes and fn_refs (emit_schema_code)
# #
# Deterministic: each list sorted by name (then file and line), an entry list in index order, the # Deterministic: each list sorted by name (then file and line), an entry list in index order, the
# paths as the compiler read them, and nothing about the run. The engine's runtime is left out. # paths as the compiler read them, and nothing about the run. The engine's runtime is left out.
@ -47,6 +50,8 @@ function schema_buf() -> Buf {
sc_units(b) sc_units(b)
buf_puts(b, "],\n \"lang\": ") buf_puts(b, "],\n \"lang\": ")
sc_lang(b) # emit_schema_lang.ludic: the keys and the languages sc_lang(b) # emit_schema_lang.ludic: the keys and the languages
buf_puts(b, ",\n \"code\": ")
sc_code(b) # emit_schema_code.ludic: the code map
buf_puts(b, "\n}\n") buf_puts(b, "\n}\n")
return b return b
} }

View file

@ -0,0 +1,459 @@
# emit_schema_code.ludic — the schema's "code" (emit_schema.ludic): the program's code map as the
# compiler read it, so an editor needs no scan of its own. Every place is "file:line:col".
#
# modules name, package, layer, uses (null without a `uses` line) and where, friend, files
# states name, module, at actions name, module, at
# reducers state, action, module, at dispatch action, module, at
# events name, module, at, cancellable, net (@ToServer / @ToClients), emits (sites) and
# listeners (the @On handlers: handler, module, at)
# ports name, module, at, bound, members (name, type, default as written, required)
# binds port, member, fn (the function a `fn name` names, else null), value as written, at
# handlers (emit_schema_code_game.ludic) name, module, at, phase, hook, target, public, net,
# queries, scene, layer
# models, prefabs, scenes, fn_refs (emit_schema_code_game.ludic)
#
# Each list is sorted by name (then file and line) or, for sites, by place. What the runtime
# declares, and what the compiler writes itself, is left out.
# ---- what the parser records for it ------------------------------------------------------------
var g_cx_emits: []Node = new []Node # every `emit E(...)` read
var g_cx_fnrefs: []Node = new []Node # every `fn name` read, in code or data
var g_cx_h: []Node = new []Node # every handler written: its node (name, phase, net role)
var g_cx_hname: []pointer = new []pointer # its name as written (a scene's are qualified later)
var g_cx_hhook: []pointer = new []pointer # null, "On", "OnSpawn", "OnDespawn", ..., "OnStart", "OnQuit"
var g_cx_htarget: []pointer = new []pointer # the event, model or property the hook names
var g_cx_hq: []Node = new []Node # its @Queries (an S_QUERY), or null
var g_cx_hpub: []bool = new []bool # @Public
var g_cx_hscene: []pointer = new []pointer # the scene and layer it is written in, or null
var g_cx_hlayer: []pointer = new []pointer
var g_cx_hlisten: []Node = new []Node # an @On handler's listener (g_onlisten), whose event is resolved
var g_cx_slayers: []pointer = new []pointer # per g_scenes entry: ",a,b," its layers
var g_cx_sthen: []pointer = new []pointer # `lasts N then T`'s T, or null (N is the scene's sp0..sp1)
var g_cx_sloads: []pointer = new []pointer # `loads then T`'s T, or null
var g_cx_spub: []bool = new []bool
function cx_emit(n: Node) -> void { push(g_cx_emits, n) }
function cx_fnref(n: Node) -> void { push(g_cx_fnrefs, n) }
function cx_handler(h: Node, name: pointer, hook: pointer, target: pointer, q: Node, pub: bool, scene: pointer, layer: pointer) -> void {
push(g_cx_h, h)
push(g_cx_hname, name)
push(g_cx_hhook, hook)
push(g_cx_htarget, target)
push(g_cx_hq, q)
push(g_cx_hpub, pub)
push(g_cx_hscene, scene)
push(g_cx_hlayer, layer)
push(g_cx_hlisten, null)
}
# the handler just recorded is an @On listener, the one just registered
function cx_listened() -> void {
let k = len(g_cx_hlisten) - 1
if k >= 0 and len(g_onlisten) > 0 { g_cx_hlisten[k] = g_onlisten[len(g_onlisten) - 1] }
}
function cx_scene(layers: pointer, then_scene: pointer, loads: pointer, pub: bool) -> void {
push(g_cx_slayers, layers)
push(g_cx_sthen, then_scene)
push(g_cx_sloads, loads)
push(g_cx_spub, pub)
}
# ---- the section ----------------------------------------------------------------------------------
function sc_code(b: Buf) -> void {
buf_puts(b, "{\n \"modules\": [")
cx_modules(b)
buf_puts(b, "\n ],\n \"states\": [")
cx_decls(b, 2)
buf_puts(b, "\n ],\n \"actions\": [")
cx_decls(b, 3)
buf_puts(b, "\n ],\n \"reducers\": [")
cx_reducers(b)
buf_puts(b, "\n ],\n \"dispatch\": [")
cx_dispatch(b)
buf_puts(b, "\n ],\n \"events\": [")
cx_events(b)
buf_puts(b, "\n ],\n \"ports\": [")
cx_ports(b)
buf_puts(b, "\n ],\n \"binds\": [")
cx_binds(b)
buf_puts(b, "\n ],\n \"handlers\": [")
cx_handlers(b)
buf_puts(b, "\n ],\n \"models\": [")
cx_models(b)
buf_puts(b, "\n ],\n \"prefabs\": [")
cx_prefabs(b)
buf_puts(b, "\n ],\n \"scenes\": [")
cx_scenes(b)
buf_puts(b, "\n ],\n \"fn_refs\": [")
cx_fn_refs(b)
buf_puts(b, "\n ]\n }")
}
# ---- places ---------------------------------------------------------------------------------------
function cx_at_text(n: Node) -> pointer {
return `{n.file}:{itoa(n.line)}:{itoa(n.col)}`
}
function cx_at(b: Buf, n: Node) -> void {
buf_puts(b, "\"at\": ")
if n == null or n.file == null { buf_puts(b, "null") } else { jq_put(b, cx_at_text(n)) }
}
# a key that sorts by place
function cx_at_key(n: Node) -> pointer {
var f = n.file
if f == null { f = "" }
return `{f}\t{itoa(1000000 + n.line)}\t{itoa(1000000 + n.col)}`
}
# written by the program, in a file it read (not the runtime's, not the compiler's own)
function cx_written(n: Node) -> bool {
return n != null and n.file != null and n.pos >= 0 and not is_runtime_file(n.file)
}
# or read from a resource file, whose tokens carry no offset (lex_at)
function cx_written_or_data(n: Node) -> bool {
if cx_written(n) { return true }
if n == null or n.file == null { return false }
let k = len(n.file)
return k > 5 and (n.file[k - 5 .. k] == ".lres")
}
function cx_module(b: Buf, file: pointer) -> void {
buf_puts(b, "\"module\": ")
sc_str(b, sc_mod(file))
}
# ",a,b," as a JSON array, each name once
function cx_list(b: Buf, list: pointer) -> void {
buf_puts(b, "[")
let seen = ss_new(64)
var n = 0
var i = 1
while i < len(list) {
var j = i
while j < len(list) and list[j] != ',' { j += 1 }
if j > i {
let name = list[i .. j]
if ss_add(seen, name) {
if n > 0 { buf_puts(b, ", ") }
jq_put(b, name)
n += 1
}
}
i = j + 1
}
buf_puts(b, "]")
}
# the indices 0..n-1 sorted by keys
function cx_order(keys: []pointer) -> []int {
let xs = new []int
var i = 0
while i < len(keys) {
if keys[i] != null { push(xs, i) }
i += 1
}
sc_sort(xs, keys)
return xs
}
# ---- modules --------------------------------------------------------------------------------------
function cx_mod_names(names: []pointer, seen: StrSet, from: []pointer) -> void {
var i = 0
while i < len(from) {
if from[i] != null and len(from[i]) > 0 and ss_add(seen, from[i]) { push(names, from[i]) }
i += 1
}
}
function cx_modules(b: Buf) -> void {
let names = new []pointer
let seen = ss_new(256)
cx_mod_names(names, seen, g_mod_name)
cx_mod_names(names, seen, g_mu_mod)
cx_mod_names(names, seen, g_mod_friends)
let xs = cx_order(names)
var a = 0
while a < len(xs) {
let m = names[xs[a]]
sc_sep(b, a == 0, " ")
buf_puts(b, "{\"name\": ")
jq_put(b, m)
let files = new []pointer
var i = 0
while i < len(g_mod_file) {
if (g_mod_name[i] == m) and not is_runtime_file(g_mod_file[i]) { push(files, g_mod_file[i]) }
i += 1
}
var pkg: pointer = null
if len(files) > 0 and len(pkg_of_file(files[0])) > 0 { pkg = pkg_of_file(files[0]) }
buf_puts(b, ", \"package\": ")
sc_str(b, pkg)
let ly = mod_layer(m)
buf_puts(b, ", \"layer\": ")
if len(ly) == 0 { buf_puts(b, "null") } else { jq_put(b, ly) }
let u = mod_find_uses(m)
buf_puts(b, ", \"uses\": ")
if u < 0 { buf_puts(b, "null") } else { cx_list(b, g_mu_list[u]) }
buf_puts(b, ", \"uses_at\": ")
if u < 0 { buf_puts(b, "null") } else { jq_put(b, `{g_mu_file[u]}:{itoa(g_mu_line[u])}`) }
buf_puts(b, ", \"friend\": ")
cx_friend(b, m)
buf_puts(b, ", \"files\": [")
let fx = cx_order(files)
i = 0
while i < len(fx) {
if i > 0 { buf_puts(b, ", ") }
jq_put(b, files[fx[i]])
i += 1
}
buf_puts(b, "]}")
a += 1
}
}
# null, or {"of": null} for a friend of every module, else {"of": [the modules]}
function cx_friend(b: Buf, m: pointer) -> void {
var all = false
var list: pointer = null
var i = 0
while i < len(g_mod_friends) {
if (g_mod_friends[i] == m) {
if len(g_fr_scope[i]) == 0 { all = true } else if list == null { list = g_fr_scope[i] } else { list = list + g_fr_scope[i] }
}
i += 1
}
if not all and list == null {
buf_puts(b, "null")
return
}
buf_puts(b, "{\"of\": ")
if all { buf_puts(b, "null") } else { cx_list(b, list) }
buf_puts(b, "}")
}
# ---- states, actions, reducers, dispatch ----------------------------------------------------------
# the records of one kind (2 a state, 3 an action) in prog
function cx_decls(b: Buf, uns: int) -> void {
let keys = new []pointer
var i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_COMP and d.uns == uns and d.s != null and cx_written(d) { push(keys, sc_key(sc_name(d.s), d.file, d.line)) } else { push(keys, null) }
i += 1
}
let xs = cx_order(keys)
i = 0
while i < len(xs) {
let d = prog[xs[i]]
sc_sep(b, i == 0, " ")
buf_puts(b, "{\"name\": ")
jq_put(b, sc_name(d.s))
buf_puts(b, ", ")
cx_module(b, d.file)
buf_puts(b, ", ")
cx_at(b, d)
buf_puts(b, "}")
i += 1
}
}
function cx_reducers(b: Buf) -> void {
let keys = new []pointer
var i = 0
while i < len(g_red_nodes) {
let f = g_red_nodes[i]
if cx_written(f) { push(keys, `{g_red_state[i]}\t{g_red_action[i]}\t{cx_at_key(f)}`) } else { push(keys, null) }
i += 1
}
let xs = cx_order(keys)
i = 0
while i < len(xs) {
let k = xs[i]
let f = g_red_nodes[k]
sc_sep(b, i == 0, " ")
buf_puts(b, "{\"state\": ")
jq_put(b, g_red_state[k])
buf_puts(b, ", \"action\": ")
jq_put(b, g_red_action[k])
buf_puts(b, ", ")
cx_module(b, f.file)
buf_puts(b, ", ")
cx_at(b, f)
buf_puts(b, "}")
i += 1
}
}
function cx_dispatch(b: Buf) -> void {
let keys = new []pointer
var i = 0
while i < len(g_dsp_at) {
let n = g_dsp_at[i]
if cx_written(n) { push(keys, cx_at_key(n)) } else { push(keys, null) }
i += 1
}
let xs = cx_order(keys)
i = 0
while i < len(xs) {
let n = g_dsp_at[xs[i]]
sc_sep(b, i == 0, " ")
buf_puts(b, "{\"action\": ")
jq_put(b, sc_name(g_dsp_names[xs[i]]))
buf_puts(b, ", ")
cx_module(b, n.file)
buf_puts(b, ", ")
cx_at(b, n)
buf_puts(b, "}")
i += 1
}
}
# ---- events ---------------------------------------------------------------------------------------
function cx_events(b: Buf) -> void {
let keys = new []pointer
var i = 0
while i < len(g_events) {
let e = g_events[i]
if e.s != null and cx_written(e) { push(keys, sc_key(sc_name(e.s), e.file, e.line)) } else { push(keys, null) }
i += 1
}
let ekeys = new []pointer # the emits, by place
i = 0
while i < len(g_cx_emits) {
if cx_written(g_cx_emits[i]) { push(ekeys, cx_at_key(g_cx_emits[i])) } else { push(ekeys, null) }
i += 1
}
let ex = cx_order(ekeys)
let hkeys = new []pointer # the listeners, by place
i = 0
while i < len(g_cx_h) {
if g_cx_hlisten[i] != null and cx_written(g_cx_h[i]) { push(hkeys, cx_at_key(g_cx_h[i])) } else { push(hkeys, null) }
i += 1
}
let hx = cx_order(hkeys)
let xs = cx_order(keys)
i = 0
while i < len(xs) {
let e = g_events[xs[i]]
sc_sep(b, i == 0, " ")
buf_puts(b, "{\"name\": ")
jq_put(b, sc_name(e.s))
buf_puts(b, ", ")
cx_module(b, e.file)
buf_puts(b, ", ")
cx_at(b, e)
buf_puts(b, ", \"cancellable\": ")
sc_bool(b, e.ival == 1)
buf_puts(b, ", \"net\": ")
sc_str(b, e.ty)
buf_puts(b, ", \"emits\": [")
var n = 0
var k = 0
while k < len(ex) {
let s = g_cx_emits[ex[k]]
if (s.s == e.s) {
if n > 0 { buf_puts(b, ", ") }
jq_put(b, cx_at_text(s))
n += 1
}
k += 1
}
buf_puts(b, "], \"listeners\": [")
n = 0
k = 0
while k < len(hx) {
let h = hx[k]
if (g_cx_hlisten[h].s == e.s) {
if n > 0 { buf_puts(b, ", ") }
buf_puts(b, "{\"handler\": ")
jq_put(b, g_cx_hname[h])
buf_puts(b, ", ")
cx_module(b, g_cx_h[h].file)
buf_puts(b, ", ")
cx_at(b, g_cx_h[h])
buf_puts(b, "}")
n += 1
}
k += 1
}
buf_puts(b, "]}")
i += 1
}
}
# ---- ports and binds ------------------------------------------------------------------------------
function cx_ports(b: Buf) -> void {
let keys = new []pointer
var i = 0
while i < len(g_pt_name) {
let c = g_pt_comp[i]
if cx_written(c) { push(keys, sc_key(g_pt_name[i], c.file, c.line)) } else { push(keys, null) }
i += 1
}
let xs = cx_order(keys)
i = 0
while i < len(xs) {
let k = xs[i]
let c = g_pt_comp[k]
sc_sep(b, i == 0, " ")
buf_puts(b, "{\"name\": ")
jq_put(b, g_pt_name[k])
buf_puts(b, ", ")
cx_module(b, c.file)
buf_puts(b, ", ")
cx_at(b, c)
buf_puts(b, ", \"bound\": ")
sc_bool(b, g_pt_bind[k] != null)
buf_puts(b, ", \"members\": [")
var f = 0
while f < len(c.kids) {
let fi = c.kids[f]
if f > 0 { buf_puts(b, ", ") }
buf_puts(b, "{\"name\": ")
jq_put(b, fi.s)
buf_puts(b, ", \"type\": ")
jq_put(b, sc_ty(fi.ty))
buf_puts(b, ", \"default\": ")
sc_str(b, sc_text(fi.file, fi.sp0, fi.sp1))
buf_puts(b, ", \"required\": ")
sc_bool(b, fi.a == null)
buf_puts(b, "}")
f += 1
}
buf_puts(b, "]}")
i += 1
}
}
# one row per member a bind gives
function cx_binds(b: Buf) -> void {
let keys = new []pointer
let rows = new []Node
let ports = new []pointer
var i = 0
while i < len(g_bd) {
let bd = g_bd[i]
if cx_written(bd) and bd.a != null {
var k = 0
while k < len(bd.a.kids) {
let fi = bd.a.kids[k]
if fi.kind == E_FINIT {
push(keys, cx_at_key(fi))
push(rows, fi)
push(ports, bd.s)
}
k += 1
}
}
i += 1
}
let xs = cx_order(keys)
i = 0
while i < len(xs) {
let fi = rows[xs[i]]
sc_sep(b, i == 0, " ")
buf_puts(b, "{\"port\": ")
jq_put(b, ports[xs[i]])
buf_puts(b, ", \"member\": ")
jq_put(b, fi.s)
buf_puts(b, ", \"fn\": ")
if fi.a != null and fi.a.kind == E_FNREF and fi.a.pos >= 0 { jq_put(b, sc_name(fi.a.s)) } else { buf_puts(b, "null") }
buf_puts(b, ", \"value\": ")
sc_str(b, sc_text(fi.file, fi.sp0, fi.sp1))
buf_puts(b, ", ")
cx_module(b, fi.file)
buf_puts(b, ", ")
cx_at(b, fi)
buf_puts(b, "}")
i += 1
}
}

View file

@ -0,0 +1,415 @@
# emit_schema_code_game.ludic — the "code" section's handlers, models, prefabs, scenes and fn_refs
# (emit_schema_code.ludic says the shape of the rest).
#
# handlers name (as written: a scene's without its scene), module, at, phase (null for a hook),
# hook (null, "On", "OnSpawn", "OnDespawn", "OnAttach", "OnDetach", "OnEnable",
# "OnDisable", "OnStart", "OnQuit"), target (the hook's event, model or property),
# public (@Public), net ("server" for @Server, "predicted" for @Predicted, else null),
# queries (null, or {"these": [{"property", "filter"}], "on": model or null}), scene, layer
# models name, module, at, owned (@Owned), properties [{"name", "sync"}]
# prefabs name, model, module, at, components [{"property", "fields": [{"name", "value"}]}]
# scenes name, module, at, start, public, shows, lasts (as written) and then, loads (the scene it
# goes on to), enter, exit (has one), layers
# fn_refs every `fn name` written, in code or data: fn, module, at, and the slot it fills -
# slot_kind "registry" (slot "Registry.field", entry its key), "port" (a bind's
# "Port.member"), "port_default", "default" (a record field's), "record" (a `new` or a
# spawn's "Type.field"), "event" (an emit's "Event.field"), "arg" ("callee(i)", the i-th
# argument written), "assign" (`s.tick = fn f`: "s.tick"), "let" (the name) or "value"
# (anywhere else, slot null)
# ---- handlers -------------------------------------------------------------------------------------
function cx_handlers(b: Buf) -> void {
let keys = new []pointer
var i = 0
while i < len(g_cx_h) {
let h = g_cx_h[i]
if cx_written(h) { push(keys, sc_key(g_cx_hname[i], h.file, h.line)) } else { push(keys, null) }
i += 1
}
let xs = cx_order(keys)
i = 0
while i < len(xs) {
let k = xs[i]
let h = g_cx_h[k]
let hook = g_cx_hhook[k]
sc_sep(b, i == 0, " ")
buf_puts(b, "{\"name\": ")
jq_put(b, g_cx_hname[k])
buf_puts(b, ", ")
cx_module(b, h.file)
buf_puts(b, ", ")
cx_at(b, h)
buf_puts(b, ", \"phase\": ")
if hook == null or (hook == "OnStart") or (hook == "OnQuit") { sc_str(b, h.ty) } else { buf_puts(b, "null") }
buf_puts(b, ", \"hook\": ")
sc_str(b, hook)
buf_puts(b, ", \"target\": ")
sc_str(b, g_cx_htarget[k])
buf_puts(b, ", \"public\": ")
sc_bool(b, g_cx_hpub[k])
buf_puts(b, ", \"net\": ")
if h.ival == 1 { jq_put(b, "server") } else if h.ival == 2 { jq_put(b, "predicted") } else { buf_puts(b, "null") }
buf_puts(b, ", \"queries\": ")
cx_queries(b, g_cx_hq[k])
buf_puts(b, ", \"scene\": ")
sc_str(b, g_cx_hscene[k])
buf_puts(b, ", \"layer\": ")
sc_str(b, g_cx_hlayer[k])
buf_puts(b, "}")
i += 1
}
}
# @Queries(these: [P {filter}, ...], on: M): its terms, a filter as written (parse_queries_anno)
function cx_queries(b: Buf, q: Node) -> void {
if q == null or q.c == null {
buf_puts(b, "null")
return
}
buf_puts(b, "{\"these\": [")
var on: pointer = null
var n = 0
var i = 0
while i < len(q.c.kids) {
let t = q.c.kids[i]
if t.ival == 1 {
on = t.s
} else {
if n > 0 { buf_puts(b, ", ") }
buf_puts(b, "{\"property\": ")
jq_put(b, t.s)
buf_puts(b, ", \"filter\": ")
sc_str(b, sc_text(t.file, t.sp0, t.sp1))
buf_puts(b, "}")
n += 1
}
i += 1
}
buf_puts(b, "], \"on\": ")
sc_str(b, on)
buf_puts(b, "}")
}
# ---- models and prefabs ---------------------------------------------------------------------------
function cx_models(b: Buf) -> void {
let keys = new []pointer
var i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_ARCH and d.s != null and cx_written(d) { push(keys, sc_key(sc_name(d.s), d.file, d.line)) } else { push(keys, null) }
i += 1
}
let xs = cx_order(keys)
i = 0
while i < len(xs) {
let d = prog[xs[i]]
sc_sep(b, i == 0, " ")
buf_puts(b, "{\"name\": ")
jq_put(b, sc_name(d.s))
buf_puts(b, ", ")
cx_module(b, d.file)
buf_puts(b, ", ")
cx_at(b, d)
buf_puts(b, ", \"owned\": ")
sc_bool(b, d.ival == 1)
buf_puts(b, ", \"properties\": [")
var k = 0
while k < len(d.kids) {
if k > 0 { buf_puts(b, ", ") }
buf_puts(b, "{\"name\": ")
jq_put(b, sc_name(d.kids[k].s))
buf_puts(b, ", \"sync\": ")
sc_bool(b, d.kids[k].ival == 1)
buf_puts(b, "}")
k += 1
}
buf_puts(b, "]}")
i += 1
}
}
function cx_prefabs(b: Buf) -> void {
let keys = new []pointer
var i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_PREFAB and d.s != null and cx_written(d) { push(keys, sc_key(sc_name(d.s), d.file, d.line)) } else { push(keys, null) }
i += 1
}
let xs = cx_order(keys)
i = 0
while i < len(xs) {
let d = prog[xs[i]]
sc_sep(b, i == 0, " ")
buf_puts(b, "{\"name\": ")
jq_put(b, sc_name(d.s))
buf_puts(b, ", \"model\": ")
sc_str(b, sc_name(d.ty))
buf_puts(b, ", ")
cx_module(b, d.file)
buf_puts(b, ", ")
cx_at(b, d)
buf_puts(b, ", \"components\": [")
var k = 0
while k < len(d.kids) {
let ci = d.kids[k]
if k > 0 { buf_puts(b, ", ") }
buf_puts(b, "{\"property\": ")
jq_put(b, sc_name(ci.s))
buf_puts(b, ", \"fields\": [")
var f = 0
while ci.a != null and f < len(ci.a.kids) {
let fi = ci.a.kids[f]
if f > 0 { buf_puts(b, ", ") }
buf_puts(b, "{\"name\": ")
jq_put(b, fi.s)
buf_puts(b, ", \"value\": ")
sc_str(b, sc_text(fi.file, fi.sp0, fi.sp1))
buf_puts(b, "}")
f += 1
}
buf_puts(b, "]}")
k += 1
}
buf_puts(b, "]}")
i += 1
}
}
# ---- scenes ---------------------------------------------------------------------------------------
function cx_scenes(b: Buf) -> void {
let keys = new []pointer
var i = 0
while i < len(g_scenes) {
let n = g_scenes[i]
if cx_written(n) and i < len(g_cx_slayers) { push(keys, sc_key(sc_name(n.s), n.file, n.line)) } else { push(keys, null) }
i += 1
}
let xs = cx_order(keys)
i = 0
while i < len(xs) {
let k = xs[i]
let n = g_scenes[k]
sc_sep(b, i == 0, " ")
buf_puts(b, "{\"name\": ")
jq_put(b, sc_name(n.s))
buf_puts(b, ", ")
cx_module(b, n.file)
buf_puts(b, ", ")
cx_at(b, n)
buf_puts(b, ", \"start\": ")
sc_bool(b, n.ival == g_start_scene)
buf_puts(b, ", \"public\": ")
sc_bool(b, g_cx_spub[k])
buf_puts(b, ", \"shows\": ")
sc_str(b, n.ty)
buf_puts(b, ", \"lasts\": ")
sc_str(b, sc_text(n.file, n.sp0, n.sp1))
buf_puts(b, ", \"then\": ")
sc_str(b, g_cx_sthen[k])
buf_puts(b, ", \"loads\": ")
sc_str(b, g_cx_sloads[k])
buf_puts(b, ", \"enter\": ")
sc_bool(b, n.a != null)
buf_puts(b, ", \"exit\": ")
sc_bool(b, n.b != null)
buf_puts(b, ", \"layers\": ")
cx_list(b, g_cx_slayers[k])
buf_puts(b, "}")
i += 1
}
}
# ---- fn refs --------------------------------------------------------------------------------------
# the walk's findings: each `fn name` node met, with the slot it was met in (the first meeting wins)
var g_cx_wn: []Node = new []Node
var g_cx_wkind: []pointer = new []pointer
var g_cx_wslot: []pointer = new []pointer
var g_cx_wentry: []pointer = new []pointer
function cx_fn_refs(b: Buf) -> void {
cx_walk_all()
let keys = new []pointer
var i = 0
while i < len(g_cx_fnrefs) {
let n = g_cx_fnrefs[i]
if cx_written_or_data(n) and n.s != null { push(keys, `{cx_at_key(n)}\t{n.s}`) } else { push(keys, null) }
i += 1
}
let xs = cx_order(keys)
i = 0
var first = true
var last: pointer = null
while i < len(xs) {
let n = g_cx_fnrefs[xs[i]]
let key = keys[xs[i]]
if last == null or not (key == last) { # a node read twice is one site
last = key
var w = 0
while w < len(g_cx_wn) and g_cx_wn[w] != n { w += 1 }
sc_sep(b, first, " ")
first = false
buf_puts(b, "{\"fn\": ")
jq_put(b, sc_name(n.s))
buf_puts(b, ", ")
cx_module(b, n.file)
buf_puts(b, ", ")
cx_at(b, n)
buf_puts(b, ", \"slot_kind\": ")
if w < len(g_cx_wn) { jq_put(b, g_cx_wkind[w]) } else { jq_put(b, "value") }
buf_puts(b, ", \"slot\": ")
if w < len(g_cx_wn) { sc_str(b, g_cx_wslot[w]) } else { buf_puts(b, "null") }
buf_puts(b, ", \"entry\": ")
if w < len(g_cx_wn) { sc_str(b, g_cx_wentry[w]) } else { buf_puts(b, "null") }
buf_puts(b, "}")
}
i += 1
}
}
# every place a `fn name` can be written, the narrowest slots first: a registry's rows, the binds, the
# ports' defaults, then the program's declarations and the bodies kept outside them
function cx_walk_all() -> void {
var i = 0
while i < len(g_df_rec) {
cx_walk_rec(g_df_rec[i], "registry", g_df_reg[i], g_df_key[i], 0)
i += 1
}
i = 0
while i < len(g_bd) {
if g_bd[i].a != null { cx_walk_rec(g_bd[i].a, "port", g_bd[i].s, null, 0) }
i += 1
}
i = 0
while i < len(g_pt_comp) {
let c = g_pt_comp[i]
var f = 0
while f < len(c.kids) {
cx_walk(c.kids[f].a, "port_default", `{g_pt_name[i]}.{c.kids[f].s}`, null, 0)
f += 1
}
i += 1
}
i = 0
while i < len(prog) {
if prog[i].file == null or not is_runtime_file(prog[i].file) { cx_walk(prog[i], "value", null, null, 0) }
i += 1
}
cx_walk_list(g_tests)
cx_walk_list(g_onlisten)
cx_walk_list(g_onspawn)
cx_walk_list(g_ondespawn)
cx_walk_list(g_onattach)
cx_walk_list(g_ondetach)
cx_walk_list(g_onenable)
cx_walk_list(g_ondisable)
cx_walk_list(g_scenes)
}
function cx_walk_list(xs: []Node) -> void {
var i = 0
while i < len(xs) {
cx_walk(xs[i], "value", null, null, 0)
i += 1
}
}
# a record literal's values, each in the slot owner.field
function cx_walk_rec(rec: Node, kind: pointer, owner: pointer, entry: pointer, depth: int) -> void {
if rec == null or depth > 400 { return }
var i = 0
while i < len(rec.kids) {
let fi = rec.kids[i]
if fi.kind == E_FINIT { cx_walk(fi.a, kind, `{owner}.{fi.s}`, entry, depth + 1) } else { cx_walk(fi, "value", null, null, depth + 1) }
i += 1
}
}
function cx_callee(c: Node) -> pointer {
if c == null { return null }
if c.kind == E_ID and c.s != null { return sc_name(c.s) }
if c.kind == E_MEMBER and c.a != null and c.a.kind == E_ID and c.a.s != null and c.s != null { return `{c.a.s}.{c.s}` }
return null
}
function cx_walk(n: Node, kind: pointer, slot: pointer, entry: pointer, depth: int) -> void {
if n == null or depth > 400 { return }
let k = n.kind
let d = depth + 1
if k == E_FNREF {
push(g_cx_wn, n)
push(g_cx_wkind, kind)
push(g_cx_wslot, slot)
push(g_cx_wentry, entry)
return
}
if k == E_NEW and n.a != null and n.a.kind == E_REC and n.s != null {
cx_walk_rec(n.a, "record", sc_name(n.s), null, d)
cx_walk(n.b, "value", null, null, d)
return
}
if k == S_EMIT and n.a != null and n.s != null {
cx_walk_rec(n.a, "event", sc_name(n.s), null, d)
return
}
if (k == S_SPAWN or k == N_PREFAB) {
var i = 0
while i < len(n.kids) {
let ci = n.kids[i]
if ci.kind == E_FINIT and ci.a != null and ci.a.kind == E_REC { cx_walk_rec(ci.a, "record", sc_name(ci.s), null, d) } else { cx_walk(ci, "value", null, null, d) }
i += 1
}
return
}
if k == E_CALL {
let callee = cx_callee(n.a)
cx_walk(n.a, "value", null, null, d)
var i = 0
var arg = 0
while i < len(n.kids) {
let a = n.kids[i]
if a != null and a.pos >= 0 {
if callee != null { cx_walk(a, "arg", `{callee}({itoa(arg)})`, null, d) } else { cx_walk(a, "value", null, null, d) }
arg += 1
} else {
cx_walk(a, "value", null, null, d)
}
i += 1
}
return
}
if k == N_COMP {
var i = 0
while i < len(n.kids) {
if n.s != null and n.kids[i].s != null { cx_walk(n.kids[i].a, "default", `{sc_name(n.s)}.{n.kids[i].s}`, null, d) }
i += 1
}
return
}
if k == S_ASSIGN and n.b != null and n.b.kind == E_FNREF { # `s.tick = fn f`: the slot as written
cx_walk(n.b, "assign", cx_callee(n.a), null, d)
return
}
if k == S_LET and n.a != null and n.a.kind == E_FNREF and n.s != null {
cx_walk(n.a, "let", n.s, null, d)
return
}
if k == E_LIST { # a list of fn values fills the slot it is in
var i = 0
while i < len(n.kids) {
cx_walk(n.kids[i], kind, slot, entry, d)
i += 1
}
return
}
if k == N_SYS { # its .b and .c are its layer's and scene's tags
cx_walk(n.a, "value", null, null, d)
return
}
if k == N_SCENE {
cx_walk(n.a, "value", null, null, d)
cx_walk(n.b, "value", null, null, d)
return
}
cx_walk(n.a, "value", null, null, d)
cx_walk(n.b, "value", null, null, d)
cx_walk(n.c, "value", null, null, d)
var i = 0
while i < len(n.kids) {
cx_walk(n.kids[i], "value", null, null, d)
i += 1
}
}

View file

@ -304,6 +304,7 @@ const TPL_HOLE: int = 92 # on the callee of e
# non-cancellable event yields 0. # non-cancellable event yields 0.
function parse_emit() -> Node { function parse_emit() -> Node {
pi += 1; let n = node(S_EMIT); n.s = eat_id() pi += 1; let n = node(S_EMIT); n.s = eat_id()
cx_emit(n) # the schema's code map (emit_schema_code.ludic)
let r = node(E_REC) let r = node(E_REC)
eat_op("("); skipnl() eat_op("("); skipnl()
while not is_op(")") { while not is_op(")") {
@ -344,7 +345,7 @@ function p_primary() -> Node {
if (t.text == "spawn") { return parse_spawn() } # spawn as an expression: the new entity if (t.text == "spawn") { return parse_spawn() } # spawn as an expression: the new entity
# fn name — a top-level function as a value, for a worker entry point (Job.parallel_for) # fn name — a top-level function as a value, for a worker entry point (Job.parallel_for)
# `fn` stays an ordinary name before an operator word (`p < fn and ...`) or at the end of a line # `fn` stays an ordinary name before an operator word (`p < fn and ...`) or at the end of a line
if (t.text == "fn") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 1].line == t.line) and (toks[pi + 1].text != "and") and (toks[pi + 1].text != "or") and (toks[pi + 1].text != "not") and (toks[pi + 1].text != "in") and (toks[pi + 1].text != "is") { pi += 1; let n = node(E_FNREF); n.line = t.line; n.s = eat_id(); return n } if (t.text == "fn") and (toks[pi + 1].kind == TK_ID) and (toks[pi + 1].line == t.line) and (toks[pi + 1].text != "and") and (toks[pi + 1].text != "or") and (toks[pi + 1].text != "not") and (toks[pi + 1].text != "in") and (toks[pi + 1].text != "is") { pi += 1; let n = node(E_FNREF); n.line = t.line; n.s = eat_id(); cx_fnref(n); return n }
# try EXPR else { ... } — evaluate a fallible (result-typed) expression; on # try EXPR else { ... } — evaluate a fallible (result-typed) expression; on
# `ok` the whole expression is its payload, on `err` the else block runs (with # `ok` the whole expression is its payload, on `err` the else block runs (with
# the message bound to `error`) and its trailing expression is the fallback. # the message bound to `error`) and its trailing expression is the fallback.
@ -954,6 +955,30 @@ function already_loaded(full: pointer) -> bool {
return false return false
} }
# a handler's hook, for the schema's code map: the attribute that made it one, and what it names
function decl_hook(ev: pointer, sp: pointer, dsp: pointer, att: pointer, det: pointer, en: pointer, dis: pointer, ph: pointer) -> pointer {
if ev != null { return "On" }
if sp != null { return "OnSpawn" }
if dsp != null { return "OnDespawn" }
if att != null { return "OnAttach" }
if det != null { return "OnDetach" }
if en != null { return "OnEnable" }
if dis != null { return "OnDisable" }
if ph != null {
if (ph == "Start") { return "OnStart" }
return "OnQuit"
}
return null
}
function decl_hook_target(ev: pointer, sp: pointer, dsp: pointer, att: pointer, det: pointer, en: pointer, dis: pointer) -> pointer {
if ev != null { return ev }
if sp != null { return sp }
if dsp != null { return dsp }
if att != null { return att }
if det != null { return det }
if en != null { return en }
return dis
}
# parse one top-level declaration (or resolve an import) into `prog`. # parse one top-level declaration (or resolve an import) into `prog`.
# Modifiers are `@annotations` in front of the declaration: `@export`, `@edge`, # Modifiers are `@annotations` in front of the declaration: `@export`, `@edge`,
# `@pure`, `@deterministic`, … — one channel, not a zoo of prefix keywords. # `@pure`, `@deterministic`, … — one channel, not a zoo of prefix keywords.
@ -1108,7 +1133,9 @@ function parse_one_decl() -> void {
if is_det { push(g_det_names, h.s) } if is_det { push(g_det_names, h.s) }
if g_aok_pending { push(g_alloc_ok_names, h.s); g_aok_pending = false } if g_aok_pending { push(g_alloc_ok_names, h.s); g_aok_pending = false }
if (role != null) { if (role == "server") { h.ival = 1 } else { h.ival = 2 } } # N5: @Server=1 / @Predicted=2 if (role != null) { if (role == "server") { h.ival = 1 } else { h.ival = 2 } } # N5: @Server=1 / @Predicted=2
if (on_event != null) { register_onlisten(on_event, h.a); return } # @On(Event) listener cx_handler(h, h.s, decl_hook(on_event, onspawn_model, ondespawn_model, onattach_prop, ondetach_prop, onenable_prop, ondisable_prop, hook_phase),
decl_hook_target(on_event, onspawn_model, ondespawn_model, onattach_prop, ondetach_prop, onenable_prop, ondisable_prop), qspec, is_public, null, null) # the schema's code map
if (on_event != null) { register_onlisten(on_event, h.a); cx_listened(); return } # @On(Event) listener
if (onspawn_model != null) { if (onspawn_model != null) {
register_onspawn(onspawn_model, h.a) # spawn hook register_onspawn(onspawn_model, h.a) # spawn hook
if is_public { ensure_event(`model_{onspawn_model}_spawn`, false) } # @Public -> model_<M>_spawn if is_public { ensure_event(`model_{onspawn_model}_spawn`, false) } # @Public -> model_<M>_spawn

View file

@ -189,7 +189,7 @@ function parse_queries_anno() -> Node {
let pname = eat_id() let pname = eat_id()
let v = node(E_ID); v.s = pname; push(qn.kids, v) # binding var = property name let v = node(E_ID); v.s = pname; push(qn.kids, v) # binding var = property name
let t = node(E_ID); t.s = pname; t.ival = 0; push(terms.kids, t) let t = node(E_ID); t.s = pname; t.ival = 0; push(terms.kids, t)
if is_op("{") { pi += 1; let ce = expr(); eat_op("}") if is_op("{") { pi += 1; let f0 = pi; let ce = expr(); t.sp0 = toks[f0].off; t.sp1 = toks[pi - 1].oend; eat_op("}")
let cb = node(E_ID); cb.s = pname; wh = mk_and(wh, qualify_fields(ce, cb)) } let cb = node(E_ID); cb.s = pname; wh = mk_and(wh, qualify_fields(ce, cb)) }
if is_op(",") { pi += 1 } if is_op(",") { pi += 1 }
skipnl() skipnl()
@ -249,12 +249,13 @@ function parse_scene() -> void {
while is_id("start") or is_id("public") or is_id("shows") or is_id("lasts") or is_id("loads") { while is_id("start") or is_id("public") or is_id("shows") or is_id("lasts") or is_id("loads") {
if is_id("start") { pi += 1; g_start_scene = g_scene_count } if is_id("start") { pi += 1; g_start_scene = g_scene_count }
else if is_id("shows") { pi += 1; n.ty = eat_id() } # shows Menu: the engine opens, renders and closes it else if is_id("shows") { pi += 1; n.ty = eat_id() } # shows Menu: the engine opens, renders and closes it
else if is_id("lasts") { pi += 1; lasts = expr(); if not is_id("then") { perr("lasts N needs `then Scene`") }; pi += 1; then_scene = eat_id() } else if is_id("lasts") { pi += 1; let l0 = pi; lasts = expr(); n.sp0 = toks[l0].off; n.sp1 = toks[pi - 1].oend; if not is_id("then") { perr("lasts N needs `then Scene`") }; pi += 1; then_scene = eat_id() }
else if is_id("loads") { pi += 1; if not is_id("then") { perr("loads needs `then Scene`") }; pi += 1; loads_then = eat_id(); g_uses_atlas = true } else if is_id("loads") { pi += 1; if not is_id("then") { perr("loads needs `then Scene`") }; pi += 1; loads_then = eat_id(); g_uses_atlas = true }
else { pi += 1; is_pub = true } else { pi += 1; is_pub = true }
} }
g_scene_count += 1 g_scene_count += 1
if is_pub { ensure_event_empty(`scene_{n.s}_enter`); ensure_event_empty(`scene_{n.s}_exit`) } if is_pub { ensure_event_empty(`scene_{n.s}_enter`); ensure_event_empty(`scene_{n.s}_exit`) }
var layers = "," # the schema's code map (emit_schema_code.ludic)
skipnl(); eat_op("{") skipnl(); eat_op("{")
while true { while true {
skipnl(); if is_op("}") { break } skipnl(); if is_op("}") { break }
@ -273,6 +274,7 @@ function parse_scene() -> void {
} }
if is_id("layer") { # layer Name [public] { handler .. } if is_id("layer") { # layer Name [public] { handler .. }
pi += 1; let lname = eat_id() pi += 1; let lname = eat_id()
layers = layers + lname + ","
if is_id("public") { pi += 1; ensure_event_empty(`layer_{lname}_show`); ensure_event_empty(`layer_{lname}_hide`) } if is_id("public") { pi += 1; ensure_event_empty(`layer_{lname}_show`); ensure_event_empty(`layer_{lname}_hide`) }
skipnl(); eat_op("{") skipnl(); eat_op("{")
let ltag = node(E_ID); ltag.s = lname # the layer name, tagged onto each handler let ltag = node(E_ID); ltag.s = lname # the layer name, tagged onto each handler
@ -287,6 +289,7 @@ function parse_scene() -> void {
} }
if not is_id("handler") { perr("expected 'handler' in layer") } if not is_id("handler") { perr("expected 'handler' in layer") }
let h = parse_system() # N_SYS: .s name, .ty phase, .a body let h = parse_system() # N_SYS: .s name, .ty phase, .a body
cx_handler(h, h.s, null, null, qspec, false, n.s, lname)
if (qspec != null) { qspec.a = h.a; let wrap = node(N_BLOCK); push(wrap.kids, qspec); h.a = wrap } if (qspec != null) { qspec.a = h.a; let wrap = node(N_BLOCK); push(wrap.kids, qspec); h.a = wrap }
h.s = `{n.s}_{h.s}` # scene-qualified: two scenes may both have a `Draw` handler h.s = `{n.s}_{h.s}` # scene-qualified: two scenes may both have a `Draw` handler
h.c = n # tag the owning scene (null = global) h.c = n # tag the owning scene (null = global)
@ -302,6 +305,7 @@ function parse_scene() -> void {
eat_op("}") eat_op("}")
if (lasts != null) { scene_add_timer(n, lasts, then_scene) } if (lasts != null) { scene_add_timer(n, lasts, then_scene) }
if (loads_then != null) { scene_add_loader(n, loads_then) } if (loads_then != null) { scene_add_loader(n, loads_then) }
cx_scene(layers, then_scene, loads_then, is_pub)
push(g_scenes, n) push(g_scenes, n)
} }

View file

@ -0,0 +1,122 @@
# stdin_file.ludic — `--stdin-file <path>`: an unsaved buffer checked in place of its file. The text
# on stdin is read once, whole, and read_file (io.ludic) hands a copy of it back wherever the program
# opens <path> - an import, a barrel, a component's .xml / .lss, an .lres - so diagnostics follow
# typing without a save. Two paths are the same file when they are after normalise_path: separators
# made '/', a relative path put under $PWD, `.` / `..` / `//` folded (and case folded on Windows).
var g_sf_path: pointer = null # <path> as given: what a warning about it names
var g_sf_norm: pointer = null # ... normalised, what each open is compared against
var g_sf_base: pointer = null # ... its last part, the cheap test before normalising an open
var g_sf_text: pointer = null # stdin, NUL-terminated
var g_sf_len: int = 0
var g_sf_served: bool = false # the program opened <path> at least once
# the flag: remember the path and take stdin now, once (an empty stdin is an empty file)
function stdin_file_set(path: pointer) -> void {
g_sf_path = path
g_sf_norm = normalise_path(path)
g_sf_base = base_name(g_sf_norm)
var cap = 4096
var buf = bytes(cap + 1)
var n = 0
var c = read_char()
while c >= 0 {
if n == cap { cap *= 2; buf = resize(buf, cap + 1) }
buf[n] = c
n += 1
c = read_char()
}
buf[n] = 0
g_sf_text = buf
g_sf_len = n
}
# read_file's first question: the buffer's text (a copy, as a read of the file would be) when
# `path` is <path>, else null
function stdin_file_serve(path: pointer) -> pointer {
if g_sf_text == null { return null }
if not sf_tail_is(path, g_sf_base) { return null }
if normalise_path(path) != g_sf_norm { return null }
let out = bytes(g_sf_len + 1)
var i = 0
while i < g_sf_len { out[i] = g_sf_text[i]; i += 1 }
out[g_sf_len] = 0
g_sf_served = true
return out
}
# a buffer for a file the program never opened was checked against nothing: say so rather than
# report the saved files clean
function stdin_file_unreached() -> void {
if g_sf_text == null or g_sf_served { return }
pwarn_at(g_sf_path, 1, 0, `--stdin-file {g_sf_path}: the program does not reach this file, so the buffer was not checked`)
}
# does `path`'s last part equal `base`? (no allocation: every read_file asks)
function sf_tail_is(path: pointer, base: pointer) -> bool {
var n = 0
while path[n] != 0 { n += 1 }
let m = len(base)
if n < m { return false }
if n > m and not is_sep(path[n - m - 1]) { return false }
var i = 0
while i < m {
if sf_fold(path[n - m + i]) != sf_fold(base[i]) { return false }
i += 1
}
return true
}
function sf_fold(c: int) -> int {
if c >= 'A' and c <= 'Z' and host_is_windows() { return c + 32 }
return c
}
# one spelling per file: '/' separators, absolute (a relative path is under $PWD, the directory the
# compiler was started in), no `.`, `..` or empty parts; lower case on Windows
function normalise_path(p: pointer) -> pointer {
var s = fwd_slashes(p)
let abs = (len(s) > 0 and s[0] == '/') or (len(s) > 1 and s[1] == ':')
if not abs {
let cwd = getenv("PWD")
if cwd != null and len(cwd) > 0 { s = fwd_slashes(cwd) + "/" + s }
}
let parts = new []pointer # the first np are the path so far
var np = 0
var start = 0
var i = 0
let n = len(s)
while i <= n {
if i == n or s[i] == '/' {
let part = s[start..i]
if part == ".." and np > 0 and parts[np - 1] != ".." { np -= 1 }
else if part != "" and part != "." {
if np < len(parts) { parts[np] = part } else { push(parts, part) }
np += 1
}
start = i + 1
}
i += 1
}
var out = ""
if len(s) > 0 and s[0] == '/' { out = "/" }
i = 0
while i < np {
if i > 0 { out = out + "/" }
out = out + parts[i]
i += 1
}
if host_is_windows() { out = sf_lower(out) }
return out
}
function sf_lower(s: pointer) -> pointer {
let n = len(s)
let out = bytes(n + 1)
var i = 0
while i < n {
var c = s[i]
if c >= 'A' and c <= 'Z' { c += 32 }
out[i] = c
i += 1
}
out[n] = 0
return out
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -163,6 +163,7 @@ entry {
else if a == "--diagnostics=json" { g_diag_json = true } # every error, as JSON on stdout (diag.ludic) else if a == "--diagnostics=json" { g_diag_json = true } # every error, as JSON on stdout (diag.ludic)
else if a == "--diagnostics=text" { g_diag_json = false } else if a == "--diagnostics=text" { g_diag_json = false }
else if a == "--emit-syntax" { syntax_emit() } # the language's vocabulary as JSON (frontend/vocab.ludic) else if a == "--emit-syntax" { syntax_emit() } # the language's vocabulary as JSON (frontend/vocab.ludic)
else if a == "--stdin-file" { ai += 1; if ai < arg_count() { stdin_file_set(arg(ai)) } } # stdin stands for that file (stdin_file.ludic)
else if a == "--emit-schema" { ai += 1; if ai < arg_count() { g_schema_out = arg(ai) } } # emit_schema.ludic else if a == "--emit-schema" { ai += 1; if ai < arg_count() { g_schema_out = arg(ai) } } # emit_schema.ludic
else if a == "--globals" { g_allow_globals = true } else if a == "--globals" { g_allow_globals = true }
else if a == "--migrate-state" { # 0.S2: write the plan ludic migrate state applies else if a == "--migrate-state" { # 0.S2: write the plan ludic migrate state applies
@ -263,10 +264,12 @@ entry {
if g_check_only { if g_check_only {
pm_check_maps() # every map's tables against their records (permap_check.ludic) pm_check_maps() # every map's tables against their records (permap_check.ludic)
emit_program() emit_program()
stdin_file_unreached() # --stdin-file naming a file nothing opened
if g_dg_errors > 0 { diag_exit(1) } if g_dg_errors > 0 { diag_exit(1) }
diag_exit(0) diag_exit(0)
} }
emit_program() emit_program()
stdin_file_unreached()
if g_dg_errors > 0 { diag_exit(1) } # errors a pass went on from (diag.ludic) if g_dg_errors > 0 { diag_exit(1) } # errors a pass went on from (diag.ludic)
deps_flush() # LUDIC_DEPS=<file>: the module graph (ludic deps) deps_flush() # LUDIC_DEPS=<file>: the module graph (ludic deps)

View file

@ -2,6 +2,8 @@
# The compiler reads one .ludic file whole and emits LLVM IR text to stdout. # The compiler reads one .ludic file whole and emits LLVM IR text to stdout.
function read_file(path: pointer) -> pointer { function read_file(path: pointer) -> pointer {
let unsaved = stdin_file_serve(path) # --stdin-file: the buffer stands for its file (stdin_file.ludic)
if unsaved != null { return unsaved }
let f = file_open(path, "rb") let f = file_open(path, "rb")
if (f == null) { return null } if (f == null) { return null }
file_seek(f, 0, 2) file_seek(f, 0, 2)

View file

@ -40,6 +40,7 @@ program LudicDev {
import "assets.ludic" import "assets.ludic"
import "release.ludic" import "release.ludic"
import "pkg.ludic" import "pkg.ludic"
import "pkg_lock.ludic"
import "pkg_test.ludic" import "pkg_test.ludic"
function usage() -> void { function usage() -> void {

View file

@ -25,6 +25,8 @@ program Ludic {
import "split.ludic" import "split.ludic"
import "project.ludic" import "project.ludic"
import "pkg.ludic" import "pkg.ludic"
import "pkg_lock.ludic"
import "pkg_remove.ludic"
import "assets.ludic" import "assets.ludic"
import "packignore.ludic" import "packignore.ludic"
import "pack.ludic" import "pack.ludic"
@ -49,6 +51,7 @@ program Ludic {
print(" compile to build/<name> (a native binary, nothing to ship beside it)") print(" compile to build/<name> (a native binary, nothing to ship beside it)")
print(" build [file] --check only check it: types, modules, uses and ports; nothing emitted") print(" build [file] --check only check it: types, modules, uses and ports; nothing emitted")
print(" build [file] --check --diagnostics=json every error as a JSON array on stdout") print(" build [file] --check --diagnostics=json every error as a JSON array on stdout")
print(" build [file] --check --stdin-file PATH stdin is PATH's unsaved text (an editor's buffer)")
print(" test [file|dir...] [-j N] [-v] [--test NAME]") print(" test [file|dir...] [-j N] [-v] [--test NAME]")
print(" compile and run the project's tests (-v: every test's line)") print(" compile and run the project's tests (-v: every test's line)")
print(" deps [file] [--graph|--dot|--writes|--uses MOD|--check F|--baseline F]") print(" deps [file] [--graph|--dot|--writes|--uses MOD|--check F|--baseline F]")
@ -60,7 +63,9 @@ program Ludic {
print("") print("")
print("packages:") print("packages:")
print(" add <module>[@version] add a dependency, then resolve + fetch + link") print(" add <module>[@version] add a dependency, then resolve + fetch + link")
print(" get resolve every package.ludic dependency and write the lock") print(" remove <module> drop a dependency: its require, what only it locked, its ludic_modules/ link")
print(" get [--json] resolve every package.ludic dependency and write the lock")
print(" (--json: what changed in the lock, as a JSON object on stdout)")
print(" update [module] bump a dependency (or all) to its latest published version") print(" update [module] bump a dependency (or all) to its latest published version")
print(" verify check every locked package against the store by content hash") print(" verify check every locked package against the store by content hash")
print(" vendor copy the resolved packages into ./vendor for offline builds") print(" vendor copy the resolved packages into ./vendor for offline builds")
@ -80,6 +85,9 @@ program Ludic {
print(" upgrade [version] reinstall from the docs site (the same script that installed it)") print(" upgrade [version] reinstall from the docs site (the same script that installed it)")
print(" doctor check that the install is complete and usable") print(" doctor check that the install is complete and usable")
print(" fmt [--check] [paths...] format Ludic source in place (--check: report, write nothing)") print(" fmt [--check] [paths...] format Ludic source in place (--check: report, write nothing)")
print(" fmt --lint [--json] check the project's `lint` rules (--json: one JSON array on stdout)")
print(" fmt - [--stdin-name PATH] format stdin to stdout under the project's rules, as if it were PATH")
print(" fmt - --lint [--json] [--stdin-name PATH] lint stdin as PATH")
print(" lsp run the language server on stdio (what editors spawn)") print(" lsp run the language server on stdio (what editors spawn)")
print(" ui-preview [--font DIR] preview a game's ludic.ui components for a studio, on stdio") print(" ui-preview [--font DIR] preview a game's ludic.ui components for a studio, on stdio")
print(" help this message") print(" help this message")
@ -123,6 +131,7 @@ program Ludic {
if (cmd == "add") { return cmd_pkg_add() } if (cmd == "add") { return cmd_pkg_add() }
if (cmd == "get") { return cmd_pkg_get() } if (cmd == "get") { return cmd_pkg_get() }
if (cmd == "remove") { return cmd_pkg_remove() }
if (cmd == "update") { return cmd_pkg_update() } if (cmd == "update") { return cmd_pkg_update() }
if (cmd == "verify") { return cmd_pkg_verify() } if (cmd == "verify") { return cmd_pkg_verify() }
if (cmd == "vendor") { return cmd_pkg_vendor() } if (cmd == "vendor") { return cmd_pkg_vendor() }

View file

@ -1,5 +1,5 @@
# pkg.ludic — the Ludic package manager (issue #63), the `ludic add`/`get`/ # pkg.ludic — the Ludic package manager (issue #63), the `ludic add`/`get`/
# `update`/`verify`/`vendor` commands. # `update`/`verify`/`vendor` commands (`remove` and `get --json`: pkg_remove.ludic, pkg_lock.ludic).
# #
# It realises the v1 direction decided in the RFC: # It realises the v1 direction decided in the RFC:
# #
@ -484,7 +484,7 @@ function write_lock(sels: []Manifest) -> bool {
function do_install(root: Manifest) -> int { function do_install(root: Manifest) -> int {
let sels = resolve(root) let sels = resolve(root)
if len(sels) == 0 { if len(sels) == 0 {
print("no dependencies to resolve") pkg_say("no dependencies to resolve")
write_lock(sels) write_lock(sels)
return 0 return 0
} }
@ -505,11 +505,11 @@ function do_install(root: Manifest) -> int {
if slen(h) == 0 { err(`ludic: failed to snapshot {m.module}@{m.ver}\n`); return 1 } if slen(h) == 0 { err(`ludic: failed to snapshot {m.module}@{m.ver}\n`); return 1 }
m.hash = `sha256:{h}` m.hash = `sha256:{h}`
link_module(m.module, h) link_module(m.module, h)
print(` {m.module} {m.ver} ({m.kind}) sha256:{sslice(h, 0, 12)}…`) pkg_say(` {m.module} {m.ver} ({m.kind}) sha256:{sslice(h, 0, 12)}…`)
i += 1 i += 1
} }
if not write_lock(sels) { err("ludic: cannot write package.lock.ludic\n"); return 1 } if not write_lock(sels) { err("ludic: cannot write package.lock.ludic\n"); return 1 }
print(`resolved {string(len(sels))} package(s) — see package.lock.ludic; linked under ludic_modules/`) pkg_say(`resolved {string(len(sels))} package(s) — see package.lock.ludic; linked under ludic_modules/`)
return 0 return 0
} }
@ -596,12 +596,19 @@ function cmd_pkg_add() -> int {
return do_install(read_root_manifest()) return do_install(read_root_manifest())
} }
# ludic get — resolve + fetch + link every dependency in package.ludic, write lock # ludic get [--json] — resolve + fetch + link every dependency in package.ludic, write lock. --json
# says what changed in the lock as one JSON object on stdout (lock_diff_json), the rest on stderr.
function cmd_pkg_get() -> int { function cmd_pkg_get() -> int {
let txt = read_file("package.ludic") let txt = read_file("package.ludic")
if txt == null { err("ludic: no package.ludic in the current directory (ludic add <module> to start one)\n"); return 1 } if txt == null { err("ludic: no package.ludic in the current directory (ludic add <module> to start one)\n"); return 1 }
print("resolving dependencies (MVS)…") let json = pkg_has_flag("--json")
return do_install(parse_manifest(txt)) let before = read_lock_or_empty()
g_pkg_err = json
pkg_say("resolving dependencies (MVS)…")
let rc = do_install(parse_manifest(txt))
if rc != 0 or not json { return rc }
out(lock_diff_json(before, read_lock_or_empty()))
return 0
} }
# ludic update [module] — bump a dep (or all) to its latest published version, relock # ludic update [module] — bump a dep (or all) to its latest published version, relock

View file

@ -0,0 +1,116 @@
# pkg_lock.ludic — the package manager's answers for editors and tools: `ludic get --json` (what a
# get changed in package.lock.ludic, as one JSON object on stdout) and `ludic remove <module>`, the
# inverse of `ludic add`. Neither touches the network: the diff is of the lock before and after, and
# a removal re-reads the dependency graph from the store's own copies of each package.ludic.
# the human lines of add / get / update: on stdout, or on stderr under --json
var g_pkg_err: bool = false
function pkg_say(line: pointer) -> void {
if g_pkg_err { err(line + nl()) } else { print(line) }
}
function pkg_has_flag(flag: pointer) -> bool {
var ai = 2
while ai < arg_count() {
if arg(ai) == flag { return true }
ai += 1
}
return false
}
# ---- JSON out --------------------------------------------------------------------
function pkg_hex(d: int) -> pointer {
let b = bytes(2)
b[0] = '0' + d
if d > 9 { b[0] = 'a' + d - 10 }
b[1] = 0
return b
}
function pkg_jesc(c: int) -> pointer {
if c == '"' { return "\\\"" }
if c == 92 { return "\\\\" }
if c == '\n' { return "\\n" }
if c == '\t' { return "\\t" }
if c == '\r' { return "\\r" }
return "\\u00" + pkg_hex(c >> 4) + pkg_hex(c & 15)
}
# `s` as a JSON string, quotes included
function pkg_jq(s: pointer) -> pointer {
var o = "\""
let n = slen(s)
var start = 0
var i = 0
while i < n {
let c = s[i] & 255
if c == '"' or c == 92 or c < 32 {
o = o + sslice(s, start, i) + pkg_jesc(c)
start = i + 1
}
i += 1
}
return o + sslice(s, start, n) + "\""
}
# a lock line as the lock records it
function lock_entry_json(m: Manifest) -> pointer {
var o = `{{"name": {pkg_jq(m.module)}, "version": {pkg_jq(m.ver)}, "hash": {pkg_jq(m.hash)}, "kind": {pkg_jq(m.kind)}, "provides": [`
var p = 0
while p < len(m.provides) {
if p > 0 { o = o + ", " }
o = o + pkg_jq(m.provides[p])
p += 1
}
return o + "]}"
}
function lock_index(ms: []Manifest, module: pointer) -> int {
var i = 0
while i < len(ms) {
if ms[i].module == module { return i }
i += 1
}
return -1
}
function json_list(items: []pointer) -> pointer {
if len(items) == 0 { return "[]" }
var o = "["
var i = 0
while i < len(items) {
if i > 0 { o = o + "," }
o = o + nl() + " " + items[i]
i += 1
}
return o + nl() + " ]"
}
# what changed between two locks: {"added", "removed", "changed", "unchanged"}. A module whose version
# or content hash moved is changed; `from` / `to` are its versions, the hashes beside them.
function lock_diff_json(before: []Manifest, after: []Manifest) -> pointer {
let added = new []pointer
let removed = new []pointer
let changed = new []pointer
var same = 0
var i = 0
while i < len(after) {
let m = after[i]
let at = lock_index(before, m.module)
if at < 0 { push(added, lock_entry_json(m)) }
else {
let o = before[at]
if o.ver == m.ver and o.hash == m.hash { same += 1 }
else { push(changed, `{{"name": {pkg_jq(m.module)}, "from": {pkg_jq(o.ver)}, "to": {pkg_jq(m.ver)}, "from_hash": {pkg_jq(o.hash)}, "to_hash": {pkg_jq(m.hash)}}}`) }
}
i += 1
}
i = 0
while i < len(before) {
if lock_index(after, before[i].module) < 0 { push(removed, lock_entry_json(before[i])) }
i += 1
}
var o = "{" + nl() + ` "added": {json_list(added)},` + nl()
o = o + ` "removed": {json_list(removed)},` + nl()
o = o + ` "changed": {json_list(changed)},` + nl()
return o + ` "unchanged": {string(same)}` + nl() + "}" + nl()
}
function read_lock_or_empty() -> []Manifest {
let t = read_file("package.lock.ludic")
if t == null { return new []Manifest }
return parse_lock(t)
}

View file

@ -0,0 +1,134 @@
# pkg_remove.ludic — `ludic remove <module>`, the inverse of `ludic add`: the `require` line leaves
# package.ludic, and the lock keeps exactly what the remaining requires still reach. The graph is
# read from the store's copies of each locked package.ludic, so nothing is fetched; what the removal
# leaves unreachable leaves the lock and its ludic_modules/ link (the link `add` made, never the store
# entry it points at, which other projects share). Source still importing a removed package is a
# warning: the caller may be about to delete it.
# package.ludic's text with every `require "<module>" ...` line dropped
function drop_require(txt: pointer, module: pointer) -> pointer {
var out = ""
let n = slen(txt)
var i = 0
while i < n {
let line = line_at(txt, i)
i = i + slen(line) + 1
let ts = tok_line(line)
if not (len(ts) >= 2 and ts[0] == "require" and ts[1] == module) { out = out + line + nl() }
}
return out
}
function requires(m: Manifest, module: pointer) -> bool {
var i = 0
while i < len(m.deps) {
if m.deps[i].module == module { return true }
i += 1
}
return false
}
var g_graph_unknown: pointer = "" # a locked package the store has no copy of, when there is one
# the locked entries the root's requires still reach, through the store's package.ludic files
function lock_reachable(root: Manifest, locked: []Manifest) -> []Manifest {
let seen = new []pointer
let todo = new []pointer
var d = 0
while d < len(root.deps) {
push(todo, root.deps[d].module)
d += 1
}
while len(todo) > 0 {
let m = List.pop(todo)
let at = lock_index(locked, m)
if find_mod(seen, m) < 0 and at >= 0 {
push(seen, m)
let dir = `{store_root()}{strip_prefix(locked[at].hash, "sha256:")}`
if not shq(`test -d "{dir}"`) { g_graph_unknown = m }
else {
let mt = read_file(`{dir}/package.ludic`)
if mt != null {
let dm = parse_manifest(mt)
var k = 0
while k < len(dm.deps) {
push(todo, dm.deps[k].module)
k += 1
}
}
}
}
}
let keep = new []Manifest
var i = 0
while i < len(locked) {
if find_mod(seen, locked[i].module) >= 0 { push(keep, locked[i]) }
i += 1
}
return keep
}
# take down the ludic_modules/ view `add` linked, and the directories it leaves empty
function unlink_module(module: pointer) -> void {
let link = `ludic_modules/{module}`
if shq(`test -L "{link}"`) {
shell(`rm -f "{link}"; d=$(dirname "{link}"); while [ "$d" != ludic_modules ] && [ "$d" != . ] && rmdir "$d" 2>/dev/null; do d=$(dirname "$d"); done; rmdir ludic_modules 2>/dev/null`)
} else if shq(`test -e "{link}"`) {
err(`ludic remove: warning: {link} is not a link 'ludic add' made; left as it is\n`)
}
if shq(`test -e "vendor/{module}"`) { err(`ludic remove: vendor/{module} is still there (run 'ludic vendor' again)\n`) }
}
# the project's own files that still import `module`
function warn_importers(module: pointer) -> void {
let hits = split_lines(capture(`grep -rlF --include='*.ludic' --exclude-dir=ludic_modules --exclude-dir=vendor --exclude-dir=build --exclude-dir=.git -e 'import "{module}/' -e 'import "{module}"' . | sed 's|^\\./||' | LC_ALL=C sort`))
if len(hits) == 0 { return }
err(`ludic remove: warning: {module} is still imported by:\n`)
var i = 0
while i < len(hits) {
err(` {hits[i]}\n`)
i += 1
}
}
# ludic remove <module> — drop a dependency: the require, what only it brought into the lock, the links
function cmd_pkg_remove() -> int {
if arg_count() < 3 { err("usage: ludic remove <module>\n"); return 1 }
let module = split_spec(arg(2))[0]
let txt = read_file("package.ludic")
if txt == null { err("ludic remove: no package.ludic in the current directory\n"); return 1 }
let locked = read_lock_or_empty()
if not requires(parse_manifest(txt), module) {
var why = ""
if lock_index(locked, module) >= 0 { why = " (the lock has it because another package requires it)" }
err(`ludic remove: package.ludic does not require {module}{why}\n`)
return 1
}
if not write_file("package.ludic", drop_require(txt, module)) { err("ludic remove: cannot write package.ludic\n"); return 1 }
print(`ludic remove: {module} is no longer required`)
if file_exists("package.lock.ludic") {
var keep = lock_reachable(read_root_manifest(), locked)
if g_graph_unknown != "" {
err(`ludic remove: the store has no copy of {g_graph_unknown}, so only {module} leaves the lock; 'ludic get' settles the rest\n`)
keep = new []Manifest
var j = 0
while j < len(locked) {
if not (locked[j].module == module) { push(keep, locked[j]) }
j += 1
}
}
if not write_lock(keep) { err("ludic remove: cannot write package.lock.ludic\n"); return 1 }
var i = 0
while i < len(locked) {
let m = locked[i]
if lock_index(keep, m.module) < 0 {
unlink_module(m.module)
print(` removed {m.module} {m.ver}`)
if not (m.module == module) { warn_importers(m.module) }
}
i += 1
}
if lock_index(keep, module) >= 0 { print(` {module} stays locked: another package still requires it`) }
}
warn_importers(module)
return 0
}

View file

@ -12,6 +12,13 @@ function pt_write(path: pointer, body: pointer) -> void {
write_file(path, body) write_file(path, body)
} }
# a file's text, "" when it is not there
function pt_read(path: pointer) -> pointer {
let t = read_file(path)
if t == null { return "" }
return t
}
# commit the current tree of `dir` and tag it `tag` # commit the current tree of `dir` and tag it `tag`
function pt_commit_tag(dir: pointer, tag: pointer) -> void { function pt_commit_tag(dir: pointer, tag: pointer) -> void {
shell(`git -C {dir} add -A`) shell(`git -C {dir} add -A`)
@ -170,6 +177,38 @@ function cmd_test_pkg() -> int {
ok("ludic vendor copies the resolved packages into ./vendor") ok("ludic vendor copies the resolved packages into ./vendor")
} else { bad2("ludic vendor failed", capture_line(`tail -1 {work}/vendor.out`)) } } else { bad2("ludic vendor failed", capture_line(`tail -1 {work}/vendor.out`)) }
# ---- for editors (R9): get --json says what changed in the lock; remove undoes add ----
let proj5 = `{work}/proj5`
pt_write(`{proj5}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl())
pt_write(`{proj5}/src/app.ludic`, "program App {\n import \"example.test/greeter/greet.ludic\"\n}\n")
shell(`( cd {proj5} && {envp} {root}/bin/ludic get --json > {work}/gj.json 2> {work}/gj.err )`)
let gj = pt_read(`{work}/gj.json`)
if s_contains(gj, `"name": "example.test/greeter", "version": "1.0.0"`) and s_contains(gj, `"name": "example.test/util", "version": "1.0.0"`) and s_contains(gj, `"unchanged": 0`) and shq(`python3 -m json.tool {work}/gj.json > /dev/null`) and not s_contains(gj, "resolving") {
ok("ludic get --json: a fresh lock is two added packages, one JSON object on stdout")
} else { bad2("ludic get --json (fresh)", gj) }
pt_write(`{proj5}/package.ludic`, `package "app"` + nl() + `version "0.0.0"` + nl() + `require "example.test/greeter" "1.0.0"` + nl() + `require "example.test/util" "1.2.0"` + nl())
let gj2 = capture(`( cd {proj5} && {envp} {root}/bin/ludic get --json 2>/dev/null )`)
if s_contains(gj2, `"added": []`) and s_contains(gj2, `"name": "example.test/util", "from": "1.0.0", "to": "1.2.0"`) and s_contains(gj2, `"unchanged": 1`) {
ok("ludic get --json: a raised minimum is a change from one version to the other")
} else { bad2("ludic get --json (changed)", gj2) }
if not shq(`( cd {proj5} && {root}/bin/ludic remove example.test/nothere > {work}/rm0.out 2>&1 )`) and shq(`grep -q 'does not require' {work}/rm0.out`) {
ok("ludic remove refuses a module package.ludic does not require")
} else { bad2("ludic remove of an unrequired module", capture_line(`cat {work}/rm0.out`)) }
let store_n = capture_line(`ls {store} | wc -l`)
shell(`( cd {proj5} && LUDIC_STORE={store} {root}/bin/ludic remove example.test/util > {work}/rm1.out 2>&1 )`)
let lk1 = pt_read(`{proj5}/package.lock.ludic`)
if not s_contains(pt_read(`{proj5}/package.ludic`), "example.test/util") and s_contains(lk1, "example.test/util") and file_exists(`{proj5}/ludic_modules/example.test/util`) {
ok("ludic remove keeps what another package still requires locked and linked")
} else { bad2("ludic remove dropped a package still required", capture_line(`cat {work}/rm1.out`)) }
shell(`( cd {proj5} && LUDIC_STORE={store} {root}/bin/ludic remove example.test/greeter > {work}/rm2.out 2>&1 )`)
let lk2 = pt_read(`{proj5}/package.lock.ludic`)
if not s_contains(lk2, "example.test/") and not file_exists(`{proj5}/ludic_modules`) and capture_line(`ls {store} | wc -l`) == store_n {
ok("ludic remove drops the require, what only it locked and its links, and leaves the shared store alone")
} else { bad2("ludic remove of the last require", capture_line(`cat {work}/rm2.out`)) }
if shq(`grep -q 'still imported by' {work}/rm2.out`) and shq(`grep -q 'src/app.ludic' {work}/rm2.out`) {
ok("ludic remove warns about source still importing what it removed")
} else { bad("ludic remove said nothing about src/app.ludic importing the package") }
# ---- package-declarable engine-system + namespace (issue #62) ------------- # ---- package-declarable engine-system + namespace (issue #62) -------------
# A source package registers a compile-time engine system (@EngineSystem) and a # A source package registers a compile-time engine system (@EngineSystem) and a
# Foo.* namespace (@Namespace) with no compiler edit; a consumer game imports it # Foo.* namespace (@Namespace) with no compiler edit; a consumer game imports it

View file

@ -280,6 +280,11 @@ function parse_build_args(start: int) -> pointer {
else if a == "--no-maps" { g_no_maps_build = true } # --check leaves the map directories alone else if a == "--no-maps" { g_no_maps_build = true } # --check leaves the map directories alone
else if a == "--maps" { g_maps_build = true } # ... or reads them, whatever program it checks else if a == "--maps" { g_maps_build = true } # ... or reads them, whatever program it checks
else if a == "--diagnostics=json" { g_check_build = true; g_diag_json_build = true } # every error, as JSON on stdout else if a == "--diagnostics=json" { g_check_build = true; g_diag_json_build = true } # every error, as JSON on stdout
else if a == "--stdin-file" { # stdin is that file's unsaved text: checked, never built
ai += 1
if ai < arg_count() { g_stdin_file_build = arg(ai); g_check_build = true }
else { err("ludic: --stdin-file needs a path\n"); g_argerr = true }
}
else if a == "-o" { else if a == "-o" {
ai += 1 ai += 1
if ai < arg_count() { g_out = arg(ai) } if ai < arg_count() { g_out = arg(ai) }
@ -287,7 +292,7 @@ function parse_build_args(start: int) -> pointer {
} }
else if a[0] == '-' { else if a[0] == '-' {
err(`ludic: unknown option {a}\n`) err(`ludic: unknown option {a}\n`)
err(" build/run take: [file] [--headless|--windowed] [-o out] [--save-temps] [--check [--diagnostics=json] [--maps|--no-maps]]\n") err(" build/run take: [file] [--headless|--windowed] [-o out] [--save-temps] [--check [--diagnostics=json] [--stdin-file path] [--maps|--no-maps]]\n")
g_argerr = true g_argerr = true
} }
else { src = a } else { src = a }
@ -311,6 +316,7 @@ function output_path(entry: pointer) -> pointer {
# and nothing emitted or linked; a fraction of a build's time, for iterating on uses lines # and nothing emitted or linked; a fraction of a build's time, for iterating on uses lines
var g_check_build: bool = false var g_check_build: bool = false
var g_diag_json_build: bool = false # --diagnostics=json: the compiler's JSON array is the whole stdout var g_diag_json_build: bool = false # --diagnostics=json: the compiler's JSON array is the whole stdout
var g_stdin_file_build: pointer = "" # --stdin-file <path>: the compiler reads stdin where it would read <path>
var g_no_maps_build: bool = false var g_no_maps_build: bool = false
var g_maps_build: bool = false var g_maps_build: bool = false
# the maps are checked with the package's entry - the game - and left alone for any other program: a # the maps are checked with the package's entry - the game - and left alone for any other program: a
@ -330,13 +336,17 @@ function bld_same_path(a: pointer, b: pointer) -> bool {
if len(y) > 2 and y[0] == '.' and y[1] == '/' { y = y[2..len(y)] } if len(y) > 2 and y[0] == '.' and y[1] == '/' { y = y[2..len(y)] }
return x == y return x == y
} }
function stdin_file_flag() -> pointer {
if g_stdin_file_build == "" { return "" }
return ` --stdin-file {sh_single(g_stdin_file_build)}`
}
function check_app(src: pointer) -> int { function check_app(src: pointer) -> int {
ensure_ludicc() ensure_ludicc()
if g_diag_json_build { if g_diag_json_build {
if not shq(`{ludicc()} --check --diagnostics=json{no_maps_flag(src)}{title_flag()}{unsafe_flag()} {src}`) { return 1 } if not shq(`{ludicc()} --check --diagnostics=json{no_maps_flag(src)}{title_flag()}{unsafe_flag()}{stdin_file_flag()} {src}`) { return 1 }
return 0 return 0
} }
if not shq(`{ludicc()} --check{no_maps_flag(src)}{title_flag()}{unsafe_flag()} {src}`) { return 1 } if not shq(`{ludicc()} --check{no_maps_flag(src)}{title_flag()}{unsafe_flag()}{stdin_file_flag()} {src}`) { return 1 }
print(`checked {src}`) print(`checked {src}`)
return 0 return 0
} }
@ -561,7 +571,8 @@ function strip_ext(p: pointer) -> pointer {
# ---- ludic fmt / lsp -------------------------------------------------------- # ---- ludic fmt / lsp --------------------------------------------------------
# ludic fmt [--check] [paths...] — format the project's source in place (src/ and # ludic fmt [--check] [paths...] — format the project's source in place (src/ and
# tests/ by default), or the paths named. --check writes nothing and exits # tests/ by default), or the paths named. `--lint [--json]` checks the project's style rules and
# `-` formats stdin to stdout, both from the project's root (fmt_in_project). --check writes nothing and exits
# non-zero if anything is unformatted, which is what a pre-commit hook or CI # non-zero if anything is unformatted, which is what a pre-commit hook or CI
# wants. # wants.
# #
@ -572,18 +583,35 @@ function strip_ext(p: pointer) -> pointer {
function cmd_fmt() -> int { function cmd_fmt() -> int {
var mode = "-w" var mode = "-w"
var args = "" var args = ""
var lint = false
var json = false
var stdin = false
var name = ""
var ai = 2 var ai = 2
while ai < arg_count() { while ai < arg_count() {
let a = arg(ai) let a = arg(ai)
if a == "--check" { mode = "--check" } if a == "--check" { mode = "--check" }
else if a == "--lint" { lint = true }
else if a == "--json" { json = true }
else if a == "-" { stdin = true }
else if a == "--stdin-name" and ai + 1 < arg_count() {
name = arg(ai + 1)
ai += 1
}
else if a[0] == '-' { else if a[0] == '-' {
err(`ludic fmt: unknown option {a}\n`) err(`ludic fmt: unknown option {a}\n`)
err(" usage: ludic fmt [--check] [paths...]\n") err(" usage: ludic fmt [--check] [paths...] | ludic fmt --lint [--json] | ludic fmt - [--stdin-name PATH] [--lint [--json]]\n")
return 1 return 1
} }
else { args = `{args} {a}` } else { args = `{args} {a}` }
ai += 1 ai += 1
} }
if json and not lint { err("ludic fmt: --json goes with --lint\n"); return 1 }
if name != "" and not stdin { err("ludic fmt: --stdin-name goes with - (the buffer on stdin)\n"); return 1 }
if lint or stdin {
if args != "" { err("ludic fmt: --lint walks the project's `lint paths`, and - reads stdin; neither takes paths\n"); return 1 }
return fmt_in_project(lint, json, stdin, name)
}
if args == "" { if args == "" {
let found = capture_line("find src tests -name '*.ludic' 2>/dev/null | sort") let found = capture_line("find src tests -name '*.ludic' 2>/dev/null | sort")
if found == "" { if found == "" {
@ -595,6 +623,21 @@ function cmd_fmt() -> int {
return sh(`{tool("ludic-fmt")} {mode}{args}`) return sh(`{tool("ludic-fmt")} {mode}{args}`)
} }
# `ludic fmt --lint` and `ludic fmt -`: ludic-fmt run from the project's root - the nearest directory
# upwards holding a package.ludic, from the --stdin-name's directory or else this one - so its `lint`
# lines, paths and baseline apply, with the buffer standing for its path relative to that root.
function fmt_in_project(lint: bool, json: bool, stdin: bool, name: pointer) -> int {
var flags = ""
if stdin { flags = ` - --stdin-name {sh_single(name)} --stdin-rel "$rel"` }
if lint { flags = flags + " --lint" }
if json { flags = flags + " --json" }
var s = `F={sh_single(tool("ludic-fmt"))}; case "$F" in /*) ;; */*) F="$PWD/$F" ;; esac; n={sh_single(name)}; `
s = s + `if [ -n "$n" ] && a=$(cd -- "$(dirname -- "$n")" 2>/dev/null && pwd -P); then b=$(basename -- "$n"); else a=$(pwd -P); b=; fi; `
s = s + `r=$a; while [ "$r" != / ] && [ ! -f "$r/package.ludic" ]; do r=$(dirname -- "$r"); done; rel=; `
s = s + `if [ -f "$r/package.ludic" ]; then if [ -n "$b" ]; then rel=${{a#"$r"}}; rel=${{rel#/}}; rel=${{rel:+$rel/}}$b; fi; cd -- "$r" || exit 2; fi; `
return sh(s + `exec "$F"{flags}`)
}
# ludic lsp — the language server on stdio. Editors are configured to run this, # ludic lsp — the language server on stdio. Editors are configured to run this,
# so the server's location is the CLI's problem rather than the user's. # so the server's location is the CLI's problem rather than the user's.
function cmd_lsp() -> int { function cmd_lsp() -> int {

View file

@ -2,7 +2,8 @@
# What an editor of a program's data needs, from the compiler's own view: every record with its # What an editor of a program's data needs, from the compiler's own view: every record with its
# fields, types, defaults, docs and attributes (@Ref, @Range, ...), every registry with its entries # fields, types, defaults, docs and attributes (@Ref, @Range, ...), every registry with its entries
# in their final order and where each is written, every const, and the functions a `fn` value can # in their final order and where each is written, every const, and the functions a `fn` value can
# name. The compiler writes it (`ludicc --emit-schema`, selfhost/backend/emit_schema.ludic) once the # name, the text keys, and the code map (modules, actions, events, ports, handlers, scenes, fn refs).
# The compiler writes it (`ludicc --emit-schema`, selfhost/backend/emit_schema.ludic) once the
# program has type-checked. # program has type-checked.
# #
# ludic schema [file] the JSON on stdout # ludic schema [file] the JSON on stdout

View file

@ -12,7 +12,7 @@
# the built-in commands a hook can wrap (every user command that does work) # the built-in commands a hook can wrap (every user command that does work)
function is_hookable(cmd: pointer) -> bool { function is_hookable(cmd: pointer) -> bool {
return (cmd == "build") or (cmd == "run") or (cmd == "test") or (cmd == "deps") or (cmd == "schema") or (cmd == "migrate") or (cmd == "bundle") or (cmd == "pack") or (cmd == "clean") or (cmd == "fmt") or (cmd == "get") or (cmd == "add") or (cmd == "update") or (cmd == "verify") or (cmd == "vendor") or (cmd == "assets") or (cmd == "build-lib") return (cmd == "build") or (cmd == "run") or (cmd == "test") or (cmd == "deps") or (cmd == "schema") or (cmd == "migrate") or (cmd == "bundle") or (cmd == "pack") or (cmd == "clean") or (cmd == "fmt") or (cmd == "get") or (cmd == "add") or (cmd == "remove") or (cmd == "update") or (cmd == "verify") or (cmd == "vendor") or (cmd == "assets") or (cmd == "build-lib")
} }
# the directory this `ludic` binary lives in, with no trailing slash # the directory this `ludic` binary lives in, with no trailing slash
@ -32,6 +32,17 @@ function script_env(event: pointer, phase: pointer) -> pointer {
return env return env
} }
# a command whose stdout is for a program (`--json`) or whose stdin is a buffer (`ludic fmt -`): its
# hooks read nothing and say everything on stderr, so neither is spoilt
function machine_output() -> bool {
var ai = 2
while ai < arg_count() {
if arg(ai) == "--json" or arg(ai) == "-" { return true }
ai += 1
}
return false
}
# run every `hook <phase> <event>` in order; the first failure's exit code, or 0 # run every `hook <phase> <event>` in order; the first failure's exit code, or 0
function run_hooks(phase: pointer, event: pointer) -> int { function run_hooks(phase: pointer, event: pointer) -> int {
if not file_exists("package.ludic") { return 0 } if not file_exists("package.ludic") { return 0 }
@ -40,8 +51,14 @@ function run_hooks(phase: pointer, event: pointer) -> int {
while i + 2 < len(m.hooks) { while i + 2 < len(m.hooks) {
if m.hooks[i] == phase and m.hooks[i + 1] == event { if m.hooks[i] == phase and m.hooks[i + 1] == event {
let cmd = m.hooks[i + 2] let cmd = m.hooks[i + 2]
say(`> {phase} {event}: {cmd}`) var rc = 0
let rc = sh(`{script_env(event, phase)}{cmd}`) if machine_output() {
err(`> {phase} {event}: {cmd}\n`)
rc = sh(`{script_env(event, phase)}( {cmd} ) < /dev/null 1>&2`)
} else {
say(`> {phase} {event}: {cmd}`)
rc = sh(`{script_env(event, phase)}{cmd}`)
}
if rc != 0 { if rc != 0 {
err(`ludic: the {phase} {event} hook failed (exit {string(rc)}): {cmd}\n`) err(`ludic: the {phase} {event} hook failed (exit {string(rc)}): {cmd}\n`)
return rc return rc

View file

@ -17,6 +17,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/frontend/ast.ludic") push(f, "selfhost/frontend/ast.ludic")
push(f, "selfhost/frontend/lex.ludic") push(f, "selfhost/frontend/lex.ludic")
push(f, "selfhost/frontend/diag.ludic") push(f, "selfhost/frontend/diag.ludic")
push(f, "selfhost/frontend/stdin_file.ludic")
push(f, "selfhost/frontend/parse.ludic") push(f, "selfhost/frontend/parse.ludic")
push(f, "selfhost/frontend/parse_game.ludic") push(f, "selfhost/frontend/parse_game.ludic")
push(f, "selfhost/frontend/generics.ludic") push(f, "selfhost/frontend/generics.ludic")
@ -101,6 +102,8 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/backend/emit_schema_ui.ludic") push(f, "selfhost/backend/emit_schema_ui.ludic")
push(f, "selfhost/backend/emit_schema_hash.ludic") push(f, "selfhost/backend/emit_schema_hash.ludic")
push(f, "selfhost/backend/emit_schema_lang.ludic") push(f, "selfhost/backend/emit_schema_lang.ludic")
push(f, "selfhost/backend/emit_schema_code.ludic")
push(f, "selfhost/backend/emit_schema_code_game.ludic")
push(f, "selfhost/check/check_state.ludic") push(f, "selfhost/check/check_state.ludic")
push(f, "selfhost/check/check_index.ludic") push(f, "selfhost/check/check_index.ludic")
push(f, "selfhost/check/check_types.ludic") push(f, "selfhost/check/check_types.ludic")

View file

@ -99,6 +99,52 @@ function diag_json_case(path: pointer, n: int, want: pointer, label: pointer) ->
if s_contains(capture(`cat {js}`), want) { ok(label) } else { bad2(label, `no diagnostic holds [{want}]`) } if s_contains(capture(`cat {js}`), want) { ok(label) } else { bad2(label, `no diagnostic holds [{want}]`) }
} }
# --stdin-file <path>: stdin stands for <path> wherever the program opens it. A buffer that breaks an
# import reached through its barrel (spelt with `..`) is refused at the buffer's line under the file's
# usual name; a buffer that fixes a refused entry checks clean; a path nothing opens is a warning.
function stdin_file_cases() -> void {
let lbl = "--stdin-file: an unsaved buffer checked in place of its file"
let fish = "examples/modules/kinds/fishing/index.ludic"
let js = `{tmp_dir()}/sf_break.json`
let spelt = "./examples/modules/kinds/fishing/../fishing/index.ludic"
if shq(`sed 's/return fishing_st.landed/return "seven"/' {fish} | bin/ludicc examples/modules/private_kinds.ludic --check --diagnostics=json --stdin-file {spelt} > {js} 2>/dev/null`) {
bad2(lbl, "a buffer with an error checked clean"); return
}
let broke = capture(`cat {js}`)
if not s_contains(broke, `"file": "{fish}", "line": 21`) { bad2(lbl, `no error at the buffer's line 21 of {fish}: {s_trim(broke)}`); return }
let js2 = `{tmp_dir()}/sf_fix.json`
let fix = "sed -e 's/print(on + 1)/print(1)/' -e 's/half(1, 2)/half(1)/' -e 's/-> int { return \"seven\" }/-> int { return 7 }/'"
let clean = shq(`{fix} examples/rejected/every_error.ludic | bin/ludicc examples/rejected/every_error.ludic --check --diagnostics=json --stdin-file examples/rejected/every_error.ludic > {js2} 2>/dev/null`)
let said = s_trim(capture(`cat {js2}`))
if not clean or not (said == "[]") { bad2(lbl, `a buffer that fixes the saved file's errors said {said}`); return }
let js3 = `{tmp_dir()}/sf_none.json`
shq(`printf '' | bin/ludicc examples/modules/private_kinds.ludic --check --diagnostics=json --stdin-file examples/nowhere.ludic > {js3} 2>/dev/null`)
if not s_contains(capture(`cat {js3}`), "the program does not reach this file") { bad2(lbl, "a path the program never opens was not reported"); return }
ok(lbl)
}
# `ludic fmt` for editors (R9): run from a directory under the project, `--lint --json` and `-` find
# the nearest package.ludic upwards and judge a buffer as the file --stdin-name names, relative to it
function fmt_editor_cases() -> void {
let lbl = "ludic fmt --lint --json and ludic fmt - from inside a project"
let p = `{tmp_dir()}/fmt_ed`
let here = capture_line("pwd")
shell(`rm -rf {p} && mkdir -p {p}/src/deep`)
write_file(`{p}/package.ludic`, "package \"t\"\nlint one_statement\nlint paths \"src\"\n")
write_file(`{p}/src/deep/a.ludic`, "function f() -> int {\n let a = 1; return a\n}\n")
let js = `{p}/out.json`
if shq(`cd {p}/src/deep && {here}/bin/ludic fmt --lint --json > {js} 2>/dev/null`) { bad2(lbl, "--lint --json passed a file breaking one_statement"); return }
let said = capture(`cat {js}`)
if not s_contains(said, `"file": "src/deep/a.ludic", "line": 2, "col": 15`) { bad2(lbl, `--lint --json said {s_trim(said)}`); return }
let one = capture(`cd {p}/src/deep && {here}/bin/ludic fmt - --lint --json --stdin-name a.ludic < a.ludic 2>/dev/null`)
if not s_contains(one, `"file": "a.ludic", "line": 2`) { bad2(lbl, `a buffer linted as src/deep/a.ludic said {s_trim(one)}`); return }
let out = capture(`cd {p}/src/deep && printf 'function g() -> int{{return 2}}\n' | {here}/bin/ludic fmt - --stdin-name {p}/src/deep/b.ludic`)
if not (s_trim(out) == "function g() -> int { return 2 }") { bad2(lbl, `ludic fmt - wrote {s_trim(out)}`); return }
if shq(`printf '{{\n' | {here}/bin/ludic fmt - > /dev/null 2>&1`) { bad2(lbl, "ludic fmt - accepted an unclosed brace"); return }
if shq(`{here}/bin/ludic fmt --json > /dev/null 2>&1`) { bad2(lbl, "--json without --lint was accepted"); return }
ok(lbl)
}
# a feature example that consumes an in-repo controller package (packages/): same # a feature example that consumes an in-repo controller package (packages/): same
# as feat_case, but the compiler is pointed at packages/ via LUDIC_MODULES so a # as feat_case, but the compiler is pointed at packages/ via LUDIC_MODULES so a
# `import "ludic.foo/bar.ludic"` resolves, and LUDIC_HOME anchors the engine # `import "ludic.foo/bar.ludic"` resolves, and LUDIC_HOME anchors the engine
@ -1227,6 +1273,8 @@ function cmd_dev_test() -> int {
diag_json_case("rejected/every_error", 3, "\"line\": 8, \"col\": 15, \"severity\": \"error\", \"message\": \"half takes 1 argument(s) and this call gives 2\"", "--diagnostics=json: every type error as {file, line, col, severity, message}") diag_json_case("rejected/every_error", 3, "\"line\": 8, \"col\": 15, \"severity\": \"error\", \"message\": \"half takes 1 argument(s) and this call gives 2\"", "--diagnostics=json: every type error as {file, line, col, severity, message}")
diag_json_case("rejected/ref_unknown", 1, "@Ref(Shops): this program has no Shops - left unresolved", "--diagnostics=json: an @Ref to a registry the program lacks is a warning beside the error") diag_json_case("rejected/ref_unknown", 1, "@Ref(Shops): this program has no Shops - left unresolved", "--diagnostics=json: an @Ref to a registry the program lacks is a warning beside the error")
diag_json_case("rejected/node_bad", 8, "crate.shape is \\\"cube\\\", and field Tool.shape is @OneOf(", "--diagnostics=json: every @Node / @Clip / @Material with no model field, @OneOf of the wrong kind or a row outside its words, and the targets the program lacks as warnings") diag_json_case("rejected/node_bad", 8, "crate.shape is \\\"cube\\\", and field Tool.shape is @OneOf(", "--diagnostics=json: every @Node / @Clip / @Material with no model field, @OneOf of the wrong kind or a row outside its words, and the targets the program lacks as warnings")
stdin_file_cases()
fmt_editor_cases()
feat_case("lang/generics", "", "7 Crater Lake a 2 west 7 0.75 2.5 1", "generics.ludic (L5: generic records and functions, nested instances, fn-typed parameters and fields, inference from the result's slot)") feat_case("lang/generics", "", "7 Crater Lake a 2 west 7 0.75 2.5 1", "generics.ludic (L5: generic records and functions, nested instances, fn-typed parameters and fields, inference from the result's slot)")
reject_case("rejected/generic_unbound", "cannot tell what T is in this call to pool_new", "a call whose type arguments nothing decides is refused") reject_case("rejected/generic_unbound", "cannot tell what T is in this call to pool_new", "a call whose type arguments nothing decides is refused")
reject_case("rejected/generic_mismatch", "words wants a Pool<string> and this is a Pool<int>", "two instances of one generic are two types") reject_case("rejected/generic_mismatch", "words wants a Pool<string> and this is a Pool<int>", "two instances of one generic are two types")
@ -1303,6 +1351,21 @@ function cmd_dev_test() -> int {
schema_case("lang/i18n/i18n", "\"unused\": [\"map.close\"]", "--emit-schema: lang.unused - en.po's keys the program uses nowhere") schema_case("lang/i18n/i18n", "\"unused\": [\"map.close\"]", "--emit-schema: lang.unused - en.po's keys the program uses nowhere")
schema_case("lang/i18n/i18n", "\"missing\": [\"hud.day\"], \"fuzzy\": [\"pause.close\"], \"extra\": [\"old.title\"]", "--emit-schema: lang.languages - another .po's missing, fuzzy and extra keys against en.po") schema_case("lang/i18n/i18n", "\"missing\": [\"hud.day\"], \"fuzzy\": [\"pause.close\"], \"extra\": [\"old.title\"]", "--emit-schema: lang.languages - another .po's missing, fuzzy and extra keys against en.po")
schema_case("lang/i18n/i18n", "{\"english\": \"Close\", \"keys\": [\"map.close\", \"pause.close\"]}", "--emit-schema: lang.split - one English under several keys") schema_case("lang/i18n/i18n", "{\"english\": \"Close\", \"keys\": [\"map.close\", \"pause.close\"]}", "--emit-schema: lang.split - one English under several keys")
schema_case("actions/modules", "{\"state\": \"Bag\", \"action\": \"PickUp\", \"module\": \"bag\", \"at\": \"examples/actions/mods/bag/index.ludic:4:16\"}", "--emit-schema: code.reducers - a reducer's state, action, module and place")
schema_case("actions/modules", "{\"action\": \"PickUp\", \"module\": null, \"at\": \"examples/actions/modules.ludic:8:21\"}", "--emit-schema: code.dispatch - every dispatch site")
schema_case("modules/ports", "{\"name\": \"Clock\", \"module\": \"clock\", \"at\": \"examples/modules/station/clock/index.ludic:4:13\", \"bound\": true, \"members\": [{\"name\": \"now\", \"type\": \"fn()->int\", \"default\": null, \"required\": true}, {\"name\": \"day\", \"type\": \"fn()->int\", \"default\": \"fn first_day\", \"required\": false}]}", "--emit-schema: code.ports - a port's members, their fn types and defaults")
schema_case("modules/ports", "{\"port\": \"Clock\", \"member\": \"now\", \"fn\": \"game_hours\", \"value\": \"fn game_hours\", \"module\": \"app\", \"at\": \"examples/modules/station/app/index.ludic:3:14\"}", "--emit-schema: code.binds - a bind's member, the function it names and where")
schema_case("modules/ports", "{\"name\": \"clock\", \"package\": null, \"layer\": null, \"uses\": [\"units\"], \"uses_at\": \"examples/modules/station/clock/index.ludic:3\", \"friend\": null, \"files\": [\"examples/modules/station/clock/index.ludic\"]}", "--emit-schema: code.modules - a module's uses and files")
schema_case("modules/friend_of", "{\"name\": \"lab\", \"package\": null, \"layer\": null, \"uses\": null, \"uses_at\": null, \"friend\": {\"of\": [\"bank\"]}", "--emit-schema: code.modules - a friend module and whose names it sees")
schema_case("networking/net_demo", "{\"name\": \"Move\", \"module\": null, \"at\": \"examples/networking/net_demo.ludic:24:19\", \"cancellable\": false, \"net\": \"toserver\", \"emits\": [\"examples/networking/net_demo.ludic:32:10\"], \"listeners\": [{\"handler\": \"DoMove\", \"module\": null, \"at\": \"examples/networking/net_demo.ludic:25:21\"}]}", "--emit-schema: code.events - a remote event, its emit sites and @On listeners")
schema_case("networking/net_demo", "{\"name\": \"Ship\", \"module\": null, \"at\": \"examples/networking/net_demo.ludic:22:16\", \"owned\": true, \"properties\": [{\"name\": \"Pos\", \"sync\": true}]}", "--emit-schema: code.models - an @Owned model and its @Sync property")
schema_case("networking/net_roles", "\"phase\": \"Update\", \"hook\": null, \"target\": null, \"public\": false, \"net\": \"server\"", "--emit-schema: code.handlers - an @Server handler's phase and net role")
schema_case("lang/annotations", "\"queries\": {\"these\": [{\"property\": \"Transform\", \"filter\": \"scale > 0\"}, {\"property\": \"Velocity\", \"filter\": \"dx > 0 or dy > 0\"}], \"on\": \"Actor\"}", "--emit-schema: code.handlers - @Queries' terms, filters and model")
schema_case("lang/annotations", "\"phase\": null, \"hook\": \"OnSpawn\", \"target\": \"Actor\"", "--emit-schema: code.handlers - a lifecycle hook and the model it names")
schema_case("lang/scenes", "{\"name\": \"Title\", \"module\": null, \"at\": \"examples/lang/scenes.ludic:24:9\", \"start\": true, \"public\": false, \"shows\": null, \"lasts\": null, \"then\": null, \"loads\": null, \"enter\": true, \"exit\": true, \"layers\": [\"Main\"]}", "--emit-schema: code.scenes - the start scene, its blocks and layers")
schema_case("lang/scenes", "\"scene\": \"Play\", \"layer\": \"Hud\"", "--emit-schema: code.handlers - a scene's handler, its scene and layer")
schema_case("state/write_reach", "{\"fn\": \"log_note\", \"module\": null, \"at\": \"examples/state/write_reach.ludic:21:37\", \"slot_kind\": \"registry\", \"slot\": \"Kinds.note\", \"entry\": \"box\"}", "--emit-schema: code.fn_refs - a fn in a registry row, the field and row it fills")
schema_case("modules/ports", "\"slot_kind\": \"port_default\", \"slot\": \"Clock.day\"", "--emit-schema: code.fn_refs - a port member's default")
diag_json_case("rejected/i18n_keys/key_missing", 2, "the key pause.gone is not in examples/rejected/i18n_keys/lang/en.po", "a key literal en.po does not have is refused, at its line and column") diag_json_case("rejected/i18n_keys/key_missing", 2, "the key pause.gone is not in examples/rejected/i18n_keys/lang/en.po", "a key literal en.po does not have is refused, at its line and column")
diag_json_case("rejected/i18n_keys/key_missing", 2, "is a plural key, and examples/rejected/i18n_keys/lang/en.po:7 gives pause.resume no msgid_plural", "kn\"...\" is a plural key: en.po's entry has a msgid_plural") diag_json_case("rejected/i18n_keys/key_missing", 2, "is a plural key, and examples/rejected/i18n_keys/lang/en.po:7 gives pause.resume no msgid_plural", "kn\"...\" is a plural key: en.po's entry has a msgid_plural")
diag_json_case("rejected/i18n_keys/key_missing", 2, "trf: hud.day is given 2 value(s), and its English in examples/rejected/i18n_keys/lang/en.po has 1 hole(s)", "trf given more values than the key's English has holes: a warning") diag_json_case("rejected/i18n_keys/key_missing", 2, "trf: hud.day is given 2 value(s), and its English in examples/rejected/i18n_keys/lang/en.po has 1 hole(s)", "trf given more values than the key's English has holes: a warning")

View file

@ -129,6 +129,28 @@ function cmd_test_tools() -> int {
write_file(`{lp}/src/c.ludic`, "function h() -> string { return \"a; b\" }\n") write_file(`{lp}/src/c.ludic`, "function h() -> string { return \"a; b\" }\n")
if shq(`cd {lp} && {lfmt} --lint > /dev/null`) { ok("a ; inside a string is not a statement") } else { bad("a ; inside a string is not a statement") } if shq(`cd {lp} && {lfmt} --lint > /dev/null`) { ok("a ; inside a string is not a statement") } else { bad("a ; inside a string is not a statement") }
# --- for editors (R9): --lint --json, and a buffer on stdin -----------------
write_file(`{lp}/src/d.ludic`, "function d() -> int {\n let a = 1; let b = \"q\\\"\"; return a\n}\n")
let lj = `{lp}/lint.json`
if not shq(`cd {lp} && {lfmt} --lint --json > {lj} 2> {lp}/lint.err`) and shq(`python3 -m json.tool {lj} > /dev/null`) and shq(`grep -q '"file": "src/d.ludic", "line": 2, "col": 15, "rule": "one_statement"' {lj}`) and not shq(`grep -q 'lint:' {lj}`) {
ok("--lint --json: one JSON array on stdout, with the column, the summary on stderr")
} else { bad2("--lint --json", capture_line(`cat {lj}`)) }
shell(`cd {lp} && {lfmt} --init-baseline > /dev/null`)
write_file(`{lp}/src/d.ludic`, "function d() -> int { return 1 }\n")
let pinned = capture_line(`cat {lp}/lint-baseline.txt`)
shell(`cd {lp} && {lfmt} --lint --json > /dev/null 2>&1`)
check("--lint --json never lowers the baseline (plain --lint does)", capture_line(`cat {lp}/lint-baseline.txt`), pinned)
write_file(`{lp}/buf.txt`, "function g() -> int {\n let b = 2; return b\n}\n")
let sj = capture(`cd {lp} && {lfmt} - --lint --json --stdin-name src/new.ludic < buf.txt`)
if s_contains(sj, `"file": "src/new.ludic", "line": 2`) { ok("- --lint --json: a buffer judged as the file it stands for") } else { bad2("- --lint --json", sj) }
check("a buffer outside the lint paths breaks no rule", s_trim(capture(`cd {lp} && {lfmt} - --lint --json --stdin-name notes/new.ludic < buf.txt 2>/dev/null`)), "[]")
check("- formats stdin to stdout", capture_line(`printf 'function f() -> int{{return 1}}\n' | {fmt} -`), "function f() -> int { return 1 }")
let bad_rc = sh(`printf 'function f() -> int {{\n let s = (1]\n}}\n' | {fmt} - --stdin-name x.ludic > {tmp_dir()}/bad.out 2> {tmp_dir()}/bad.err`)
if bad_rc == 2 and shq(`grep -q '^x.ludic:2:13: error:' {tmp_dir()}/bad.err`) and shq(`test ! -s {tmp_dir()}/bad.out`) {
ok("- refuses a buffer with a bracket closed by the wrong one: exit 2, where on stderr, nothing on stdout")
} else { bad2("- refuses an unreadable buffer", capture_line(`cat {tmp_dir()}/bad.err`)) }
check("- refuses an unclosed string", string(sh(`printf 'let s = "abc\n' | {fmt} - > /dev/null 2>&1`)), "2")
# --- --check exit status, for CI and pre-commit hooks ---------------------- # --- --check exit status, for CI and pre-commit hooks ----------------------
let chk1 = sh(`{fmt} --check -q {tmp_dir()}/m.ludic`) let chk1 = sh(`{fmt} --check -q {tmp_dir()}/m.ludic`)
check("--check exits 1 on unformatted input", string(chk1), "1") check("--check exits 1 on unformatted input", string(chk1), "1")

View file

@ -8,11 +8,16 @@
# ludic-fmt -w a.ludic rewrite in place # ludic-fmt -w a.ludic rewrite in place
# ludic-fmt --check a.ludic exit 1 if unformatted # ludic-fmt --check a.ludic exit 1 if unformatted
# ludic-fmt a.md format the ```ludic fences in a document # ludic-fmt a.md format the ```ludic fences in a document
# cat a.ludic | ludic-fmt - filter mode (stdin -> stdout) # cat a.ludic | ludic-fmt - filter mode (stdin -> stdout); a buffer that does not read as
# Ludic (an open string, an unclosed bracket) exits 2, why on stderr
# ludic-fmt --lint --json the project's lint, as one JSON array on stdout
# ludic-fmt - --lint --json --stdin-name src/a.ludic lint a buffer as that file
program LudicFmt { program LudicFmt {
import "fmt_lint.ludic" import "fmt_lint.ludic"
import "fmt_lint_rules.ludic" import "fmt_lint_rules.ludic"
import "fmt_lint_run.ludic" import "fmt_lint_run.ludic"
import "fmt_lint_json.ludic"
import "fmt_stdin.ludic"
# ---- token kinds (mirror ludic_syntax.h) ---- # ---- token kinds (mirror ludic_syntax.h) ----
const LT_EOF: int = 0 const LT_EOF: int = 0
const LT_NL: int = 1 const LT_NL: int = 1
@ -632,24 +637,43 @@ program LudicFmt {
var failed = false var failed = false
var lint_only = false var lint_only = false
var lint_init = false var lint_init = false
var json = false
var stdin_name = ""
var stdin_rel = ""
let files = new []pointer let files = new []pointer
var ai = 1 var ai = 1
while ai < arg_count() { while ai < arg_count() {
let a = arg(ai) let a = arg(ai)
if (a == "--json") { json = true; ai += 1; continue }
if (a == "--stdin-name") and ai + 1 < arg_count() { stdin_name = string(arg(ai + 1)); ai += 2; continue }
if (a == "--stdin-rel") and ai + 1 < arg_count() { stdin_rel = string(arg(ai + 1)); ai += 2; continue }
if (a == "-w") or (a == "--write") { write = true } if (a == "-w") or (a == "--write") { write = true }
else { if (a == "--check") or (a == "-l") { check = true } else { if (a == "--check") or (a == "-l") { check = true }
else { if (a == "-q") or (a == "--quiet") { quiet = true } else { if (a == "-q") or (a == "--quiet") { quiet = true }
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 == "--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 == "--lint") { lint_only = true } else { if (a == "--lint") { lint_only = true }
else { if (a == "--init-baseline") { lint_only = true; lint_init = true } else { if (a == "--init-baseline") { lint_only = true; lint_init = true }
else { if (a == "-h") or (a == "--help") { print("ludic-fmt — format Ludic source; --check also checks package.ludic's lint rules, --lint checks the project"); return } else { if (a == "-h") or (a == "--help") { print("ludic-fmt — format Ludic source; --check also checks package.ludic's lint rules, --lint checks the project (--json: as a JSON array); - formats stdin (--stdin-name PATH: as that file)"); return }
else { push(files, a) } } } } } } } else { push(files, a) } } } } } } }
ai += 1 ai += 1
} }
if indent < 1 or indent > 8 { indent = 2 } if indent < 1 or indent > 8 { indent = 2 }
let stdin_dash = len(files) == 1 and (files[0] == "-")
if json and not lint_only { lt_err("ludic-fmt: --json goes with --lint\n"); exit(2) }
lt_json = json
lt_partial = json and not lint_init # a report for an editor never rewrites the baseline
# - --lint: a buffer on stdin, judged as the file --stdin-name names
if lint_only and stdin_dash {
if lint_init { lt_err("ludic-fmt: --init-baseline reads the project, not stdin\n"); exit(2) }
if not fs_lint_stdin(stdin_name, stdin_rel) { exit(1) }
return
}
# --lint: the project's rules over its paths, against the baseline (L10) # --lint: the project's rules over its paths, against the baseline (L10)
if lint_only { if lint_only {
if not lint_config() { print("ludic-fmt --lint: package.ludic states no `lint` rules"); exit(2) } if not lint_config() {
if json { lt_err("ludic-fmt --lint: package.ludic states no `lint` rules\n") } else { print("ludic-fmt --lint: package.ludic states no `lint` rules") }
exit(2)
}
let all = new []string let all = new []string
var li = 0 var li = 0
while li < len(lt_paths) { while li < len(lt_paths) {
@ -668,9 +692,19 @@ program LudicFmt {
let linting = check and lint_config() let linting = check and lint_config()
# stdin filter mode # stdin filter mode
if len(files) == 0 or (len(files) == 1 and (files[0] == "-")) { if len(files) == 0 or stdin_dash {
let text = slurp_stdin() let text = slurp_stdin()
let out = format_source(text, false, indent) let md = fs_ends(stdin_name, ".md") or fs_ends(stdin_name, ".markdown")
if not md {
lex(text)
if not fs_parses() {
var shown = stdin_name
if len(shown) == 0 { shown = "<stdin>" }
lt_err(`{shown}:{fs_err_line}:{fs_err_col}: error: {fs_err_msg}\n`)
exit(2)
}
}
let out = format_source(text, md, indent)
file_write(file_stdout(), out, cstr_len(out)) file_write(file_stdout(), out, cstr_len(out))
return return
} }

View file

@ -22,6 +22,7 @@ var lr_file: []string = new []string
var lr_rule: []string = new []string var lr_rule: []string = new []string
var lr_line: []int = new []int var lr_line: []int = new []int
var lr_msg: []string = new []string var lr_msg: []string = new []string
var lr_col: []int = new []int # 1-based byte column, 0 when the rule has none (a file's length)
function lt_atoi(s: string) -> int { function lt_atoi(s: string) -> int {
var n = 0 var n = 0
@ -103,9 +104,11 @@ function lint_config() -> bool {
} }
return any return any
} }
function lt_report(path: string, rule: string, line: int, msg: string) -> void { function lt_report(path: string, rule: string, line: int, msg: string) -> void { lt_report_at(path, rule, line, 0, msg) }
function lt_report_at(path: string, rule: string, line: int, col: int, msg: string) -> void {
push(lr_file, path) push(lr_file, path)
push(lr_rule, rule) push(lr_rule, rule)
push(lr_line, line) push(lr_line, line)
push(lr_col, col)
push(lr_msg, msg) push(lr_msg, msg)
} }

View file

@ -0,0 +1,91 @@
# fmt_lint_json.ludic — `ludic-fmt --lint --json`: the violations --lint would print, as ONE JSON
# array on stdout for an editor, `[{"file", "line", "col", "rule", "message"}]` ordered by file, then
# line, then column; "col" only where the rule has one. Everything else --lint says goes to stderr.
var lt_json: bool = false
var lj_idx: []int = new []int # the reports --lint would have printed, by index
var lt_show_from: string = "" # a buffer on stdin is judged as this path (its baseline, its scope)
var lt_show_as: string = "" # and reported under this name (--stdin-name, or "-")
function lt_err(s: string) -> void { file_write(file_stderr(), s, len(s)) }
function lt_shown(path: string) -> string {
if len(lt_show_from) > 0 and path == lt_show_from { return lt_show_as }
return path
}
function lt_strcmp(a: string, b: string) -> int {
var i = 0
while i < len(a) and i < len(b) {
if a[i] != b[i] { return a[i] - b[i] }
i += 1
}
return len(a) - len(b)
}
# report x before report y?
function lj_before(x: int, y: int) -> bool {
let c = lt_strcmp(lr_file[x], lr_file[y])
if c != 0 { return c < 0 }
if lr_line[x] != lr_line[y] { return lr_line[x] < lr_line[y] }
if lr_col[x] != lr_col[y] { return lr_col[x] < lr_col[y] }
return lt_strcmp(lr_rule[x], lr_rule[y]) < 0
}
function lj_sort() -> void {
var i = 1
while i < len(lj_idx) {
let x = lj_idx[i]
var j = i - 1
while j >= 0 and lj_before(x, lj_idx[j]) {
lj_idx[j + 1] = lj_idx[j]
j -= 1
}
lj_idx[j + 1] = x
i += 1
}
}
function lj_hex(d: int) -> int {
if d < 10 { return '0' + d }
return 'a' + d - 10
}
# `s` as a JSON string, quotes included
function lt_jq(b: Buf, s: string) -> void {
buf_putc(b, '"')
var i = 0
while i < len(s) {
let c = s[i] & 255
if c == '"' { buf_puts(b, "\\\"") }
else if c == '\\' { buf_puts(b, "\\\\") }
else if c == '\n' { buf_puts(b, "\\n") }
else if c == '\t' { buf_puts(b, "\\t") }
else if c == '\r' { buf_puts(b, "\\r") }
else if c < 32 {
buf_puts(b, "\\u00")
buf_putc(b, lj_hex(c >> 4))
buf_putc(b, lj_hex(c & 15))
}
else { buf_putc(b, c) }
i += 1
}
buf_putc(b, '"')
}
function lt_json_out() -> void {
lj_sort()
let b = buf_new()
buf_putc(b, '[')
var k = 0
while k < len(lj_idx) {
let i = lj_idx[k]
if k > 0 { buf_putc(b, ',') }
buf_puts(b, "\n {\"file\": ")
lt_jq(b, lt_shown(lr_file[i]))
buf_puts(b, `, "line": {lr_line[i]}`)
if lr_col[i] > 0 { buf_puts(b, `, "col": {lr_col[i]}`) }
buf_puts(b, ", \"rule\": ")
lt_jq(b, lr_rule[i])
buf_puts(b, ", \"message\": ")
lt_jq(b, lr_msg[i])
buf_putc(b, '}')
k += 1
}
if k > 0 { buf_putc(b, '\n') }
buf_puts(b, "]\n")
let s = buf_str(b)
file_write(file_stdout(), s, cstr_len(s))
}

View file

@ -26,8 +26,9 @@ function lint_comments(path: string, ls: []string) -> void {
} else { } else {
if run > 0 { if run > 0 {
let header = not code_seen and start == 0 let header = not code_seen and start == 0
if header and lt_max_header > 0 and run > lt_max_header { lt_report(path, "max_header_lines", start + 1, `the opening comment is {run} lines (the limit is {lt_max_header})`) } let col = lt_indent(ls[start]) + 1
if not header and lt_max_comment > 0 and run > lt_max_comment { lt_report(path, "max_comment_lines", start + 1, `a comment of {run} lines (the limit is {lt_max_comment}): say why in a line or two; the story goes in the commit or docs/`) } if header and lt_max_header > 0 and run > lt_max_header { lt_report_at(path, "max_header_lines", start + 1, col, `the opening comment is {run} lines (the limit is {lt_max_header})`) }
if not header and lt_max_comment > 0 and run > lt_max_comment { lt_report_at(path, "max_comment_lines", start + 1, col, `a comment of {run} lines (the limit is {lt_max_comment}): say why in a line or two; the story goes in the commit or docs/`) }
run = 0 run = 0
} }
if not blank { code_seen = true } if not blank { code_seen = true }
@ -35,6 +36,14 @@ function lint_comments(path: string, ls: []string) -> void {
i += 1 i += 1
} }
} }
# the spaces and tabs a line opens with
function lt_indent(line: string) -> int {
var i = 0
while i < len(line) and (line[i] == ' ' or line[i] == 9) { i += 1 }
return i
}
# a token's 1-based byte column on its line
function lt_tok_col(i: int) -> int { return tk_start[i] - linestart[tk_line[i]] + 1 }
# 0 a blank line, 1 a comment line, 2 code # 0 a blank line, 1 a comment line, 2 code
function lt_words_raw(line: string) -> int { function lt_words_raw(line: string) -> int {
var i = 0 var i = 0
@ -48,7 +57,7 @@ function lint_statements(path: string) -> void {
while i < ntok() { while i < ntok() {
if tk_kind[i] == LT_OP and tok_len(i) == 1 and src[tk_start[i]] == ';' { if tk_kind[i] == LT_OP and tok_len(i) == 1 and src[tk_start[i]] == ';' {
let k = tk_kind[i + 1] let k = tk_kind[i + 1]
if k != LT_NL and k != LT_COMMENT and k != LT_EOF { lt_report(path, "one_statement", tk_line[i] + 1, "two statements on one line") } if k != LT_NL and k != LT_COMMENT and k != LT_EOF { lt_report_at(path, "one_statement", tk_line[i] + 1, lt_tok_col(i + 1), "two statements on one line") }
} }
i += 1 i += 1
} }
@ -60,7 +69,7 @@ function lint_functions(path: string) -> void {
let end = lt_fn_end(i) let end = lt_fn_end(i)
if end >= 0 { if end >= 0 {
let n = tk_line[end] - tk_line[i] + 1 let n = tk_line[end] - tk_line[i] + 1
if n > lt_max_fn { lt_report(path, "max_function_lines", tk_line[i] + 1, `{lt_fn_name(i)} is {n} lines (the limit is {lt_max_fn})`) } if n > lt_max_fn { lt_report_at(path, "max_function_lines", tk_line[i] + 1, lt_tok_col(i), `{lt_fn_name(i)} is {n} lines (the limit is {lt_max_fn})`) }
} }
} }
i += 1 i += 1

View file

@ -104,14 +104,23 @@ function lint_judge(init: bool) -> bool {
file_close(f) file_close(f)
} }
} }
print(` lint: {total} violation(s) in the baseline, {len(pf)} file/rule pair(s)`) let summary = ` lint: {total} violation(s) in the baseline, {len(pf)} file/rule pair(s)`
if lt_json {
lt_err(`{summary}\n`)
lt_json_out()
} else { print(summary) }
return ok return ok
} }
function lt_print_pair(path: string, rule: string, now: int, allow: int) -> void { function lt_print_pair(path: string, rule: string, now: int, allow: int) -> void {
var i = 0 var i = 0
while i < len(lr_file) { while i < len(lr_file) {
if lr_file[i] == path and lr_rule[i] == rule { print(`{path}:{lr_line[i]}: {rule}: {lr_msg[i]}`) } if lr_file[i] == path and lr_rule[i] == rule {
if lt_json { push(lj_idx, i) } else { print(`{lt_shown(path)}:{lr_line[i]}: {rule}: {lr_msg[i]}`) }
}
i += 1 i += 1
} }
if allow > 0 { print(` ({path} may have {allow} of {rule}, and has {now})`) } if allow > 0 {
let note = ` ({lt_shown(path)} may have {allow} of {rule}, and has {now})`
if lt_json { lt_err(`{note}\n`) } else { print(note) }
}
} }

View file

@ -0,0 +1,110 @@
# fmt_stdin.ludic — `ludic-fmt -`: a buffer on stdin, formatted to stdout, for an editor. The
# formatter works on tokens and never fails, so a buffer it cannot read as Ludic - a string, a
# template or a key literal left open, a bracket never closed or closed by the wrong one - would come
# back re-indented around the fault; such a buffer is refused instead, with where on stderr.
var fs_err_line: int = 0
var fs_err_col: int = 0
var fs_err_msg: string = ""
function fs_fail(i: int, msg: string) -> bool {
fs_err_line = tk_line[i] + 1
fs_err_col = tk_start[i] - linestart[tk_line[i]] + 1
fs_err_msg = msg
return false
}
# a string-ish token that reached the end of its line (or the text) without its closing quote
function fs_open_quote(i: int) -> bool {
let t = tok_text(i)
let n = tok_len(i)
var q = t[0]
var body = 1
if q == 'k' {
q = '"'
body = 2
if n > 1 and t[1] == 'n' { body = 3 }
}
if n <= body { return true }
if t[n - 1] != q { return true }
return t[n - 2] == '\\' and not fs_escaped_end(t, n)
}
# is the backslash before the last byte itself escaped (`"a\\"` is closed, `"a\"` is not)?
function fs_escaped_end(t: pointer, n: int) -> bool {
var k = n - 2
var run = 0
while k >= 1 and t[k] == '\\' {
run += 1
k -= 1
}
return run % 2 == 0
}
function fs_closer(c: int) -> int {
if c == '(' { return ')' }
if c == '[' { return ']' }
return '}'
}
# lex(text) first; true when the tokens read as Ludic, else fs_err_* say where they stop
function fs_parses() -> bool {
let open = new []int
var i = 0
while i < ntok() {
let k = tk_kind[i]
if k == LT_STR and fs_open_quote(i) { return fs_fail(i, "this string is never closed") }
if k == LT_OP and tok_len(i) == 1 {
let c = src[tk_start[i]]
if c == '(' or c == '[' or c == '{' { push(open, i) }
if c == ')' or c == ']' or c == '}' {
if len(open) == 0 { return fs_fail(i, `a {string(tok_text(i))} with nothing open to close`) }
let o = open[len(open) - 1]
let want = fs_closer(src[tk_start[o]])
if c != want {
let at = `{tk_line[o] + 1}:{tk_start[o] - linestart[tk_line[o]] + 1}`
return fs_fail(i, `a {string(tok_text(i))} where the {string(tok_text(o))} opened at {at} wants its close`)
}
let closed = List.pop(open)
}
}
i += 1
}
if len(open) > 0 {
let o = open[len(open) - 1]
return fs_fail(o, `this {string(tok_text(o))} is never closed`)
}
return true
}
function fs_ends(s: string, suf: string) -> bool {
if len(suf) > len(s) { return false }
return s[len(s) - len(suf) .. len(s)] == suf
}
function fs_strip_dot(p: string) -> string {
var s = p
while len(s) >= 2 and s[0] == '.' and s[1] == '/' { s = s[2 .. len(s)] }
while len(s) > 1 and s[len(s) - 1] == '/' { s = s[0 .. len(s) - 1] }
return s
}
# is a buffer standing for `rel` (relative to the project) one --lint would walk?
function fs_in_scope(rel: string) -> bool {
if not fs_ends(rel, ".ludic") { return false }
var i = 0
while i < len(lt_paths) {
let p = fs_strip_dot(lt_paths[i])
if p == "." or rel == p { return true }
if len(rel) > len(p) and rel[0 .. len(p)] == p and rel[len(p)] == '/' { return true }
i += 1
}
return false
}
# `ludic-fmt - --lint [--json]`: the buffer judged as the file it stands for, against the baseline,
# which is never rewritten from a buffer. A buffer outside the project's lint paths breaks no rule.
function fs_lint_stdin(name: string, rel_in: string) -> bool {
var rel = fs_strip_dot(rel_in)
if len(rel) == 0 { rel = fs_strip_dot(name) }
var shown = name
if len(shown) == 0 { shown = "-" }
if len(rel) == 0 { rel = "-" }
lt_show_from = rel
lt_show_as = shown
let text = string(slurp_stdin())
if lint_config() and (rel == "-" or fs_in_scope(rel)) { lint_file(rel, text) }
lt_partial = true
return lint_judge(false)
}