on master help sign in
Parse Int/Float units as ordinary type application, normalized in inference

Units were special-cased in the parser: `parse_type_atom_inner` forked on `Int`/`Float` into a separate unit grammar (`parse_unit_expr`/`UnitExpr`) and produced a dedicated `Type::UnitAnnotated { base, unit }` node. The unit was never an ordinary type argument — source was non-uniform, and the unit grammar (`*`/`/`/`^`/`1`/`_`) lived only in that fork. Make the unit an ordinary type argument, with the unit semantics recovered by a single normalization pass (option 3): - Parser: drop the `Int`/`Float` → `UnitAnnotated` fork. `Int M`, `Int 1`, `Int (M / S)` now parse as ordinary `App(Int, <unit-shaped>)`. The head `Int`/`Float` atom consumes an optional unit-shaped argument so the unit is picked up even in operand position (`IO Int 1`). The unit algebra is parsed by `parse_unit_shaped_arg`/`parse_unit_algebra`, producing `Type::Unit` atoms combined by two new nodes `Type::UnitMul`/`UnitDiv`. The dead `parse_unit_expr`/`parse_unit_power`/`parse_unit_type_arg` are removed; the unparenthesized-unit-argument check now matches `App(Int/Float, …)`. - AST: `Type::UnitAnnotated` is gone; `Type::Unit(UnitExpr)` stays for atoms, joined by `UnitMul`/`UnitDiv` for the algebra. The whole unit argument is carried in the ordinary `Type` tree. - Inference: one normalization step in `ast_type_to_ty`'s `App` arm — `App(Int/Float, <unit-shaped>)` becomes `Con("Int"/"Float", [Unit(u)])` via the new `ast_type_to_unit_ty`, which interprets `Unit`/`UnitMul`/`UnitDiv` as unit algebra. This is the only unit-aware front-end code left. The type-witness path (`asT (Maybe Int) …`) interprets a value-position spine as a type, so it suppresses the unit-annotation requirement there. - Consumers (codegen descriptors, types.rs walkers, unused, format, and the LSP type walkers) updated: unit-bearing numerics are recognised as `App(Int/Float, …)` and erased/recursed through the ordinary `App` shape; the `UnitMul`/`UnitDiv` leaves recurse. The LSP's independent `ParsedType` text parser is untouched. Unit arithmetic (M/M → dimensionless, scalar multiply, unit polymorphism) is preserved. Full workspace green, including the 14 units tests and the base-listing reparse test (`Int 1 -> IO Int 1`, `IO Float 1`).

commit7f548751b0f9620f1933e6127d67695b05ef6684 authoriko <ilyakooo0@gmail.com> dateFri 4 Sep 2026, 19:30 (1 month ago) parent2d1d7a0
14 files changed+350−182
crates/knot-compiler/src/codegen.rs+7−2 crates/knot-compiler/src/infer.rs+116−52 crates/knot-compiler/src/types.rs+43−20 crates/knot-compiler/src/unused.rs+4−1 crates/knot-lsp/src/defs.rs+4−1 crates/knot-lsp/src/incremental.rs+4−1 crates/knot-lsp/src/linked_editing.rs+4−2 crates/knot-lsp/src/rename.rs+5−1 crates/knot-lsp/src/semantic_tokens.rs+3−2 crates/knot-lsp/src/shared.rs+7−2 crates/knot-lsp/src/type_format.rs+8−10 crates/knot/src/ast.rs+15−10 crates/knot/src/format.rs+36−12 crates/knot/src/parser.rs+94−66
crates/knot-compiler/src/codegen.rs+7−2
@@ -21799,6 +21799,10 @@ fn ast_type_to_descriptor_type(
2179921799 ast::TypeKind::App { func, arg } => {
2180021800 if matches!(&func.node, ast::TypeKind::Named(n) if n == "Maybe") {
2180121801 format!("?{}", ast_type_to_descriptor_type(arg, aliases))
21802+ } else if matches!(&func.node, ast::TypeKind::Named(n) if n == "Int" || n == "Float") {
21803+ // A unit-bearing numeric (`Int M`): units are phantom for the
21804+ // wire, so the descriptor carries just the base numeric type.
21805+ ast_type_to_descriptor_type(func, aliases)
2180221806 } else {
2180321807 "text".to_string()
2180421808 }
@@ -21822,7 +21826,6 @@ fn ast_type_to_descriptor_type(
2182221826 ast::TypeKind::Relation(inner) => {
2182321827 format!("[{}]", ast_type_to_descriptor_type(inner, aliases))
2182421828 }
21825− ast::TypeKind::UnitAnnotated { base, .. } => ast_type_to_descriptor_type(base, aliases),
2182621829 // A refined field (`amount: Int 1 where \x -> x > 0`) is transparent for
2182721830 // the wire: it must carry the base type's descriptor, not the `text`
2182821831 // fallback — otherwise valid JSON numbers/objects are (de)serialized as
@@ -21892,11 +21895,13 @@ fn resolve_type_for_descriptor(
2189221895 vec![("value".into(), resolve_type_for_descriptor(arg, aliases))],
2189321896 ),
2189421897 ])
21898+ } else if matches!(&func.node, ast::TypeKind::Named(n) if n == "Int" || n == "Float") {
21899+ // A unit-bearing numeric (`Int M`): erase the unit for the wire.
21900+ resolve_type_for_descriptor(func, aliases)
2189521901 } else {
2189621902 ResolvedType::Text
2189721903 }
2189821904 }
21899− ast::TypeKind::UnitAnnotated { base, .. } => resolve_type_for_descriptor(base, aliases),
2190021905 // Refined types are transparent for the wire descriptor; recurse into
2190121906 // the base so the response side agrees with the request side.
2190221907 ast::TypeKind::Refined { base, .. } => resolve_type_for_descriptor(base, aliases),
crates/knot-compiler/src/infer.rs+116−52
@@ -91,26 +91,32 @@ fn is_unit_arg(expr: &ast::Expr) -> bool {
9191}
9292
9393/// Map a value-spine unit argument to its `UnitExpr` AST.
94−fn unit_arg_to_ast(expr: &ast::Expr) -> ast::UnitExpr {
95− match &expr.node {
96− ast::ExprKind::Lit(ast::Literal::Int(n)) if n == "1" => ast::UnitExpr::Dimensionless,
97− ast::ExprKind::Constructor(name) => ast::UnitExpr::Named(name.clone()),
94+fn unit_arg_to_ast(expr: &ast::Expr) -> ast::Type {
95+ let span = expr.span;
96+ let node = match &expr.node {
97+ ast::ExprKind::Lit(ast::Literal::Int(n)) if n == "1" => {
98+ ast::TypeKind::Unit(ast::UnitExpr::Dimensionless)
99+ }
100+ ast::ExprKind::Constructor(name) => {
101+ ast::TypeKind::Unit(ast::UnitExpr::Named(name.clone()))
102+ }
98103 ast::ExprKind::Var(ast::Binding::User(name)) => {
99− ast::UnitExpr::Named(name.clone())
104+ ast::TypeKind::Unit(ast::UnitExpr::Named(name.clone()))
100105 }
101− ast::ExprKind::TypeHole => ast::UnitExpr::Hole,
106+ ast::ExprKind::TypeHole => ast::TypeKind::Unit(ast::UnitExpr::Hole),
102107 // Compound unit in value-spine form: `(M / S)` arrives as a `BinOp`.
103108 ast::ExprKind::BinOp { op, lhs, rhs } => {
104109 let l = unit_arg_to_ast(lhs);
105110 let r = unit_arg_to_ast(rhs);
106111 match op {
107− ast::BinOp::Mul => ast::UnitExpr::Mul(Box::new(l), Box::new(r)),
108− ast::BinOp::Div => ast::UnitExpr::Div(Box::new(l), Box::new(r)),
109− _ => ast::UnitExpr::Dimensionless,
112+ ast::BinOp::Mul => ast::TypeKind::UnitMul(Box::new(l), Box::new(r)),
113+ ast::BinOp::Div => ast::TypeKind::UnitDiv(Box::new(l), Box::new(r)),
114+ _ => ast::TypeKind::Unit(ast::UnitExpr::Dimensionless),
110115 }
111116 }
112− _ => ast::UnitExpr::Dimensionless,
113− }
117+ _ => ast::TypeKind::Unit(ast::UnitExpr::Dimensionless),
118+ };
119+ ast::Spanned::new(node, span)
114120}
115121
116122// ── Monad info (shared with codegen) ──────────────────────────────
@@ -2094,6 +2100,29 @@ impl Infer {
20942100 }
20952101 }
20962102
2103+ /// Interpret a unit-shaped type argument of `Int`/`Float` as a unit.
2104+ /// Returns `Some` only when the argument is built entirely from unit atoms
2105+ /// (`Unit`) and unit algebra (`UnitMul`/`UnitDiv`); any other type (`Record`,
2106+ /// `Function`, a real type `App`, …) yields `None` so the caller falls back
2107+ /// to ordinary type application. This is the single place that knows the
2108+ /// argument of `Int`/`Float` is a unit dimension.
2109+ fn ast_type_to_unit_ty(&mut self, ty: &ast::Type) -> Option<UnitTy> {
2110+ match &ty.node {
2111+ ast::TypeKind::Unit(u) => Some(self.ast_unit_to_unit_ty(u)),
2112+ ast::TypeKind::UnitMul(a, b) => {
2113+ let a = self.ast_type_to_unit_ty(a)?;
2114+ let b = self.ast_type_to_unit_ty(b)?;
2115+ Some(a.mul(&b))
2116+ }
2117+ ast::TypeKind::UnitDiv(a, b) => {
2118+ let a = self.ast_type_to_unit_ty(a)?;
2119+ let b = self.ast_type_to_unit_ty(b)?;
2120+ Some(a.div(&b))
2121+ }
2122+ _ => None,
2123+ }
2124+ }
2125+
20972126 fn error(&mut self, msg: String, span: Span) {
20982127 // Dedup identical diagnostics: the alias fixpoint and multi-pass
20992128 // collection can re-derive the same error at the same span several
@@ -4608,22 +4637,25 @@ impl Infer {
46084637 // A numeric base as a type argument. Optionally followed by a
46094638 // unit argument (`1`, a unit atom like `Ms`, or a unit var) —
46104639 // `Float 1`, `Int Ms`. Bare (no unit) means dimensionless.
4611− let unit = match spine.get(consumed) {
4640+ // Built as ordinary application `App(Int, unit)`; the `App`
4641+ // normalization in `ast_type_to_ty` produces the unit-bearing
4642+ // `Con("Int", [Unit(..)])`.
4643+ match spine.get(consumed) {
46124644 Some(u_expr) if is_unit_arg(u_expr) => {
46134645 consumed += 1;
46144646 // The unit argument is erased with the type: record its
46154647 // span so codegen drops it from the value spine.
46164648 self.type_arg_spans.insert(u_expr.span);
4617− unit_arg_to_ast(u_expr)
4649+ let base = knot::ast::Spanned {
4650+ node: TypeKind::Named(name.clone()),
4651+ span: head.span,
4652+ };
4653+ TypeKind::App {
4654+ func: Box::new(base),
4655+ arg: Box::new(unit_arg_to_ast(u_expr)),
4656+ }
46184657 }
4619− _ => knot::ast::UnitExpr::Dimensionless,
4620− };
4621− TypeKind::UnitAnnotated {
4622− base: Box::new(knot::ast::Spanned {
4623− node: TypeKind::Named(name.clone()),
4624− span: head.span,
4625− }),
4626− unit,
4658+ _ => TypeKind::Named(name.clone()),
46274659 }
46284660 } else {
46294661 TypeKind::Named(name.clone())
@@ -4818,6 +4850,34 @@ impl Infer {
48184850 if let Some(t) = self.expand_param_alias(ty) {
48194851 return t;
48204852 }
4853+ // Unit normalization (the ONLY unit-aware front-end step): an
4854+ // `Int`/`Float` applied to a unit-shaped argument — `Int M`,
4855+ // `Float u`, `Int 1`, `Int (M / S)` — becomes the canonical
4856+ // unit-bearing type `Con("Int"/"Float", [Unit(u)])`. The
4857+ // argument is interpreted as unit algebra; see `ast_type_to_unit_ty`.
4858+ if let ast::TypeKind::Named(n) = &func.node
4859+ && (n == "Int" || n == "Float")
4860+ && let Some(unit_ty) = self.ast_type_to_unit_ty(arg)
4861+ {
4862+ let saved_flag = self.in_type_annotation;
4863+ let saved_enforce = self.enforce_units;
4864+ self.in_type_annotation = false;
4865+ self.enforce_units = false;
4866+ let base_ty = self.ast_type_to_ty(func);
4867+ self.in_type_annotation = saved_flag;
4868+ self.enforce_units = saved_enforce;
4869+ return match base_ty {
4870+ Ty::Int => Ty::int_with_unit(unit_ty),
4871+ Ty::Float => Ty::float_with_unit(unit_ty),
4872+ _ => {
4873+ self.error(
4874+ "unit annotations are only allowed on Int and Float types".into(),
4875+ ty.span,
4876+ );
4877+ Ty::Error
4878+ }
4879+ };
4880+ }
48214881 let arg_ty = self.ast_type_to_ty(arg);
48224882 let func_ty = self.ast_type_to_ty(func);
48234883 match func_ty {
@@ -4857,35 +4917,20 @@ impl Infer {
48574917 Ty::Variant(ctor_tys, row_var)
48584918 }
48594919 ast::TypeKind::IO { ty: inner_ty } => Ty::IO(Box::new(self.ast_type_to_ty(inner_ty))),
4860− ast::TypeKind::UnitAnnotated { base, unit } => {
4861− // Convert the base (`Int`/`Float`) without the bare-numeric
4862− // check — the unit is supplied right here.
4863− let saved_flag = self.in_type_annotation;
4864− let saved_enforce = self.enforce_units;
4865− self.in_type_annotation = false;
4866− self.enforce_units = false;
4867− let base_ty = self.ast_type_to_ty(base);
4868− self.in_type_annotation = saved_flag;
4869− self.enforce_units = saved_enforce;
4870− let unit_ty = self.ast_unit_to_unit_ty(unit);
4871− match base_ty {
4872− Ty::Int => Ty::int_with_unit(unit_ty),
4873− Ty::Float => Ty::float_with_unit(unit_ty),
4874− _ => {
4875− self.error(
4876− "unit annotations are only allowed on Int and Float types".into(),
4877− ty.span,
4878− );
4879− Ty::Error
4880− }
4881− }
4882− }
48834920 ast::TypeKind::Unit(_unit) => {
4884− // A standalone type-level unit expression. Only meaningful as
4885− // the argument of `Con("Int"/"Float", [Unit(u)])`, which is
4886− // built via `UnitAnnotated`. Reaching here means the unit
4887− // appeared bare in a type position — treat it as an error
4888− // since a unit is not a value-inhabited type.
4921+ // A standalone type-level unit atom. Only meaningful as the
4922+ // argument of `Int`/`Float` (handled by the `App` unit
4923+ // normalization). Reaching here means the unit appeared bare in
4924+ // a type position — an error, since a unit is not a
4925+ // value-inhabited type.
4926+ self.error(
4927+ "a unit expression cannot appear as a standalone type — it must be the argument of Int or Float".into(),
4928+ ty.span,
4929+ );
4930+ Ty::Error
4931+ }
4932+ ast::TypeKind::UnitMul(..) | ast::TypeKind::UnitDiv(..) => {
4933+ // Unit algebra outside an `Int`/`Float` argument.
48894934 self.error(
48904935 "a unit expression cannot appear as a standalone type — it must be the argument of Int or Float".into(),
48914936 ty.span,
@@ -7441,7 +7486,17 @@ impl Infer {
74417486 break;
74427487 };
74437488 let Some(ty_ast) = first_ty.take() else { break };
7489+ // A type WITNESS argument is written in
7490+ // value position (`asT (Maybe Int) …`), so
7491+ // the unit annotation requirement does not
7492+ // apply — suppress it while interpreting.
7493+ let saved_flag = self.in_type_annotation;
7494+ let saved_enforce = self.enforce_units;
7495+ self.in_type_annotation = false;
7496+ self.enforce_units = false;
74447497 let arg_ty = self.ast_type_to_ty(&ty_ast);
7498+ self.in_type_annotation = saved_flag;
7499+ self.enforce_units = saved_enforce;
74457500 self.type_arg_spans.insert(ty_ast.span);
74467501 let mut mapping: HashMap<TyVar, Ty> = HashMap::new();
74477502 mapping.insert(witness_var, arg_ty);
@@ -7502,7 +7557,15 @@ impl Infer {
75027557 flatten_type_tokens(arg, &mut flat);
75037558 if let Some((ty_ast, consumed)) = self.consume_type_arg(&flat) {
75047559 self.type_arg_spans.insert(ty_ast.span);
7560+ // Type-witness argument in value position: units not
7561+ // enforced (see the other witness site above).
7562+ let saved_flag = self.in_type_annotation;
7563+ let saved_enforce = self.enforce_units;
7564+ self.in_type_annotation = false;
7565+ self.enforce_units = false;
75057566 let ascribed = self.ast_type_to_ty(&ty_ast);
7567+ self.in_type_annotation = saved_flag;
7568+ self.enforce_units = saved_enforce;
75067569 // The witness type is the value's type: pin `rest`
75077570 // (`a -> a`) to `ascribed -> ascribed`. The result of
75087571 // consuming ONLY the type argument is the partially
@@ -15912,10 +15975,11 @@ fn collect_alias_refs(ty: &ast::Type, alias_names: &HashSet<String>, out: &mut H
1591215975 ast::TypeKind::IO { ty, .. } => {
1591315976 collect_alias_refs(ty, alias_names, out);
1591415977 }
15915− ast::TypeKind::UnitAnnotated { base, .. } => {
15916− collect_alias_refs(base, alias_names, out);
15917− }
1591815978 ast::TypeKind::Unit(_) => {}
15979+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
15980+ collect_alias_refs(a, alias_names, out);
15981+ collect_alias_refs(b, alias_names, out);
15982+ }
1591915983 ast::TypeKind::Refined { base, .. } => {
1592015984 collect_alias_refs(base, alias_names, out);
1592115985 }
crates/knot-compiler/src/types.rs+43−20
@@ -763,10 +763,14 @@ fn collect_named_alias_refs(ty: &Type, alias_names: &HashSet<String>, out: &mut
763763 TypeKind::IO { ty } => {
764764 collect_named_alias_refs(ty, alias_names, out);
765765 }
766− TypeKind::UnitAnnotated { base, .. } | TypeKind::Refined { base, .. } => {
766+ TypeKind::Refined { base, .. } => {
767767 collect_named_alias_refs(base, alias_names, out);
768768 }
769769 TypeKind::Unit(_) => {}
770+ TypeKind::UnitMul(a, b) | TypeKind::UnitDiv(a, b) => {
771+ collect_named_alias_refs(a, alias_names, out);
772+ collect_named_alias_refs(b, alias_names, out);
773+ }
770774 TypeKind::Forall { ty, .. } => {
771775 collect_named_alias_refs(ty, alias_names, out);
772776 }
@@ -874,10 +878,12 @@ impl<'a> ReservedFieldWalker<'a> {
874878 self.walk(func, seen);
875879 self.walk(arg, seen);
876880 }
877− TypeKind::UnitAnnotated { base, .. } | TypeKind::Refined { base, .. } => {
878− self.walk(base, seen)
879− }
881+ TypeKind::Refined { base, .. } => self.walk(base, seen),
880882 TypeKind::Unit(_) => {}
883+ TypeKind::UnitMul(a, b) | TypeKind::UnitDiv(a, b) => {
884+ self.walk(a, seen);
885+ self.walk(b, seen);
886+ }
881887 TypeKind::IO { ty } | TypeKind::Forall { ty, .. } => self.walk(ty, seen),
882888 // A function can never be stored in a column, so nothing below one
883889 // reaches a table.
@@ -1235,15 +1241,23 @@ fn value_predicates(
12351241 out.push((label, synth_lambda(&param, body)));
12361242 }
12371243 }
1238− TypeKind::UnitAnnotated { base, .. } => {
1244+ TypeKind::UnitMul(a, b) | TypeKind::UnitDiv(a, b) => {
12391245 out.extend(value_predicates(
1240− base,
1246+ a,
1247+ refined_types,
1248+ alias_ast_types,
1249+ data_ctor_decls,
1250+ seen_aliases,
1251+ ));
1252+ out.extend(value_predicates(
1253+ b,
12411254 refined_types,
12421255 alias_ast_types,
12431256 data_ctor_decls,
12441257 seen_aliases,
12451258 ));
12461259 }
1260+ TypeKind::Unit(_) => {}
12471261 // Type application such as `Maybe Nat` or `Result Text Pos`. The
12481262 // refinement lives on a type *argument*, but the runtime value is
12491263 // wrapped in one of the type's constructors (`Just {value: x}`,
@@ -1604,6 +1618,20 @@ fn resolve_type(
16041618 }
16051619 spine_args.reverse();
16061620
1621+ // A unit-bearing numeric (`Int M`): units are phantom for schema
1622+ // resolution, so resolve to the base numeric type.
1623+ if let TypeKind::Named(n) = &spine_head.node
1624+ && (n == "Int" || n == "Float")
1625+ {
1626+ return resolve_type(
1627+ spine_head,
1628+ aliases,
1629+ assoc_types,
1630+ single_variant_params,
1631+ multi_variant_params,
1632+ );
1633+ }
1634+
16071635 // Associated type application: match the whole spine against a
16081636 // definition of matching arity, binding ALL argument patterns into
16091637 // a SINGLE substitution. Matching only the first argument (the old
@@ -1809,17 +1837,8 @@ fn resolve_type(
18091837 multi_variant_params,
18101838 )
18111839 }
1812− TypeKind::UnitAnnotated { base, .. } => {
1813− // Units are phantom — erase for schema resolution
1814− resolve_type(
1815− base,
1816− aliases,
1817− assoc_types,
1818− single_variant_params,
1819− multi_variant_params,
1820− )
1821− }
18221840 TypeKind::Unit(_) => ResolvedType::Named("unknown".into()),
1841+ TypeKind::UnitMul(..) | TypeKind::UnitDiv(..) => ResolvedType::Named("unknown".into()),
18231842 TypeKind::Refined { base, .. } => {
18241843 // Refinements are phantom for schema — base type determines storage
18251844 resolve_type(
@@ -2217,11 +2236,15 @@ fn apply_type_subst(ty: &Type, subst: &HashMap<String, Type>) -> Type {
22172236 },
22182237 TypeKind::Hole => TypeKind::Hole,
22192238 TypeKind::Callsite => TypeKind::Callsite,
2220− TypeKind::UnitAnnotated { base, unit } => TypeKind::UnitAnnotated {
2221− base: Box::new(apply_type_subst(base, subst)),
2222− unit: unit.clone(),
2223− },
22242239 TypeKind::Unit(u) => TypeKind::Unit(u.clone()),
2240+ TypeKind::UnitMul(a, b) => TypeKind::UnitMul(
2241+ Box::new(apply_type_subst(a, subst)),
2242+ Box::new(apply_type_subst(b, subst)),
2243+ ),
2244+ TypeKind::UnitDiv(a, b) => TypeKind::UnitDiv(
2245+ Box::new(apply_type_subst(a, subst)),
2246+ Box::new(apply_type_subst(b, subst)),
2247+ ),
22252248 TypeKind::Refined { base, predicate } => TypeKind::Refined {
22262249 base: Box::new(apply_type_subst(base, subst)),
22272250 predicate: predicate.clone(),
crates/knot-compiler/src/unused.rs+4−1
@@ -387,8 +387,11 @@ fn walk_type(t: &Type, r: &mut Refs) {
387387 TypeKind::IO { ty } => {
388388 walk_type(ty, r);
389389 }
390− TypeKind::UnitAnnotated { base, .. } => walk_type(base, r),
391390 TypeKind::Unit(_) => {}
391+ TypeKind::UnitMul(a, b) | TypeKind::UnitDiv(a, b) => {
392+ walk_type(a, r);
393+ walk_type(b, r);
394+ }
392395 TypeKind::Refined { base, predicate } => {
393396 walk_type(base, r);
394397 walk_expr(predicate, r);
crates/knot-lsp/src/defs.rs+4−1
@@ -352,7 +352,10 @@ impl<'a> DefResolver<'a> {
352352 }
353353 TypeKind::IO { ty, .. } => self.resolve_type(ty, source),
354354 TypeKind::Unit(_) => {}
355− TypeKind::UnitAnnotated { base, .. } => self.resolve_type(base, source),
355+ TypeKind::UnitMul(a, b) | TypeKind::UnitDiv(a, b) => {
356+ self.resolve_type(a, source);
357+ self.resolve_type(b, source);
358+ }
356359 TypeKind::Refined { base, predicate } => {
357360 self.resolve_type(base, source);
358361 self.resolve_expr(predicate);
crates/knot-lsp/src/incremental.rs+4−1
@@ -506,7 +506,10 @@ fn collect_type_names(ty: &ast::Type, out: &mut HashSet<String>) {
506506 }
507507 ast::TypeKind::IO { ty, .. } => collect_type_names(ty, out),
508508 ast::TypeKind::Unit(_) => {}
509− ast::TypeKind::UnitAnnotated { base, .. } => collect_type_names(base, out),
509+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
510+ collect_type_names(a, out);
511+ collect_type_names(b, out);
512+ }
510513 ast::TypeKind::Refined { base, .. } => collect_type_names(base, out),
511514 ast::TypeKind::Forall { ty, .. } => collect_type_names(ty, out),
512515 }
crates/knot-lsp/src/linked_editing.rs+4−2
@@ -250,8 +250,10 @@ fn collect_type_field_name_spans(
250250 ast::TypeKind::IO { ty: inner, .. } => {
251251 collect_type_field_name_spans(inner, field_name, source, ranges)
252252 }
253− ast::TypeKind::UnitAnnotated { base, .. } => {
254− collect_type_field_name_spans(base, field_name, source, ranges)
253+ ast::TypeKind::Unit(_) => {}
254+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
255+ collect_type_field_name_spans(a, field_name, source, ranges);
256+ collect_type_field_name_spans(b, field_name, source, ranges);
255257 }
256258 ast::TypeKind::Refined { base, .. } => {
257259 collect_type_field_name_spans(base, field_name, source, ranges)
crates/knot-lsp/src/rename.rs+5−1
@@ -1078,7 +1078,11 @@ fn field_sites_in_type<F: FnMut(&str, Span)>(ty: &ast::Type, source: &str, f: &m
10781078 field_sites_in_type(result, source, f);
10791079 }
10801080 ast::TypeKind::IO { ty: inner, .. } => field_sites_in_type(inner, source, f),
1081− ast::TypeKind::UnitAnnotated { base, .. } => field_sites_in_type(base, source, f),
1081+ ast::TypeKind::Unit(_) => {}
1082+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
1083+ field_sites_in_type(a, source, f);
1084+ field_sites_in_type(b, source, f);
1085+ }
10821086 ast::TypeKind::Refined { base, .. } => field_sites_in_type(base, source, f),
10831087 ast::TypeKind::Forall { ty: inner, .. } => field_sites_in_type(inner, source, f),
10841088 _ => {}
crates/knot-lsp/src/semantic_tokens.rs+3−2
@@ -656,8 +656,9 @@ impl<'a> TokenCollector<'a> {
656656 self.visit_type(inner);
657657 }
658658 ast::TypeKind::Unit(_) => {}
659− ast::TypeKind::UnitAnnotated { base, .. } => {
660− self.visit_type(base);
659+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
660+ self.visit_type(a);
661+ self.visit_type(b);
661662 }
662663 // Leaves with no named sub-types. Listed explicitly (rather than a
663664 // `_` wildcard) so a newly added `TypeKind` variant surfaces as a
crates/knot-lsp/src/shared.rs+7−2
@@ -1051,7 +1051,10 @@ fn type_contains_offset(ty: &ast::Type, offset: usize) -> bool {
10511051 .any(|f| type_contains_offset(&f.value, offset))
10521052 }),
10531053 ast::TypeKind::IO { ty, .. } => type_contains_offset(ty, offset),
1054− ast::TypeKind::UnitAnnotated { base, .. } => type_contains_offset(base, offset),
1054+ ast::TypeKind::Unit(_) => false,
1055+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
1056+ type_contains_offset(a, offset) || type_contains_offset(b, offset)
1057+ }
10551058 ast::TypeKind::Refined { base, .. } => type_contains_offset(base, offset),
10561059 ast::TypeKind::Forall { ty, .. } => type_contains_offset(ty, offset),
10571060 _ => false,
@@ -1125,9 +1128,11 @@ fn type_mentions_var(ty: &ast::Type, var: &str) -> bool {
11251128 .iter()
11261129 .any(|c| c.fields.iter().any(|f| type_mentions_var(&f.value, var))),
11271130 ast::TypeKind::IO { ty, .. }
1128− | ast::TypeKind::UnitAnnotated { base: ty, .. }
11291131 | ast::TypeKind::Refined { base: ty, .. } => type_mentions_var(ty, var),
11301132 ast::TypeKind::Unit(_) => false,
1133+ ast::TypeKind::UnitMul(a, b) | ast::TypeKind::UnitDiv(a, b) => {
1134+ type_mentions_var(a, var) || type_mentions_var(b, var)
1135+ }
11311136 ast::TypeKind::Forall { vars, ty } => {
11321137 !vars.iter().any(|n| n == var) && type_mentions_var(ty, var)
11331138 }
crates/knot-lsp/src/type_format.rs+8−10
@@ -69,7 +69,8 @@ fn format_type_kind_d(ty: &TypeKind, depth: usize) -> String {
6969 | TypeKind::IO { .. }
7070 | TypeKind::Refined { .. }
7171 | TypeKind::Forall { .. }
72− | TypeKind::Unit(_)
72+ | TypeKind::UnitMul(..)
73+ | TypeKind::UnitDiv(..)
7374 ) {
7475 format!("({a})")
7576 } else {
@@ -133,16 +134,13 @@ fn format_type_kind_d(ty: &TypeKind, depth: usize) -> String {
133134 }
134135 TypeKind::Hole => "_".into(),
135136 TypeKind::Callsite => "?".into(),
136− TypeKind::UnitAnnotated { base, unit } => {
137− // `Float M`, `Float (M / S^2)` — space-separated, parens for compound.
138− let u = format_unit_expr_d(unit, d);
139− if u.contains(' ') || u.contains('^') {
140− format!("{} ({})", format_type_kind_d(&base.node, d), u)
141− } else {
142− format!("{} {}", format_type_kind_d(&base.node, d), u)
143− }
144− }
145137 TypeKind::Unit(u) => format_unit_expr_d(u, d),
138+ TypeKind::UnitMul(a, b) => {
139+ format!("{} * {}", format_type_kind_d(&a.node, d), format_type_kind_d(&b.node, d))
140+ }
141+ TypeKind::UnitDiv(a, b) => {
142+ format!("{} / {}", format_type_kind_d(&a.node, d), format_type_kind_d(&b.node, d))
143+ }
146144 TypeKind::Refined { base, predicate } => {
147145 format!(
148146 "{} where {}",
crates/knot/src/ast.rs+15−10
@@ -548,18 +548,23 @@ pub enum TypeKind {
548548 /// grounds against its merged record type.
549549 Callsite,
550550
551− /// `Float M`, `Float (M / S^2)`, `Float u` — a type-level unit
552− /// expression, appearing as the argument of a type application to
553− /// `Int`/`Float`. Carries the compile-time unit algebra (`*`, `/`, `^`).
554− /// A bare `Named(n)` unit is a concrete unit (`M`) when `n` is uppercase
555− /// or a unit variable (`u`) when lowercase.
551+ /// `Float M`, `Float u`, `Float 1` — a type-level unit ATOM appearing as
552+ /// the argument of a type application to `Int`/`Float`. A bare `Named(n)`
553+ /// unit is a concrete unit (`M`) when `n` is uppercase or a unit variable
554+ /// (`u`) when lowercase; `1` is dimensionless; `_` is a unit hole. The
555+ /// unit algebra (`*`, `/`) is expressed by the ordinary-application-shaped
556+ /// `UnitMul`/`UnitDiv` nodes below, so `Int (M / S)` is just an ordinary
557+ /// type application whose argument happens to be unit-shaped — there is no
558+ /// separate unit-annotated type node.
556559 Unit(UnitExpr),
557560
558− /// `Float M` / `Int Usd` / `Float (M / S^2)` — numeric type with unit.
559− /// Kept as a dedicated node (rather than desugared to `App(Named "Float",
560− /// Unit u)`) so inference can recognise the shape without peeling
561− /// application spines. The `base` is `Named "Int"`/`Named "Float"`.
562− UnitAnnotated { base: Box<Type>, unit: UnitExpr },
561+ /// Unit product `a * b`, only ever the (possibly nested) argument of
562+ /// `Int`/`Float`. Parsed by the ordinary type grammar; interpreted as unit
563+ /// multiplication by the inference normalization pass.
564+ UnitMul(Box<Type>, Box<Type>),
565+ /// Unit quotient `a / b`, only ever the (possibly nested) argument of
566+ /// `Int`/`Float`. Interpreted as unit division by the normalization pass.
567+ UnitDiv(Box<Type>, Box<Type>),
563568
564569 /// `T \x -> predicate` — refined type (the predicate is always a lambda).
565570 Refined {
crates/knot/src/format.rs+36−12
@@ -236,11 +236,15 @@ fn render_type_prec(t: &Type, ctx: TyPrec) -> String {
236236 TypeKind::Named(n) => n.clone(),
237237 TypeKind::Var(n) => n.clone(),
238238 TypeKind::App { func, arg } => {
239− let s = format!(
240− "{} {}",
241− render_type_prec(func, TyPrec::App),
242− render_type_atom(arg)
243− );
239+ // A unit-bearing numeric renders its unit argument atom bare, a
240+ // compound unit algebra parenthesized: `Int M`, `Int (M / S)`.
241+ let arg_s = match &arg.node {
242+ TypeKind::UnitMul(..) | TypeKind::UnitDiv(..) => {
243+ format!("({})", render_type_atom(arg))
244+ }
245+ _ => render_type_atom(arg),
246+ };
247+ let s = format!("{} {}", render_type_prec(func, TyPrec::App), arg_s);
244248 if ctx > TyPrec::App {
245249 format!("({})", s)
246250 } else {
@@ -330,14 +334,16 @@ fn render_type_prec(t: &Type, ctx: TyPrec) -> String {
330334 }
331335 TypeKind::Hole => "_".into(),
332336 TypeKind::Callsite => "?".into(),
333− TypeKind::UnitAnnotated { base, unit } => {
334− // `Float M`, `Float (M / S^2)`, `Float u` — space-separated
335− // application. Parenthesize compound units (those with operators)
336− // so the algebraic precedence is explicit.
337− let unit_str = render_unit_type_arg(unit);
338− format!("{} {}", render_type_atom(base), unit_str)
339− }
337+ // A unit-bearing numeric (`Int M`, `Int (M / S)`) is ordinary
338+ // application `App(Int/Float, unit)`; the generic `App` arm renders it.
339+ // The unit arms below render the unit-shaped argument itself.
340340 TypeKind::Unit(u) => render_unit_type_arg(u),
341+ TypeKind::UnitMul(a, b) => {
342+ format!("{} * {}", render_unit_type_operand(a), render_unit_type_operand(b))
343+ }
344+ TypeKind::UnitDiv(a, b) => {
345+ format!("{} / {}", render_unit_type_operand(a), render_unit_type_operand(b))
346+ }
341347 TypeKind::Refined { base, predicate } => {
342348 // `T where \x -> ...` — predicate is always a lambda.
343349 let s = format!(
@@ -444,6 +450,24 @@ fn render_unit_type_arg(u: &UnitExpr) -> String {
444450 }
445451}
446452
453+/// Render a unit-shaped operand of `UnitMul`/`UnitDiv`: a bare atom stays bare,
454+/// a nested product/quotient is parenthesized so the algebra reads unambiguously.
455+fn render_unit_type_operand(t: &Type) -> String {
456+ match &t.node {
457+ TypeKind::Unit(u) => match u {
458+ UnitExpr::Named(n) => n.clone(),
459+ UnitExpr::Dimensionless => "1".into(),
460+ UnitExpr::Hole => "_".into(),
461+ _ => format!("({})", render_unit_expr(u)),
462+ },
463+ other => {
464+ // A nested UnitMul/UnitDiv (or anything unexpected): parenthesize.
465+ let _ = other;
466+ format!("({})", render_type_atom(t))
467+ }
468+ }
469+}
470+
447471// ── Expression precedence ───────────────────────────────────────────
448472
449473/// Mirrors the parser's Pratt binding powers (`parse_expr_bp`): each binary
crates/knot/src/parser.rs+94−66
@@ -799,38 +799,8 @@ impl Parser {
799799// ── Declarations ────────────────────────────────────────────────────
800800
801801impl Parser {
802− /// Parse a unit expression: products, quotients, powers of named units.
803− /// Grammar:
804− /// unit_expr = unit_mul_div
805− /// unit_mul_div = unit_power (('*' | '/') unit_power)*
806− /// unit_power = unit_atom ('^' integer)?
807− /// unit_atom = lower_ident | '1' | '(' unit_expr ')'
808− fn parse_unit_expr(&mut self) -> Option<UnitExpr> {
809− let mut lhs = self.parse_unit_power()?;
810− loop {
811− if self.eat(&TokenKind::Star) {
812− let rhs = self.parse_unit_power()?;
813− lhs = UnitExpr::Mul(Box::new(lhs), Box::new(rhs));
814− } else if self.eat(&TokenKind::Slash) {
815− let rhs = self.parse_unit_power()?;
816− lhs = UnitExpr::Div(Box::new(lhs), Box::new(rhs));
817− } else if self.at(&TokenKind::Caret) {
818− // `^` power was removed: a unit power is written with `*`
819− // (`M ^ 2` → `M * M`), which is also the form arithmetic
820− // produces. Point the user at it.
821− self.error("`^` unit power is gone — write the product instead: `M ^ 2` is `M * M`");
822− return None;
823− } else {
824− break;
825− }
826− }
827− Some(lhs)
828− }
829−
830− fn parse_unit_power(&mut self) -> Option<UnitExpr> {
831− self.parse_unit_atom()
832− }
833−
802+ /// Parse one unit atom: a named unit (`M`, `u`), the dimensionless `1`,
803+ /// or a hole `_`. Used by `parse_unit_atom_ty` to build a `Type::Unit`.
834804 fn parse_unit_atom(&mut self) -> Option<UnitExpr> {
835805 match self.peek() {
836806 TokenKind::Upper(_) => {
@@ -855,13 +825,6 @@ impl Parser {
855825 self.advance();
856826 Some(UnitExpr::Hole)
857827 }
858− TokenKind::LParen => {
859− self.advance();
860− let inner = self.parse_unit_expr()?;
861− self.expect(&TokenKind::RParen, "expected ')' in unit expression")
862− .ok()?;
863− Some(inner)
864− }
865828 _ => {
866829 self.error("expected unit name, '1', or '(' in unit expression");
867830 None
@@ -882,22 +845,78 @@ impl Parser {
882845 }
883846 }
884847
885− /// Parse a unit argument in type position: `M`, `u`, `1`, or
886− /// `(M / S^2)`. A bare identifier is a single unit atom; a parenthesized
887− /// form allows the full unit algebra (`* / ^`).
888− fn parse_unit_type_arg(&mut self) -> Option<UnitExpr> {
848+ /// Parse the unit argument of `Int`/`Float` as an ordinary type-shaped tree:
849+ /// a bare atom (`Int M`, `Float u`, `Int 1`) or a parenthesized algebra
850+ /// (`Int (M / S)`). The argument is unit-shaped — `Unit` atoms combined by
851+ /// `UnitMul`/`UnitDiv` — but carried in the ordinary `Type` tree so the
852+ /// argument of `Int`/`Float` is just a regular type-application argument.
853+ /// The inference normalization pass interprets it as a unit.
854+ fn parse_unit_shaped_arg(&mut self) -> Option<Type> {
889855 match self.peek() {
890856 TokenKind::LParen => {
891857 self.advance();
892− let inner = self.parse_unit_expr()?;
858+ let inner = self.parse_unit_algebra()?;
893859 self.expect(&TokenKind::RParen, "expected ')' in unit argument")
894860 .ok()?;
895861 Some(inner)
896862 }
897− _ => self.parse_unit_atom(),
863+ _ => self.parse_unit_atom_ty(),
898864 }
899865 }
900866
867+ /// Parse a unit algebra over `Type` nodes: `*`/`/` products and quotients of
868+ /// unit atoms, left-associative. Scoped to the parenthesized argument of
869+ /// `Int`/`Float`; nowhere else in the type grammar.
870+ fn parse_unit_algebra(&mut self) -> Option<Type> {
871+ let mut lhs = self.parse_unit_algebra_atom()?;
872+ loop {
873+ if self.at(&TokenKind::Star) {
874+ self.advance();
875+ let rhs = self.parse_unit_algebra_atom()?;
876+ let span = Span::new(lhs.span.start, rhs.span.end);
877+ lhs = Spanned::new(
878+ TypeKind::UnitMul(Box::new(lhs), Box::new(rhs)),
879+ span,
880+ );
881+ } else if self.at(&TokenKind::Slash) {
882+ self.advance();
883+ let rhs = self.parse_unit_algebra_atom()?;
884+ let span = Span::new(lhs.span.start, rhs.span.end);
885+ lhs = Spanned::new(
886+ TypeKind::UnitDiv(Box::new(lhs), Box::new(rhs)),
887+ span,
888+ );
889+ } else if self.at(&TokenKind::Caret) {
890+ self.error("`^` unit power is gone — write the product instead: `M ^ 2` is `M * M`");
891+ return None;
892+ } else {
893+ break;
894+ }
895+ }
896+ Some(lhs)
897+ }
898+
899+ fn parse_unit_algebra_atom(&mut self) -> Option<Type> {
900+ match self.peek() {
901+ TokenKind::LParen => {
902+ self.advance();
903+ let inner = self.parse_unit_algebra()?;
904+ self.expect(&TokenKind::RParen, "expected ')' in unit expression")
905+ .ok()?;
906+ Some(inner)
907+ }
908+ _ => self.parse_unit_atom_ty(),
909+ }
910+ }
911+
912+ /// Parse one unit atom as a `Type`: a named unit (`M`, `u`), the
913+ /// dimensionless `1`, or a hole `_`.
914+ fn parse_unit_atom_ty(&mut self) -> Option<Type> {
915+ let unit = self.parse_unit_atom()?;
916+ let span = self.prev_span();
917+ Some(Spanned::new(TypeKind::Unit(unit), span))
918+ }
919+
901920 // ── subset constraint ────────────────────────────────────────────
902921
903922 // Parse the rest of a subset constraint after `*name` has been consumed.
@@ -4100,9 +4119,15 @@ impl Parser {
41004119 Some(arg) => arg,
41014120 None => fail!(),
41024121 };
4103− if !arg_parenthesized
4104− && matches!(arg.node, TypeKind::UnitAnnotated { .. })
4105− {
4122+ // A unit-carrying numeric type (`Int M`) now arrives as ordinary
4123+ // application `App(Named Int, Unit _)`. Unparenthesized in
4124+ // argument position it would mis-nest (`Maybe Int M` reads `M`
4125+ // as Maybe's second argument), so require parens.
4126+ let is_unit_numeric = matches!(&arg.node,
4127+ TypeKind::App { func, .. }
4128+ if matches!(&func.node, TypeKind::Named(n) if n == "Int" || n == "Float")
4129+ );
4130+ if !arg_parenthesized && is_unit_numeric {
41064131 self.error(
41074132 "parenthesize a unit-carrying type argument: write `Maybe (Int 1)`, not `Maybe Int 1`",
41084133 );
@@ -4235,25 +4260,28 @@ impl Parser {
42354260 },
42364261 span,
42374262 ))
4238− } else if (name == "Float" || name == "Int") && self.can_start_unit_type_arg() {
4239− // `Float M`, `Float u`, `Float (M / S)` — the unit is a
4240− // regular type-argument position parsed as a unit
4241− // expression. A bare Upper/Lower identifier is a unit
4242− // (`M`, `u`); a parenthesized form carries the algebraic
4243− // operators `* /`. A `(` could also start a parenthesized
4244− // type (`Float (Int -> Text)` is application, not a unit),
4245− // so save/restore on failure.
4246− let saved = self.save();
4247− let diag_count = self.diagnostics.len();
4248− let unit = self.parse_unit_type_arg();
4249− if let Some(unit) = unit {
4250− let span = Span::new(tok.span.start, self.prev_span().end);
4251− let base = Box::new(Spanned::new(TypeKind::Named(name), tok.span));
4252− return Some(Spanned::new(TypeKind::UnitAnnotated { base, unit }, span));
4263+ } else if name == "Int" || name == "Float" {
4264+ // `Int`/`Float` may take a unit argument (`Int M`, `Int 1`,
4265+ // `Int (M / S)`), parsed by the application loop in
4266+ // `parse_type_app`. Route back through it so the unit is
4267+ // consumed even when this atom is reached directly (e.g. as
4268+ // `IO`'s argument: `IO Int 1`). We already consumed the head
4269+ // token, so reconstruct the application manually: parse the
4270+ // unit-shaped argument if present and wrap in `App`.
4271+ let base = Spanned::new(TypeKind::Named(name), tok.span);
4272+ if self.can_start_unit_type_arg() {
4273+ if let Some(arg) = self.parse_unit_shaped_arg() {
4274+ let span = Span::new(tok.span.start, arg.span.end);
4275+ return Some(Spanned::new(
4276+ TypeKind::App {
4277+ func: Box::new(base),
4278+ arg: Box::new(arg),
4279+ },
4280+ span,
4281+ ));
4282+ }
42534283 }
4254− self.diagnostics.truncate(diag_count);
4255− self.restore(saved);
4256− Some(Spanned::new(TypeKind::Named(name), tok.span))
4284+ Some(base)
42574285 } else {
42584286 Some(Spanned::new(TypeKind::Named(name), tok.span))
42594287 }