on master help sign in
Record/variant fields: source order via IndexMap; ^name resolves bottom-to-top

Ty::Record and Ty::Variant stored fields in a BTreeMap, discarding source definition order — implicit-field ^name resolution fell back to alphabetical order within a nesting level. Switch the field map to an order-preserving IndexMap<String, Ty> (alias FieldMap): record types now retain declaration order, and resolve_implicit_ref iterates candidates in reverse so the bottom-defined field is tried first, as intended. Variant constructors use the same map. Record-type equality and unification are unaffected — unify_records compares by key lookup (now O(1)), not position, so insertion order carries no semantics. Unit-of-measure maps stay BTreeMap (they rely on sorted base-unit keys). Behavior change is confined to the order candidates are enumerated in ^name resolution (visible in ambiguity / no-match error lists) and any consumer iterating a record type's fields; resolution outcome is unchanged (exactly one type-matching candidate wins, >1 is an ambiguity error either way). Verified: ambiguity error lists candidates bottom-to-top (reverse declaration) instead of alphabetically; a clean single-match resolution still works; 75/75 tests + 34/34 examples green.

commitf4077c56222f2a8e8bd95b1ecc62d618023aaabf authoriko <mail@iko.soy> dateMon 3 Aug 2026, 15:03 (2 months ago) parent0c0d710
3 files changed+76−58
Cargo.lock+1 crates/knot-compiler/Cargo.toml+4 crates/knot-compiler/src/infer.rs+71−58
Cargo.lock+1
@@ -1459,6 +1459,7 @@ dependencies = [
14591459 "cranelift-native",
14601460 "cranelift-object",
14611461 "gimli",
1462+ "indexmap",
14621463 "knot",
14631464 "knot-runtime",
14641465 "object",
crates/knot-compiler/Cargo.toml+4
@@ -31,6 +31,10 @@ z3 = { version = "0.20", default-features = false, features = ["gh-release"] }
3131# Content-hash of the embedded runtime archive, used at link time to reject a
3232# stale on-disk libknot_runtime.a (replaces the old mtime heuristic).
3333blake3 = { version = "1", default-features = false }
34+# Order-preserving map for record/variant field storage: source definition order
35+# is retained so implicit-field (^name) resolution can try fields in
36+# bottom-to-top declaration order instead of BTreeMap's alphabetical order.
37+indexmap = { version = "2", default-features = false }
3438
3539[build-dependencies]
3640blake3 = { version = "1", default-features = false }
crates/knot-compiler/src/infer.rs+71−58
@@ -8,6 +8,7 @@ use knot::ast;
88use knot::ast::Span;
99use knot::diagnostic::Diagnostic;
1010use std::collections::{BTreeMap, HashMap, HashSet};
11+use indexmap::IndexMap;
1112
1213/// Collect all variable names bound by a pattern, recursing into
1314/// Constructor, Record, List, and Cons sub-patterns.
@@ -435,6 +436,12 @@ type TyVar = u32;
435436/// Internal type representation for unification-based inference.
436437// `TyCon` deliberately shares the `Ty` prefix — it is standard PL terminology
437438// ("type constructor") and renaming would ripple across the whole crate.
439+/// Field/constructor map for records and variants. `IndexMap` preserves source
440+/// definition order (records) / declaration order (variants) so consumers that
441+/// care about order — implicit-field `^name` resolution, `show` output, JSON
442+/// encoding — see a stable, source-faithful sequence instead of BTreeMap's
443+/// alphabetical sort. By-key access (`get`/`contains_key`/`insert`) is unchanged.
444+type FieldMap = IndexMap<String, Ty>;
438445#[allow(clippy::enum_variant_names)]
439446#[derive(Debug, Clone, PartialEq)]
440447enum Ty {
@@ -450,14 +457,17 @@ enum Ty {
450457 /// Function type.
451458 Fun(Box<Ty>, Box<Ty>),
452459 /// Record with named fields and optional row variable (open record).
453− Record(BTreeMap<String, Ty>, Option<TyVar>),
460+ /// Fields keep source definition order (IndexMap) so implicit-field `^name`
461+ /// resolution can try them bottom-to-top; record-type equality is by-key,
462+ /// so order is not semantic.
463+ Record(FieldMap, Option<TyVar>),
454464 /// Relation (set) type: [T].
455465 Relation(Box<Ty>),
456466 /// Named algebraic data type with optional type arguments.
457467 Con(String, Vec<Ty>),
458468 /// Variant with named constructors and optional row variable (open variant).
459469 /// Each constructor maps to its field types as a Record.
460− Variant(BTreeMap<String, Ty>, Option<TyVar>),
470+ Variant(FieldMap, Option<TyVar>),
461471 /// Unapplied type constructor (e.g. `[]`, `Maybe`).
462472 /// Used for higher-kinded type polymorphism.
463473 TyCon(String),
@@ -497,7 +507,7 @@ enum Ty {
497507
498508impl Ty {
499509 fn unit() -> Ty {
500− Ty::Record(BTreeMap::new(), None)
510+ Ty::Record(IndexMap::new(), None)
501511 }
502512
503513 /// Strip outer `Ty::Alias` wrappers to expose the underlying type.
@@ -1755,7 +1765,7 @@ impl Infer {
17551765 Ty::Fun(Box::new(self.apply_impl(p, excluded)), Box::new(self.apply_impl(r, excluded)))
17561766 }
17571767 Ty::Record(fields, row) => {
1758− let mut applied: BTreeMap<String, Ty> = fields
1768+ let mut applied: FieldMap = fields
17591769 .iter()
17601770 .map(|(k, v)| (k.clone(), self.apply_impl(v, excluded)))
17611771 .collect();
@@ -1776,7 +1786,7 @@ impl Infer {
17761786 }
17771787 }
17781788 Ty::Variant(ctors, row) => {
1779− let mut applied: BTreeMap<String, Ty> = ctors
1789+ let mut applied: FieldMap = ctors
17801790 .iter()
17811791 .map(|(k, v)| (k.clone(), self.apply_impl(v, excluded)))
17821792 .collect();
@@ -2731,10 +2741,10 @@ impl Infer {
27312741 /// unlinked.
27322742 fn flatten_record_row(
27332743 &mut self,
2734− fields: &BTreeMap<String, Ty>,
2744+ fields: &FieldMap,
27352745 row: Option<TyVar>,
27362746 span: Span,
2737− ) -> (BTreeMap<String, Ty>, Option<TyVar>) {
2747+ ) -> (FieldMap, Option<TyVar>) {
27382748 let mut all = fields.clone();
27392749 let mut tail = row;
27402750 while let Some(rv) = tail {
@@ -2763,14 +2773,14 @@ impl Infer {
27632773
27642774 fn unify_records(
27652775 &mut self,
2766− f1: &BTreeMap<String, Ty>,
2776+ f1: &FieldMap,
27672777 r1: Option<TyVar>,
2768− f2: &BTreeMap<String, Ty>,
2778+ f2: &FieldMap,
27692779 r2: Option<TyVar>,
27702780 span: Span,
27712781 t1_provided: bool,
27722782 ) {
2773− // Unify common fields (BTreeMap lookup is O(log n), no HashSet needed)
2783+ // Unify common fields (IndexMap lookup is O(1), no HashSet needed)
27742784 for (key, ty1) in f1 {
27752785 if let Some(ty2) = f2.get(key) {
27762786 self.unify_dir(ty1, ty2, span, t1_provided);
@@ -2800,12 +2810,12 @@ impl Infer {
28002810 self.unify_dir(&v1, &v2, span, t1_provided);
28012811 }
28022812
2803− let only1: BTreeMap<String, Ty> = all1
2813+ let only1: FieldMap = all1
28042814 .iter()
28052815 .filter(|(k, _)| !all2.contains_key(*k))
28062816 .map(|(k, v)| (k.clone(), v.clone()))
28072817 .collect();
2808− let only2: BTreeMap<String, Ty> = all2
2818+ let only2: FieldMap = all2
28092819 .iter()
28102820 .filter(|(k, _)| !all1.contains_key(*k))
28112821 .map(|(k, v)| (k.clone(), v.clone()))
@@ -2896,9 +2906,9 @@ impl Infer {
28962906
28972907 fn unify_variants(
28982908 &mut self,
2899− c1: &BTreeMap<String, Ty>,
2909+ c1: &FieldMap,
29002910 r1: Option<TyVar>,
2901− c2: &BTreeMap<String, Ty>,
2911+ c2: &FieldMap,
29022912 r2: Option<TyVar>,
29032913 span: Span,
29042914 t1_provided: bool,
@@ -2910,12 +2920,12 @@ impl Infer {
29102920 }
29112921 }
29122922
2913− let mut only1: BTreeMap<String, Ty> = c1
2923+ let mut only1: FieldMap = c1
29142924 .iter()
29152925 .filter(|(k, _)| !c2.contains_key(*k))
29162926 .map(|(k, v)| (k.clone(), v.clone()))
29172927 .collect();
2918− let mut only2: BTreeMap<String, Ty> = c2
2928+ let mut only2: FieldMap = c2
29192929 .iter()
29202930 .filter(|(k, _)| !c1.contains_key(*k))
29212931 .map(|(k, v)| (k.clone(), v.clone()))
@@ -3063,9 +3073,9 @@ impl Infer {
30633073 (var, arg_ty.clone())
30643074 })
30653075 .collect();
3066− let mut ctors = BTreeMap::new();
3076+ let mut ctors = IndexMap::new();
30673077 for (ctor_name, fields) in &info.ctors {
3068− let field_tys: BTreeMap<String, Ty> = fields
3078+ let field_tys: FieldMap = fields
30693079 .iter()
30703080 .map(|(fname, fty)| {
30713081 let ty = self.ast_type_to_ty(fty);
@@ -3274,7 +3284,7 @@ impl Infer {
32743284 Box::new(self.subst_ty(r, mapping)),
32753285 ),
32763286 Ty::Record(fields, row) => {
3277− let mut new_fields: BTreeMap<_, _> = fields
3287+ let mut new_fields: FieldMap = fields
32783288 .iter()
32793289 .map(|(k, v)| (k.clone(), self.subst_ty(v, mapping)))
32803290 .collect();
@@ -3298,7 +3308,7 @@ impl Infer {
32983308 Ty::Record(new_fields, new_row)
32993309 }
33003310 Ty::Variant(ctors, row) => {
3301− let mut new_ctors: BTreeMap<_, _> = ctors
3311+ let mut new_ctors: FieldMap = ctors
33023312 .iter()
33033313 .map(|(k, v)| (k.clone(), self.subst_ty(v, mapping)))
33043314 .collect();
@@ -4155,7 +4165,7 @@ impl Infer {
41554165 if fields.is_empty() && rest.is_none() {
41564166 return Ty::unit();
41574167 }
4158− let field_tys: BTreeMap<String, Ty> = fields
4168+ let field_tys: FieldMap = fields
41594169 .iter()
41604170 .map(|f| (f.name.clone(), self.ast_type_to_ty(&f.value)))
41614171 .collect();
@@ -4196,10 +4206,10 @@ impl Infer {
41964206 constructors,
41974207 rest,
41984208 } => {
4199− let ctor_tys: BTreeMap<String, Ty> = constructors
4209+ let ctor_tys: FieldMap = constructors
42004210 .iter()
42014211 .map(|c| {
4202− let field_tys: BTreeMap<String, Ty> = c
4212+ let field_tys: FieldMap = c
42034213 .fields
42044214 .iter()
42054215 .map(|f| {
@@ -4576,7 +4586,7 @@ impl Infer {
45764586 })
45774587 .collect();
45784588
4579− let field_tys: BTreeMap<String, Ty> = info
4589+ let field_tys: FieldMap = info
45804590 .fields
45814591 .iter()
45824592 .map(|(name, ty)| (name.clone(), self.ast_type_to_ty(ty)))
@@ -4625,7 +4635,7 @@ impl Infer {
46254635 })
46264636 .collect();
46274637
4628− let field_tys: BTreeMap<String, Ty> = fields
4638+ let field_tys: FieldMap = fields
46294639 .iter()
46304640 .map(|(name, ty)| (name.clone(), self.ast_type_to_ty(ty)))
46314641 .collect();
@@ -4981,10 +4991,11 @@ impl Infer {
49814991 /// and function bindings are invisible) for a field named `name` whose
49824992 /// type unifies with `expected`. Search order: nearest scope first,
49834993 /// then shallowest record-nesting depth (a binding's own fields beat
4984− /// fields of nested records), then fields in sorted order (record types
4985− /// store fields in a `BTreeMap`, so source declaration order is
4986− /// unavailable). Each candidate is tested with a speculative unify
4987− /// against a throwaway clone of the real substitution; only the winning
4994+ /// fields of nested records), then fields in source definition order
4995+ /// bottom-to-top (record types store fields in an `IndexMap` preserving
4996+ /// declaration order, iterated in reverse). Each candidate is tested
4997+ /// with a speculative unify against a throwaway clone of the real
4998+ /// substitution; only the winning
49884999 /// candidate's constraints are committed to `self`. The resolved
49895000 /// (root binding, field path) is recorded in `implicit_refs` keyed by
49905001 /// `span` so codegen can lower `^name` to a projection chain.
@@ -5027,7 +5038,8 @@ impl Infer {
50275038 // Candidate search over an immutable view of the scopes. Walk
50285039 // innermost-to-outermost (nearest scope wins across the whole
50295040 // search) and BFS the record's fields shallowest-first; `fields` is
5030− // a `BTreeMap`, so within a level iteration is by sorted field name.
5041+ // an `IndexMap` in source definition order, iterated in REVERSE so
5042+ // within a level the bottom-defined field is tried first.
50315043 let mut candidates: Vec<(String, Vec<String>, Ty)> =
50325044 with_candidate.into_iter().collect();
50335045 'scopes: for scope in self.scopes.iter().rev() {
@@ -5049,6 +5061,7 @@ impl Infer {
50495061 let frontier: Vec<(Vec<String>, Ty)> = match root_ty.peel_alias() {
50505062 Ty::Record(fields, _) => fields
50515063 .iter()
5064+ .rev() // bottom-to-top: last-defined field tried first
50525065 .map(|(f, t)| (vec![f.clone()], t.clone()))
50535066 .collect(),
50545067 _ => Vec::new(),
@@ -5069,10 +5082,10 @@ impl Infer {
50695082 // Descend into nested record fields (without
50705083 // committing anything: `apply` is read-only).
50715084 if let Ty::Record(sub, _) = self.apply(&field_ty).peel_alias().clone() {
5072− for (f, t) in sub {
5085+ for (f, t) in sub.iter().rev() {
50735086 let mut p = path.clone();
5074− p.push(f);
5075− next.push((p, t));
5087+ p.push(f.clone());
5088+ next.push((p, t.clone()));
50765089 }
50775090 }
50785091 }
@@ -5470,7 +5483,7 @@ impl Infer {
54705483 );
54715484 }
54725485 }
5473− let field_tys: BTreeMap<String, Ty> = fields
5486+ let field_tys: FieldMap = fields
54745487 .iter()
54755488 .map(|f| {
54765489 // A signature-only field (`name : Type`, no `=`) is a
@@ -5650,7 +5663,7 @@ impl Infer {
56505663 let field_ty = self.fresh();
56515664 let rv = self.fresh_var();
56525665 let constraint = Ty::Record(
5653− BTreeMap::from([(field.clone(), field_ty.clone())]),
5666+ IndexMap::from([(field.clone(), field_ty.clone())]),
56545667 Some(rv),
56555668 );
56565669 self.unify(&base_ty, &constraint, e.span);
@@ -6644,9 +6657,9 @@ impl Infer {
66446657 // keep the `{} -> data_ty` form because the applied syntax is
66456658 // always `rec.Name.Ctor {}` (a record application), matching
66466659 // how `Ctor {}` is typed through the App arm.
6647− let mut ns_fields = BTreeMap::new();
6660+ let mut ns_fields = IndexMap::new();
66486661 for ctor in constructors {
6649− let field_tys: BTreeMap<String, Ty> = ctor
6662+ let field_tys: FieldMap = ctor
66506663 .fields
66516664 .iter()
66526665 .map(|f| (f.name.clone(), self.ast_type_to_ty(&f.value)))
@@ -6687,7 +6700,7 @@ impl Infer {
66876700 // `Ty::Con("rec.Api")` produced by the hoisted `DeclKind::Route`
66886701 // (desugar `hoist_record_routes`); the record value itself is
66896702 // erased to unit at runtime.
6690− let mut ns_fields = BTreeMap::new();
6703+ let mut ns_fields = IndexMap::new();
66916704 for entry in entries {
66926705 let input_ty = self.route_input_record_ty(entry);
66936706 ns_fields.insert(entry.constructor.clone(), Ty::Fun(Box::new(input_ty), Box::new(Ty::Con(name.clone(), vec![]))));
@@ -6906,7 +6919,7 @@ impl Infer {
69066919 let resolved = self.apply(expected);
69076920 if let Ty::Record(expected_fields, None) = resolved.peel_alias() {
69086921 let expected_fields = expected_fields.clone();
6909− let mut field_tys = BTreeMap::new();
6922+ let mut field_tys = IndexMap::new();
69106923 for f in fields {
69116924 // Required CLI constant (sig-only field, empty-record
69126925 // placeholder value): take the sig type, skip the value.
@@ -7111,7 +7124,7 @@ impl Infer {
71117124 /// the handler receives (path params + query params + body fields +
71127125 /// request headers) and the rate-limit `key` function's first argument.
71137126 fn route_input_record_ty(&mut self, entry: &ast::RouteEntry) -> Ty {
7114− let mut input_fields: BTreeMap<String, Ty> = BTreeMap::new();
7127+ let mut input_fields: FieldMap = IndexMap::new();
71157128 for seg in &entry.path {
71167129 if let ast::PathSegment::Param { name, ty } = seg {
71177130 input_fields.insert(name.clone(), self.ast_type_to_ty(ty));
@@ -7150,7 +7163,7 @@ impl Infer {
71507163 .map(|f| (f.name.clone(), self.ast_type_to_ty(&f.value)))
71517164 .collect();
71527165 Ty::Record(
7153− BTreeMap::from([
7166+ IndexMap::from([
71547167 ("body".into(), resp),
71557168 ("headers".into(), Ty::Record(hdrs, None)),
71567169 ]),
@@ -7287,7 +7300,7 @@ impl Infer {
72877300 /// error is a genuinely non-record argument.
72887301 fn record_fields(
72897302 ty: &Ty,
7290− ) -> Result<(std::collections::BTreeMap<String, Ty>, Option<TyVar>), String> {
7303+ ) -> Result<(FieldMap, Option<TyVar>), String> {
72917304 match ty.peel_alias() {
72927305 Ty::Record(fields, tail) => Ok((fields.clone(), *tail)),
72937306 other => Err(format!(
@@ -7357,14 +7370,14 @@ impl Infer {
73577370 if is_fetch_with {
73587371 let opts_ty = self.infer_expr(args[1]);
73597372 let header_row = Ty::Record(
7360− BTreeMap::from([
7373+ IndexMap::from([
73617374 ("name".into(), Ty::Text),
73627375 ("value".into(), Ty::Text),
73637376 ]),
73647377 None,
73657378 );
73667379 let expected_opts = Ty::Record(
7367− BTreeMap::from([(
7380+ IndexMap::from([(
73687381 "headers".into(),
73697382 Ty::Relation(Box::new(header_row)),
73707383 )]),
@@ -7398,7 +7411,7 @@ impl Infer {
73987411 self.annotation_vars.insert(p.clone(), v);
73997412 }
74007413 if let Some(record_ty) = &record_ty {
7401− let field_tys: BTreeMap<String, Ty> = info
7414+ let field_tys: FieldMap = info
74027415 .fields
74037416 .iter()
74047417 .map(|(name, ty)| (name.clone(), self.ast_type_to_ty(ty)))
@@ -7428,7 +7441,7 @@ impl Infer {
74287441 None,
74297442 );
74307443 Ty::Record(
7431− BTreeMap::from([
7444+ IndexMap::from([
74327445 ("body".into(), raw_body_ty),
74337446 ("headers".into(), headers_ty),
74347447 ]),
@@ -7438,7 +7451,7 @@ impl Infer {
74387451 _ => raw_body_ty,
74397452 };
74407453 let err_ty = Ty::Record(
7441− BTreeMap::from([
7454+ IndexMap::from([
74427455 ("message".into(), Ty::Text),
74437456 ("status".into(), Ty::Int),
74447457 ]),
@@ -7968,7 +7981,7 @@ impl Infer {
79687981 }
79697982 }
79707983 ast::PatKind::Record(field_pats) => {
7971− let mut field_types = BTreeMap::new();
7984+ let mut field_types = IndexMap::new();
79727985 for fp in field_pats {
79737986 if field_types.contains_key(&fp.name) {
79747987 self.error(
@@ -8227,7 +8240,7 @@ impl Infer {
82278240 let rv = *rv;
82288241 self.bind_var(
82298242 rv,
8230− Ty::Variant(BTreeMap::new(), None),
8243+ Ty::Variant(IndexMap::new(), None),
82318244 span,
82328245 );
82338246 return;
@@ -8910,7 +8923,7 @@ impl Infer {
89108923 let v = self.fresh_var();
89118924 self.annotation_vars.insert(p.clone(), v);
89128925 }
8913− let field_tys: BTreeMap<String, Ty> = ctors[0]
8926+ let field_tys: FieldMap = ctors[0]
89148927 .fields
89158928 .iter()
89168929 .map(|f| {
@@ -9400,10 +9413,10 @@ impl Infer {
94009413 self.aliases.insert(
94019414 "RefinementError".into(),
94029415 Ty::Record(
9403− BTreeMap::from([
9416+ IndexMap::from([
94049417 ("typeName".into(), Ty::Text),
94059418 ("violations".into(), Ty::Relation(Box::new(Ty::Record(
9406− BTreeMap::from([
9419+ IndexMap::from([
94079420 ("field".into(), Ty::Con("Maybe".into(), vec![Ty::Text])),
94089421 ("message".into(), Ty::Text),
94099422 ]),
@@ -9421,7 +9434,7 @@ impl Infer {
94219434 self.aliases.insert(
94229435 "HttpError".into(),
94239436 Ty::Record(
9424− BTreeMap::from([
9437+ IndexMap::from([
94259438 ("status".into(), Ty::Int),
94269439 ("message".into(), Ty::Text),
94279440 ]),
@@ -9434,7 +9447,7 @@ impl Infer {
94349447 self.aliases.insert(
94359448 "RequestCtx".into(),
94369449 Ty::Record(
9437− BTreeMap::from([
9450+ IndexMap::from([
94389451 ("clientIp".into(), Ty::Text),
94399452 ("receivedAt".into(), Ty::int_with_unit(UnitTy::named("Ms"))),
94409453 (
@@ -9805,7 +9818,7 @@ impl Infer {
98059818 let a = self.fresh_var();
98069819 let b = self.fresh_var();
98079820 let err_ty = Ty::Record(
9808− BTreeMap::from([
9821+ IndexMap::from([
98099822 ("message".into(), Ty::Text),
98109823 ("status".into(), Ty::Int),
98119824 ]),
@@ -10977,7 +10990,7 @@ impl Infer {
1097710990
1097810991 // generateKeyPair : IO {random} {privateKey: Bytes, publicKey: Bytes}
1097910992 let key_pair_record = Ty::Record(
10980− BTreeMap::from([
10993+ IndexMap::from([
1098110994 ("privateKey".into(), Ty::Bytes),
1098210995 ("publicKey".into(), Ty::Bytes),
1098310996 ]),
@@ -11474,14 +11487,14 @@ impl Infer {
1147411487 )),
1147511488 );
1147611489 let limit_ty = Ty::Record(
11477− BTreeMap::from([
11490+ IndexMap::from([
1147811491 ("requests".into(), Ty::Int),
1147911492 ("window".into(), Ty::int_with_unit(UnitTy::named("Ms"))),
1148011493 ]),
1148111494 None,
1148211495 );
1148311496 let expected = Ty::Record(
11484− BTreeMap::from([
11497+ IndexMap::from([
1148511498 ("key".into(), key_ty),
1148611499 ("limit".into(), limit_ty),
1148711500 ]),