on master help sign in
16 files changed+2332−323
since splitsnapshots
unifiedsplit
Cargo.lock+6 Cargo.toml+1 crates/knot-compiler/Cargo.toml+5 crates/knot-compiler/src/codegen.rs+278−91 crates/knot-compiler/src/infer.rs+45−4 crates/knot-compiler/src/main.rs+2−1 crates/knot-compiler/src/types.rs+141−2 crates/knot-compiler/tests/descriptor_roundtrip.rs+84 crates/knot-descriptor/Cargo.toml+6 crates/knot-descriptor/src/lib.rs+376 crates/knot-jit/tests/cli_overrides.rs+567−1 crates/knot-runtime/Cargo.toml+1 crates/knot-runtime/src/db_dump.rs+143 crates/knot-runtime/src/lib.rs+635−146 crates/knot-runtime/src/tui.rs+21−78 crates/knot/src/parser.rs+21
Cargo.lock+6
@@ -1491,6 +1491,7 @@ dependencies = [
14911491 "gimli",
14921492 "indexmap",
14931493 "knot",
1494+ "knot-descriptor",
14941495 "knot-runtime",
14951496 "libc",
14961497 "object",
@@ -1498,6 +1499,10 @@ dependencies = [
14981499 "z3",
14991500]
15001501
1502+[[package]]
1503+name = "knot-descriptor"
1504+version = "0.1.0"
1505+
15011506[[package]]
15021507name = "knot-jit"
15031508version = "0.1.0"
@@ -1537,6 +1542,7 @@ dependencies = [
15371542 "dashmap",
15381543 "ed25519-dalek",
15391544 "getrandom 0.4.2",
1545+ "knot-descriptor",
15401546 "libc",
15411547 "ratatui",
15421548 "rusqlite",
Cargo.toml+1
@@ -17,6 +17,7 @@ edition = "2024"
1717[workspace.dependencies]
1818libc = "0.2"
1919knot = { path = "crates/knot" }
20+knot-descriptor = { path = "crates/knot-descriptor" }
2021knot-compiler = { path = "crates/knot-compiler" }
2122knot-runtime = { path = "crates/knot-runtime" }
2223knot-jit = { path = "crates/knot-jit" }
crates/knot-compiler/Cargo.toml+5
@@ -13,6 +13,7 @@ path = "src/main.rs"
1313
1414[dependencies]
1515knot = { workspace = true }
16+knot-descriptor = { workspace = true }
1617knot-runtime = { workspace = true }
1718cranelift-codegen = { workspace = true }
1819cranelift-frontend = { workspace = true }
@@ -40,3 +41,7 @@ cranelift-jit = "0.131"
4041
4142[build-dependencies]
4243blake3 = { version = "1", default-features = false }
44+
45+[dev-dependencies]
46+knot-descriptor = { workspace = true }
47+
crates/knot-compiler/src/codegen.rs+278−91
@@ -198,6 +198,12 @@ pub struct Codegen<M: cranelift_module::Module = ObjectModule> {
198198 // `from` of a pending migration's first step (the baseline it upgrades).
199199 locked_schemas: HashMap<String, String>,
200200
201+ // Every migration step (committed chain + pending), as pure data
202+ // (source, step_idx, from_schema, to_schema, using_src). Registered with the
203+ // runtime in C main's PROLOGUE — before the `db` subcommand dispatch — so
204+ // `db info` can show the full chain without running `knot_user_main`.
205+ migration_steps: Vec<(String, i64, String, String, String)>,
206+
201207 // Fork identity for an uncommitted migration: hash of the pending
202208 // (to-schema, using-src) pairs, or empty when there is no pending
203209 // migration. When set, the runtime routes the DB open to a fork file so
@@ -231,6 +237,11 @@ pub struct Codegen<M: cranelift_module::Module = ObjectModule> {
231237 // Type aliases for resolving response types in OpenAPI descriptors
232238 type_aliases: HashMap<String, ResolvedType>,
233239
240+ // CLI `#`-arg merged defaults: arg name → the concrete literal default any
241+ // scope supplies. A `_` (required) declaration whose name has a concrete
242+ // default elsewhere uses it instead of aborting when the flag is absent.
243+ cli_arg_defaults: HashMap<String, Option<String>>,
244+
234245 // Trampolines for user functions used as values: fn_name -> trampoline_func_id
235246 user_fn_trampolines: HashMap<String, FuncId>,
236247
@@ -674,19 +685,31 @@ fn scalar_override_type(
674685 // unit-agnostic, classify by the base scalar.
675686 name if name.starts_with("Int ") => "Int".to_string(),
676687 name if name.starts_with("Float ") => "Float".to_string(),
677− name if name.starts_with("Rel ") => {
678− let elem = name[4..].trim();
688+ name if name.starts_with("Rel ")
689+ || name.starts_with("Vec ")
690+ || name.starts_with("List ") =>
691+ {
692+ let container = name.split_whitespace().next().unwrap();
693+ let elem = name[container.len()..].trim();
679694 let elem = elem
680695 .strip_prefix('(')
681696 .and_then(|r| r.strip_suffix(')'))
682697 .unwrap_or(elem);
683698 let elem_base = match elem {
684− "Int" | "Float" | "Text" | "Bool" => elem,
685− e if e.starts_with("Int ") => "Int",
686− e if e.starts_with("Float ") => "Float",
699+ "Int" | "Float" | "Text" | "Bool" => elem.to_string(),
700+ "Bytes" => "Bytes".to_string(),
701+ "Uuid" => "Uuid".to_string(),
702+ e if e.starts_with("Int ") => "Int".to_string(),
703+ e if e.starts_with("Float ") => "Float".to_string(),
704+ // A refined element parses as its base scalar; the descriptor
705+ // carries the refined name so the per-element predicate check
706+ // can run. An enum element parses as a constructor name; the
707+ // ctor set is expanded later by `override_runtime_descriptor`.
708+ e if refined_types.contains_key(e) => format!("refined:{e}"),
709+ e if adt_ctors.contains_key(e) => format!("enum:{e}"),
687710 _ => return None,
688711 };
689− format!("Rel {elem_base}")
712+ format!("{container} {elem_base}")
690713 }
691714 name => {
692715 match type_aliases.get(name)? {
@@ -992,6 +1015,7 @@ fn compile_inner<M: cranelift_module::Module>(
9921015 .unwrap_or("knot");
9931016 cg.db_path = format!("{}.db", stem);
9941017 cg.source_schemas = type_env.source_schemas.clone();
1018+ cg.cli_arg_defaults = type_env.cli_arg_defaults.clone();
9951019 for (name, schema) in &type_env.source_schemas {
9961020 if schema.starts_with("_value:") {
9971021 cg.scalar_sources.insert(name.clone());
@@ -1030,6 +1054,42 @@ fn compile_inner<M: cranelift_module::Module>(
10301054 fork_hash(&parts.join(""))
10311055 };
10321056 }
1057+ // Compute the per-source migration step metadata EAGERLY (before any
1058+ // function is emitted), so C main's prologue — built before `knot_user_main`
1059+ // — can register it for `db info`. Mirrors the step assembly in the
1060+ // migration emission loop, minus the parsed fn (which only that loop needs).
1061+ {
1062+ let pending = crate::lockfile::pending_migrations(program);
1063+ let mut rels: Vec<String> = cg.migrate_chains.keys().cloned().collect();
1064+ for (r, _) in &pending {
1065+ if !rels.contains(r) {
1066+ rels.push(r.clone());
1067+ }
1068+ }
1069+ rels.sort();
1070+ for relation in rels {
1071+ let mut steps: Vec<(i64, String, String, String)> = Vec::new();
1072+ if let Some(chain) = cg.migrate_chains.get(&relation) {
1073+ for step in chain {
1074+ steps.push((
1075+ steps.len() as i64,
1076+ step.from_schema.clone(),
1077+ step.to_schema.clone(),
1078+ step.using_src.clone(),
1079+ ));
1080+ }
1081+ }
1082+ if let Some((_, p)) = pending.iter().find(|(r, _)| r.as_str() == relation.as_str()) {
1083+ let from = cg.locked_schemas.get(&relation).cloned().unwrap_or_default();
1084+ let to = cg.pending_targets.get(&relation).cloned().unwrap_or_default();
1085+ let using = knot::format::render_expr_source(&p.using_fn);
1086+ steps.push((steps.len() as i64, from, to, using));
1087+ }
1088+ for (idx, from, to, using) in steps {
1089+ cg.migration_steps.push((relation.clone(), idx, from, to, using));
1090+ }
1091+ }
1092+ }
10331093 // Sources the codebase no longer declares but the schema lockfile still
10341094 // tracks: emit a startup DROP for each so removed relations don't linger.
10351095 cg.dropped_sources =
@@ -1667,6 +1727,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
16671727 migrate_chains: HashMap::new(),
16681728 pending_targets: HashMap::new(),
16691729 locked_schemas: HashMap::new(),
1730+ migration_steps: Vec::new(),
16701731 fork_token: String::new(),
16711732 dropped_sources: Vec::new(),
16721733 diagnostics: Vec::new(),
@@ -1675,6 +1736,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
16751736 route_entries: HashMap::new(),
16761737 fetch_route_entries: HashMap::new(),
16771738 type_aliases: HashMap::new(),
1739+ cli_arg_defaults: HashMap::new(),
16781740 user_fn_trampolines: HashMap::new(),
16791741 capture_env_in_progress: std::collections::HashSet::new(),
16801742 relational_do_spans: HashSet::new(),
@@ -1872,6 +1934,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
18721934 // failure in any one source rolls all of them back.
18731935 self.declare_rt("knot_migrate_group_begin", &[p], &[]);
18741936 self.declare_rt("knot_migrate_group_end", &[p], &[]);
1937+ self.declare_rt("knot_register_migration_step", &[p, p, types::I64, p, p, p, p, p, p], &[]);
18751938 // Runs knot_user_main under a catch_unwind boundary on the main thread.
18761939 self.declare_rt("knot_run_user_main", &[p, p], &[p]);
18771940 // Runs the whole program (knot_program_main) on a worker thread under
@@ -1891,9 +1954,11 @@ impl<M: cranelift_module::Module> Codegen<M> {
18911954
18921955 // CLI constant overrides
18931956 self.declare_rt("knot_override_lookup", &[p, p, types::I32], &[p]);
1957+ self.declare_rt("knot_override_forbid", &[p, p], &[p]);
18941958 self.declare_rt("knot_override_lookup_ex", &[p, p, p, p], &[p]);
18951959 self.declare_rt("knot_override_unwrap_just", &[p], &[p]);
18961960 self.declare_rt("knot_override_check_refined", &[p, p, p, p, p, p], &[]);
1961+ self.declare_rt("knot_override_check_refined_each", &[p, p, p, p, p, p, p], &[]);
18971962 self.declare_rt("knot_override_check_help", &[p, p], &[]);
18981963
18991964 // STM tracking
@@ -2183,7 +2248,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
21832248 self.declare_rt("knot_api_register", &[p, p, p], &[]);
21842249 self.declare_rt("knot_api_handle", &[types::I32, p], &[types::I32]);
21852250
2186− // DB explorer TUI
2251+ // DB explorer TUI + binary dump/restore (`db dump`/`db restore`)
21872252 self.declare_rt("knot_db_handle", &[types::I32, p, p, p], &[types::I32]);
21882253 self.declare_rt("knot_dump_handle", &[types::I32, p, p, p], &[types::I32]);
21892254 self.declare_rt("knot_schema_source_set", &[p, p], &[]);
@@ -4579,6 +4644,23 @@ impl<M: cranelift_module::Module> Codegen<M> {
45794644 cg.call_rt_void(builder, "knot_schema_source_set", &[sptr, slen]);
45804645 }
45814646
4647+ // Register the baked-in migration chain with the runtime here, in
4648+ // the prologue — BEFORE the `db` subcommand dispatch — so `db info`
4649+ // can show the full chain without running `knot_user_main`.
4650+ let steps = cg.migration_steps.clone();
4651+ for (src_name, step_idx, from, to, using_src) in steps {
4652+ let (nptr, nlen) = cg.string_ptr(builder, &src_name);
4653+ let (fptr, flen) = cg.string_ptr(builder, &from);
4654+ let (tptr, tlen) = cg.string_ptr(builder, &to);
4655+ let (uptr, ulen) = cg.string_ptr(builder, &using_src);
4656+ let idx = builder.ins().iconst(types::I64, step_idx);
4657+ cg.call_rt_void(
4658+ builder,
4659+ "knot_register_migration_step",
4660+ &[nptr, nlen, idx, fptr, flen, tptr, tlen, uptr, ulen],
4661+ );
4662+ }
4663+
45824664 // Register route tables for the api command
45834665 let mut route_tables: Vec<(Value, Vec<ast::RouteEntry>)> = Vec::new();
45844666 for (route_name, entries) in &all_routes {
@@ -4861,9 +4943,11 @@ impl<M: cranelift_module::Module> Codegen<M> {
48614943 }
48624944
48634945 for relation in relations {
4864− // Assemble (old_schema, new_schema, using_fn) steps: committed
4865− // chain first, then the pending step (if any).
4866− let mut steps: Vec<(String, String, ast::Expr)> = Vec::new();
4946+ // Assemble (old_schema, new_schema, using_fn, using_src) steps:
4947+ // committed chain first, then the pending step (if any).
4948+ // `using_src` is the raw migration-fn text, registered so
4949+ // `db info` can show the full chain.
4950+ let mut steps: Vec<(String, String, ast::Expr, String)> = Vec::new();
48674951 if let Some(chain) = migrate_chains.get(&relation) {
48684952 for step in chain {
48694953 match parse_migration_fn(&step.using_src) {
@@ -4871,6 +4955,7 @@ impl<M: cranelift_module::Module> Codegen<M> {
48714955 step.from_schema.clone(),
48724956 step.to_schema.clone(),
48734957 f,
4958+ step.using_src.clone(),
48744959 )),
48754960 None => {
48764961 cg.diagnostics.push(knot::diagnostic::Diagnostic::error(
@@ -4895,10 +4980,12 @@ impl<M: cranelift_module::Module> Codegen<M> {
48954980 .cloned()
48964981 .unwrap_or_default();
48974982 let to = pending_targets.get(&relation).cloned().unwrap_or_default();
4898− steps.push((from, to, using_ast.clone()));
4983+ let sp = using_ast.span;
4984+ let src = cg.source_text[sp.start..sp.end].to_string();
4985+ steps.push((from, to, using_ast.clone(), src));
48994986 }
49004987
4901− for (step_idx, (old_schema, new_schema, using_fn)) in steps.iter().enumerate() {
4988+ for (step_idx, (old_schema, new_schema, using_fn, _using_src)) in steps.iter().enumerate() {
49024989 let (name_ptr, name_len) = cg.string_ptr(builder, &relation);
49034990 let (old_ptr, old_len) = cg.string_ptr(builder, &old_schema);
49044991 let (new_ptr, new_len) = cg.string_ptr(builder, &new_schema);
@@ -6567,6 +6654,28 @@ ast::ExprKind::Atomic(inner) => {
65676654 );
65686655 }
65696656
6657+ // A collection of a refined element (`Vec refined:Pos`,
6658+ // `Rel refined:Pos`, `List refined:Pos`, optionally
6659+ // `Maybe`-wrapped): run the predicate per element.
6660+ let coll_refined = ["Vec refined:", "Rel refined:", "List refined:"]
6661+ .iter()
6662+ .find_map(|p| {
6663+ type_str
6664+ .strip_prefix(p)
6665+ .or_else(|| type_str.strip_prefix(&format!("Maybe {p}")))
6666+ });
6667+ if let Some(refined_name) = coll_refined {
6668+ let refined_name = refined_name.to_string();
6669+ self.emit_refined_collection_override_check(
6670+ builder,
6671+ name,
6672+ &refined_name,
6673+ override_val,
6674+ env,
6675+ db,
6676+ );
6677+ }
6678+
65706679 match default {
65716680 Some(d) => {
65726681 // Optional: use override if present, else the default.
@@ -6596,30 +6705,82 @@ ast::ExprKind::Atomic(inner) => {
65966705 builder.block_params(merge_block)[0]
65976706 }
65986707 None => {
6599− // Required: abort if the flag is missing.
6600− let missing_block = builder.create_block();
6601− let ok_block = builder.create_block();
6602− let is_null =
6603− builder.ins().icmp_imm(IntCC::Equal, override_val, 0);
6604− builder
6605− .ins()
6606− .brif(is_null, missing_block, &[], ok_block, &[]);
6607− builder.switch_to_block(missing_block);
6608− builder.seal_block(missing_block);
6609− let (msg_ptr, msg_len) = self.string_ptr(
6610− builder,
6611− &format!("missing required argument --{}", name),
6612− );
6613− let _ = self.call_rt(builder, "knot_todo", &[msg_ptr, msg_len]);
6614− builder.ins().jump(ok_block, &[]);
6615− builder.switch_to_block(ok_block);
6616− builder.seal_block(ok_block);
6617− override_val
6708+ // Required — UNLESS another scope declared this same
6709+ // `#name` with a concrete default (the agreement check
6710+ // merged it). Then the flag-or-default logic applies:
6711+ // the flag wins, else the merged default.
6712+ let merged_default = self
6713+ .cli_arg_defaults
6714+ .get(name.as_str())
6715+ .and_then(|d| d.as_ref())
6716+ .cloned();
6717+ match merged_default {
6718+ Some(lit_src) => {
6719+ // Parse the merged literal default into an
6720+ // expr and use the optional-arg default path.
6721+ let lexer = knot::lexer::Lexer::new(&lit_src);
6722+ let (tokens, _) = lexer.tokenize();
6723+ let parser =
6724+ knot::parser::Parser::new(lit_src.clone(), tokens);
6725+ let (dexpr, _) = parser.parse_file_expr();
6726+ let default_block = builder.create_block();
6727+ let override_block = builder.create_block();
6728+ let merge_block = builder.create_block();
6729+ builder.append_block_param(merge_block, self.ptr_type);
6730+ let is_null = builder
6731+ .ins()
6732+ .icmp_imm(IntCC::Equal, override_val, 0);
6733+ builder
6734+ .ins()
6735+ .brif(is_null, default_block, &[], override_block, &[]);
6736+
6737+ builder.switch_to_block(override_block);
6738+ builder.seal_block(override_block);
6739+ builder.ins().jump(merge_block, &[override_val.into()]);
6740+
6741+ builder.switch_to_block(default_block);
6742+ builder.seal_block(default_block);
6743+ let default_val =
6744+ self.compile_expr(builder, &dexpr, env, db);
6745+ builder.ins().jump(merge_block, &[default_val.into()]);
6746+
6747+ builder.switch_to_block(merge_block);
6748+ builder.seal_block(merge_block);
6749+ builder.block_params(merge_block)[0]
6750+ }
6751+ None => {
6752+ // Truly required: abort if the flag is missing.
6753+ let missing_block = builder.create_block();
6754+ let ok_block = builder.create_block();
6755+ let is_null = builder
6756+ .ins()
6757+ .icmp_imm(IntCC::Equal, override_val, 0);
6758+ builder
6759+ .ins()
6760+ .brif(is_null, missing_block, &[], ok_block, &[]);
6761+ builder.switch_to_block(missing_block);
6762+ builder.seal_block(missing_block);
6763+ let (msg_ptr, msg_len) = self.string_ptr(
6764+ builder,
6765+ &format!("missing required argument --{}", name),
6766+ );
6767+ let _ =
6768+ self.call_rt(builder, "knot_todo", &[msg_ptr, msg_len]);
6769+ builder.ins().jump(ok_block, &[]);
6770+ builder.switch_to_block(ok_block);
6771+ builder.seal_block(ok_block);
6772+ override_val
6773+ }
6774+ }
66186775 }
66196776 }
66206777 } else {
6621− // Non-overridable type — no CLI parser. Fall through to the
6622− // default (or error for required args).
6778+ // Non-overridable type — no CLI parser. Abort loudly if the
6779+ // flag was passed (rather than silently using the default),
6780+ // then fall through to the default (or error for required
6781+ // args).
6782+ let (name_ptr, name_len) = self.string_ptr(builder, name.as_str());
6783+ self.call_rt(builder, "knot_override_forbid", &[name_ptr, name_len]);
66236784 match default {
66246785 Some(d) => self.compile_expr(builder, d.as_ref(), env, db),
66256786 None => {
@@ -13475,6 +13636,22 @@ ast::ExprKind::Atomic(inner) => {
1347513636 {
1347613637 return format!("Maybe {expanded}");
1347713638 }
13639+ // A collection element (`Vec enum:Mode`, `Rel refined:Pos`, …): expand
13640+ // the element descriptor, keeping the container prefix.
13641+ for container in ["Vec ", "Rel ", "List "] {
13642+ if let Some(rest) = type_str.strip_prefix(container)
13643+ && let Some(expanded) = expand(rest)
13644+ {
13645+ return format!("{container}{expanded}");
13646+ }
13647+ if let Some(rest) = type_str
13648+ .strip_prefix("Maybe ")
13649+ .and_then(|r| r.strip_prefix(container))
13650+ && let Some(expanded) = expand(rest)
13651+ {
13652+ return format!("Maybe {container}{expanded}");
13653+ }
13654+ }
1347813655 type_str.to_string()
1347913656 }
1348013657
@@ -13618,6 +13795,65 @@ ast::ExprKind::Atomic(inner) => {
1361813795 builder.seal_block(done_block);
1361913796 }
1362013797
13798+ /// Emit the per-element refined check for a collection override
13799+ /// (`Vec`/`Rel`/`List refined:Pos`). Compiles the predicate chain and calls
13800+ /// the runtime iterator, which applies it to each element and aborts on the
13801+ /// first failure. No-op when the flag is absent (null) — the default was
13802+ /// checked at compile time.
13803+ fn emit_refined_collection_override_check(
13804+ &mut self,
13805+ builder: &mut FunctionBuilder,
13806+ flag_name: &str,
13807+ type_name: &str,
13808+ override_val: Value,
13809+ env: &mut Env,
13810+ db: Value,
13811+ ) {
13812+ // Collect the predicate chain (stacked refinements conjoin), as in
13813+ // `emit_refined_override_check`.
13814+ let mut preds: Vec<ast::Expr> = Vec::new();
13815+ let mut cur: String = type_name.to_string();
13816+ loop {
13817+ let Some(pred) = self.refined_types.get(&cur) else { break };
13818+ preds.push(pred.clone());
13819+ let Some(ast_ty) = self.alias_ast.get(&cur) else { break };
13820+ let ast::TypeKind::Refined { base, .. } = &ast_ty.node else {
13821+ break;
13822+ };
13823+ let ast::TypeKind::Named(n) = &base.node else { break };
13824+ cur = n.clone();
13825+ }
13826+ if preds.is_empty() {
13827+ return;
13828+ }
13829+
13830+ // Unwrap a Maybe-wrapped collection (`Just {value: coll}`); a `Nothing`
13831+ // override carries no elements to check. A non-Maybe override is the
13832+ // collection itself — `knot_override_unwrap_just` returns null for a
13833+ // non-Just value, so only unwrap when the descriptor was Maybe-wrapped.
13834+ let is_maybe = self
13835+ .overridable_constants
13836+ .get(flag_name)
13837+ .map(|t| t.starts_with("Maybe "))
13838+ .unwrap_or(false);
13839+ let coll = if is_maybe {
13840+ self.call_rt(builder, "knot_override_unwrap_just", &[override_val])
13841+ } else {
13842+ override_val
13843+ };
13844+
13845+ let (flag_ptr, flag_len) = self.string_ptr(builder, flag_name);
13846+ let (ty_ptr, ty_len) = self.string_ptr(builder, type_name);
13847+ for pred in &preds {
13848+ let pred_fn = self.compile_expr(builder, pred, env, db);
13849+ self.call_rt_void(
13850+ builder,
13851+ "knot_override_check_refined_each",
13852+ &[db, coll, pred_fn, flag_ptr, flag_len, ty_ptr, ty_len],
13853+ );
13854+ }
13855+ }
13856+
1362113857 fn emit_override_literal(
1362213858 &mut self,
1362313859 builder: &mut FunctionBuilder,
@@ -18723,66 +18959,17 @@ pub(crate) fn split_schema_fields(s: &str) -> Vec<(String, String)> {
1872318959}
1872418960
1872518961/// Parse a `{name Kind name2 Kind2}` record descriptor into (name, Kind) pairs.
18962+/// Parse a record descriptor's `{name Kind name2 Kind2}` into (name, kind)
18963+/// pairs, using the single-owner `knot-descriptor` grammar. Field kinds are
18964+/// re-emitted in descriptor form (verbatim).
1872618965fn parse_schema_columns(schema: &str) -> Vec<(String, String)> {
18727− let t = schema.trim();
18728− let inner = match t.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
18729− Some(b) => b,
18730− None => return Vec::new(),
18731− };
18732− let mut out = Vec::new();
18733− let bytes = inner.as_bytes();
18734− let mut i = 0;
18735− while i < bytes.len() {
18736− // skip whitespace
18737− while i < bytes.len() && (bytes[i] as char).is_whitespace() {
18738− i += 1;
18739− }
18740− if i >= bytes.len() {
18741− break;
18742− }
18743− // field name
18744− let name_start = i;
18745− while i < bytes.len() && ((bytes[i] as char).is_alphanumeric() || bytes[i] == b'_') {
18746− i += 1;
18747− }
18748− let name = &inner[name_start..i];
18749− // skip ws
18750− while i < bytes.len() && (bytes[i] as char).is_whitespace() {
18751− i += 1;
18752− }
18753− // kind: balanced bracket group or a bare scalar ident
18754− let kind_start = i;
18755− if i < bytes.len() && (bytes[i] == b'{' || bytes[i] == b'(' || bytes[i] == b'[') {
18756− let open = bytes[i];
18757− let close = match open {
18758− b'{' => b'}',
18759− b'(' => b')',
18760− _ => b']',
18761− };
18762− let mut depth = 0usize;
18763− while i < bytes.len() {
18764− if bytes[i] == open {
18765− depth += 1;
18766− } else if bytes[i] == close {
18767− depth -= 1;
18768− if depth == 0 {
18769− i += 1;
18770− break;
18771− }
18772− }
18773− i += 1;
18774− }
18775− } else {
18776− while i < bytes.len() && ((bytes[i] as char).is_alphanumeric() || bytes[i] == b'_') {
18777− i += 1;
18778− }
18779− }
18780− let kind = &inner[kind_start..i];
18781− if !name.is_empty() {
18782− out.push((name.to_string(), kind.to_string()));
18783− }
18966+ match knot_descriptor::parse(schema.trim()) {
18967+ Ok(knot_descriptor::Desc::Record(fields)) => fields
18968+ .into_iter()
18969+ .map(|(name, ty)| (name, knot_descriptor::emit(&ty)))
18970+ .collect(),
18971+ _ => Vec::new(),
1878418972 }
18785− out
1878618973}
1878718974
1878818975// ── Pipe chain analysis ───────────────────────────────────────────
crates/knot-compiler/src/infer.rs+45−4
@@ -4398,7 +4398,7 @@ impl Infer {
43984398 if shadows_enclosing {
43994399 self.error(
44004400 format!(
4401− "`{name}` is already defined in an enclosing scope, and shadowing is not allowed"
4401+ "`{name}` is already defined in an enclosing scope , and shadowing is not allowed"
44024402 ),
44034403 span,
44044404 );
@@ -7011,10 +7011,34 @@ impl Infer {
70117011 }
70127012 };
70137013 self.push_scope();
7014+ // CLI `#arg` fields resolve globally by name, not lexically, so
7015+ // a nested `with` re-declaring an agreeing `#name` is NOT
7016+ // shadowing (every declaration refers to the same value).
7017+ // `check_with_chain_shadowing` already validated that the
7018+ // declarations agree; exempt them from `bind_at`'s shadow check.
7019+ let cli_arg_names: std::collections::HashSet<String> =
7020+ if let ast::ExprKind::Record(field_exprs) = &record.node {
7021+ field_exprs
7022+ .iter()
7023+ .filter_map(|f| match &f.value.node {
7024+ ast::ExprKind::ArgDecl { name, .. } => Some(name.clone()),
7025+ _ => None,
7026+ })
7027+ .collect()
7028+ } else {
7029+ std::collections::HashSet::new()
7030+ };
70147031 for (name, ty) in &fields {
70157032 // `fields` comes from the resolved record TYPE (no per-field
70167033 // span), so point the shadow error at the whole `with` record.
7017− self.bind_at(name, Scheme::mono(ty.clone()), record.span);
7034+ if cli_arg_names.contains(name) {
7035+ // Bind without the shadowing error (see above).
7036+ if let Some(scope) = self.scopes.last_mut() {
7037+ scope.insert(name.to_string(), Scheme::mono(ty.clone()));
7038+ }
7039+ } else {
7040+ self.bind_at(name, Scheme::mono(ty.clone()), record.span);
7041+ }
70187042 }
70197043 // Mark this scope as a `with` frame (span + field schemes) so
70207044 // the `Var` arm can redirect a `with`-field reference to the
@@ -10564,7 +10588,7 @@ ast::ExprKind::Atomic(inner) => {
1056410588 let span = value.map(|v| v.span).unwrap_or(Span { start: 0, end: 0 });
1056510589 self.error(
1056610590 format!(
10567− "`{name}` is already defined in an enclosing scope, and shadowing is not allowed"
10591+ "`{name}` is already defined in an enclosing scope , and shadowing is not allowed"
1056810592 ),
1056910593 span,
1057010594 );
@@ -14578,6 +14602,16 @@ fn check_inner(
1457814602 let mut field_tys: Vec<(String, Ty)> =
1457914603 Vec::with_capacity(field_exprs.len());
1458014604 let mut absent_tys: Vec<Ty> = Vec::new();
14605+ // CLI `#arg` names in this layer: they resolve globally by
14606+ // name, so a nested re-declaration isn't shadowing (the
14607+ // agreement check runs in `check_with_chain_shadowing`).
14608+ let cli_arg_names: std::collections::HashSet<String> = field_exprs
14609+ .iter()
14610+ .filter_map(|f| match &f.value.node {
14611+ ast::ExprKind::ArgDecl { name, .. } => Some(name.clone()),
14612+ _ => None,
14613+ })
14614+ .collect();
1458114615 for f in field_exprs {
1458214616 let val_ty = infer.infer_expr(&f.value);
1458314617 match f.name.as_name() {
@@ -14592,7 +14626,14 @@ fn check_inner(
1459214626 .push((cur.span, field_tys.iter().map(|(n, _)| n.clone()).collect()));
1459314627 infer.push_scope();
1459414628 for (name, ty) in &field_tys {
14595− infer.bind_at(name, Scheme::mono(ty.clone()), record.span);
14629+ if cli_arg_names.contains(name) {
14630+ // Agreeing `#arg`: bind without the shadowing error.
14631+ if let Some(scope) = infer.scopes.last_mut() {
14632+ scope.insert(name.to_string(), Scheme::mono(ty.clone()));
14633+ }
14634+ } else {
14635+ infer.bind_at(name, Scheme::mono(ty.clone()), record.span);
14636+ }
1459614637 }
1459714638 *infer.with_scope_stack.last_mut().expect("just pushed") = Some((
1459814639 cur.span,
crates/knot-compiler/src/main.rs+2−1
@@ -626,7 +626,7 @@ fn cmd_build(
626626
627627 // Reject `with`-chain shadowing: a field name bound by two layers of the
628628 // top-level `with {…} (with {…} …)` chain silently shadows the outer one.
629− let with_shadow_diags = types::check_with_chain_shadowing(&program);
629+ let (with_shadow_diags, cli_arg_defaults) = types::check_with_chain_shadowing(&program);
630630 if !with_shadow_diags.is_empty() {
631631 for diag in &with_shadow_diags {
632632 eprintln!("{}", diag.render(&source, &filename));
@@ -636,6 +636,7 @@ fn cmd_build(
636636
637637 // Resolve types
638638 let mut type_env = types::TypeEnv::from_program(&program);
639+ type_env.cli_arg_defaults = cli_arg_defaults;
639640
640641 // Type inference
641642 let infer::CheckOutput {
crates/knot-compiler/src/types.rs+141−2
@@ -131,6 +131,11 @@ pub struct TypeEnv {
131131 pub source_refinements: HashMap<String, Vec<(Option<String>, String, Expr)>>,
132132 /// Refined type aliases: type_name -> predicate expr
133133 pub refined_types: HashMap<String, Expr>,
134+ /// CLI `#`-arg merged defaults: arg name → the concrete literal default any
135+ /// scope supplies (`None` if every declaration is `_`/required). Computed by
136+ /// `check_with_chain_shadowing`; codegen reads it so a `_` declaration with
137+ /// a concrete default elsewhere doesn't emit the missing-argument abort.
138+ pub cli_arg_defaults: HashMap<String, Option<String>>,
134139 /// Multi-variant parameterized data types: name -> (param names,
135140 /// per-constructor raw field types). Used to substitute type arguments
136141 /// at application sites.
@@ -380,6 +385,7 @@ impl TypeEnv {
380385 subset_constraints,
381386 source_refinements: HashMap::new(),
382387 refined_types,
388+ cli_arg_defaults: HashMap::new(),
383389 multi_variant_params,
384390 single_variant_params,
385391 }
@@ -710,11 +716,22 @@ pub fn check_alias_cycles(program: &Expr) -> Vec<knot::diagnostic::Diagnostic> {
710716/// one layer. This catches what the per-scope `bind_at` check cannot: the
711717/// declaration record is the INNERMOST `with`'s record, so an outer layer's
712718/// same-named field is a separate binding that no enclosing-scope check sees.
713−pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagnostic> {
719+pub fn check_with_chain_shadowing(
720+ program: &Expr,
721+) -> (
722+ Vec<knot::diagnostic::Diagnostic>,
723+ HashMap<String, Option<String>>,
724+) {
714725 use knot::ast::ExprKind;
715726 let mut diags = Vec::new();
716727 // (name, span) of every field bound by an OUTER layer, in chain order.
717728 let mut outer: HashMap<String, Span> = HashMap::new();
729+ // `#`-argument fields seen across the chain: name → (type sig text, the
730+ // default's literal display if it is a scalar literal). CLI args resolve
731+ // globally by name, so a repeated `#name` is NOT shadowing — every
732+ // declaration refers to the same value — as long as they all agree on type
733+ // and default. Tracked separately so an agreeing repeat is allowed.
734+ let mut cli_args: HashMap<String, (Option<String>, Option<String>, Span)> = HashMap::new();
718735 let mut cur = program;
719736 loop {
720737 match &cur.node {
@@ -723,6 +740,12 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
723740 // repeated across two outer layers shadows within the chain.
724741 if let ExprKind::Record(fields) = &record.node {
725742 for f in fields {
743+ // A CLI `#arg` is not a shadowing candidate; check it
744+ // against prior `#arg` decls instead.
745+ if let ExprKind::ArgDecl { .. } = &f.value.node {
746+ check_cli_arg_agreement(f, &mut cli_args, &mut diags);
747+ continue;
748+ }
726749 // Absent keys have no name, so they can never shadow.
727750 let Some(fname) = f.name.as_name() else { continue };
728751 if outer.insert(fname.to_string(), f.value.span).is_some() {
@@ -742,6 +765,10 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
742765 // Its fields must not repeat any outer layer's name.
743766 if let ExprKind::Record(fields) = &record.node {
744767 for f in fields {
768+ if let ExprKind::ArgDecl { .. } = &f.value.node {
769+ check_cli_arg_agreement(f, &mut cli_args, &mut diags);
770+ continue;
771+ }
745772 let Some(fname) = f.name.as_name() else { continue };
746773 if outer.contains_key(fname) {
747774 diags.push(
@@ -759,7 +786,119 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
759786 _ => break,
760787 }
761788 }
762− diags
789+ // Surface the merged CLI-arg defaults: name → the concrete literal default
790+ // if any scope supplies one (`None` if every declaration is `_`/required).
791+ // Codegen uses this so a `_` declaration with a concrete default elsewhere
792+ // doesn't emit the missing-argument abort.
793+ let arg_defaults: HashMap<String, Option<String>> = cli_args
794+ .into_iter()
795+ .map(|(name, (_ty, default, _span))| (name, default))
796+ .collect();
797+ (diags, arg_defaults)
798+}
799+
800+/// Record a CLI `#arg` declaration and check it against prior declarations of
801+/// the same name across the `with` chain. A single declaration is always fine
802+/// (any default). A REPEAT declaration must agree with the earlier one on type
803+/// (the sig) and default — and since agreement must be checkable, a repeated
804+/// arg's default must be a literal or `_`. Two concrete defaults must be equal
805+/// literals; a concrete default may pair with any number of `_` (required)
806+/// declarations, in which case the concrete one supplies the value.
807+fn check_cli_arg_agreement(
808+ f: &knot::ast::RecordField,
809+ cli_args: &mut HashMap<String, (Option<String>, Option<String>, Span)>,
810+ diags: &mut Vec<knot::diagnostic::Diagnostic>,
811+) {
812+ use knot::ast::ExprKind;
813+ let ExprKind::ArgDecl { name, default } = &f.value.node else {
814+ return;
815+ };
816+ // The declared type (the `Type #name` sig), rendered for comparison.
817+ let ty_str = f
818+ .sig
819+ .as_ref()
820+ .map(|s| knot::format::render_type(&s.ty));
821+ // The default, as a literal display (`None` if `_` or non-literal).
822+ let default_str = default
823+ .as_ref()
824+ .and_then(|d| default_literal_display(d));
825+
826+ match cli_args.get(name) {
827+ None => {
828+ cli_args.insert(name.clone(), (ty_str, default_str, f.value.span));
829+ }
830+ Some((prev_ty, prev_default, prev_span)) => {
831+ // This is a REPEAT declaration of the same `#name`. Since every
832+ // declaration must resolve to the same value, agreement is required
833+ // — and it must be CHECKABLE, so a repeated arg needs a literal (or
834+ // `_`) default on every occurrence.
835+ if default.is_some() && default_str.is_none() {
836+ diags.push(
837+ knot::diagnostic::Diagnostic::error(format!(
838+ "argument `#{name}` is declared in multiple scopes, so its default must be a literal (or `_`) here",
839+ ))
840+ .label(f.value.span, "computed defaults can't be compared across scopes"),
841+ );
842+ return;
843+ }
844+ // Types must agree.
845+ if *prev_ty != ty_str {
846+ diags.push(
847+ knot::diagnostic::Diagnostic::error(format!(
848+ "argument `#{name}` is declared with different types",
849+ ))
850+ .label(f.value.span, "this declaration")
851+ .label(*prev_span, "conflicts with this earlier declaration"),
852+ );
853+ return;
854+ }
855+ // Defaults must agree: equal literals, or the `_`-exception (one
856+ // concrete default among `_` declarations).
857+ let defaults_ok = match (prev_default, &default_str) {
858+ // Both `_`: fine.
859+ (None, None) => true,
860+ // One concrete, one `_`: fine (the concrete one supplies the value).
861+ (Some(_), None) | (None, Some(_)) => true,
862+ // Two concrete: must be the same literal.
863+ (Some(a), Some(b)) => a == b,
864+ };
865+ if !defaults_ok {
866+ diags.push(
867+ knot::diagnostic::Diagnostic::error(format!(
868+ "argument `#{name}` is declared with different defaults",
869+ ))
870+ .label(f.value.span, "this declaration")
871+ .label(*prev_span, "conflicts with this earlier declaration"),
872+ );
873+ return;
874+ }
875+ // Keep the concrete default (if any) as the canonical one.
876+ let merged = prev_default.clone().or(default_str);
877+ cli_args.insert(name.clone(), (ty_str, merged, *prev_span));
878+ }
879+ }
880+}
881+
882+/// Render a default-value expression as a literal display string, or `None` if
883+/// it isn't a scalar literal (Int/Float/Text/Bool/Maybe thereof). Mirrors the
884+/// CLI default rendering used at codegen.
885+fn default_literal_display(expr: &knot::ast::Expr) -> Option<String> {
886+ use knot::ast::ExprKind;
887+ match &expr.node {
888+ ExprKind::Lit(lit) => match lit {
889+ knot::ast::Literal::Int(s) => Some(s.clone()),
890+ knot::ast::Literal::Float(f) => Some(format!("{}", f)),
891+ knot::ast::Literal::Text(s) => {
892+ let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
893+ Some(format!("\"{}\"", escaped))
894+ }
895+ },
896+ ExprKind::UnaryOp {
897+ op: knot::ast::UnaryOp::Neg,
898+ operand,
899+ } => default_literal_display(operand).map(|s| format!("-{}", s)),
900+ _ => None,
901+ }
763902}
764903
765904/// Collect the alias names referenced anywhere inside a type AST.
crates/knot-compiler/tests/descriptor_roundtrip.rsnew file+84
@@ -0,0 +1,84 @@
1+//! Cross-validation that the `knot-descriptor` parser accepts every schema
2+//! descriptor the compiler emits, and that its `emit` reproduces the compiler's
3+//! bytes exactly. This is what lets the descriptor dialect have a single owner:
4+//! the emitter (here) and the parser (knot-descriptor) are locked to one
5+//! grammar by this test, so the ~dozens of hand-rolled descriptor scanners in
6+//! the runtime can be replaced by `knot_descriptor::parse` without changing the
7+//! wire format.
8+//!
9+//! Byte-exactness matters because the schema lock compares descriptors with
10+//! string equality (`lockfile.rs`): any drift between emit and re-emit would
11+//! read as a phantom schema change.
12+
13+use knot_compiler::types::TypeEnv;
14+
15+/// Build a program's `TypeEnv` (structural pass) then compute its schemas, and
16+/// return every emitted descriptor string.
17+fn emitted_descriptors(src: &str) -> Vec<String> {
18+ let lexer = knot::lexer::Lexer::new(src);
19+ let (tokens, lex) = lexer.tokenize();
20+ assert!(lex.iter().all(|d| d.severity != knot::diagnostic::Severity::Error));
21+ let parser = knot::parser::Parser::new(src.to_string(), tokens);
22+ let (program, parse) = parser.parse_file_expr();
23+ assert!(
24+ parse.iter().all(|d| d.severity != knot::diagnostic::Severity::Error)
25+ );
26+ let mut env = TypeEnv::from_program(&program);
27+ env.compute_schemas(&program);
28+ env.source_schemas.values().cloned().collect()
29+}
30+
31+/// Every compiler-emitted descriptor parses under knot-descriptor and re-emits
32+/// to the identical bytes.
33+fn assert_round_trips(src: &str) {
34+ let descs = emitted_descriptors(src);
35+ assert!(!descs.is_empty(), "test program must declare a source");
36+ for d in &descs {
37+ let parsed = knot_descriptor::parse(d)
38+ .unwrap_or_else(|e| panic!("knot-descriptor rejected compiler output {d:?}: {e}"));
39+ let reemitted = knot_descriptor::emit(&parsed);
40+ assert_eq!(
41+ &reemitted, d,
42+ "re-emitted descriptor differs from compiler's bytes"
43+ );
44+ }
45+}
46+
47+#[test]
48+fn scalar_source() {
49+ assert_round_trips("with {\nRel (Int 1) *n\n}\n(| yield {})");
50+}
51+
52+#[test]
53+fn flat_record() {
54+ assert_round_trips("with {\nR {name Text age (Int 1)}\nRel R *rs\n}\n(| yield {})");
55+}
56+
57+#[test]
58+fn nested_record_and_relation_field() {
59+ assert_round_trips(
60+ "with {\nAddr {city Text zip (Int 1)}\nR {name Text addr Addr tags (Rel Text)}\nRel R *rs\n}\n(| yield {})",
61+ );
62+}
63+
64+#[test]
65+fn enum_and_payload_adts() {
66+ assert_round_trips(
67+ "with {\nColor Red {} Green {} Blue {}\nS {c Color}\nRel S *s\n}\n(| yield {})",
68+ );
69+ assert_round_trips(
70+ "with {\nShape Circle {radius (Int 1)} Rect {w (Int 1) h (Int 1)} Point {}\nRel Shape *shapes\n}\n(| yield {})",
71+ );
72+}
73+
74+#[test]
75+fn kitchen_sink() {
76+ assert_round_trips(
77+ "with {\n\
78+ Addr {city Text zip (Int 1)}\n\
79+ Shape Circle {radius (Int 1)} Rect {w (Int 1) h (Int 1)} Point {}\n\
80+ Reading {name Text addr Addr shape Shape tags (Rel Text) history (Rel {at (Int 1)})}\n\
81+ Rel Reading *readings\n\
82+ }\n(| yield {})",
83+ );
84+}
crates/knot-descriptor/Cargo.tomlnew file+6
@@ -0,0 +1,6 @@
1+[package]
2+name = "knot-descriptor"
3+version.workspace = true
4+edition.workspace = true
5+
6+[dependencies]
crates/knot-descriptor/src/lib.rsnew file+376
@@ -0,0 +1,376 @@
1+//! The schema-descriptor wire format: a *deliberately terse, minimal* type
2+//! syntax — separate from (and not accepted by) the real knot grammar.
3+//!
4+//! A descriptor is a persisted relation's element type rendered name-free, so
5+//! it survives the type being deleted from source. The compiler emits it; the
6+//! runtime reads it to derive the physical storage layout.
7+//!
8+//! Grammar (whitespace-insensitive except where noted):
9+//!
10+//! ```text
11+//! desc := scalar | record | "(Rel " desc ")" | "(" ctor (" | " ctor)* ")"
12+//! record := "{" field (" "+ field)* "}" // fields separated by 2+ spaces
13+//! field := ident " " desc
14+//! ctor := ident | ident record // payload attaches with NO space
15+//! scalar := "Int" | "Float" | "Text" | "Bool" | "Bytes" | "Uuid"
16+//! ```
17+//!
18+//! This crate is the SINGLE owner of that grammar. Both the compiler's emitter
19+//! and the runtime's readers route through here, so the two sides can never
20+//! drift into different approximations of the format. A round-trip property
21+//! (`emit(parse(s)) == s`) locks emitter and parser to one grammar.
22+
23+use std::fmt;
24+
25+/// A parsed schema descriptor.
26+#[derive(Debug, Clone, PartialEq, Eq)]
27+pub enum Desc {
28+ /// A scalar column type.
29+ Scalar(Scalar),
30+ /// `{name Text addr {city Text}}` — an ordered list of named fields.
31+ Record(Vec<(String, Desc)>),
32+ /// `(Rel Text)` — a relation (set) of elements.
33+ Relation(Box<Desc>),
34+ /// `(Red | Blue)` / `(Circle{radius Int} | Point)` — a sum type. Each
35+ /// constructor is a name plus its (possibly empty) payload fields.
36+ Variant(Vec<(String, Vec<(String, Desc)>)>),
37+}
38+
39+/// The scalar column types.
40+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41+pub enum Scalar {
42+ Int,
43+ Float,
44+ Text,
45+ Bool,
46+ Bytes,
47+ Uuid,
48+}
49+
50+impl Scalar {
51+ fn as_str(self) -> &'static str {
52+ match self {
53+ Scalar::Int => "Int",
54+ Scalar::Float => "Float",
55+ Scalar::Text => "Text",
56+ Scalar::Bool => "Bool",
57+ Scalar::Bytes => "Bytes",
58+ Scalar::Uuid => "Uuid",
59+ }
60+ }
61+
62+ fn from_str(s: &str) -> Option<Scalar> {
63+ match s {
64+ "Int" => Some(Scalar::Int),
65+ "Float" => Some(Scalar::Float),
66+ "Text" => Some(Scalar::Text),
67+ "Bool" => Some(Scalar::Bool),
68+ "Bytes" => Some(Scalar::Bytes),
69+ "Uuid" => Some(Scalar::Uuid),
70+ _ => None,
71+ }
72+ }
73+}
74+
75+/// A descriptor parse error: a byte offset plus a human-readable message.
76+#[derive(Debug, Clone, PartialEq, Eq)]
77+pub struct ParseError {
78+ pub pos: usize,
79+ pub msg: String,
80+}
81+
82+impl fmt::Display for ParseError {
83+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84+ write!(f, "descriptor parse error at byte {}: {}", self.pos, self.msg)
85+ }
86+}
87+
88+impl std::error::Error for ParseError {}
89+
90+/// Parse a schema-descriptor string into a `Desc`.
91+///
92+/// The input is treated as a single descriptor. Leading/trailing whitespace is
93+/// allowed; anything left over after one descriptor is an error.
94+pub fn parse(input: &str) -> Result<Desc, ParseError> {
95+ let mut p = Parser {
96+ chars: input.as_bytes(),
97+ pos: 0,
98+ };
99+ p.skip_ws();
100+ let d = p.parse_desc()?;
101+ p.skip_ws();
102+ if p.pos != p.chars.len() {
103+ return Err(p.err(format!("unexpected trailing input {:?}", p.rest())));
104+ }
105+ Ok(d)
106+}
107+
108+/// Render a `Desc` back to its canonical descriptor string. This is the exact
109+/// inverse of [`parse`] and matches the compiler's emitter byte-for-byte, so
110+/// `emit(parse(s)) == s` for any well-formed descriptor.
111+pub fn emit(d: &Desc) -> String {
112+ let mut out = String::new();
113+ emit_into(d, &mut out);
114+ out
115+}
116+
117+fn emit_into(d: &Desc, out: &mut String) {
118+ match d {
119+ Desc::Scalar(s) => out.push_str(s.as_str()),
120+ Desc::Record(fields) => {
121+ out.push('{');
122+ for (i, (name, fty)) in fields.iter().enumerate() {
123+ if i > 0 {
124+ out.push_str(" ");
125+ }
126+ out.push_str(name);
127+ out.push(' ');
128+ emit_into(fty, out);
129+ }
130+ out.push('}');
131+ }
132+ Desc::Relation(inner) => {
133+ out.push_str("(Rel ");
134+ emit_into(inner, out);
135+ out.push(')');
136+ }
137+ Desc::Variant(ctors) => {
138+ out.push('(');
139+ for (i, (name, fields)) in ctors.iter().enumerate() {
140+ if i > 0 {
141+ out.push_str(" | ");
142+ }
143+ out.push_str(name);
144+ if !fields.is_empty() {
145+ // Payload attaches directly: `Circle{radius Int}`.
146+ emit_into(&Desc::Record(fields.clone()), out);
147+ }
148+ }
149+ out.push(')');
150+ }
151+ }
152+}
153+
154+struct Parser<'a> {
155+ chars: &'a [u8],
156+ pos: usize,
157+}
158+
159+impl<'a> Parser<'a> {
160+ fn err(&self, msg: String) -> ParseError {
161+ ParseError { pos: self.pos, msg }
162+ }
163+
164+ fn rest(&self) -> String {
165+ String::from_utf8_lossy(&self.chars[self.pos..]).into_owned()
166+ }
167+
168+ fn peek(&self) -> Option<u8> {
169+ self.chars.get(self.pos).copied()
170+ }
171+
172+ fn skip_ws(&mut self) {
173+ while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
174+ self.pos += 1;
175+ }
176+ }
177+
178+ /// Skip whitespace, returning how many spaces were skipped. Callers use the
179+ /// count to enforce the record field separator (2+ spaces).
180+ fn skip_ws_count(&mut self) -> usize {
181+ let start = self.pos;
182+ self.skip_ws();
183+ self.pos - start
184+ }
185+
186+ fn expect(&mut self, c: u8) -> Result<(), ParseError> {
187+ if self.peek() == Some(c) {
188+ self.pos += 1;
189+ Ok(())
190+ } else {
191+ Err(self.err(format!(
192+ "expected {:?}, found {:?}",
193+ c as char,
194+ self.peek().map(|b| b as char)
195+ )))
196+ }
197+ }
198+
199+ fn eat_keyword(&mut self, kw: &str) -> bool {
200+ if self.chars[self.pos..].starts_with(kw.as_bytes()) {
201+ // Must not be followed by an identifier char (so `Rel` doesn't
202+ // match a field named `Relay`).
203+ let next = self.chars.get(self.pos + kw.len());
204+ if !matches!(next, Some(b) if b.is_ascii_alphanumeric() || *b == b'_') {
205+ self.pos += kw.len();
206+ return true;
207+ }
208+ }
209+ false
210+ }
211+
212+ fn parse_ident(&mut self) -> Result<String, ParseError> {
213+ let start = self.pos;
214+ while matches!(self.peek(), Some(b) if b.is_ascii_alphanumeric() || b == b'_') {
215+ self.pos += 1;
216+ }
217+ if self.pos == start {
218+ return Err(self.err(format!("expected an identifier, found {:?}", self.rest())));
219+ }
220+ Ok(String::from_utf8_lossy(&self.chars[start..self.pos]).into_owned())
221+ }
222+
223+ fn parse_desc(&mut self) -> Result<Desc, ParseError> {
224+ self.skip_ws();
225+ match self.peek() {
226+ Some(b'{') => self.parse_record().map(Desc::Record),
227+ Some(b'(') => self.parse_paren(),
228+ Some(_) => {
229+ let name = self.parse_ident()?;
230+ match Scalar::from_str(&name) {
231+ Some(s) => Ok(Desc::Scalar(s)),
232+ None => Err(self.err(format!(
233+ "unknown type {name:?} (expected a scalar, record, (Rel ..), or (A | B))"
234+ ))),
235+ }
236+ }
237+ None => Err(self.err("unexpected end of input".to_string())),
238+ }
239+ }
240+
241+ fn parse_record(&mut self) -> Result<Vec<(String, Desc)>, ParseError> {
242+ self.expect(b'{')?;
243+ let mut fields = Vec::new();
244+ self.skip_ws();
245+ if self.peek() == Some(b'}') {
246+ self.pos += 1;
247+ return Ok(fields);
248+ }
249+ loop {
250+ self.skip_ws();
251+ let name = self.parse_ident()?;
252+ // Field name and type are separated by a single space.
253+ self.skip_ws();
254+ let ty = self.parse_desc()?;
255+ fields.push((name, ty));
256+ // A record field boundary is 2+ spaces at depth 0; `}` ends it.
257+ let before = self.pos;
258+ let spaces = self.skip_ws_count();
259+ match self.peek() {
260+ Some(b'}') => {
261+ self.pos += 1;
262+ return Ok(fields);
263+ }
264+ Some(_) if spaces >= 2 => continue,
265+ Some(_) => {
266+ return Err(self.err(format!(
267+ "expected 2+ spaces or '}}' after field (found {:?})",
268+ self.rest()
269+ )));
270+ }
271+ None => {
272+ self.pos = before;
273+ return Err(self.err("unterminated record (missing '}')".to_string()));
274+ }
275+ }
276+ }
277+ }
278+
279+ /// `(Rel <desc>)` or a variant `(A | B)` / `(Circle{..} | Point)`.
280+ fn parse_paren(&mut self) -> Result<Desc, ParseError> {
281+ self.expect(b'(')?;
282+ self.skip_ws();
283+ if self.eat_keyword("Rel") {
284+ let inner = self.parse_desc()?;
285+ self.skip_ws();
286+ self.expect(b')')?;
287+ return Ok(Desc::Relation(Box::new(inner)));
288+ }
289+ // Variant: one or more ctors separated by ` | `.
290+ let mut ctors = Vec::new();
291+ loop {
292+ self.skip_ws();
293+ let name = self.parse_ident()?;
294+ // A payload record attaches with NO intervening space: `Circle{..}`.
295+ let fields = if self.peek() == Some(b'{') {
296+ self.parse_record()?
297+ } else {
298+ Vec::new()
299+ };
300+ ctors.push((name, fields));
301+ self.skip_ws();
302+ if self.peek() == Some(b'|') {
303+ self.pos += 1;
304+ continue;
305+ }
306+ self.expect(b')')?;
307+ return Ok(Desc::Variant(ctors));
308+ }
309+ }
310+}
311+
312+#[cfg(test)]
313+mod tests {
314+ use super::*;
315+
316+ fn roundtrip(s: &str) {
317+ let d = parse(s).unwrap_or_else(|e| panic!("parse failed for {s:?}: {e}"));
318+ let back = emit(&d);
319+ assert_eq!(back, s, "round-trip mismatch for {s:?}");
320+ }
321+
322+ #[test]
323+ fn scalars() {
324+ for s in ["Int", "Float", "Text", "Bool", "Bytes", "Uuid"] {
325+ roundtrip(s);
326+ }
327+ }
328+
329+ #[test]
330+ fn records() {
331+ roundtrip("{name Text}");
332+ roundtrip("{name Text age Int}");
333+ roundtrip("{name Text addr {city Text zip Int}}");
334+ roundtrip("{}"); // empty record
335+ }
336+
337+ #[test]
338+ fn relations() {
339+ roundtrip("(Rel Text)");
340+ roundtrip("(Rel {name Text})");
341+ roundtrip("{tags (Rel Text)}");
342+ roundtrip("{pets (Rel (Rel Int))}");
343+ }
344+
345+ #[test]
346+ fn variants() {
347+ roundtrip("(Red | Blue)");
348+ roundtrip("(Red | Green | Blue)");
349+ roundtrip("(Circle{radius Int} | Point)");
350+ roundtrip("(Circle{radius Int} | Rect{w Int h Int} | Point)");
351+ }
352+
353+ #[test]
354+ fn nested_combinations() {
355+ roundtrip("{name Text status (Active | Inactive) history (Rel {at Int})}");
356+ roundtrip("(Just{v Text} | Nothing)");
357+ }
358+
359+ #[test]
360+ fn rejects_garbage() {
361+ assert!(parse("").is_err());
362+ assert!(parse("Int Extra").is_err());
363+ assert!(parse("{name}").is_err());
364+ assert!(parse("{name Text").is_err());
365+ assert!(parse("(Rel Text").is_err());
366+ assert!(parse("Nope").is_err());
367+ assert!(parse("(Red Blue)").is_err()); // missing ` | ` separator
368+ assert!(parse("Circle{radius Int}").is_err()); // variant must be parenthesized
369+ }
370+
371+ #[test]
372+ fn rejects_unknown_scalar() {
373+ let e = parse("Missing").unwrap_err();
374+ assert!(e.msg.contains("unknown type"), "{e}");
375+ }
376+}
crates/knot-jit/tests/cli_overrides.rs+567−1
@@ -381,6 +381,320 @@ Rel (Int 1) #ports []
381381 assert_ne!(code, 0, "expected element parse failure, stderr: {err}");
382382}
383383
384+// ── Vec and List: same flag syntax as Rel, different value semantics ────────
385+
386+#[test]
387+fn rel_dedups_vec_keeps_order_and_dups() {
388+ // A Rel is a set: duplicate elements collapse. A Vec is ordered and keeps
389+ // duplicates. Same flag syntax (`--tags=a,b,a`), different value.
390+ let dir = TempDir::fresh("rel_dedup");
391+ build(
392+ &dir,
393+ "rel_dedup",
394+ r#"with {
395+Rel Text #tags []
396+}
397+(|
398+ base.println (base.show tags)
399+ yield {})"#,
400+ );
401+ let (out, err, code) = run_with_args(&dir, "rel_dedup", &["--tags=a,b,a"]);
402+ assert_eq!(code, 0, "stderr: {err}");
403+ assert_eq!(out.trim_end(), "\"[a, b]\"\n{}");
404+
405+ let dir = TempDir::fresh("vec_dups");
406+ build(
407+ &dir,
408+ "vec_dups",
409+ r#"with {
410+Vec Text #tags (base.the (Vec Text) [])
411+}
412+(|
413+ base.println (base.show tags)
414+ yield {})"#,
415+ );
416+ let (out, err, code) = run_with_args(&dir, "vec_dups", &["--tags=a,b,a"]);
417+ assert_eq!(code, 0, "stderr: {err}");
418+ assert_eq!(out.trim_end(), "\"[a, b, a]\"\n{}");
419+}
420+
421+#[test]
422+fn vec_int_repeated_flags() {
423+ let dir = TempDir::fresh("vec_int");
424+ build(
425+ &dir,
426+ "vec_int",
427+ r#"with {
428+Vec (Int 1) #nums (base.the (Vec (Int 1)) [])
429+}
430+(|
431+ base.println (base.show nums)
432+ yield {})"#,
433+ );
434+ let (out, err, code) = run_with_args(&dir, "vec_int", &["--nums=1,2", "--nums=3"]);
435+ assert_eq!(code, 0, "stderr: {err}");
436+ assert_eq!(out.trim_end(), "\"[1, 2, 3]\"\n{}");
437+}
438+
439+#[test]
440+fn list_text_builds_cons_chain() {
441+ let dir = TempDir::fresh("list_text");
442+ build(
443+ &dir,
444+ "list_text",
445+ r#"with {
446+List Text #tags (List.Nil {})
447+}
448+(|
449+ base.println (base.show tags)
450+ yield {})"#,
451+ );
452+ let (out, err, code) = run_with_args(&dir, "list_text", &["--tags=a,b"]);
453+ assert_eq!(code, 0, "stderr: {err}");
454+ assert_eq!(
455+ out.trim_end(),
456+ "\"Cons {head a tail Cons {head b tail Nil}}\"\n{}"
457+ );
458+}
459+
460+#[test]
461+fn list_int_empty_flag_is_nil() {
462+ let dir = TempDir::fresh("list_empty");
463+ build(
464+ &dir,
465+ "list_empty",
466+ r#"with {
467+List (Int 1) #nums (List.Nil {})
468+}
469+(|
470+ base.println (base.show nums)
471+ yield {})"#,
472+ );
473+ let (out, err, code) = run_with_args(&dir, "list_empty", &["--nums="]);
474+ assert_eq!(code, 0, "stderr: {err}");
475+ assert_eq!(out.trim_end(), "\"Nil\"\n{}");
476+}
477+
478+#[test]
479+fn vec_bool_bare_flag() {
480+ let dir = TempDir::fresh("vec_bool");
481+ build(
482+ &dir,
483+ "vec_bool",
484+ r#"with {
485+Vec Bool #flags (base.the (Vec Bool) [])
486+}
487+(|
488+ base.println (base.show flags)
489+ yield {})"#,
490+ );
491+ let (out, err, code) = run_with_args(&dir, "vec_bool", &["--flags"]);
492+ assert_eq!(code, 0, "stderr: {err}");
493+ assert_eq!(out.trim_end(), "\"[True]\"\n{}");
494+}
495+
496+#[test]
497+fn maybe_vec_override_and_nothing() {
498+ let dir = TempDir::fresh("maybe_vec");
499+ build(
500+ &dir,
501+ "maybe_vec",
502+ r#"with {
503+Maybe (Vec Text) #tags (Maybe.Nothing {})
504+}
505+(|
506+ base.println (base.show tags)
507+ yield {})"#,
508+ );
509+ // A Maybe flag wraps the parsed value in Just.
510+ let (out, err, code) = run_with_args(&dir, "maybe_vec", &["--tags=a,b"]);
511+ assert_eq!(code, 0, "stderr: {err}");
512+ assert_eq!(out.trim_end(), "\"Just {value [a, b]}\"\n{}");
513+ // No flag: the Nothing default applies.
514+ let (out, err, code) = run_with_args(&dir, "maybe_vec", &[]);
515+ assert_eq!(code, 0, "stderr: {err}");
516+ assert_eq!(out.trim_end(), "\"Nothing\"\n{}");
517+}
518+
519+// ── Collections of the extended element types (Bytes, Uuid, enum, refined) ──
520+
521+#[test]
522+fn vec_of_bytes() {
523+ let dir = TempDir::fresh("vec_bytes");
524+ build(
525+ &dir,
526+ "vec_bytes",
527+ r#"with {
528+Vec Bytes #keys (base.the (Vec Bytes) [])
529+}
530+(|
531+ base.println (base.show keys)
532+ yield {})"#,
533+ );
534+ let (out, err, code) = run_with_args(&dir, "vec_bytes", &["--keys=3q2-7w,AAAA"]);
535+ assert_eq!(code, 0, "stderr: {err}");
536+ assert_eq!(out.trim_end(), "\"[deadbeef, 000000]\"\n{}");
537+}
538+
539+#[test]
540+fn vec_of_bytes_bad_element_aborts() {
541+ let dir = TempDir::fresh("vec_bytes_bad");
542+ build(
543+ &dir,
544+ "vec_bytes_bad",
545+ r#"with {
546+Vec Bytes #keys (base.the (Vec Bytes) [])
547+}
548+(|
549+ base.println (base.show keys)
550+ yield {})"#,
551+ );
552+ let (_, err, code) = run_with_args(&dir, "vec_bytes_bad", &["--keys=3q2+7w=="]);
553+ assert_ne!(code, 0, "expected element parse failure, stderr: {err}");
554+}
555+
556+#[test]
557+fn vec_of_uuid() {
558+ let dir = TempDir::fresh("vec_uuid");
559+ build(
560+ &dir,
561+ "vec_uuid",
562+ r#"with {
563+Vec Uuid #ids (base.the (Vec Uuid) [])
564+}
565+(|
566+ base.println (base.show ids)
567+ yield {})"#,
568+ );
569+ let (out, err, code) = run_with_args(
570+ &dir,
571+ "vec_uuid",
572+ &["--ids=550e8400-e29b-41d4-a716-446655440000"],
573+ );
574+ assert_eq!(code, 0, "stderr: {err}");
575+ assert_eq!(
576+ out.trim_end(),
577+ "\"[550e8400-e29b-41d4-a716-446655440000]\"\n{}"
578+ );
579+}
580+
581+#[test]
582+fn vec_of_enum() {
583+ let dir = TempDir::fresh("vec_enum");
584+ build(
585+ &dir,
586+ "vec_enum",
587+ r#"with {
588+Mode Dev {} Prod {}
589+Vec Mode #modes (base.the (Vec Mode) [])
590+}
591+(|
592+ base.println (base.show modes)
593+ yield {})"#,
594+ );
595+ let (out, err, code) = run_with_args(&dir, "vec_enum", &["--modes=Dev,Prod"]);
596+ assert_eq!(code, 0, "stderr: {err}");
597+ assert_eq!(out.trim_end(), "\"[Dev, Prod]\"\n{}");
598+}
599+
600+#[test]
601+fn rel_of_enum_dedups() {
602+ let dir = TempDir::fresh("rel_enum");
603+ build(
604+ &dir,
605+ "rel_enum",
606+ r#"with {
607+Mode Dev {} Prod {}
608+Rel Mode #modes []
609+}
610+(|
611+ base.println (base.show modes)
612+ yield {})"#,
613+ );
614+ let (out, err, code) = run_with_args(&dir, "rel_enum", &["--modes=Dev,Prod,Dev"]);
615+ assert_eq!(code, 0, "stderr: {err}");
616+ assert_eq!(out.trim_end(), "\"[Dev, Prod]\"\n{}");
617+}
618+
619+#[test]
620+fn vec_of_enum_bad_ctor_aborts() {
621+ let dir = TempDir::fresh("vec_enum_bad");
622+ build(
623+ &dir,
624+ "vec_enum_bad",
625+ r#"with {
626+Mode Dev {} Prod {}
627+Vec Mode #modes (base.the (Vec Mode) [])
628+}
629+(|
630+ base.println (base.show modes)
631+ yield {})"#,
632+ );
633+ let (_, err, code) = run_with_args(&dir, "vec_enum_bad", &["--modes=Staging"]);
634+ assert_ne!(code, 0, "expected unknown-constructor error, stderr: {err}");
635+}
636+
637+#[test]
638+fn vec_of_refined_checks_each_element() {
639+ let dir = TempDir::fresh("vec_refined");
640+ build(
641+ &dir,
642+ "vec_refined",
643+ r#"with {
644+Pos Int 1 \x -> x > 0
645+Vec Pos #nums (base.the (Vec Pos) [])
646+}
647+(|
648+ base.println (base.show nums)
649+ yield {})"#,
650+ );
651+ let (out, err, code) = run_with_args(&dir, "vec_refined", &["--nums=1,2"]);
652+ assert_eq!(code, 0, "stderr: {err}");
653+ assert_eq!(out.trim_end(), "\"[1, 2]\"\n{}");
654+ // A failing element aborts, naming the flag and type.
655+ let (_, err, code) = run_with_args(&dir, "vec_refined", &["--nums=1,-5"]);
656+ assert_ne!(code, 0, "expected predicate failure, stderr: {err}");
657+ assert!(err.contains("does not satisfy predicate"), "stderr: {err}");
658+}
659+
660+#[test]
661+fn list_of_refined_checks_each_element() {
662+ let dir = TempDir::fresh("list_refined");
663+ build(
664+ &dir,
665+ "list_refined",
666+ r#"with {
667+Pos Int 1 \x -> x > 0
668+List Pos #nums (List.Nil {})
669+}
670+(|
671+ base.println (base.show nums)
672+ yield {})"#,
673+ );
674+ let (_, err, code) = run_with_args(&dir, "list_refined", &["--nums=1,-5"]);
675+ assert_ne!(code, 0, "expected predicate failure, stderr: {err}");
676+ assert!(err.contains("does not satisfy predicate"), "stderr: {err}");
677+}
678+
679+#[test]
680+fn maybe_vec_of_refined_checks_each_element() {
681+ let dir = TempDir::fresh("maybe_vec_refined");
682+ build(
683+ &dir,
684+ "maybe_vec_refined",
685+ r#"with {
686+Pos Int 1 \x -> x > 0
687+Maybe (Vec Pos) #nums (Maybe.Nothing {})
688+}
689+(|
690+ base.println (base.show nums)
691+ yield {})"#,
692+ );
693+ let (_, err, code) = run_with_args(&dir, "maybe_vec_refined", &["--nums=1,-5"]);
694+ assert_ne!(code, 0, "expected predicate failure, stderr: {err}");
695+ assert!(err.contains("does not satisfy predicate"), "stderr: {err}");
696+}
697+
384698// ── Maybe variants of the new types ─────────────────────────────────────────
385699
386700#[test]
@@ -402,7 +716,8 @@ Maybe Mode #mode (Maybe.Nothing {})
402716 let (out, err, code) = run_with_args(&dir, "maybe_enum", &["--mode=Prod"]);
403717 assert_eq!(code, 0, "stderr: {err}");
404718 assert_eq!(out.trim_end(), "\"prod\"\n{}");
405− let (out, err, code) = run_with_args(&dir, "maybe_enum", &["--mode=Nothing"]);
719+ // No flag: the Nothing default applies.
720+ let (out, err, code) = run_with_args(&dir, "maybe_enum", &[]);
406721 assert_eq!(code, 0, "stderr: {err}");
407722 assert_eq!(out.trim_end(), "\"none\"\n{}");
408723}
@@ -414,3 +729,254 @@ Maybe Mode #mode (Maybe.Nothing {})
414729// `with`-field loop compiles the field's body inline rather than calling the
415730// global fn that would consult the override. That pre-existing gap is out of
416731// scope here; the runtime CLI path (`#`-args) is covered above.
732+
733+// ── Repeated `#`-argument declarations ──────────────────────────────────────
734+//
735+// A `#name` may be declared in multiple scopes; every declaration refers to the
736+// same (global, name-keyed) CLI argument, so they must agree on type and
737+// default. Two concrete defaults must be equal literals; a concrete default may
738+// pair with any number of `_` (required) declarations.
739+
740+/// Build `src`, expecting failure with `needle` in stderr.
741+fn build_err(dir: &TempDir, name: &str, src: &str, needle: &str) {
742+ let src_path = dir.path().join(format!("{name}.knot"));
743+ std::fs::write(&src_path, src).unwrap();
744+ let out = Command::new(e2e::knot_bin())
745+ .arg("build")
746+ .arg(&src_path)
747+ .arg("-o")
748+ .arg(dir.path().join(name))
749+ .output()
750+ .expect("failed to run knot build");
751+ assert!(
752+ !out.status.success(),
753+ "expected build of {name} to fail, but it succeeded"
754+ );
755+ let err = String::from_utf8_lossy(&out.stderr);
756+ assert!(
757+ err.contains(needle),
758+ "error for {name} did not contain `{needle}`:\n{err}"
759+ );
760+}
761+
762+#[test]
763+fn duplicate_arg_in_one_record_is_rejected() {
764+ let dir = TempDir::fresh("dup_arg_record");
765+ build_err(
766+ &dir,
767+ "dup_arg_record",
768+ r#"with {
769+Port Int 1
770+Port #port 8080
771+Port #port 9090
772+}
773+(|
774+ base.println port
775+ yield {})"#,
776+ "declared more than once",
777+ );
778+}
779+
780+#[test]
781+fn repeated_arg_agreeing_literals_allowed() {
782+ let dir = TempDir::fresh("repeat_agree");
783+ build(
784+ &dir,
785+ "repeat_agree",
786+ r#"with {
787+Port Int 1
788+Port #port 8080
789+}
790+(with {
791+Port #port 8080
792+}
793+(|
794+ base.println port
795+ yield {}))"#,
796+ );
797+ let (out, err, code) = run_with_args(&dir, "repeat_agree", &[]);
798+ assert_eq!(code, 0, "stderr: {err}");
799+ assert_eq!(out.trim_end(), "8080\n{}");
800+}
801+
802+#[test]
803+fn repeated_arg_different_defaults_rejected() {
804+ let dir = TempDir::fresh("repeat_diff");
805+ build_err(
806+ &dir,
807+ "repeat_diff",
808+ r#"with {
809+Port Int 1
810+Port #port 8080
811+}
812+(with {
813+Port #port 9090
814+}
815+(|
816+ base.println port
817+ yield {}))"#,
818+ "different defaults",
819+ );
820+}
821+
822+#[test]
823+fn repeated_arg_different_types_rejected() {
824+ let dir = TempDir::fresh("repeat_types");
825+ build_err(
826+ &dir,
827+ "repeat_types",
828+ r#"with {
829+Port Int 1
830+Port #port 8080
831+}
832+(with {
833+Text #port "hello"
834+}
835+(|
836+ base.println (base.show port)
837+ yield {}))"#,
838+ "different types",
839+ );
840+}
841+
842+#[test]
843+fn repeated_arg_computed_default_rejected() {
844+ let dir = TempDir::fresh("repeat_computed");
845+ build_err(
846+ &dir,
847+ "repeat_computed",
848+ r#"with {
849+Port Int 1
850+Port #port 8080
851+}
852+(with {
853+Port #port (4 * 2020)
854+}
855+(|
856+ base.println port
857+ yield {}))"#,
858+ "must be a literal",
859+ );
860+}
861+
862+// ── Gap closures ────────────────────────────────────────────────────────────
863+
864+#[test]
865+fn non_settable_arg_with_flag_aborts() {
866+ // A record `#`-arg isn't CLI-settable. Passing the flag must abort loudly,
867+ // not silently fall through to the default.
868+ let dir = TempDir::fresh("record_forbid");
869+ build(
870+ &dir,
871+ "record_forbid",
872+ r#"with {
873+Cfg {name Text}
874+Cfg #cfg {name "default"}
875+}
876+(|
877+ base.println cfg.name
878+ yield {})"#,
879+ );
880+ // No flag: the default applies.
881+ let (out, err, code) = run_with_args(&dir, "record_forbid", &[]);
882+ assert_eq!(code, 0, "stderr: {err}");
883+ assert_eq!(out.trim_end(), "\"default\"\n{}");
884+ // Flag present: loud error, not a silent no-op.
885+ let (_, err, code) =
886+ run_with_args(&dir, "record_forbid", &["--cfg={name \"x\"}"]);
887+ assert_ne!(code, 0, "expected a loud error, stderr: {err}");
888+ assert!(
889+ err.contains("cannot be set from the command line"),
890+ "stderr: {err}"
891+ );
892+}
893+
894+#[test]
895+fn collection_of_non_scalar_with_flag_aborts() {
896+ // A collection of a NON-CLI-parseable element (a record) isn't settable. A
897+ // passed flag must abort loudly, not silently fall through to the default.
898+ let dir = TempDir::fresh("vec_record_forbid");
899+ build(
900+ &dir,
901+ "vec_record_forbid",
902+ r#"with {
903+Pt {x Int 1}
904+Vec Pt #pts (base.the (Vec Pt) [])
905+}
906+(|
907+ base.println "hi"
908+ yield {})"#,
909+ );
910+ let (_, err, code) = run_with_args(&dir, "vec_record_forbid", &["--pts=x"]);
911+ assert_ne!(code, 0, "expected a loud error, stderr: {err}");
912+ assert!(
913+ err.contains("cannot be set from the command line"),
914+ "stderr: {err}"
915+ );
916+}
917+
918+#[test]
919+fn required_underscore_uses_sibling_default() {
920+ // `#port _` (required) in one scope + `#port 8080` (concrete default) in
921+ // another: the concrete default wins when the flag is absent, in both
922+ // declaration orders. The flag still wins when present.
923+ for (name, src) in [
924+ (
925+ "uscore_outer",
926+ r#"with {
927+Port Int 1
928+Port #port _
929+}
930+(with {
931+Port #port 8080
932+}
933+(|
934+ base.println port
935+ yield {}))"#,
936+ ),
937+ (
938+ "uscore_inner",
939+ r#"with {
940+Port Int 1
941+Port #port 8080
942+}
943+(with {
944+Port #port _
945+}
946+(|
947+ base.println port
948+ yield {}))"#,
949+ ),
950+ ] {
951+ let dir = TempDir::fresh(name);
952+ build(&dir, name, src);
953+ // No flag: the concrete default applies (not a missing-argument abort).
954+ let (out, err, code) = run_with_args(&dir, name, &[]);
955+ assert_eq!(code, 0, "{name}: stderr: {err}");
956+ assert_eq!(out.trim_end(), "8080\n{}", "{name}");
957+ // Flag present: the flag wins.
958+ let (out, err, code) = run_with_args(&dir, name, &["--port=9090"]);
959+ assert_eq!(code, 0, "{name}: stderr: {err}");
960+ assert_eq!(out.trim_end(), "9090\n{}", "{name}");
961+ }
962+}
963+
964+#[test]
965+fn truly_required_underscore_still_aborts() {
966+ // A `_` arg with NO concrete default anywhere stays required.
967+ let dir = TempDir::fresh("uscore_required");
968+ build(
969+ &dir,
970+ "uscore_required",
971+ r#"with {
972+Port Int 1
973+Port #port _
974+}
975+(|
976+ base.println port
977+ yield {})"#,
978+ );
979+ let (_, err, code) = run_with_args(&dir, "uscore_required", &[]);
980+ assert_ne!(code, 0, "expected a missing-argument abort, stderr: {err}");
981+ assert!(err.contains("missing required argument"), "stderr: {err}");
982+}
crates/knot-runtime/Cargo.toml+1
@@ -14,6 +14,7 @@ default = []
1414gc-stats = []
1515
1616[dependencies]
17+knot-descriptor = { workspace = true }
1718rusqlite = { workspace = true }
1819tiny_http = { workspace = true }
1920ratatui = "0.30"
crates/knot-runtime/src/db_dump.rsnew file+143
@@ -0,0 +1,143 @@
1+//! Binary dump/restore of a knot database as a single portable file.
2+//!
3+//! `dump` uses SQLite's `VACUUM INTO`, which produces a consistent,
4+//! self-contained copy of a *live* database (WAL content is checkpointed into
5+//! the copy; no `-wal`/`-shm` sidecars). The result is itself a valid SQLite
6+//! database — so `restore` is just copying that file over the target, atomically
7+//! (write to a temp sibling, then rename) so an interrupted restore never
8+//! leaves a half-written db.
9+
10+use rusqlite::Connection;
11+use std::path::Path;
12+
13+/// Copy the database at `src` to a single binary file at `dest`, consistent
14+/// even while `src` is live. Safe to call on a database another process is
15+/// writing: `VACUUM INTO` runs under the source's own locking.
16+///
17+/// `dest` must not already exist (SQLite refuses to overwrite), so a stale
18+/// copy is removed first only if it's a file we're replacing wholesale.
19+pub fn dump_db(src: &Path, dest: &Path) -> Result<(), String> {
20+ if !src.exists() {
21+ return Err(format!("no database at {}", src.display()));
22+ }
23+ // SQLite requires the VACUUM INTO target to not exist. Remove a previous
24+ // dump so re-dumping to the same path works.
25+ if dest.exists() {
26+ std::fs::remove_file(dest)
27+ .map_err(|e| format!("cannot replace existing dump at {}: {}", dest.display(), e))?;
28+ }
29+ let conn = Connection::open(src).map_err(|e| format!("cannot open {}: {}", src.display(), e))?;
30+ let dest_str = dest
31+ .to_str()
32+ .ok_or_else(|| format!("destination path is not valid UTF-8: {}", dest.display()))?;
33+ // Escape single quotes for the SQL string literal.
34+ let escaped = dest_str.replace('\'', "''");
35+ conn.execute_batch(&format!("VACUUM INTO '{}';", escaped))
36+ .map_err(|e| format!("dump failed: {}", e))
37+}
38+
39+/// Restore a database: copy a dump file (produced by [`dump_db`]) over `dest`
40+/// atomically. The dump is a complete SQLite database, so restore is a file
41+/// copy — done via a temp sibling + rename so a crash mid-restore leaves the
42+/// old `dest` intact rather than a truncated one.
43+///
44+/// The target must not be in use: this replaces the file, which a live writer
45+/// would not expect. (Dump is live-safe; restore is an offline operation.)
46+pub fn restore_db(dump: &Path, dest: &Path) -> Result<(), String> {
47+ if !dump.exists() {
48+ return Err(format!("no dump file at {}", dump.display()));
49+ }
50+ // Sanity-check the dump is a SQLite db before overwriting anything.
51+ Connection::open(dump)
52+ .and_then(|c| c.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())))
53+ .map_err(|e| format!("{} is not a valid database: {}", dump.display(), e))?;
54+
55+ let parent = dest
56+ .parent()
57+ .ok_or_else(|| format!("destination has no parent directory: {}", dest.display()))?;
58+ let tmp = parent.join(format!(
59+ ".{}.knot-restore-tmp",
60+ dest.file_name()
61+ .and_then(|n| n.to_str())
62+ .unwrap_or("db")
63+ ));
64+ std::fs::copy(dump, &tmp).map_err(|e| format!("failed to stage restore: {}", e))?;
65+ std::fs::rename(&tmp, dest).map_err(|e| {
66+ let _ = std::fs::remove_file(&tmp);
67+ format!("failed to move restored db into place: {}", e)
68+ })
69+}
70+
71+#[cfg(test)]
72+mod tests {
73+ use super::*;
74+
75+ fn fresh(dir: &str, name: &str) -> std::path::PathBuf {
76+ let d = std::env::temp_dir().join(format!("knot_dbdump_{}_{}", dir, std::process::id()));
77+ std::fs::create_dir_all(&d).unwrap();
78+ d.join(name)
79+ }
80+
81+ /// Build a small db with a known row.
82+ fn make_db(path: &Path) {
83+ let c = Connection::open(path).unwrap();
84+ c.execute_batch("CREATE TABLE t (name TEXT, n INTEGER); INSERT INTO t VALUES ('a', 1), ('b', 2);").unwrap();
85+ }
86+
87+ fn row_count(path: &Path) -> i64 {
88+ Connection::open(path)
89+ .unwrap()
90+ .query_row("SELECT count(*) FROM t", [], |r| r.get(0))
91+ .unwrap()
92+ }
93+
94+ #[test]
95+ fn dump_then_restore_roundtrips_data() {
96+ let src = fresh("rt", "src.db");
97+ let dump = fresh("rt", "out.dump");
98+ let restored = fresh("rt", "restored.db");
99+ make_db(&src);
100+ dump_db(&src, &dump).unwrap();
101+ assert!(dump.exists());
102+ restore_db(&dump, &restored).unwrap();
103+ assert_eq!(row_count(&restored), 2);
104+ }
105+
106+ #[test]
107+ fn dump_is_a_valid_single_file_db() {
108+ let src = fresh("valid", "src.db");
109+ let dump = fresh("valid", "out.dump");
110+ make_db(&src);
111+ dump_db(&src, &dump).unwrap();
112+ // The dump opens directly as a database and holds the data.
113+ assert_eq!(row_count(&dump), 2);
114+ // No WAL/SHM sidecars: one self-contained file.
115+ assert!(!dump.with_extension("dump-wal").exists());
116+ assert!(!dump.with_extension("dump-shm").exists());
117+ }
118+
119+ #[test]
120+ fn dump_missing_source_errors() {
121+ let r = dump_db(&fresh("err", "nope.db"), &fresh("err", "out.dump"));
122+ assert!(r.is_err());
123+ }
124+
125+ #[test]
126+ fn restore_rejects_a_non_database() {
127+ let bad = fresh("bad", "bad.dump");
128+ std::fs::write(&bad, b"not a database").unwrap();
129+ let r = restore_db(&bad, &fresh("bad", "out.db"));
130+ assert!(r.is_err());
131+ }
132+
133+ #[test]
134+ fn redump_replaces_existing_dump() {
135+ let src = fresh("redump", "src.db");
136+ let dump = fresh("redump", "out.dump");
137+ make_db(&src);
138+ dump_db(&src, &dump).unwrap();
139+ // Second dump to the same path must succeed (not "target exists").
140+ dump_db(&src, &dump).unwrap();
141+ assert_eq!(row_count(&dump), 2);
142+ }
143+}
crates/knot-runtime/src/lib.rs+635−146
@@ -10,6 +10,7 @@
1010// without benefit since they are never called from safe Rust.
1111#![allow(clippy::not_unsafe_ptr_arg_deref)]
1212
13+pub mod db_dump;
1314pub mod log;
1415mod tui;
1516
@@ -3550,8 +3551,7 @@ fn debug_sql_params(sql: &str, params: &[rusqlite::types::Value]) {
35503551/// scalar overrides only the last occurrence is meaningful (last-wins,
35513552/// matching the previous single-slot behavior). `is_bool` enables the
35523553/// bare-flag-means-true two-arg disambiguation.
3553−fn override_flag_values(name: &str, is_bool: bool) -> Vec<String> {
3554− let flag = format!("--{}", name);
3554+fn override_flag_values(name: &str, is_bool: bool) -> Vec<String> { let flag = format!("--{}", name);
35553555 // Use args_os and skip non-UTF-8 entries: std::env::args() panics on
35563556 // non-UTF-8 argv, which would crash before any override lookup runs.
35573557 let args: Vec<String> = std::env::args_os()
@@ -3610,6 +3610,23 @@ fn override_parse_error(name: &str, val: &str, expected: &str) -> ! {
36103610/// Scans `std::env::args()` for `--{name}=value` or `--{name} value`.
36113611/// `type_tag`: 0=Int, 1=Float, 2=Text, 3=Bool, 4=Maybe Int, 5=Maybe Float, 6=Maybe Text, 7=Maybe Bool.
36123612/// Maybe tags automatically wrap the parsed value in `Just`.
3613+/// Forbid a CLI flag for a `#`-argument whose type isn't CLI-settable (a
3614+/// record, a function, a collection of non-scalars). When such an arg has a
3615+/// default, codegen routes here so a passed flag aborts loudly instead of
3616+/// silently falling through to the default. No-op when the flag is absent.
3617+#[unsafe(no_mangle)]
3618+pub extern "C-unwind" fn knot_override_forbid(name_ptr: *const u8, name_len: usize) -> *mut Value {
3619+ let name = unsafe { str_from_raw(name_ptr, name_len) };
3620+ if !override_flag_values(name, false).is_empty() {
3621+ eprintln!(
3622+ "Error: argument --{} has a type that cannot be set from the command line",
3623+ name
3624+ );
3625+ std::process::exit(1);
3626+ }
3627+ alloc(Value::Unit)
3628+}
3629+
36133630/// Returns a boxed `Value` if found, or null if absent.
36143631/// On parse error, prints a message and exits.
36153632#[unsafe(no_mangle)]
@@ -3687,14 +3704,47 @@ pub extern "C-unwind" fn knot_override_lookup(
36873704 }
36883705}
36893706
3707+/// The element type of a collection override (`Vec`/`Rel`/`List <elem>`).
3708+enum ElemKind<'a> {
3709+ /// A CLI-parseable scalar: "Int" | "Float" | "Text" | "Bool".
3710+ Scalar(&'a str),
3711+ Bytes,
3712+ Uuid,
3713+ /// All-nullary ADT: valid constructor names.
3714+ Enum(&'a [String]),
3715+ /// Refined element: the base scalar it parses as. The per-element
3716+ /// predicate check runs in generated code, not here.
3717+ Refined(&'a str),
3718+}
3719+
3720+impl<'a> ElemKind<'a> {
3721+ fn is_bool(&self) -> bool {
3722+ matches!(self, ElemKind::Scalar("Bool"))
3723+ }
3724+ fn expected(&self) -> String {
3725+ match self {
3726+ ElemKind::Scalar(s) => s.to_string(),
3727+ ElemKind::Bytes => "base64url-encoded Bytes".to_string(),
3728+ ElemKind::Uuid => "a Uuid".to_string(),
3729+ ElemKind::Enum(ctors) => format!("one of: {}", ctors.join(", ")),
3730+ ElemKind::Refined(base) => base.to_string(),
3731+ }
3732+ }
3733+}
3734+
36903735/// The override kinds that go through `knot_override_lookup_ex` rather than
36913736/// the tag-based `knot_override_lookup`. Parsed from the compiler-emitted
36923737/// descriptor string.
36933738enum ExKind<'a> {
36943739 Bytes,
36953740 Uuid,
3696− /// Element scalar for `Rel <scalar>` ("Int" | "Float" | "Text" | "Bool").
3697− Rel(&'a str),
3741+ /// Element type for `Rel <elem>`. A relation is a SET: rows are deduped.
3742+ Rel(ElemKind<'a>),
3743+ /// `Vec <elem>` — ordered, allows duplicates. Shares the relation
3744+ /// representation but skips dedup.
3745+ Vec(ElemKind<'a>),
3746+ /// `List <elem>` — a Cons/Nil chain.
3747+ List(ElemKind<'a>),
36983748 /// All-nullary ADT: valid constructor names.
36993749 Enum(&'a [String]),
37003750 /// Refined scalar: the base scalar ("Int" | "Float" | "Text" | "Bool").
@@ -3705,10 +3755,11 @@ enum ExKind<'a> {
37053755impl<'a> ExKind<'a> {
37063756 /// Whether the flag takes a bare `--name` form (bools only).
37073757 fn is_bool(&self) -> bool {
3708− matches!(
3709− self,
3710− ExKind::Rel("Bool") | ExKind::Refined("Bool")
3711− )
3758+ match self {
3759+ ExKind::Rel(e) | ExKind::Vec(e) | ExKind::List(e) => e.is_bool(),
3760+ ExKind::Refined(b) => *b == "Bool",
3761+ _ => false,
3762+ }
37123763 }
37133764
37143765 /// Expected-type wording for parse errors.
@@ -3716,7 +3767,9 @@ impl<'a> ExKind<'a> {
37163767 match self {
37173768 ExKind::Bytes => "base64url-encoded Bytes".to_string(),
37183769 ExKind::Uuid => "a Uuid (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)".to_string(),
3719− ExKind::Rel(e) => format!("a comma-separated list of {}", e),
3770+ ExKind::Rel(e) | ExKind::Vec(e) | ExKind::List(e) => {
3771+ format!("a comma-separated list of {}", e.expected())
3772+ }
37203773 ExKind::Enum(ctors) => format!("one of: {}", ctors.join(", ")),
37213774 ExKind::Refined(b) => b.to_string(),
37223775 }
@@ -3724,8 +3777,8 @@ impl<'a> ExKind<'a> {
37243777}
37253778
37263779/// Parse the compiler-emitted descriptor: `Bytes`, `Uuid`, `Rel <scalar>`,
3727−/// `enum:<Name>(<Ctor>,<Ctor>,…)`, `refined:<Name>(<base-scalar>)`, each
3728−/// optionally prefixed by `Maybe `.
3780+/// `Vec <scalar>`, `List <scalar>`, `enum:<Name>(<Ctor>,<Ctor>,…)`,
3781+/// `refined:<Name>(<base-scalar>)`, each optionally prefixed by `Maybe `.
37293782fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
37303783 let (is_maybe, base) = match desc.strip_prefix("Maybe ") {
37313784 Some(rest) => (true, rest),
@@ -3734,7 +3787,9 @@ fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
37343787 let kind = match base {
37353788 "Bytes" => ExKind::Bytes,
37363789 "Uuid" => ExKind::Uuid,
3737− _ if base.starts_with("Rel ") => ExKind::Rel(&base[4..]),
3790+ _ if base.starts_with("Rel ") => ExKind::Rel(parse_elem_descriptor(&base[4..])?),
3791+ _ if base.starts_with("Vec ") => ExKind::Vec(parse_elem_descriptor(&base[4..])?),
3792+ _ if base.starts_with("List ") => ExKind::List(parse_elem_descriptor(&base[5..])?),
37383793 _ if base.starts_with("enum:") => {
37393794 let rest = &base[5..];
37403795 let open = rest.find('(')?;
@@ -3761,6 +3816,36 @@ fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
37613816 Some((is_maybe, kind))
37623817}
37633818
3819+/// Parse a collection element descriptor into an `ElemKind`: a bare scalar
3820+/// (`Int`/`Float`/`Text`/`Bool`), `Bytes`, `Uuid`, an enum (`enum:Name(Ctor,…)`),
3821+/// or a refined type (`refined:Name(base)`).
3822+fn parse_elem_descriptor(elem: &str) -> Option<ElemKind<'_>> {
3823+ match elem {
3824+ "Int" | "Float" | "Text" | "Bool" => Some(ElemKind::Scalar(elem)),
3825+ "Bytes" => Some(ElemKind::Bytes),
3826+ "Uuid" => Some(ElemKind::Uuid),
3827+ _ if elem.starts_with("enum:") => {
3828+ let rest = &elem[5..];
3829+ let open = rest.find('(')?;
3830+ let close = rest.rfind(')')?;
3831+ let ctor_str = &rest[open + 1..close];
3832+ let ctors: Vec<String> = if ctor_str.is_empty() {
3833+ Vec::new()
3834+ } else {
3835+ ctor_str.split(',').map(|s| s.to_string()).collect()
3836+ };
3837+ Some(ElemKind::Enum(Box::leak(ctors.into_boxed_slice())))
3838+ }
3839+ _ if elem.starts_with("refined:") => {
3840+ let rest = &elem[8..];
3841+ let open = rest.find('(')?;
3842+ let close = rest.rfind(')')?;
3843+ Some(ElemKind::Refined(&rest[open + 1..close]))
3844+ }
3845+ _ => None,
3846+ }
3847+}
3848+
37643849/// Decode URL-safe base64 (RFC 4648 §5, `-`/`_`, padding optional). Returns
37653850/// None on any invalid character or length. Rejecting standard-alphabet
37663851/// characters (`+`/`/`) is deliberate: the CLI accepts base64url only.
@@ -3795,24 +3880,56 @@ fn decode_base64url(s: &str) -> Option<Vec<u8>> {
37953880 Some(out)
37963881}
37973882
3798−/// Parse one scalar element for a `Rel <scalar>` override.
3799−fn parse_rel_element(name: &str, val: &str, elem: &str) -> *mut Value {
3883+/// Parse one element for a `Vec`/`Rel`/`List <elem>` override.
3884+fn parse_rel_element(name: &str, val: &str, elem: &ElemKind) -> *mut Value {
38003885 match elem {
3801− "Int" => match val.parse::<i64>() {
3802− Ok(n) => alloc_int(n),
3803− Err(_) => override_parse_error(name, val, "Int"),
3804− },
3805− "Float" => match val.parse::<f64>() {
3806− Ok(n) if n.is_finite() => alloc_float(n),
3807− _ => override_parse_error(name, val, "Float"),
3886+ ElemKind::Scalar(s) => match *s {
3887+ "Int" => match val.parse::<i64>() {
3888+ Ok(n) => alloc_int(n),
3889+ Err(_) => override_parse_error(name, val, "Int"),
3890+ },
3891+ "Float" => match val.parse::<f64>() {
3892+ Ok(n) if n.is_finite() => alloc_float(n),
3893+ _ => override_parse_error(name, val, "Float"),
3894+ },
3895+ "Text" => alloc(Value::Text(Arc::from(val))),
3896+ "Bool" => match val {
3897+ "true" | "True" | "1" => encode_bool(true),
3898+ "false" | "False" | "0" => encode_bool(false),
3899+ _ => override_parse_error(name, val, "true or false"),
3900+ },
3901+ _ => override_parse_error(name, val, "a scalar"),
38083902 },
3809− "Text" => alloc(Value::Text(Arc::from(val))),
3810− "Bool" => match val {
3811− "true" | "True" | "1" => encode_bool(true),
3812− "false" | "False" | "0" => encode_bool(false),
3813− _ => override_parse_error(name, val, "true or false"),
3903+ ElemKind::Bytes => match decode_base64url(val) {
3904+ Some(bytes) => alloc(Value::Bytes(Arc::from(bytes))),
3905+ None => override_parse_error(name, val, "base64url-encoded Bytes"),
38143906 },
3815− _ => override_parse_error(name, val, "a scalar"),
3907+ ElemKind::Uuid => {
3908+ let valid = val.len() == 36
3909+ && val.as_bytes().iter().enumerate().all(|(i, &b)| {
3910+ if i == 8 || i == 13 || i == 18 || i == 23 {
3911+ b == b'-'
3912+ } else {
3913+ b.is_ascii_hexdigit()
3914+ }
3915+ });
3916+ if valid {
3917+ alloc(Value::Text(Arc::from(val)))
3918+ } else {
3919+ override_parse_error(name, val, "a Uuid")
3920+ }
3921+ }
3922+ ElemKind::Enum(ctors) => {
3923+ if ctors.iter().any(|c| c == val) {
3924+ let tag = intern_str(val);
3925+ alloc(Value::Constructor(tag, alloc(Value::Unit)))
3926+ } else {
3927+ override_parse_error(name, val, &format!("one of: {}", ctors.join(", ")))
3928+ }
3929+ }
3930+ // A refined element parses as its base scalar; the per-element
3931+ // predicate check runs in generated code.
3932+ ElemKind::Refined(base) => parse_rel_element(name, val, &ElemKind::Scalar(base)),
38163933 }
38173934}
38183935
@@ -3840,21 +3957,13 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
38403957
38413958 let values = override_flag_values(name, kind.is_bool());
38423959
3843− // A Maybe-wrapped flag explicitly set to "Nothing" means `Nothing {}`.
3844− if is_maybe
3845− && let Some(last) = values.last()
3846− && last == "Nothing"
3847− {
3848− return make_nothing();
3849− }
3850−
3851− if let ExKind::Rel(elem) = &kind {
3960+ if let ExKind::Rel(elem) | ExKind::Vec(elem) | ExKind::List(elem) = &kind {
38523961 if values.is_empty() {
38533962 return std::ptr::null_mut();
38543963 }
38553964 // Each occurrence may be comma-separated; an empty value
38563965 // (`--tag=`) contributes no elements, so `--tag=` alone yields
3857− // the empty relation.
3966+ // the empty collection.
38583967 let mut rows: Vec<*mut Value> = Vec::new();
38593968 for occ in &values {
38603969 for piece in occ.split(',') {
@@ -3864,8 +3973,23 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
38643973 rows.push(parse_rel_element(name, piece, elem));
38653974 }
38663975 }
3867− let rel = alloc(Value::Relation(rows));
3868− return if is_maybe { make_just(rel) } else { rel };
3976+ let v = match &kind {
3977+ // A relation is a set: dedup.
3978+ ExKind::Rel(_) => alloc(Value::Relation(in_memory_dedup(rows))),
3979+ // A vec is ordered and allows duplicates: same representation, no
3980+ // dedup.
3981+ ExKind::Vec(_) => alloc(Value::Relation(rows)),
3982+ // A list is a Cons/Nil chain, built tail-first.
3983+ ExKind::List(_) => {
3984+ let mut acc = list_nil();
3985+ for row in rows.into_iter().rev() {
3986+ acc = list_cons(row, acc);
3987+ }
3988+ acc
3989+ }
3990+ _ => unreachable!(),
3991+ };
3992+ return if is_maybe { make_just(v) } else { v };
38693993 }
38703994
38713995 // Scalar-shaped kinds: last occurrence wins.
@@ -3907,9 +4031,11 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
39074031 ExKind::Refined(base) => {
39084032 // Parse as the base scalar; the refinement predicate is checked
39094033 // by generated code after this returns.
3910− parse_rel_element(name, val_str, base)
4034+ parse_rel_element(name, val_str, &ElemKind::Scalar(base))
4035+ }
4036+ ExKind::Rel(_) | ExKind::Vec(_) | ExKind::List(_) => {
4037+ unreachable!("handled above")
39114038 }
3912− ExKind::Rel(_) => unreachable!("handled above"),
39134039 };
39144040
39154041 if is_maybe {
@@ -3984,6 +4110,74 @@ pub extern "C-unwind" fn knot_override_check_refined(
39844110 }
39854111}
39864112
4113+/// Apply a refined predicate to every element of a collection override
4114+/// (`Vec`/`Rel` — the relation representation — or `List`, a Cons/Nil chain).
4115+/// `pred_fn` is the compiled predicate (a function value); `db` is the live db
4116+/// handle for the call. Aborts naming the flag and type on the first element
4117+/// that fails. No-op when the override is null (flag absent — the default was
4118+/// already checked at compile time).
4119+#[unsafe(no_mangle)]
4120+pub extern "C-unwind" fn knot_override_check_refined_each(
4121+ db: *mut std::ffi::c_void,
4122+ coll: *mut Value,
4123+ pred_fn: *mut Value,
4124+ flag_ptr: *const u8,
4125+ flag_len: usize,
4126+ ty_ptr: *const u8,
4127+ ty_len: usize,
4128+) {
4129+ if coll.is_null() {
4130+ return;
4131+ }
4132+ let flag = unsafe { str_from_raw(flag_ptr, flag_len) }.to_string();
4133+ let ty = unsafe { str_from_raw(ty_ptr, ty_len) }.to_string();
4134+ let check = |elem: *mut Value| {
4135+ let res = knot_value_call(db, pred_fn, elem);
4136+ let ok = matches!(unsafe { as_ref(res) }, Value::Bool(true));
4137+ if !ok {
4138+ eprintln!(
4139+ "Error: value '{}' for --{} does not satisfy predicate for type '{}'",
4140+ display_value(elem),
4141+ flag,
4142+ ty
4143+ );
4144+ std::process::exit(1);
4145+ }
4146+ };
4147+ match unsafe { as_ref(coll) } {
4148+ Value::Relation(rows) => {
4149+ for &row in rows {
4150+ check(row);
4151+ }
4152+ }
4153+ // A List is a Cons/Nil chain: Cons {head, tail}.
4154+ Value::Constructor(..) => {
4155+ let mut cur = coll;
4156+ loop {
4157+ match unsafe { as_ref(cur) } {
4158+ Value::Constructor(tag, payload) if ctor_leaf(tag) == "Cons" => {
4159+ if let Value::Record(fields) = unsafe { as_ref(*payload) } {
4160+ let head = fields.iter().find(|f| &*f.name == "head").map(|f| f.value);
4161+ let tail = fields.iter().find(|f| &*f.name == "tail").map(|f| f.value);
4162+ if let Some(h) = head {
4163+ check(h);
4164+ }
4165+ match tail {
4166+ Some(t) => cur = t,
4167+ None => break,
4168+ }
4169+ } else {
4170+ break;
4171+ }
4172+ }
4173+ _ => break,
4174+ }
4175+ }
4176+ }
4177+ _ => {}
4178+ }
4179+}
4180+
39874181/// Decode a default-value string emitted by the compiler.
39884182///
39894183/// The compiler escapes `\` as `\\` and `,` as `\c` so the entry
@@ -13994,6 +14188,73 @@ fn read_schema_cursor(conn: &rusqlite::Connection, name: &str) -> Option<(String
1399414188 .ok()
1399514189}
1399614190
14191+/// One migration step baked into the binary, recorded at startup for
14192+/// `db info`: the `from`/`to` descriptors and the migration fn source.
14193+#[derive(Clone, Debug)]
14194+pub struct MigrationStep {
14195+ pub step_idx: i64,
14196+ pub from_schema: String,
14197+ pub to_schema: String,
14198+ pub using_src: String,
14199+}
14200+
14201+/// The migration chain per source, recorded as the binary registers each step
14202+/// at startup. Lets `db info` show the full migration history (which lives in
14203+/// the binary, not the db) alongside the db's current cursor.
14204+static MIGRATION_CHAINS: std::sync::LazyLock<RwLock<HashMap<String, Vec<MigrationStep>>>> =
14205+ std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
14206+
14207+/// Record one migration step for `db info`. Idempotent per (source, step_idx)
14208+/// — re-registration (e.g. a rebuilt binary with the same chain) replaces it.
14209+fn register_migration_step(source: &str, step: MigrationStep) {
14210+ let mut chains = MIGRATION_CHAINS.write().unwrap_or_else(|e| e.into_inner());
14211+ let chain = chains.entry(source.to_string()).or_default();
14212+ match chain.iter_mut().find(|s| s.step_idx == step.step_idx) {
14213+ Some(existing) => *existing = step,
14214+ None => chain.push(step),
14215+ }
14216+ chain.sort_by_key(|s| s.step_idx);
14217+}
14218+
14219+/// `<program>` startup registers each baked-in migration step so `db info` can
14220+/// show the full per-source chain (which lives in the binary, not the db).
14221+/// `from`/`to` are the step's descriptors, `using_src` the migration fn source.
14222+#[unsafe(no_mangle)]
14223+pub extern "C-unwind" fn knot_register_migration_step(
14224+ source_ptr: *const u8,
14225+ source_len: usize,
14226+ step_idx: i64,
14227+ from_ptr: *const u8,
14228+ from_len: usize,
14229+ to_ptr: *const u8,
14230+ to_len: usize,
14231+ using_ptr: *const u8,
14232+ using_len: usize,
14233+) {
14234+ let source = unsafe { str_from_raw(source_ptr, source_len) };
14235+ register_migration_step(
14236+ source,
14237+ MigrationStep {
14238+ step_idx,
14239+ from_schema: unsafe { str_from_raw(from_ptr, from_len) }.to_string(),
14240+ to_schema: unsafe { str_from_raw(to_ptr, to_len) }.to_string(),
14241+ using_src: unsafe { str_from_raw(using_ptr, using_len) }.to_string(),
14242+ },
14243+ );
14244+}
14245+
14246+
14247+/// Read the recorded chain for a source (empty if none registered).
14248+fn migration_chain_for(source: &str) -> Vec<MigrationStep> {
14249+ MIGRATION_CHAINS
14250+ .read()
14251+ .unwrap_or_else(|e| e.into_inner())
14252+ .get(source)
14253+ .cloned()
14254+ .unwrap_or_default()
14255+}
14256+
14257+
1399714258/// Apply a migration to a source relation.
1399814259///
1399914260/// Checks the stored schema in `_knot_schema`:
@@ -23474,6 +23735,231 @@ pub extern "C-unwind" fn knot_api_handle(argc: i32, argv: *const *const u8) -> i
2347423735/// Handle `<program> db` subcommand: launch TUI database explorer.
2347523736/// Returns 1 if handled (caller should exit), 0 otherwise.
2347623737#[unsafe(no_mangle)]
23738+/// Read a NUL-terminated C-string argument (arg index `i`) from argv.
23739+unsafe fn c_arg(argv: *const *const u8, i: i32) -> String {
23740+ unsafe {
23741+ let ptr = *argv.add(i as usize);
23742+ let mut len = 0;
23743+ while *ptr.add(len) != 0 {
23744+ len += 1;
23745+ }
23746+ String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).to_string()
23747+ }
23748+}
23749+
23750+/// Minimal ANSI styling for `db info`, applied only on a TTY (plain otherwise,
23751+/// so piped output stays clean).
23752+#[derive(Clone, Copy)]
23753+struct Style {
23754+ tty: bool,
23755+}
23756+
23757+impl Style {
23758+ fn detect() -> Self {
23759+ Style {
23760+ tty: std::io::IsTerminal::is_terminal(&std::io::stdout()),
23761+ }
23762+ }
23763+ fn wrap(&self, code: &str, s: &str) -> String {
23764+ if self.tty {
23765+ format!("\x1b[{}m{}\x1b[0m", code, s)
23766+ } else {
23767+ s.to_string()
23768+ }
23769+ }
23770+ fn bold(&self, s: &str) -> String {
23771+ self.wrap("1", s)
23772+ }
23773+ fn dim(&self, s: &str) -> String {
23774+ self.wrap("2", s)
23775+ }
23776+ fn cyan(&self, s: &str) -> String {
23777+ self.wrap("36", s)
23778+ }
23779+ fn green(&self, s: &str) -> String {
23780+ self.wrap("32", s)
23781+ }
23782+ fn yellow(&self, s: &str) -> String {
23783+ self.wrap("33", s)
23784+ }
23785+}
23786+
23787+/// Human-readable byte size (e.g. `56.0 KB`).
23788+fn human_bytes(n: u64) -> String {
23789+ const KB: u64 = 1024;
23790+ const MB: u64 = KB * 1024;
23791+ const GB: u64 = MB * 1024;
23792+ if n >= GB {
23793+ format!("{:.1} GB", n as f64 / GB as f64)
23794+ } else if n >= MB {
23795+ format!("{:.1} MB", n as f64 / MB as f64)
23796+ } else if n >= KB {
23797+ format!("{:.1} KB", n as f64 / KB as f64)
23798+ } else {
23799+ format!("{} B", n)
23800+ }
23801+}
23802+
23803+/// `<program> db info` — print a human-readable report of the database in knot
23804+/// terms: file summary, then per source its current schema descriptor (the db's
23805+/// actual state), the full baked-in migration chain, and the row count.
23806+fn db_info(db_path: &str) {
23807+ let st = Style::detect();
23808+ let conn = match Connection::open(db_path) {
23809+ Ok(c) => c,
23810+ Err(e) => {
23811+ eprintln!("knot db info: cannot open {}: {}", db_path, e);
23812+ std::process::exit(1);
23813+ }
23814+ };
23815+
23816+ // ── Header: file summary ──
23817+ let size = std::fs::metadata(db_path).map(|m| m.len()).unwrap_or(0);
23818+ let pages: i64 = conn
23819+ .query_row("PRAGMA page_count", [], |r| r.get(0))
23820+ .unwrap_or(0);
23821+ let page_size: i64 = conn
23822+ .query_row("PRAGMA page_size", [], |r| r.get(0))
23823+ .unwrap_or(0);
23824+ let journal: String = conn
23825+ .query_row("PRAGMA journal_mode", [], |r| r.get(0))
23826+ .unwrap_or_default();
23827+ println!("{}", st.bold(db_path));
23828+ println!(
23829+ "{}",
23830+ st.dim(&format!(
23831+ "{} · {} pages × {} B · {}",
23832+ human_bytes(size),
23833+ pages,
23834+ page_size,
23835+ journal
23836+ ))
23837+ );
23838+ println!();
23839+
23840+ // ── Sources ──
23841+ let mut stmt = match conn
23842+ .prepare("SELECT name, schema, applied FROM _knot_schema ORDER BY name")
23843+ {
23844+ Ok(s) => s,
23845+ Err(_) => {
23846+ println!("{}", st.dim("(empty database — no sources)"));
23847+ return;
23848+ }
23849+ };
23850+ let sources: Vec<(String, String, i64)> = stmt
23851+ .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
23852+ .map(|r| r.filter_map(|x| x.ok()).collect())
23853+ .unwrap_or_default();
23854+
23855+ if sources.is_empty() {
23856+ println!("{}", st.dim("(empty database — no sources)"));
23857+ return;
23858+ }
23859+
23860+ println!("{}", st.bold(&format!("{} source{}", sources.len(), if sources.len() == 1 { "" } else { "s" })));
23861+ println!();
23862+
23863+ // Per-relation on-disk footprint: every physical table/index belonging to a
23864+ // source, summed from dbstat. A source `users` owns `_knot_users` (main
23865+ // table), `_knot_users/…` (child/link tables for nested fields), and
23866+ // `_knot_users_…` (its indexes). We match against the *known* source names
23867+ // (longest prefix wins) so a source name containing `_`/`/` isn't split
23868+ // wrongly. Falls back to None when dbstat isn't compiled in.
23869+ let source_names: Vec<&str> = sources.iter().map(|(n, _, _)| n.as_str()).collect();
23870+ let relation_bytes: Option<std::collections::HashMap<String, u64>> = conn
23871+ .prepare("SELECT name, pgsize FROM dbstat")
23872+ .ok()
23873+ .map(|mut s| {
23874+ let mut map: std::collections::HashMap<String, u64> = std::collections::HashMap::new();
23875+ let rows = s.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)));
23876+ if let Ok(rows) = rows {
23877+ for r in rows.flatten() {
23878+ let (tbl, bytes) = (r.0, r.1 as u64);
23879+ let Some(rest) = tbl.strip_prefix("_knot_") else { continue };
23880+ if rest == "schema" {
23881+ continue;
23882+ }
23883+ // Longest known source that is `rest` exactly, or a prefix
23884+ // followed by `_` (index) or `/` (child table).
23885+ let owner = source_names
23886+ .iter()
23887+ .filter(|src| {
23888+ let s: &str = src;
23889+ rest == s
23890+ || rest
23891+ .strip_prefix(s)
23892+ .is_some_and(|t| t.starts_with('_') || t.starts_with('/'))
23893+ })
23894+ .max_by_key(|src| src.len());
23895+ if let Some(owner) = owner {
23896+ *map.entry(owner.to_string()).or_insert(0) += bytes;
23897+ }
23898+ }
23899+ }
23900+ map
23901+ });
23902+
23903+ for (name, schema, applied) in &sources {
23904+ let table = quote_ident(&format!("_knot_{}", name));
23905+ let rows: i64 = conn
23906+ .query_row(&format!("SELECT count(*) FROM {}", table), [], |r| r.get(0))
23907+ .unwrap_or(0);
23908+
23909+ // Source header: *name, size, row count, migration position.
23910+ let size_part = relation_bytes
23911+ .as_ref()
23912+ .and_then(|m| m.get(name.as_str()))
23913+ .map(|b| format!("{} · ", human_bytes(*b)))
23914+ .unwrap_or_default();
23915+ println!(
23916+ "{} {}",
23917+ st.cyan(&format!("*{}", name)),
23918+ st.dim(&format!(
23919+ "{}{} row{} · migration {}",
23920+ size_part,
23921+ rows,
23922+ if rows == 1 { "" } else { "s" },
23923+ applied
23924+ ))
23925+ );
23926+ // Current descriptor (the db's actual state).
23927+ println!("{}", schema);
23928+
23929+ // The full migration chain, each step marked applied / pending.
23930+ let chain = migration_chain_for(name);
23931+ if !chain.is_empty() {
23932+ for (i, step) in chain.iter().enumerate() {
23933+ let last = i + 1 == chain.len();
23934+ let connector = if last { "└─" } else { "├─" };
23935+ if step.step_idx < *applied {
23936+ println!(
23937+ "{} {} {}",
23938+ st.dim(connector),
23939+ st.green("applied"),
23940+ st.dim(&format!("{} → {}", step.from_schema, step.to_schema))
23941+ );
23942+ } else {
23943+ println!(
23944+ "{} {} {}",
23945+ st.dim(connector),
23946+ st.yellow("pending"),
23947+ format!("{} → {}", step.from_schema, step.to_schema)
23948+ );
23949+ }
23950+ }
23951+ }
23952+ println!();
23953+ }
23954+}
23955+
23956+/// `<program> db` subcommands: with no further argument, launch the TUI
23957+/// explorer; `db info` prints a human-readable report (per-source descriptor,
23958+/// migration chain, row counts); `db dump <out>` snapshots the (live) database
23959+/// to a single binary file (`VACUUM INTO`); `db restore <dump>` replaces the
23960+/// database with a dump file (offline). Returns 1 if handled (caller exits), 0
23961+/// to continue.
23962+#[unsafe(no_mangle)]
2347723963pub extern "C-unwind" fn knot_db_handle(
2347823964 argc: i32,
2347923965 argv: *const *const u8,
@@ -23483,21 +23969,64 @@ pub extern "C-unwind" fn knot_db_handle(
2348323969 if argc < 2 {
2348423970 return 0;
2348523971 }
23486− let arg1 = unsafe {
23487− let ptr = *argv.add(1);
23488− let mut len = 0;
23489− while *ptr.add(len) != 0 {
23490− len += 1;
23491− }
23492− String::from_utf8_lossy(std::slice::from_raw_parts(ptr, len)).to_string()
23493− };
23494−
23972+ let arg1 = unsafe { c_arg(argv, 1) };
2349523973 if arg1 != "db" {
2349623974 return 0;
2349723975 }
23498−
2349923976 let db_path = unsafe { str_from_raw(db_path_ptr, db_path_len) };
23977+ let db = std::path::Path::new(db_path);
23978+ let prog = unsafe { c_arg(argv, 0) };
23979+
23980+ // `./app db dump <out>` / `./app db restore <dump>` — binary snapshot.
23981+ if argc >= 3 {
23982+ match unsafe { c_arg(argv, 2) }.as_str() {
23983+ "dump" => {
23984+ if argc < 4 {
23985+ eprintln!("usage: {} db dump <out>", prog);
23986+ std::process::exit(1);
23987+ }
23988+ let out = unsafe { c_arg(argv, 3) };
23989+ match crate::db_dump::dump_db(db, std::path::Path::new(&out)) {
23990+ Ok(()) => {
23991+ println!("Dumped {} -> {}", db.display(), out);
23992+ return 1;
23993+ }
23994+ Err(e) => {
23995+ eprintln!("db dump: {}", e);
23996+ std::process::exit(1);
23997+ }
23998+ }
23999+ }
24000+ "restore" => {
24001+ if argc < 4 {
24002+ eprintln!("usage: {} db restore <dump>", prog);
24003+ std::process::exit(1);
24004+ }
24005+ let dump = unsafe { c_arg(argv, 3) };
24006+ match crate::db_dump::restore_db(std::path::Path::new(&dump), db) {
24007+ Ok(()) => {
24008+ println!("Restored {} -> {}", dump, db.display());
24009+ return 1;
24010+ }
24011+ Err(e) => {
24012+ eprintln!("db restore: {}", e);
24013+ std::process::exit(1);
24014+ }
24015+ }
24016+ }
24017+ "info" => {
24018+ db_info(db_path);
24019+ return 1;
24020+ }
24021+ other => {
24022+ eprintln!("unknown db subcommand: {}", other);
24023+ eprintln!("usage: {} db [info | dump <out> | restore <dump>]", prog);
24024+ std::process::exit(1);
24025+ }
24026+ }
24027+ }
2350024028
24029+ // `./app db` — the TUI explorer.
2350124030 if let Err(e) = tui::run_db_explorer(db_path) {
2350224031 eprintln!("knot db: {}", e);
2350324032 std::process::exit(1);
@@ -23651,68 +24180,40 @@ fn capitalize(s: &str) -> String {
2365124180/// declarations (`name Text`, `age Int`). The descriptor is the knot record
2365224181/// format: fields separated by 2+ spaces, name and type by a single space.
2365324182/// Handles nested relations (`col Rel {inner}`) and ADT tags.
24183+/// Convert a schema descriptor (`{name Text age Int}`) to knot field
24184+/// declarations (`name Text`, `age (Int 1)`). Fields are split with the
24185+/// single-owner `knot-descriptor` grammar (robust to nested `{..}`/`(..)`),
24186+/// then each field's *type* is re-emitted in descriptor form verbatim — except
24187+/// a bare top-level numeric, which gains the dimensionless unit knot source
24188+/// requires (`Int` → `(Int 1)`). Matches the original hand-rolled splitter's
24189+/// output exactly.
2365424190fn schema_descriptor_to_fields(schema: &str) -> Vec<String> {
23655− // Strip ONE outer brace pair.
2365624191 let t = schema.trim();
23657− let inner = if t.starts_with('{') && t.ends_with('}') {
23658− &t[1..t.len() - 1]
24192+ let parsed = if t.starts_with('{') {
24193+ knot_descriptor::parse(t).ok()
2365924194 } else {
23660− t
24195+ // A bare (braceless) field list, e.g. a ctor payload fragment.
24196+ knot_descriptor::parse(&format!("{{{t}}}")).ok()
24197+ };
24198+ let Some(knot_descriptor::Desc::Record(fields)) = parsed else {
24199+ return Vec::new();
2366124200 };
23662− let inner = inner.trim();
23663− // Split on 2+ spaces (the field separator).
23664− let mut fields = Vec::new();
23665− let mut current = String::new();
23666− let mut depth = 0;
23667− let chars: Vec<char> = inner.chars().collect();
23668− let mut i = 0;
23669− while i < chars.len() {
23670− let c = chars[i];
23671− match c {
23672− '{' => { depth += 1; current.push(c); }
23673− '}' => { depth -= 1; current.push(c); }
23674− '[' => { depth += 1; current.push(c); }
23675− ']' => { depth -= 1; current.push(c); }
23676− ' ' if depth == 0 => {
23677− // 2+ spaces separate fields; a single space is within a field.
23678− let mut j = i;
23679− while j < chars.len() && chars[j] == ' ' {
23680− j += 1;
23681− }
23682− if j - i >= 2 {
23683− // A field boundary.
23684− if !current.trim().is_empty() {
23685− fields.push(current.trim().to_string());
23686− }
23687− current = String::new();
23688− i = j;
23689− continue;
23690− }
23691− current.push(c);
23692− }
23693− _ => current.push(c),
23694− }
23695− i += 1;
23696− }
23697− if !current.trim().is_empty() {
23698− fields.push(current.trim().to_string());
23699− }
23700− // The descriptor stores `Int`/`Float` without a unit. Knot requires one —
23701− // emit `Int 1`/`Float 1` (dimensionless) for a bare numeric type.
2370224201 fields
2370324202 .into_iter()
23704− .map(|f| {
23705− let parts: Vec<&str> = f.splitn(2, ' ').collect();
23706− if parts.len() == 2 {
23707− let (name, ty) = (parts[0], parts[1]);
23708− match ty {
23709− "Int" => format!("{} (Int 1)", name),
23710− "Float" => format!("{} (Float 1)", name),
23711− _ => f,
24203+ .map(|(name, ty)| {
24204+ let ty_str = match ty {
24205+ // Bare numerics need a dimensionless unit in knot source.
24206+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Int) => {
24207+ "(Int 1)".to_string()
2371224208 }
23713− } else {
23714− f
23715− }
24209+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Float) => {
24210+ "(Float 1)".to_string()
24211+ }
24212+ // Everything else round-trips to the descriptor spelling
24213+ // (`(Rel T)`, `(A | B)`, nested records) verbatim.
24214+ other => knot_descriptor::emit(&other),
24215+ };
24216+ format!("{} {}", name, ty_str)
2371624217 })
2371724218 .collect()
2371824219}
@@ -23732,51 +24233,39 @@ fn qualify_adt_ctor(value: &str, type_name: &str) -> String {
2373224233}
2373324234
2373424235/// Convert an ADT schema descriptor (`(Circle{radius Int} | Point)`) to knot
23735−/// constructor declarations (`Circle {radius (Int 1)}`, `Point {}`).
24236+/// constructor declarations (`Circle {radius (Int 1)}`, `Point {}`), using the
24237+/// single-owner `knot-descriptor` grammar.
2373624238fn schema_descriptor_to_adt_ctors(schema: &str) -> Vec<String> {
23737− // Strip the outer parens and split on ` | `.
23738− let inner = schema.trim().trim_start_matches('(').trim_end_matches(')').trim();
23739− let mut ctors = Vec::new();
23740− let mut current = String::new();
23741− let mut depth = 0;
23742− let chars: Vec<char> = inner.chars().collect();
23743− let mut i = 0;
23744− while i < chars.len() {
23745− let c = chars[i];
23746− match c {
23747− '{' => { depth += 1; current.push(c); }
23748− '}' => { depth -= 1; current.push(c); }
23749− '|' if depth == 0 => {
23750− if !current.trim().is_empty() {
23751− ctors.push(adt_ctor_to_knot(current.trim()));
23752− }
23753− current = String::new();
23754− i += 1;
23755− continue;
23756− }
23757− _ => current.push(c),
23758− }
23759− i += 1;
23760− }
23761− if !current.trim().is_empty() {
23762− ctors.push(adt_ctor_to_knot(current.trim()));
23763− }
23764− ctors
24239+ let Ok(knot_descriptor::Desc::Variant(ctors)) = knot_descriptor::parse(schema.trim())
24240+ else {
24241+ return Vec::new();
24242+ };
24243+ ctors.iter().map(|(name, fields)| adt_ctor_to_knot(name, fields)).collect()
2376524244}
2376624245
23767−/// Convert one ADT constructor descriptor (`Circle{radius Int}` or `Point`) to
23768−/// the knot form (`Circle {radius (Int 1)}` or `Point {}`).
23769−fn adt_ctor_to_knot(ctor: &str) -> String {
23770− let ctor = ctor.trim();
23771− if let Some(brace) = ctor.find('{') {
23772− let name = ctor[..brace].trim();
23773− let fields_str = &ctor[brace..]; // `{radius Int}`
23774− let fields = schema_descriptor_to_fields(fields_str);
23775− format!("{} {{{}}}", name, fields.join(" "))
23776− } else {
24246+/// Convert one ADT constructor (name + payload fields) to the knot form
24247+/// (`Circle {radius (Int 1)}` or `Point {}`).
24248+fn adt_ctor_to_knot(name: &str, fields: &[(String, knot_descriptor::Desc)]) -> String {
24249+ if fields.is_empty() {
2377724250 // A nullary constructor.
23778− format!("{} {{}}", ctor)
24251+ return format!("{} {{}}", name);
2377924252 }
24253+ let specs: Vec<String> = fields
24254+ .iter()
24255+ .map(|(n, t)| {
24256+ let ty_str = match t {
24257+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Int) => {
24258+ "(Int 1)".to_string()
24259+ }
24260+ knot_descriptor::Desc::Scalar(knot_descriptor::Scalar::Float) => {
24261+ "(Float 1)".to_string()
24262+ }
24263+ other => knot_descriptor::emit(other),
24264+ };
24265+ format!("{} {}", n, ty_str)
24266+ })
24267+ .collect();
24268+ format!("{} {{{}}}", name, specs.join(" "))
2378024269}
2378124270
2378224271fn generate_openapi(name: &str, table: &RouteTable) -> String {
crates/knot-runtime/src/tui.rs+21−78
@@ -57,85 +57,28 @@ fn parse_schema_kind(schema: &str) -> SchemaKind {
5757 if t.is_empty() {
5858 return SchemaKind::Unit;
5959 }
60− if t.starts_with('(') && t.ends_with(')') {
61− // ADT: split top-level `|`, each ctor is `Name` or `Name{...}`.
62− let inner = &t[1..t.len() - 1];
63− let mut ctors = Vec::new();
64− for ctor_part in split_toplevel(inner, '|') {
65− let ctor_part = ctor_part.trim();
66− if ctor_part.is_empty() {
67− continue;
68− }
69− if let Some(brace) = ctor_part.find('{') {
70− let name = ctor_part[..brace].trim().to_string();
71− let fields = parse_record_fields(&ctor_part[brace..]);
72− ctors.push(AdtCtor { name, fields });
73− } else {
74− ctors.push(AdtCtor { name: ctor_part.to_string(), fields: Vec::new() });
75− }
76− }
77− SchemaKind::Adt(ctors)
78− } else {
79− SchemaKind::Record(parse_record_fields(t))
80− }
81−}
82−
83−/// Parse `{name Kind name2 Kind2}` into (name, kind) pairs.
84−fn parse_record_fields(s: &str) -> Vec<(String, String)> {
85− let t = s.trim();
86− let inner = match t.strip_prefix('{').and_then(|x| x.strip_suffix('}')) {
87− Some(b) => b,
88− None => return Vec::new(),
89− };
90− let bytes = inner.as_bytes();
91− let mut out = Vec::new();
92− let mut i = 0;
93− while i < bytes.len() {
94− while i < bytes.len() && (bytes[i] as char).is_whitespace() { i += 1; }
95− if i >= bytes.len() { break; }
96− let name_start = i;
97− while i < bytes.len() && ((bytes[i] as char).is_alphanumeric() || bytes[i] == b'_') { i += 1; }
98− let name = &inner[name_start..i];
99− while i < bytes.len() && (bytes[i] as char).is_whitespace() { i += 1; }
100− let kind_start = i;
101− if i < bytes.len() && (bytes[i] == b'{' || bytes[i] == b'(' || bytes[i] == b'[') {
102− let open = bytes[i];
103− let close = match open { b'{' => b'}', b'(' => b')', _ => b']' };
104− let mut depth = 0usize;
105− while i < bytes.len() {
106− if bytes[i] == open { depth += 1; }
107− else if bytes[i] == close { depth -= 1; if depth == 0 { i += 1; break; } }
108− i += 1;
109− }
110− } else {
111− while i < bytes.len() && ((bytes[i] as char).is_alphanumeric() || bytes[i] == b'_') { i += 1; }
112− }
113− let kind = &inner[kind_start..i];
114− if !name.is_empty() {
115− out.push((name.to_string(), kind.to_string()));
116− }
117− }
118− out
119−}
120−
121−/// Split on `sep` at the top nesting level (respecting `{...}`/`(...)`/`[...]`).
122−fn split_toplevel(s: &str, sep: char) -> Vec<&str> {
123− let mut parts = Vec::new();
124− let mut depth = 0usize;
125− let mut start = 0;
126− for (i, c) in s.char_indices() {
127− match c {
128− '{' | '(' | '[' => depth += 1,
129− '}' | ')' | ']' => depth = depth.saturating_sub(1),
130− c if c == sep && depth == 0 => {
131− parts.push(&s[start..i]);
132− start = i + c.len_utf8();
133− }
134− _ => {}
135− }
60+ match knot_descriptor::parse(t) {
61+ Ok(knot_descriptor::Desc::Variant(ctors)) => SchemaKind::Adt(
62+ ctors
63+ .into_iter()
64+ .map(|(name, fields)| AdtCtor {
65+ name,
66+ fields: fields
67+ .into_iter()
68+ .map(|(n, ty)| (n, knot_descriptor::emit(&ty)))
69+ .collect(),
70+ })
71+ .collect(),
72+ ),
73+ Ok(knot_descriptor::Desc::Record(fields)) => SchemaKind::Record(
74+ fields
75+ .into_iter()
76+ .map(|(n, ty)| (n, knot_descriptor::emit(&ty)))
77+ .collect(),
78+ ),
79+ // A scalar/relational element or an unparseable schema: no fields to show.
80+ _ => SchemaKind::Record(Vec::new()),
13681 }
137− parts.push(&s[start..]);
138− parts
13982}
14083
14184// ── Data loading ─────────────────────────────────────────────────
crates/knot/src/parser.rs+21
@@ -2517,6 +2517,17 @@ impl Parser {
25172517 unreachable!()
25182518 };
25192519 let bare = n.trim_start_matches('#').to_string();
2520+ // A `#name` may be declared in *different* scopes (they
2521+ // resolve to the same value), but never twice in one
2522+ // record.
2523+ if fields.iter().any(|f| {
2524+ matches!(&f.value.node, ExprKind::ArgDecl { name, .. } if *name == bare)
2525+ }) {
2526+ self.error_at(
2527+ t.span,
2528+ format!("argument `#{bare}` is declared more than once in this scope"),
2529+ );
2530+ }
25202531 self.skip_newlines();
25212532 let default = if self.at(&TokenKind::Underscore) {
25222533 self.advance();
@@ -2692,6 +2703,16 @@ impl Parser {
26922703 let hspan = self.span();
26932704 self.advance();
26942705 let bare = hname.trim_start_matches('#').to_string();
2706+ // A `#name` may be declared in *different* scopes (they resolve
2707+ // to the same value), but never twice in one record.
2708+ if fields.iter().any(|f| {
2709+ matches!(&f.value.node, ExprKind::ArgDecl { name, .. } if *name == bare)
2710+ }) {
2711+ self.error_at(
2712+ hspan,
2713+ format!("argument `#{bare}` is declared more than once in this scope"),
2714+ );
2715+ }
26952716 self.skip_newlines();
26962717 let default = if self.at(&TokenKind::Underscore) {
26972718 self.advance();