Merge commit 'c804c4b' into lang/foundations (R10: --stdin-file checks an unsaved buffer in place of a file the program reads); reseeded, bootstrap-cfree fixpoint holds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-30 00:15:01 +03:00
commit 3d65ec6def
12 changed files with 184559 additions and 12481 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

@ -2323,6 +2323,7 @@ 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 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 and natives, for editors ludic schema -o build/schema.json # records, registries, entries, consts, components and natives, 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
@ -2346,6 +2347,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

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,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

@ -162,7 +162,11 @@ entry {
else if a == "--release" { g_release = true } # what ships: Build.schema_hash() is 0 (emit_schema_hash.ludic) else if a == "--release" { g_release = true } # what ships: Build.schema_hash() is 0 (emit_schema_hash.ludic)
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 }
<<<<<<< HEAD
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)
>>>>>>> c804c4b
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 +267,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

@ -49,6 +49,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]")

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
} }

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")

View file

@ -99,6 +99,30 @@ 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)
}
# 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 +1251,7 @@ 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()
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")