on master help sign in
Let Vec, List, and Rel `#`-arguments share the collection flag syntax

A `#`-argument of a collection type — `Vec <scalar>`, `List <scalar>`, or `Rel <scalar>` — now accepts the same CLI forms: comma-separated within one flag (`--tags=a,b`) and/or repeated flags (`--tags=a --tags=b`), with each element parsed per the scalar type. An empty value (`--tags=`) contributes no elements, so `--tags=` alone yields the empty collection. with { Vec Text #tags (base.the (Vec Text) []) } ./app --tags=a,b,a → [a, b, a] (ordered, keeps duplicates) with { List Text #tags (List.Nil {}) } ./app --tags=a,b → Cons {head a tail Cons {head b tail Nil}} Each container builds its own runtime value from the same flag syntax: - Rel → `Value::Relation`, deduplicated (a set). - Vec → `Value::Relation`, ordered, duplicates kept (no dedup). - List → a `Cons`/`Nil` chain, built tail-first. Maybe-wrapped collections work too: `--tags=Nothing` → `Nothing {}`, otherwise the collection wraps in `Just {value …}`. Bare `--flags` yields `[True]` for `Vec Bool` (the bool bare-flag rule applies per element). Also fixes a latent bug: the Rel flag path built `Value::Relation(rows)` without deduplicating, so `--tags=a,b,a` yielded `[a, b, a]` — a relation with a duplicate row, violating set semantics. It now dedups via `in_memory_dedup`. 6 new tests in cli_overrides (Rel dedup vs Vec order/dups, Vec repeated flags, List cons chain, List empty, Vec bare bool, Maybe Vec). Full workspace suite green.

commit0653e18e8fa9257850f5d5965962b05c215a8cf3 authoriko <ilyakooo0@gmail.com> dateSun 6 Sep 2026, 08:16 (1 month ago) parent33339f9
3 files changed+179−12
crates/knot-compiler/src/codegen.rs+7−3 crates/knot-jit/tests/cli_overrides.rs+133 crates/knot-runtime/src/lib.rs+39−9
crates/knot-compiler/src/codegen.rs+7−3
@@ -680,8 +680,12 @@ fn scalar_override_type(
680680 // unit-agnostic, classify by the base scalar.
681681 name if name.starts_with("Int ") => "Int".to_string(),
682682 name if name.starts_with("Float ") => "Float".to_string(),
683− name if name.starts_with("Rel ") => {
684− let elem = name[4..].trim();
683+ name if name.starts_with("Rel ")
684+ || name.starts_with("Vec ")
685+ || name.starts_with("List ") =>
686+ {
687+ let container = name.split_whitespace().next().unwrap();
688+ let elem = name[container.len()..].trim();
685689 let elem = elem
686690 .strip_prefix('(')
687691 .and_then(|r| r.strip_suffix(')'))
@@ -692,7 +696,7 @@ fn scalar_override_type(
692696 e if e.starts_with("Float ") => "Float",
693697 _ => return None,
694698 };
695− format!("Rel {elem_base}")
699+ format!("{container} {elem_base}")
696700 }
697701 name => {
698702 match type_aliases.get(name)? {
crates/knot-jit/tests/cli_overrides.rs+133
@@ -381,6 +381,139 @@ 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+ let (out, err, code) = run_with_args(&dir, "maybe_vec", &["--tags=a,b"]);
510+ assert_eq!(code, 0, "stderr: {err}");
511+ assert_eq!(out.trim_end(), "\"Just {value [a, b]}\"\n{}");
512+ let (out, err, code) = run_with_args(&dir, "maybe_vec", &["--tags=Nothing"]);
513+ assert_eq!(code, 0, "stderr: {err}");
514+ assert_eq!(out.trim_end(), "\"Nothing\"\n{}");
515+}
516+
384517// ── Maybe variants of the new types ─────────────────────────────────────────
385518
386519#[test]
crates/knot-runtime/src/lib.rs+39−9
@@ -3695,7 +3695,13 @@ enum ExKind<'a> {
36953695 Bytes,
36963696 Uuid,
36973697 /// Element scalar for `Rel <scalar>` ("Int" | "Float" | "Text" | "Bool").
3698+ /// A relation is a SET: rows are deduplicated.
36983699 Rel(&'a str),
3700+ /// `Vec <scalar>` — ordered, allows duplicates. Shares the relation
3701+ /// representation but skips dedup.
3702+ Vec(&'a str),
3703+ /// `List <scalar>` — a Cons/Nil chain.
3704+ List(&'a str),
36993705 /// All-nullary ADT: valid constructor names.
37003706 Enum(&'a [String]),
37013707 /// Refined scalar: the base scalar ("Int" | "Float" | "Text" | "Bool").
@@ -3708,7 +3714,10 @@ impl<'a> ExKind<'a> {
37083714 fn is_bool(&self) -> bool {
37093715 matches!(
37103716 self,
3711− ExKind::Rel("Bool") | ExKind::Refined("Bool")
3717+ ExKind::Rel("Bool")
3718+ | ExKind::Vec("Bool")
3719+ | ExKind::List("Bool")
3720+ | ExKind::Refined("Bool")
37123721 )
37133722 }
37143723
@@ -3717,7 +3726,9 @@ impl<'a> ExKind<'a> {
37173726 match self {
37183727 ExKind::Bytes => "base64url-encoded Bytes".to_string(),
37193728 ExKind::Uuid => "a Uuid (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)".to_string(),
3720− ExKind::Rel(e) => format!("a comma-separated list of {}", e),
3729+ ExKind::Rel(e) | ExKind::Vec(e) | ExKind::List(e) => {
3730+ format!("a comma-separated list of {}", e)
3731+ }
37213732 ExKind::Enum(ctors) => format!("one of: {}", ctors.join(", ")),
37223733 ExKind::Refined(b) => b.to_string(),
37233734 }
@@ -3725,8 +3736,8 @@ impl<'a> ExKind<'a> {
37253736}
37263737
37273738/// Parse the compiler-emitted descriptor: `Bytes`, `Uuid`, `Rel <scalar>`,
3728−/// `enum:<Name>(<Ctor>,<Ctor>,…)`, `refined:<Name>(<base-scalar>)`, each
3729−/// optionally prefixed by `Maybe `.
3739+/// `Vec <scalar>`, `List <scalar>`, `enum:<Name>(<Ctor>,<Ctor>,…)`,
3740+/// `refined:<Name>(<base-scalar>)`, each optionally prefixed by `Maybe `.
37303741fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
37313742 let (is_maybe, base) = match desc.strip_prefix("Maybe ") {
37323743 Some(rest) => (true, rest),
@@ -3736,6 +3747,8 @@ fn parse_ex_descriptor(desc: &str) -> Option<(bool, ExKind<'_>)> {
37363747 "Bytes" => ExKind::Bytes,
37373748 "Uuid" => ExKind::Uuid,
37383749 _ if base.starts_with("Rel ") => ExKind::Rel(&base[4..]),
3750+ _ if base.starts_with("Vec ") => ExKind::Vec(&base[4..]),
3751+ _ if base.starts_with("List ") => ExKind::List(&base[5..]),
37393752 _ if base.starts_with("enum:") => {
37403753 let rest = &base[5..];
37413754 let open = rest.find('(')?;
@@ -3849,13 +3862,13 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
38493862 return make_nothing();
38503863 }
38513864
3852− if let ExKind::Rel(elem) = &kind {
3865+ if let ExKind::Rel(elem) | ExKind::Vec(elem) | ExKind::List(elem) = &kind {
38533866 if values.is_empty() {
38543867 return std::ptr::null_mut();
38553868 }
38563869 // Each occurrence may be comma-separated; an empty value
38573870 // (`--tag=`) contributes no elements, so `--tag=` alone yields
3858− // the empty relation.
3871+ // the empty collection.
38593872 let mut rows: Vec<*mut Value> = Vec::new();
38603873 for occ in &values {
38613874 for piece in occ.split(',') {
@@ -3865,8 +3878,23 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
38653878 rows.push(parse_rel_element(name, piece, elem));
38663879 }
38673880 }
3868− let rel = alloc(Value::Relation(rows));
3869− return if is_maybe { make_just(rel) } else { rel };
3881+ let v = match &kind {
3882+ // A relation is a set: dedup.
3883+ ExKind::Rel(_) => alloc(Value::Relation(in_memory_dedup(rows))),
3884+ // A vec is ordered and allows duplicates: same representation, no
3885+ // dedup.
3886+ ExKind::Vec(_) => alloc(Value::Relation(rows)),
3887+ // A list is a Cons/Nil chain, built tail-first.
3888+ ExKind::List(_) => {
3889+ let mut acc = list_nil();
3890+ for row in rows.into_iter().rev() {
3891+ acc = list_cons(row, acc);
3892+ }
3893+ acc
3894+ }
3895+ _ => unreachable!(),
3896+ };
3897+ return if is_maybe { make_just(v) } else { v };
38703898 }
38713899
38723900 // Scalar-shaped kinds: last occurrence wins.
@@ -3910,7 +3938,9 @@ pub extern "C-unwind" fn knot_override_lookup_ex(
39103938 // by generated code after this returns.
39113939 parse_rel_element(name, val_str, base)
39123940 }
3913− ExKind::Rel(_) => unreachable!("handled above"),
3941+ ExKind::Rel(_) | ExKind::Vec(_) | ExKind::List(_) => {
3942+ unreachable!("handled above")
3943+ }
39143944 };
39153945
39163946 if is_maybe {