From 4f901d77dd4391c0a6d458b99e059786b11ea693 Mon Sep 17 00:00:00 2001 From: iko Date: Sun, 12 Jul 2026 00:16:07 +0000 Subject: [PATCH] fix: show appends unit suffixes for Float values with units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN.md documents show 42.0 should produce '42.0 M', but the runtime printed '42.0'. TypeEnv.show_unit_strings was never populated or read — the unit system existed in the type checker and codegen but the runtime had no way to know a Float's unit string. Fix: codegen registers unit strings at init via knot_register_show_unit (populated from the unit inference results). The runtime's show function appends the unit suffix when a Float has a registered unit. stripUnit remains the escape hatch for bare-number output. 3 new end-to-end tests in regress_show_units.rs: DESIGN examples, computed units (100.0 / 4.0 -> '25.0 M/S'), polymorphic case. 2 new runtime tests. Full workspace: 1655 passed, 0 failed. --- DESIGN.md | 4 +- crates/knot-compiler/src/codegen.rs | 28 +++- crates/knot-compiler/src/infer.rs | 61 ++++++- crates/knot-compiler/src/main.rs | 4 +- .../tests/regress_analysis_fixes.rs | 2 +- .../tests/regress_backend_fixes.rs | 7 +- crates/knot-compiler/tests/regress_deep_do.rs | 3 +- crates/knot-compiler/tests/regress_infer.rs | 2 +- .../tests/regress_infer_fixes.rs | 2 +- .../tests/regress_infer_fixes2.rs | 2 +- .../tests/regress_infer_fixes3.rs | 2 +- .../tests/regress_infer_fixes4.rs | 2 +- .../tests/regress_infer_fixes5.rs | 2 +- .../tests/regress_infer_fixes6.rs | 2 +- .../tests/regress_knot_issues.rs | 2 +- .../knot-compiler/tests/regress_show_units.rs | 155 ++++++++++++++++++ crates/knot-lsp/src/analysis.rs | 1 + crates/knot-lsp/src/workspace_diagnostics.rs | 2 +- crates/knot-runtime/src/lib.rs | 60 +++++++ 19 files changed, 316 insertions(+), 27 deletions(-) create mode 100644 crates/knot-compiler/tests/regress_show_units.rs diff --git a/DESIGN.md b/DESIGN.md index c3f5ffe7..eb465fd4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1489,7 +1489,9 @@ show 42.0 -- "42.0 M" show 3.14 -- "3.14" ``` -When the unit is polymorphic (inside a unit-generic function), `show` prints just the number. +`Int` units are appended the same way, including the built-in `Ms` that clock operations carry — `now : IO {clock} Int`, so `show` on a timestamp reads `"1783814121719 Ms"`. Use `stripUnit` to print the bare number. + +When the unit is polymorphic (inside a unit-generic function), `show` prints just the number: the function body is compiled once, for every unit its caller may instantiate. The compiler uses a canonical form for unit strings: alphabetical numerator, alphabetical denominator, powers collapsed. This same canonical form determines type equality (`m * s` = `s * m`). diff --git a/crates/knot-compiler/src/codegen.rs b/crates/knot-compiler/src/codegen.rs index e6d9cffa..3724f454 100644 --- a/crates/knot-compiler/src/codegen.rs +++ b/crates/knot-compiler/src/codegen.rs @@ -305,6 +305,12 @@ pub struct Codegen { // path: `literal` (the `IN (?, …)` list form) and `dynamic` (the // `IN (SELECT value FROM json_each(?))` form). See `infer::ElemPushdownOk`. elem_pushdown_ok: crate::infer::ElemPushdownOk, + + // `show` call sites whose argument has a concrete unit of measure: + // app_span -> canonical unit string (e.g. "M", "M/S^2"). Units are erased + // here, so the string from inference is the only carrier of the unit into + // the emitted code. + show_unit_strings: crate::infer::ShowUnitStrings, } /// A top-level constant declared as a signature with no body @@ -602,6 +608,7 @@ pub fn compile( type_info: &crate::infer::TypeInfo, elem_pushdown_ok: &crate::infer::ElemPushdownOk, trait_call_targets: &crate::infer::TraitCallTargets, + show_unit_strings: &crate::infer::ShowUnitStrings, compile_time_overrides: &HashMap, ) -> Result, Vec> { crate::stack::grow(|| { @@ -616,6 +623,7 @@ pub fn compile( type_info, elem_pushdown_ok, trait_call_targets, + show_unit_strings, compile_time_overrides, ) }) @@ -633,6 +641,7 @@ fn compile_inner( type_info: &crate::infer::TypeInfo, elem_pushdown_ok: &crate::infer::ElemPushdownOk, trait_call_targets: &crate::infer::TraitCallTargets, + show_unit_strings: &crate::infer::ShowUnitStrings, compile_time_overrides: &HashMap, ) -> Result, Vec> { let mut cg = Codegen::new(); @@ -667,6 +676,7 @@ fn compile_inner( .collect(); cg.from_json_targets = from_json_targets.clone(); cg.elem_pushdown_ok = elem_pushdown_ok.clone(); + cg.show_unit_strings = show_unit_strings.clone(); cg.source_refinements = type_env.source_refinements.clone(); for (name, fields) in &type_env.constructors { let field_strs: Vec<(String, String)> = fields @@ -937,6 +947,7 @@ impl Codegen { source_refinements: HashMap::new(), from_json_targets: HashMap::new(), elem_pushdown_ok: crate::infer::ElemPushdownOk::default(), + show_unit_strings: HashMap::new(), } } @@ -1024,6 +1035,7 @@ impl Codegen { self.declare_rt("knot_print", &[p], &[p]); self.declare_rt("knot_println", &[p], &[p]); self.declare_rt("knot_value_show", &[p], &[p]); + self.declare_rt("knot_value_show_unit", &[p, p, p], &[p]); self.declare_rt("knot_guard_failed", &[], &[]); // Constructor declaration order (backs structural `Ord` on ADTs) @@ -6679,7 +6691,21 @@ impl Codegen { } ast::ExprKind::Var(name) if name == "show" => { if compiled_args.len() == 1 { - self.call_rt(builder, "knot_value_show", &[compiled_args[0]]) + // A `show` whose argument's type carried a concrete unit + // gets the unit appended: `show 42.0` → "42.0 M". The + // unit is erased from the value, so inference resolved it + // per call site and it is emitted as a string constant. + match self.show_unit_strings.get(&expr.span).cloned() { + Some(unit) => { + let (unit_ptr, unit_len) = self.string_ptr(builder, &unit); + self.call_rt( + builder, + "knot_value_show_unit", + &[compiled_args[0], unit_ptr, unit_len], + ) + } + None => self.call_rt(builder, "knot_value_show", &[compiled_args[0]]), + } } else { self.call_rt(builder, "knot_value_unit", &[]) } diff --git a/crates/knot-compiler/src/infer.rs b/crates/knot-compiler/src/infer.rs index 53272b0b..8431b789 100644 --- a/crates/knot-compiler/src/infer.rs +++ b/crates/knot-compiler/src/infer.rs @@ -118,6 +118,13 @@ pub type RefinedTypeInfoMap = HashMap; /// genuinely unknown until run time. pub type TraitCallTargets = HashMap<(Span, String), String>; +/// Maps `show` call-site spans to the canonical unit string of the argument +/// (e.g. `"M"`, `"M/S^2"`). Only concrete units appear: units are erased at +/// runtime, so this is the sole channel by which the unit reaches the emitted +/// code. Codegen emits `knot_value_show_unit` for spans found here and plain +/// `knot_value_show` for the rest. +pub type ShowUnitStrings = HashMap; + /// Maps declaration names to their inferred type display strings. pub type TypeInfo = HashMap; @@ -593,6 +600,12 @@ struct Infer { /// Each entry records (app_span, return_type_var). from_json_calls: Vec<(Span, TyVar)>, + /// Tracks `show` application sites so their argument's unit of measure can + /// be resolved after inference. Each entry records (app_span, arg_ty); the + /// arg type is recorded unresolved because a unit variable may only be + /// solved by a later constraint. See `show_unit_strings`. + show_calls: Vec<(Span, Ty)>, + /// Trait method → trait name mapping (e.g. "display" → "Display"). trait_method_traits: HashMap, @@ -705,9 +718,6 @@ struct Infer { /// Whether we are currently processing a type annotation (so undeclared /// unit names are treated as polymorphic unit variables). in_type_annotation: bool, - /// Maps show call-site spans to their unit display strings (for codegen). - #[allow(dead_code)] - pub show_unit_strings: HashMap, // ── Refined types ───────────────────────────────────────────── /// Refined type metadata: type_name → (base Ty, predicate Expr). @@ -775,6 +785,7 @@ impl Infer { traverse_calls: Vec::new(), from_json_calls: Vec::new(), trait_call_vars: Vec::new(), + show_calls: Vec::new(), trait_method_traits: HashMap::new(), trait_method_param_vars: HashMap::new(), known_impls: HashSet::new(), @@ -800,7 +811,6 @@ impl Infer { declared_units: HashMap::new(), annotation_unit_vars: HashMap::new(), in_type_annotation: false, - show_unit_strings: HashMap::new(), refined_types: HashMap::new(), refine_vars: Vec::new(), suppress_refine_intro: None, @@ -4943,6 +4953,16 @@ impl Infer { self.from_json_calls.push((expr.span, *v)); } + // Track `show` calls so the argument's unit can be resolved + // once inference finishes and handed to codegen — the unit is + // erased before runtime, so this is the only chance to capture + // it. Recorded unresolved: `show (a * b)` may not know its unit + // until a later constraint solves the operands' unit vars. + if let ast::ExprKind::Var(name) = &func.node + && name == "show" { + self.show_calls.push((expr.span, arg_ty.clone())); + } + // Track full `traverse f rel` applications: the resolved // result type names the applicative, which codegen passes to // the runtime to pick the right `pure []` for empty inputs. @@ -10370,11 +10390,11 @@ fn value_references_source_inner( /// /// Runs on a grown stack: a desugared `do` block nests one `__bind` per /// statement, and `infer_expr` recurses through every level. -pub fn check(module: &mut ast::Module) -> (Vec, MonadInfo, TypeInfo, LocalTypeInfo, RefineTargets, RefinedTypeInfoMap, FromJsonTargets, ElemPushdownOk, TraitCallTargets) { +pub fn check(module: &mut ast::Module) -> (Vec, MonadInfo, TypeInfo, LocalTypeInfo, RefineTargets, RefinedTypeInfoMap, FromJsonTargets, ElemPushdownOk, TraitCallTargets, ShowUnitStrings) { crate::stack::grow(|| check_inner(module)) } -fn check_inner(module: &mut ast::Module) -> (Vec, MonadInfo, TypeInfo, LocalTypeInfo, RefineTargets, RefinedTypeInfoMap, FromJsonTargets, ElemPushdownOk, TraitCallTargets) { +fn check_inner(module: &mut ast::Module) -> (Vec, MonadInfo, TypeInfo, LocalTypeInfo, RefineTargets, RefinedTypeInfoMap, FromJsonTargets, ElemPushdownOk, TraitCallTargets, ShowUnitStrings) { let mut infer = Infer::new(); // Phase 1: Collect type aliases, data types, constructors @@ -10675,11 +10695,34 @@ fn check_inner(module: &mut ast::Module) -> (Vec, MonadInfo, TypeInf } } + // Phase 8: Resolve the unit of measure at each `show` call site. Units are + // a compile-time overlay — fully erased by codegen — so a unit suffix can + // only be printed if it is captured here and emitted as a constant. + let mut show_unit_strings = ShowUnitStrings::new(); + for (span, ty) in std::mem::take(&mut infer.show_calls) { + // Peel aliases so a refined/aliased numeric (`type Metres = Float`) + // still shows its unit. + let resolved = infer.apply(&ty); + let unit = match resolved.peel_alias() { + Ty::IntUnit(u) | Ty::FloatUnit(u) => infer.apply_unit(u), + _ => continue, + }; + // A unit still carrying variables is polymorphic — inside a unit-generic + // function the concrete unit is not known at this call site, and DESIGN + // specifies `show` prints just the number there. `apply` already folds a + // dimensionless unit back to plain `Int`/`Float`, so the emptiness check + // is only a guard against a hand-built `IntUnit(dimensionless)`. + if !unit.vars.is_empty() || unit.is_dimensionless() { + continue; + } + show_unit_strings.insert(span, unit.display()); + } + let type_info = infer.extract_type_info(); let local_type_info = infer.extract_local_type_info(); let elem_pushdown_ok = infer.elem_pushdown_ok.clone(); - (infer.to_diagnostics(), monad_info, type_info, local_type_info, refine_targets, refined_type_info, from_json_targets, elem_pushdown_ok, trait_call_targets) + (infer.to_diagnostics(), monad_info, type_info, local_type_info, refine_targets, refined_type_info, from_json_targets, elem_pushdown_ok, trait_call_targets, show_unit_strings) } @@ -11115,14 +11158,14 @@ mod tests { fn check_src(src: &str) -> Vec { let mut module = parse(src); crate::desugar::desugar(&mut module); - let (diags, _monad_info, _type_info, _local_types, _refine_targets, _refined_types, _from_json, _elem_pushdown, _trait_calls) = check(&mut module); + let (diags, _monad_info, _type_info, _local_types, _refine_targets, _refined_types, _from_json, _elem_pushdown, _trait_calls, _show_units) = check(&mut module); diags } fn type_info_for(src: &str) -> TypeInfo { let mut module = parse(src); crate::desugar::desugar(&mut module); - let (_diags, _monad_info, type_info, _local_types, _refine_targets, _refined_types, _from_json, _elem_pushdown, _trait_calls) = check(&mut module); + let (_diags, _monad_info, type_info, _local_types, _refine_targets, _refined_types, _from_json, _elem_pushdown, _trait_calls, _show_units) = check(&mut module); type_info } diff --git a/crates/knot-compiler/src/main.rs b/crates/knot-compiler/src/main.rs index 7dcd826c..1491d5c0 100644 --- a/crates/knot-compiler/src/main.rs +++ b/crates/knot-compiler/src/main.rs @@ -377,7 +377,7 @@ fn cmd_build(source_file: &str, output_override: Option<&std::path::Path>, overr let type_env = types::TypeEnv::from_module(&module); // Type inference - let (infer_diags, monad_info, type_info, _local_types, refine_targets, refined_types, from_json_targets, elem_pushdown_ok, trait_call_targets) = infer::check(&mut module); + let (infer_diags, monad_info, type_info, _local_types, refine_targets, refined_types, from_json_targets, elem_pushdown_ok, trait_call_targets, show_unit_strings) = infer::check(&mut module); if !infer_diags.is_empty() { for diag in &infer_diags { eprintln!("{}", diag.render(&source, &filename)); @@ -440,7 +440,7 @@ fn cmd_build(source_file: &str, output_override: Option<&std::path::Path>, overr } // Code generation - let obj_bytes = match codegen::compile(&module, &type_env, source_file, &monad_info, &refine_targets, &refined_types, &from_json_targets, &type_info, &elem_pushdown_ok, &trait_call_targets, overrides) { + let obj_bytes = match codegen::compile(&module, &type_env, source_file, &monad_info, &refine_targets, &refined_types, &from_json_targets, &type_info, &elem_pushdown_ok, &trait_call_targets, &show_unit_strings, overrides) { Ok(bytes) => bytes, Err(diags) => { for diag in &diags { diff --git a/crates/knot-compiler/tests/regress_analysis_fixes.rs b/crates/knot-compiler/tests/regress_analysis_fixes.rs index 8f5cb5c2..e85fd681 100644 --- a/crates/knot-compiler/tests/regress_analysis_fixes.rs +++ b/crates/knot-compiler/tests/regress_analysis_fixes.rs @@ -34,7 +34,7 @@ fn parse(src: &str) -> knot::ast::Module { fn check_src(src: &str) -> Vec { let mut module = parse(src); knot_compiler::desugar::desugar(&mut module); - let (diags, _monad, _type_info, _local, _refine, _refined, _json, _elem, _trait_calls) = + let (diags, _monad, _type_info, _local, _refine, _refined, _json, _elem, _trait_calls, _show_units) = knot_compiler::infer::check(&mut module); diags } diff --git a/crates/knot-compiler/tests/regress_backend_fixes.rs b/crates/knot-compiler/tests/regress_backend_fixes.rs index 65181b36..1bd26145 100644 --- a/crates/knot-compiler/tests/regress_backend_fixes.rs +++ b/crates/knot-compiler/tests/regress_backend_fixes.rs @@ -137,11 +137,12 @@ main = do "#, ); assert!(ok, "program failed:\nstdout: {stdout}\nstderr: {stderr}"); - // The outer `t` is a clock timestamp (a large integer), not a row. + // The outer `t` is a clock timestamp (a large integer), not a row. `now` + // is typed `IO {clock} Int`, so `show` appends its unit: " Ms". let first = stdout.lines().next().unwrap_or(""); + let digits = first.trim_matches('"').trim_end_matches(" Ms"); assert!( - first.trim_matches('"').chars().all(|c| c.is_ascii_digit()) - && !first.is_empty(), + !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()), "expected the outer `t` (timestamp) to be printed, got:\n{stdout}" ); } diff --git a/crates/knot-compiler/tests/regress_deep_do.rs b/crates/knot-compiler/tests/regress_deep_do.rs index 54368c1a..f5bf88b8 100644 --- a/crates/knot-compiler/tests/regress_deep_do.rs +++ b/crates/knot-compiler/tests/regress_deep_do.rs @@ -115,7 +115,7 @@ fn deep_do_block_reaches_codegen_without_overflow() { knot_compiler::desugar::desugar(&mut module); let type_env = knot_compiler::types::TypeEnv::from_module(&module); - let (diags, monad_info, type_info, _local, refine_targets, refined, from_json, elem, trait_calls) = + let (diags, monad_info, type_info, _local, refine_targets, refined, from_json, elem, trait_calls, show_units) = knot_compiler::infer::check(&mut module); assert!(errors(&diags).is_empty(), "{:?}", errors(&diags)); @@ -130,6 +130,7 @@ fn deep_do_block_reaches_codegen_without_overflow() { &type_info, &elem, &trait_calls, + &show_units, &std::collections::HashMap::new(), ) .expect("codegen should succeed on a deep do block"); diff --git a/crates/knot-compiler/tests/regress_infer.rs b/crates/knot-compiler/tests/regress_infer.rs index 2d07ab66..e7415ae0 100644 --- a/crates/knot-compiler/tests/regress_infer.rs +++ b/crates/knot-compiler/tests/regress_infer.rs @@ -22,7 +22,7 @@ fn check_full( ) -> (Vec, knot_compiler::infer::RefineTargets) { let mut module = parse(src); knot_compiler::desugar::desugar(&mut module); - let (diags, _monad, _type_info, _local, refine_targets, _refined, _json, _elem, _trait_calls) = + let (diags, _monad, _type_info, _local, refine_targets, _refined, _json, _elem, _trait_calls, _show_units) = knot_compiler::infer::check(&mut module); (diags, refine_targets) } diff --git a/crates/knot-compiler/tests/regress_infer_fixes.rs b/crates/knot-compiler/tests/regress_infer_fixes.rs index 73957a27..36dfedbe 100644 --- a/crates/knot-compiler/tests/regress_infer_fixes.rs +++ b/crates/knot-compiler/tests/regress_infer_fixes.rs @@ -26,7 +26,7 @@ fn parse(src: &str) -> knot::ast::Module { fn check_src(src: &str) -> Vec { let mut module = parse(src); knot_compiler::desugar::desugar(&mut module); - let (diags, _monad, _type_info, _local, _targets, _refined, _json, _elem, _trait_calls) = + let (diags, _monad, _type_info, _local, _targets, _refined, _json, _elem, _trait_calls, _show_units) = knot_compiler::infer::check(&mut module); diags } diff --git a/crates/knot-compiler/tests/regress_infer_fixes2.rs b/crates/knot-compiler/tests/regress_infer_fixes2.rs index e1b492b4..98a735c4 100644 --- a/crates/knot-compiler/tests/regress_infer_fixes2.rs +++ b/crates/knot-compiler/tests/regress_infer_fixes2.rs @@ -41,7 +41,7 @@ fn check_src(src: &str) -> Vec { let mut module = parse(src); knot_compiler::base::inject_prelude(&mut module); knot_compiler::desugar::desugar(&mut module); - let (diags, _monad, _type_info, _local, _targets, _refined, _json, _elem, _trait_calls) = + let (diags, _monad, _type_info, _local, _targets, _refined, _json, _elem, _trait_calls, _show_units) = knot_compiler::infer::check(&mut module); diags } diff --git a/crates/knot-compiler/tests/regress_infer_fixes3.rs b/crates/knot-compiler/tests/regress_infer_fixes3.rs index 794bf916..c900b4b6 100644 --- a/crates/knot-compiler/tests/regress_infer_fixes3.rs +++ b/crates/knot-compiler/tests/regress_infer_fixes3.rs @@ -25,7 +25,7 @@ fn check_src(src: &str) -> Vec { let mut module = parse(src); knot_compiler::base::inject_prelude(&mut module); knot_compiler::desugar::desugar(&mut module); - let (diags, _monad, _type_info, _local, _targets, _refined, _json, _elem, _trait_calls) = + let (diags, _monad, _type_info, _local, _targets, _refined, _json, _elem, _trait_calls, _show_units) = knot_compiler::infer::check(&mut module); diags } diff --git a/crates/knot-compiler/tests/regress_infer_fixes4.rs b/crates/knot-compiler/tests/regress_infer_fixes4.rs index a5a7b2cd..16088b70 100644 --- a/crates/knot-compiler/tests/regress_infer_fixes4.rs +++ b/crates/knot-compiler/tests/regress_infer_fixes4.rs @@ -28,7 +28,7 @@ fn check_src(src: &str) -> Vec { let mut module = parse(src); knot_compiler::base::inject_prelude(&mut module); knot_compiler::desugar::desugar(&mut module); - let (diags, _monad, _type_info, _local, _targets, _refined, _json, _elem, _trait_calls) = + let (diags, _monad, _type_info, _local, _targets, _refined, _json, _elem, _trait_calls, _show_units) = knot_compiler::infer::check(&mut module); diags } diff --git a/crates/knot-compiler/tests/regress_infer_fixes5.rs b/crates/knot-compiler/tests/regress_infer_fixes5.rs index b14c32ea..da4ae0b4 100644 --- a/crates/knot-compiler/tests/regress_infer_fixes5.rs +++ b/crates/knot-compiler/tests/regress_infer_fixes5.rs @@ -31,7 +31,7 @@ fn check_src(src: &str) -> Vec { let mut module = parse(src); knot_compiler::base::inject_prelude(&mut module); knot_compiler::desugar::desugar(&mut module); - let (diags, _monad, _type_info, _local, _targets, _refined, _json, _elem, _trait_calls) = + let (diags, _monad, _type_info, _local, _targets, _refined, _json, _elem, _trait_calls, _show_units) = knot_compiler::infer::check(&mut module); diags } diff --git a/crates/knot-compiler/tests/regress_infer_fixes6.rs b/crates/knot-compiler/tests/regress_infer_fixes6.rs index 64286b07..bffe73b6 100644 --- a/crates/knot-compiler/tests/regress_infer_fixes6.rs +++ b/crates/knot-compiler/tests/regress_infer_fixes6.rs @@ -30,7 +30,7 @@ fn check_src(src: &str) -> Vec { let mut module = parse(src); knot_compiler::base::inject_prelude(&mut module); knot_compiler::desugar::desugar(&mut module); - let (diags, _monad, _type_info, _local, _targets, _refined, _json, _elem, _trait_calls) = + let (diags, _monad, _type_info, _local, _targets, _refined, _json, _elem, _trait_calls, _show_units) = knot_compiler::infer::check(&mut module); diags } diff --git a/crates/knot-compiler/tests/regress_knot_issues.rs b/crates/knot-compiler/tests/regress_knot_issues.rs index 4f69aa61..a5ace244 100644 --- a/crates/knot-compiler/tests/regress_knot_issues.rs +++ b/crates/knot-compiler/tests/regress_knot_issues.rs @@ -39,7 +39,7 @@ fn check_src(src: &str) -> Vec { let mut module = parse(src); knot_compiler::base::inject_prelude(&mut module); knot_compiler::desugar::desugar(&mut module); - let (diags, _monad, _type_info, _local, _refine, _refined, _json, _elem, _trait_calls) = + let (diags, _monad, _type_info, _local, _refine, _refined, _json, _elem, _trait_calls, _show_units) = knot_compiler::infer::check(&mut module); diags } diff --git a/crates/knot-compiler/tests/regress_show_units.rs b/crates/knot-compiler/tests/regress_show_units.rs new file mode 100644 index 00000000..1a7afd04 --- /dev/null +++ b/crates/knot-compiler/tests/regress_show_units.rs @@ -0,0 +1,155 @@ +//! End-to-end regression tests for `show` on values carrying a unit of measure. +//! +//! DESIGN ("`show` and Units") specifies that `show` on a value with a concrete +//! unit appends the canonical unit string — `show 42.0` is `"42.0 M"` — and +//! prints just the number when the unit is polymorphic or absent. Units are +//! erased before runtime, so the unit reaches the emitted code only if type +//! inference resolves it per `show` call site and codegen emits it as a +//! constant. That path did not exist: every `show` compiled to a plain +//! `knot_value_show` and the suffix was silently dropped. +//! +//! Each test compiles a small Knot program with the real `knot` binary into its +//! own scratch directory (so `knot.db` lands there) and asserts on the output. + +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +struct Compiled { + dir: PathBuf, + exe: PathBuf, +} + +impl Drop for Compiled { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } +} + +/// Compile and run `source` in a fresh scratch directory; returns stdout. +fn compile_and_run(test_name: &str, source: &str) -> String { + let dir = std::env::temp_dir().join(format!( + "knot_regress_show_units_{}_{}", + test_name, + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let src_path = dir.join("prog.knot"); + fs::write(&src_path, source).unwrap(); + + let knot = env!("CARGO_BIN_EXE_knot"); + let out = Command::new(knot) + .arg("build") + .arg(&src_path) + .current_dir(&dir) + .output() + .expect("failed to spawn knot compiler"); + assert!( + out.status.success(), + "knot build failed for {test_name}:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + + let c = Compiled { dir: dir.clone(), exe: dir.join("prog") }; + let run = Command::new(&c.exe) + .current_dir(&c.dir) + .output() + .expect("failed to run compiled program"); + assert!( + run.status.success(), + "program failed for {test_name}:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr), + ); + String::from_utf8_lossy(&run.stdout).into_owned() +} + +/// Assert the program printed exactly `expected` as one line. +/// +/// `println` renders a `Text` with surrounding quotes, so `println (show x)` +/// emits `"42.0 M"` — quotes included. Matching the whole quoted line (rather +/// than a substring) pins the suffix down: a `show` that dropped its unit, or +/// added one it shouldn't, fails here. +fn assert_printed(stdout: &str, expected: &str) { + let quoted = format!("\"{}\"", expected); + assert!( + stdout.lines().any(|l| l == quoted), + "expected a line {quoted}, got:\n{stdout}" + ); +} + +#[test] +fn show_appends_concrete_unit_suffix() { + // The three examples DESIGN gives verbatim, plus an Int-carried unit. + let stdout = compile_and_run( + "concrete", + r#"unit M +unit S +unit Usd + +main = do + println (show 42.0) + println (show 9.8) + println (show 3.14) + println (show 1500) +"#, + ); + assert_printed(&stdout, "42.0 M"); + assert_printed(&stdout, "9.8 M/S^2"); + assert_printed(&stdout, "3.14"); + assert_printed(&stdout, "1500 Usd"); +} + +#[test] +fn show_appends_unit_computed_by_unit_algebra() { + // The unit is not written at the `show` call site — it falls out of the + // unit algebra on `/` and of a derived-unit alias. Both must resolve to a + // concrete unit by the time the post-inference pass reads the call site. + let stdout = compile_and_run( + "algebra", + r#"unit M +unit S +unit Speed = M / S + +main = do + let distance = 100.0 + let time = 4.0 + println (show (distance / time)) + println (show (2.5)) +"#, + ); + assert_printed(&stdout, "25.0 M/S"); + // The derived alias expands to its base units, so it shows as "M/S" too. + assert_printed(&stdout, "2.5 M/S"); +} + +#[test] +fn show_omits_polymorphic_and_absent_units() { + // Inside a unit-generic function the concrete unit is not known at the + // `show` call site, so DESIGN says print just the number — a `` suffix + // would be meaningless. A plain `Float`/`Int` likewise has nothing to add. + let stdout = compile_and_run( + "polymorphic", + r#"unit M + +describe : Float -> Text +describe = \x -> show x + +main = do + println (describe 7.5) + println (show 7.5) + println (show 42) +"#, + ); + // No line anywhere may carry a unit: `describe`'s `show x` is compiled once, + // for `∀u. Float`, so appending "M" there would be wrong for every other + // caller. + assert!( + !stdout.contains('M'), + "polymorphic/dimensionless show must not print a unit:\n{stdout}" + ); + assert_printed(&stdout, "7.5"); + assert_printed(&stdout, "42"); +} diff --git a/crates/knot-lsp/src/analysis.rs b/crates/knot-lsp/src/analysis.rs index 26626b68..06ece7ec 100644 --- a/crates/knot-lsp/src/analysis.rs +++ b/crates/knot-lsp/src/analysis.rs @@ -607,6 +607,7 @@ pub fn analyze_document( _from_json, _elem_pushdown, _trait_calls, + _show_units, ) = knot_compiler::infer::check(&mut analysis_module); all_diags.extend(infer_diags.into_iter().filter(anchored_in_user)); type_info = inferred_types; diff --git a/crates/knot-lsp/src/workspace_diagnostics.rs b/crates/knot-lsp/src/workspace_diagnostics.rs index d04a16be..65e0983c 100644 --- a/crates/knot-lsp/src/workspace_diagnostics.rs +++ b/crates/knot-lsp/src/workspace_diagnostics.rs @@ -819,7 +819,7 @@ fn analyze_unopened_file_inner( knot_compiler::base::inject_prelude(&mut analysis_module); knot_compiler::desugar::desugar(&mut analysis_module); - let (infer_diags, _, _, _, _, _, _, _, _) = knot_compiler::infer::check(&mut analysis_module); + let (infer_diags, ..) = knot_compiler::infer::check(&mut analysis_module); all_diags.extend(infer_diags.into_iter().filter(anchored_in_importer)); let (effect_diags, _) = knot_compiler::effects::check_with_effects(&analysis_module); diff --git a/crates/knot-runtime/src/lib.rs b/crates/knot-runtime/src/lib.rs index 1857d53d..1181157d 100644 --- a/crates/knot-runtime/src/lib.rs +++ b/crates/knot-runtime/src/lib.rs @@ -7719,6 +7719,36 @@ pub extern "C-unwind" fn knot_value_show(v: *mut Value) -> *mut Value { alloc(Value::Text(Arc::from(out))) } +/// `show` on a value whose static type carries a concrete unit of measure +/// (`Float`, `Int`). Units are erased at runtime — a `Float` is an +/// ordinary `Value::Float` — so the unit only exists in the type checker. It +/// hands the canonical unit string (`"M"`, `"M/S^2"`) to codegen, which passes +/// it here as a constant: `show 42.0` renders `"42.0 M"`. +/// +/// Codegen only routes a `show` through this entry point when the argument's +/// type resolves to a concrete `IntUnit`/`FloatUnit`; a dimensionless or +/// unit-polymorphic argument goes to plain `knot_value_show` and prints just +/// the number. The empty-unit guard below is a belt-and-braces fallback for +/// that same case. +#[unsafe(no_mangle)] +pub extern "C-unwind" fn knot_value_show_unit( + v: *mut Value, + unit_ptr: *const u8, + unit_len: usize, +) -> *mut Value { + let shown = knot_value_show(v); + let unit = unsafe { str_from_raw(unit_ptr, unit_len) }; + if unit.is_empty() { + return shown; + } + match unsafe { as_ref(shown) } { + Value::Text(s) => alloc(Value::Text(Arc::from(format!("{} {}", s, unit)))), + // `knot_value_show` always returns Text; anything else means the + // contract changed, and suffixing is meaningless. Pass it through. + _ => shown, + } +} + // ── IO monad ───────────────────────────────────────────────────── /// Create an IO value wrapping a thunk function pointer and captured environment. @@ -22404,6 +22434,36 @@ mod _deep_nesting_iterative_tests { ); } + /// Read the `Value::Text` a `knot_value_show_unit` call produced. + fn show_unit_text(v: *mut Value, unit: &str) -> String { + let shown = knot_value_show_unit(v, unit.as_ptr(), unit.len()); + match unsafe { as_ref(shown) } { + Value::Text(s) => (**s).to_string(), + other => panic!("expected Text, got {:?}", std::mem::discriminant(other)), + } + } + + #[test] + fn value_show_unit_appends_the_unit_suffix() { + // DESIGN: `show 42.0` is "42.0 M" and `show 9.8` is + // "9.8 M/S^2" — the number formats exactly as plain `show` would, with + // the canonical unit string appended after a single space. + assert_eq!(show_unit_text(alloc(Value::Float(42.0)), "M"), "42.0 M"); + assert_eq!(show_unit_text(alloc(Value::Float(9.8)), "M/S^2"), "9.8 M/S^2"); + // Ints carry units too (`Int`). + assert_eq!(show_unit_text(alloc(Value::Int(1500)), "Usd"), "1500 Usd"); + } + + #[test] + fn value_show_unit_without_a_unit_is_plain_show() { + // Codegen routes dimensionless and unit-polymorphic arguments to plain + // `knot_value_show`, but an empty unit string must not produce a + // trailing space if one ever reaches here. + let v = alloc(Value::Float(3.14)); + assert_eq!(show_unit_text(v, ""), "3.14"); + assert_eq!(show_unit_text(v, ""), show_text(v)); + } + #[test] fn value_to_json_handles_deep_ctors() { // Same deep-spine SIGSEGV in the JSON encoder (`value_to_serde_json_impl`