on master help sign in
Allow repeated `#`-arg declarations across scopes, with agreement rules

`#name` CLI args resolve globally by name at runtime (knot_override_lookup), so a repeated `#name` is not shadowing — every declaration refers to the same value. This change makes that legal, with checks that they agree: - Same-record duplicate `#name` is an error (parser: "declared more than once in this scope"). Multiple declarations belong in *different* scopes. - A repeat across scopes must agree on type (the `Type #name` sig) and default. Since agreement must be checkable, a repeated arg's default must be a literal or `_`. Two concrete defaults must be equal literals; a concrete default may pair with any number of `_` (required) declarations. - A single (non-repeated) `#name` keeps its existing freedom — any default, including enum constructors and computed values like `base.textToBytes`. The agreement check runs in `check_with_chain_shadowing` (which now exempts `#`-args from the plain shadowing error and validates them separately), and the typechecker's `bind_at` shadowing check is likewise exempted for `#`-args in both with-record binding paths. 5 new tests in cli_overrides; full suite 544/0.

commit33339f938015ea6d27c3f1ff418e6383c273d222 authoriko <ilyakooo0@gmail.com> dateSun 6 Sep 2026, 00:41 (1 month ago) parentc9c7da7
4 files changed+315−4
crates/knot-compiler/src/infer.rs+45−4 crates/knot-compiler/src/types.rs+120 crates/knot-jit/tests/cli_overrides.rs+129 crates/knot/src/parser.rs+21
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/types.rs+120
@@ -715,6 +715,12 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
715715 let mut diags = Vec::new();
716716 // (name, span) of every field bound by an OUTER layer, in chain order.
717717 let mut outer: HashMap<String, Span> = HashMap::new();
718+ // `#`-argument fields seen across the chain: name → (type sig text, the
719+ // default's literal display if it is a scalar literal). CLI args resolve
720+ // globally by name, so a repeated `#name` is NOT shadowing — every
721+ // declaration refers to the same value — as long as they all agree on type
722+ // and default. Tracked separately so an agreeing repeat is allowed.
723+ let mut cli_args: HashMap<String, (Option<String>, Option<String>, Span)> = HashMap::new();
718724 let mut cur = program;
719725 loop {
720726 match &cur.node {
@@ -723,6 +729,12 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
723729 // repeated across two outer layers shadows within the chain.
724730 if let ExprKind::Record(fields) = &record.node {
725731 for f in fields {
732+ // A CLI `#arg` is not a shadowing candidate; check it
733+ // against prior `#arg` decls instead.
734+ if let ExprKind::ArgDecl { .. } = &f.value.node {
735+ check_cli_arg_agreement(f, &mut cli_args, &mut diags);
736+ continue;
737+ }
726738 // Absent keys have no name, so they can never shadow.
727739 let Some(fname) = f.name.as_name() else { continue };
728740 if outer.insert(fname.to_string(), f.value.span).is_some() {
@@ -742,6 +754,10 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
742754 // Its fields must not repeat any outer layer's name.
743755 if let ExprKind::Record(fields) = &record.node {
744756 for f in fields {
757+ if let ExprKind::ArgDecl { .. } = &f.value.node {
758+ check_cli_arg_agreement(f, &mut cli_args, &mut diags);
759+ continue;
760+ }
745761 let Some(fname) = f.name.as_name() else { continue };
746762 if outer.contains_key(fname) {
747763 diags.push(
@@ -762,6 +778,110 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
762778 diags
763779}
764780
781+/// Record a CLI `#arg` declaration and check it against prior declarations of
782+/// the same name across the `with` chain. A single declaration is always fine
783+/// (any default). A REPEAT declaration must agree with the earlier one on type
784+/// (the sig) and default — and since agreement must be checkable, a repeated
785+/// arg's default must be a literal or `_`. Two concrete defaults must be equal
786+/// literals; a concrete default may pair with any number of `_` (required)
787+/// declarations, in which case the concrete one supplies the value.
788+fn check_cli_arg_agreement(
789+ f: &knot::ast::RecordField,
790+ cli_args: &mut HashMap<String, (Option<String>, Option<String>, Span)>,
791+ diags: &mut Vec<knot::diagnostic::Diagnostic>,
792+) {
793+ use knot::ast::ExprKind;
794+ let ExprKind::ArgDecl { name, default } = &f.value.node else {
795+ return;
796+ };
797+ // The declared type (the `Type #name` sig), rendered for comparison.
798+ let ty_str = f
799+ .sig
800+ .as_ref()
801+ .map(|s| knot::format::render_type(&s.ty));
802+ // The default, as a literal display (`None` if `_` or non-literal).
803+ let default_str = default
804+ .as_ref()
805+ .and_then(|d| default_literal_display(d));
806+
807+ match cli_args.get(name) {
808+ None => {
809+ cli_args.insert(name.clone(), (ty_str, default_str, f.value.span));
810+ }
811+ Some((prev_ty, prev_default, prev_span)) => {
812+ // This is a REPEAT declaration of the same `#name`. Since every
813+ // declaration must resolve to the same value, agreement is required
814+ // — and it must be CHECKABLE, so a repeated arg needs a literal (or
815+ // `_`) default on every occurrence.
816+ if default.is_some() && default_str.is_none() {
817+ diags.push(
818+ knot::diagnostic::Diagnostic::error(format!(
819+ "argument `#{name}` is declared in multiple scopes, so its default must be a literal (or `_`) here",
820+ ))
821+ .label(f.value.span, "computed defaults can't be compared across scopes"),
822+ );
823+ return;
824+ }
825+ // Types must agree.
826+ if *prev_ty != ty_str {
827+ diags.push(
828+ knot::diagnostic::Diagnostic::error(format!(
829+ "argument `#{name}` is declared with different types",
830+ ))
831+ .label(f.value.span, "this declaration")
832+ .label(*prev_span, "conflicts with this earlier declaration"),
833+ );
834+ return;
835+ }
836+ // Defaults must agree: equal literals, or the `_`-exception (one
837+ // concrete default among `_` declarations).
838+ let defaults_ok = match (prev_default, &default_str) {
839+ // Both `_`: fine.
840+ (None, None) => true,
841+ // One concrete, one `_`: fine (the concrete one supplies the value).
842+ (Some(_), None) | (None, Some(_)) => true,
843+ // Two concrete: must be the same literal.
844+ (Some(a), Some(b)) => a == b,
845+ };
846+ if !defaults_ok {
847+ diags.push(
848+ knot::diagnostic::Diagnostic::error(format!(
849+ "argument `#{name}` is declared with different defaults",
850+ ))
851+ .label(f.value.span, "this declaration")
852+ .label(*prev_span, "conflicts with this earlier declaration"),
853+ );
854+ return;
855+ }
856+ // Keep the concrete default (if any) as the canonical one.
857+ let merged = prev_default.clone().or(default_str);
858+ cli_args.insert(name.clone(), (ty_str, merged, *prev_span));
859+ }
860+ }
861+}
862+
863+/// Render a default-value expression as a literal display string, or `None` if
864+/// it isn't a scalar literal (Int/Float/Text/Bool/Maybe thereof). Mirrors the
865+/// CLI default rendering used at codegen.
866+fn default_literal_display(expr: &knot::ast::Expr) -> Option<String> {
867+ use knot::ast::ExprKind;
868+ match &expr.node {
869+ ExprKind::Lit(lit) => match lit {
870+ knot::ast::Literal::Int(s) => Some(s.clone()),
871+ knot::ast::Literal::Float(f) => Some(format!("{}", f)),
872+ knot::ast::Literal::Text(s) => {
873+ let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
874+ Some(format!("\"{}\"", escaped))
875+ }
876+ },
877+ ExprKind::UnaryOp {
878+ op: knot::ast::UnaryOp::Neg,
879+ operand,
880+ } => default_literal_display(operand).map(|s| format!("-{}", s)),
881+ _ => None,
882+ }
883+}
884+
765885/// Collect the alias names referenced anywhere inside a type AST.
766886fn collect_named_alias_refs(ty: &Type, alias_names: &HashSet<String>, out: &mut HashSet<String>) {
767887 match &ty.node {
crates/knot-jit/tests/cli_overrides.rs+129
@@ -414,3 +414,132 @@ Maybe Mode #mode (Maybe.Nothing {})
414414// `with`-field loop compiles the field's body inline rather than calling the
415415// global fn that would consult the override. That pre-existing gap is out of
416416// scope here; the runtime CLI path (`#`-args) is covered above.
417+
418+// ── Repeated `#`-argument declarations ──────────────────────────────────────
419+//
420+// A `#name` may be declared in multiple scopes; every declaration refers to the
421+// same (global, name-keyed) CLI argument, so they must agree on type and
422+// default. Two concrete defaults must be equal literals; a concrete default may
423+// pair with any number of `_` (required) declarations.
424+
425+/// Build `src`, expecting failure with `needle` in stderr.
426+fn build_err(dir: &TempDir, name: &str, src: &str, needle: &str) {
427+ let src_path = dir.path().join(format!("{name}.knot"));
428+ std::fs::write(&src_path, src).unwrap();
429+ let out = Command::new(e2e::knot_bin())
430+ .arg("build")
431+ .arg(&src_path)
432+ .arg("-o")
433+ .arg(dir.path().join(name))
434+ .output()
435+ .expect("failed to run knot build");
436+ assert!(
437+ !out.status.success(),
438+ "expected build of {name} to fail, but it succeeded"
439+ );
440+ let err = String::from_utf8_lossy(&out.stderr);
441+ assert!(
442+ err.contains(needle),
443+ "error for {name} did not contain `{needle}`:\n{err}"
444+ );
445+}
446+
447+#[test]
448+fn duplicate_arg_in_one_record_is_rejected() {
449+ let dir = TempDir::fresh("dup_arg_record");
450+ build_err(
451+ &dir,
452+ "dup_arg_record",
453+ r#"with {
454+Port Int 1
455+Port #port 8080
456+Port #port 9090
457+}
458+(|
459+ base.println port
460+ yield {})"#,
461+ "declared more than once",
462+ );
463+}
464+
465+#[test]
466+fn repeated_arg_agreeing_literals_allowed() {
467+ let dir = TempDir::fresh("repeat_agree");
468+ build(
469+ &dir,
470+ "repeat_agree",
471+ r#"with {
472+Port Int 1
473+Port #port 8080
474+}
475+(with {
476+Port #port 8080
477+}
478+(|
479+ base.println port
480+ yield {}))"#,
481+ );
482+ let (out, err, code) = run_with_args(&dir, "repeat_agree", &[]);
483+ assert_eq!(code, 0, "stderr: {err}");
484+ assert_eq!(out.trim_end(), "8080\n{}");
485+}
486+
487+#[test]
488+fn repeated_arg_different_defaults_rejected() {
489+ let dir = TempDir::fresh("repeat_diff");
490+ build_err(
491+ &dir,
492+ "repeat_diff",
493+ r#"with {
494+Port Int 1
495+Port #port 8080
496+}
497+(with {
498+Port #port 9090
499+}
500+(|
501+ base.println port
502+ yield {}))"#,
503+ "different defaults",
504+ );
505+}
506+
507+#[test]
508+fn repeated_arg_different_types_rejected() {
509+ let dir = TempDir::fresh("repeat_types");
510+ build_err(
511+ &dir,
512+ "repeat_types",
513+ r#"with {
514+Port Int 1
515+Port #port 8080
516+}
517+(with {
518+Text #port "hello"
519+}
520+(|
521+ base.println (base.show port)
522+ yield {}))"#,
523+ "different types",
524+ );
525+}
526+
527+#[test]
528+fn repeated_arg_computed_default_rejected() {
529+ let dir = TempDir::fresh("repeat_computed");
530+ build_err(
531+ &dir,
532+ "repeat_computed",
533+ r#"with {
534+Port Int 1
535+Port #port 8080
536+}
537+(with {
538+Port #port (4 * 2020)
539+}
540+(|
541+ base.println port
542+ yield {}))"#,
543+ "must be a literal",
544+ );
545+}
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();