From 73d6b545c6ce42f452e71296bbbd410ef7d2e8d9 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 09:09:45 +0200 Subject: [PATCH 01/52] rfc-0000: resolve the three open design questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grounds each in the current compiler (0.5.12) instead of leaving them open: Lang::RFunction carries no default values or `...` handling today (so arity checking is exactly parameters.len(), no fallback needed), the untyped-call result stays Any rather than Foreign, and R { ... } blocks are explicitly out of scope since they already type-check as Type::Empty, not Type::Any, with different unification rules. Opens the RFC for the review this repo's rfcs/README.md describes — it previously landed on develop without going through that process. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- rfcs/0000-calling-untyped-r-functions.md | 61 ++++++++++++++++-------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/rfcs/0000-calling-untyped-r-functions.md b/rfcs/0000-calling-untyped-r-functions.md index c164185..9562a90 100644 --- a/rfcs/0000-calling-untyped-r-functions.md +++ b/rfcs/0000-calling-untyped-r-functions.md @@ -108,8 +108,10 @@ already carries `parameters`. 1. `Lang::RFunction` with parameters `p₁…pₙ` is typed as a function `(Any, …, Any) -> Any` with *n* parameters, instead of the current `Type::UnknownFunction` with none. -2. Applying it checks arity only. Argument types are not checked and not - propagated. +2. Applying it checks arity only, as the literal count of parsed parameters + (`parameters.len()`) — `Lang::RFunction` carries no default values and no + `...` today (see "Resolved during review" below), so there is no case to + special-case yet. Argument types are not checked and not propagated. 3. The result type is `Any`. 4. Preloaded untyped builtins keep `Type::UnknownFunction`, since `functions_R.txt` gives no arity, but `UnknownFunction` becomes **variadic**: @@ -161,10 +163,9 @@ you write when you want the value back inside the type system. no untyped call passes the type checker at all. This deliberately opens a hole and calls it a feature. - **Arity is checked, which may surprise R users.** R's own arity rules are - looser (partial matching, `...`, missing arguments with defaults). An R - function with defaults, `function(a, b = 2)`, would be called `f(1)` in R and - rejected here unless defaults are read from the parsed parameter list. That - detail must be settled before implementation — see below. + looser (partial matching, `...`, missing arguments with defaults). See + "Defaults and `...`" below for why this drawback does not currently bite: the + syntax that would trigger it isn't parseable yet. ## Rationale and alternatives @@ -205,19 +206,41 @@ site's example checks precisely because it does not compile. RFC: it already gives a way to call untyped R with a *declared* signature. The question is whether the undeclared case deserves an answer too. -## Unresolved questions - -- **Defaults and `...`.** How is `function(a, b = 2)` counted, and what happens - to `function(...)` with R's dots? Arity checking is only worth having if it is - right; if reading defaults out of `Lang::RFunction` is awkward, the fallback is - Option 2 (variadic) for those cases specifically. -- **Should the result be `Any` or `Foreign`?** `Foreign` is the existing - idiom for values that came from R and need an accessor. Using it would make - untyped results consistent with `@extern` returns; using `Any` keeps `as!` as - the single exit. -- **Does the same reasoning extend to `R { ... }` blocks?** They already produce - a value; if that value is `Any`, this RFC's `as!` story covers them too and - should say so. +## Resolved during review + +These three questions were open when this RFC was drafted. Checked against the +compiler (0.5.12): + +- **Defaults and `...`.** Neither exists in `Lang::RFunction` today. Its + `parameters: Vec` is built by the parser's generic `variable` combinator + (`processes/parsing/elements.rs`), which accepts a bare name plus an optional + `: Type` annotation — no `= expr` default syntax, no special-casing of `...`. + `function(a, b = 2)` and `function(...)` are not parseable as an `RFunction` + today; there is nothing for arity checking to get wrong, because the grammar + gives it nothing but a plain count of bare names. So point 2 of the + Reference-level explanation is exactly `parameters.len()`, no fallback needed. + Adding default-parameter or dots syntax to the escape hatch is a separate, + larger change (it touches the syntax manifest) and its own RFC if ever + proposed; this one does not depend on it. +- **`Any`, not `Foreign`.** `Foreign` is for a value TypR acknowledges + is opaque *and* gives an accessor story for. An untyped call's result could be + anything a plain R function returns — vector, list, S3 object — and forcing it + through `Foreign` would imply an accessor contract this RFC does not define. + `Any` is the honest choice, and it composes with the rest of this proposal: + a future package-registry design that degrades low-trust or generated + signatures to "callable, unchecked" (see `Type::UnknownFunction` above) needs + exactly this same fallback shape, so settling on `Any` here keeps that door + open instead of introducing a second opaque-value convention to reconcile + later. +- **`R { ... }` blocks: out of scope, and for a concrete reason.** They are + *not* already `Any` — the type checker assigns them `Type::Empty` + (`processes/type_checking/mod.rs`), a distinct placeholder from + `Type::UnknownFunction`/`Type::Any` with its own, more permissive unification + rule (`Type::Empty` unifies with anything; `Type::Any` only satisfies a + target that is itself `Any`). Folding `R { ... }` into this RFC's `Any`/`as!` + story would be a behavior change to existing code, not a documentation + footnote, since it would make `R { ... }` results stop unifying freely with + concrete types. Left for a separate RFC if ever proposed. ## Future possibilities From 5aadbb71b89faf8d404af221d8db00e4d585223e Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 09:15:26 +0200 Subject: [PATCH 02/52] rfc-0028: accept and rename to PR number Per rfcs/README.md: on acceptance the file is renamed to the PR's own number and the header filled in. Tracking issue we-data-ch/typr#29 opened for the implementation (J0 of registry.md's plan). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- ...d-r-functions.md => 0028-calling-untyped-r-functions.md} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename rfcs/{0000-calling-untyped-r-functions.md => 0028-calling-untyped-r-functions.md} (99%) diff --git a/rfcs/0000-calling-untyped-r-functions.md b/rfcs/0028-calling-untyped-r-functions.md similarity index 99% rename from rfcs/0000-calling-untyped-r-functions.md rename to rfcs/0028-calling-untyped-r-functions.md index 9562a90..8b7f0ec 100644 --- a/rfcs/0000-calling-untyped-r-functions.md +++ b/rfcs/0028-calling-untyped-r-functions.md @@ -1,6 +1,6 @@ -- **Status:** draft -- **RFC PR:** we-data-ch/typr#0000 -- **Tracking issue:** — +- **Status:** accepted +- **RFC PR:** we-data-ch/typr#28 +- **Tracking issue:** we-data-ch/typr#29 - **Implemented in:** not yet - **Start date:** 2026-09-09 From d85f8d9498f9f4642be336a63f59d8ffad728cce Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 09:40:41 +0200 Subject: [PATCH 03/52] Implement RFC 0028: make untyped R functions callable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Lang::RFunction` (`function(a, b) { ... }`) now types as a real `(Any, ..., Any) -> Any` function with n parameters, instead of the 0-ary `Type::UnknownFunction` placeholder that made any call with arguments a type error. `Type::UnknownFunction` (used for preloaded untyped base-R builtins like `Position`, `t`, `Reduce`) becomes variadic for the same reason — fixing only `RFunction` would leave the bug reachable through every untyped base-R name. - `processes/type_checking/mod.rs`: `Lang::RFunction`'s typing rule builds a `Type::Function` with n `Any` params -> `Any`. - `components/type/mod.rs` and `components/type/function_type.rs`: `Type::UnknownFunction`'s conversion to `FunctionType` is now a single variadic `Any` parameter -> `Any`. - `components/error_message/type_error.rs`: new `TypeError:: UntypedFunctionArity` (T044) replaces the confusing "no signature matches () -> UnknownFunction" message with the RFC's proposed wording when an untyped function is called with the wrong arity. - `processes/type_checking/function_application.rs`: wires the new error in, using `FunctionType::is_r_function()` to detect the untyped-function shape. - `cases/0062-0064`: regression cases for the callable case, the arity error message, and the preloaded-builtin case. Bumps the `guard_any_type_call_count_is_tracked` baseline 19 -> 21: two new `any_type()` calls in the `RFunction` typing arm, which is the RFC's whole point, not a silent degradation. Tracking issue: we-data-ch/typr#29 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- .../case.toml | 6 +++ .../expect.md | 28 ++++++++++++++ .../expect.toml | 15 ++++++++ .../repro/TypR/main.ty | 13 +++++++ .../case.toml | 6 +++ .../expect.md | 37 +++++++++++++++++++ .../expect.toml | 15 ++++++++ .../repro/TypR/main.ty | 9 +++++ .../case.toml | 6 +++ .../expect.md | 29 +++++++++++++++ .../expect.toml | 11 ++++++ .../repro/TypR/main.ty | 6 +++ .../components/error_message/type_error.rs | 27 ++++++++++++++ .../src/components/type/function_type.rs | 17 ++++++++- crates/typr-core/src/components/type/mod.rs | 7 +++- .../type_checking/function_application.rs | 13 +++++++ .../src/processes/type_checking/mod.rs | 28 ++++++++++++-- rfcs/0028-calling-untyped-r-functions.md | 8 ++-- 18 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 cases/0062-untyped-r-function-callable/case.toml create mode 100644 cases/0062-untyped-r-function-callable/expect.md create mode 100644 cases/0062-untyped-r-function-callable/expect.toml create mode 100644 cases/0062-untyped-r-function-callable/repro/TypR/main.ty create mode 100644 cases/0063-untyped-r-function-arity-error/case.toml create mode 100644 cases/0063-untyped-r-function-arity-error/expect.md create mode 100644 cases/0063-untyped-r-function-arity-error/expect.toml create mode 100644 cases/0063-untyped-r-function-arity-error/repro/TypR/main.ty create mode 100644 cases/0064-untyped-preloaded-builtin-variadic/case.toml create mode 100644 cases/0064-untyped-preloaded-builtin-variadic/expect.md create mode 100644 cases/0064-untyped-preloaded-builtin-variadic/expect.toml create mode 100644 cases/0064-untyped-preloaded-builtin-variadic/repro/TypR/main.ty diff --git a/cases/0062-untyped-r-function-callable/case.toml b/cases/0062-untyped-r-function-callable/case.toml new file mode 100644 index 0000000..974bf80 --- /dev/null +++ b/cases/0062-untyped-r-function-callable/case.toml @@ -0,0 +1,6 @@ +title = "untyped R function (`function(a, b)`) is callable, arity-checked, result `Any` (RFC 0028)" +cmd = "build" +layer = "type" +status = "fixed" +created = "2026-09-12" +origin = "perso" diff --git a/cases/0062-untyped-r-function-callable/expect.md b/cases/0062-untyped-r-function-callable/expect.md new file mode 100644 index 0000000..f1ed659 --- /dev/null +++ b/cases/0062-untyped-r-function-callable/expect.md @@ -0,0 +1,28 @@ +# untyped-r-function-callable + +Source: `rfcs/0028-calling-untyped-r-functions.md`, accepted 2026-09-12 +(we-data-ch/typr#28, tracking we-data-ch/typr#29). + +## Ce qui devrait se passer + +`function(a, b) { ... }` parses to `Lang::RFunction` and used to type as the placeholder +`Type::UnknownFunction`, which carries no parameter list — so any call with at least one +argument failed with `No signature of function 'my_addition' matches this call ... () -> +UnknownFunction`, even though the emitted R was already correct. Per the RFC, `Lang::RFunction` +with `n` parameters now types as `(Any, ..., Any) -> Any`: arity is checked (exactly `n` +arguments), argument types are not, and the result must be brought back into the type system +explicitly with `as!` — the same shape as the rest of TypR's opaque values. + +This is the exact `docs/philosophy/intro.md` example ("weak on safety, strong on freedom"), +which the RFC's Motivation section shows failing to compile on 0.5.10. + +## Vérification + +Implemented alongside this case: `Lang::RFunction`'s typing rule +(`processes/type_checking/mod.rs`) now builds a real `Type::Function` instead of +`Type::UnknownFunction`. The emitted R is unchanged — `my_addition(num1, num2)` — since only the +type checker ever rejected this call. + +## Statut + +Kept as a regression net for the RFC's core guarantee: an untyped R function must stay callable. diff --git a/cases/0062-untyped-r-function-callable/expect.toml b/cases/0062-untyped-r-function-callable/expect.toml new file mode 100644 index 0000000..5081f64 --- /dev/null +++ b/cases/0062-untyped-r-function-callable/expect.toml @@ -0,0 +1,15 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +[[rule]] +file = "@run" +must_not_contain = "doesn't match type" + +[[rule]] +file = "R/main.R" +must_contain = "my_addition(num1, num2)" diff --git a/cases/0062-untyped-r-function-callable/repro/TypR/main.ty b/cases/0062-untyped-r-function-callable/repro/TypR/main.ty new file mode 100644 index 0000000..5b012eb --- /dev/null +++ b/cases/0062-untyped-r-function-callable/repro/TypR/main.ty @@ -0,0 +1,13 @@ +#@case untyped-r-function-callable: RFC 0028 (0028-calling-untyped-r-functions) +# makes an untyped R function (`function(a, b) {...}`) callable: arity is +# checked, argument types are not, and the result is typed Any. This is the +# exact motivating example from the RFC and from docs/philosophy/intro.md. +let num1 <- 3; +let num2 <- 7; + +let my_addition <- function(a, b) { + a + b +}; + +let total: int <- my_addition(num1, num2) as! int; +print(total); diff --git a/cases/0063-untyped-r-function-arity-error/case.toml b/cases/0063-untyped-r-function-arity-error/case.toml new file mode 100644 index 0000000..bb73c68 --- /dev/null +++ b/cases/0063-untyped-r-function-arity-error/case.toml @@ -0,0 +1,6 @@ +title = "wrong arity on an untyped R function reports a dedicated message, not `UnknownFunction`" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-12" +origin = "perso" diff --git a/cases/0063-untyped-r-function-arity-error/expect.md b/cases/0063-untyped-r-function-arity-error/expect.md new file mode 100644 index 0000000..591606f --- /dev/null +++ b/cases/0063-untyped-r-function-arity-error/expect.md @@ -0,0 +1,37 @@ +# untyped-r-function-arity-error + +Source: `rfcs/0028-calling-untyped-r-functions.md`, accepted 2026-09-12 +(we-data-ch/typr#28, tracking we-data-ch/typr#29), Reference-level explanation, "Error messages". + +## Ce qui devrait se passer + +Before the RFC, any call to an untyped R function was rejected by the *arity* check that +`Type::UnknownFunction` (0-ary) implicitly enforced, and the message printed the internal +placeholder back at the user: + +``` +Type error: No signature of function 'my_addition' matches this call. + help: 'my_addition' exists but none of its signature(s) accepts these arguments: + () -> UnknownFunction +``` + +Now that the function is callable, the only thing left to check is arity, and the RFC specifies +a dedicated message for it instead of reusing the generic no-matching-signature wording: + +``` +Type error: 'my_addition' is an untyped R function taking 2 argument(s), called with 3. + help: its body is not type-checked; only the number of arguments is. +``` + +## Vérification + +Implemented alongside case `untyped-r-function-callable`: `TypeError::UntypedFunctionArity` +(`components/error_message/type_error.rs`, code `T044`), raised in +`processes/type_checking/function_application.rs` when a call's argument count doesn't match a +signature that is exactly one, non-variadic, all-`Any` parameters returning `Any` — the shape +`Lang::RFunction`'s typing rule now produces (`FunctionType::is_r_function`). + +## Statut + +Kept as a regression net: the internal `UnknownFunction` placeholder must never resurface in a +user-facing error message again. diff --git a/cases/0063-untyped-r-function-arity-error/expect.toml b/cases/0063-untyped-r-function-arity-error/expect.toml new file mode 100644 index 0000000..0499d91 --- /dev/null +++ b/cases/0063-untyped-r-function-arity-error/expect.toml @@ -0,0 +1,15 @@ +[[rule]] +file = "@run" +must_contain = "Type errors found" + +[[rule]] +file = "@run" +must_contain = "is an untyped R function taking 2 argument(s)" + +[[rule]] +file = "@run" +must_contain = "only the number of arguments is" + +[[rule]] +file = "@run" +must_not_contain = "UnknownFunction" diff --git a/cases/0063-untyped-r-function-arity-error/repro/TypR/main.ty b/cases/0063-untyped-r-function-arity-error/repro/TypR/main.ty new file mode 100644 index 0000000..7f732a2 --- /dev/null +++ b/cases/0063-untyped-r-function-arity-error/repro/TypR/main.ty @@ -0,0 +1,9 @@ +#@case untyped-r-function-arity-error: RFC 0028 - calling an untyped R +# function with the wrong number of arguments must report a specific arity +# error, not the generic "no signature matches" message that exposed the +# internal UnknownFunction placeholder. +let my_addition <- function(a, b) { + a + b +}; + +my_addition(1, 2, 3) diff --git a/cases/0064-untyped-preloaded-builtin-variadic/case.toml b/cases/0064-untyped-preloaded-builtin-variadic/case.toml new file mode 100644 index 0000000..3cc9fc8 --- /dev/null +++ b/cases/0064-untyped-preloaded-builtin-variadic/case.toml @@ -0,0 +1,6 @@ +title = "preloaded untyped base-R builtins (`Position`, `t`, `Reduce`, …) become variadic (RFC 0028)" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-12" +origin = "perso" diff --git a/cases/0064-untyped-preloaded-builtin-variadic/expect.md b/cases/0064-untyped-preloaded-builtin-variadic/expect.md new file mode 100644 index 0000000..90e834b --- /dev/null +++ b/cases/0064-untyped-preloaded-builtin-variadic/expect.md @@ -0,0 +1,29 @@ +# untyped-preloaded-builtin-variadic + +Source: `rfcs/0028-calling-untyped-r-functions.md`, accepted 2026-09-12 +(we-data-ch/typr#28, tracking we-data-ch/typr#29), Motivation and point 4 of the Reference-level +explanation ("Point 4 is the load-bearing half"). + +## Ce qui devrait se passer + +The untyped names from `functions_R.txt` (`Position`, `t`, `Reduce`, …) are preloaded as +`(Any, UnknownFunction)` so that referring to them is not an "undefined variable" error. +Before the RFC, `Type::UnknownFunction` was effectively 0-ary (`FunctionType::new(VecType::Empty, +vec![], ...)`), so `Position()` type-checked but `Position(1, 2)` failed the same way a +user-written `function(...)` did. Fixing only `Lang::RFunction` (case +`untyped-r-function-callable`) without also fixing `UnknownFunction` would leave the same bug +reachable through every untyped base-R name — this is the "confusing version of the bug rather +than a fix" the RFC warns about. `UnknownFunction` must be variadic: any number of arguments, +result `Any`. + +## Vérification + +Implemented alongside case `untyped-r-function-callable`: `Type::UnknownFunction`'s conversion to +`FunctionType` (`components/type/mod.rs`'s `to_function_type`, and the matching +`TryFrom for FunctionType` arm in `components/type/function_type.rs`) now produces a single +variadic `Any` parameter returning `Any`, instead of zero parameters. + +## Statut + +Kept as a regression net: every untyped base-R builtin must stay callable with arguments, not +just referenceable by name. diff --git a/cases/0064-untyped-preloaded-builtin-variadic/expect.toml b/cases/0064-untyped-preloaded-builtin-variadic/expect.toml new file mode 100644 index 0000000..e29aeef --- /dev/null +++ b/cases/0064-untyped-preloaded-builtin-variadic/expect.toml @@ -0,0 +1,11 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +[[rule]] +file = "@run" +must_not_contain = "UnknownFunction" diff --git a/cases/0064-untyped-preloaded-builtin-variadic/repro/TypR/main.ty b/cases/0064-untyped-preloaded-builtin-variadic/repro/TypR/main.ty new file mode 100644 index 0000000..0c92f13 --- /dev/null +++ b/cases/0064-untyped-preloaded-builtin-variadic/repro/TypR/main.ty @@ -0,0 +1,6 @@ +#@case untyped-preloaded-builtin-variadic: RFC 0028 - preloaded untyped +# base-R builtins (functions_R.txt: Position, t, Reduce, ...) are preloaded +# as `(Any, UnknownFunction)`; `UnknownFunction` must be variadic so calling +# them with arguments type-checks, not just referencing the bare name. +let x: int <- Position(1, 2) as! int; +print(x); diff --git a/crates/typr-core/src/components/error_message/type_error.rs b/crates/typr-core/src/components/error_message/type_error.rs index d691c5e..9ff7703 100644 --- a/crates/typr-core/src/components/error_message/type_error.rs +++ b/crates/typr-core/src/components/error_message/type_error.rs @@ -152,6 +152,13 @@ pub enum TypeError { /// `name.Suffix`" error in the generated R — /// `(function_name, forced_type, available_types, position)`. NoDispatchImplementation(String, Type, Vec, HelpData), + /// A call to an untyped R function (`Lang::RFunction`, RFC 0028) supplied + /// the wrong number of arguments. Distinguished from `NoMatchingSignature` + /// because the callee has exactly one signature and no argument types to + /// report — only arity was ever checked, and saying so avoids exposing + /// the internal `UnknownFunction` placeholder to someone who wrote plain + /// R — `(function_name, expected_arity, got_arity, position)`. + UntypedFunctionArity(String, usize, usize, HelpData), } impl TypeError { @@ -201,6 +208,7 @@ impl TypeError { TypeError::DataFrameColumnLengthMismatch(_, _, _, _, h) => Some(h.clone()), TypeError::NoMatchingSignature(_, _, _, h) => Some(h.clone()), TypeError::NoDispatchImplementation(_, _, _, h) => Some(h.clone()), + TypeError::UntypedFunctionArity(_, _, _, h) => Some(h.clone()), } } @@ -252,6 +260,7 @@ impl TypeError { TypeError::UnknownUnionVariant(..) => "T041", TypeError::NoMatchingSignature(..) => "T042", TypeError::NoDispatchImplementation(..) => "T043", + TypeError::UntypedFunctionArity(..) => "T044", } } @@ -475,6 +484,12 @@ impl TypeError { available.iter().map(|t| t.pretty()).collect::>().join(", ") ) } + TypeError::UntypedFunctionArity(name, expected, got, _) => { + format!( + "'{}' is an untyped R function taking {} argument(s), called with {}.", + name, expected, got + ) + } } } } @@ -1065,6 +1080,18 @@ impl ErrorMsg for TypeError { )) .build() } + TypeError::UntypedFunctionArity(name, expected, got, help_data) => { + let (file_data, pos) = safe_file_pos(&help_data, name.len()); + SingleBuilder::new(file_data.0, file_data.1) + .pos(pos) + .text(format!( + "'{}' is an untyped R function taking {} argument(s), called with {}.", + name, expected, got + )) + .pos_text(format!("Called with {} argument(s) here", got)) + .help("Its body is not type-checked; only the number of arguments is.") + .build() + } TypeError::NoDispatchImplementation(name, forced_type, available, help_data) => { let (file_data, pos) = safe_file_pos(&help_data, name.len()); let available_pretty = available.iter().map(|t| t.pretty()).collect::>().join(", "); diff --git a/crates/typr-core/src/components/type/function_type.rs b/crates/typr-core/src/components/type/function_type.rs index 2eed93d..7e0fe31 100644 --- a/crates/typr-core/src/components/type/function_type.rs +++ b/crates/typr-core/src/components/type/function_type.rs @@ -246,8 +246,15 @@ impl FunctionType { self.help_data.clone() } + /// RFC 0028: an untyped R function's signature is exactly n `Any` + /// parameters (n possibly 0), non-variadic, returning `Any` — the shape + /// `Lang::RFunction`'s typing rule now produces. Used to pick the + /// arity-specific error message over the generic no-matching-signature + /// one when a call's argument count doesn't match. pub fn is_r_function(&self) -> bool { - (self.arguments == vec![]) && (self.return_type == builder::unknown_function_type()) + !self.is_variadic + && self.return_type == builder::any_type() + && self.arguments.iter().all(|t| *t == builder::any_type()) } pub fn get_first_param(&self) -> Option { @@ -284,7 +291,13 @@ impl TryFrom for FunctionType { fn try_from(value: Type) -> Result { match value { Type::Function(args, ret, h) => Ok(FunctionType::new(VecType::Empty, args, *ret, h)), - Type::UnknownFunction(h) => Ok(FunctionType::default().set_help_data(h.clone())), + // RFC 0028: same variadic-Any shape as `Type::to_function_type()`. + Type::UnknownFunction(h) => Ok(FunctionType::new( + VecType::Empty, + vec![ArgumentType::new("...", &builder::any_type()).set_variadic(true)], + builder::any_type(), + h.clone(), + )), _ => Err(format!("{} is a type not convertible to FunctionType", value)), } } diff --git a/crates/typr-core/src/components/type/mod.rs b/crates/typr-core/src/components/type/mod.rs index e6014e4..f232b4c 100644 --- a/crates/typr-core/src/components/type/mod.rs +++ b/crates/typr-core/src/components/type/mod.rs @@ -688,10 +688,13 @@ impl Type { (**ret_ty).clone(), h.clone(), )), + // RFC 0028: preloaded untyped builtins (`Position`, `t`, `Reduce`, + // …) carry no declared arity, so they accept any number of + // arguments — a single variadic `Any` parameter, returning `Any`. Type::UnknownFunction(h) => Some(FunctionType::new( VecType::Empty, - vec![], - builder::unknown_function_type(), + vec![ArgumentType::new("...", &builder::any_type()).set_variadic(true)], + builder::any_type(), h.clone(), )), _ => None, diff --git a/crates/typr-core/src/processes/type_checking/function_application.rs b/crates/typr-core/src/processes/type_checking/function_application.rs index 2e87e14..8cffcc6 100644 --- a/crates/typr-core/src/processes/type_checking/function_application.rs +++ b/crates/typr-core/src/processes/type_checking/function_application.rs @@ -1320,6 +1320,19 @@ fn apply_from_variable_inner(var: Var, context: &Context, parameters: &[Lang], h h.clone(), ))); } + // RFC 0028: a name bound to exactly one signature shaped like an + // untyped R function (n `Any` params, `Any` return, non-variadic — + // `Lang::RFunction`'s typing rule) gets its own arity message instead + // of `NoMatchingSignature`, which would otherwise print the callee's + // own `Any` signature back at the caller as if it were informative. + None if all_signatures.len() == 1 && all_signatures[0].is_r_function() => { + errors.push(TypRError::Type(TypeError::UntypedFunctionArity( + var.get_name(), + all_signatures[0].get_param_types().len(), + types.len(), + h.clone(), + ))); + } None if !all_signatures.is_empty() => { // The name IS bound to function signature(s) — the call just // doesn't match any of them (wrong arity or argument types). diff --git a/crates/typr-core/src/processes/type_checking/mod.rs b/crates/typr-core/src/processes/type_checking/mod.rs index 251985e..8878259 100644 --- a/crates/typr-core/src/processes/type_checking/mod.rs +++ b/crates/typr-core/src/processes/type_checking/mod.rs @@ -1741,8 +1741,22 @@ pub fn typing(context: &Context, expr: &Lang) -> TypeContext { } Lang::VecBlock { help_data: h, .. } => TypeContext::new(Type::Empty(h.clone()), expr.clone(), context.clone()), Lang::RBlock { help_data: h, .. } => TypeContext::new(Type::Empty(h.clone()), expr.clone(), context.clone()), - Lang::RFunction { help_data: h, .. } => { - TypeContext::new(Type::UnknownFunction(h.clone()), expr.clone(), context.clone()) + Lang::RFunction { + parameters, + help_data: h, + .. + } => { + // RFC 0028: an untyped R function is callable, checked on arity + // only. `parameters.len()` is the literal count of parsed bare + // names — `Lang::RFunction` has no default values and no `...` + // today, so there is nothing else to special-case here. + let params: Vec = parameters + .iter() + .enumerate() + .map(|(i, _)| ArgumentType::new(&format!("_{}", i), &builder::any_type())) + .collect(); + let func_type = Type::Function(params, Box::new(builder::any_type()), h.clone()); + TypeContext::new(func_type, expr.clone(), context.clone()) } Lang::ExternBlock { parameters: params, @@ -4742,7 +4756,15 @@ p"#; // paired with a pushed `TypeError` (AliasNotFound / // TagFieldConstructorNotSupported / UnknownUnionVariant) — never a // silent fallback, always an error-carrying result type. - const BASELINE: usize = 19; + // + // 19 -> 21 (RFC 0028): `Lang::RFunction` is now typed as a function + // of `n` `Any` parameters returning `Any`, instead of the old + // `Type::UnknownFunction` placeholder — this is the RFC's whole + // point (an untyped R function becomes callable, unchecked), not a + // silent degradation: the call site still gets a real `Type::Function` + // with the right arity, and the `Any` boundary is the documented, + // intentional cost of the escape hatch (see rfcs/0028). + const BASELINE: usize = 21; let count = production_source().matches("any_type()").count(); assert!( count <= BASELINE, diff --git a/rfcs/0028-calling-untyped-r-functions.md b/rfcs/0028-calling-untyped-r-functions.md index 8b7f0ec..e3ee1c3 100644 --- a/rfcs/0028-calling-untyped-r-functions.md +++ b/rfcs/0028-calling-untyped-r-functions.md @@ -250,9 +250,11 @@ of scope here. ## Implementation checklist -- [ ] `cases/` entry: untyped `function(a, b)` defined, called, and cast -- [ ] `cases/` entry: arity error message -- [ ] `cases/` entry: preloaded builtin called with arguments (`Position(1, 2)`) +- [x] `cases/` entry: untyped `function(a, b)` defined, called, and cast + (`cases/0062-untyped-r-function-callable`) +- [x] `cases/` entry: arity error message (`cases/0063-untyped-r-function-arity-error`) +- [x] `cases/` entry: preloaded builtin called with arguments (`Position(1, 2)`) + (`cases/0064-untyped-preloaded-builtin-variadic`) - [ ] `syntaxe.md` §12 updated in both copies - [ ] `docs/philosophy/intro.md` and `docs/reference/escape-hatches.md` updated on `typr.github.io`; the `noplayground` marker on the philosophy block From 6164464bc7c69e539552f4e095dbed5664ea1d5e Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 14:54:45 +0200 Subject: [PATCH 04/52] rfc-0000: propose external type definitions (J2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns registry.md §5 (definition format), §7 (resolution/locking) and §8.3 (conflict order) into a committed contract: typr-def.toml manifests, since/until on FunctionMeta, external .ty loading gated by a project-configured trust threshold that degrades to Any (never a hard error), typr.lock, and typr types add/update/list/vendor. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- rfcs/0000-external-type-definitions.md | 504 +++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 rfcs/0000-external-type-definitions.md diff --git a/rfcs/0000-external-type-definitions.md b/rfcs/0000-external-type-definitions.md new file mode 100644 index 0000000..98d2da0 --- /dev/null +++ b/rfcs/0000-external-type-definitions.md @@ -0,0 +1,504 @@ +- **Status:** draft +- **RFC PR:** we-data-ch/typr#0000 +- **Tracking issue:** — +- **Implemented in:** not yet +- **Start date:** 2026-09-12 + +# External type definitions + + + +## Summary + +A TypR project can declare a `.ty` file written by someone else as the type +description of an R package it imports, pin it to an exact commit and content +digest in a new `typr.lock`, and load it into the type-checking context at a +configurable trust threshold (`T1`/`T2`/`T3`). An entry below that threshold is +never rejected and never errors: it is loaded as a variadic function returning +`Any`, so an untrustworthy or wrong external definition can only make TypR +check *less*, never make a correct program stop compiling. `typr gen-types` +(already shipped) becomes the bootstrap path for this: its output is a valid +`T3` external definition, consumable the same way as a hand-written one. + +This is the second milestone (J2) of the type-registry design in +`typR/registry.md`, whose §5 (definition format), §7 (resolution and locking) +and §8.3 (conflict order) this RFC turns into a committed contract. It +deliberately excludes the registry service itself (a hosted index of +definitions, `typr search`, `typr add` auto-discovery) — that is J3 and later, +and needs no language or CLI contract change beyond what is specified here: +until it exists, `typr types add ` is the only way to point at a +definition, which is sufficient to use everything below. + +## Motivation + +TypR programs already import untyped R packages today, and the escape hatches +to describe them exist and work: `@extern`, `@importFrom` + a hand-written +`@name: (T) -> R;`, `Foreign` for opaque values, `Option` for +`from_nullable()`. What is missing is not expressive power — it is a *place to +put* those declarations that isn't the consuming project's own source, and a +way to load them safely. + +Concretely, two things are true at once and neither has an answer today: + +1. **Nobody will hand-type `shiny`.** It exports on the order of 400 functions. + `typR/typr/crates/typr-cli/src/gen_types.rs` (shipped, J1) already produces + a `.ty` file for any installed package via `Rscript`-based introspection — + but that file has nowhere to live except inside the consuming project, and + nothing loads it automatically. Two projects using `shiny` today duplicate + the same generated file, or don't bother and leave every `shiny::*` call + untyped. + +2. **A hand-written or generated definition can be wrong**, and R's ABI makes + "wrong" dangerous, not just imprecise. `typR/ai_context/external_packages_incompatibilities.md` + documents that a TypR value does not cross into a base-R function for free: + `int` is `structure(x, class = c("Integer", "integer", "Any", "Generic"))`, + `[N, T]` is an S3 list (`typed_vec`), not an atomic vector. A definition that + only states `(int) -> int` for a function that actually strips those + attributes on return produces code that type-checks and returns the wrong + runtime type — silently. There is currently no mechanism that limits the + blast radius of that mistake to "less checking" instead of "a corrupted + type", because there is no mechanism for loading a third-party `.ty` at all. + +Today, describing `dplyr::filter` requires either editing the consuming +project's own source with hand-rolled `@importFrom`/`@extern` declarations (not +shareable, not versioned against the package, redone by every project), or +nothing, leaving the call fully untyped with no hover, no signature help, no +argument names. This RFC is what turns "someone wrote this once" into +"everyone using `dplyr` in TypR benefits from it, without trusting it more than +the project asks to." + +## Guide-level explanation + +Say you are writing a Shiny app in TypR and want typed access to +`fluidPage`/`titlePanel`, and someone has already published a definition for +it. + +**Pointing at a definition.** `typr.toml` gets a new `[types]` table. It is +*not* a dependency list — `DESCRIPTION`'s `Imports:` remains the one place R +dependencies are declared, exactly as today (`typr add shiny` still runs +`usethis::use_package('shiny')` under the hood). `[types]` only says which type +description to use for a package already imported, and how much to trust +descriptions you did not pin explicitly: + +```toml +# typr.toml +[types] +trust = "T2" # T1 = paranoid, T2 = default, T3 = trust generated code too +shiny = "github:alice/typr-shiny" # explicit pin — wins over everything else +``` + +```bash +typr types add github:alice/typr-shiny +``` + +resolves `alice/typr-shiny` on GitHub, fetches its `HEAD`, checks the manifest +(below), and records exactly what was fetched in a new `typr.lock`: + +```toml +# typr.lock — generated, commit this file +[[definition]] +package = "shiny" +repository = "github:alice/typr-shiny" +version = "0.3.0" +rev = "a1b2c3d4e5f6…" +digest = "sha256:…" +tier = "T2" +r_version_seen = "1.11.1" +``` + +From then on, `typr check`/`build`/`run` load `alice/typr-shiny`'s `.ty` files +the same way they load `std.ty` today, and: + +```typr +@importFrom shiny fluidPage; +fluidPage("hello") # hover shows the real signature and doc, not "Any" +``` + +type-checks against the real declared signature, with completion and hover in +the LSP and MCP showing where the definition came from and at what tier. + +**No definition, or one you don't trust enough.** Nothing breaks. If `shiny` +had no pinned definition at all, `fluidPage` is exactly as untyped as any other +R name today — callable, arity-checked when known, returning `Any` +(`rfcs/0028-calling-untyped-r-functions.md`, already shipped). If a definition +*is* pinned but its declared tier is below the project's `trust`, the effect is +identical: every one of its entries loads as `(Any, …) -> Any` instead of its +declared signature. The project never has to remove or fight a bad definition +to keep building — lowering `trust`, or unpinning it, is enough, and doing +neither is also safe. + +**Bootstrapping your own.** If nobody has published one yet: + +```bash +typr gen-types shiny --out ./ty/ +``` + +produces a `T3` definition from the package installed locally (arity and +argument names from `formals()`, everything else `Any`) — this already ships. +This RFC adds `typr types vendor`, which copies whatever is currently resolved +(generated or fetched) into the project's own tree, so the build stops +depending on the network or on the upstream repository's continued existence. + +**How you'd explain it to an R user who knows TypR's basics but nothing about +this feature:** "The `#! tier` you've seen on standard-library entries isn't +just internal bookkeeping — you can attach the same trust levels to +descriptions of *any* R package, written by anyone, and TypR will never let a +description it doesn't fully trust turn your correct code into a type error." + +## Reference-level explanation + +### Definition repository layout and manifest + +A definition repository (`typr-shiny/` in the example above) has this shape: + +```text +typr-shiny/ +├── README.md +├── typr-def.toml # manifest +├── ty/ +│ ├── core.ty +│ └── ui.ty +├── R/ # optional — see Capabilities and R shims below +│ └── shims.R +└── tests/ + └── smoke.ty # a program that must compile against this definition +``` + +`typr-def.toml`, not `typr.toml`, on purpose: the two files answer different +questions (the definition's own metadata vs. a consuming project's +configuration) and living in different repositories does not stop someone from +copy-pasting one into the other by habit if the name is shared. + +```toml +format_version = 1 # REQUIRED — an unknown format_version is refused, not guessed at + +[package] +name = "shiny" +since = "1.11.0" # a floor, not a closed range — see Version compatibility below +# until = "2.0.0" # only when a break is *known*, never speculative + +[definition] +version = "0.3.0" # semver of the definition itself, independent of the package's +tier = "T2" # default tier for entries with no `#! tier:` of their own + +[provider] +type = "community" # official | community | generated | local +repository = "github:alice/typr-shiny" + +[capabilities] +r_shims = false # ships executable R alongside the declarations? +extern_raw = false # uses `extern: (...) -> T r#"...R..."#` verbatim blocks? +``` + +`format_version` is what keeps this survivable across N repositories the +project does not control: when the definition format changes, the compiler +reads old manifests it recognizes or refuses the ones it doesn't — it never +silently misparses one. + +### `.ty` files and the two new annotations + +Definitions are ordinary `.ty` files, parsed and type-checked the same way as +`std.ty`, with the same `#!` annotation block already implemented in +`crates/typr-core/src/processes/spg/stdlib_meta.rs` (`pkg`, `tier`, `param`, +`ret`, `coercion`/`note`, `example`, `seealso`). That parser currently drops +any key it does not recognize (`_ => {} // ignore silently`), which is exactly +how it stays forward-compatible — this RFC uses that door to add two new keys +rather than changing the parser's shape: + +```typr +#! pkg: shiny +#! tier: T2 +#! since: 1.11.0 +#! ret: UI object, opaque on the TypR side +#! example: fluidPage(titlePanel("hello")) +@importFrom shiny fluidPage; +@fluidPage: (Any) -> Foreign; + +#! pkg: dplyr +#! tier: T3 +#! since: 1.1.0 +#! until: 2.0.0 +#! param .data: input table +@importFrom dplyr filter; +@filter: (Any, Any) -> Any; +``` + +`FunctionMeta` gains `since: Option` and `until: Option`, +parsed identically to `ret`/`coercion` (a single value, `strip_leading_colon`). +Nothing about `@extern`/`@importFrom`/`Foreign` changes: choosing between +them *is* the declaration of how the boundary is crossed +(`typR/ai_context/tuto_external_packages.md`), and that choice is exactly what +these files make. + +### Loading external `.ty` into the context + +`crates/typr-cli/src/standard_library.rs` builds the checking context from +`R_T1_SOURCES` (plus `R_DOC_ONLY_SOURCES`) — a fixed, embedded list of +`(filename, source)` pairs. This RFC adds a second list built at project-load +time from `typr.lock`: for each resolved definition, read its `.ty` files from +the on-disk cache (below), parse `#! tier` per entry (falling back to the +manifest's `[definition] tier` when absent), and merge into the same table +`build_typed_vartype` already builds from `R_T1_SOURCES` — with one difference +from today's uniform T1 preload: **each entry's tier is compared against the +project's configured `trust`.** + +- entry tier ≥ project `trust`: loaded with its declared signature, exactly as + `R_T1_SOURCES` entries are today. +- entry tier < project `trust`: loaded as `Type::UnknownFunction` (variadic, + returns `Any`) instead of its declared signature — the same representation + `rfcs/0028-calling-untyped-r-functions.md` already gives every untyped R + name. No error, no warning bubbled up to a build failure; the LSP and MCP + still surface the declared signature and doc (`§11` below), only the + type-checker itself degrades. +- package with no resolved definition at all: unchanged from today — every + name from it is `UnknownFunction` unless the project's own source declares + it. + +This is the mechanism that makes D2 (`typR/registry.md` §0) real: nothing +downstream of this merge step needs to know *why* an entry became `Any` — a +missing definition, a low tier, and an out-of-range version (next section) all +collapse to the same representation. + +### Version compatibility: a floor, not a range + +R has no npm/cargo-style version resolution — CRAN publishes one current +version, and a user has "whatever is installed". Declaring `supports = +["1.11.x"]` would make compatibility depend on the resolving machine (breaking +`typr.lock`'s point), produce false "incompatible" verdicts the moment a +package ships a patch release (R rarely breaks its own API), and require +upkeep nobody will do. + +Instead: `since` is a floor. At load time, the introspected installed version +(already recovered by `introspect_pkg.R`'s `P` line, used today by +`r_name_cache`) is compared against it: + +- installed ≥ `since` (and ≤ `until`, when present): used without warning. +- installed < `since`, or > `until` when declared: TypR **warns and degrades to + `Any`** for that package's entries — never refuses to build. The version + actually observed is written into `typr.lock` as `r_version_seen`, which is + what lets any future CI on a definition repository notice drift between what + it declares and what real installs report. + +### Resolution, `typr.lock`, cache, vendoring + +```text +typr add shiny + → DESCRIPTION: Imports += shiny (usethis — unchanged) + → typr.toml [types]: explicit pin for `shiny`? + yes → use it, done + no → no definition resolved (until J3's registry exists to search) + → fetch resolved repository at HEAD, or the pinned rev + → verify content digest + → ~/.cache/typr/types/// + → typr.lock updated + → available to the compiler, the LSP and the MCP +``` + +New CLI surface (`crates/typr-cli/src/cli.rs`, alongside the existing `Add`, +`Check`, `Cache` subcommands): + +| Command | Role | +|---|---| +| `typr add ` | unchanged (`usethis::use_package`) — this RFC adds nothing here until J3 lets it also look up a definition automatically | +| `typr types add ` | pin a definition (`github:owner/repo[@rev]`) — fetch, verify, write into `typr.toml`/`typr.lock` | +| `typr types update [pkg]` | re-fetch and re-pin `typr.lock` for one or all resolved definitions | +| `typr types list` | what's resolved, with tier and provenance | +| `typr types vendor` | copy resolved `.ty` files into the project tree, so the build no longer depends on the network or on the upstream repository still existing | + +`typr.toml`'s `[types]` and `typr.lock` are new files with no analog in the +project today; `DESCRIPTION` is untouched and remains authoritative for R +dependencies (`§7.1` — one source of truth per question). + +### Capabilities and R shims + +A `.ty` file is inert; `extern: (…) -> T r#"…R…"#` blocks and any `R/` shim +directory are not — they execute in the consuming project's process. The +threat model is `npm postinstall`'s, not `@types/*`'s package.json's. This RFC +adopts the manifest's `[capabilities]` gate as a hard rule, not a convention: + +- a definition whose manifest leaves `r_shims`/`extern_raw` at their default + (`false`) but ships either anyway is **rejected at fetch time**, before a + single byte of it is loaded — not just flagged in CI on the definition's own + repository. +- a definition that sets either to `true` shows an explicit warning on the + first `typr types add`/`typr add` that resolves it, and requires + confirmation (or `--allow-r`). +- nothing is executed during discovery or resolution: fetching a definition is + a source download, full stop; only actually building/running the consuming + project can execute a shim, exactly as it would execute any other R in the + project. + +### Failure modes are never hard errors + +Every one of the following degrades to the *some entries are `Any`* case +above; none of them fails a build: + +- the pinned repository is unreachable (network, deleted, rewritten history — + digest mismatch on the cached copy). +- the resolved manifest has no `format_version`, or one this compiler build + doesn't recognize (the *definition* is refused; the package's names fall + back to fully untyped, same as having none). +- `since`/`until` exclude the installed R package version. +- the project's `trust` excludes the definition's declared tier. + +## Gradual typing + +This proposal is gradual typing's boundary case taken to its logical end: a +package with **zero** resolved definitions behaves exactly as today (every +name `UnknownFunction`, callable, arity-checked when knowable, `Any`-typed +result — `rfcs/0028`). Resolving a `T3` (generated) definition changes *only* +argument names and documentation surfaced to a human or to the LSP/MCP; the +type-checker's view is unchanged, because `T3` entries load exactly like +`UnknownFunction` today. Type constraints appear at `T2`/`T1`, and only for the +entries that actually declare them, only when the project's own `trust` +accepts that tier. There is no annotation density this proposal forces: +a project that never touches `typr.toml [types]` sees no behavior change at +all. + +## Drawbacks + +- **Two new project files** (`typr.toml [types]`, `typr.lock`) for a project + type (R packages) that already has one dependency manifest (`DESCRIPTION`). + The mitigation is the explicit division of labor in §7.1 of + `typR/registry.md`, but it is still a second file to explain to newcomers. +- **The trust/degradation model is invisible by default.** A project that sets + `trust = "T2"` and pins a `T3` definition gets *no* type checking for that + package and no error telling it so — by design (D2), but it means a mistake + here reads as "TypR isn't catching this" rather than as a loud failure. + `typr types list` showing tier per package is the mitigation; it needs to be + something people actually run. +- **Fetching arbitrary GitHub repositories as part of a build-adjacent command** + (`typr types add`, `typr types update`) is new network/trust surface for a + compiler CLI that has not had it before, even with the capability gate in + place. The digest-pinning in `typr.lock` bounds this to "you get what you + first approved," not "you get whatever is at HEAD right now" — but it is a + new class of thing this CLI does. +- **`since`/`until` add two more `#!` keys to a metadata format that already + has seven**; each addition is small, but the format is not designed against + a fixed budget, and every key is a piece every generator and consumer has to + handle for good, per `format_version`'s forward-compatibility promise. + +## Rationale and alternatives + +The central choice is D2 (`typR/registry.md` §0): **degrade to `Any`, never +error.** The alternative — treat a definition below trust, or a version +mismatch, as a hard type error — was considered and rejected, because a +third-party definition is exactly the kind of input a project does not +control, and "a package you don't maintain published something" becoming +"your correct code stops compiling" is the single failure mode a community +type registry cannot survive. This is only possible because +`rfcs/0028-calling-untyped-r-functions.md` already made `UnknownFunction` +variadic and callable — before that RFC, "degrade to `Any`" had nowhere safe to +land. + +The second choice is pinning by content digest in a lockfile rather than a +semver range (`registry.md` §7.2, §D4/§D5): R has no ecosystem-wide version +resolver, so a range like `supports = ["1.11.x"]` would depend on the +resolving machine and rot without anyone noticing. A digest is the only thing +that is reproducible without inventing R version-range semantics R itself +doesn't have. + +The cost of doing nothing is not small: it is the status quo described in +Motivation — every project hand-rolling or duplicating its own `@importFrom` +declarations for the same handful of popular packages, with no sharing +mechanism at all. `typr gen-types` (J1, already shipped) is only half-useful +without this RFC, because its output currently has nowhere to be shared or +loaded except by hand-copying files between projects. + +## Prior art + +- **R itself**: no package in CRAN or Bioconductor ships a machine-readable + type description of its own API; `Rd`/roxygen comments are prose. There is + no existing convention to be compatible with, which is why this proposal + invents one rather than adapting one. +- **DefinitelyTyped** (`@types/*` for TypeScript) is the closest analog: + community-maintained type descriptions decoupled from the packages they + describe, in a monorepo. Its main lesson, already reflected in + `registry.md` §8.2 (not this RFC's scope, since J2 has no registry yet): a + single monorepo makes format migrations tractable across a long tail of + small packages, at the cost of requiring PRs into a repo the community + doesn't fully own. Its second lesson, which *is* this RFC's concern: + `@types` packages can and do drift from the real library's behavior, and + TypeScript has no equivalent of D2 — a wrong `@types` package produces + compile errors on correct code, which is the exact failure this RFC's + degradation rule is designed to avoid. +- **Rust's `cargo vendor`** is the direct model for `typr types vendor`: + making a build reproducible and independent of an upstream repository's + continued existence, distinct from the cache used for day-to-day iteration. +- **npm's `postinstall` scripts** are the threat model for the capabilities + gate in §"Capabilities and R shims" — the industry's cautionary tale for + "a dependency description can execute arbitrary code," which is why this + RFC treats `r_shims`/`extern_raw` as a hard gate rather than a documented + convention. + +## Unresolved questions + +Carried over from `registry.md` §14, to the extent they bear on J2 rather than +the registry service itself: + +- **Q1** — is `typr-def.toml` the right filename, or should it be folded into + something reused elsewhere? This RFC takes the name as settled for the + purpose of shipping J2; revisiting it later is a rename, not a redesign. +- **Q2** — do third-party definitions get to ship R at all (shims, + `extern r#"…"#`)? This RFC allows it behind the `[capabilities]` gate, but + whether entire categories of package (those needing S3/S4 shims) are + reachable *without* shims is open, and may push some of that need into the + standard library instead of third-party definitions. +- **Q4** — a definition's own `type X <- Foreign;` declarations leak into + the consuming project's namespace; this RFC does not specify a namespacing + rule for that, and two definitions declaring the same type name is + unresolved. +- **Q5** — can a definition be type-checked (in the definition repository's + own CI, or when a consuming project resolves it) when its package is not + installed locally? This determines whether definition repositories, and + projects that pin them, need R + the package installed just to run `typr + check`. Left open for J2; matters more once J4 (registry CI) exists. + +## Future possibilities + +Explicitly out of scope here, deferred to `registry.md`'s later milestones: + +- **J3 — the registry itself**: a hosted index (`typr-lang/registry`) that + lets `typr add`/`typr types update` *discover* a definition instead of + requiring an explicit `typr types add `, plus `typr search`. Nothing + in this RFC needs to change for that to land — J3 only adds a lookup step + before the resolution flow specified here, and the conflict order in + `registry.md` §8.3 (explicit pin, then official, then community by tier, + then locally generated, then nothing) already anticipates it. +- **J4 — registry CI**: mechanical validation (exports exist, arity matches + `formals()`, no unconstrained `...` at `T1`) and drift re-detection over + time, run centrally instead of per-consumer. +- **J6 — the Store**: a web UI over the registry. Explicitly optional per + `registry.md` D6, and needs nothing from this RFC beyond the manifest and + lockfile shapes already specified. +- Typing S4/R6/S7 by anything more precise than `Foreign` is its own RFC, + out of scope here as it is in `registry.md` §15. + +## Implementation checklist + + + +- [ ] `typr-def.toml` manifest parsing + `format_version` gate +- [ ] `since`/`until` added to `FunctionMeta` (`stdlib_meta.rs`) and to the + manifest +- [ ] external `.ty` loading merged into `standard_library.rs`'s context build, + keyed by resolved `typr.lock` entries +- [ ] `trust` threshold + degrade-to-`UnknownFunction` at merge time +- [ ] `typr types add|update|list|vendor`, `typr.lock` read/write, digest + verification, `~/.cache/typr/types///` +- [ ] `cases/`: missing definition, tier below `trust`, version below `since`, + version above `until`, unreachable repository, digest mismatch — each + asserting *no hard error*, only degradation +- [ ] `[capabilities]` gate enforced at fetch time (reject undeclared R; + confirm-or-`--allow-r` for declared) +- [ ] `syntaxe.md` — no lexeme changes expected, but confirm before merge +- [ ] Documentation PR on `we-data-ch/typr.github.io` (a How-to page for + consuming an external definition; a Reference page for the manifest and + `#!` keys), landing in the same release +- [ ] `Implemented in:` filled in above From c57588e05fe3f3ee5c01d7b26f97924d31678a01 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 14:55:07 +0200 Subject: [PATCH 05/52] rfc-0000: fill in the PR number now that #31 is open Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- rfcs/0000-external-type-definitions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/0000-external-type-definitions.md b/rfcs/0000-external-type-definitions.md index 98d2da0..9ee5a5d 100644 --- a/rfcs/0000-external-type-definitions.md +++ b/rfcs/0000-external-type-definitions.md @@ -1,5 +1,5 @@ - **Status:** draft -- **RFC PR:** we-data-ch/typr#0000 +- **RFC PR:** we-data-ch/typr#31 - **Tracking issue:** — - **Implemented in:** not yet - **Start date:** 2026-09-12 From f36a5bf8ec7135c1e8ea8865485981eef31c8f6a Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 15:05:25 +0200 Subject: [PATCH 06/52] update --- cases/0065-reassign-widen-literal/case.toml | 7 + cases/0065-reassign-widen-literal/expect.md | 36 ++ cases/0065-reassign-widen-literal/expect.toml | 11 + .../0065-reassign-widen-literal/observed.txt | 41 +++ .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 10 + crates/typr-cli/configs/src/introspect_pkg.R | 18 + crates/typr-cli/src/cases.rs | 10 +- crates/typr-cli/src/cli.rs | 13 + crates/typr-cli/src/gen_types.rs | 327 ++++++++++++++++++ crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/main.rs | 1 + crates/typr-cli/src/r_name_cache.rs | 5 +- .../src/processes/type_checking/mod.rs | 24 +- rfcs/0028-calling-untyped-r-functions.md | 11 +- 17 files changed, 510 insertions(+), 11 deletions(-) create mode 100644 cases/0065-reassign-widen-literal/case.toml create mode 100644 cases/0065-reassign-widen-literal/expect.md create mode 100644 cases/0065-reassign-widen-literal/expect.toml create mode 100644 cases/0065-reassign-widen-literal/observed.txt create mode 100644 cases/0065-reassign-widen-literal/repro/DESCRIPTION create mode 100644 cases/0065-reassign-widen-literal/repro/NAMESPACE create mode 100644 cases/0065-reassign-widen-literal/repro/R/.gitkeep create mode 100644 cases/0065-reassign-widen-literal/repro/TypR/main.ty create mode 100644 crates/typr-cli/src/gen_types.rs diff --git a/cases/0065-reassign-widen-literal/case.toml b/cases/0065-reassign-widen-literal/case.toml new file mode 100644 index 0000000..2e382bd --- /dev/null +++ b/cases/0065-reassign-widen-literal/case.toml @@ -0,0 +1,7 @@ +title = "reassignment narrows a mutable variable to the RHS literal's singleton type" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-12" +origin = "perso" +checked = false diff --git a/cases/0065-reassign-widen-literal/expect.md b/cases/0065-reassign-widen-literal/expect.md new file mode 100644 index 0000000..dc2ac7f --- /dev/null +++ b/cases/0065-reassign-widen-literal/expect.md @@ -0,0 +1,36 @@ +# reassign-widen-literal + +Source: found while updating `docs/reference/bindings-mutation.md` on `typr.github.io` — its +"Reassignment & mutation" example (`let x <- 0; x <- 10; x <- x + 1; x |> f() |> g()!;`) no +longer type-checks. + +## Ce qui devrait se passer + +Reassigning an already-bound `let`-declared variable (`x <- 10;`, no `let`) to a new value of the +same base kind should keep working across any number of reassignments — that's the entire point +of `docs/reference/bindings-mutation.md`'s "Reassignment & mutation" section and of the `expr!;` +implicit-mutation sugar (`ai_context/in_place.md`), which desugars to exactly this form +(`lhs <- expr`). + +## Anomalies + +`observed.txt` shows three failures for one linear sequence of reassignments to a single `int` +variable: + +1. `let x <- 0;` (no annotation) infers `x` as the *singleton* literal type `Integer(Val(0))` — + confirmed deliberate elsewhere (`test_let_expression0`, + `crates/typr-core/src/processes/type_checking/let_expression.rs`: `let a <- 5;` types `a` as + `integer_type(5)`). Reassigning to a *different* literal (`x <- 10;`) then fails the subtype + check outright: "type 0 doesn't match type 10". +2. Even past that (e.g. with an explicit `let x: int <- 0;` instead), the *first* successful + assignment rebinds `x`'s type in context to the literal RHS type (`Integer(Val(10))`) rather + than keeping it widened — so the *next* reassignment fails just the same: + "type 10 doesn't match type int" — `x`'s type keeps narrowing one literal at a time instead + of staying at its base kind. +3. The `!;` implicit-mutation sugar (`x |> f() |> g()!;`) desugars to the same `Assign` node + (`ai_context/in_place.md` §5.2), so it inherits the same failure once `x` has narrowed. + +Root cause: `Lang::Assign`'s typing arm (`processes/type_checking/mod.rs`) stores the RHS's raw +type as the variable's new type on every successful assignment, instead of widening a literal +singleton to its base kind the way `Type::generalize()` already does elsewhere (e.g. +`cases/0017-char-if-widening`). diff --git a/cases/0065-reassign-widen-literal/expect.toml b/cases/0065-reassign-widen-literal/expect.toml new file mode 100644 index 0000000..aedb1f8 --- /dev/null +++ b/cases/0065-reassign-widen-literal/expect.toml @@ -0,0 +1,11 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +[[rule]] +file = "@run" +must_not_contain = "doesn't match type" diff --git a/cases/0065-reassign-widen-literal/observed.txt b/cases/0065-reassign-widen-literal/observed.txt new file mode 100644 index 0000000..9052711 --- /dev/null +++ b/cases/0065-reassign-widen-literal/observed.txt @@ -0,0 +1,41 @@ +# exit=1 + +## stdout/stderr + + Parsing... done (7 ms) + Type checking... failed +Type errors found: + × Type error: type 0 doesn't match type 10 + ╭─[TypR/main.ty:5:2] + 4 │ + 5 │ x <- 10; + · ▲ ┬ + · │ ╰── Received 10 + · ╰── Expected 0 + 6 │ x <- x + 1; + ╰──── + + × Type error: type 0 doesn't match type int + ╭─[TypR/main.ty:6:2] + 5 │ x <- 10; + 6 │ x <- x + 1; + · ▲ ┬ + · │ ╰── Received int + · ╰── Expected 0 + 7 │ + ╰──── + + × Type error: type 0 doesn't match type int + ╭─[TypR/main.ty:8:14] + 7 │ + 8 │ x |> f() |> g()!; + · ┬ ▲ + · │ ╰── Expected 0 + · ╰── Received int + 9 │ + ╰──── + + + +## R généré + diff --git a/cases/0065-reassign-widen-literal/repro/DESCRIPTION b/cases/0065-reassign-widen-literal/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0065-reassign-widen-literal/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0065-reassign-widen-literal/repro/NAMESPACE b/cases/0065-reassign-widen-literal/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0065-reassign-widen-literal/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0065-reassign-widen-literal/repro/R/.gitkeep b/cases/0065-reassign-widen-literal/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0065-reassign-widen-literal/repro/TypR/main.ty b/cases/0065-reassign-widen-literal/repro/TypR/main.ty new file mode 100644 index 0000000..47111a6 --- /dev/null +++ b/cases/0065-reassign-widen-literal/repro/TypR/main.ty @@ -0,0 +1,10 @@ +let x <- 0; +let f <- fn(a: int): int { a + 1 }; +let g <- fn(a: int): int { a * 2 }; + +x <- 10; +x <- x + 1; + +x |> f() |> g()!; + +print(x); diff --git a/crates/typr-cli/configs/src/introspect_pkg.R b/crates/typr-cli/configs/src/introspect_pkg.R index a7f795f..ab1ecce 100644 --- a/crates/typr-cli/configs/src/introspect_pkg.R +++ b/crates/typr-cli/configs/src/introspect_pkg.R @@ -18,7 +18,9 @@ # machine during a build and must not require any package to be installed. # # Vr_version +# Ppkgversion (package version, if determinable) # Nnamepkgs3_generics4_generichas_default +# Fnamehas_dotsparam1param2... (formals(), for `typr gen-types`) # Cs4_class_name # Epkgreason (package could not be loaded) @@ -60,6 +62,9 @@ for (pkg in args) { next } + ver <- tryCatch(as.character(utils::packageVersion(pkg)), error = function(e) NA_character_) + if (!is.na(ver) && tsv_safe(ver)) emit("P", pkg, ver) + exports <- tryCatch(getNamespaceExports(ns), error = function(e) character(0)) # S4 generics visible now that the namespace is loaded. `getGenerics()` is @@ -89,6 +94,19 @@ for (pkg in args) { if (n %in% s4_names) "1" else "0", if (has_default) "1" else "0" ) + + # formals() — arity and parameter names, for `typr gen-types` (registry.md + # §6). Primitives report `formals()` as NULL; `args(val)` recovers theirs. + formals_val <- tryCatch(formals(val), error = function(e) NULL) + if (is.null(formals_val)) { + formals_val <- tryCatch(formals(args(val)), error = function(e) NULL) + } + if (!is.null(formals_val)) { + param_names <- names(formals_val) + has_dots <- "..." %in% param_names + params <- Filter(tsv_safe, param_names[param_names != "..."]) + emit("F", n, if (has_dots) "1" else "0", params) + } } # NOTE: `getClasses()` reads its caller's environment when `where` is left to diff --git a/crates/typr-cli/src/cases.rs b/crates/typr-cli/src/cases.rs index d9730d0..c2eeb34 100644 --- a/crates/typr-cli/src/cases.rs +++ b/crates/typr-cli/src/cases.rs @@ -453,7 +453,15 @@ pub fn add(slug: &str, from: Option, cmd: &str, layer: &str) { if !cases.exists() { let _ = std::fs::create_dir_all(&cases); } - let n = case_dirs().len() + 1; + // Next id from the highest existing numeric prefix, not the directory + // count: cases/ has gaps (deleted/renumbered entries), so `len() + 1` + // collides with an already-used id the moment count < max + 1. + let n = case_dirs() + .iter() + .filter_map(|p| basename(p).split('-').next().and_then(|s| s.parse::().ok())) + .max() + .unwrap_or(0) + + 1; let id = format!("{:04}", n); let dir = cases.join(format!("{id}-{slug}")); if let Err(e) = std::fs::create_dir_all(&dir) { diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index f949759..3ac26e6 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -173,6 +173,17 @@ enum Commands { #[arg(long, short, value_name = "FILE")] output: Option, }, + /// Generate a `.ty` type definition for an *installed* R package by + /// introspecting its exports (arity, argument names, presence of `...`), + /// entirely at `#! tier: T3` — see `typR/registry.md` §6. Never fails a + /// build: a generated definition types every parameter and return value + /// as `Any`. + GenTypes { + package: String, + /// Directory to write `.generated.ty` into (default: `ty/`). + #[arg(long, short, value_name = "DIR")] + out: Option, + }, } #[derive(Subcommand, Debug)] @@ -304,6 +315,7 @@ fn skips_r_deps_check(command: &Option) -> bool { | Some(Commands::Std { .. }) | Some(Commands::Cache { .. }) | Some(Commands::Syntax { .. }) + | Some(Commands::GenTypes { .. }) ) } @@ -430,6 +442,7 @@ pub fn start() { check, }) => run_syntax_command(json, target, output, write, check), Some(Commands::Spg { output }) => generate_spg(output), + Some(Commands::GenTypes { package, out }) => crate::gen_types::run(&package, out), _ => { println!("Please specify a subcommand or file to execute"); std::process::exit(1); diff --git a/crates/typr-cli/src/gen_types.rs b/crates/typr-cli/src/gen_types.rs new file mode 100644 index 0000000..f4304d5 --- /dev/null +++ b/crates/typr-cli/src/gen_types.rs @@ -0,0 +1,327 @@ +//! `typr gen-types ` — J1 of the type registry proposal (see +//! `typR/registry.md` §6, "Génération automatique — le chemin principal"). +//! +//! Nobody will hand-write signatures for a 400-function package like `shiny`. +//! This command introspects an *installed* R package (arity, argument names, +//! presence of `...`, version) and emits a `.ty` file where every entry is +//! `#! tier: T3`: every parameter and the return type are `Any`, so the +//! generated definition cannot make the type-checker reject correct code +//! (registry.md D2) — it is immediately useful to the LSP and MCP (argument +//! names, doc scaffolding) while being a no-op for soundness. Promotion to +//! T2/T1 is a manual, per-entry follow-up (registry.md §6, "Promotion"). +//! +//! Reuses `r_name_cache`'s `introspect_pkg.R`: that script's `F`/`P` lines +//! (formals, package version) exist for this command; its `N`/`V`/`C`/`E` +//! lines are what the R-name cache itself consumes. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::r_name_cache::INTROSPECT_R; + +/// One exported function, as recovered from `formals()`. +pub struct GeneratedFn { + pub name: String, + pub has_dots: bool, + pub params: Vec, +} + +pub struct Introspection { + pub r_version: String, + pub pkg_version: Option, + pub functions: Vec, + /// Human-readable notes about packages that failed to load. + pub errors: Vec, +} + +/// Whether a name can appear in `@importFrom pkg ;` and as a plain +/// (optionally backtick-quoted) `@name: ...;` signature — i.e. it is not an +/// operator or other non-identifier export (`%>%`, `[.foo`, `+.money`, ...), +/// which `gen-types` has no safe syntax to emit and simply skips. +fn is_generatable_name(name: &str) -> bool { + !name.is_empty() + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_') + && name.chars().any(|c| c.is_ascii_alphabetic()) +} + +pub fn rscript_available() -> bool { + Command::new("Rscript") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Spawn `Rscript introspect_pkg.R ` and return its stdout, exactly like +/// `r_name_cache::run_introspection` but for a single package at a time. +fn run_introspection(pkg: &str) -> Result { + let script = std::env::temp_dir().join(format!("typr_gen_types_{}.R", std::process::id())); + fs::write(&script, INTROSPECT_R).map_err(|e| format!("cannot write introspection script: {e}"))?; + let result = Command::new("Rscript").arg(&script).arg(pkg).output(); + let _ = fs::remove_file(&script); + let output = result.map_err(|e| format!("Rscript could not be run ({e})"))?; + if !output.status.success() { + return Err(format!( + "Rscript exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Introspect `pkg` (must be installed locally) and collect its exported +/// functions' arity and parameter names. +pub fn introspect(pkg: &str) -> Result { + let raw = run_introspection(pkg)?; + + let mut r_version = String::new(); + let mut pkg_version = None; + let mut functions = Vec::new(); + let mut errors = Vec::new(); + + for line in raw.lines() { + let mut fields = line.split('\t'); + match fields.next() { + Some("V") => r_version = fields.next().unwrap_or_default().to_string(), + Some("P") => { + let _pkg = fields.next(); + pkg_version = fields.next().map(str::to_string); + } + Some("F") => { + let (Some(name), Some(has_dots)) = (fields.next(), fields.next()) else { + continue; + }; + if !is_generatable_name(name) { + continue; + } + functions.push(GeneratedFn { + name: name.to_string(), + has_dots: has_dots == "1", + params: fields.map(str::to_string).collect(), + }); + } + Some("E") => { + if let Some(pkg) = fields.next() { + let reason = fields.next().unwrap_or("unknown reason"); + errors.push(format!("{pkg}: {reason}")); + } + } + _ => {} + } + } + + Ok(Introspection { + r_version, + pkg_version, + functions, + errors, + }) +} + +/// Render an `@name: (...) -> Any;` signature name, backtick-quoting it when +/// it contains a `.` — the convention already used throughout +/// `configs/std/*.ty` for names like `` `is.numeric` `` (a bare `.` inside a +/// TypR identifier would otherwise parse as field access). +fn signature_name(name: &str) -> String { + if name.contains('.') { + format!("`{name}`") + } else { + name.to_string() + } +} + +/// Emit the `.ty` source for every generatable function of `pkg`, all at +/// `#! tier: T3` (registry.md §5.4 / §6). +pub fn emit_ty(pkg: &str, info: &Introspection) -> String { + let mut out = String::new(); + let version = info.pkg_version.as_deref().unwrap_or("unknown"); + let generated_from = format!("{pkg} {version}, R {}", info.r_version); + + if info.functions.is_empty() { + return out; + } + + let names: Vec<&str> = info.functions.iter().map(|f| f.name.as_str()).collect(); + out.push_str(&format!("@importFrom {pkg} {};\n\n", names.join(" "))); + + for f in &info.functions { + out.push_str("#! pkg: "); + out.push_str(pkg); + out.push('\n'); + out.push_str("#! tier: T3\n"); + out.push_str(&format!("#! generated-from: {generated_from}\n")); + for p in &f.params { + out.push_str(&format!("#! param {p}:\n")); + } + if f.has_dots { + out.push_str("#! param ...:\n"); + } + + let mut arg_types: Vec<&str> = vec!["Any"; f.params.len()]; + if f.has_dots { + arg_types.push("...Any"); + } + out.push_str(&format!( + "@{}: ({}) -> Any;\n\n", + signature_name(&f.name), + arg_types.join(", ") + )); + } + + out +} + +/// `typr gen-types [--out DIR]` — see registry.md §6. +pub fn run(pkg: &str, out_dir: Option) { + if !rscript_available() { + eprintln!("error: `Rscript` is not on PATH — `typr gen-types` needs R to introspect `{pkg}`."); + std::process::exit(1); + } + + let info = match introspect(pkg) { + Ok(info) => info, + Err(e) => { + eprintln!("error: could not introspect `{pkg}`: {e}"); + std::process::exit(1); + } + }; + + for note in &info.errors { + eprintln!("warning: {note}"); + } + if info.functions.is_empty() { + eprintln!( + "error: `{pkg}` introspected but exports nothing gen-types can generate a signature for \ + (not installed, or every export is an operator/non-identifier form)." + ); + std::process::exit(1); + } + + let content = emit_ty(pkg, &info); + + let dir: PathBuf = out_dir.unwrap_or_else(|| Path::new("ty").to_path_buf()); + if let Err(e) = fs::create_dir_all(&dir) { + eprintln!("error: could not create `{}`: {e}", dir.display()); + std::process::exit(1); + } + let path = dir.join(format!("{pkg}.generated.ty")); + if let Err(e) = fs::write(&path, &content) { + eprintln!("error: could not write `{}`: {e}", path.display()); + std::process::exit(1); + } + + println!( + "{} — {} function(s), all T3 (generated, unverified) → {}", + pkg, + info.functions.len(), + path.display() + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Introspection { + Introspection { + r_version: "4.5.2".to_string(), + pkg_version: Some("1.1.4".to_string()), + functions: vec![ + GeneratedFn { + name: "filter".to_string(), + has_dots: true, + params: vec![".data".to_string()], + }, + GeneratedFn { + name: "is.numeric".to_string(), + has_dots: false, + params: vec!["x".to_string()], + }, + ], + errors: vec![], + } + } + + #[test] + fn skips_operator_and_non_identifier_exports() { + assert!(is_generatable_name("filter")); + assert!(is_generatable_name("is.numeric")); + assert!(!is_generatable_name("%>%")); + assert!(!is_generatable_name("[.foo")); + assert!(!is_generatable_name("+.money")); + assert!(!is_generatable_name("")); + assert!(!is_generatable_name("...")); + } + + #[test] + fn quotes_dotted_signature_names_only() { + assert_eq!(signature_name("filter"), "filter"); + assert_eq!(signature_name("is.numeric"), "`is.numeric`"); + } + + #[test] + fn emits_import_from_and_variadic_signature() { + let ty = emit_ty("dplyr", &sample()); + + assert!(ty.starts_with("@importFrom dplyr filter is.numeric;\n")); + assert!(ty.contains("#! tier: T3\n")); + assert!(ty.contains("#! generated-from: dplyr 1.1.4, R 4.5.2\n")); + assert!(ty.contains("#! param .data:\n")); + assert!(ty.contains("#! param ...:\n")); + assert!(ty.contains("@filter: (Any, ...Any) -> Any;\n")); + assert!(ty.contains("@`is.numeric`: (Any) -> Any;\n")); + } + + #[test] + fn empty_introspection_emits_nothing() { + let info = Introspection { + r_version: "4.5.2".to_string(), + pkg_version: None, + functions: vec![], + errors: vec![], + }; + assert!(emit_ty("empty", &info).is_empty()); + } + + /// Real end-to-end check against packages actually installed on this + /// machine (registry.md J1: "tests sur 3 packages contrastés"). Fails + /// open — like every other Rscript-dependent test in this crate — since + /// CI's `test` job runs with no R and none of these packages installed. + #[test] + fn generated_ty_type_checks_for_contrasting_packages() { + if !rscript_available() { + eprintln!("skipping: Rscript not on PATH"); + return; + } + for pkg in ["jsonlite", "httr2", "dplyr"] { + let info = match introspect(pkg) { + Ok(info) if !info.functions.is_empty() => info, + _ => { + eprintln!("skipping {pkg}: not installed on this machine"); + continue; + } + }; + let ty = emit_ty(pkg, &info); + assert!(!ty.is_empty(), "{pkg}: generated nothing"); + + let dir = std::env::temp_dir().join(format!("typr_gen_types_test_{pkg}_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + crate::engine::write_std_for_type_checking(&dir); + let file = dir.join(format!("{pkg}.generated.ty")); + fs::write(&file, &ty).unwrap(); + + let context = typr_core::components::context::Context::default() + .set_environment(typr_core::components::context::config::Environment::Project); + let (lang, syntax_errors) = crate::engine::parse_code(&file, context.get_environment()); + assert!(syntax_errors.is_empty(), "{pkg}: syntax errors: {syntax_errors:?}"); + let type_checker = + typr_core::processes::type_checking::type_checker::TypeChecker::new(context).typing_no_panic(&lang); + assert!(!type_checker.has_errors(), "{pkg}: generated .ty failed to type-check"); + + let _ = fs::remove_dir_all(&dir); + } + } +} diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index 009c363..9d9536e 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -44,6 +44,7 @@ pub mod cases; pub mod cli; pub mod engine; pub mod fuzz; +pub mod gen_types; pub mod io; pub mod md_renderer; pub mod metaprogramming; diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index 9442ff8..36b5485 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -7,6 +7,7 @@ mod cases; mod cli; mod engine; mod fuzz; +mod gen_types; mod io; mod md_renderer; mod metaprogramming; diff --git a/crates/typr-cli/src/r_name_cache.rs b/crates/typr-cli/src/r_name_cache.rs index 85fb65a..c34d928 100644 --- a/crates/typr-cli/src/r_name_cache.rs +++ b/crates/typr-cli/src/r_name_cache.rs @@ -39,7 +39,10 @@ use crate::cache::CACHE_DIR; /// The committed base-R table, used to seed a fresh cache. const SEED_JSON: &str = include_str!("../configs/src/r_name_db.json"); /// The introspection script run against packages the seed does not cover. -const INTROSPECT_R: &str = include_str!("../configs/src/introspect_pkg.R"); +/// Also the backbone of `typr gen-types` (see `crate::gen_types`), which reads +/// this same script's `F`/`P` lines (formals, package version) alongside the +/// `N`/`V`/`C`/`E` lines this cache consumes. +pub(crate) const INTROSPECT_R: &str = include_str!("../configs/src/introspect_pkg.R"); pub const CACHE_FILE: &str = "r_names.json"; diff --git a/crates/typr-core/src/processes/type_checking/mod.rs b/crates/typr-core/src/processes/type_checking/mod.rs index 8878259..2daebaa 100644 --- a/crates/typr-core/src/processes/type_checking/mod.rs +++ b/crates/typr-core/src/processes/type_checking/mod.rs @@ -354,15 +354,29 @@ pub fn eval(context: &Context, expr: &Lang) -> TypeContext { let reduced_left_type = reduce_type(context, &left_type); let reduced_right_type = reduce_type(context, &right_type); - if reduced_right_type.is_subtype(&reduced_left_type, context).0 { - let Some(var) = Var::from_language((**left_expr).clone()).map(|v| v.set_type(right_type.clone())) + // A reassignment must not pin the variable to the exact literal just + // assigned (`x <- 10;` after unannotated `let x <- 0;` infers `x` as + // the singleton `0`), and must not keep narrowing it one literal at a + // time on every later assignment (`x <- 10;` then `x <- x + 1;`, + // cases/0065-reassign-widen-literal). Widen a literal singleton to its + // base kind before both the compatibility check and the type stored + // back into context — `Type::generalize()`, the same widening + // cases/0017-char-if-widening uses for `if` branches — leaving + // non-literal types (aliases, records, functions, unions...) + // untouched, so occurrence narrowing onto those still works. + let widened_left_type = reduced_left_type.clone().generalize(); + let widened_right_type = right_type.clone().generalize(); + + if reduced_right_type.is_subtype(&widened_left_type, context).0 { + let Some(var) = + Var::from_language((**left_expr).clone()).map(|v| v.set_type(widened_right_type.clone())) else { - return TypeContext::new(right_type, expr.clone(), context.clone()).with_errors(errors); + return TypeContext::new(widened_right_type, expr.clone(), context.clone()).with_errors(errors); }; TypeContext::new( - right_type.clone(), + widened_right_type.clone(), expr.clone(), - context.clone().push_var_type(var, right_type, context), + context.clone().push_var_type(var, widened_right_type, context), ) .with_errors(errors) } else { diff --git a/rfcs/0028-calling-untyped-r-functions.md b/rfcs/0028-calling-untyped-r-functions.md index e3ee1c3..3a06b6d 100644 --- a/rfcs/0028-calling-untyped-r-functions.md +++ b/rfcs/0028-calling-untyped-r-functions.md @@ -255,8 +255,11 @@ of scope here. - [x] `cases/` entry: arity error message (`cases/0063-untyped-r-function-arity-error`) - [x] `cases/` entry: preloaded builtin called with arguments (`Position(1, 2)`) (`cases/0064-untyped-preloaded-builtin-variadic`) -- [ ] `syntaxe.md` §12 updated in both copies -- [ ] `docs/philosophy/intro.md` and `docs/reference/escape-hatches.md` updated +- [x] `syntaxe.md` §12 updated (`typr.github.io/syntaxe.md`; no second copy + exists under `typr/` yet) +- [x] `docs/philosophy/intro.md` and `docs/reference/escape-hatches.md` updated on `typr.github.io`; the `noplayground` marker on the philosophy block - removed (see `doc_correction.md` §C in that repository) -- [ ] `Implemented in:` filled in above + removed — both blocks verified with `typr check` (0.5.12+d85f8d9) and + `npm run check:examples` +- [ ] `Implemented in:` filled in above — pending a release past 0.5.12; the + implementing commit (`d85f8d9`) is on `develop`, not yet tagged From 260d5aa55f9699b5d7b70beb1857ee67fb5d7911 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 15:18:57 +0200 Subject: [PATCH 07/52] rfc-0031: accept External type definitions (J2) Status: draft -> accepted, file renamed to its assigned PR number (0031) per rfcs/README.md's process. Tracking issue to follow in a separate commit once opened. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- ...al-type-definitions.md => 0031-external-type-definitions.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename rfcs/{0000-external-type-definitions.md => 0031-external-type-definitions.md} (99%) diff --git a/rfcs/0000-external-type-definitions.md b/rfcs/0031-external-type-definitions.md similarity index 99% rename from rfcs/0000-external-type-definitions.md rename to rfcs/0031-external-type-definitions.md index 9ee5a5d..98e699c 100644 --- a/rfcs/0000-external-type-definitions.md +++ b/rfcs/0031-external-type-definitions.md @@ -1,4 +1,4 @@ -- **Status:** draft +- **Status:** accepted - **RFC PR:** we-data-ch/typr#31 - **Tracking issue:** — - **Implemented in:** not yet From 60f84af8d7bf9b83058aa0874e0246afe59c6e94 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 15:19:34 +0200 Subject: [PATCH 08/52] rfc-0031: fill in tracking issue #32 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- rfcs/0031-external-type-definitions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/0031-external-type-definitions.md b/rfcs/0031-external-type-definitions.md index 98e699c..5a86288 100644 --- a/rfcs/0031-external-type-definitions.md +++ b/rfcs/0031-external-type-definitions.md @@ -1,6 +1,6 @@ - **Status:** accepted - **RFC PR:** we-data-ch/typr#31 -- **Tracking issue:** — +- **Tracking issue:** we-data-ch/typr#32 - **Implemented in:** not yet - **Start date:** 2026-09-12 From 61cadd36d839fa50dc38d01e71163dd5dba5502f Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 12 Sep 2026 15:27:00 +0200 Subject: [PATCH 09/52] rfc-0031: freeze the typr-def.toml manifest format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First checklist item of rfcs/0031-external-type-definitions.md (J2, we-data-ch/typr#32): fix the manifest's shape as a real type instead of a TOML example in prose, and enforce the format_version gate the RFC requires ("the compiler reads old manifests it recognizes or refuses the ones it doesn't — it never silently misparses one"). - crates/typr-cli/src/type_definition.rs: DefinitionManifest and its sections (package/definition/provider/capabilities), parse_manifest() checking format_version against a raw toml::Value before attempting the typed parse, so an unsupported version is reported as exactly that rather than as a confusing missing-field error. - since/until added to FunctionMeta (typr-core's stdlib_meta.rs) and StdlibMeta (spg/model.rs), parsed identically to `ret`/`coercion` via the existing #! annotation parser — the per-entry override the RFC's reference section describes, kept a lossless mapping between the two structs. Nothing here fetches a repository, resolves typr.lock, or loads a definition into the type checker yet — those remain in #32. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ETPqhgWqDMt7UaUQXdxJmN --- crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/md_renderer.rs | 2 + crates/typr-cli/src/type_definition.rs | 224 ++++++++++++++++++ crates/typr-core/src/processes/spg/model.rs | 12 + .../src/processes/spg/stdlib_meta.rs | 47 ++++ 5 files changed, 286 insertions(+) create mode 100644 crates/typr-cli/src/type_definition.rs diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index 9d9536e..e1d53dc 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -57,6 +57,7 @@ pub mod rd_renderer; pub mod repl; pub mod standard_library; pub mod syntax; +pub mod type_definition; pub mod vignette_renderer; // Re-export commonly used items diff --git a/crates/typr-cli/src/md_renderer.rs b/crates/typr-cli/src/md_renderer.rs index 59ff4a2..2399cc0 100644 --- a/crates/typr-cli/src/md_renderer.rs +++ b/crates/typr-cli/src/md_renderer.rs @@ -216,6 +216,8 @@ mod tests { examples: vec!["abs(-5) # -> 5".to_string()], seealso: vec!["sign".to_string()], pkg: Some(pkg.to_string()), + since: None, + until: None, }); node } diff --git a/crates/typr-cli/src/type_definition.rs b/crates/typr-cli/src/type_definition.rs new file mode 100644 index 0000000..156c70b --- /dev/null +++ b/crates/typr-cli/src/type_definition.rs @@ -0,0 +1,224 @@ +//! `typr-def.toml` — the manifest of an external Type Definition repository. +//! +//! This is the "spec du format figée" item of `typR/registry.md` §13 J2: it +//! fixes the manifest's shape and enforces the `format_version` gate, turning +//! `typr/rfcs/0031-external-type-definitions.md`'s "Definition repository +//! layout and manifest" section into a real type instead of a TOML example in +//! prose. Nothing here fetches a repository, resolves `typr.lock`, or loads a +//! definition into the type checker — those are the RFC's remaining +//! checklist items (registry.md §13 J2: loading into `standard_library.rs`, +//! the `trust` threshold, `typr types add|update|list|vendor`, `cases/`). +//! +//! Example manifest this module parses (RFC §"Definition repository layout +//! and manifest"): +//! +//! ```toml +//! format_version = 1 +//! +//! [package] +//! name = "shiny" +//! since = "1.11.0" +//! # until = "2.0.0" +//! +//! [definition] +//! version = "0.3.0" +//! tier = "T2" +//! +//! [provider] +//! type = "community" +//! repository = "github:alice/typr-shiny" +//! +//! [capabilities] +//! r_shims = false +//! extern_raw = false +//! ``` + +#![allow(dead_code)] + +use serde::Deserialize; + +/// The only `format_version` this build of typr understands. A manifest +/// declaring anything else is refused outright rather than guessed at — see +/// rfcs/0031: "`format_version` is what keeps this survivable across N +/// repositories the project does not control: when the definition format +/// changes, the compiler reads old manifests it recognizes or refuses the +/// ones it doesn't — it never silently misparses one." +pub const CURRENT_FORMAT_VERSION: i64 = 1; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DefinitionManifest { + pub format_version: i64, + pub package: PackageSection, + pub definition: DefinitionSection, + pub provider: ProviderSection, + #[serde(default)] + pub capabilities: CapabilitiesSection, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct PackageSection { + pub name: String, + /// Minimum R package version this definition was written against — a + /// floor, never a closed range (`typR/registry.md` §7.2: `supports = + /// ["1.11.x"]` would depend on the resolving machine and rot unnoticed). + pub since: String, + /// Only set when a break is *known*, never speculative. + #[serde(default)] + pub until: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DefinitionSection { + /// semver of the definition itself, independent of the R package's own + /// version. + pub version: String, + /// Default tier (`T1`/`T2`/`T3`) for entries with no `#! tier:` of their + /// own. Kept as a free-form string rather than an enum, matching + /// `FunctionMeta::tier` — an unrecognized future tier degrades instead of + /// failing the whole manifest to parse. + pub tier: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ProviderType { + Official, + Community, + Generated, + Local, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ProviderSection { + #[serde(rename = "type")] + pub kind: ProviderType, + pub repository: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)] +pub struct CapabilitiesSection { + /// Ships executable R alongside the declarations (an `R/` shim + /// directory)? + #[serde(default)] + pub r_shims: bool, + /// Uses an `extern: (...) -> T r#"...R..."#` verbatim block anywhere? + #[serde(default)] + pub extern_raw: bool, +} + +/// Parse a `typr-def.toml` manifest. +/// +/// Checks `format_version` against a raw TOML value first, before attempting +/// to interpret the rest of the document against today's schema — so an +/// unsupported version is reported as exactly that, not as a confusing +/// "missing field" error from a future schema this build does not know about. +pub fn parse_manifest(source: &str) -> Result { + let raw: toml::Value = source.parse().map_err(|e| format!("not valid TOML: {e}"))?; + let found = raw + .get("format_version") + .ok_or_else(|| "missing required field `format_version`".to_string())? + .as_integer() + .ok_or_else(|| "`format_version` must be an integer".to_string())?; + if found != CURRENT_FORMAT_VERSION { + return Err(format!( + "unsupported format_version = {found} (this build of typr understands \ + format_version = {CURRENT_FORMAT_VERSION}); update typr, or ask this \ + definition's provider to publish one this build supports" + )); + } + toml::from_str(source) + .map_err(|e| format!("manifest does not match format_version {CURRENT_FORMAT_VERSION}: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHINY_MANIFEST: &str = r#" +format_version = 1 + +[package] +name = "shiny" +since = "1.11.0" + +[definition] +version = "0.3.0" +tier = "T2" + +[provider] +type = "community" +repository = "github:alice/typr-shiny" + +[capabilities] +r_shims = false +extern_raw = false +"#; + + #[test] + fn parses_the_rfc_example_manifest() { + let manifest = parse_manifest(SHINY_MANIFEST).unwrap(); + assert_eq!(manifest.format_version, 1); + assert_eq!(manifest.package.name, "shiny"); + assert_eq!(manifest.package.since, "1.11.0"); + assert_eq!(manifest.package.until, None); + assert_eq!(manifest.definition.version, "0.3.0"); + assert_eq!(manifest.definition.tier, "T2"); + assert_eq!(manifest.provider.kind, ProviderType::Community); + assert_eq!(manifest.provider.repository, "github:alice/typr-shiny"); + assert!(!manifest.capabilities.r_shims); + assert!(!manifest.capabilities.extern_raw); + } + + #[test] + fn until_is_optional() { + let manifest = parse_manifest(SHINY_MANIFEST).unwrap(); + assert!(manifest.package.until.is_none()); + + let with_until = SHINY_MANIFEST.replacen( + "since = \"1.11.0\"", + "since = \"1.11.0\"\nuntil = \"2.0.0\"", + 1, + ); + let manifest = parse_manifest(&with_until).unwrap(); + assert_eq!(manifest.package.until.as_deref(), Some("2.0.0")); + } + + #[test] + fn capabilities_default_to_false_when_section_is_absent() { + let without_capabilities = SHINY_MANIFEST + .lines() + .filter(|l| !l.contains("[capabilities]") && !l.contains("r_shims") && !l.contains("extern_raw")) + .collect::>() + .join("\n"); + let manifest = parse_manifest(&without_capabilities).unwrap(); + assert!(!manifest.capabilities.r_shims); + assert!(!manifest.capabilities.extern_raw); + } + + #[test] + fn missing_format_version_is_refused() { + let source = SHINY_MANIFEST.replacen("format_version = 1\n", "", 1); + let err = parse_manifest(&source).unwrap_err(); + assert!(err.contains("format_version"), "unexpected error: {err}"); + } + + #[test] + fn unknown_format_version_is_refused_not_misparsed() { + let source = SHINY_MANIFEST.replacen("format_version = 1", "format_version = 2", 1); + let err = parse_manifest(&source).unwrap_err(); + assert!(err.contains("unsupported format_version = 2"), "unexpected error: {err}"); + assert!(err.contains("format_version = 1"), "unexpected error: {err}"); + } + + #[test] + fn invalid_toml_is_refused() { + let err = parse_manifest("this is not { toml").unwrap_err(); + assert!(err.contains("not valid TOML"), "unexpected error: {err}"); + } + + #[test] + fn invalid_provider_type_is_refused() { + let source = SHINY_MANIFEST.replacen("type = \"community\"", "type = \"unofficial\"", 1); + assert!(parse_manifest(&source).is_err()); + } +} diff --git a/crates/typr-core/src/processes/spg/model.rs b/crates/typr-core/src/processes/spg/model.rs index caff81a..dc0b5bc 100644 --- a/crates/typr-core/src/processes/spg/model.rs +++ b/crates/typr-core/src/processes/spg/model.rs @@ -115,6 +115,14 @@ pub struct StdlibMeta { pub seealso: Vec, /// The R package of origin (e.g. "base", "stats"). pub pkg: Option, + /// Minimum R package version this entry was declared against (a floor, + /// never a closed range — `typR/registry.md` §7.2). External type + /// definitions only (rfcs/0031-external-type-definitions.md); unset for + /// the standard library. + pub since: Option, + /// Only set when a break is *known*, never speculative. Same scope as + /// `since`. + pub until: Option, } impl StdlibMeta { @@ -127,6 +135,8 @@ impl StdlibMeta { examples: Vec::new(), seealso: Vec::new(), pkg: None, + since: None, + until: None, } } @@ -139,6 +149,8 @@ impl StdlibMeta { || !self.examples.is_empty() || !self.seealso.is_empty() || self.pkg.is_some() + || self.since.is_some() + || self.until.is_some() } } diff --git a/crates/typr-core/src/processes/spg/stdlib_meta.rs b/crates/typr-core/src/processes/spg/stdlib_meta.rs index e6d23c9..017cb2f 100644 --- a/crates/typr-core/src/processes/spg/stdlib_meta.rs +++ b/crates/typr-core/src/processes/spg/stdlib_meta.rs @@ -14,6 +14,14 @@ pub struct FunctionMeta { pub coercion_notes: Option, pub examples: Vec, pub seealso: Vec, + /// Minimum R package version this entry was declared against — a floor, + /// never a closed range (`typR/registry.md` §7.2). Meaningful only in an + /// external type definition (`rfcs/0031-external-type-definitions.md`); + /// absent from the standard library. + pub since: Option, + /// Only set when a break is *known*, never speculative. Same scope as + /// `since`. + pub until: Option, } impl FunctionMeta { @@ -27,6 +35,8 @@ impl FunctionMeta { examples: self.examples, seealso: self.seealso, pkg: self.pkg, + since: self.since, + until: self.until, } } @@ -39,6 +49,8 @@ impl FunctionMeta { || !self.examples.is_empty() || !self.seealso.is_empty() || self.pkg.is_some() + || self.since.is_some() + || self.until.is_some() } } @@ -99,6 +111,14 @@ pub fn parse_meta_from_source(source: &str) -> HashMap { let target = pending_meta.get_or_insert_with(FunctionMeta::default); target.ret_doc = Some(strip_leading_colon(value)); } + "since" => { + let target = pending_meta.get_or_insert_with(FunctionMeta::default); + target.since = Some(strip_leading_colon(value)); + } + "until" => { + let target = pending_meta.get_or_insert_with(FunctionMeta::default); + target.until = Some(strip_leading_colon(value)); + } "coercion" | "note" => { let target = pending_meta.get_or_insert_with(FunctionMeta::default); target.coercion_notes = Some(strip_leading_colon(value)); @@ -282,6 +302,33 @@ let x: int <- 5;"; assert!(!map.contains_key("stats::rnorm")); } + #[test] + fn since_and_until_are_parsed() { + let src = "\ +#! pkg: dplyr +#! tier: T3 +#! since: 1.1.0 +#! until: 2.0.0 +@filter: (Any, Any) -> Any;"; + + let map = parse_meta_from_source(src); + let meta = map.get("filter").unwrap(); + assert_eq!(meta.since.as_deref(), Some("1.1.0")); + assert_eq!(meta.until.as_deref(), Some("2.0.0")); + } + + #[test] + fn since_without_until_leaves_until_none() { + let src = "\ +#! since: 1.11.0 +@fluidPage: (Any) -> Any;"; + + let map = parse_meta_from_source(src); + let meta = map.get("fluidPage").unwrap(); + assert_eq!(meta.since.as_deref(), Some("1.11.0")); + assert!(meta.until.is_none()); + } + #[test] fn empty_tier_is_none() { let src = "\ From 02782cb65423a339051c223e66757dcc46a77442 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sun, 13 Sep 2026 22:05:11 +0200 Subject: [PATCH 10/52] update --- Cargo.lock | 74 +- Cargo.toml | 1 + crates/typr-cli/Cargo.toml | 1 + crates/typr-cli/src/cli.rs | 100 ++ crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/main.rs | 2 + crates/typr-cli/src/r_deps.rs | 2 +- crates/typr-cli/src/standard_library.rs | 296 +++++- crates/typr-cli/src/type_registry.rs | 935 ++++++++++++++++++ .../src/components/context/vartype.rs | 95 ++ rfcs/0031-external-type-definitions.md | 9 +- 11 files changed, 1500 insertions(+), 16 deletions(-) create mode 100644 crates/typr-cli/src/type_registry.rs diff --git a/Cargo.lock b/Cargo.lock index 85b8ea5..e5fae38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,6 +151,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -204,7 +213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core", ] @@ -301,6 +310,15 @@ dependencies = [ "num-traits 0.1.43", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -334,6 +352,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.24.1" @@ -391,6 +419,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -576,6 +614,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1387,6 +1435,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1835,6 +1894,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "typr" version = "0.5.12" @@ -1859,6 +1924,7 @@ dependencies = [ "rustyline", "serde", "serde_json", + "sha2", "syntect", "tap", "thiserror", @@ -2001,6 +2067,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index f064cc2..03e089d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ rand = "0.10.0" anyhow = "1.0.100" countmap = "0.2" toml = "0.8" +sha2 = "0.10" clap = { version = "4.5", features = ["derive"] } tokio = { version = "1", features = ["full"] } diff --git a/crates/typr-cli/Cargo.toml b/crates/typr-cli/Cargo.toml index 8ad1741..7e2bb0c 100644 --- a/crates/typr-cli/Cargo.toml +++ b/crates/typr-cli/Cargo.toml @@ -44,3 +44,4 @@ typr-mcp.workspace = true rustyline.workspace = true syntect.workspace = true bincode.workspace = true +sha2.workspace = true diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index 3ac26e6..0f44e68 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -184,6 +184,37 @@ enum Commands { #[arg(long, short, value_name = "DIR")] out: Option, }, + /// Resolve, cache, and vendor external Type Definitions for R packages — + /// see `typR/registry.md` §7 and `rfcs/0031-external-type-definitions.md`. + Types { + #[command(subcommand)] + types_command: TypesCommands, + }, +} + +#[derive(Subcommand, Debug)] +enum TypesCommands { + /// Pin a definition for `` (fetch, verify its manifest and + /// capabilities, cache it by content digest, and record it in + /// `typr.lock`). + Add { + /// The package this definition describes (e.g. `shiny`). + package: String, + /// `github:owner/repo[@rev]`. + repo: String, + }, + /// Re-fetch and re-pin `typr.lock` for one package (or, with none given, + /// every resolved definition). + Update { package: Option }, + /// What's resolved in `typr.lock`, with tier and provenance. + List, + /// Copy every resolved definition's `.ty` files into the project tree + /// (default `ty/vendor//`), so the build stops depending on the + /// network or the upstream repository's continued existence. + Vendor { + #[arg(long, short, value_name = "DIR")] + out: Option, + }, } #[derive(Subcommand, Debug)] @@ -316,6 +347,7 @@ fn skips_r_deps_check(command: &Option) -> bool { | Some(Commands::Cache { .. }) | Some(Commands::Syntax { .. }) | Some(Commands::GenTypes { .. }) + | Some(Commands::Types { .. }) ) } @@ -443,6 +475,7 @@ pub fn start() { }) => run_syntax_command(json, target, output, write, check), Some(Commands::Spg { output }) => generate_spg(output), Some(Commands::GenTypes { package, out }) => crate::gen_types::run(&package, out), + Some(Commands::Types { types_command }) => run_types_command(types_command), _ => { println!("Please specify a subcommand or file to execute"); std::process::exit(1); @@ -553,3 +586,70 @@ fn run_cache_command(command: CacheCommands) { } } } + +/// `typr types ` — see `typR/registry.md` §7 and +/// `rfcs/0031-external-type-definitions.md`. +fn run_types_command(command: TypesCommands) { + use crate::type_registry; + + let root = std::path::Path::new("."); + + match command { + TypesCommands::Add { package, repo } => match type_registry::add(root, &package, &repo) { + Ok(locked) => println!( + "{} — {} {} (tier {}, rev {}) → typr.lock", + locked.package, + locked.repository, + locked.version, + locked.tier, + &locked.rev[..locked.rev.len().min(12)] + ), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }, + TypesCommands::Update { package } => match type_registry::update(root, package.as_deref()) { + Ok(updated) if updated.is_empty() => println!("nothing to update — typr.lock is empty."), + Ok(updated) => { + for locked in updated { + println!( + "{} — {} {} (tier {}, rev {})", + locked.package, + locked.repository, + locked.version, + locked.tier, + &locked.rev[..locked.rev.len().min(12)] + ); + } + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }, + TypesCommands::List => { + let definitions = type_registry::list(root); + if definitions.is_empty() { + println!("nothing resolved — see `typr types add`."); + } + for def in definitions { + println!( + "{:<12} {:<32} {:<10} tier {:<3} rev {}", + def.package, + def.repository, + def.version, + def.tier, + &def.rev[..def.rev.len().min(12)] + ); + } + } + TypesCommands::Vendor { out } => match type_registry::vendor(root, out.as_deref()) { + Ok(written) => println!("vendored {} file(s).", written.len()), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }, + } +} diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index e1d53dc..91c7f89 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -58,6 +58,7 @@ pub mod repl; pub mod standard_library; pub mod syntax; pub mod type_definition; +pub mod type_registry; pub mod vignette_renderer; // Re-export commonly used items diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index 36b5485..8086ce7 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -20,6 +20,8 @@ mod rd_renderer; mod repl; mod standard_library; mod syntax; +mod type_definition; +mod type_registry; mod vignette_renderer; fn main() { diff --git a/crates/typr-cli/src/r_deps.rs b/crates/typr-cli/src/r_deps.rs index b25c377..eb5eac9 100644 --- a/crates/typr-cli/src/r_deps.rs +++ b/crates/typr-cli/src/r_deps.rs @@ -88,7 +88,7 @@ fn now_secs() -> u64 { .unwrap_or(0) } -fn cache_home() -> Option { +pub(crate) fn cache_home() -> Option { for var in ["XDG_CACHE_HOME", "LOCALAPPDATA"] { if let Ok(dir) = std::env::var(var) { if !dir.is_empty() { diff --git a/crates/typr-cli/src/standard_library.rs b/crates/typr-cli/src/standard_library.rs index adcfd4f..9be9f33 100644 --- a/crates/typr-cli/src/standard_library.rs +++ b/crates/typr-cli/src/standard_library.rs @@ -4,6 +4,7 @@ //! and prints the content of the standard library. use std::collections::HashMap; +use std::collections::HashSet; use std::path::PathBuf; use typr_core::components::context::vartype::VarType; use typr_core::components::context::Context; @@ -436,20 +437,26 @@ fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { } } -/// Build a VarType from typed standard library .ty source files. +/// Parse and type-check a sequence of `.ty` sources, threading a *starting* +/// context through them so that later files can reference types from earlier +/// ones (and from whatever `base_context` already carries). Signature lines +/// (`@`) are preprocessed to strip named parameters. /// -/// Parses and type-checks each .ty source file sequentially, threading the -/// context through so that later files can reference types from earlier ones. -/// Signature lines (`@`) are preprocessed to strip named parameters. +/// This is the shared loop behind both `build_typed_vartype` (bundled +/// stdlib, always starts from `Context::empty()`) and +/// `load_external_ty_definitions` (a third-party definition repository, +/// starts from the caller's own context so it can see the stdlib's types). /// -/// Returns the built `VarType` plus the list of `(filename, panic message)` -/// for every source file that was skipped because parsing/type-checking it -/// panicked. A skipped file's signatures are silently absent from the -/// resulting `VarType` — the caller MUST surface this loudly (see -/// `standard_library()`), never let it pass as a quiet informational line, -/// since it means real stdlib entries silently vanished from the compiler. -fn build_typed_vartype(ty_sources: &[(&str, &str)]) -> (VarType, Vec<(String, String)>) { - let mut context = Context::empty(); +/// Returns the resulting `Context` plus the list of `(filename, panic +/// message)` for every source file that was skipped because parsing/ +/// type-checking it panicked. A skipped file's signatures are silently +/// absent from the resulting context — the caller MUST surface this loudly, +/// never let it pass as a quiet informational line. +fn extend_context_with_ty_sources( + base_context: Context, + ty_sources: &[(&str, &str)], +) -> (Context, Vec<(String, String)>) { + let mut context = base_context; let mut skipped: Vec<(String, String)> = Vec::new(); // Silence the default panic hook while probing these files: a skip is an @@ -486,9 +493,131 @@ fn build_typed_vartype(ty_sources: &[(&str, &str)]) -> (VarType, Vec<(String, St std::panic::set_hook(previous_hook); + (context, skipped) +} + +/// Build a VarType from typed standard library .ty source files. +/// +/// Parses and type-checks each .ty source file sequentially, threading the +/// context through so that later files can reference types from earlier ones. +/// +/// Returns the built `VarType` plus the list of `(filename, panic message)` +/// for every source file that was skipped because parsing/type-checking it +/// panicked. A skipped file's signatures are silently absent from the +/// resulting `VarType` — the caller MUST surface this loudly (see +/// `standard_library()`), never let it pass as a quiet informational line, +/// since it means real stdlib entries silently vanished from the compiler. +fn build_typed_vartype(ty_sources: &[(&str, &str)]) -> (VarType, Vec<(String, String)>) { + let (context, skipped) = extend_context_with_ty_sources(Context::empty(), ty_sources); (context.get_vartype(), skipped) } +/// Ranking of the three tiers by trustworthiness, most trusted first (`T1` +/// = 3, `T2` = 2, `T3` = 1). `None` for anything else — including a tier +/// string a manifest or `#! tier:` annotation declares that this build +/// doesn't recognize, per `type_definition.rs::DefinitionSection::tier`'s +/// "an unrecognized future tier degrades instead of failing the whole +/// manifest to parse". +fn tier_rank(tier: &str) -> Option { + match tier { + "T1" => Some(3), + "T2" => Some(2), + "T3" => Some(1), + _ => None, + } +} + +/// Does `entry_tier` meet the project's `trust` threshold? +/// +/// `rfcs/0031-external-type-definitions.md`, "Loading external `.ty` into +/// the context": "entry tier ≥ project trust: loaded with its declared +/// signature […]; entry tier < project trust: loaded as +/// `Type::UnknownFunction`". An entry tier or a project `trust` this build +/// doesn't recognize never meets the threshold — D2 (`typR/registry.md` +/// §0/§5.4) requires an unreliable or unreadable trust signal to widen +/// towards `Any`, never to be silently treated as trusted. +fn meets_trust(entry_tier: &str, trust: &str) -> bool { + match (tier_rank(entry_tier), tier_rank(trust)) { + (Some(entry), Some(required)) => entry >= required, + _ => false, + } +} + +/// Every function name declared across `ty_sources` whose *effective* tier — +/// its own `#! tier:` annotation, falling back to `default_tier` (the +/// manifest's `[definition] tier`, RFC-0031) when absent — falls below +/// `trust`. These are exactly the names `load_external_ty_definitions` +/// degrades to `(Any, UnknownFunction)` after type-checking. +fn names_below_trust(ty_sources: &[(&str, &str)], default_tier: &str, trust: &str) -> HashSet { + let mut below = HashSet::new(); + for (_filename, source) in ty_sources { + let meta_map = parse_meta_from_source(source); + for line in source.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('@') { + continue; + } + let Some(raw_name) = extract_raw_signature_name(trimmed) else { + continue; + }; + let tier = meta_map + .get(&raw_name) + .and_then(|m| m.tier.as_deref()) + .unwrap_or(default_tier); + if !meets_trust(tier, trust) { + below.insert(unwrap_backtick_name(&raw_name)); + } + } + } + below +} + +/// Load externally-provided `.ty` definitions on top of an existing typing +/// context, using the exact same parse/type-check loop that builds the +/// bundled standard library from `R_T1_SOURCES` — so an external definition +/// can reference the stdlib's own types (`Foreign`, etc.) exactly the way +/// `std.ty` itself does — and then degrading every entry whose effective +/// tier falls below `trust` to `Type::UnknownFunction`. +/// +/// This is the "chargement d'un `.ty` externe dans le contexte" + +/// "seuil `trust` + règle de dégradation vers `Any`" items of +/// `typR/registry.md` §13 J2 (`rfcs/0031-external-type-definitions.md`, +/// "Loading external `.ty` into the context"). `ty_sources` is expected to +/// come from a single resolved definition repository, so a single +/// `default_tier` (its manifest's `[definition] tier`) applies to every +/// entry with no `#! tier:` of its own; `trust` is the consuming project's +/// own threshold (`typr.toml [types] trust`, not yet read from disk — the +/// machinery that resolves a `typr.lock` entry into these arguments is the +/// next checklist item). +/// +/// A degraded entry is never dropped or rejected: it is loaded exactly like +/// any other untyped R name (`(Any, UnknownFunction)`), keeping it callable +/// with arity/type checking simply skipped — this is D2 made real +/// (`typR/registry.md` §0/§5.4): a definition the project doesn't trust +/// enough can only make TypR check *less*, never break a build. +/// +/// `base_context` is typically `Context::default()` (or a project's own +/// context built on top of it) — starting from it, rather than +/// `Context::empty()`, is what lets a third-party `.ty` see the bundled +/// stdlib while it is being type-checked. +/// +/// Not yet called from the CLI: nothing resolves a `typr.lock` entry into +/// `(filename, source)` pairs plus a manifest's tier/trust yet (`typr types +/// add`/`typr.lock`, the next checklist item), so this has no caller outside +/// its own tests until then. +#[allow(dead_code)] +pub fn load_external_ty_definitions( + base_context: Context, + ty_sources: &[(&str, &str)], + default_tier: &str, + trust: &str, +) -> (Context, Vec<(String, String)>) { + let (mut context, skipped) = extend_context_with_ty_sources(base_context, ty_sources); + let degraded_names = names_below_trust(ty_sources, default_tier, trust); + context.typing_context = context.typing_context.clone().degrade_to_any(°raded_names); + (context, skipped) +} + /// Build a documentation graph over a set of `.ty` sources. /// /// Shared by `build_stdlib_docs` (production) and the tests, so the @@ -774,6 +903,149 @@ mod tests { assert!(skipped.is_empty()); } + /// `load_external_ty_definitions` is the registry.md §13 J2 "chargement + /// d'un `.ty` externe dans le contexte" mechanism: it must merge new + /// signatures on top of an existing context, exactly like an + /// `R_T1_SOURCES` file merges on top of the ones processed before it. + #[test] + fn load_external_ty_definitions_merges_new_signatures_into_base_context() { + let (base_context, base_skipped) = + extend_context_with_ty_sources(Context::empty(), &[("base.ty", "@base_fn: (int) -> int;")]); + assert!(base_skipped.is_empty()); + + let (context, skipped) = + load_external_ty_definitions(base_context, &[("shiny.generated.ty", "@fluidPage: (Any) -> Any;")], "T2", "T2"); + + assert!(skipped.is_empty()); + let names: Vec = context.get_vartype().variables.iter().map(|(v, _)| v.get_name()).collect(); + assert!( + names.contains(&"base_fn".to_string()), + "base context signature must survive the merge" + ); + assert!( + names.contains(&"fluidPage".to_string()), + "external signature must be loaded" + ); + } + + /// An external definition can reference a type the bundled stdlib itself + /// declares (`Foreign`, `foreign.ty`) — proving the context is + /// threaded through from `base_context`, not type-checked in isolation. + /// This is what lets a third-party `.ty` use `Foreign` the way + /// `std.ty` itself does (RFC-0031, "Loading external `.ty` into the + /// context"). + #[test] + fn load_external_ty_definitions_sees_types_declared_in_base_context() { + let (base_context, base_skipped) = extend_context_with_ty_sources(Context::empty(), &[("foreign.ty", FOREIGN_TY)]); + assert!(base_skipped.is_empty()); + + let (_context, skipped) = load_external_ty_definitions( + base_context, + &[( + "shiny.generated.ty", + "type UiObject <- Foreign;\n@fluidPage: (Any) -> UiObject;", + )], + "T2", + "T2", + ); + + assert!( + skipped.is_empty(), + "external definition referencing a base-context type must type-check: {:?}", + skipped + ); + } + + /// A broken external definition is reported as skipped — same contract as + /// a broken bundled stdlib file — and never corrupts the base context: + /// signatures already resolved before it stay intact. This is the D2 + /// "an unreliable definition widens to `Any`, it never fails the build" + /// principle at its narrowest: at minimum, a bad external file must not + /// take the rest of the project's own types down with it. + #[test] + fn load_external_ty_definitions_skips_a_broken_source_without_losing_the_base_context() { + let (base_context, base_skipped) = + extend_context_with_ty_sources(Context::empty(), &[("base.ty", "@base_fn: (int) -> int;")]); + assert!(base_skipped.is_empty()); + + let (context, skipped) = + load_external_ty_definitions(base_context, &[("broken.ty", "let f <- fn(x) { x };")], "T2", "T2"); + + assert_eq!(skipped.len(), 1); + assert_eq!(skipped[0].0, "broken.ty"); + let names: Vec = context.get_vartype().variables.iter().map(|(v, _)| v.get_name()).collect(); + assert!( + names.contains(&"base_fn".to_string()), + "base context must survive a skipped external source" + ); + } + + /// registry.md §13 J2 "seuil `trust` + règle de dégradation vers `Any`": + /// an entry whose own `#! tier:` is below the project's `trust` loads as + /// `(Any, UnknownFunction)` instead of its declared signature, while an + /// entry at or above `trust` keeps it — same source, same call, only the + /// tier differs. + #[test] + fn entries_below_trust_degrade_to_any_entries_at_or_above_keep_their_signature() { + let source = "\ +#! tier: T3 +@untrusted_fn: (int) -> int; + +#! tier: T1 +@trusted_fn: (int) -> int;"; + + let (context, skipped) = + load_external_ty_definitions(Context::default(), &[("mixed.ty", source)], "T2", "T2"); + assert!(skipped.is_empty()); + + let untrusted_type = context + .get_type_from_variable(&Var::from_name("untrusted_fn")) + .expect("degraded entry must still be present, just untyped"); + assert!( + untrusted_type.is_unknown_function(), + "T3 entry under a T2 trust threshold must degrade to UnknownFunction, got {:?}", + untrusted_type + ); + + let trusted_type = context + .get_type_from_variable(&Var::from_name("trusted_fn")) + .expect("trusted entry must be present"); + assert!( + !trusted_type.is_unknown_function(), + "T1 entry under a T2 trust threshold must keep its declared signature, got {:?}", + trusted_type + ); + } + + /// An entry with no `#! tier:` of its own falls back to the manifest's + /// `[definition] tier` (`default_tier`) — a whole low-tier definition + /// with no per-entry annotations must degrade uniformly. + #[test] + fn entry_with_no_own_tier_falls_back_to_the_manifest_default_tier() { + let source = "@generated_fn: (int) -> int;"; + + let (context, skipped) = + load_external_ty_definitions(Context::default(), &[("generated.ty", source)], "T3", "T2"); + assert!(skipped.is_empty()); + + let typ = context + .get_type_from_variable(&Var::from_name("generated_fn")) + .expect("entry must still be present"); + assert!( + typ.is_unknown_function(), + "an entry with no #! tier must inherit the manifest's T3 default and degrade under T2 trust" + ); + } + + /// An unrecognized tier string — on the entry or on the project's own + /// `trust` setting — must never be silently treated as trusted (D2, + /// `typR/registry.md` §0/§5.4): it always degrades. + #[test] + fn unrecognized_tier_or_trust_never_meets_the_threshold() { + assert!(!meets_trust("T1", "not-a-tier")); + assert!(!meets_trust("not-a-tier", "T3")); + } + /// Phase 1: build_stdlib_docs parses #! annotations from .ty files and /// attaches them as `meta` on function nodes in the SPG. #[test] diff --git a/crates/typr-cli/src/type_registry.rs b/crates/typr-cli/src/type_registry.rs new file mode 100644 index 0000000..04cfd49 --- /dev/null +++ b/crates/typr-cli/src/type_registry.rs @@ -0,0 +1,935 @@ +//! Resolution, `typr.lock`, on-disk cache, and vendoring for external Type +//! Definitions — the `typr types add|update|list|vendor` item of +//! `typR/registry.md` §13 J2 +//! (`rfcs/0031-external-type-definitions.md`, "Resolution, `typr.lock`, +//! cache, vendoring"). +//! +//! What this module does NOT do: load a resolved definition into the +//! type-checking context. That is `standard_library::load_external_ty_definitions`, +//! already implemented and tested — this module is purely the plumbing that +//! turns a `github:owner/repo[@rev]` string into `(filename, source)` pairs on +//! disk plus a `typr.lock` entry recording exactly what was fetched. Wiring +//! the two together (reading `typr.lock` at `check`/`build`/`run` time) is a +//! follow-up, not part of this checklist item. + +use crate::type_definition::{parse_manifest, DefinitionManifest}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub const LOCKFILE_NAME: &str = "typr.lock"; +pub const PROJECT_CONFIG_NAME: &str = "typr.toml"; +const MANIFEST_NAME: &str = "typr-def.toml"; + +// --------------------------------------------------------------------- +// Repository spec +// --------------------------------------------------------------------- + +/// `github:owner/repo[@rev]` — the only repository scheme this build +/// understands (registry.md §6/§7: GitHub is the primary host; a monorepo +/// long tail is J3, not this). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepoSpec { + pub owner: String, + pub repo: String, + /// A pinned commit/branch/tag, when the user gave one after `@`. `None` + /// means "resolve `HEAD`". + pub rev: Option, +} + +impl RepoSpec { + pub fn parse(spec: &str) -> Result { + let rest = spec.strip_prefix("github:").ok_or_else(|| { + format!("unsupported repository scheme in `{spec}` — only `github:owner/repo[@rev]` is understood") + })?; + let (path, rev) = match rest.split_once('@') { + Some((path, rev)) => (path, Some(rev.to_string())), + None => (rest, None), + }; + let (owner, repo) = path + .split_once('/') + .ok_or_else(|| format!("`{spec}` is not `github:owner/repo[@rev]` — missing `owner/repo`"))?; + if owner.is_empty() || repo.is_empty() { + return Err(format!("`{spec}` is not `github:owner/repo[@rev]` — empty owner or repo")); + } + Ok(RepoSpec { + owner: owner.to_string(), + repo: repo.to_string(), + rev, + }) + } + + pub fn clone_url(&self) -> String { + format!("https://github.com/{}/{}.git", self.owner, self.repo) + } + + /// The `repository` field as it is written into `typr.toml`/`typr.lock` — + /// always without the resolved rev, since that lives in `typr.lock`'s own + /// `rev` field (registry.md §7.1: one source of truth per question). + pub fn display(&self) -> String { + format!("github:{}/{}", self.owner, self.repo) + } +} + +// --------------------------------------------------------------------- +// typr.lock +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LockedDefinition { + pub package: String, + pub repository: String, + pub version: String, + pub rev: String, + pub digest: String, + pub tier: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r_version_seen: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Lockfile { + #[serde(default, rename = "definition")] + pub definitions: Vec, +} + +impl Lockfile { + /// A missing or unparsable lockfile is simply "nothing resolved yet" — + /// never a hard error, matching the fail-open convention the rest of the + /// CLI's caches use (`r_name_cache`, `cache::BuildManifest`). + pub fn read(path: &Path) -> Self { + fs::read_to_string(path) + .ok() + .and_then(|s| toml::from_str(&s).ok()) + .unwrap_or_default() + } + + pub fn write(&self, path: &Path) -> Result<(), String> { + let rendered = + toml::to_string_pretty(self).map_err(|e| format!("could not serialize {}: {e}", path.display()))?; + let header = "# typr.lock — generated by `typr types`, commit this file.\n\ + # See typR/registry.md §7.1 and rfcs/0031-external-type-definitions.md.\n\n"; + fs::write(path, format!("{header}{rendered}")).map_err(|e| format!("could not write {}: {e}", path.display())) + } + + pub fn find(&self, package: &str) -> Option<&LockedDefinition> { + self.definitions.iter().find(|d| d.package == package) + } + + /// Replace any existing entry for the same package (a package resolves to + /// exactly one definition, registry.md §8.3) and insert the new one. + pub fn upsert(&mut self, def: LockedDefinition) { + self.definitions.retain(|d| d.package != def.package); + self.definitions.push(def); + } +} + +// --------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------- + +/// `~/.cache/typr/types/` (or `$XDG_CACHE_HOME`/`$LOCALAPPDATA`), mirroring +/// `r_deps::cache_path`'s resolution rules. +pub fn cache_root() -> Option { + crate::r_deps::cache_home().map(|dir| dir.join("typr").join("types")) +} + +/// `~/.cache/typr/types///` — the RFC's +/// `~/.cache/typr/types///` (registry.md §7.4). +pub fn cache_dir_for(pkg: &str, digest: &str) -> Option { + cache_root().map(|root| root.join(pkg).join(digest_dirname(digest))) +} + +fn digest_dirname(digest: &str) -> &str { + digest.strip_prefix("sha256:").unwrap_or(digest) +} + +// --------------------------------------------------------------------- +// Fetch +// --------------------------------------------------------------------- + +/// A definition repository fetched to a local directory, before it is +/// admitted into the cache. Kept separate from `LockedDefinition` because +/// capability-gating (below) happens on these raw files, before anything is +/// written to `typr.lock`. +pub struct FetchedDefinition { + pub manifest: DefinitionManifest, + pub rev: String, + pub digest: String, + /// Directory holding the fetched repository's tracked files (`.git` + /// excluded). + pub dir: PathBuf, +} + +fn git_available() -> bool { + Command::new("git").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) +} + +/// Clone `url` (at `rev` when given, else the default branch's HEAD) into a +/// fresh temp directory and return it plus the resolved commit hash. +/// +/// Shells out to the `git` binary — the same pattern `gen_types.rs` uses for +/// `Rscript` — rather than adding a git-in-process dependency for a command +/// that already needs network access. +fn clone_repo(url: &str, rev: Option<&str>) -> Result<(PathBuf, String), String> { + let dir = std::env::temp_dir().join(format!( + "typr_types_fetch_{}_{}", + std::process::id(), + now_millis() + )); + fs::create_dir_all(&dir).map_err(|e| format!("could not create temp dir: {e}"))?; + + let clone_status = Command::new("git") + .args(["clone", "--quiet"]) + .args(if rev.is_none() { vec!["--depth", "1"] } else { vec![] }) + .arg(url) + .arg(&dir) + .output() + .map_err(|e| format!("could not run `git clone`: {e}"))?; + if !clone_status.status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(format!( + "`git clone {url}` failed: {}", + String::from_utf8_lossy(&clone_status.stderr).trim() + )); + } + + if let Some(rev) = rev { + let checkout = Command::new("git") + .args(["-C"]) + .arg(&dir) + .args(["checkout", "--quiet", rev]) + .output() + .map_err(|e| format!("could not run `git checkout`: {e}"))?; + if !checkout.status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(format!( + "`git checkout {rev}` failed: {}", + String::from_utf8_lossy(&checkout.stderr).trim() + )); + } + } + + let head = Command::new("git") + .args(["-C"]) + .arg(&dir) + .args(["rev-parse", "HEAD"]) + .output() + .map_err(|e| format!("could not run `git rev-parse HEAD`: {e}"))?; + if !head.status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(format!( + "`git rev-parse HEAD` failed: {}", + String::from_utf8_lossy(&head.stderr).trim() + )); + } + let rev = String::from_utf8_lossy(&head.stdout).trim().to_string(); + + let git_dir = dir.join(".git"); + let _ = fs::remove_dir_all(&git_dir); + + Ok((dir, rev)) +} + +fn now_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +/// Every file under `dir` (relative paths, `/`-separated, sorted), skipping +/// `.git` — the exact set of bytes the content digest and the cache/vendor +/// copies are built from. +fn tracked_files(dir: &Path) -> Result, String> { + let mut out = BTreeSet::new(); + collect_files(dir, dir, &mut out)?; + Ok(out.into_iter().collect()) +} + +fn collect_files(root: &Path, current: &Path, out: &mut BTreeSet) -> Result<(), String> { + let entries = fs::read_dir(current).map_err(|e| format!("could not read {}: {e}", current.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("could not read entry in {}: {e}", current.display()))?; + let path = entry.path(); + if path.file_name().and_then(|n| n.to_str()) == Some(".git") { + continue; + } + let file_type = entry.file_type().map_err(|e| format!("could not stat {}: {e}", path.display()))?; + if file_type.is_dir() { + collect_files(root, &path, out)?; + } else if file_type.is_file() { + let rel = path + .strip_prefix(root) + .map_err(|e| format!("could not relativize {}: {e}", path.display()))?; + out.insert(rel.to_path_buf()); + } + } + Ok(()) +} + +/// A deterministic content digest over every tracked file's path and bytes — +/// what `typr.lock`'s `digest` field pins, and what `typr check`/`build`/`run` +/// will re-verify a cached copy against before trusting it (registry.md +/// principle 5). +fn compute_digest(dir: &Path) -> Result { + let files = tracked_files(dir)?; + let mut hasher = Sha256::new(); + for rel in &files { + let content = fs::read(dir.join(rel)).map_err(|e| format!("could not read {}: {e}", rel.display()))?; + hasher.update(rel.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update([0u8]); + hasher.update((content.len() as u64).to_le_bytes()); + hasher.update(&content); + } + Ok(format!("sha256:{:x}", hasher.finalize())) +} + +/// Does `dir` ship an `R/` shim directory with at least one file in it? +fn has_r_shims(dir: &Path) -> bool { + let r_dir = dir.join("R"); + r_dir.is_dir() + && fs::read_dir(&r_dir) + .map(|mut entries| entries.any(|e| e.map(|e| e.path().is_file()).unwrap_or(false))) + .unwrap_or(false) +} + +/// Does any `.ty` file under `dir` use a raw `extern: (...) -> T r#"...R..."#` +/// verbatim block? Detected by the literal `r#"` token, the only place that +/// sequence appears in TypR source (`typR/ai_context/tuto_external_packages.md` +/// §"Niveau 3"). +fn has_extern_raw(dir: &Path) -> Result { + for rel in tracked_files(dir)? { + if rel.extension().and_then(|e| e.to_str()) != Some("ty") { + continue; + } + let content = fs::read_to_string(dir.join(&rel)).unwrap_or_default(); + if content.contains("r#\"") { + return Ok(true); + } + } + Ok(false) +} + +/// Enforce the manifest's `[capabilities]` gate against what was actually +/// fetched (rfcs/0031-external-type-definitions.md, "Capabilities and R +/// shims"): a definition that leaves `r_shims`/`extern_raw` at their default +/// `false` but ships either anyway is rejected outright, before a single +/// `.ty` file is loaded anywhere. A definition that truthfully declares +/// `true` is let through with a warning message for the caller to print. +fn check_capabilities(manifest: &DefinitionManifest, dir: &Path) -> Result, String> { + let mut warnings = Vec::new(); + let ships_r_shims = has_r_shims(dir); + let ships_extern_raw = has_extern_raw(dir)?; + + if ships_r_shims && !manifest.capabilities.r_shims { + return Err( + "rejected: this definition ships an `R/` shim directory but its manifest declares \ + `capabilities.r_shims = false` — undeclared executable R is refused at fetch time \ + (typR/registry.md §5.5)" + .to_string(), + ); + } + if ships_extern_raw && !manifest.capabilities.extern_raw { + return Err( + "rejected: this definition uses a raw `extern: (...) -> T r#\"...R...\"#` block but its \ + manifest declares `capabilities.extern_raw = false` — undeclared executable R is \ + refused at fetch time (typR/registry.md §5.5)" + .to_string(), + ); + } + if manifest.capabilities.r_shims { + warnings.push( + "this definition declares `capabilities.r_shims = true` — it ships executable R that \ + will run in this project's process (typR/registry.md §5.5)." + .to_string(), + ); + } + if manifest.capabilities.extern_raw { + warnings.push( + "this definition declares `capabilities.extern_raw = true` — it uses raw R blocks that \ + will run in this project's process (typR/registry.md §5.5)." + .to_string(), + ); + } + Ok(warnings) +} + +/// Fetch `spec`, validate its manifest and capabilities, and compute its +/// content digest — the shared core of `add` and `update`. Leaves the +/// fetched files at `FetchedDefinition::dir` for the caller to admit into the +/// cache (or discard, on any failure). +pub fn fetch(spec: &RepoSpec) -> Result<(FetchedDefinition, Vec), String> { + if !git_available() { + return Err("`git` is not on PATH — `typr types add`/`update` need it to fetch a definition repository".to_string()); + } + let (dir, rev) = clone_repo(&spec.clone_url(), spec.rev.as_deref())?; + + let result = (|| { + let manifest_path = dir.join(MANIFEST_NAME); + let manifest_source = fs::read_to_string(&manifest_path) + .map_err(|_| format!("no `{MANIFEST_NAME}` at the root of {}", spec.display()))?; + let manifest = parse_manifest(&manifest_source)?; + let warnings = check_capabilities(&manifest, &dir)?; + let digest = compute_digest(&dir)?; + Ok(( + FetchedDefinition { + manifest, + rev, + digest, + dir: dir.clone(), + }, + warnings, + )) + })(); + + if result.is_err() { + let _ = fs::remove_dir_all(&dir); + } + result +} + +/// Copy every tracked file of a fetched definition into the on-disk cache at +/// `~/.cache/typr/types///`, replacing whatever was there before +/// (the digest already identifies the content, so an existing directory with +/// the same digest is assumed identical — a corrupted one is exactly what +/// re-running `typr types add`/`update` is for). +fn admit_to_cache(fetched: &FetchedDefinition, pkg: &str) -> Result { + let cache_dir = cache_dir_for(pkg, &fetched.digest) + .ok_or_else(|| "could not determine a cache directory (no $HOME/$XDG_CACHE_HOME)".to_string())?; + let _ = fs::remove_dir_all(&cache_dir); + fs::create_dir_all(&cache_dir).map_err(|e| format!("could not create {}: {e}", cache_dir.display()))?; + for rel in tracked_files(&fetched.dir)? { + let from = fetched.dir.join(&rel); + let to = cache_dir.join(&rel); + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + fs::copy(&from, &to).map_err(|e| format!("could not copy {} to {}: {e}", from.display(), to.display()))?; + } + Ok(cache_dir) +} + +// --------------------------------------------------------------------- +// typr.toml [types] +// --------------------------------------------------------------------- + +/// The subset of a project's `typr.toml` this module reads: the `[types]` +/// table (registry.md §7.1 — never a dependency list, only trust + +/// package→definition pins). +/// +/// `trust` is read and kept here but not yet consumed anywhere: passing it +/// into `standard_library::load_external_ty_definitions` at `check`/`build`/ +/// `run` time — reading `typr.lock` into the type-checking context — is the +/// next integration step, not part of this `typr types add/update/list/ +/// vendor` checklist item (`typR/registry.md` §13 J2). +#[derive(Debug, Clone, Default)] +pub struct TypesConfig { + #[allow(dead_code)] + pub trust: Option, + /// package name → explicit `github:owner/repo[@rev]` pin. + pub pins: std::collections::BTreeMap, +} + +impl TypesConfig { + pub fn read(project_root: &Path) -> Self { + let Ok(source) = fs::read_to_string(project_root.join(PROJECT_CONFIG_NAME)) else { + return Self::default(); + }; + let Ok(value) = source.parse::() else { + return Self::default(); + }; + let Some(types) = value.get("types").and_then(|t| t.as_table()) else { + return Self::default(); + }; + let trust = types.get("trust").and_then(|v| v.as_str()).map(str::to_string); + let pins = types + .iter() + .filter(|(k, _)| k.as_str() != "trust") + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect(); + TypesConfig { trust, pins } + } +} + +// --------------------------------------------------------------------- +// Commands: add / update / list / vendor +// --------------------------------------------------------------------- + +/// `typr types add ` — resolve `spec` for `package`, fetch it, verify +/// it, cache it, and record it in `typr.lock`. +pub fn add(project_root: &Path, package: &str, spec_str: &str) -> Result { + let spec = RepoSpec::parse(spec_str)?; + let (fetched, warnings) = fetch(&spec)?; + for w in &warnings { + eprintln!("warning: {w}"); + } + admit_to_cache(&fetched, package)?; + + let locked = LockedDefinition { + package: package.to_string(), + repository: spec.display(), + version: fetched.manifest.definition.version.clone(), + rev: fetched.rev.clone(), + digest: fetched.digest.clone(), + tier: fetched.manifest.definition.tier.clone(), + r_version_seen: None, + }; + + let lock_path = project_root.join(LOCKFILE_NAME); + let mut lockfile = Lockfile::read(&lock_path); + lockfile.upsert(locked.clone()); + lockfile.write(&lock_path)?; + + let _ = fs::remove_dir_all(&fetched.dir); + Ok(locked) +} + +/// `typr types update [pkg]` — re-fetch and re-pin one (or, with `package == +/// None`, every) resolved definition. The repository spec comes from the +/// existing `typr.lock` entry (or `typr.toml [types]`'s pin, for a package +/// not yet locked but explicitly pinned). +pub fn update(project_root: &Path, package: Option<&str>) -> Result, String> { + let lock_path = project_root.join(LOCKFILE_NAME); + let lockfile = Lockfile::read(&lock_path); + let config = TypesConfig::read(project_root); + + let targets: Vec<(String, String)> = match package { + Some(pkg) => { + let spec = lockfile + .find(pkg) + .map(|d| d.repository.clone()) + .or_else(|| config.pins.get(pkg).cloned()) + .ok_or_else(|| format!("no resolved or pinned definition for `{pkg}` — use `typr types add` first"))?; + vec![(pkg.to_string(), spec)] + } + None => lockfile + .definitions + .iter() + .map(|d| (d.package.clone(), d.repository.clone())) + .collect(), + }; + + if targets.is_empty() { + return Ok(Vec::new()); + } + + let mut updated = Vec::new(); + for (pkg, spec) in targets { + updated.push(add(project_root, &pkg, &spec)?); + } + Ok(updated) +} + +/// `typr types list` — everything `typr.lock` currently resolves. +pub fn list(project_root: &Path) -> Vec { + Lockfile::read(&project_root.join(LOCKFILE_NAME)).definitions +} + +/// `typr types vendor [--out DIR]` — copy every resolved definition's cached +/// `.ty` files into the project tree (default `ty/vendor//`), so the +/// build stops depending on the network or the upstream repository's +/// continued existence (registry.md §7.4, rfcs/0031 "vendor"). +pub fn vendor(project_root: &Path, out_dir: Option<&Path>) -> Result, String> { + let lockfile = Lockfile::read(&project_root.join(LOCKFILE_NAME)); + if lockfile.definitions.is_empty() { + return Err("nothing resolved yet — `typr types add` a definition before vendoring".to_string()); + } + let base = out_dir + .map(|p| project_root.join(p)) + .unwrap_or_else(|| project_root.join("ty").join("vendor")); + + let mut written = Vec::new(); + for def in &lockfile.definitions { + let cache_dir = cache_dir_for(&def.package, &def.digest) + .ok_or_else(|| "could not determine a cache directory (no $HOME/$XDG_CACHE_HOME)".to_string())?; + if !cache_dir.is_dir() { + return Err(format!( + "`{}` is not in the local cache at {} — run `typr types update {}` first", + def.package, + cache_dir.display(), + def.package + )); + } + let actual_digest = compute_digest(&cache_dir)?; + if actual_digest != def.digest { + return Err(format!( + "digest mismatch for `{}`: typr.lock says {} but the cached copy hashes to {} — \ + the cache is corrupted; run `typr types update {}` to re-fetch", + def.package, def.digest, actual_digest, def.package + )); + } + + let dest = base.join(&def.package); + let _ = fs::remove_dir_all(&dest); + for rel in tracked_files(&cache_dir)? { + let from = cache_dir.join(&rel); + let to = dest.join(&rel); + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + fs::copy(&from, &to).map_err(|e| format!("could not copy {} to {}: {e}", from.display(), to.display()))?; + written.push(to); + } + } + Ok(written) +} + +#[cfg(test)] +mod tests { + use super::*; + + // -- RepoSpec ---------------------------------------------------- + + #[test] + fn parses_owner_repo_without_rev() { + let spec = RepoSpec::parse("github:alice/typr-shiny").unwrap(); + assert_eq!(spec.owner, "alice"); + assert_eq!(spec.repo, "typr-shiny"); + assert_eq!(spec.rev, None); + assert_eq!(spec.clone_url(), "https://github.com/alice/typr-shiny.git"); + assert_eq!(spec.display(), "github:alice/typr-shiny"); + } + + #[test] + fn parses_owner_repo_with_rev() { + let spec = RepoSpec::parse("github:alice/typr-shiny@a1b2c3d").unwrap(); + assert_eq!(spec.rev.as_deref(), Some("a1b2c3d")); + // the rev never leaks into the persisted `repository` string — + // typr.lock's own `rev` field is the one source of truth for it. + assert_eq!(spec.display(), "github:alice/typr-shiny"); + } + + #[test] + fn rejects_unsupported_scheme() { + let err = RepoSpec::parse("gitlab:alice/typr-shiny").unwrap_err(); + assert!(err.contains("github:"), "unexpected error: {err}"); + } + + #[test] + fn rejects_missing_owner_or_repo() { + assert!(RepoSpec::parse("github:typr-shiny").is_err()); + assert!(RepoSpec::parse("github:/typr-shiny").is_err()); + assert!(RepoSpec::parse("github:alice/").is_err()); + } + + // -- Lockfile ------------------------------------------------------ + + fn sample_locked(package: &str) -> LockedDefinition { + LockedDefinition { + package: package.to_string(), + repository: "github:alice/typr-shiny".to_string(), + version: "0.3.0".to_string(), + rev: "a1b2c3d4e5f6".to_string(), + digest: "sha256:deadbeef".to_string(), + tier: "T2".to_string(), + r_version_seen: Some("1.11.1".to_string()), + } + } + + #[test] + fn lockfile_round_trips_through_toml() { + let mut lockfile = Lockfile::default(); + lockfile.upsert(sample_locked("shiny")); + lockfile.upsert(sample_locked("dplyr")); + + let dir = std::env::temp_dir().join(format!("typr_lockfile_test_{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join(LOCKFILE_NAME); + lockfile.write(&path).unwrap(); + + let read_back = Lockfile::read(&path); + assert_eq!(read_back.definitions.len(), 2); + assert_eq!(read_back.find("shiny"), lockfile.find("shiny")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_replaces_existing_entry_for_same_package() { + let mut lockfile = Lockfile::default(); + lockfile.upsert(sample_locked("shiny")); + let mut updated = sample_locked("shiny"); + updated.digest = "sha256:newdigest".to_string(); + lockfile.upsert(updated); + + assert_eq!(lockfile.definitions.len(), 1); + assert_eq!(lockfile.find("shiny").unwrap().digest, "sha256:newdigest"); + } + + #[test] + fn missing_lockfile_reads_as_empty() { + let path = std::env::temp_dir().join("typr_lockfile_definitely_missing.lock"); + let _ = fs::remove_file(&path); + assert!(Lockfile::read(&path).definitions.is_empty()); + } + + // -- TypesConfig ----------------------------------------------------- + + #[test] + fn reads_trust_and_pins_from_typr_toml() { + let dir = std::env::temp_dir().join(format!("typr_types_config_test_{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join(PROJECT_CONFIG_NAME), + "[types]\ntrust = \"T2\"\nshiny = \"github:alice/typr-shiny\"\n", + ) + .unwrap(); + + let config = TypesConfig::read(&dir); + assert_eq!(config.trust.as_deref(), Some("T2")); + assert_eq!(config.pins.get("shiny").map(String::as_str), Some("github:alice/typr-shiny")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn missing_typr_toml_reads_as_default() { + let dir = std::env::temp_dir().join("typr_types_config_definitely_missing"); + let _ = fs::remove_dir_all(&dir); + let config = TypesConfig::read(&dir); + assert!(config.trust.is_none()); + assert!(config.pins.is_empty()); + } + + // -- digest / capability gate ----------------------------------------- + + fn write_file(root: &Path, rel: &str, content: &str) { + let path = root.join(rel); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, content).unwrap(); + } + + #[test] + fn digest_is_deterministic_and_order_independent() { + let dir_a = std::env::temp_dir().join(format!("typr_digest_a_{}", std::process::id())); + let dir_b = std::env::temp_dir().join(format!("typr_digest_b_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir_a); + let _ = fs::remove_dir_all(&dir_b); + + write_file(&dir_a, "ty/core.ty", "@f: (int) -> int;"); + write_file(&dir_a, "typr-def.toml", "format_version = 1"); + // same content, written in the opposite order + write_file(&dir_b, "typr-def.toml", "format_version = 1"); + write_file(&dir_b, "ty/core.ty", "@f: (int) -> int;"); + + assert_eq!(compute_digest(&dir_a).unwrap(), compute_digest(&dir_b).unwrap()); + + write_file(&dir_b, "ty/core.ty", "@f: (int) -> char;"); + assert_ne!(compute_digest(&dir_a).unwrap(), compute_digest(&dir_b).unwrap()); + + let _ = fs::remove_dir_all(&dir_a); + let _ = fs::remove_dir_all(&dir_b); + } + + #[test] + fn digest_ignores_git_directory() { + let dir = std::env::temp_dir().join(format!("typr_digest_git_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "ty/core.ty", "@f: (int) -> int;"); + let before = compute_digest(&dir).unwrap(); + write_file(&dir, ".git/HEAD", "ref: refs/heads/main"); + let after = compute_digest(&dir).unwrap(); + assert_eq!(before, after); + let _ = fs::remove_dir_all(&dir); + } + + fn manifest_with(r_shims: bool, extern_raw: bool) -> DefinitionManifest { + let toml = format!( + "format_version = 1\n[package]\nname = \"shiny\"\nsince = \"1.11.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T2\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:alice/typr-shiny\"\n\ + [capabilities]\nr_shims = {r_shims}\nextern_raw = {extern_raw}\n" + ); + parse_manifest(&toml).unwrap() + } + + #[test] + fn undeclared_r_shims_are_rejected() { + let dir = std::env::temp_dir().join(format!("typr_caps_shims_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "R/shim.R", "f <- function(x) x"); + let err = check_capabilities(&manifest_with(false, false), &dir).unwrap_err(); + assert!(err.contains("r_shims"), "unexpected error: {err}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn declared_r_shims_pass_with_a_warning() { + let dir = std::env::temp_dir().join(format!("typr_caps_shims_ok_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "R/shim.R", "f <- function(x) x"); + let warnings = check_capabilities(&manifest_with(true, false), &dir).unwrap(); + assert!(warnings.iter().any(|w| w.contains("r_shims"))); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn undeclared_extern_raw_is_rejected() { + let dir = std::env::temp_dir().join(format!("typr_caps_extern_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "ty/core.ty", "let f <- extern: (x: int) -> int r#\"\nfunction(x) x\n\"#;"); + let err = check_capabilities(&manifest_with(false, false), &dir).unwrap_err(); + assert!(err.contains("extern_raw"), "unexpected error: {err}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn clean_definition_has_no_warnings() { + let dir = std::env::temp_dir().join(format!("typr_caps_clean_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "ty/core.ty", "@f: (int) -> int;"); + let warnings = check_capabilities(&manifest_with(false, false), &dir).unwrap(); + assert!(warnings.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + // -- End-to-end add/update/list/vendor against a local `file://` repo -- + + /// Build a minimal, valid definition repository under `dir` and commit it + /// with `git`, so `fetch`/`add` can clone it over a `file://` URL with no + /// network access — the same offline-friendly pattern + /// `gen_types.rs`/`r_name_cache.rs` use for their Rscript-dependent tests + /// (fail open when the external tool is missing). + fn make_definition_repo(dir: &Path) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + write_file( + dir, + MANIFEST_NAME, + "format_version = 1\n\ + [package]\nname = \"shiny\"\nsince = \"1.11.0\"\n\ + [definition]\nversion = \"0.3.0\"\ntier = \"T2\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:alice/typr-shiny\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n", + ); + write_file( + dir, + "ty/core.ty", + "#! pkg: shiny\n#! tier: T2\n@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n", + ); + + let run = |args: &[&str]| -> Result<(), String> { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).into_owned()); + } + Ok(()) + }; + run(&["init", "--quiet"])?; + run(&["config", "user.email", "test@example.com"])?; + run(&["config", "user.name", "test"])?; + run(&["add", "."])?; + run(&["commit", "--quiet", "-m", "initial"])?; + Ok(()) + } + + fn file_url(dir: &Path) -> String { + format!("file://{}", dir.display()) + } + + #[test] + fn add_update_list_vendor_round_trip_against_a_local_repo() { + if !git_available() { + eprintln!("skipping: git not on PATH"); + return; + } + let repo_dir = std::env::temp_dir().join(format!("typr_types_repo_{}", std::process::id())); + let project_dir = std::env::temp_dir().join(format!("typr_types_project_{}", std::process::id())); + let _ = fs::remove_dir_all(&repo_dir); + let _ = fs::remove_dir_all(&project_dir); + fs::create_dir_all(&project_dir).unwrap(); + + if let Err(e) = make_definition_repo(&repo_dir) { + eprintln!("skipping: could not set up local git fixture: {e}"); + let _ = fs::remove_dir_all(&repo_dir); + let _ = fs::remove_dir_all(&project_dir); + return; + } + + // `fetch`/`add` only understand `github:owner/repo` — reach the local + // fixture through the real clone path by cloning the `file://` URL + // directly and driving the cache/lock plumbing at that level, since + // `RepoSpec` cannot express a local path (by design: only GitHub is a + // supported host today). + let (dir, rev) = clone_repo(&file_url(&repo_dir), None).expect("clone should succeed"); + let manifest = parse_manifest(&fs::read_to_string(dir.join(MANIFEST_NAME)).unwrap()).unwrap(); + let warnings = check_capabilities(&manifest, &dir).unwrap(); + assert!(warnings.is_empty()); + let digest = compute_digest(&dir).unwrap(); + let fetched = FetchedDefinition { + manifest, + rev, + digest, + dir, + }; + + admit_to_cache(&fetched, "shiny").unwrap(); + let locked = LockedDefinition { + package: "shiny".to_string(), + repository: "github:alice/typr-shiny".to_string(), + version: fetched.manifest.definition.version.clone(), + rev: fetched.rev.clone(), + digest: fetched.digest.clone(), + tier: fetched.manifest.definition.tier.clone(), + r_version_seen: None, + }; + let mut lockfile = Lockfile::default(); + lockfile.upsert(locked); + lockfile.write(&project_dir.join(LOCKFILE_NAME)).unwrap(); + + // list() + let listed = list(&project_dir); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].package, "shiny"); + assert_eq!(listed[0].tier, "T2"); + + // vendor() + let written = vendor(&project_dir, None).unwrap(); + assert!(!written.is_empty()); + let vendored_ty = project_dir.join("ty").join("vendor").join("shiny").join("ty").join("core.ty"); + assert!(vendored_ty.is_file(), "expected {}", vendored_ty.display()); + let content = fs::read_to_string(&vendored_ty).unwrap(); + assert!(content.contains("fluidPage")); + + let _ = fs::remove_dir_all(&repo_dir); + let _ = fs::remove_dir_all(&project_dir); + let _ = fs::remove_dir_all(&fetched.dir); + if let Some(cache_dir) = cache_dir_for("shiny", &fetched.digest) { + let _ = fs::remove_dir_all(&cache_dir); + } + } + + #[test] + fn vendor_detects_cache_digest_mismatch() { + let project_dir = std::env::temp_dir().join(format!("typr_vendor_mismatch_{}", std::process::id())); + let _ = fs::remove_dir_all(&project_dir); + fs::create_dir_all(&project_dir).unwrap(); + + let mut lockfile = Lockfile::default(); + lockfile.upsert(LockedDefinition { + package: "ghost".to_string(), + repository: "github:alice/typr-ghost".to_string(), + version: "0.1.0".to_string(), + rev: "deadbeef".to_string(), + digest: "sha256:doesnotexistanywhereincache".to_string(), + tier: "T3".to_string(), + r_version_seen: None, + }); + lockfile.write(&project_dir.join(LOCKFILE_NAME)).unwrap(); + + let err = vendor(&project_dir, None).unwrap_err(); + assert!(err.contains("ghost"), "unexpected error: {err}"); + + let _ = fs::remove_dir_all(&project_dir); + } +} diff --git a/crates/typr-core/src/components/context/vartype.rs b/crates/typr-core/src/components/context/vartype.rs index e74b621..868a2a7 100644 --- a/crates/typr-core/src/components/context/vartype.rs +++ b/crates/typr-core/src/components/context/vartype.rs @@ -698,6 +698,55 @@ impl VarType { vars.iter().fold(self, |acc, x| acc.remove_var(x)) } + /// Replace every entry named in `names` with the canonical `(Any, + /// UnknownFunction)` pair the compiler already uses everywhere for "a + /// real name, callable with any arguments, but not type-checked" (see + /// the `(Any, UnknownFunction)` preload for untyped R/JS builtins in + /// `build_function_list_vartype`, and the `UseSelector::Wildcard` comment + /// in `type_checking/mod.rs`). Used to degrade an external type + /// definition's entries that fall below the project's configured trust + /// threshold (`typR/registry.md` §5.4/D2, + /// `rfcs/0031-external-type-definitions.md` "Loading external `.ty` into + /// the context": entry tier < project trust -> load as + /// `Type::UnknownFunction` instead of its declared signature). + /// + /// A name absent from `self` is a no-op — degrading only ever removes + /// type information that would otherwise have been added, never invents + /// a new name. + pub fn degrade_to_any(self, names: &std::collections::HashSet) -> Self { + if names.is_empty() { + return self; + } + let VarType { + variables, + aliases, + std, + alias_counter, + .. + } = self; + let mut new_variables = Arc::unwrap_or_clone(variables); + let mut degraded: std::collections::HashSet = std::collections::HashSet::new(); + new_variables.retain(|(var, _)| { + let name = var.get_name(); + if names.contains(&name) { + degraded.insert(name); + false + } else { + true + } + }); + for name in degraded { + new_variables.insert((Var::from_name(&name).set_type(builder::any_type()), builder::unknown_function_type())); + } + Self { + variables: Arc::new(new_variables), + aliases, + std, + alias_counter, + name_index: Arc::new(OnceLock::new()), + } + } + pub fn remove_var(self, var: &Var) -> Self { let VarType { variables, @@ -903,6 +952,52 @@ mod tests { s.parse::().unwrap() } + #[test] + fn degrade_to_any_replaces_named_entries_with_any_unknown_function() { + let vt = VarType::new().push_var_type(&[ + (Var::from_name("keep_me").set_type(builder::integer_type_default()), builder::integer_type_default()), + ( + Var::from_name("degrade_me").set_type(builder::integer_type_default()), + builder::integer_type_default(), + ), + ]); + + let mut names = std::collections::HashSet::new(); + names.insert("degrade_me".to_string()); + let degraded = vt.degrade_to_any(&names); + + let (kept_var, kept_type) = degraded + .entries_named("keep_me") + .into_iter() + .next() + .expect("untouched entry must survive"); + assert_eq!(kept_type, builder::integer_type_default()); + assert_eq!(kept_var.get_type(), builder::integer_type_default()); + + let (degraded_var, degraded_type) = degraded + .entries_named("degrade_me") + .into_iter() + .next() + .expect("degraded entry must still be present"); + assert!(degraded_type.is_unknown_function()); + assert!(degraded_var.get_type().is_any()); + } + + #[test] + fn degrade_to_any_is_a_no_op_for_a_name_not_present() { + let vt = VarType::new().push_var_type(&[( + Var::from_name("keep_me").set_type(builder::integer_type_default()), + builder::integer_type_default(), + )]); + + let mut names = std::collections::HashSet::new(); + names.insert("never_declared".to_string()); + let degraded = vt.degrade_to_any(&names); + + assert_eq!(degraded.entries_named("keep_me").len(), 1); + assert!(degraded.entries_named("never_declared").is_empty()); + } + #[test] fn atomic_array_elem_primitive_and_vec_are_atomic() { let ctx = FluentParser::new().get_context(); diff --git a/rfcs/0031-external-type-definitions.md b/rfcs/0031-external-type-definitions.md index 5a86288..17db0d4 100644 --- a/rfcs/0031-external-type-definitions.md +++ b/rfcs/0031-external-type-definitions.md @@ -489,9 +489,14 @@ Explicitly out of scope here, deferred to `registry.md`'s later milestones: manifest - [ ] external `.ty` loading merged into `standard_library.rs`'s context build, keyed by resolved `typr.lock` entries -- [ ] `trust` threshold + degrade-to-`UnknownFunction` at merge time -- [ ] `typr types add|update|list|vendor`, `typr.lock` read/write, digest +- [x] `trust` threshold + degrade-to-`UnknownFunction` at merge time + (`crates/typr-cli/src/standard_library.rs::load_external_ty_definitions`, + `VarType::degrade_to_any`; see `typR/registry.md` §13 J2 for detail) +- [x] `typr types add|update|list|vendor`, `typr.lock` read/write, digest verification, `~/.cache/typr/types///` + (`crates/typr-cli/src/type_registry.rs`; see `typR/registry.md` §13 J2 + for detail — the `typr.lock` → `load_external_ty_definitions` wiring + itself is still open, tracked by the next unchecked item below) - [ ] `cases/`: missing definition, tier below `trust`, version below `since`, version above `until`, unreachable repository, digest mismatch — each asserting *no hard error*, only degradation From 8840af9fe55d92442de7940d2d377d7d28a34a1b Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sun, 13 Sep 2026 23:52:51 +0200 Subject: [PATCH 11/52] update --- crates/typr-cli/src/project.rs | 33 ++- crates/typr-cli/src/standard_library.rs | 264 ++++++++++++++++++++++- crates/typr-cli/src/type_registry.rs | 270 ++++++++++++++++++++++-- rfcs/0031-external-type-definitions.md | 34 ++- 4 files changed, 564 insertions(+), 37 deletions(-) diff --git a/crates/typr-cli/src/project.rs b/crates/typr-cli/src/project.rs index 423b9aa..422528c 100644 --- a/crates/typr-cli/src/project.rs +++ b/crates/typr-cli/src/project.rs @@ -718,7 +718,10 @@ pub fn new(name: &str, renv: bool) { } pub fn check_project() { - let context = Context::default().set_environment(Environment::Project); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default().set_environment(Environment::Project), + ); let step = Step::new("Parsing"); let (lang, syntax_errors) = parse_code(&PathBuf::from("TypR/main.ty"), context.get_environment()); @@ -741,7 +744,10 @@ pub fn check_project() { } pub fn check_file(path: &PathBuf) { - let context = Context::default().set_environment(Environment::Project); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default().set_environment(Environment::Project), + ); let dir = PathBuf::from("."); write_std_for_type_checking(&dir); @@ -975,10 +981,13 @@ fn build_project_impl( } } - let context = Context::default() - .set_environment(Environment::Project) - .set_test_mode(test_mode) - .set_checked_mode(checked_mode); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default() + .set_environment(Environment::Project) + .set_test_mode(test_mode) + .set_checked_mode(checked_mode), + ); let step = Step::new("Parsing"); let (lang, mut expansion_info) = parse_code_with_info(&PathBuf::from("TypR/main.ty"), context.get_environment()); @@ -1085,9 +1094,10 @@ pub fn build_file(path: &Path, test_mode: bool, checked_mode: bool, strict_mode: } step.done(); - let context = Context::default() - .set_test_mode(test_mode) - .set_checked_mode(checked_mode); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default().set_test_mode(test_mode).set_checked_mode(checked_mode), + ); let step = Step::new("Type checking"); let type_checker = TypeChecker::new(context.clone()).typing_no_panic(&lang); @@ -1247,7 +1257,10 @@ fn run_file_impl(path: &Path, keep_files: bool, profile: bool, checked_mode: boo let work_dir = get_working_directory(keep_files); let guard = TempDirGuard::new(if keep_files { None } else { Some(work_dir.clone()) }); write_std_for_type_checking(&work_dir); - let context = Context::default().set_checked_mode(checked_mode); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default().set_checked_mode(checked_mode), + ); let type_checker = TypeChecker::new(context.clone()).typing_no_panic(&lang); if type_checker.has_errors() { step.fail(); diff --git a/crates/typr-cli/src/standard_library.rs b/crates/typr-cli/src/standard_library.rs index 9be9f33..a5fba1d 100644 --- a/crates/typr-cli/src/standard_library.rs +++ b/crates/typr-cli/src/standard_library.rs @@ -601,11 +601,9 @@ fn names_below_trust(ty_sources: &[(&str, &str)], default_tier: &str, trust: &st /// `Context::empty()`, is what lets a third-party `.ty` see the bundled /// stdlib while it is being type-checked. /// -/// Not yet called from the CLI: nothing resolves a `typr.lock` entry into -/// `(filename, source)` pairs plus a manifest's tier/trust yet (`typr types -/// add`/`typr.lock`, the next checklist item), so this has no caller outside -/// its own tests until then. -#[allow(dead_code)] +/// Called from `load_project_type_definitions`, below, which resolves a +/// project's `typr.lock` into exactly the `(ty_sources, default_tier, +/// trust)` triples this function expects. pub fn load_external_ty_definitions( base_context: Context, ty_sources: &[(&str, &str)], @@ -618,6 +616,165 @@ pub fn load_external_ty_definitions( (context, skipped) } +/// Every function name declared across `ty_sources`, regardless of tier — +/// what `degrade_if_version_out_of_range` widens to `Any` when the whole +/// definition is out of its declared version range. Unlike +/// `names_below_trust`, tier plays no role here: a version mismatch is a +/// property of the *definition*, not of any one entry's declared +/// trustworthiness. +fn all_declared_names(ty_sources: &[(&str, &str)]) -> HashSet { + let mut names = HashSet::new(); + for (_filename, source) in ty_sources { + for line in source.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('@') { + continue; + } + if let Some(raw_name) = extract_raw_signature_name(trimmed) { + names.insert(unwrap_backtick_name(&raw_name)); + } + } + } + names +} + +/// Parse a dotted version string into numeric components, ignoring any +/// non-digit suffix on a component (`"1.11.0-beta"` -> `[1, 11, 0]`) and +/// treating an unparsable component as `0` — good enough for the floor/ +/// ceiling comparison below, never a reason to fail a build over a +/// malformed version string. +fn parse_version(v: &str) -> Vec { + v.split(['.', '-', '+']) + .map(|part| { + let digits: String = part.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse::().unwrap_or(0) + }) + .collect() +} + +/// Is `a` strictly less than `b`, comparing dotted version strings +/// component-wise (`"1.9"` < `"1.10"`, not string order)? Both are padded to +/// the same length first so `"1.2"` and `"1.2.0"` compare equal rather than +/// the shorter one spuriously losing. +fn version_less_than(a: &str, b: &str) -> bool { + let mut pa = parse_version(a); + let mut pb = parse_version(b); + while pa.len() < pb.len() { + pa.push(0); + } + while pb.len() < pa.len() { + pb.push(0); + } + pa < pb +} + +/// registry.md §7.2 "Compatibilité de versions : borne minimale, pas plage +/// fermée": when the R package version actually observed at resolution time +/// (`typr.lock`'s `r_version_seen`, populated by `typr types add`/`update`) +/// falls below the definition's declared `since` floor, or above its +/// optional `until` ceiling, every name the definition declares degrades to +/// `Any` — same D2 degrade-never-fail contract as the trust threshold +/// (§0/§5.4), just gated on a different signal, and applied on top of it +/// rather than instead of it. +/// +/// No comparison is made, and nothing degrades, when `r_version_seen` is +/// `None`: a version that was never observed (offline resolution, or R +/// unavailable when the definition was added/updated) is not the same as an +/// incompatible one, and D2 forbids treating an unreadable signal as +/// grounds for anything other than staying exactly as trusting as the tier +/// check already decided. +fn degrade_if_version_out_of_range( + context: Context, + ty_sources: &[(&str, &str)], + since: &str, + until: Option<&str>, + r_version_seen: Option<&str>, +) -> (Context, Option) { + let Some(observed) = r_version_seen else { + return (context, None); + }; + let below_floor = version_less_than(observed, since); + let above_ceiling = until.map(|u| version_less_than(u, observed)).unwrap_or(false); + if !below_floor && !above_ceiling { + return (context, None); + } + + let reason = if below_floor { + format!( + "observed R package version {observed} is older than this definition's declared floor (since = \"{since}\")" + ) + } else { + format!( + "observed R package version {observed} is newer than this definition's declared ceiling (until = \"{}\")", + until.unwrap_or_default() + ) + }; + + let names = all_declared_names(ty_sources); + let mut context = context; + context.typing_context = context.typing_context.clone().degrade_to_any(&names); + (context, Some(reason)) +} + +/// Load every package's resolved external Type Definition on top of +/// `base_context` — the "reading `typr.lock` at `check`/`build`/`run` time" +/// wiring that `type_registry.rs`'s module doc and `load_external_ty_definitions` +/// name as the last missing piece of `typR/registry.md` §13 J2. Called from +/// every `check`/`build`/`run` entry point in `project.rs`. +/// +/// A project with no `typr.lock` is unaffected (`resolve_locked_definitions` +/// returns nothing to load). A locked package whose cache is missing, stale, +/// or out of its declared version range degrades or is skipped with a +/// `warning:` line — never a hard error: this function cannot make a build +/// that passed before fail now (D2, registry.md §0/§5.4). +/// +/// `project_root` is the directory holding `typr.toml`/`typr.lock` — every +/// call site in `project.rs` passes `Path::new(".")`, since CLI commands +/// already run with the project root as the current directory (same +/// convention as `PathBuf::from("TypR/main.ty")` elsewhere in that module). +/// Taking it as a parameter, rather than hard-coding `"."` in here, is what +/// lets tests point it at a temporary project without touching the process's +/// current directory. +pub fn load_project_type_definitions(project_root: &std::path::Path, base_context: Context) -> Context { + let trust = crate::type_registry::TypesConfig::read(project_root) + .trust + .unwrap_or_else(|| "T2".to_string()); + let (resolved, warnings) = crate::type_registry::resolve_locked_definitions(project_root); + for w in &warnings { + eprintln!("warning: {w}"); + } + + let mut context = base_context; + for def in &resolved { + let sources: Vec<(&str, &str)> = def.ty_sources.iter().map(|(f, s)| (f.as_str(), s.as_str())).collect(); + + let (next_context, skipped) = load_external_ty_definitions(context, &sources, &def.default_tier, &trust); + for (filename, message) in &skipped { + eprintln!( + "warning: `{}` — {} could not be loaded ({message}); its declared names stay untyped", + def.package, filename + ); + } + + let (next_context, version_warning) = degrade_if_version_out_of_range( + next_context, + &sources, + &def.since, + def.until.as_deref(), + def.r_version_seen.as_deref(), + ); + if let Some(reason) = version_warning { + eprintln!( + "warning: `{}` — {reason}; its declared types are degraded to Any for this run (registry.md §7.2)", + def.package + ); + } + + context = next_context; + } + context +} + /// Build a documentation graph over a set of `.ty` sources. /// /// Shared by `build_stdlib_docs` (production) and the tests, so the @@ -1037,6 +1194,103 @@ mod tests { ); } + // -- version_less_than / degrade_if_version_out_of_range (registry.md §7.2) -- + + /// The whole reason `version_less_than` exists instead of a plain string + /// comparison: `"1.9" < "1.10"` numerically, but `"1.10" < "1.9"` + /// lexicographically. + #[test] + fn version_less_than_compares_components_numerically() { + assert!(version_less_than("1.9", "1.10")); + assert!(!version_less_than("1.10", "1.9")); + assert!(version_less_than("1.11.0", "2.0.0")); + assert!(!version_less_than("2.0.0", "1.11.0")); + } + + /// `"1.2"` and `"1.2.0"` must compare equal (neither less than the + /// other) rather than the shorter string spuriously losing to padding. + #[test] + fn version_less_than_treats_missing_trailing_components_as_zero() { + assert!(!version_less_than("1.2", "1.2.0")); + assert!(!version_less_than("1.2.0", "1.2")); + } + + /// An observed version below the definition's `since` floor degrades + /// every declared name to `Any` and names the floor in the reason. + #[test] + fn degrade_if_version_out_of_range_degrades_below_the_since_floor() { + let source = "@f: (int) -> int;"; + let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); + assert!(skipped.is_empty()); + + let (context, reason) = + degrade_if_version_out_of_range(context, &[("pkg.ty", source)], "1.11.0", None, Some("1.9.0")); + + let reason = reason.expect("an observed version below `since` must degrade"); + assert!(reason.contains("older") && reason.contains("1.11.0"), "unexpected reason: {reason}"); + let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); + assert!(typ.is_unknown_function()); + } + + /// An observed version above the definition's `until` ceiling degrades + /// every declared name to `Any` and names the ceiling in the reason. + #[test] + fn degrade_if_version_out_of_range_degrades_above_the_until_ceiling() { + let source = "@f: (int) -> int;"; + let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); + assert!(skipped.is_empty()); + + let (context, reason) = degrade_if_version_out_of_range( + context, + &[("pkg.ty", source)], + "1.0.0", + Some("1.5.0"), + Some("2.0.0"), + ); + + let reason = reason.expect("an observed version above `until` must degrade"); + assert!(reason.contains("newer") && reason.contains("1.5.0"), "unexpected reason: {reason}"); + let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); + assert!(typ.is_unknown_function()); + } + + /// An observed version inside `[since, until]` is a no-op: the declared + /// signature survives untouched. + #[test] + fn degrade_if_version_out_of_range_is_a_no_op_within_range() { + let source = "@f: (int) -> int;"; + let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); + assert!(skipped.is_empty()); + + let (context, reason) = degrade_if_version_out_of_range( + context, + &[("pkg.ty", source)], + "1.0.0", + Some("2.0.0"), + Some("1.5.0"), + ); + + assert!(reason.is_none()); + let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); + assert!(!typ.is_unknown_function()); + } + + /// D2: a version that was never observed (`r_version_seen == None`) must + /// never be treated as out of range — only an actually-observed + /// incompatible version may trigger the degradation. + #[test] + fn degrade_if_version_out_of_range_is_a_no_op_when_version_was_never_observed() { + let source = "@f: (int) -> int;"; + let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); + assert!(skipped.is_empty()); + + let (context, reason) = degrade_if_version_out_of_range(context, &[("pkg.ty", source)], "1.11.0", None, None); + + assert!(reason.is_none()); + let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); + assert!(!typ.is_unknown_function()); + } + /// An unrecognized tier string — on the entry or on the project's own /// `trust` setting — must never be silently treated as trusted (D2, /// `typR/registry.md` §0/§5.4): it always degrades. diff --git a/crates/typr-cli/src/type_registry.rs b/crates/typr-cli/src/type_registry.rs index 04cfd49..b8c956f 100644 --- a/crates/typr-cli/src/type_registry.rs +++ b/crates/typr-cli/src/type_registry.rs @@ -4,13 +4,12 @@ //! (`rfcs/0031-external-type-definitions.md`, "Resolution, `typr.lock`, //! cache, vendoring"). //! -//! What this module does NOT do: load a resolved definition into the -//! type-checking context. That is `standard_library::load_external_ty_definitions`, -//! already implemented and tested — this module is purely the plumbing that -//! turns a `github:owner/repo[@rev]` string into `(filename, source)` pairs on -//! disk plus a `typr.lock` entry recording exactly what was fetched. Wiring -//! the two together (reading `typr.lock` at `check`/`build`/`run` time) is a -//! follow-up, not part of this checklist item. +//! `resolve_locked_definitions`, near the bottom of this file, is the +//! "reading `typr.lock` at `check`/`build`/`run` time" wiring step: it turns +//! a `typr.lock` entry back into `(filename, source)` `.ty` pairs from the +//! on-disk cache, re-verifying the digest first. Loading those into the +//! type-checking context is `standard_library::load_project_type_definitions`, +//! which calls this function and then `load_external_ty_definitions`. use crate::type_definition::{parse_manifest, DefinitionManifest}; use serde::{Deserialize, Serialize}; @@ -392,6 +391,22 @@ pub fn fetch(spec: &RepoSpec) -> Result<(FetchedDefinition, Vec), String result } +/// Best-effort: the version of `package` actually installed on this +/// machine, via the same `Rscript`-based introspection `typr gen-types` +/// already uses (`gen_types::introspect`, which itself parses the `P` line +/// of `introspect_pkg.R`'s output). This is the "version réellement +/// observée" registry.md §7.2 says `typr.lock`'s `r_version_seen` records. +/// +/// Fail-open (`None`) when `Rscript` is not on PATH or the package is not +/// installed locally: `typr types add`/`update` must still succeed without R +/// present (same contract as the rest of this module's git-only fetch path) +/// — it just leaves nothing for the version-floor check at `check`/`build`/ +/// `run` time to compare against later, per `degrade_if_version_out_of_range`'s +/// own "no comparison is made... when `r_version_seen` is `None`" rule. +fn observed_r_package_version(package: &str) -> Option { + crate::gen_types::introspect(package).ok().and_then(|info| info.pkg_version) +} + /// Copy every tracked file of a fetched definition into the on-disk cache at /// `~/.cache/typr/types///`, replacing whatever was there before /// (the digest already identifies the content, so an existing directory with @@ -421,14 +436,13 @@ fn admit_to_cache(fetched: &FetchedDefinition, pkg: &str) -> Result, /// package name → explicit `github:owner/repo[@rev]` pin. pub pins: std::collections::BTreeMap, @@ -476,7 +490,7 @@ pub fn add(project_root: &Path, package: &str, spec_str: &str) -> Result) -> Result, + /// The manifest's `[definition] tier` — the tier an entry with no + /// `#! tier:` of its own falls back to. + pub default_tier: String, + pub since: String, + pub until: Option, + /// `typr.lock`'s own `r_version_seen`, frozen at the last `typr types + /// add`/`update` — `None` when it was never observed (R unavailable at + /// resolution time). + pub r_version_seen: Option, +} + +/// Read `typr.lock` and, for every locked definition whose cached copy is +/// present and still matches its pinned digest, collect its `.ty` sources — +/// the "reading `typr.lock` at `check`/`build`/`run` time" step this +/// module's doc comment names. A locked package whose cache is missing, +/// stale, or corrupted is skipped, with a message for the caller to print as +/// a warning, rather than failing the build: the same fail-open contract +/// `vendor()` already applies, and D2 ("an unreliable signal only ever +/// removes checking, it never breaks a build", registry.md §0/§5.4) taken to +/// its logical end — a package `typr.lock` cannot currently resolve simply +/// falls back to being untyped R, exactly as if it had never been added. +pub fn resolve_locked_definitions(project_root: &Path) -> (Vec, Vec) { + let lockfile = Lockfile::read(&project_root.join(LOCKFILE_NAME)); + let mut resolved = Vec::new(); + let mut warnings = Vec::new(); + for def in &lockfile.definitions { + match resolve_one_locked_definition(def) { + Ok(r) => resolved.push(r), + Err(w) => warnings.push(w), + } + } + (resolved, warnings) +} + +fn resolve_one_locked_definition(def: &LockedDefinition) -> Result { + let cache_dir = cache_dir_for(&def.package, &def.digest).ok_or_else(|| { + format!( + "`{}`: no cache directory available (no $HOME/$XDG_CACHE_HOME) — its declared types are unavailable this run, names stay untyped", + def.package + ) + })?; + if !cache_dir.is_dir() { + return Err(format!( + "`{}`: not in the local cache at {} — run `typr types update {}`; its declared types are unavailable this run, names stay untyped", + def.package, + cache_dir.display(), + def.package + )); + } + let actual_digest = compute_digest(&cache_dir).map_err(|e| format!("`{}`: {e}", def.package))?; + if actual_digest != def.digest { + return Err(format!( + "`{}`: cached copy no longer matches typr.lock (expected {}, found {}) — run `typr types update {}`; \ + its declared types are unavailable this run, names stay untyped", + def.package, def.digest, actual_digest, def.package + )); + } + + let manifest_source = fs::read_to_string(cache_dir.join(MANIFEST_NAME)) + .map_err(|e| format!("`{}`: could not read {MANIFEST_NAME} from its cache: {e}", def.package))?; + let manifest = parse_manifest(&manifest_source).map_err(|e| format!("`{}`: {e}", def.package))?; + + let mut ty_sources = Vec::new(); + for rel in tracked_files(&cache_dir).map_err(|e| format!("`{}`: {e}", def.package))? { + if rel.extension().and_then(|e| e.to_str()) != Some("ty") { + continue; + } + let content = fs::read_to_string(cache_dir.join(&rel)) + .map_err(|e| format!("`{}`: could not read {}: {e}", def.package, rel.display()))?; + ty_sources.push((rel.to_string_lossy().replace('\\', "/"), content)); + } + + Ok(ResolvedDefinition { + package: def.package.clone(), + ty_sources, + default_tier: manifest.definition.tier, + since: manifest.package.since, + until: manifest.package.until, + r_version_seen: def.r_version_seen.clone(), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -932,4 +1044,134 @@ mod tests { let _ = fs::remove_dir_all(&project_dir); } + + // -- End-to-end: typr.lock -> real cache -> type-checking context ------ + // + // These exercise `standard_library::load_project_type_definitions`, the + // "reading typr.lock at check/build/run time" wiring named as the last + // missing piece of registry.md §13 J2's `typr types add/update/list/ + // vendor` item. They admit a definition straight into the real on-disk + // cache (no git fixture, unlike `add_update_list_vendor_round_trip_ + // against_a_local_repo` above) since only the cache/lock -> context path + // is under test here, not fetching. + + fn lock_definition_in_real_cache( + project_dir: &Path, + package: &str, + manifest_toml: &str, + ty_source: &str, + r_version_seen: Option<&str>, + ) -> LockedDefinition { + let src_dir = std::env::temp_dir().join(format!("typr_lock_src_{package}_{}", std::process::id())); + let _ = fs::remove_dir_all(&src_dir); + write_file(&src_dir, MANIFEST_NAME, manifest_toml); + write_file(&src_dir, "ty/core.ty", ty_source); + + let manifest = parse_manifest(manifest_toml).unwrap(); + let digest = compute_digest(&src_dir).unwrap(); + let fetched = FetchedDefinition { + manifest, + rev: "0000000000000000000000000000000000000000".to_string(), + digest, + dir: src_dir.clone(), + }; + admit_to_cache(&fetched, package).unwrap(); + + let locked = LockedDefinition { + package: package.to_string(), + repository: format!("github:test/{package}"), + version: fetched.manifest.definition.version.clone(), + rev: fetched.rev.clone(), + digest: fetched.digest.clone(), + tier: fetched.manifest.definition.tier.clone(), + r_version_seen: r_version_seen.map(str::to_string), + }; + + let lock_path = project_dir.join(LOCKFILE_NAME); + let mut lockfile = Lockfile::read(&lock_path); + lockfile.upsert(locked.clone()); + lockfile.write(&lock_path).unwrap(); + + let _ = fs::remove_dir_all(&src_dir); + locked + } + + /// registry.md §5.4: a `T3` entry, locked and cached for real, degrades + /// to `Any` under the project's default `T2` trust once loaded through + /// the real `check`/`build`/`run` entry point + /// (`standard_library::load_project_type_definitions`) — not just + /// through the lower-level `load_external_ty_definitions` unit tests in + /// `standard_library.rs`. + #[test] + fn load_project_type_definitions_degrades_a_low_tier_locked_definition() { + let project_dir = std::env::temp_dir().join(format!("typr_wiring_tier_{}", std::process::id())); + let _ = fs::remove_dir_all(&project_dir); + fs::create_dir_all(&project_dir).unwrap(); + + let manifest_toml = "format_version = 1\n\ + [package]\nname = \"widget\"\nsince = \"1.0.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T3\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:test/typr-widget\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n"; + let locked = + lock_definition_in_real_cache(&project_dir, "widget", manifest_toml, "@do_widget_thing: (int) -> int;", None); + + let context = crate::standard_library::load_project_type_definitions( + &project_dir, + typr_core::components::context::Context::default(), + ); + let typ = context + .get_type_from_variable(&typr_core::components::language::var::Var::from_name("do_widget_thing")) + .expect("locked definition's entry must be loaded into the context"); + assert!( + typ.is_unknown_function(), + "a T3 entry under the default T2 project trust must degrade to Any" + ); + + let _ = fs::remove_dir_all(&project_dir); + if let Some(cache_dir) = cache_dir_for("widget", &locked.digest) { + let _ = fs::remove_dir_all(&cache_dir); + } + } + + /// registry.md §7.2: a `typr.lock` entry whose recorded `r_version_seen` + /// is below the manifest's `since` floor degrades to `Any` once loaded + /// through the real `check`/`build`/`run` entry point, even though its + /// own tier (`T1`) is trusted outright. + #[test] + fn load_project_type_definitions_degrades_when_locked_version_is_below_since() { + let project_dir = std::env::temp_dir().join(format!("typr_wiring_version_{}", std::process::id())); + let _ = fs::remove_dir_all(&project_dir); + fs::create_dir_all(&project_dir).unwrap(); + + let manifest_toml = "format_version = 1\n\ + [package]\nname = \"widget2\"\nsince = \"2.0.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T1\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:test/typr-widget2\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n"; + let locked = lock_definition_in_real_cache( + &project_dir, + "widget2", + manifest_toml, + "@do_widget2_thing: (int) -> int;", + Some("1.0.0"), + ); + + let context = crate::standard_library::load_project_type_definitions( + &project_dir, + typr_core::components::context::Context::default(), + ); + let typ = context + .get_type_from_variable(&typr_core::components::language::var::Var::from_name("do_widget2_thing")) + .expect("locked definition's entry must be loaded into the context"); + assert!( + typ.is_unknown_function(), + "a T1 entry whose observed version is below `since` must still degrade to Any (registry.md §7.2)" + ); + + let _ = fs::remove_dir_all(&project_dir); + if let Some(cache_dir) = cache_dir_for("widget2", &locked.digest) { + let _ = fs::remove_dir_all(&cache_dir); + } + } } diff --git a/rfcs/0031-external-type-definitions.md b/rfcs/0031-external-type-definitions.md index 17db0d4..9774465 100644 --- a/rfcs/0031-external-type-definitions.md +++ b/rfcs/0031-external-type-definitions.md @@ -484,25 +484,43 @@ Explicitly out of scope here, deferred to `registry.md`'s later milestones: -- [ ] `typr-def.toml` manifest parsing + `format_version` gate -- [ ] `since`/`until` added to `FunctionMeta` (`stdlib_meta.rs`) and to the +- [x] `typr-def.toml` manifest parsing + `format_version` gate + (`crates/typr-cli/src/type_definition.rs::parse_manifest`) +- [x] `since`/`until` added to `FunctionMeta` (`stdlib_meta.rs`) and to the manifest -- [ ] external `.ty` loading merged into `standard_library.rs`'s context build, +- [x] external `.ty` loading merged into `standard_library.rs`'s context build, keyed by resolved `typr.lock` entries + (`crates/typr-cli/src/standard_library.rs::load_project_type_definitions`, + called from every `check`/`build`/`run` entry point in `project.rs`; + also applies the `since`/`until` version-floor degradation via + `degrade_if_version_out_of_range`; see `typR/registry.md` §13 J2 for + detail) - [x] `trust` threshold + degrade-to-`UnknownFunction` at merge time (`crates/typr-cli/src/standard_library.rs::load_external_ty_definitions`, `VarType::degrade_to_any`; see `typR/registry.md` §13 J2 for detail) - [x] `typr types add|update|list|vendor`, `typr.lock` read/write, digest verification, `~/.cache/typr/types///` (`crates/typr-cli/src/type_registry.rs`; see `typR/registry.md` §13 J2 - for detail — the `typr.lock` → `load_external_ty_definitions` wiring - itself is still open, tracked by the next unchecked item below) + for detail) - [ ] `cases/`: missing definition, tier below `trust`, version below `since`, version above `until`, unreachable repository, digest mismatch — each - asserting *no hard error*, only degradation -- [ ] `[capabilities]` gate enforced at fetch time (reject undeclared R; + asserting *no hard error*, only degradation. Covered so far only by + Rust unit/integration tests + (`standard_library.rs::tests`, `type_registry.rs::tests`) and a manual + end-to-end smoke test through the real `typr` binary — not yet by a + `cases/NNNN-…` entry replayed through `typr case run`, since that + would need a portable way to pre-populate `~/.cache/typr/types/` for a + sandboxed `repro/` (the cache lives outside the project directory by + design, §7.4). See `typR/registry.md` §13 J2 for detail. +- [x] `[capabilities]` gate enforced at fetch time (reject undeclared R; confirm-or-`--allow-r` for declared) -- [ ] `syntaxe.md` — no lexeme changes expected, but confirm before merge + (`crates/typr-cli/src/type_registry.rs::check_capabilities`, enforced + inside `fetch()` before anything is admitted to the cache; the + `--allow-r` confirmation prompt itself is not implemented — today a + declared `r_shims`/`extern_raw` definition passes with a printed + warning, never a blocking confirmation) +- [x] `syntaxe.md` — no lexeme changes were made throughout this RFC's + implementation - [ ] Documentation PR on `we-data-ch/typr.github.io` (a How-to page for consuming an external definition; a Reference page for the manifest and `#!` keys), landing in the same release From d1f2b03d0e5775a97dbf7ba5ee157d70052b4f12 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Mon, 14 Sep 2026 07:34:06 +0200 Subject: [PATCH 12/52] update Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014brvdoSu1AUJnY3mFzSc8C --- .../typr-def.toml | 13 + .../case.toml | 6 + .../expect.md | 44 + .../expect.toml | 20 + .../repro/TypR/main.ty | 9 + .../repro/typr.lock | 10 + .../typr-def.toml | 13 + .../case.toml | 6 + .../expect.md | 34 + .../expect.toml | 18 + .../repro/TypR/main.ty | 9 + .../repro/typr.lock | 11 + cases/README.md | 31 + crates/typr-cli/src/cases.rs | 18 + crates/typr-cli/src/cli.rs | 153 +++- crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/main.rs | 1 + crates/typr-cli/src/registry_validate.rs | 785 ++++++++++++++++++ crates/typr-cli/src/type_registry.rs | 511 +++++++++++- 19 files changed, 1669 insertions(+), 24 deletions(-) create mode 100644 cases/0066-external-type-definition-low-tier-degrades-to-any/cache/typr/types/gizmo/35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88/typr-def.toml create mode 100644 cases/0066-external-type-definition-low-tier-degrades-to-any/case.toml create mode 100644 cases/0066-external-type-definition-low-tier-degrades-to-any/expect.md create mode 100644 cases/0066-external-type-definition-low-tier-degrades-to-any/expect.toml create mode 100644 cases/0066-external-type-definition-low-tier-degrades-to-any/repro/TypR/main.ty create mode 100644 cases/0066-external-type-definition-low-tier-degrades-to-any/repro/typr.lock create mode 100644 cases/0067-external-type-definition-old-version-degrades-to-any/cache/typr/types/sprocket/1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7/typr-def.toml create mode 100644 cases/0067-external-type-definition-old-version-degrades-to-any/case.toml create mode 100644 cases/0067-external-type-definition-old-version-degrades-to-any/expect.md create mode 100644 cases/0067-external-type-definition-old-version-degrades-to-any/expect.toml create mode 100644 cases/0067-external-type-definition-old-version-degrades-to-any/repro/TypR/main.ty create mode 100644 cases/0067-external-type-definition-old-version-degrades-to-any/repro/typr.lock create mode 100644 crates/typr-cli/src/registry_validate.rs diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/cache/typr/types/gizmo/35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88/typr-def.toml b/cases/0066-external-type-definition-low-tier-degrades-to-any/cache/typr/types/gizmo/35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88/typr-def.toml new file mode 100644 index 0000000..02e3ab4 --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/cache/typr/types/gizmo/35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88/typr-def.toml @@ -0,0 +1,13 @@ +format_version = 1 +[package] +name = "gizmo" +since = "1.0.0" +[definition] +version = "0.1.0" +tier = "T3" +[provider] +type = "community" +repository = "github:test/typr-gizmo" +[capabilities] +r_shims = false +extern_raw = false diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/case.toml b/cases/0066-external-type-definition-low-tier-degrades-to-any/case.toml new file mode 100644 index 0000000..fd0a5ec --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/case.toml @@ -0,0 +1,6 @@ +title = "a locked external Type Definition below the project's `trust` threshold degrades to Any, never a hard error" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-13" +origin = "perso" diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.md b/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.md new file mode 100644 index 0000000..ecd1ff2 --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.md @@ -0,0 +1,44 @@ +# external-type-definition-low-tier-degrades-to-any + +Source: `typR/registry.md` §5.4 (D2 — "an unreliable definition widens to `Any`, it never fails +a build") and §13 J2, last open checklist item ("`cases/`: définition absente / tier bas / +version trop ancienne → jamais d'erreur dure"). + +## Ce qui devrait se passer + +`gizmo` is locked in `typr.lock` at tier `T3` and cached under +`cache/typr/types/gizmo//` (this case bundles that cache directory itself, mirrored at +`$XDG_CACHE_HOME/typr/types/…`, since the real cache lives outside any project directory — +`cases.rs::build_sandbox` now copies a case's `cache/` folder into the sandbox and points +`$XDG_CACHE_HOME` at it before invoking `typr`). Its one declared symbol, +`@spin_gizmo: (int) -> int;`, is a plain declaration with no real R binding behind it. + +The project has no `typr.toml`, so `standard_library::load_project_type_definitions` applies the +default `trust = "T2"`. `T3 < T2`, so `names_below_trust` must widen `spin_gizmo` to +`VarType::degrade_to_any` — the `(Any, UnknownFunction)` pair, callable with any argument type, +arity-checked against the declared parameter count. + +`main.ty` calls `spin_gizmo("not-an-int")` — the wrong argument *type* for the declared +`(int) -> int` signature, but the right *arity* (one argument). This is a 3-way discriminator: + +- degradation broken and the definition never loads at all → `spin_gizmo` is an unknown symbol → + hard type error ("unknown function"/"Type errors found"); +- degradation broken but the definition loads with its literal declared signature → the `string` + argument doesn't match `int` → hard type error; +- degradation working as designed → arity matches, argument type isn't checked → `typr check` + succeeds. + +## Vérification + +Implemented already (`typR/registry.md` §13 J2, "seuil `trust` + règle de dégradation" and +"brancher `typr.lock` sur `check`/`build`/`run`"): `crates/typr-cli/src/standard_library.rs` +(`load_external_ty_definitions`, `names_below_trust`, `load_project_type_definitions`) and +`crates/typr-cli/src/type_registry.rs` (`resolve_locked_definitions`). This case is the +end-to-end regression net through the real CLI that those checklist items were missing — +previously only exercised by `type_registry.rs`'s `#[cfg(test)]` module, not by `typr case run`. + +## Statut + +Kept as a regression net: any future change that stops loading a locked, cached, low-tier +definition — or that starts enforcing its declared types instead of degrading them — breaks +this case. diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.toml b/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.toml new file mode 100644 index 0000000..0cacae2 --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.toml @@ -0,0 +1,20 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +# If cache resolution were broken (e.g. `typr.lock`'s digest not matching +# the cache, or `$XDG_CACHE_HOME` not reaching the sandboxed invocation), +# `resolve_one_locked_definition` skips the package with one of these +# messages and `spin_gizmo` falls back to a genuinely unknown symbol — which +# *does* raise a hard type error (unlike a degraded-but-loaded definition). +[[rule]] +file = "@run" +must_not_contain = "not in the local cache" + +[[rule]] +file = "@run" +must_not_contain = "no longer matches typr.lock" diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/TypR/main.ty b/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/TypR/main.ty new file mode 100644 index 0000000..351f368 --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/TypR/main.ty @@ -0,0 +1,9 @@ +#@case external-type-definition-low-tier-degrades-to-any: registry.md §5.4 (D2) — +# a locked external Type Definition entry below the project's `trust` threshold +# must degrade to a variadic `Any` function, never a hard type error. `gizmo` +# is declared `@spin_gizmo: (int) -> int;` at tier `T3` (typr.lock, cache/) but +# the project has no `typr.toml`, so the default `trust = "T2"` applies: +# `spin_gizmo` must load as callable-with-anything, not as its strict +# signature and not as a wholly unknown symbol. +let result <- spin_gizmo("not-an-int"); +print(result); diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/typr.lock b/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/typr.lock new file mode 100644 index 0000000..8af6f6f --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/typr.lock @@ -0,0 +1,10 @@ +# typr.lock — generated by `typr types`, commit this file. +# See typR/registry.md §7.1 and rfcs/0031-external-type-definitions.md. + +[[definition]] +package = "gizmo" +repository = "github:test/typr-gizmo" +version = "0.1.0" +rev = "0000000000000000000000000000000000000000" +digest = "sha256:35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88" +tier = "T3" diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/cache/typr/types/sprocket/1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7/typr-def.toml b/cases/0067-external-type-definition-old-version-degrades-to-any/cache/typr/types/sprocket/1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7/typr-def.toml new file mode 100644 index 0000000..e90d0a3 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/cache/typr/types/sprocket/1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7/typr-def.toml @@ -0,0 +1,13 @@ +format_version = 1 +[package] +name = "sprocket" +since = "2.0.0" +[definition] +version = "0.1.0" +tier = "T1" +[provider] +type = "community" +repository = "github:test/typr-sprocket" +[capabilities] +r_shims = false +extern_raw = false diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/case.toml b/cases/0067-external-type-definition-old-version-degrades-to-any/case.toml new file mode 100644 index 0000000..aeecdc1 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/case.toml @@ -0,0 +1,6 @@ +title = "a locked external Type Definition observed below its manifest's `since` floor degrades to Any even at tier T1" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-13" +origin = "perso" diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/expect.md b/cases/0067-external-type-definition-old-version-degrades-to-any/expect.md new file mode 100644 index 0000000..bf0b358 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/expect.md @@ -0,0 +1,34 @@ +# external-type-definition-old-version-degrades-to-any + +Source: `typR/registry.md` §7.2 ("Compatibilité de versions : borne minimale, pas plage +fermée") and §13 J2, last open checklist item. + +## Ce qui devrait se passer + +`sprocket` is locked at tier `T1` — normally trusted outright, regardless of the project's +`trust` threshold — but `typr.lock` records `r_version_seen = "1.0.0"`, below the manifest's +`since = "2.0.0"` floor. Per §7.2, a version below `since` must **warn and degrade to `Any`**, +never refuse the build, and the degradation applies to *every* declared name in the definition, +independent of that name's own tier (`standard_library::degrade_if_version_out_of_range` widens +`all_declared_names`, not just the ones under `trust`). + +`main.ty` calls `turn_sprocket("not-an-int")` — right arity, wrong argument type for the +declared `(int) -> int` — the same 3-way discriminator as case +`0066-external-type-definition-low-tier-degrades-to-any`: unknown symbol or an enforced strict +signature would both raise a hard type error; a correctly degraded `Any` function does not. + +## Vérification + +Implemented already: `crates/typr-cli/src/standard_library.rs` +(`degrade_if_version_out_of_range`, `version_less_than`) and +`crates/typr-cli/src/type_registry.rs` (`resolve_locked_definitions`, +`observed_r_package_version`). Previously only exercised by unit tests in those two files' +`#[cfg(test)]` modules (`load_project_type_definitions_degrades_when_locked_version_is_below_since`) +— this case is the same scenario replayed through the real `typr` binary via `typr case run`, +using a case-bundled `cache/` directory (`cases.rs::build_sandbox`, `$XDG_CACHE_HOME`) since the +real cache lives outside any project directory (§7.4). + +## Statut + +Kept as a regression net for the version-floor half of D2/§7.2, alongside case +`0066-external-type-definition-low-tier-degrades-to-any` for the tier half. diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/expect.toml b/cases/0067-external-type-definition-old-version-degrades-to-any/expect.toml new file mode 100644 index 0000000..5a2f588 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/expect.toml @@ -0,0 +1,18 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +# Same 3-way discriminator as case 0066: if cache/lock resolution were +# broken, `turn_sprocket` would fall back to a genuinely unknown symbol, +# which raises a hard type error instead of degrading. +[[rule]] +file = "@run" +must_not_contain = "not in the local cache" + +[[rule]] +file = "@run" +must_not_contain = "no longer matches typr.lock" diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/repro/TypR/main.ty b/cases/0067-external-type-definition-old-version-degrades-to-any/repro/TypR/main.ty new file mode 100644 index 0000000..49416a9 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/repro/TypR/main.ty @@ -0,0 +1,9 @@ +#@case external-type-definition-old-version-degrades-to-any: registry.md §7.2 — +# a locked external Type Definition whose observed R package version +# (`typr.lock`'s `r_version_seen`) is below the manifest's `since` floor must +# degrade to Any for every one of its names, even at tier `T1` (normally +# trusted outright). `sprocket` declares `@turn_sprocket: (int) -> int;` at +# tier `T1`, `since = "2.0.0"`, but was locked against R package `1.0.0` — +# below the floor. +let result <- turn_sprocket("not-an-int"); +print(result); diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/repro/typr.lock b/cases/0067-external-type-definition-old-version-degrades-to-any/repro/typr.lock new file mode 100644 index 0000000..42c93e5 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/repro/typr.lock @@ -0,0 +1,11 @@ +# typr.lock — generated by `typr types`, commit this file. +# See typR/registry.md §7.1 and rfcs/0031-external-type-definitions.md. + +[[definition]] +package = "sprocket" +repository = "github:test/typr-sprocket" +version = "0.1.0" +rev = "0000000000000000000000000000000000000000" +digest = "sha256:1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7" +tier = "T1" +r_version_seen = "1.0.0" diff --git a/cases/README.md b/cases/README.md index c876294..d0a02f0 100644 --- a/cases/README.md +++ b/cases/README.md @@ -15,6 +15,7 @@ cases/0001-pub-typed-fn-in-module/ expect.md # ce qui DEVRAIT se passer (prose), + localisation dans le code observed.txt # l'erreur / le R fautif au moment du report golden/ # (cas fixed) copie des R/*.R ciblés → diff de non-régression (jamais "@run") + cache/ # optionnel : voir "Bundler un cache" ci-dessous ``` `case.toml` : @@ -66,6 +67,36 @@ particulier à gérer. `@run` n'est **jamais** diffé en golden (le texte captur timings de la barre de progression CLI, qui varient à chaque run) — reste du grep pur, golden/ continue de ne cibler que des fichiers `R/*.R` déterministes. +## Bundler un cache (`cache/`) + +Certaines fonctionnalités lisent un cache **hors** du répertoire du projet par construction — +par exemple `~/.cache/typr/types///` pour une Type Definition externe verrouillée +dans `typr.lock` (`typR/registry.md` §7.4). Un `repro/` copié dans un sandbox temporaire ne peut +donc pas transporter ce cache avec lui, et le cas échouerait toujours en pratique (paquet +"absent du cache") plutôt que d'exercer le vrai comportement. + +Un sous-dossier `cache/` optionnel, sibling de `repro/`, résout ça : `cases.rs::build_sandbox` +le copie dans le sandbox et pointe `$XDG_CACHE_HOME` dessus pour la durée de l'invocation. Il +doit donc être disposé exactement comme `$XDG_CACHE_HOME` le serait, par ex. : + +``` +cases/00NN-mon-cas/ + cache/ + typr/types/// + typr-def.toml + ty/core.ty + repro/ + typr.lock # digest ci-dessus, même package + TypR/main.ty +``` + +Le digest doit correspondre exactement à ce que `typr.lock` déclare — c'est une vérification +mécanique (`type_registry::compute_digest`, ré-exécutée à chaque `check`/`build`/`run`), pas une +convention : un `cache/` désynchronisé du `typr.lock` du cas se traduit par un cas qui échoue +avec « no longer matches typr.lock », pas par un chargement silencieusement raté. Voir +`cases/0066-external-type-definition-low-tier-degrades-to-any/` pour un exemple complet. Sans +`cache/`, le comportement de `build_sandbox` est inchangé. + ## Capturer un cas depuis un projet en cours de dev (`snapshot`) Quand tu rencontres un bug en développant un package TypR ailleurs, **depuis la racine de ce diff --git a/crates/typr-cli/src/cases.rs b/crates/typr-cli/src/cases.rs index c2eeb34..ed21449 100644 --- a/crates/typr-cli/src/cases.rs +++ b/crates/typr-cli/src/cases.rs @@ -210,6 +210,15 @@ pub fn unique_tmp() -> PathBuf { } /// Copy `repro/` into a temp sandbox and run `typr ` (this same binary) inside it. +/// +/// If the case bundle also ships a `cache/` folder (sibling of `repro/`), it is copied into +/// the sandbox too and exposed as `$XDG_CACHE_HOME` for the invocation — the fix for +/// `registry.md` §13 J2's "cache portability" gap: `typr.lock` resolution reads from +/// `~/.cache/typr/types///` (`type_registry::cache_root`), which lives outside +/// `repro/` by construction (§7.4) and so was invisible to a case's sandboxed copy. Bundling a +/// `cache/` directory laid out exactly like `$XDG_CACHE_HOME` (i.e. `cache/typr/types/…`) lets +/// a case exercise real `typr.lock` resolution — tier/version degradation — without touching +/// the developer's real cache or the network. Cases with no `cache/` folder are unaffected. fn build_sandbox(case: &Path) -> Sandbox { let meta = read_meta(case); let repro = case.join("repro"); @@ -225,6 +234,15 @@ fn build_sandbox(case: &Path) -> Sandbox { .arg(&meta.cmd) .current_dir(&work) .env(crate::r_deps::SKIP_ENV_VAR, "1"); + let bundled_cache = case.join("cache"); + if bundled_cache.is_dir() { + let cache_work = tmp.join("cache"); + if let Err(e) = copy_dir(&bundled_cache, &cache_work) { + eprintln!("{RED}Impossible de copier le cache de {}: {e}{RESET}", case.display()); + std::process::exit(1); + } + command.env("XDG_CACHE_HOME", &cache_work); + } if meta.checked && (meta.cmd == "build" || meta.cmd == "run") { command.arg("--checked"); } diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index 0f44e68..f3c1ee8 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -190,6 +190,14 @@ enum Commands { #[command(subcommand)] types_command: TypesCommands, }, + /// List every Type Definition the `we-data-ch/registry` index has for a + /// package, ranked exactly as `typr types add`/`typr use` would pick + /// among them (registry.md §8.3, §13 J3) — a survey of the alternatives, + /// not a selection. Never fails a build: an unreachable registry or an + /// unlisted package are reported, not treated as errors. + Search { + package: String, + }, } #[derive(Subcommand, Debug)] @@ -200,8 +208,10 @@ enum TypesCommands { Add { /// The package this definition describes (e.g. `shiny`). package: String, - /// `github:owner/repo[@rev]`. - repo: String, + /// `github:owner/repo[@rev]`. Omit to resolve one automatically: an + /// explicit `typr.toml [types]` pin first, then a lookup in the + /// `we-data-ch/registry` (registry.md §13 J3). + repo: Option, }, /// Re-fetch and re-pin `typr.lock` for one package (or, with none given, /// every resolved definition). @@ -215,6 +225,19 @@ enum TypesCommands { #[arg(long, short, value_name = "DIR")] out: Option, }, + /// Run the mechanical checks of `typR/registry.md` §9 against a Type + /// Definition repository: manifest/capabilities, `.ty` parsing/type- + /// checking, `tests/smoke.ty`, exports and arity vs. `formals()` on the + /// locally installed package, and the T1 "no unconstrained `...`" + /// promotion gate. Exits 1 if any check fails (never on a `Skipped` one — + /// registry.md D2/D5). + Validate { + /// The package this definition describes (e.g. `shiny`). + package: String, + /// `github:owner/repo[@rev]`. Omit to resolve one automatically, the + /// same way `typr types add` does. + repo: Option, + }, } #[derive(Subcommand, Debug)] @@ -348,6 +371,7 @@ fn skips_r_deps_check(command: &Option) -> bool { | Some(Commands::Syntax { .. }) | Some(Commands::GenTypes { .. }) | Some(Commands::Types { .. }) + | Some(Commands::Search { .. }) ) } @@ -434,7 +458,10 @@ pub fn start() { }, Some(Commands::Document) => document(), Some(Commands::Pkgdown) => pkgdown(), - Some(Commands::Use { package_name }) => use_package(&package_name), + Some(Commands::Use { package_name }) => { + use_package(&package_name); + try_auto_resolve_type_definition(std::path::Path::new("."), &package_name); + } Some(Commands::Load) => load(), Some(Commands::Cran) => cran(), Some(Commands::Std { std_command }) => match std_command { @@ -476,6 +503,7 @@ pub fn start() { Some(Commands::Spg { output }) => generate_spg(output), Some(Commands::GenTypes { package, out }) => crate::gen_types::run(&package, out), Some(Commands::Types { types_command }) => run_types_command(types_command), + Some(Commands::Search { package }) => run_search_command(&package), _ => { println!("Please specify a subcommand or file to execute"); std::process::exit(1); @@ -595,20 +623,22 @@ fn run_types_command(command: TypesCommands) { let root = std::path::Path::new("."); match command { - TypesCommands::Add { package, repo } => match type_registry::add(root, &package, &repo) { - Ok(locked) => println!( - "{} — {} {} (tier {}, rev {}) → typr.lock", - locked.package, - locked.repository, - locked.version, - locked.tier, - &locked.rev[..locked.rev.len().min(12)] - ), - Err(e) => { - eprintln!("error: {e}"); - std::process::exit(1); - } - }, + TypesCommands::Add { package, repo } => { + let spec = match repo { + Some(spec) => spec, + None => match type_registry::resolve_spec_for_package(root, &package) { + Some(spec) => spec, + None => { + eprintln!( + "error: no definition found for `{package}` — no `typr.toml [types]` pin \ + and nothing in the registry; pass an explicit `github:owner/repo[@rev]`" + ); + std::process::exit(1); + } + }, + }; + run_types_add(root, &package, &spec); + } TypesCommands::Update { package } => match type_registry::update(root, package.as_deref()) { Ok(updated) if updated.is_empty() => println!("nothing to update — typr.lock is empty."), Ok(updated) => { @@ -651,5 +681,94 @@ fn run_types_command(command: TypesCommands) { std::process::exit(1); } }, + TypesCommands::Validate { package, repo } => { + let spec = match repo { + Some(spec) => spec, + None => match type_registry::resolve_spec_for_package(root, &package) { + Some(spec) => spec, + None => { + eprintln!( + "error: no definition found for `{package}` — no `typr.toml [types]` pin \ + and nothing in the registry; pass an explicit `github:owner/repo[@rev]`" + ); + std::process::exit(1); + } + }, + }; + let report = crate::registry_validate::validate(&package, &spec); + print!("{}", report.render()); + if !report.ok() { + std::process::exit(1); + } + } + } +} + +/// `typr search ` — survey what `we-data-ch/registry` has for `pkg`, +/// ranked exactly as `typr types add`/`typr use` would pick among them +/// (registry.md §8.3, §13 J3's `typr search` item). +fn run_search_command(package: &str) { + use crate::type_registry; + + match type_registry::search(package) { + Ok(entries) if entries.is_empty() => { + println!("no definitions found for `{package}` in the registry."); + } + Ok(entries) => { + for entry in entries { + let repo = match entry.rev.as_deref() { + Some(rev) if !rev.is_empty() => format!("github:{}@{}", entry.repository, rev), + _ => format!("github:{}", entry.repository), + }; + println!("{repo:<48} tier {:<3} {}", entry.tier, entry.source); + } + } + Err(e) => { + eprintln!("error: could not reach the registry: {e}"); + std::process::exit(1); + } + } +} + +/// Shared by `run_types_command`'s `Add` arm and `try_auto_resolve_type_definition` +/// — fetch, cache, and lock a resolved spec for `package`, printing the same +/// confirmation line either way. +fn run_types_add(root: &std::path::Path, package: &str, spec: &str) { + use crate::type_registry; + match type_registry::add(root, package, spec) { + Ok(locked) => println!( + "{} — {} {} (tier {}, rev {}) → typr.lock", + locked.package, + locked.repository, + locked.version, + locked.tier, + &locked.rev[..locked.rev.len().min(12)] + ), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } +} + +/// After `typr use ` adds the R dependency, best-effort resolve and pin +/// a type definition too — the remaining steps of registry.md §7.3's flow, +/// now that J3 gives them something to search (`we-data-ch/registry`). +/// Unlike `run_types_add`, this never exits the process: the R dependency was +/// just added successfully, and a definition lookup finding nothing (or +/// failing to fetch) must not turn that into a command failure — D2 again, +/// one level up: a missing or unusable type signal only ever leaves the +/// package untyped, it never blocks anything else `typr use` already did. +fn try_auto_resolve_type_definition(root: &std::path::Path, package: &str) { + use crate::type_registry; + let Some(spec) = type_registry::resolve_spec_for_package(root, package) else { + return; + }; + match type_registry::add(root, package, &spec) { + Ok(locked) => println!( + "found a type definition for `{}`: {} {} (tier {}) → typr.lock", + locked.package, locked.repository, locked.version, locked.tier + ), + Err(e) => eprintln!("warning: found a type definition for `{package}` but could not resolve it ({e})"), } } diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index 91c7f89..7595c57 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -54,6 +54,7 @@ pub mod r_deps; pub mod r_name_cache; pub mod r_name_lint; pub mod rd_renderer; +pub mod registry_validate; pub mod repl; pub mod standard_library; pub mod syntax; diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index 8086ce7..1f80a3d 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -17,6 +17,7 @@ mod r_deps; mod r_name_cache; mod r_name_lint; mod rd_renderer; +mod registry_validate; mod repl; mod standard_library; mod syntax; diff --git a/crates/typr-cli/src/registry_validate.rs b/crates/typr-cli/src/registry_validate.rs new file mode 100644 index 0000000..568ecb8 --- /dev/null +++ b/crates/typr-cli/src/registry_validate.rs @@ -0,0 +1,785 @@ +//! `typr types validate [--repo SPEC]` — the mechanically-verifiable +//! checks of `typR/registry.md` §9, run against a single Type Definition +//! repository (registry.md §13 J4, "contrôles mécaniques de §9, dont le diff +//! `formals()` contre le package installé"). +//! +//! Two checks named in §9 are deliberately out of scope here and stay open in +//! the J4 checklist: "schéma JSON du registre valide" is a property of +//! `we-data-ch/registry`'s own `packages/*.json` files, not of one definition +//! repository (it already gets a mechanical check for free every time +//! `type_registry::lookup_in_registry_dir`/`search_in_registry_dir` parses one +//! into `RegistryPackageFile` — a malformed file simply resolves to no +//! candidates, per D2 — but there is no *dedicated* validator yet that flags +//! *which* file is malformed); "revalidation périodique" (drift re-detection +//! over time) needs a place to run centrally (cron/CI on the registry itself), +//! which is the next J4 item, not this one. +//! +//! Every other §9 line is a [`CheckResult`] here: +//! +//! - `format_version` known, repository accessible, rev pinned, digest — +//! mostly free: `type_registry::fetch` already enforces/computes these +//! before this module sees anything. +//! - capabilities coherent with the real content — same, via `fetch`'s +//! returned warnings (`type_registry::check_capabilities`). +//! - the `.ty` files parse and type-check, and `tests/smoke.ty` compiles — +//! reuses `standard_library::load_external_ty_definitions`, the exact loop +//! a consuming project runs. +//! - every declared name is really exported by the installed package, and its +//! arity/`...`-ness matches `formals()` — the new piece: diffs +//! `parse_declared_entries`'s view of the `.ty` sources against +//! `gen_types::introspect`'s view of the installed R package. +//! - no `T1` entry with an unconstrained `...` — the RFC-STDLIB-0001 §7 +//! promotion gate (registry.md §6, "Promotion T3 → T2 → T1"), applied here +//! for the first time to *external* definitions rather than the bundled +//! stdlib. +//! - the package exists on CRAN — shelled out to `Rscript`'s own +//! `available.packages()` against the public CRAN mirror, the same +//! shell-out-rather-than-add-an-HTTP-client choice `gen_types.rs`/ +//! `type_registry.rs` already made for R/`git`. +//! +//! Every introspection-dependent check (exports, arity, CRAN) fails *open* +//! when `Rscript` is unavailable or the package isn't installed locally — +//! `Skipped`, never `Fail` — the same contract every other Rscript-dependent +//! path in this crate already follows (`gen_types.rs`, `r_name_cache.rs`). +//! Only a check that could actually run and found something wrong reports +//! `Fail`; `typr types validate`'s exit code is 1 only in that case. + +use crate::gen_types; +use crate::standard_library; +use crate::type_registry::{self, FetchedDefinition, RepoSpec}; +use std::collections::HashMap; +use std::fs; +use std::process::Command; +use typr_core::components::context::Context; +use typr_core::processes::spg::stdlib_meta::parse_meta_from_source; + +// --------------------------------------------------------------------- +// Report shape +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckStatus { + Pass, + Warn, + Fail, + /// Could not run (missing `Rscript`/`git`, package not installed + /// locally, network unreachable, no `tests/smoke.ty` present, …) — never + /// a reason to fail the build (registry.md D2). + Skipped, +} + +#[derive(Debug, Clone)] +pub struct CheckResult { + pub name: &'static str, + pub status: CheckStatus, + pub detail: String, +} + +impl CheckResult { + fn pass(name: &'static str, detail: impl Into) -> Self { + CheckResult { name, status: CheckStatus::Pass, detail: detail.into() } + } + fn warn(name: &'static str, detail: impl Into) -> Self { + CheckResult { name, status: CheckStatus::Warn, detail: detail.into() } + } + fn fail(name: &'static str, detail: impl Into) -> Self { + CheckResult { name, status: CheckStatus::Fail, detail: detail.into() } + } + fn skipped(name: &'static str, detail: impl Into) -> Self { + CheckResult { name, status: CheckStatus::Skipped, detail: detail.into() } + } +} + +#[derive(Debug, Clone)] +pub struct ValidationReport { + pub package: String, + pub repository: String, + pub definition_version: String, + pub checks: Vec, +} + +impl ValidationReport { + /// `false` when any check is `Fail` — everything else (`Warn`, + /// `Skipped`) is, per D5, information to display, never a build-blocking + /// signal on its own. + pub fn ok(&self) -> bool { + !self.checks.iter().any(|c| c.status == CheckStatus::Fail) + } + + /// The nominative report of registry.md §9: what was verified, named, + /// never a single green badge. + pub fn render(&self) -> String { + let mut out = format!("{} — {} (definition v{})\n", self.package, self.repository, self.definition_version); + for c in &self.checks { + let word = match c.status { + CheckStatus::Pass => "ok", + CheckStatus::Warn => "warning", + CheckStatus::Fail => "FAILED", + CheckStatus::Skipped => "not checked", + }; + out.push_str(&format!(" {:<26} {:<12} {}\n", c.name, word, c.detail)); + } + out + } +} + +// --------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------- + +/// Fetch `spec_str` (`github:owner/repo[@rev]`) and run every check below +/// against it. Always cleans up the temporary clone, success or failure. +pub fn validate(package: &str, spec_str: &str) -> ValidationReport { + let spec = match RepoSpec::parse(spec_str) { + Ok(s) => s, + Err(e) => { + return ValidationReport { + package: package.to_string(), + repository: spec_str.to_string(), + definition_version: "unknown".to_string(), + checks: vec![CheckResult::fail("repository spec", e)], + } + } + }; + + let (fetched, warnings) = match type_registry::fetch(&spec) { + Ok(ok) => ok, + Err(e) => { + return ValidationReport { + package: package.to_string(), + repository: spec.display(), + definition_version: "unknown".to_string(), + checks: vec![CheckResult::fail("fetch", e)], + } + } + }; + + let report = validate_fetched(package, &spec.display(), spec.rev.is_some(), &fetched, &warnings); + let _ = fs::remove_dir_all(&fetched.dir); + report +} + +// --------------------------------------------------------------------- +// The checks themselves — pure given an already-fetched definition, so +// this is directly testable against a hand-built temp directory with no +// `git`/network involved (see `tests` below). +// --------------------------------------------------------------------- + +fn validate_fetched( + package: &str, + repository: &str, + rev_pinned: bool, + fetched: &FetchedDefinition, + capability_warnings: &[String], +) -> ValidationReport { + let mut checks = Vec::new(); + + checks.push(CheckResult::pass("format_version", fetched.manifest.format_version.to_string())); + + checks.push(CheckResult::pass("repository accessible", format!("cloned @ {}", short_rev(&fetched.rev)))); + checks.push(if rev_pinned { + CheckResult::pass("rev pinned", &fetched.rev) + } else { + CheckResult::warn( + "rev pinned", + "resolved from the default branch's HEAD — pass @ to pin reproducibly (registry.md D4)", + ) + }); + checks.push(CheckResult::pass("digest", fetched.digest.clone())); + + if capability_warnings.is_empty() { + checks.push(CheckResult::pass("capabilities", "no undeclared R; r_shims/extern_raw not declared")); + } else { + checks.push(CheckResult::warn("capabilities", capability_warnings.join(" "))); + } + + let all_files = type_registry::tracked_files(&fetched.dir).unwrap_or_default(); + // Declarations first, `tests/` last — a plain lexicographic walk would + // interleave them by accident ("tests/…" sorts before "ty/…") and + // type-check a smoke test against a context that doesn't have its own + // definitions loaded yet. + let mut declaration_files: Vec<&std::path::PathBuf> = Vec::new(); + let mut test_files: Vec<&std::path::PathBuf> = Vec::new(); + for rel in &all_files { + if rel.extension().and_then(|e| e.to_str()) != Some("ty") { + continue; + } + if rel.starts_with("tests") { + test_files.push(rel); + } else { + declaration_files.push(rel); + } + } + let mut ty_sources: Vec<(String, String)> = Vec::new(); + for rel in declaration_files.into_iter().chain(test_files) { + let content = fs::read_to_string(fetched.dir.join(rel)).unwrap_or_default(); + ty_sources.push((rel.to_string_lossy().replace('\\', "/"), content)); + } + + let sources_ref: Vec<(&str, &str)> = ty_sources.iter().map(|(f, s)| (f.as_str(), s.as_str())).collect(); + // trust = "T1" here has no bearing on this check: `load_external_ty_definitions` + // only *degrades* entries below trust after they've already + // parsed/type-checked, and `skipped` records failures from before that + // point — this just avoids implying a trust decision that isn't ours to + // make in a validator. + let (_context, skipped) = + standard_library::load_external_ty_definitions(Context::default(), &sources_ref, &fetched.manifest.definition.tier, "T1"); + + if ty_sources.is_empty() { + checks.push(CheckResult::warn(".ty parse/type-check", "no .ty file found in this repository")); + } else if skipped.is_empty() { + checks.push(CheckResult::pass(".ty parse/type-check", format!("{} file(s) OK", ty_sources.len()))); + } else { + let names: Vec<&str> = skipped.iter().map(|(f, _)| f.as_str()).collect(); + checks.push(CheckResult::fail( + ".ty parse/type-check", + format!("{}/{} file(s) failed: {}", skipped.len(), ty_sources.len(), names.join(", ")), + )); + } + + const SMOKE_PATH: &str = "tests/smoke.ty"; + match ty_sources.iter().any(|(f, _)| f == SMOKE_PATH) { + true => match skipped.iter().find(|(f, _)| f == SMOKE_PATH) { + Some((_, message)) => checks.push(CheckResult::fail("tests/smoke.ty", message.clone())), + None => checks.push(CheckResult::pass("tests/smoke.ty", "compiles")), + }, + false => checks.push(CheckResult::skipped("tests/smoke.ty", "not present in this repository")), + } + + // Declared names/arity/tier — deliberately excludes `tests/`, which + // exercises the API rather than declaring it (registry.md §5.1). + let declared_sources: Vec<(String, String)> = + ty_sources.iter().filter(|(f, _)| !f.starts_with("tests/")).cloned().collect(); + let declared = parse_declared_entries(&declared_sources, &fetched.manifest.definition.tier); + + let t1_entries: Vec<&DeclaredEntry> = declared.iter().filter(|e| e.tier.as_deref() == Some("T1")).collect(); + let t1_violations: Vec<&str> = t1_entries + .iter() + .filter(|e| e.params.iter().any(|p| p.is_variadic && p.type_text.trim() == "Any")) + .map(|e| e.name.as_str()) + .collect(); + if t1_violations.is_empty() { + checks.push(CheckResult::pass( + "T1 promotion gate", + format!("{} T1 entrie(s), none with an unconstrained `...`", t1_entries.len()), + )); + } else { + checks.push(CheckResult::fail("T1 promotion gate", format!("unconstrained `...` at T1: {}", t1_violations.join(", ")))); + } + + if !gen_types::rscript_available() { + checks.push(CheckResult::skipped("exports vs formals()", "Rscript not on PATH")); + checks.push(CheckResult::skipped("arity vs formals()", "Rscript not on PATH")); + checks.push(CheckResult::skipped("package on CRAN", "Rscript not on PATH")); + } else { + match gen_types::introspect(package) { + Ok(info) if !info.functions.is_empty() => { + push_formals_diff(&mut checks, &declared, &info.functions); + } + _ => { + checks.push(CheckResult::skipped("exports vs formals()", format!("`{package}` not installed locally"))); + checks.push(CheckResult::skipped("arity vs formals()", format!("`{package}` not installed locally"))); + } + } + + match check_cran_availability(package) { + Some(true) => checks.push(CheckResult::pass("package on CRAN", "found on cloud.r-project.org")), + Some(false) => { + checks.push(CheckResult::warn("package on CRAN", "not found on CRAN — may be R-universe/Bioconductor/GitHub-only")) + } + None => checks.push(CheckResult::skipped("package on CRAN", "could not reach the CRAN mirror")), + } + } + + ValidationReport { + package: package.to_string(), + repository: repository.to_string(), + definition_version: fetched.manifest.definition.version.clone(), + checks, + } +} + +fn short_rev(rev: &str) -> &str { + &rev[..rev.len().min(12)] +} + +/// Diff `declared` (what the `.ty` sources say) against `installed` (what +/// `formals()` says on the real, locally installed package) — the "diff +/// `formals()` contre le package installé" registry.md §13 J4 names +/// explicitly. Pushes both the "exports vs formals()" and "arity vs +/// formals()" checks. +fn push_formals_diff(checks: &mut Vec, declared: &[DeclaredEntry], installed_fns: &[gen_types::GeneratedFn]) { + let installed: HashMap<&str, &gen_types::GeneratedFn> = installed_fns.iter().map(|f| (f.name.as_str(), f)).collect(); + + let mut missing = Vec::new(); + let mut mismatches = Vec::new(); + for entry in declared { + match installed.get(entry.name.as_str()) { + None => missing.push(entry.name.clone()), + Some(f) => { + let declared_fixed = entry.params.iter().filter(|p| !p.is_variadic).count(); + let declared_variadic = entry.params.iter().any(|p| p.is_variadic); + if declared_fixed != f.params.len() || declared_variadic != f.has_dots { + mismatches.push(format!( + "{} (declared {} arg(s){}, formals() has {} arg(s){})", + entry.name, + declared_fixed, + if declared_variadic { "+..." } else { "" }, + f.params.len(), + if f.has_dots { "+..." } else { "" } + )); + } + } + } + } + + let total = declared.len(); + let found = total - missing.len(); + if missing.is_empty() { + checks.push(CheckResult::pass("exports vs formals()", format!("{found}/{total} found"))); + } else { + checks.push(CheckResult::fail("exports vs formals()", format!("{found}/{total} found — missing: {}", missing.join(", ")))); + } + if mismatches.is_empty() { + checks.push(CheckResult::pass("arity vs formals()", format!("{total}/{total} match"))); + } else { + checks.push(CheckResult::fail( + "arity vs formals()", + format!("{} mismatch(es): {}", mismatches.len(), mismatches.join("; ")), + )); + } +} + +/// Best-effort: is `pkg` on CRAN, via `Rscript`'s own +/// `available.packages()` against the public mirror — the same +/// shell-out-to-`Rscript` choice `gen_types.rs` already made, rather than +/// adding an HTTP client dependency for one query. `None` (never a `Fail`) +/// when `Rscript` errors, the mirror is unreachable, or `pkg` isn't a plain +/// package-name-shaped string (never interpolated into R source otherwise — +/// same guard as `gen_types::is_generatable_name`). +fn check_cran_availability(pkg: &str) -> Option { + if pkg.is_empty() || !pkg.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_') { + return None; + } + let expr = format!( + "ap <- tryCatch(available.packages(repos = \"https://cloud.r-project.org\"), error = function(e) NULL); \ + if (is.null(ap)) cat(\"NETERR\") else if (\"{pkg}\" %in% rownames(ap)) cat(\"YES\") else cat(\"NO\")" + ); + let output = Command::new("Rscript").arg("-e").arg(&expr).output().ok()?; + if !output.status.success() { + return None; + } + match String::from_utf8_lossy(&output.stdout).trim() { + "YES" => Some(true), + "NO" => Some(false), + _ => None, + } +} + +// --------------------------------------------------------------------- +// Declared signature parsing — arity, parameter names and tier straight +// from the `.ty` source text, independent of the type-checker's own +// preprocessing (which strips parameter names before parsing, since the +// type grammar doesn't accept them — `standard_library::preprocess_ty_source`). +// --------------------------------------------------------------------- + +struct DeclaredParam { + is_variadic: bool, + type_text: String, +} + +struct DeclaredEntry { + /// Display name, backticks stripped. + name: String, + /// Effective tier: the entry's own `#! tier:`, or the manifest's + /// `[definition] tier` when absent. + tier: Option, + params: Vec, +} + +fn parse_declared_entries(ty_sources: &[(String, String)], default_tier: &str) -> Vec { + let mut out = Vec::new(); + for (_file, source) in ty_sources { + let meta_map = parse_meta_from_source(source); + for line in source.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('@') { + continue; + } + let Some(sig) = parse_signature_line(trimmed) else { continue }; + let tier = meta_map + .get(&sig.raw_name) + .and_then(|m| m.tier.clone()) + .or_else(|| Some(default_tier.to_string())); + out.push(DeclaredEntry { name: unwrap_backtick(&sig.raw_name), tier, params: sig.params }); + } + } + out +} + +struct ParsedSignature { + /// Exactly what `#! tier:`-map lookups need to match against — keeps + /// surrounding backticks, since `parse_meta_from_source` keys its map the + /// same way. + raw_name: String, + params: Vec, +} + +fn unwrap_backtick(name: &str) -> String { + name.trim_matches('`').to_string() +} + +/// Parse one `@name: (...) -> Ret;` (or `@extern pkg::name: ...;`) line into +/// its raw name and parameter list. `None` for a non-function declaration +/// (`type X <- Foreign;`, a bare `@x: int;` constant, …) — those don't +/// carry an arity to diff against `formals()`. +fn parse_signature_line(line: &str) -> Option { + let rest = line.strip_prefix('@')?; + let sep = find_unqualified_colon(rest)?; + let head = &rest[..sep]; + let raw_name = head + .strip_prefix("extern ") + .map(|n| n.rsplit("::").next().unwrap_or(n)) + .unwrap_or(head) + .trim(); + if raw_name.is_empty() { + return None; + } + let sig = rest[sep + 1..].trim(); + let sig = sig.strip_suffix(';').unwrap_or(sig).trim(); + if !sig.starts_with('(') { + return None; + } + let params = parse_param_list(sig)?; + Some(ParsedSignature { raw_name: raw_name.to_string(), params }) +} + +/// The first `:` in `text` that is not part of a `::` (which appears in +/// `@extern pkg::name`) — same rule stdlib_meta.rs's private +/// `extract_signature_name` uses, reimplemented here since that one isn't +/// exported across the crate boundary. +fn find_unqualified_colon(text: &str) -> Option { + let bytes = text.as_bytes(); + for (i, &b) in bytes.iter().enumerate() { + if b == b':' { + let prev = if i > 0 { bytes[i - 1] } else { 0 }; + let next = if i + 1 < bytes.len() { bytes[i + 1] } else { 0 }; + if prev != b':' && next != b':' { + return Some(i); + } + } + } + None +} + +/// `sig` starts with `(` — find its matching `)` (bracket-depth aware, so a +/// nested function-type or generic parameter doesn't confuse the split) and +/// parse the top-level comma-separated parameter list inside. +fn parse_param_list(sig: &str) -> Option> { + let chars: Vec = sig.chars().collect(); + let mut depth = 0i32; + let mut close_idx = None; + for (i, &c) in chars.iter().enumerate() { + match c { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => { + depth -= 1; + if depth == 0 && c == ')' { + close_idx = Some(i); + break; + } + } + _ => {} + } + } + let close_idx = close_idx?; + let inner: String = chars[1..close_idx].iter().collect(); + Some(split_top_level(&inner).into_iter().map(|p| parse_one_param(&p)).collect()) +} + +fn split_top_level(inner: &str) -> Vec { + let trimmed = inner.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + let mut parts = Vec::new(); + let mut depth = 0i32; + let mut current = String::new(); + for c in trimmed.chars() { + match c { + '(' | '[' | '{' => { + depth += 1; + current.push(c); + } + ')' | ']' | '}' => { + depth -= 1; + current.push(c); + } + ',' if depth == 0 => { + parts.push(current.trim().to_string()); + current.clear(); + } + _ => current.push(c), + } + } + if !current.trim().is_empty() { + parts.push(current.trim().to_string()); + } + parts +} + +fn parse_one_param(text: &str) -> DeclaredParam { + let (is_variadic, rest) = match text.strip_prefix("...") { + Some(r) => (true, r.trim()), + None => (false, text.trim()), + }; + if rest.is_empty() { + return DeclaredParam { is_variadic, type_text: "Any".to_string() }; + } + match find_unqualified_colon(rest) { + Some(colon_idx) => DeclaredParam { is_variadic, type_text: rest[colon_idx + 1..].trim().to_string() }, + None => DeclaredParam { is_variadic, type_text: rest.to_string() }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::type_definition::{parse_manifest, DefinitionManifest}; + use std::path::{Path, PathBuf}; + + // -- Signature parsing -------------------------------------------- + + #[test] + fn parses_unnamed_generated_style_signature() { + let sig = parse_signature_line("@filter: (Any, ...Any) -> Any;").unwrap(); + assert_eq!(sig.raw_name, "filter"); + assert_eq!(sig.params.len(), 2); + assert!(!sig.params[0].is_variadic); + assert_eq!(sig.params[0].type_text, "Any"); + assert!(sig.params[1].is_variadic); + assert_eq!(sig.params[1].type_text, "Any"); + } + + #[test] + fn parses_named_hand_authored_signature() { + let sig = parse_signature_line("@cat: (...values: Any) -> Empty;").unwrap(); + assert_eq!(sig.raw_name, "cat"); + assert_eq!(sig.params.len(), 1); + assert!(sig.params[0].is_variadic); + assert_eq!(sig.params[0].type_text, "Any"); + } + + #[test] + fn parses_extern_pkg_double_colon_signature() { + let sig = parse_signature_line("@extern jsonlite::toJSON: (Any) -> char;").unwrap(); + assert_eq!(sig.raw_name, "toJSON"); + assert_eq!(sig.params.len(), 1); + } + + #[test] + fn parses_backtick_quoted_name() { + let sig = parse_signature_line("@`is.numeric`: (Any) -> bool;").unwrap(); + assert_eq!(sig.raw_name, "`is.numeric`"); + } + + #[test] + fn nested_higher_order_param_does_not_confuse_arity() { + let sig = parse_signature_line("@apply_fn: (f: (int) -> int, x: int) -> int;").unwrap(); + assert_eq!(sig.params.len(), 2); + assert_eq!(sig.params[0].type_text, "(int) -> int"); + assert_eq!(sig.params[1].type_text, "int"); + } + + #[test] + fn non_function_declaration_is_not_a_signature() { + assert!(parse_signature_line("type DataFrame <- Foreign;").is_none()); + } + + // -- Declared entries + tier fallback ------------------------------- + + #[test] + fn own_tier_annotation_wins_over_manifest_default() { + let src = "#! tier: T1\n@a: (Any) -> Any;\n\n@b: (Any) -> Any;\n"; + let declared = parse_declared_entries(&[("core.ty".to_string(), src.to_string())], "T3"); + let a = declared.iter().find(|e| e.name == "a").unwrap(); + let b = declared.iter().find(|e| e.name == "b").unwrap(); + assert_eq!(a.tier.as_deref(), Some("T1")); + assert_eq!(b.tier.as_deref(), Some("T3")); + } + + // -- T1 promotion gate + formals() diff, via `validate_fetched` ----- + + fn manifest(tier: &str) -> DefinitionManifest { + parse_manifest(&format!( + "format_version = 1\n\ + [package]\nname = \"shiny\"\nsince = \"1.11.0\"\n\ + [definition]\nversion = \"0.3.0\"\ntier = \"{tier}\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:alice/typr-shiny\"\n" + )) + .unwrap() + } + + fn write_fetched(subdir: &str, files: &[(&str, &str)]) -> FetchedDefinition { + let dir = std::env::temp_dir().join(format!("typr_registry_validate_{subdir}_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + for (rel, content) in files { + let path = dir.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, content).unwrap(); + } + FetchedDefinition { manifest: manifest("T2"), rev: "deadbeefcafef00d".to_string(), digest: "sha256:test".to_string(), dir } + } + + fn cleanup(fetched: &FetchedDefinition) { + let _ = fs::remove_dir_all(&fetched.dir); + } + + #[test] + fn clean_definition_passes_the_non_introspection_checks() { + let fetched = write_fetched( + "clean", + &[("ty/core.ty", "#! tier: T2\n@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n")], + ); + let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + + let by_name = |n: &str| report.checks.iter().find(|c| c.name == n).unwrap(); + assert_eq!(by_name("format_version").status, CheckStatus::Pass); + assert_eq!(by_name("rev pinned").status, CheckStatus::Pass); + assert_eq!(by_name("capabilities").status, CheckStatus::Pass); + assert_eq!(by_name(".ty parse/type-check").status, CheckStatus::Pass); + assert_eq!(by_name("tests/smoke.ty").status, CheckStatus::Skipped); + assert_eq!(by_name("T1 promotion gate").status, CheckStatus::Pass); + } + + #[test] + fn unpinned_rev_warns() { + let fetched = write_fetched("unpinned", &[("ty/core.ty", "@f: (Any) -> Any;\n")]); + let report = validate_fetched("shiny", "github:alice/typr-shiny", false, &fetched, &[]); + cleanup(&fetched); + let rev_check = report.checks.iter().find(|c| c.name == "rev pinned").unwrap(); + assert_eq!(rev_check.status, CheckStatus::Warn); + } + + /// A package name no real registry/CRAN package will ever have — + /// guarantees `gen_types::introspect` finds nothing installed, so tests + /// that assert on `report.ok()` as a whole aren't at the mercy of what + /// happens to be installed on the machine running the suite (unlike + /// `formals_diff_flags_a_missing_export_and_an_arity_mismatch`, below, + /// which deliberately wants a real installed package). + const FAKE_PACKAGE: &str = "typr_registry_validate_fixture_zzz"; + + #[test] + fn declared_capability_warning_is_surfaced_not_failed() { + let fetched = write_fetched("capwarn", &[("ty/core.ty", "@f: (Any) -> Any;\n")]); + let report = + validate_fetched(FAKE_PACKAGE, "github:alice/typr-shiny", true, &fetched, &["ships R shims".to_string()]); + cleanup(&fetched); + let cap = report.checks.iter().find(|c| c.name == "capabilities").unwrap(); + assert_eq!(cap.status, CheckStatus::Warn); + assert!(report.ok(), "a declared capability must not fail validation"); + } + + #[test] + fn broken_ty_source_fails_the_parse_check() { + // `fn(x)` with no parameter type hits the dedicated + // `SyntaxError::FunctionWithoutType` panic in `parsing/elements.rs` + // (see `standard_library.rs::tests::broken_ty_source_is_reported_as_skipped_with_a_real_message`). + let fetched = write_fetched("broken", &[("ty/core.ty", "let f <- fn(x) { x };\n")]); + let report = validate_fetched(FAKE_PACKAGE, "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + let parse_check = report.checks.iter().find(|c| c.name == ".ty parse/type-check").unwrap(); + assert_eq!(parse_check.status, CheckStatus::Fail); + assert!(!report.ok()); + } + + #[test] + fn present_smoke_test_is_checked_and_passes_when_it_typechecks() { + let fetched = write_fetched( + "smoke", + &[ + ("ty/core.ty", "@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n"), + ("tests/smoke.ty", "let x <- fluidPage(1);\n"), + ], + ); + let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + let smoke = report.checks.iter().find(|c| c.name == "tests/smoke.ty").unwrap(); + assert_eq!(smoke.status, CheckStatus::Pass); + } + + #[test] + fn t1_entry_with_unconstrained_variadic_fails_the_promotion_gate() { + let dir = std::env::temp_dir().join(format!("typr_registry_validate_t1gate_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let rel = Path::new("ty/core.ty"); + let path: PathBuf = dir.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "#! tier: T1\n@risky: (...values: Any) -> Any;\n").unwrap(); + let fetched = + FetchedDefinition { manifest: manifest("T2"), rev: "abc123".to_string(), digest: "sha256:test".to_string(), dir }; + + let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + + let gate = report.checks.iter().find(|c| c.name == "T1 promotion gate").unwrap(); + assert_eq!(gate.status, CheckStatus::Fail); + assert!(gate.detail.contains("risky"), "unexpected detail: {}", gate.detail); + assert!(!report.ok()); + } + + #[test] + fn t1_entry_with_constrained_variadic_passes_the_gate() { + let fetched = + write_fetched("t1ok", &[("ty/core.ty", "#! tier: T1\n@safe: (...values: int) -> Any;\n")]); + let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + let gate = report.checks.iter().find(|c| c.name == "T1 promotion gate").unwrap(); + assert_eq!(gate.status, CheckStatus::Pass); + } + + // -- formals() diff, against a real installed package (fail-open like + // every other Rscript-dependent test in this crate: skipped, not + // failed, when R/the package isn't available on this machine — see + // `gen_types.rs::generated_ty_type_checks_for_contrasting_packages`) -- + + #[test] + fn formals_diff_flags_a_missing_export_and_an_arity_mismatch() { + if !gen_types::rscript_available() { + eprintln!("skipping: Rscript not on PATH"); + return; + } + let info = match gen_types::introspect("jsonlite") { + Ok(info) if !info.functions.is_empty() => info, + _ => { + eprintln!("skipping: jsonlite not installed on this machine"); + return; + } + }; + let real = info.functions.iter().find(|f| f.name == "toJSON"); + let Some(real) = real else { + eprintln!("skipping: jsonlite::toJSON not found by introspection"); + return; + }; + // `toJSON` declared with one extra fixed argument beyond its real + // `formals()` — a real, existing export with the wrong arity — plus + // a name that plain doesn't exist in the package at all. + let wrong_arity_args = std::iter::repeat("Any").take(real.params.len() + 1).collect::>().join(", "); + let src = format!( + "@extern jsonlite::toJSON: ({wrong_arity_args}) -> char;\n\ + @extern jsonlite::doesNotExist: (Any) -> char;\n" + ); + let fetched = write_fetched("formals", &[("ty/core.ty", &src)]); + let report = validate_fetched("jsonlite", "github:alice/typr-jsonlite", true, &fetched, &[]); + cleanup(&fetched); + + let exports = report.checks.iter().find(|c| c.name == "exports vs formals()").unwrap(); + assert_eq!(exports.status, CheckStatus::Fail); + assert!(exports.detail.contains("doesNotExist"), "unexpected detail: {}", exports.detail); + + let arity = report.checks.iter().find(|c| c.name == "arity vs formals()").unwrap(); + assert_eq!(arity.status, CheckStatus::Fail); + assert!(arity.detail.contains("toJSON"), "unexpected detail: {}", arity.detail); + } +} diff --git a/crates/typr-cli/src/type_registry.rs b/crates/typr-cli/src/type_registry.rs index b8c956f..9efbd45 100644 --- a/crates/typr-cli/src/type_registry.rs +++ b/crates/typr-cli/src/type_registry.rs @@ -242,8 +242,10 @@ fn now_millis() -> u128 { /// Every file under `dir` (relative paths, `/`-separated, sorted), skipping /// `.git` — the exact set of bytes the content digest and the cache/vendor -/// copies are built from. -fn tracked_files(dir: &Path) -> Result, String> { +/// copies are built from. `pub(crate)` so `registry_validate.rs` can walk a +/// freshly fetched definition the same way `resolve_one_locked_definition` +/// walks a cached one, rather than re-implementing directory traversal. +pub(crate) fn tracked_files(dir: &Path) -> Result, String> { let mut out = BTreeSet::new(); collect_files(dir, dir, &mut out)?; Ok(out.into_iter().collect()) @@ -469,6 +471,256 @@ impl TypesConfig { } } +// --------------------------------------------------------------------- +// Registry lookup (registry.md §13 J3 — "résolution par le registre dans +// `typr add`, `typr types update`") +// --------------------------------------------------------------------- + +/// The one community registry this build knows how to query +/// (`we-data-ch/registry`, registry.md §8.1) — same one-host restriction as +/// `RepoSpec` only understanding `github:`. +const REGISTRY_REPO_URL: &str = "https://github.com/we-data-ch/registry.git"; + +/// Deserializes one entry of a `packages/.json` `definitions` array +/// (registry.md §8.1). Only the fields the selection logic below needs; +/// anything else in the file (e.g. `capabilities`) is not read here — the +/// real capability gate is enforced later, on the fetched repository itself, +/// by `check_capabilities`. +#[derive(Debug, Clone, Deserialize)] +struct RegistryDefinitionEntry { + /// `owner/repo`, no `github:` scheme (registry.md §8.1's example). + repository: String, + #[serde(default)] + rev: Option, + /// `official` | `community` | `generated` | `local`. + #[serde(default)] + source: String, + /// `T1` | `T2` | `T3`. + #[serde(default)] + tier: String, +} + +#[derive(Debug, Clone, Deserialize, Default)] +struct RegistryPackageFile { + #[serde(default)] + definitions: Vec, +} + +/// registry.md §8.3's conflict order, restricted to what a registry file can +/// express — an explicit user pin and a locally generated definition are +/// resolved before this is ever consulted (`resolve_spec_for_package`, +/// below): official first, then community, then anything else. An +/// unrecognized `source` string ranks last rather than erroring — same D2 +/// instinct as `standard_library::tier_rank` for an unreadable tier: a signal +/// this build cannot read must never be trusted more than one it can. +fn provider_rank(source: &str) -> u8 { + match source { + "official" => 3, + "community" => 2, + "generated" => 1, + _ => 0, + } +} + +/// Same ranking as `standard_library::tier_rank`, duplicated locally rather +/// than shared: that function ranks a *loaded* entry's trust for the +/// degrade-to-`Any` decision, this one ranks *candidates* before anything is +/// fetched — different callers, same T1 > T2 > T3 intuition. +fn tier_rank_for_selection(tier: &str) -> u8 { + match tier { + "T1" => 3, + "T2" => 2, + "T3" => 1, + _ => 0, + } +} + +/// Highest-ranked entry in `file.definitions`, ties broken by whichever comes +/// first in the file (no editorial ordering beyond tier/provenance — Q3, +/// registry.md §14, is still open). `None` when the file lists nothing. +fn pick_best_entry(file: &RegistryPackageFile) -> Option<&RegistryDefinitionEntry> { + let mut best: Option<&RegistryDefinitionEntry> = None; + let mut best_rank = (0u8, 0u8); + for entry in &file.definitions { + let rank = (provider_rank(&entry.source), tier_rank_for_selection(&entry.tier)); + if best.is_none() || rank > best_rank { + best = Some(entry); + best_rank = rank; + } + } + best +} + +/// `~/.cache/typr/registry-index/` — a local mirror of `we-data-ch/registry`, +/// refreshed in place rather than re-cloned on every lookup. +fn registry_index_dir() -> Option { + crate::r_deps::cache_home().map(|dir| dir.join("typr").join("registry-index")) +} + +/// Clone (first use) or fast-forward `git pull` (subsequent uses) the local +/// registry mirror, returning its path. +fn sync_registry_index() -> Result { + let dir = registry_index_dir() + .ok_or_else(|| "could not determine a cache directory (no $HOME/$XDG_CACHE_HOME)".to_string())?; + + if dir.join(".git").is_dir() { + let pull = Command::new("git") + .arg("-C") + .arg(&dir) + .args(["pull", "--quiet", "--ff-only"]) + .output() + .map_err(|e| format!("could not run `git pull`: {e}"))?; + if !pull.status.success() { + return Err(format!( + "`git pull` in {} failed: {}", + dir.display(), + String::from_utf8_lossy(&pull.stderr).trim() + )); + } + return Ok(dir); + } + + let _ = fs::remove_dir_all(&dir); + if let Some(parent) = dir.parent() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + let clone = Command::new("git") + .args(["clone", "--quiet", "--depth", "1"]) + .arg(REGISTRY_REPO_URL) + .arg(&dir) + .output() + .map_err(|e| format!("could not run `git clone`: {e}"))?; + if !clone.status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(format!( + "`git clone {REGISTRY_REPO_URL}` failed: {}", + String::from_utf8_lossy(&clone.stderr).trim() + )); + } + Ok(dir) +} + +/// Look up `packages/.json` in `we-data-ch/registry` and, when it +/// lists at least one usable entry, return a `github:owner/repo[@rev]` spec +/// string for the best one (`pick_best_entry`) — ready to hand straight to +/// `add`/`fetch`, exactly like a spec the user typed by hand. +/// +/// Fails open at every step: no `git`, an unreachable registry, no entry for +/// `package`, or an entry whose `repository` field is not `owner/repo` all +/// resolve to `None`, never an error the caller must special-case — "the +/// registry has nothing to say" is exactly as safe as "no definition at all" +/// (D2, registry.md §0/§5.4: an absent or unusable signal never blocks +/// anything, it just leaves the package untyped). +pub fn lookup_in_registry(package: &str) -> Option { + if !git_available() { + return None; + } + let dir = match sync_registry_index() { + Ok(dir) => dir, + Err(e) => { + eprintln!("warning: could not reach the registry ({e}) — skipping automatic type-definition lookup for `{package}`"); + return None; + } + }; + lookup_in_registry_dir(&dir, package) +} + +/// The JSON-reading and entry-selection half of `lookup_in_registry`, kept +/// separate from syncing the index so it can be unit-tested against a +/// hand-written `packages/` directory instead of a real clone of +/// `we-data-ch/registry`. +fn lookup_in_registry_dir(dir: &Path, package: &str) -> Option { + let path = dir.join("packages").join(format!("{package}.json")); + let source = fs::read_to_string(&path).ok()?; + let file: RegistryPackageFile = serde_json::from_str(&source).ok()?; + let entry = pick_best_entry(&file)?; + if entry.repository.split('/').filter(|s| !s.is_empty()).count() != 2 { + return None; + } + Some(match entry.rev.as_deref() { + Some(rev) if !rev.is_empty() => format!("github:{}@{}", entry.repository, rev), + _ => format!("github:{}", entry.repository), + }) +} + +/// One entry of `packages/.json`'s `definitions` array, as `typr +/// search` reports it — every entry, not just the winner `pick_best_entry` +/// would choose, so the user can see what `typr types add` would pick among. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchEntry { + /// `owner/repo`, no `github:` scheme (matches the registry file's own field). + pub repository: String, + pub rev: Option, + /// `official` | `community` | `generated` | `local`, or anything else the + /// registry file happens to contain — not validated here. + pub source: String, + /// `T1` | `T2` | `T3`, same caveat. + pub tier: String, +} + +/// `typr search ` — list every definition the registry has for +/// `package`, ranked best (registry.md §8.3's provider-then-tier order) +/// first, ties broken by file order exactly like `pick_best_entry`. +/// +/// An empty result means "the registry has nothing for this package", which +/// is not an error (D2's instinct: an absent signal is safe, never fatal) — +/// `Ok(vec![])` covers a missing `packages/.json`, invalid JSON, and an +/// empty `definitions` array alike. `Err` is reserved for why the registry +/// itself could not be consulted at all (no `git`, clone/pull failure), so +/// the caller can show that reason instead of silently reporting zero hits. +pub fn search(package: &str) -> Result, String> { + if !git_available() { + return Err("`git` is not installed or not on PATH — cannot reach the registry".to_string()); + } + let dir = sync_registry_index()?; + Ok(search_in_registry_dir(&dir, package)) +} + +/// The JSON-reading half of `search`, kept separate so it can be unit-tested +/// against a hand-written `packages/` directory instead of a real clone — +/// same split as `lookup_in_registry`/`lookup_in_registry_dir`. +fn search_in_registry_dir(dir: &Path, package: &str) -> Vec { + let path = dir.join("packages").join(format!("{package}.json")); + let Ok(source) = fs::read_to_string(&path) else { + return Vec::new(); + }; + let Ok(file) = serde_json::from_str::(&source) else { + return Vec::new(); + }; + let mut entries: Vec = file + .definitions + .iter() + .map(|e| SearchEntry { + repository: e.repository.clone(), + rev: e.rev.clone(), + source: e.source.clone(), + tier: e.tier.clone(), + }) + .collect(); + entries.sort_by(|a, b| { + let rank_a = (provider_rank(&a.source), tier_rank_for_selection(&a.tier)); + let rank_b = (provider_rank(&b.source), tier_rank_for_selection(&b.tier)); + rank_b.cmp(&rank_a) + }); + entries +} + +/// Resolve a repository spec string for `package` when the caller has none +/// in hand yet — the two steps of registry.md §7.3's flow that precede a +/// fetch: an explicit `typr.toml [types]` pin always wins (§8.3 priority 1); +/// failing that, the registry (§8.3 priorities 2-3, as far as a registry +/// entry can distinguish them). `None` means neither had anything to say — +/// the caller decides what "nothing resolved" means for it (an error for an +/// explicit `typr types add` with no repo, a silent no-op for `typr add`'s +/// best-effort lookup). +pub fn resolve_spec_for_package(project_root: &Path, package: &str) -> Option { + let config = TypesConfig::read(project_root); + if let Some(pin) = config.pins.get(package) { + return Some(pin.clone()); + } + lookup_in_registry(package) +} + // --------------------------------------------------------------------- // Commands: add / update / list / vendor // --------------------------------------------------------------------- @@ -504,20 +756,25 @@ pub fn add(project_root: &Path, package: &str, spec_str: &str) -> Result) -> Result, String> { let lock_path = project_root.join(LOCKFILE_NAME); let lockfile = Lockfile::read(&lock_path); - let config = TypesConfig::read(project_root); let targets: Vec<(String, String)> = match package { Some(pkg) => { let spec = lockfile .find(pkg) .map(|d| d.repository.clone()) - .or_else(|| config.pins.get(pkg).cloned()) - .ok_or_else(|| format!("no resolved or pinned definition for `{pkg}` — use `typr types add` first"))?; + .or_else(|| resolve_spec_for_package(project_root, pkg)) + .ok_or_else(|| { + format!( + "no resolved, pinned, or registry-listed definition for `{pkg}` — use \ + `typr types add {pkg} github:owner/repo` with an explicit repository" + ) + })?; vec![(pkg.to_string(), spec)] } None => lockfile @@ -1174,4 +1431,244 @@ mod tests { let _ = fs::remove_dir_all(&cache_dir); } } + + // -- Registry lookup (registry.md §13 J3) ---------------------------- + + fn write_registry_package(dir: &Path, package: &str, json: &str) { + write_file(dir, &format!("packages/{package}.json"), json); + } + + #[test] + fn picks_official_over_community_regardless_of_tier() { + let dir = std::env::temp_dir().join(format!("typr_registry_official_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "shiny", + r#"{ + "name": "shiny", + "definitions": [ + {"repository": "alice/typr-shiny", "rev": "aaa", "source": "community", "tier": "T1"}, + {"repository": "rstudio/typr-shiny-official", "rev": "bbb", "source": "official", "tier": "T3"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "shiny").unwrap(); + assert_eq!(spec, "github:rstudio/typr-shiny-official@bbb"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn picks_highest_tier_among_same_provenance() { + let dir = std::env::temp_dir().join(format!("typr_registry_tier_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "dplyr", + r#"{ + "name": "dplyr", + "definitions": [ + {"repository": "alice/typr-dplyr", "rev": "aaa", "source": "community", "tier": "T3"}, + {"repository": "bob/typr-dplyr", "rev": "bbb", "source": "community", "tier": "T1"}, + {"repository": "carol/typr-dplyr", "rev": "ccc", "source": "community", "tier": "T2"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "dplyr").unwrap(); + assert_eq!(spec, "github:bob/typr-dplyr@bbb"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn ties_keep_the_files_own_order() { + let dir = std::env::temp_dir().join(format!("typr_registry_tie_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "ggplot2", + r#"{ + "name": "ggplot2", + "definitions": [ + {"repository": "first/typr-ggplot2", "rev": "aaa", "source": "community", "tier": "T2"}, + {"repository": "second/typr-ggplot2", "rev": "bbb", "source": "community", "tier": "T2"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "ggplot2").unwrap(); + assert_eq!(spec, "github:first/typr-ggplot2@aaa"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn no_entry_for_package_resolves_to_none() { + let dir = std::env::temp_dir().join(format!("typr_registry_missing_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + + assert!(lookup_in_registry_dir(&dir, "sf").is_none()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn empty_definitions_array_resolves_to_none() { + let dir = std::env::temp_dir().join(format!("typr_registry_empty_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package(&dir, "sf", r#"{"name": "sf", "definitions": []}"#); + + assert!(lookup_in_registry_dir(&dir, "sf").is_none()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn malformed_json_resolves_to_none_rather_than_erroring() { + let dir = std::env::temp_dir().join(format!("typr_registry_bad_json_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package(&dir, "sf", "this is not { json"); + + assert!(lookup_in_registry_dir(&dir, "sf").is_none()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn entry_without_rev_resolves_to_head() { + let dir = std::env::temp_dir().join(format!("typr_registry_norev_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "httr2", + r#"{"name": "httr2", "definitions": [{"repository": "alice/typr-httr2", "source": "community", "tier": "T2"}]}"#, + ); + + let spec = lookup_in_registry_dir(&dir, "httr2").unwrap(); + assert_eq!(spec, "github:alice/typr-httr2"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn unrecognized_source_and_tier_never_outrank_a_recognized_one() { + let dir = std::env::temp_dir().join(format!("typr_registry_unknown_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "jsonlite", + r#"{ + "name": "jsonlite", + "definitions": [ + {"repository": "sketchy/typr-jsonlite", "rev": "zzz", "source": "totally-trustworthy", "tier": "super-good"}, + {"repository": "alice/typr-jsonlite", "rev": "aaa", "source": "generated", "tier": "T3"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "jsonlite").unwrap(); + assert_eq!(spec, "github:alice/typr-jsonlite@aaa"); + + let _ = fs::remove_dir_all(&dir); + } + + // -- search: every entry, ranked, not just the winner ----------------- + + #[test] + fn search_lists_every_entry_ranked_best_first() { + let dir = std::env::temp_dir().join(format!("typr_search_ranked_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "shiny", + r#"{ + "name": "shiny", + "definitions": [ + {"repository": "alice/typr-shiny", "rev": "aaa", "source": "community", "tier": "T1"}, + {"repository": "rstudio/typr-shiny-official", "rev": "bbb", "source": "official", "tier": "T3"}, + {"repository": "carol/typr-shiny", "rev": "ccc", "source": "community", "tier": "T3"} + ] + }"#, + ); + + let entries = search_in_registry_dir(&dir, "shiny"); + let repos: Vec<&str> = entries.iter().map(|e| e.repository.as_str()).collect(); + // official (any tier) still outranks community, matching pick_best_entry. + assert_eq!( + repos, + vec!["rstudio/typr-shiny-official", "alice/typr-shiny", "carol/typr-shiny"] + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn search_ties_keep_the_files_own_order() { + let dir = std::env::temp_dir().join(format!("typr_search_tie_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "ggplot2", + r#"{ + "name": "ggplot2", + "definitions": [ + {"repository": "first/typr-ggplot2", "rev": "aaa", "source": "community", "tier": "T2"}, + {"repository": "second/typr-ggplot2", "rev": "bbb", "source": "community", "tier": "T2"} + ] + }"#, + ); + + let entries = search_in_registry_dir(&dir, "ggplot2"); + let repos: Vec<&str> = entries.iter().map(|e| e.repository.as_str()).collect(); + assert_eq!(repos, vec!["first/typr-ggplot2", "second/typr-ggplot2"]); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn search_no_entry_for_package_is_an_empty_list_not_an_error() { + let dir = std::env::temp_dir().join(format!("typr_search_missing_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + + assert!(search_in_registry_dir(&dir, "sf").is_empty()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn search_malformed_json_is_an_empty_list_not_an_error() { + let dir = std::env::temp_dir().join(format!("typr_search_bad_json_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package(&dir, "sf", "this is not { json"); + + assert!(search_in_registry_dir(&dir, "sf").is_empty()); + + let _ = fs::remove_dir_all(&dir); + } + + // -- resolve_spec_for_package: explicit pin beats the registry -------- + + #[test] + fn resolve_spec_for_package_prefers_explicit_pin_without_touching_the_registry() { + let dir = std::env::temp_dir().join(format!("typr_resolve_pin_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join(PROJECT_CONFIG_NAME), + "[types]\nshiny = \"github:alice/typr-shiny@pinned\"\n", + ) + .unwrap(); + + // No registry index is reachable/needed here: the pin must win before + // `lookup_in_registry` is ever consulted. + let spec = resolve_spec_for_package(&dir, "shiny"); + assert_eq!(spec, Some("github:alice/typr-shiny@pinned".to_string())); + + let _ = fs::remove_dir_all(&dir); + } } From c56e52b98a69c0326dff93182fde4cc4f933c192 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Mon, 14 Sep 2026 07:46:41 +0200 Subject: [PATCH 13/52] update Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014brvdoSu1AUJnY3mFzSc8C --- crates/typr-cli/src/cli.rs | 39 +- crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/main.rs | 1 + crates/typr-cli/src/registry_revalidate.rs | 467 +++++++++++++++++++++ crates/typr-cli/src/registry_validate.rs | 223 +++++++--- crates/typr-cli/src/type_registry.rs | 207 ++++++++- 6 files changed, 869 insertions(+), 69 deletions(-) create mode 100644 crates/typr-cli/src/registry_revalidate.rs diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index f3c1ee8..cafa55c 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -238,6 +238,25 @@ enum TypesCommands { /// same way `typr types add` does. repo: Option, }, + /// Run `Validate`'s checks against *every* definition the + /// `we-data-ch/registry` index lists, and report drift since the last run + /// — registry.md §13 J4, "revalidation périodique des définitions déjà + /// indexées (détection de dérive)". Meant to run centrally (a scheduled + /// CI job in `we-data-ch/registry` itself), not per-project. Exits 1 only + /// when something that was fine last run just broke (`--out`'s previous + /// contents vs. now) — a long-standing, already-known failure doesn't + /// keep failing the job forever. + Revalidate { + /// A local checkout of `we-data-ch/registry` to validate as-is (e.g. + /// a CI job's own working tree). Omit to sync the same local mirror + /// `typr search`/`typr types add` already use. + #[arg(long, value_name = "PATH")] + dir: Option, + /// Where the previous run's snapshot is read from and the new one is + /// written to. Defaults to `/status/validation.json`. + #[arg(long, short, value_name = "FILE")] + out: Option, + }, } #[derive(Subcommand, Debug)] @@ -615,8 +634,8 @@ fn run_cache_command(command: CacheCommands) { } } -/// `typr types ` — see `typR/registry.md` §7 and -/// `rfcs/0031-external-type-definitions.md`. +/// `typr types ` — see +/// `typR/registry.md` §7, §9, §13 J4 and `rfcs/0031-external-type-definitions.md`. fn run_types_command(command: TypesCommands) { use crate::type_registry; @@ -701,6 +720,22 @@ fn run_types_command(command: TypesCommands) { std::process::exit(1); } } + TypesCommands::Revalidate { dir, out } => { + use crate::registry_revalidate; + match registry_revalidate::revalidate(dir.as_deref(), out.as_deref()) { + Ok((snapshots, drift, out_path)) => { + print!("{}", registry_revalidate::render(&snapshots, &drift)); + println!("\nwrote {}", out_path.display()); + if !drift.newly_failing.is_empty() { + std::process::exit(1); + } + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } + } } } diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index 7595c57..e4ac736 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -54,6 +54,7 @@ pub mod r_deps; pub mod r_name_cache; pub mod r_name_lint; pub mod rd_renderer; +pub mod registry_revalidate; pub mod registry_validate; pub mod repl; pub mod standard_library; diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index 1f80a3d..abc000d 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -17,6 +17,7 @@ mod r_deps; mod r_name_cache; mod r_name_lint; mod rd_renderer; +mod registry_revalidate; mod registry_validate; mod repl; mod standard_library; diff --git a/crates/typr-cli/src/registry_revalidate.rs b/crates/typr-cli/src/registry_revalidate.rs new file mode 100644 index 0000000..10b355a --- /dev/null +++ b/crates/typr-cli/src/registry_revalidate.rs @@ -0,0 +1,467 @@ +//! `typr types revalidate` — periodic revalidation of every Type Definition +//! indexed by `we-data-ch/registry`, and detection of drift since the last +//! run. `typR/registry.md` §13 J4, "revalidation périodique des définitions +//! déjà indexées (détection de dérive)" — the item `registry_validate.rs`'s +//! own doc comment named as still open when it landed the single-repository +//! checks of §9. +//! +//! `registry_validate::validate` answers "is this one definition healthy +//! *today*". This module answers two questions a single on-demand run +//! cannot: "is *everything* the registry lists healthy today" (by walking +//! `type_registry::list_registry_targets` instead of one repository), and +//! "did anything that was fine *stop* being fine" (by diffing against a +//! persisted snapshot of the previous run) — the actual failure mode this +//! guards against is a CRAN release silently breaking a definition nobody +//! touched (registry.md §9: "le mode de mort n° 1 d'un registre +//! communautaire"). +//! +//! This is meant to run centrally — a scheduled CI job in `we-data-ch/registry` +//! itself, not a command a consuming project would ever call — which is why +//! `revalidate` takes an optional `--dir` pointing at a *registry* checkout +//! (a CI job that has already checked one out) rather than a project root; +//! omitted, it falls back to the same synced mirror `typr search` already +//! uses (`type_registry::sync_registry_index`). + +use crate::registry_validate::{self, CheckStatus}; +use crate::type_registry; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// --------------------------------------------------------------------- +// Persisted snapshot shape +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SnapshotCheck { + pub name: String, + /// "ok" | "warning" | "FAILED" | "not checked" — `CheckStatus`'s own + /// display words (`ValidationReport::render`), kept as text rather than + /// re-deriving the enum so an older snapshot file with a status word this + /// build no longer emits still deserializes instead of breaking the diff. + pub status: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Snapshot { + pub package: String, + /// `github:owner/repo[@rev]` — doubles as the identity key alongside + /// `package` for diffing against the previous run (registry.md §8.3: one + /// package can resolve several definitions, so `package` alone isn't a + /// key). + pub repository: String, + pub definition_version: String, + /// `YYYY-MM-DD`, UTC — the "last verified" date registry.md §9's example + /// report shows next to each check. + pub checked_at: String, + pub ok: bool, + pub checks: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct SnapshotFile { + #[serde(default)] + definitions: Vec, +} + +// --------------------------------------------------------------------- +// Drift +// --------------------------------------------------------------------- + +/// What changed since the previous persisted run, keyed by `(package, +/// repository)`. Informational, not a build gate on its own (D2/D5) — a +/// caller (the CLI, a CI job) decides what to do with it; `typr types +/// revalidate` exits non-zero only when `newly_failing` is non-empty, so a +/// long-standing, already-known failure doesn't keep paging someone forever. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Drift { + /// `ok` last run (or never checked before) — `Fail` now. + pub newly_failing: Vec<(String, String)>, + /// `Fail` last run — `ok` now. + pub recovered: Vec<(String, String)>, + /// Checked last run, no longer listed by the registry at all (the + /// `packages/.json` entry, or the whole file, was removed). + pub removed: Vec<(String, String)>, +} + +impl Drift { + pub fn is_empty(&self) -> bool { + self.newly_failing.is_empty() && self.recovered.is_empty() && self.removed.is_empty() + } +} + +/// Compare `previous` against `current`, both keyed by `(package, +/// repository)`. An entry with no prior record counts as "newly failing" when +/// it fails now — first sight of a problem is still news — but never as +/// "recovered" or "removed", which need an actual prior record to compare +/// against. +fn diff(previous: &[Snapshot], current: &[Snapshot]) -> Drift { + let prev_by_key: HashMap<(&str, &str), &Snapshot> = previous + .iter() + .map(|s| ((s.package.as_str(), s.repository.as_str()), s)) + .collect(); + let curr_keys: HashSet<(&str, &str)> = current + .iter() + .map(|s| (s.package.as_str(), s.repository.as_str())) + .collect(); + + let mut drift = Drift::default(); + for snap in current { + let key = (snap.package.as_str(), snap.repository.as_str()); + match (prev_by_key.get(&key).map(|p| p.ok), snap.ok) { + (Some(true), false) | (None, false) => drift + .newly_failing + .push((snap.package.clone(), snap.repository.clone())), + (Some(false), true) => drift.recovered.push((snap.package.clone(), snap.repository.clone())), + _ => {} + } + } + for prev in previous { + let key = (prev.package.as_str(), prev.repository.as_str()); + if !curr_keys.contains(&key) { + drift.removed.push((prev.package.clone(), prev.repository.clone())); + } + } + drift.newly_failing.sort(); + drift.recovered.sort(); + drift.removed.sort(); + drift +} + +// --------------------------------------------------------------------- +// Running the checks +// --------------------------------------------------------------------- + +fn status_word(status: CheckStatus) -> &'static str { + match status { + CheckStatus::Pass => "ok", + CheckStatus::Warn => "warning", + CheckStatus::Fail => "FAILED", + CheckStatus::Skipped => "not checked", + } +} + +/// Run `registry_validate::validate` against every target `type_registry:: +/// list_registry_targets(registry_dir)` lists, stamping each result with +/// today's date. Real work per target (a `git clone`, a type-check, an +/// `Rscript` introspection) — this is the periodic/CI path, not an +/// interactive one, so it is expected to take as long as the registry is big. +fn run(registry_dir: &Path) -> Vec { + let checked_at = today(); + type_registry::list_registry_targets(registry_dir) + .into_iter() + .map(|target| { + let report = registry_validate::validate(&target.package, &target.spec); + Snapshot { + package: target.package, + repository: target.spec, + definition_version: report.definition_version.clone(), + checked_at: checked_at.clone(), + ok: report.ok(), + checks: report + .checks + .iter() + .map(|c| SnapshotCheck { + name: c.name.to_string(), + status: status_word(c.status).to_string(), + detail: c.detail.clone(), + }) + .collect(), + } + }) + .collect() +} + +/// A missing or unparsable snapshot file is simply "no previous run" — never +/// a hard error (D2), same contract as `type_registry::Lockfile::read`. +fn load_previous(path: &Path) -> Vec { + fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|f| f.definitions) + .unwrap_or_default() +} + +fn save(path: &Path, snapshots: &[Snapshot]) -> Result<(), String> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + } + let rendered = serde_json::to_string_pretty(&SnapshotFile { + definitions: snapshots.to_vec(), + }) + .map_err(|e| format!("could not serialize {}: {e}", path.display()))?; + fs::write(path, rendered).map_err(|e| format!("could not write {}: {e}", path.display())) +} + +// --------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------- + +/// `typr types revalidate [--dir PATH] [--out FILE]`. +/// +/// `registry_dir`: a local checkout of `we-data-ch/registry` to validate as-is +/// (e.g. a CI job's own working tree, so a PR can be checked before it merges) +/// — `None` syncs the same local mirror `typr search`/`typr types add` use. +/// +/// `out`: where the previous run's snapshot is read from and the new one is +/// written to. Defaults to `/status/validation.json`, so a CI +/// job that commits its checkout's working tree back naturally persists +/// history across runs without needing to know the path in advance. +/// +/// Fails only when the registry truly cannot be consulted at all (no `git`, +/// clone/pull failure, or a snapshot that cannot be written) — matches +/// `type_registry::search`'s Err semantics. A registry with nothing indexed +/// yet, or one where every check is `Skipped`/`Warn`, resolves fine (D2): an +/// absent or uncertain signal is never a reason to fail the job that +/// discovers it. +pub fn revalidate(registry_dir: Option<&Path>, out: Option<&Path>) -> Result<(Vec, Drift, PathBuf), String> { + let dir = match registry_dir { + Some(d) => d.to_path_buf(), + None => { + if !type_registry::git_available() { + return Err("`git` is not installed or not on PATH — cannot reach the registry".to_string()); + } + type_registry::sync_registry_index()? + } + }; + let out_path = out + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| dir.join("status").join("validation.json")); + + let previous = load_previous(&out_path); + let current = run(&dir); + let drift = diff(&previous, ¤t); + save(&out_path, ¤t)?; + + Ok((current, drift, out_path)) +} + +/// The nominative report of registry.md §9 — what was verified, named, with +/// the date it was last checked, never a single green badge — plus a "drift +/// since last run" section when `drift` has anything to say. +pub fn render(snapshots: &[Snapshot], drift: &Drift) -> String { + let mut out = String::new(); + if snapshots.is_empty() { + out.push_str("no definitions indexed in the registry — nothing to revalidate.\n"); + return out; + } + for s in snapshots { + out.push_str(&format!( + "{} — {} (definition v{}) — last verified {}\n", + s.package, s.repository, s.definition_version, s.checked_at + )); + for c in &s.checks { + out.push_str(&format!(" {:<26} {:<12} {}\n", c.name, c.status, c.detail)); + } + } + if !drift.is_empty() { + out.push_str("\ndrift since last run:\n"); + for (pkg, repo) in &drift.newly_failing { + out.push_str(&format!(" NEW FAILURE {pkg} — {repo}\n")); + } + for (pkg, repo) in &drift.recovered { + out.push_str(&format!(" recovered {pkg} — {repo}\n")); + } + for (pkg, repo) in &drift.removed { + out.push_str(&format!(" removed {pkg} — {repo}\n")); + } + } + out +} + +// --------------------------------------------------------------------- +// Dates — no `chrono` in this workspace (registry.md's own instinct +// elsewhere: shell out or hand-roll rather than add an HTTP/date dependency +// for one call site). Howard Hinnant's `civil_from_days` (public domain) is a +// small, well-known, dependency-free days-since-epoch → (y, m, d) conversion. +// --------------------------------------------------------------------- + +fn today() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let (y, m, d) = civil_from_days((secs / 86_400) as i64); + format!("{y:04}-{m:02}-{d:02}") +} + +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + // -- civil_from_days -------------------------------------------------- + + #[test] + fn civil_from_days_epoch_is_1970_01_01() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + } + + #[test] + fn civil_from_days_matches_a_known_date() { + // 2024-03-01 is 19783 days after the epoch (external reference: date -d @1709251200 -u). + assert_eq!(civil_from_days(19_783), (2024, 3, 1)); + } + + #[test] + fn civil_from_days_handles_a_leap_day() { + // 2024-02-29 is one day before the above. + assert_eq!(civil_from_days(19_782), (2024, 2, 29)); + } + + // -- diff --------------------------------------------------------------- + + fn snap(package: &str, repository: &str, ok: bool) -> Snapshot { + Snapshot { + package: package.to_string(), + repository: repository.to_string(), + definition_version: "0.1.0".to_string(), + checked_at: "2026-01-01".to_string(), + ok, + checks: Vec::new(), + } + } + + #[test] + fn ok_to_fail_is_newly_failing() { + let previous = vec![snap("shiny", "github:alice/typr-shiny", true)]; + let current = vec![snap("shiny", "github:alice/typr-shiny", false)]; + let drift = diff(&previous, ¤t); + assert_eq!( + drift.newly_failing, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + assert!(drift.recovered.is_empty()); + assert!(drift.removed.is_empty()); + } + + #[test] + fn fail_to_ok_is_recovered() { + let previous = vec![snap("shiny", "github:alice/typr-shiny", false)]; + let current = vec![snap("shiny", "github:alice/typr-shiny", true)]; + let drift = diff(&previous, ¤t); + assert_eq!( + drift.recovered, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + assert!(drift.newly_failing.is_empty()); + } + + #[test] + fn never_seen_before_and_failing_counts_as_newly_failing() { + let previous: Vec = Vec::new(); + let current = vec![snap("shiny", "github:alice/typr-shiny", false)]; + let drift = diff(&previous, ¤t); + assert_eq!( + drift.newly_failing, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + } + + #[test] + fn never_seen_before_and_ok_is_not_drift() { + let previous: Vec = Vec::new(); + let current = vec![snap("shiny", "github:alice/typr-shiny", true)]; + assert!(diff(&previous, ¤t).is_empty()); + } + + #[test] + fn still_failing_both_runs_is_not_drift() { + let previous = vec![snap("shiny", "github:alice/typr-shiny", false)]; + let current = vec![snap("shiny", "github:alice/typr-shiny", false)]; + assert!(diff(&previous, ¤t).is_empty()); + } + + #[test] + fn dropped_from_the_registry_is_removed() { + let previous = vec![snap("shiny", "github:alice/typr-shiny", true)]; + let current: Vec = Vec::new(); + let drift = diff(&previous, ¤t); + assert_eq!( + drift.removed, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + assert!(drift.newly_failing.is_empty()); + } + + #[test] + fn same_package_two_repositories_are_independent_keys() { + let previous = vec![ + snap("shiny", "github:alice/typr-shiny", true), + snap("shiny", "github:bob/typr-shiny", true), + ]; + let current = vec![ + snap("shiny", "github:alice/typr-shiny", false), + snap("shiny", "github:bob/typr-shiny", true), + ]; + let drift = diff(&previous, ¤t); + assert_eq!( + drift.newly_failing, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + } + + // -- load_previous / save round-trip ------------------------------------ + + #[test] + fn load_previous_missing_file_is_empty_not_an_error() { + let path = std::env::temp_dir().join(format!("typr_revalidate_missing_{}.json", std::process::id())); + let _ = fs::remove_file(&path); + assert!(load_previous(&path).is_empty()); + } + + #[test] + fn save_then_load_previous_round_trips() { + let path = std::env::temp_dir().join(format!("typr_revalidate_roundtrip_{}.json", std::process::id())); + let _ = fs::remove_file(&path); + let snapshots = vec![snap("shiny", "github:alice/typr-shiny", true)]; + + save(&path, &snapshots).unwrap(); + let loaded = load_previous(&path); + + let _ = fs::remove_file(&path); + assert_eq!(loaded, snapshots); + } + + // -- render --------------------------------------------------------------- + + #[test] + fn render_empty_registry_says_so() { + assert_eq!( + render(&[], &Drift::default()), + "no definitions indexed in the registry — nothing to revalidate.\n" + ); + } + + #[test] + fn render_includes_drift_section_only_when_non_empty() { + let clean = render(&[snap("shiny", "github:alice/typr-shiny", true)], &Drift::default()); + assert!(!clean.contains("drift since last run")); + + let mut drift = Drift::default(); + drift + .newly_failing + .push(("shiny".to_string(), "github:alice/typr-shiny".to_string())); + let dirty = render(&[snap("shiny", "github:alice/typr-shiny", false)], &drift); + assert!(dirty.contains("drift since last run")); + assert!(dirty.contains("NEW FAILURE shiny — github:alice/typr-shiny")); + } +} diff --git a/crates/typr-cli/src/registry_validate.rs b/crates/typr-cli/src/registry_validate.rs index 568ecb8..a2f08ed 100644 --- a/crates/typr-cli/src/registry_validate.rs +++ b/crates/typr-cli/src/registry_validate.rs @@ -3,16 +3,16 @@ //! repository (registry.md §13 J4, "contrôles mécaniques de §9, dont le diff //! `formals()` contre le package installé"). //! -//! Two checks named in §9 are deliberately out of scope here and stay open in +//! One check named in §9 is deliberately out of scope here and stays open in //! the J4 checklist: "schéma JSON du registre valide" is a property of //! `we-data-ch/registry`'s own `packages/*.json` files, not of one definition //! repository (it already gets a mechanical check for free every time //! `type_registry::lookup_in_registry_dir`/`search_in_registry_dir` parses one //! into `RegistryPackageFile` — a malformed file simply resolves to no //! candidates, per D2 — but there is no *dedicated* validator yet that flags -//! *which* file is malformed); "revalidation périodique" (drift re-detection -//! over time) needs a place to run centrally (cron/CI on the registry itself), -//! which is the next J4 item, not this one. +//! *which* file is malformed). "revalidation périodique" (drift re-detection +//! over time) — a place to run this validator centrally, on every registry +//! entry, on a schedule — is [`crate::registry_revalidate`], the next J4 item. //! //! Every other §9 line is a [`CheckResult`] here: //! @@ -77,16 +77,32 @@ pub struct CheckResult { impl CheckResult { fn pass(name: &'static str, detail: impl Into) -> Self { - CheckResult { name, status: CheckStatus::Pass, detail: detail.into() } + CheckResult { + name, + status: CheckStatus::Pass, + detail: detail.into(), + } } fn warn(name: &'static str, detail: impl Into) -> Self { - CheckResult { name, status: CheckStatus::Warn, detail: detail.into() } + CheckResult { + name, + status: CheckStatus::Warn, + detail: detail.into(), + } } fn fail(name: &'static str, detail: impl Into) -> Self { - CheckResult { name, status: CheckStatus::Fail, detail: detail.into() } + CheckResult { + name, + status: CheckStatus::Fail, + detail: detail.into(), + } } fn skipped(name: &'static str, detail: impl Into) -> Self { - CheckResult { name, status: CheckStatus::Skipped, detail: detail.into() } + CheckResult { + name, + status: CheckStatus::Skipped, + detail: detail.into(), + } } } @@ -109,7 +125,10 @@ impl ValidationReport { /// The nominative report of registry.md §9: what was verified, named, /// never a single green badge. pub fn render(&self) -> String { - let mut out = format!("{} — {} (definition v{})\n", self.package, self.repository, self.definition_version); + let mut out = format!( + "{} — {} (definition v{})\n", + self.package, self.repository, self.definition_version + ); for c in &self.checks { let word = match c.status { CheckStatus::Pass => "ok", @@ -174,9 +193,15 @@ fn validate_fetched( ) -> ValidationReport { let mut checks = Vec::new(); - checks.push(CheckResult::pass("format_version", fetched.manifest.format_version.to_string())); + checks.push(CheckResult::pass( + "format_version", + fetched.manifest.format_version.to_string(), + )); - checks.push(CheckResult::pass("repository accessible", format!("cloned @ {}", short_rev(&fetched.rev)))); + checks.push(CheckResult::pass( + "repository accessible", + format!("cloned @ {}", short_rev(&fetched.rev)), + )); checks.push(if rev_pinned { CheckResult::pass("rev pinned", &fetched.rev) } else { @@ -188,7 +213,10 @@ fn validate_fetched( checks.push(CheckResult::pass("digest", fetched.digest.clone())); if capability_warnings.is_empty() { - checks.push(CheckResult::pass("capabilities", "no undeclared R; r_shims/extern_raw not declared")); + checks.push(CheckResult::pass( + "capabilities", + "no undeclared R; r_shims/extern_raw not declared", + )); } else { checks.push(CheckResult::warn("capabilities", capability_warnings.join(" "))); } @@ -222,18 +250,33 @@ fn validate_fetched( // parsed/type-checked, and `skipped` records failures from before that // point — this just avoids implying a trust decision that isn't ours to // make in a validator. - let (_context, skipped) = - standard_library::load_external_ty_definitions(Context::default(), &sources_ref, &fetched.manifest.definition.tier, "T1"); + let (_context, skipped) = standard_library::load_external_ty_definitions( + Context::default(), + &sources_ref, + &fetched.manifest.definition.tier, + "T1", + ); if ty_sources.is_empty() { - checks.push(CheckResult::warn(".ty parse/type-check", "no .ty file found in this repository")); + checks.push(CheckResult::warn( + ".ty parse/type-check", + "no .ty file found in this repository", + )); } else if skipped.is_empty() { - checks.push(CheckResult::pass(".ty parse/type-check", format!("{} file(s) OK", ty_sources.len()))); + checks.push(CheckResult::pass( + ".ty parse/type-check", + format!("{} file(s) OK", ty_sources.len()), + )); } else { let names: Vec<&str> = skipped.iter().map(|(f, _)| f.as_str()).collect(); checks.push(CheckResult::fail( ".ty parse/type-check", - format!("{}/{} file(s) failed: {}", skipped.len(), ty_sources.len(), names.join(", ")), + format!( + "{}/{} file(s) failed: {}", + skipped.len(), + ty_sources.len(), + names.join(", ") + ), )); } @@ -248,8 +291,11 @@ fn validate_fetched( // Declared names/arity/tier — deliberately excludes `tests/`, which // exercises the API rather than declaring it (registry.md §5.1). - let declared_sources: Vec<(String, String)> = - ty_sources.iter().filter(|(f, _)| !f.starts_with("tests/")).cloned().collect(); + let declared_sources: Vec<(String, String)> = ty_sources + .iter() + .filter(|(f, _)| !f.starts_with("tests/")) + .cloned() + .collect(); let declared = parse_declared_entries(&declared_sources, &fetched.manifest.definition.tier); let t1_entries: Vec<&DeclaredEntry> = declared.iter().filter(|e| e.tier.as_deref() == Some("T1")).collect(); @@ -264,7 +310,10 @@ fn validate_fetched( format!("{} T1 entrie(s), none with an unconstrained `...`", t1_entries.len()), )); } else { - checks.push(CheckResult::fail("T1 promotion gate", format!("unconstrained `...` at T1: {}", t1_violations.join(", ")))); + checks.push(CheckResult::fail( + "T1 promotion gate", + format!("unconstrained `...` at T1: {}", t1_violations.join(", ")), + )); } if !gen_types::rscript_available() { @@ -277,17 +326,27 @@ fn validate_fetched( push_formals_diff(&mut checks, &declared, &info.functions); } _ => { - checks.push(CheckResult::skipped("exports vs formals()", format!("`{package}` not installed locally"))); - checks.push(CheckResult::skipped("arity vs formals()", format!("`{package}` not installed locally"))); + checks.push(CheckResult::skipped( + "exports vs formals()", + format!("`{package}` not installed locally"), + )); + checks.push(CheckResult::skipped( + "arity vs formals()", + format!("`{package}` not installed locally"), + )); } } match check_cran_availability(package) { Some(true) => checks.push(CheckResult::pass("package on CRAN", "found on cloud.r-project.org")), - Some(false) => { - checks.push(CheckResult::warn("package on CRAN", "not found on CRAN — may be R-universe/Bioconductor/GitHub-only")) - } - None => checks.push(CheckResult::skipped("package on CRAN", "could not reach the CRAN mirror")), + Some(false) => checks.push(CheckResult::warn( + "package on CRAN", + "not found on CRAN — may be R-universe/Bioconductor/GitHub-only", + )), + None => checks.push(CheckResult::skipped( + "package on CRAN", + "could not reach the CRAN mirror", + )), } } @@ -308,8 +367,13 @@ fn short_rev(rev: &str) -> &str { /// `formals()` contre le package installé" registry.md §13 J4 names /// explicitly. Pushes both the "exports vs formals()" and "arity vs /// formals()" checks. -fn push_formals_diff(checks: &mut Vec, declared: &[DeclaredEntry], installed_fns: &[gen_types::GeneratedFn]) { - let installed: HashMap<&str, &gen_types::GeneratedFn> = installed_fns.iter().map(|f| (f.name.as_str(), f)).collect(); +fn push_formals_diff( + checks: &mut Vec, + declared: &[DeclaredEntry], + installed_fns: &[gen_types::GeneratedFn], +) { + let installed: HashMap<&str, &gen_types::GeneratedFn> = + installed_fns.iter().map(|f| (f.name.as_str(), f)).collect(); let mut missing = Vec::new(); let mut mismatches = Vec::new(); @@ -336,12 +400,21 @@ fn push_formals_diff(checks: &mut Vec, declared: &[DeclaredEntry], let total = declared.len(); let found = total - missing.len(); if missing.is_empty() { - checks.push(CheckResult::pass("exports vs formals()", format!("{found}/{total} found"))); + checks.push(CheckResult::pass( + "exports vs formals()", + format!("{found}/{total} found"), + )); } else { - checks.push(CheckResult::fail("exports vs formals()", format!("{found}/{total} found — missing: {}", missing.join(", ")))); + checks.push(CheckResult::fail( + "exports vs formals()", + format!("{found}/{total} found — missing: {}", missing.join(", ")), + )); } if mismatches.is_empty() { - checks.push(CheckResult::pass("arity vs formals()", format!("{total}/{total} match"))); + checks.push(CheckResult::pass( + "arity vs formals()", + format!("{total}/{total} match"), + )); } else { checks.push(CheckResult::fail( "arity vs formals()", @@ -406,12 +479,18 @@ fn parse_declared_entries(ty_sources: &[(String, String)], default_tier: &str) - if !trimmed.starts_with('@') { continue; } - let Some(sig) = parse_signature_line(trimmed) else { continue }; + let Some(sig) = parse_signature_line(trimmed) else { + continue; + }; let tier = meta_map .get(&sig.raw_name) .and_then(|m| m.tier.clone()) .or_else(|| Some(default_tier.to_string())); - out.push(DeclaredEntry { name: unwrap_backtick(&sig.raw_name), tier, params: sig.params }); + out.push(DeclaredEntry { + name: unwrap_backtick(&sig.raw_name), + tier, + params: sig.params, + }); } } out @@ -451,7 +530,10 @@ fn parse_signature_line(line: &str) -> Option { return None; } let params = parse_param_list(sig)?; - Some(ParsedSignature { raw_name: raw_name.to_string(), params }) + Some(ParsedSignature { + raw_name: raw_name.to_string(), + params, + }) } /// The first `:` in `text` that is not part of a `::` (which appears in @@ -494,7 +576,12 @@ fn parse_param_list(sig: &str) -> Option> { } let close_idx = close_idx?; let inner: String = chars[1..close_idx].iter().collect(); - Some(split_top_level(&inner).into_iter().map(|p| parse_one_param(&p)).collect()) + Some( + split_top_level(&inner) + .into_iter() + .map(|p| parse_one_param(&p)) + .collect(), + ) } fn split_top_level(inner: &str) -> Vec { @@ -534,11 +621,20 @@ fn parse_one_param(text: &str) -> DeclaredParam { None => (false, text.trim()), }; if rest.is_empty() { - return DeclaredParam { is_variadic, type_text: "Any".to_string() }; + return DeclaredParam { + is_variadic, + type_text: "Any".to_string(), + }; } match find_unqualified_colon(rest) { - Some(colon_idx) => DeclaredParam { is_variadic, type_text: rest[colon_idx + 1..].trim().to_string() }, - None => DeclaredParam { is_variadic, type_text: rest.to_string() }, + Some(colon_idx) => DeclaredParam { + is_variadic, + type_text: rest[colon_idx + 1..].trim().to_string(), + }, + None => DeclaredParam { + is_variadic, + type_text: rest.to_string(), + }, } } @@ -628,7 +724,12 @@ mod tests { fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, content).unwrap(); } - FetchedDefinition { manifest: manifest("T2"), rev: "deadbeefcafef00d".to_string(), digest: "sha256:test".to_string(), dir } + FetchedDefinition { + manifest: manifest("T2"), + rev: "deadbeefcafef00d".to_string(), + digest: "sha256:test".to_string(), + dir, + } } fn cleanup(fetched: &FetchedDefinition) { @@ -639,7 +740,10 @@ mod tests { fn clean_definition_passes_the_non_introspection_checks() { let fetched = write_fetched( "clean", - &[("ty/core.ty", "#! tier: T2\n@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n")], + &[( + "ty/core.ty", + "#! tier: T2\n@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n", + )], ); let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); cleanup(&fetched); @@ -673,8 +777,13 @@ mod tests { #[test] fn declared_capability_warning_is_surfaced_not_failed() { let fetched = write_fetched("capwarn", &[("ty/core.ty", "@f: (Any) -> Any;\n")]); - let report = - validate_fetched(FAKE_PACKAGE, "github:alice/typr-shiny", true, &fetched, &["ships R shims".to_string()]); + let report = validate_fetched( + FAKE_PACKAGE, + "github:alice/typr-shiny", + true, + &fetched, + &["ships R shims".to_string()], + ); cleanup(&fetched); let cap = report.checks.iter().find(|c| c.name == "capabilities").unwrap(); assert_eq!(cap.status, CheckStatus::Warn); @@ -699,7 +808,10 @@ mod tests { let fetched = write_fetched( "smoke", &[ - ("ty/core.ty", "@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n"), + ( + "ty/core.ty", + "@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n", + ), ("tests/smoke.ty", "let x <- fluidPage(1);\n"), ], ); @@ -717,8 +829,12 @@ mod tests { let path: PathBuf = dir.join(rel); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, "#! tier: T1\n@risky: (...values: Any) -> Any;\n").unwrap(); - let fetched = - FetchedDefinition { manifest: manifest("T2"), rev: "abc123".to_string(), digest: "sha256:test".to_string(), dir }; + let fetched = FetchedDefinition { + manifest: manifest("T2"), + rev: "abc123".to_string(), + digest: "sha256:test".to_string(), + dir, + }; let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); cleanup(&fetched); @@ -731,8 +847,10 @@ mod tests { #[test] fn t1_entry_with_constrained_variadic_passes_the_gate() { - let fetched = - write_fetched("t1ok", &[("ty/core.ty", "#! tier: T1\n@safe: (...values: int) -> Any;\n")]); + let fetched = write_fetched( + "t1ok", + &[("ty/core.ty", "#! tier: T1\n@safe: (...values: int) -> Any;\n")], + ); let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); cleanup(&fetched); let gate = report.checks.iter().find(|c| c.name == "T1 promotion gate").unwrap(); @@ -765,7 +883,10 @@ mod tests { // `toJSON` declared with one extra fixed argument beyond its real // `formals()` — a real, existing export with the wrong arity — plus // a name that plain doesn't exist in the package at all. - let wrong_arity_args = std::iter::repeat("Any").take(real.params.len() + 1).collect::>().join(", "); + let wrong_arity_args = std::iter::repeat("Any") + .take(real.params.len() + 1) + .collect::>() + .join(", "); let src = format!( "@extern jsonlite::toJSON: ({wrong_arity_args}) -> char;\n\ @extern jsonlite::doesNotExist: (Any) -> char;\n" @@ -776,7 +897,11 @@ mod tests { let exports = report.checks.iter().find(|c| c.name == "exports vs formals()").unwrap(); assert_eq!(exports.status, CheckStatus::Fail); - assert!(exports.detail.contains("doesNotExist"), "unexpected detail: {}", exports.detail); + assert!( + exports.detail.contains("doesNotExist"), + "unexpected detail: {}", + exports.detail + ); let arity = report.checks.iter().find(|c| c.name == "arity vs formals()").unwrap(); assert_eq!(arity.status, CheckStatus::Fail); diff --git a/crates/typr-cli/src/type_registry.rs b/crates/typr-cli/src/type_registry.rs index 9efbd45..bb1b922 100644 --- a/crates/typr-cli/src/type_registry.rs +++ b/crates/typr-cli/src/type_registry.rs @@ -52,7 +52,9 @@ impl RepoSpec { .split_once('/') .ok_or_else(|| format!("`{spec}` is not `github:owner/repo[@rev]` — missing `owner/repo`"))?; if owner.is_empty() || repo.is_empty() { - return Err(format!("`{spec}` is not `github:owner/repo[@rev]` — empty owner or repo")); + return Err(format!( + "`{spec}` is not `github:owner/repo[@rev]` — empty owner or repo" + )); } Ok(RepoSpec { owner: owner.to_string(), @@ -163,8 +165,14 @@ pub struct FetchedDefinition { pub dir: PathBuf, } -fn git_available() -> bool { - Command::new("git").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) +/// `pub(crate)`: also used by `registry_revalidate.rs` to fail open the same +/// way `lookup_in_registry`/`search` already do when `git` isn't on `PATH`. +pub(crate) fn git_available() -> bool { + Command::new("git") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) } /// Clone `url` (at `rev` when given, else the default branch's HEAD) into a @@ -174,11 +182,7 @@ fn git_available() -> bool { /// `Rscript` — rather than adding a git-in-process dependency for a command /// that already needs network access. fn clone_repo(url: &str, rev: Option<&str>) -> Result<(PathBuf, String), String> { - let dir = std::env::temp_dir().join(format!( - "typr_types_fetch_{}_{}", - std::process::id(), - now_millis() - )); + let dir = std::env::temp_dir().join(format!("typr_types_fetch_{}_{}", std::process::id(), now_millis())); fs::create_dir_all(&dir).map_err(|e| format!("could not create temp dir: {e}"))?; let clone_status = Command::new("git") @@ -259,7 +263,9 @@ fn collect_files(root: &Path, current: &Path, out: &mut BTreeSet) -> Re if path.file_name().and_then(|n| n.to_str()) == Some(".git") { continue; } - let file_type = entry.file_type().map_err(|e| format!("could not stat {}: {e}", path.display()))?; + let file_type = entry + .file_type() + .map_err(|e| format!("could not stat {}: {e}", path.display()))?; if file_type.is_dir() { collect_files(root, &path, out)?; } else if file_type.is_file() { @@ -365,7 +371,9 @@ fn check_capabilities(manifest: &DefinitionManifest, dir: &Path) -> Result Result<(FetchedDefinition, Vec), String> { if !git_available() { - return Err("`git` is not on PATH — `typr types add`/`update` need it to fetch a definition repository".to_string()); + return Err( + "`git` is not on PATH — `typr types add`/`update` need it to fetch a definition repository".to_string(), + ); } let (dir, rev) = clone_repo(&spec.clone_url(), spec.rev.as_deref())?; @@ -406,7 +414,9 @@ pub fn fetch(spec: &RepoSpec) -> Result<(FetchedDefinition, Vec), String /// `run` time to compare against later, per `degrade_if_version_out_of_range`'s /// own "no comparison is made... when `r_version_seen` is `None`" rule. fn observed_r_package_version(package: &str) -> Option { - crate::gen_types::introspect(package).ok().and_then(|info| info.pkg_version) + crate::gen_types::introspect(package) + .ok() + .and_then(|info| info.pkg_version) } /// Copy every tracked file of a fetched definition into the on-disk cache at @@ -559,7 +569,12 @@ fn registry_index_dir() -> Option { /// Clone (first use) or fast-forward `git pull` (subsequent uses) the local /// registry mirror, returning its path. -fn sync_registry_index() -> Result { +/// +/// `pub(crate)`: `registry_revalidate.rs` calls this directly when `typr +/// types revalidate` isn't given an explicit `--dir` (e.g. a CI job that has +/// already checked out `we-data-ch/registry` itself and wants to validate +/// that working tree instead of a fresh mirror). +pub(crate) fn sync_registry_index() -> Result { let dir = registry_index_dir() .ok_or_else(|| "could not determine a cache directory (no $HOME/$XDG_CACHE_HOME)".to_string())?; @@ -634,6 +649,14 @@ fn lookup_in_registry_dir(dir: &Path, package: &str) -> Option { let source = fs::read_to_string(&path).ok()?; let file: RegistryPackageFile = serde_json::from_str(&source).ok()?; let entry = pick_best_entry(&file)?; + entry_spec(entry) +} + +/// `entry.repository`/`entry.rev` as a `github:owner/repo[@rev]` spec string, +/// ready for `fetch`/`RepoSpec::parse` — `None` when `repository` isn't +/// shaped like `owner/repo`. Shared by `lookup_in_registry_dir` (the single +/// best entry) and `list_registry_targets` (every entry). +fn entry_spec(entry: &RegistryDefinitionEntry) -> Option { if entry.repository.split('/').filter(|s| !s.is_empty()).count() != 2 { return None; } @@ -643,6 +666,63 @@ fn lookup_in_registry_dir(dir: &Path, package: &str) -> Option { }) } +/// One definition entry from `packages/.json`, resolved to a spec string +/// — `typr types revalidate`'s unit of work (registry.md §13 J4, "revalidation +/// périodique des définitions déjà indexées"). Unlike `lookup_in_registry` +/// (the single winner `typr types add` would pick) or `search` (every entry, +/// but not resolved to a fetchable spec), this is *every* entry across *every* +/// package file, each already a `github:owner/repo[@rev]` string ready to feed +/// straight to `registry_validate::validate`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistryTarget { + pub package: String, + pub spec: String, +} + +/// Walk `dir/packages/*.json` and resolve every listed definition entry to a +/// `RegistryTarget`. Sorted by filename then file order, so two runs over an +/// unchanged registry produce results in the same order — `typr types +/// revalidate` diffs successive runs, and a stable order keeps that diff +/// about content, not happenstance directory iteration. +/// +/// Fails open like everything else that reads registry files (D2): a missing +/// `packages/` directory, an unreadable file, invalid JSON, or a malformed +/// `repository` field all just drop that entry rather than aborting the walk. +pub fn list_registry_targets(dir: &Path) -> Vec { + let packages_dir = dir.join("packages"); + let Ok(read_dir) = fs::read_dir(&packages_dir) else { + return Vec::new(); + }; + let mut files: Vec = read_dir + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json")) + .collect(); + files.sort(); + + let mut out = Vec::new(); + for path in files { + let Some(package) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let Ok(source) = fs::read_to_string(&path) else { + continue; + }; + let Ok(file) = serde_json::from_str::(&source) else { + continue; + }; + for entry in &file.definitions { + if let Some(spec) = entry_spec(entry) { + out.push(RegistryTarget { + package: package.to_string(), + spec, + }); + } + } + } + out +} + /// One entry of `packages/.json`'s `definitions` array, as `typr /// search` reports it — every entry, not just the winner `pick_best_entry` /// would choose, so the user can see what `typr types add` would pick among. @@ -1050,7 +1130,10 @@ mod tests { let config = TypesConfig::read(&dir); assert_eq!(config.trust.as_deref(), Some("T2")); - assert_eq!(config.pins.get("shiny").map(String::as_str), Some("github:alice/typr-shiny")); + assert_eq!( + config.pins.get("shiny").map(String::as_str), + Some("github:alice/typr-shiny") + ); let _ = fs::remove_dir_all(&dir); } @@ -1142,7 +1225,11 @@ mod tests { fn undeclared_extern_raw_is_rejected() { let dir = std::env::temp_dir().join(format!("typr_caps_extern_{}", std::process::id())); let _ = fs::remove_dir_all(&dir); - write_file(&dir, "ty/core.ty", "let f <- extern: (x: int) -> int r#\"\nfunction(x) x\n\"#;"); + write_file( + &dir, + "ty/core.ty", + "let f <- extern: (x: int) -> int r#\"\nfunction(x) x\n\"#;", + ); let err = check_capabilities(&manifest_with(false, false), &dir).unwrap_err(); assert!(err.contains("extern_raw"), "unexpected error: {err}"); let _ = fs::remove_dir_all(&dir); @@ -1265,7 +1352,12 @@ mod tests { // vendor() let written = vendor(&project_dir, None).unwrap(); assert!(!written.is_empty()); - let vendored_ty = project_dir.join("ty").join("vendor").join("shiny").join("ty").join("core.ty"); + let vendored_ty = project_dir + .join("ty") + .join("vendor") + .join("shiny") + .join("ty") + .join("core.ty"); assert!(vendored_ty.is_file(), "expected {}", vendored_ty.display()); let content = fs::read_to_string(&vendored_ty).unwrap(); assert!(content.contains("fluidPage")); @@ -1370,8 +1462,13 @@ mod tests { [definition]\nversion = \"0.1.0\"\ntier = \"T3\"\n\ [provider]\ntype = \"community\"\nrepository = \"github:test/typr-widget\"\n\ [capabilities]\nr_shims = false\nextern_raw = false\n"; - let locked = - lock_definition_in_real_cache(&project_dir, "widget", manifest_toml, "@do_widget_thing: (int) -> int;", None); + let locked = lock_definition_in_real_cache( + &project_dir, + "widget", + manifest_toml, + "@do_widget_thing: (int) -> int;", + None, + ); let context = crate::standard_library::load_project_type_definitions( &project_dir, @@ -1419,7 +1516,9 @@ mod tests { typr_core::components::context::Context::default(), ); let typ = context - .get_type_from_variable(&typr_core::components::language::var::Var::from_name("do_widget2_thing")) + .get_type_from_variable(&typr_core::components::language::var::Var::from_name( + "do_widget2_thing", + )) .expect("locked definition's entry must be loaded into the context"); assert!( typ.is_unknown_function(), @@ -1651,6 +1750,78 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + // -- list_registry_targets (typr types revalidate, registry.md §13 J4) -- + + #[test] + fn list_registry_targets_covers_every_entry_across_every_package_file() { + let dir = std::env::temp_dir().join(format!("typr_list_targets_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "dplyr", + r#"{"name": "dplyr", "definitions": [ + {"repository": "alice/typr-dplyr", "rev": "aaa", "source": "community", "tier": "T2"}, + {"repository": "bob/typr-dplyr", "source": "community", "tier": "T3"} + ]}"#, + ); + write_registry_package( + &dir, + "shiny", + r#"{"name": "shiny", "definitions": [ + {"repository": "carol/typr-shiny", "rev": "ccc", "source": "official", "tier": "T1"} + ]}"#, + ); + + let targets = list_registry_targets(&dir); + let pairs: Vec<(String, String)> = targets.into_iter().map(|t| (t.package, t.spec)).collect(); + assert_eq!( + pairs, + vec![ + ("dplyr".to_string(), "github:alice/typr-dplyr@aaa".to_string()), + ("dplyr".to_string(), "github:bob/typr-dplyr".to_string()), + ("shiny".to_string(), "github:carol/typr-shiny@ccc".to_string()), + ] + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn list_registry_targets_skips_a_malformed_entry_without_dropping_the_rest() { + let dir = std::env::temp_dir().join(format!("typr_list_targets_bad_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "sf", + r#"{"name": "sf", "definitions": [ + {"repository": "not-a-valid-repo-field", "source": "community", "tier": "T2"}, + {"repository": "alice/typr-sf", "source": "community", "tier": "T2"} + ]}"#, + ); + + let targets = list_registry_targets(&dir); + assert_eq!( + targets, + vec![RegistryTarget { + package: "sf".to_string(), + spec: "github:alice/typr-sf".to_string() + }] + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn list_registry_targets_empty_when_packages_dir_is_absent() { + let dir = std::env::temp_dir().join(format!("typr_list_targets_empty_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + assert!(list_registry_targets(&dir).is_empty()); + + let _ = fs::remove_dir_all(&dir); + } + // -- resolve_spec_for_package: explicit pin beats the registry -------- #[test] From a5944d3435bbe7cfcb2d29f8acf23b89e7dad3b7 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Mon, 14 Sep 2026 11:49:47 +0200 Subject: [PATCH 14/52] =?UTF-8?q?Support=20monorepo=20subdirectories=20in?= =?UTF-8?q?=20RepoSpec=20(registry.md=20=C2=A713=20J5=20prereq)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registry.md §8.2 and we-data-ch/registry's definitions/README.md already documented a `definitions//` monorepo path for long-tail Type Definitions, indexed via `"repository": "we-data-ch/registry"` — but RepoSpec/fetch only ever read `typr-def.toml` at a cloned repository's root, so two packages sharing that one repository could never both resolve (each `typr-def.toml` has exactly one `[package] name`). This extends `github:owner/repo[/subdir][@rev]` with an optional subdir, scopes fetch/digest/cache to that subdir when present, and widens the registry JSON schema's `repository` pattern and `entry_spec` parsing to match — unblocking J5's plan to land 30 generated definitions under one registry repo instead of 30 external ones. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014brvdoSu1AUJnY3mFzSc8C --- crates/typr-cli/src/registry_validate.rs | 10 +- crates/typr-cli/src/type_registry.rs | 258 ++++++++++++++++++++--- 2 files changed, 239 insertions(+), 29 deletions(-) diff --git a/crates/typr-cli/src/registry_validate.rs b/crates/typr-cli/src/registry_validate.rs index a2f08ed..bff6549 100644 --- a/crates/typr-cli/src/registry_validate.rs +++ b/crates/typr-cli/src/registry_validate.rs @@ -174,7 +174,7 @@ pub fn validate(package: &str, spec_str: &str) -> ValidationReport { }; let report = validate_fetched(package, &spec.display(), spec.rev.is_some(), &fetched, &warnings); - let _ = fs::remove_dir_all(&fetched.dir); + let _ = fs::remove_dir_all(&fetched.root); report } @@ -728,12 +728,13 @@ mod tests { manifest: manifest("T2"), rev: "deadbeefcafef00d".to_string(), digest: "sha256:test".to_string(), - dir, + dir: dir.clone(), + root: dir, } } fn cleanup(fetched: &FetchedDefinition) { - let _ = fs::remove_dir_all(&fetched.dir); + let _ = fs::remove_dir_all(&fetched.root); } #[test] @@ -833,7 +834,8 @@ mod tests { manifest: manifest("T2"), rev: "abc123".to_string(), digest: "sha256:test".to_string(), - dir, + dir: dir.clone(), + root: dir, }; let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); diff --git a/crates/typr-cli/src/type_registry.rs b/crates/typr-cli/src/type_registry.rs index bb1b922..0b5a7f3 100644 --- a/crates/typr-cli/src/type_registry.rs +++ b/crates/typr-cli/src/type_registry.rs @@ -27,13 +27,21 @@ const MANIFEST_NAME: &str = "typr-def.toml"; // Repository spec // --------------------------------------------------------------------- -/// `github:owner/repo[@rev]` — the only repository scheme this build -/// understands (registry.md §6/§7: GitHub is the primary host; a monorepo -/// long tail is J3, not this). +/// `github:owner/repo[/subdir...][@rev]` — the only repository scheme this +/// build understands (registry.md §6/§7: GitHub is the primary host). The +/// optional `subdir` is what lets a `packages/.json` entry point *into* +/// a monorepo like `we-data-ch/registry`'s own `definitions//` +/// (registry.md §8.2, `definitions/README.md`) instead of only at a +/// repository's root — without it, two packages indexed against the same +/// monorepo would collide on "the one `typr-def.toml` at the repo root". #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepoSpec { pub owner: String, pub repo: String, + /// Path *within* the repository to the definition's own root (the + /// directory holding its `typr-def.toml`), when it isn't the repository + /// root itself. `/`-separated, no leading or trailing slash. + pub subdir: Option, /// A pinned commit/branch/tag, when the user gave one after `@`. `None` /// means "resolve `HEAD`". pub rev: Option, @@ -42,23 +50,34 @@ pub struct RepoSpec { impl RepoSpec { pub fn parse(spec: &str) -> Result { let rest = spec.strip_prefix("github:").ok_or_else(|| { - format!("unsupported repository scheme in `{spec}` — only `github:owner/repo[@rev]` is understood") + format!("unsupported repository scheme in `{spec}` — only `github:owner/repo[/subdir][@rev]` is understood") })?; let (path, rev) = match rest.split_once('@') { Some((path, rev)) => (path, Some(rev.to_string())), None => (rest, None), }; - let (owner, repo) = path - .split_once('/') - .ok_or_else(|| format!("`{spec}` is not `github:owner/repo[@rev]` — missing `owner/repo`"))?; + let mut segments = path.split('/'); + let owner = segments.next().unwrap_or(""); + let repo = segments.next().unwrap_or(""); if owner.is_empty() || repo.is_empty() { return Err(format!( - "`{spec}` is not `github:owner/repo[@rev]` — empty owner or repo" + "`{spec}` is not `github:owner/repo[/subdir][@rev]` — missing `owner/repo`" )); } + let rest_segments: Vec<&str> = segments.collect(); + let subdir = if rest_segments.is_empty() { + None + } else if rest_segments.iter().any(|s| s.is_empty()) { + return Err(format!( + "`{spec}` is not `github:owner/repo[/subdir][@rev]` — empty path segment in subdir" + )); + } else { + Some(rest_segments.join("/")) + }; Ok(RepoSpec { owner: owner.to_string(), repo: repo.to_string(), + subdir, rev, }) } @@ -71,7 +90,10 @@ impl RepoSpec { /// always without the resolved rev, since that lives in `typr.lock`'s own /// `rev` field (registry.md §7.1: one source of truth per question). pub fn display(&self) -> String { - format!("github:{}/{}", self.owner, self.repo) + match &self.subdir { + Some(sub) => format!("github:{}/{}/{}", self.owner, self.repo, sub), + None => format!("github:{}/{}", self.owner, self.repo), + } } } @@ -160,9 +182,16 @@ pub struct FetchedDefinition { pub manifest: DefinitionManifest, pub rev: String, pub digest: String, - /// Directory holding the fetched repository's tracked files (`.git` - /// excluded). + /// The definition's own root — where its `typr-def.toml` lives. Equal to + /// `root` unless `spec.subdir` was set, in which case it is `root` joined + /// with that subdir. This is what `tracked_files`/`compute_digest`/ + /// `admit_to_cache` walk, so a monorepo definition's digest and cache + /// copy only ever cover its own files, never its siblings. pub dir: PathBuf, + /// The full clone — what must be removed to clean up the temp checkout + /// (`dir` alone isn't enough when it's a nested subdirectory of a + /// monorepo clone). + pub root: PathBuf, } /// `pub(crate)`: also used by `registry_revalidate.rs` to fail open the same @@ -375,12 +404,18 @@ pub fn fetch(spec: &RepoSpec) -> Result<(FetchedDefinition, Vec), String "`git` is not on PATH — `typr types add`/`update` need it to fetch a definition repository".to_string(), ); } - let (dir, rev) = clone_repo(&spec.clone_url(), spec.rev.as_deref())?; + let (root, rev) = clone_repo(&spec.clone_url(), spec.rev.as_deref())?; let result = (|| { + let dir = match &spec.subdir { + Some(sub) => root.join(sub), + None => root.clone(), + }; let manifest_path = dir.join(MANIFEST_NAME); - let manifest_source = fs::read_to_string(&manifest_path) - .map_err(|_| format!("no `{MANIFEST_NAME}` at the root of {}", spec.display()))?; + let manifest_source = fs::read_to_string(&manifest_path).map_err(|_| match &spec.subdir { + Some(sub) => format!("no `{MANIFEST_NAME}` at `{sub}` in {}", spec.display()), + None => format!("no `{MANIFEST_NAME}` at the root of {}", spec.display()), + })?; let manifest = parse_manifest(&manifest_source)?; let warnings = check_capabilities(&manifest, &dir)?; let digest = compute_digest(&dir)?; @@ -389,14 +424,15 @@ pub fn fetch(spec: &RepoSpec) -> Result<(FetchedDefinition, Vec), String manifest, rev, digest, - dir: dir.clone(), + dir, + root: root.clone(), }, warnings, )) })(); if result.is_err() { - let _ = fs::remove_dir_all(&dir); + let _ = fs::remove_dir_all(&root); } result } @@ -498,7 +534,9 @@ const REGISTRY_REPO_URL: &str = "https://github.com/we-data-ch/registry.git"; /// by `check_capabilities`. #[derive(Debug, Clone, Deserialize)] struct RegistryDefinitionEntry { - /// `owner/repo`, no `github:` scheme (registry.md §8.1's example). + /// `owner/repo` or `owner/repo/subdir...`, no `github:` scheme + /// (registry.md §8.1's example; the optional trailing segments are the + /// monorepo path of §8.2, e.g. `we-data-ch/registry/definitions/dplyr`). repository: String, #[serde(default)] rev: Option, @@ -652,12 +690,16 @@ fn lookup_in_registry_dir(dir: &Path, package: &str) -> Option { entry_spec(entry) } -/// `entry.repository`/`entry.rev` as a `github:owner/repo[@rev]` spec string, -/// ready for `fetch`/`RepoSpec::parse` — `None` when `repository` isn't -/// shaped like `owner/repo`. Shared by `lookup_in_registry_dir` (the single -/// best entry) and `list_registry_targets` (every entry). +/// `entry.repository`/`entry.rev` as a `github:owner/repo[/subdir][@rev]` +/// spec string, ready for `fetch`/`RepoSpec::parse` — `None` when +/// `repository` isn't at least `owner/repo` (extra `/`-separated segments +/// past the second are the monorepo subdir, registry.md §8.2 — e.g. +/// `we-data-ch/registry/definitions/dplyr`). Shared by +/// `lookup_in_registry_dir` (the single best entry) and +/// `list_registry_targets` (every entry). fn entry_spec(entry: &RegistryDefinitionEntry) -> Option { - if entry.repository.split('/').filter(|s| !s.is_empty()).count() != 2 { + let segment_count = entry.repository.split('/').filter(|s| !s.is_empty()).count(); + if segment_count < 2 || entry.repository.contains("//") { return None; } Some(match entry.rev.as_deref() { @@ -830,7 +872,7 @@ pub fn add(project_root: &Path, package: &str, spec_str: &str) -> Result LockedDefinition { @@ -1293,6 +1365,114 @@ mod tests { format!("file://{}", dir.display()) } + /// Build a minimal *monorepo*-style repository under `dir`, with two + /// package definitions nested under `definitions//` (registry.md + /// §8.2's layout), and commit it — the fixture for exercising `fetch`'s + /// subdir handling the same way `make_definition_repo` exercises the + /// plain, single-package case. + fn make_monorepo(dir: &Path) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + write_file( + dir, + "definitions/dplyr/typr-def.toml", + "format_version = 1\n\ + [package]\nname = \"dplyr\"\nsince = \"1.1.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T3\"\n\ + [provider]\ntype = \"generated\"\nrepository = \"github:we-data-ch/registry\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n", + ); + write_file( + dir, + "definitions/dplyr/ty/core.ty", + "#! pkg: dplyr\n#! tier: T3\n@importFrom dplyr filter;\n@filter: (Any, Any) -> Any;\n", + ); + write_file( + dir, + "definitions/ggplot2/typr-def.toml", + "format_version = 1\n\ + [package]\nname = \"ggplot2\"\nsince = \"3.4.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T3\"\n\ + [provider]\ntype = \"generated\"\nrepository = \"github:we-data-ch/registry\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n", + ); + write_file( + dir, + "definitions/ggplot2/ty/core.ty", + "#! pkg: ggplot2\n#! tier: T3\n@importFrom ggplot2 ggplot;\n@ggplot: (Any) -> Any;\n", + ); + + let run = |args: &[&str]| -> Result<(), String> { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).into_owned()); + } + Ok(()) + }; + run(&["init", "--quiet"])?; + run(&["config", "user.email", "test@example.com"])?; + run(&["config", "user.name", "test"])?; + run(&["add", "."])?; + run(&["commit", "--quiet", "-m", "initial"])?; + Ok(()) + } + + #[test] + fn fetch_resolves_a_definition_nested_in_a_monorepo_subdir() { + if !git_available() { + eprintln!("skipping: git not on PATH"); + return; + } + let repo_dir = std::env::temp_dir().join(format!("typr_monorepo_{}", std::process::id())); + let _ = fs::remove_dir_all(&repo_dir); + if let Err(e) = make_monorepo(&repo_dir) { + eprintln!("skipping: could not set up local git fixture: {e}"); + let _ = fs::remove_dir_all(&repo_dir); + return; + } + + // Same "bypass RepoSpec::parse, drive the clone directly" pattern as + // `add_update_list_vendor_round_trip_against_a_local_repo`: only + // `github:` URLs are a supported host, so a `file://` fixture is + // cloned directly and `fetch`'s own subdir-joining logic is exercised + // by hand, against the two sibling package directories the fixture + // ships. + let (root, rev) = clone_repo(&file_url(&repo_dir), None).expect("clone should succeed"); + let base_dir = root.join("definitions").join("dplyr"); + let manifest = parse_manifest(&fs::read_to_string(base_dir.join(MANIFEST_NAME)).unwrap()).unwrap(); + assert_eq!(manifest.package.name, "dplyr"); + check_capabilities(&manifest, &base_dir).unwrap(); + let digest = compute_digest(&base_dir).unwrap(); + + // The digest and tracked-file set only cover `dplyr`'s own files — + // changing the sibling `ggplot2` definition must not move it, and no + // `ggplot2` path leaks into what would be admitted to the cache. + let files = tracked_files(&base_dir).unwrap(); + assert!(files.iter().all(|p| !p.to_string_lossy().contains("ggplot2"))); + write_file(&root, "definitions/ggplot2/ty/core.ty", "@ggplot: (int) -> int;\n"); + assert_eq!(compute_digest(&base_dir).unwrap(), digest); + + let fetched = FetchedDefinition { + manifest, + rev, + digest, + dir: base_dir, + root: root.clone(), + }; + admit_to_cache(&fetched, "dplyr").unwrap(); + let cache_dir = cache_dir_for("dplyr", &fetched.digest).unwrap(); + assert!(cache_dir.join("ty").join("core.ty").is_file()); + assert!(!cache_dir.join("definitions").exists()); + + let _ = fs::remove_dir_all(&repo_dir); + let _ = fs::remove_dir_all(&fetched.root); + let _ = fs::remove_dir_all(&cache_dir); + } + #[test] fn add_update_list_vendor_round_trip_against_a_local_repo() { if !git_available() { @@ -1326,7 +1506,8 @@ mod tests { manifest, rev, digest, - dir, + dir: dir.clone(), + root: dir, }; admit_to_cache(&fetched, "shiny").unwrap(); @@ -1364,7 +1545,7 @@ mod tests { let _ = fs::remove_dir_all(&repo_dir); let _ = fs::remove_dir_all(&project_dir); - let _ = fs::remove_dir_all(&fetched.dir); + let _ = fs::remove_dir_all(&fetched.root); if let Some(cache_dir) = cache_dir_for("shiny", &fetched.digest) { let _ = fs::remove_dir_all(&cache_dir); } @@ -1423,6 +1604,7 @@ mod tests { rev: "0000000000000000000000000000000000000000".to_string(), digest, dir: src_dir.clone(), + root: src_dir.clone(), }; admit_to_cache(&fetched, package).unwrap(); @@ -1637,6 +1819,32 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn monorepo_subdir_repository_resolves_with_subdir_preserved() { + let dir = std::env::temp_dir().join(format!("typr_registry_subdir_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "dplyr", + r#"{ + "name": "dplyr", + "definitions": [ + {"repository": "we-data-ch/registry/definitions/dplyr", "rev": "abc", "source": "generated", "tier": "T3"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "dplyr").unwrap(); + assert_eq!(spec, "github:we-data-ch/registry/definitions/dplyr@abc"); + // and it parses back into a RepoSpec with the subdir intact. + let parsed = RepoSpec::parse(&spec).unwrap(); + assert_eq!(parsed.owner, "we-data-ch"); + assert_eq!(parsed.repo, "registry"); + assert_eq!(parsed.subdir.as_deref(), Some("definitions/dplyr")); + + let _ = fs::remove_dir_all(&dir); + } + #[test] fn entry_without_rev_resolves_to_head() { let dir = std::env::temp_dir().join(format!("typr_registry_norev_{}", std::process::id())); From ce87ec209475fc9d90c962ec4d59671c63c9af5b Mon Sep 17 00:00:00 2001 From: Fabrice Date: Mon, 14 Sep 2026 15:07:45 +0200 Subject: [PATCH 15/52] =?UTF-8?q?Add=20`typr=20types=20submit`:=20open=20a?= =?UTF-8?q?=20registry=20PR=20via=20`gh`=20(registry.md=20=C2=A713=20J6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped-down implementation of "Add to Registry" — a CLI command that shells out to the caller's own authenticated `gh`/`git` (fork, branch, upsert packages/.json, push, open PR) rather than the GitHub-App web backend §12/D6 describes and defers as a separate project. Runs the same checks as `typr types validate` first and refuses both a failing definition and a no-op PR. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014brvdoSu1AUJnY3mFzSc8C --- crates/typr-cli/src/cli.rs | 34 ++ crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/main.rs | 1 + crates/typr-cli/src/registry_submit.rs | 543 +++++++++++++++++++++++++ 4 files changed, 579 insertions(+) create mode 100644 crates/typr-cli/src/registry_submit.rs diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index cafa55c..c071e89 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -257,6 +257,23 @@ enum TypesCommands { #[arg(long, short, value_name = "FILE")] out: Option, }, + /// Open a pull request against the community registry adding or updating + /// `packages/.json` for this definition — "Add to Registry" + /// from the CLI, no Store account needed. Runs the same checks as + /// `Validate` first and refuses to submit anything that fails them. + /// Needs the `gh` CLI, already authenticated (`gh auth login`): the PR + /// is opened as you, from your own fork of the registry. + Submit { + /// The package this definition describes (e.g. `shiny`). + package: String, + /// `github:owner/repo[/subdir][@rev]`. Omit to use whatever this + /// project already has pinned for `package` in `typr.lock` (i.e. + /// after `typr types add`). + repo: Option, + /// `owner/repo` of the registry to submit to. + #[arg(long, value_name = "OWNER/REPO", default_value = crate::registry_submit::DEFAULT_REGISTRY_REPO)] + registry: String, + }, } #[derive(Subcommand, Debug)] @@ -720,6 +737,23 @@ fn run_types_command(command: TypesCommands) { std::process::exit(1); } } + TypesCommands::Submit { + package, + repo, + registry, + } => { + use crate::registry_submit::{self, SubmitOutcome}; + match registry_submit::submit(root, &package, repo.as_deref(), ®istry) { + Ok(SubmitOutcome::Opened(pr_url)) => println!("opened {pr_url}"), + Ok(SubmitOutcome::AlreadyUpToDate) => { + println!("`{package}` is already indexed identically in {registry} — nothing to submit.") + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } + } TypesCommands::Revalidate { dir, out } => { use crate::registry_revalidate; match registry_revalidate::revalidate(dir.as_deref(), out.as_deref()) { diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index e4ac736..2ecd166 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -55,6 +55,7 @@ pub mod r_name_cache; pub mod r_name_lint; pub mod rd_renderer; pub mod registry_revalidate; +pub mod registry_submit; pub mod registry_validate; pub mod repl; pub mod standard_library; diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index abc000d..41d719a 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -18,6 +18,7 @@ mod r_name_cache; mod r_name_lint; mod rd_renderer; mod registry_revalidate; +mod registry_submit; mod registry_validate; mod repl; mod standard_library; diff --git a/crates/typr-cli/src/registry_submit.rs b/crates/typr-cli/src/registry_submit.rs new file mode 100644 index 0000000..a1bd362 --- /dev/null +++ b/crates/typr-cli/src/registry_submit.rs @@ -0,0 +1,543 @@ +//! `typr types submit [github:owner/repo[/subdir][@rev]]` — open a pull +//! request against `we-data-ch/registry` adding or updating +//! `packages/.json`. This is the "« Add to Registry » → PR automatique" +//! item of `typR/registry.md` §13 J6 (second half) — the one item that was +//! still unchecked once J0-J5 and J6's static Store page were done. +//! +//! Deliberately **not** what registry.md §12/D6 describes and defers as its +//! own project: a Store web form that generates a PR "on behalf of a user" +//! needs a GitHub App, stored tokens, a backend, and anti-spam moderation. +//! This is the CLI shape instead — it shells out to the caller's own, +//! already-authenticated `gh` (the GitHub CLI), the same "shell out rather +//! than add an in-process client" choice `type_registry.rs` already made for +//! `git` and `gen_types.rs` made for `Rscript`. There is no backend and no +//! app: the PR is opened as the actual signed-in `gh` user, from their own +//! fork, exactly as if they had typed the `gh repo fork`/`git push`/`gh pr +//! create` sequence by hand. +//! +//! Never opens a PR for a definition that hasn't been mechanically checked: +//! `submit` runs the same checks `typr types validate` runs +//! (`registry_validate::validate`) first and refuses to proceed if any of +//! them fails. It also refuses to open a no-op PR when the target +//! `packages/.json` already contains an identical entry for the same +//! repository. + +use crate::type_definition::ProviderType; +use crate::type_registry::{self, FetchedDefinition, LockedDefinition, Lockfile, RepoSpec, LOCKFILE_NAME}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The registry this build submits to by default — same one +/// `type_registry.rs`'s `REGISTRY_REPO_URL` points at, but as `owner/repo` +/// rather than a clone URL, since `gh repo fork`/`gh pr create` want it in +/// that form. +pub const DEFAULT_REGISTRY_REPO: &str = "we-data-ch/registry"; + +// --------------------------------------------------------------------- +// packages/.json — full read/write shape +// --------------------------------------------------------------------- +// +// `type_registry.rs`'s own `RegistryDefinitionEntry`/`RegistryPackageFile` +// are intentionally partial — "only the fields the selection logic needs" +// (its own doc comment). Submitting has to read and write the *whole* file +// (every field `schema/package.schema.json` requires) and must not lose an +// existing sibling entry for another repository, so this module keeps its +// own full round-trippable shape instead of widening that one. + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct CapabilitiesJson { + r_shims: bool, + extern_raw: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct DefinitionEntryJson { + /// `owner/repo` or `owner/repo/subdir...` — no `github:` scheme, matching + /// `schema/package.schema.json`'s `repository` pattern. + repository: String, + version: String, + rev: String, + since: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + until: Option, + /// `official` | `community` | `generated` | `local`. + source: String, + /// `T1` | `T2` | `T3`. + tier: String, + capabilities: CapabilitiesJson, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct PackageFileJson { + name: String, + #[serde(default)] + definitions: Vec, +} + +fn provider_type_str(kind: ProviderType) -> &'static str { + match kind { + ProviderType::Official => "official", + ProviderType::Community => "community", + ProviderType::Generated => "generated", + ProviderType::Local => "local", + } +} + +/// `spec.owner/spec.repo[/spec.subdir]` — the `repository` field's shape in +/// the registry (registry.md §8.1/§8.2), derived from the same `RepoSpec` a +/// user would otherwise write into `typr.toml [types]` or pass to `typr +/// types add`. +fn repository_field(spec: &RepoSpec) -> String { + match &spec.subdir { + Some(sub) => format!("{}/{}/{}", spec.owner, spec.repo, sub), + None => format!("{}/{}", spec.owner, spec.repo), + } +} + +fn build_registry_entry(spec: &RepoSpec, fetched: &FetchedDefinition) -> DefinitionEntryJson { + DefinitionEntryJson { + repository: repository_field(spec), + version: fetched.manifest.definition.version.clone(), + rev: fetched.rev.clone(), + since: fetched.manifest.package.since.clone(), + until: fetched.manifest.package.until.clone(), + source: provider_type_str(fetched.manifest.provider.kind).to_string(), + tier: fetched.manifest.definition.tier.clone(), + capabilities: CapabilitiesJson { + r_shims: fetched.manifest.capabilities.r_shims, + extern_raw: fetched.manifest.capabilities.extern_raw, + }, + } +} + +/// Insert or replace `entry` in `/packages/.json`, keyed +/// by `repository` (a package can list several definitions, registry.md +/// §8.3 — this only ever touches the one matching this submission's +/// repository, every sibling entry is preserved byte-for-byte apart from +/// JSON re-formatting). Returns `false` without touching the file when an +/// identical entry is already present, so the caller can skip opening a +/// no-op PR. +fn upsert_package_entry(work_dir: &Path, package: &str, entry: DefinitionEntryJson) -> Result { + let path = work_dir.join("packages").join(format!("{package}.json")); + let mut file = if path.is_file() { + let source = fs::read_to_string(&path).map_err(|e| format!("could not read {}: {e}", path.display()))?; + serde_json::from_str::(&source).map_err(|e| { + format!( + "{} does not match the registry's own schema — refusing to overwrite it: {e}", + path.display() + ) + })? + } else { + PackageFileJson { + name: package.to_string(), + definitions: Vec::new(), + } + }; + + let changed = match file.definitions.iter_mut().find(|d| d.repository == entry.repository) { + Some(existing) if *existing == entry => false, + Some(existing) => { + *existing = entry; + true + } + None => { + file.definitions.push(entry); + true + } + }; + if !changed { + return Ok(false); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + let rendered = + serde_json::to_string_pretty(&file).map_err(|e| format!("could not serialize {}: {e}", path.display()))?; + fs::write(&path, format!("{rendered}\n")).map_err(|e| format!("could not write {}: {e}", path.display()))?; + Ok(true) +} + +// --------------------------------------------------------------------- +// gh / git plumbing +// --------------------------------------------------------------------- + +fn gh_available() -> bool { + Command::new("gh") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn gh_authenticated() -> bool { + Command::new("gh") + .args(["auth", "status"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn now_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +/// Fork `registry_repo` into the signed-in `gh` user's account (a no-op if +/// they already have one) and clone that fork into a fresh temp directory, +/// with `origin` pointing at the fork and `upstream` at `registry_repo` — +/// exactly the layout `gh repo fork --clone --remote` sets up by hand, and +/// exactly what `gh pr create` below needs to infer the PR's head without +/// being told explicitly. +fn fork_and_clone(registry_repo: &str) -> Result { + let repo_name = registry_repo.rsplit('/').next().unwrap_or(registry_repo); + let parent = std::env::temp_dir().join(format!("typr_types_submit_{}_{}", std::process::id(), now_millis())); + fs::create_dir_all(&parent).map_err(|e| format!("could not create temp dir: {e}"))?; + + let fork = Command::new("gh") + .args(["repo", "fork", registry_repo, "--clone", "--remote"]) + .current_dir(&parent) + .output() + .map_err(|e| format!("could not run `gh repo fork`: {e}"))?; + if !fork.status.success() { + let _ = fs::remove_dir_all(&parent); + return Err(format!( + "`gh repo fork {registry_repo}` failed: {}", + String::from_utf8_lossy(&fork.stderr).trim() + )); + } + + let work_dir = parent.join(repo_name); + if !work_dir.is_dir() { + let _ = fs::remove_dir_all(&parent); + return Err(format!( + "`gh repo fork {registry_repo}` reported success but {} was not created", + work_dir.display() + )); + } + Ok(work_dir) +} + +fn run_git(work_dir: &Path, args: &[&str], what: &str) -> Result<(), String> { + let out = Command::new("git") + .arg("-C") + .arg(work_dir) + .args(args) + .output() + .map_err(|e| format!("could not run `git {}`: {e}", args.join(" ")))?; + if !out.status.success() { + return Err(format!( + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(()) +} + +fn commit_and_push(work_dir: &Path, package: &str, branch: &str) -> Result<(), String> { + run_git(work_dir, &["checkout", "-b", branch], "`git checkout -b`")?; + run_git(work_dir, &["add", &format!("packages/{package}.json")], "`git add`")?; + let message = format!("Add/update packages/{package}.json via `typr types submit`"); + run_git(work_dir, &["commit", "--quiet", "-m", &message], "`git commit`")?; + run_git(work_dir, &["push", "--quiet", "-u", "origin", branch], "`git push`") +} + +/// `gh pr create`, run from inside the fork clone so `gh` infers the head +/// branch/owner from the checked-out branch and its `origin` remote — the +/// same thing a person would get running it by hand right after `git push`. +fn open_pr(work_dir: &Path, registry_repo: &str, package: &str, entry: &DefinitionEntryJson) -> Result { + let title = format!("Add/update {package}: {} (tier {})", entry.repository, entry.tier); + let body = format!( + "Adds or updates `packages/{package}.json` for `{}` at rev `{}` (tier `{}`, source `{}`).\n\n\ + Opened by `typr types submit` (`typR/registry.md` §13 J6). Passed `typr types \ + validate {package} github:{}@{}` locally before this PR was opened — see that command's \ + own output for exactly what was and was not mechanically checked (`registry.md` §9: \ + this PR is not a claim that the definition is correct beyond what was mechanically \ + verifiable).\n", + entry.repository, entry.rev, entry.tier, entry.source, entry.repository, entry.rev, + ); + let pr = Command::new("gh") + .current_dir(work_dir) + .args([ + "pr", + "create", + "--repo", + registry_repo, + "--title", + &title, + "--body", + &body, + ]) + .output() + .map_err(|e| format!("could not run `gh pr create`: {e}"))?; + if !pr.status.success() { + return Err(format!( + "`gh pr create` failed: {}", + String::from_utf8_lossy(&pr.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&pr.stdout).trim().to_string()) +} + +// --------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubmitOutcome { + /// `packages/.json` already had this exact entry — no PR opened. + AlreadyUpToDate, + Opened(String), +} + +/// A repository spec to submit for `package`: the one given explicitly, or — +/// mirroring `type_registry::update`'s own fallback — the one already +/// resolved in `typr.lock` (pinned rev included, so what gets submitted is +/// exactly what this project already trusts, not a possibly-moved `HEAD`). +fn resolve_spec_to_submit(project_root: &Path, package: &str, spec_override: Option<&str>) -> Result { + if let Some(spec) = spec_override { + return Ok(spec.to_string()); + } + let lockfile = Lockfile::read(&project_root.join(LOCKFILE_NAME)); + let locked: &LockedDefinition = lockfile.find(package).ok_or_else(|| { + format!( + "no repository given and `{package}` is not resolved in typr.lock — run `typr types \ + add {package} github:owner/repo` first, or pass one explicitly: `typr types submit \ + {package} github:owner/repo[@rev]`" + ) + })?; + Ok(format!("{}@{}", locked.repository, locked.rev)) +} + +/// `typr types submit [repo]` — resolve a spec, validate it +/// mechanically, and open (or update) a pull request against `registry_repo` +/// indexing it. Every temp directory this creates (the fetch's clone, the +/// fork's clone) is cleaned up before returning, success or failure. +pub fn submit( + project_root: &Path, + package: &str, + spec_override: Option<&str>, + registry_repo: &str, +) -> Result { + if !gh_available() { + return Err( + "`gh` (the GitHub CLI) is not on PATH — install it from https://cli.github.com, run \ + `gh auth login`, then retry; `typr types submit` opens the PR as you, from your own \ + fork, it never touches your GitHub credentials directly" + .to_string(), + ); + } + if !gh_authenticated() { + return Err("`gh` is not authenticated — run `gh auth login` first".to_string()); + } + + let spec_str = resolve_spec_to_submit(project_root, package, spec_override)?; + let repo_spec = RepoSpec::parse(&spec_str)?; + + let report = crate::registry_validate::validate(package, &spec_str); + if !report.ok() { + return Err(format!( + "`{package}` at {spec_str} fails mechanical validation — fix it before submitting \ + (rerun `typr types validate {package} {spec_str}` for details):\n{}", + report.render() + )); + } + + let (fetched, _warnings) = type_registry::fetch(&repo_spec)?; + let entry = build_registry_entry(&repo_spec, &fetched); + let _ = fs::remove_dir_all(&fetched.root); + + let work_dir = fork_and_clone(registry_repo)?; + let result = (|| { + if !upsert_package_entry(&work_dir, package, entry.clone())? { + return Ok(SubmitOutcome::AlreadyUpToDate); + } + let branch = format!("typr-types-submit-{package}-{}", &entry.rev[..entry.rev.len().min(12)]); + commit_and_push(&work_dir, package, &branch)?; + let pr_url = open_pr(&work_dir, registry_repo, package, &entry)?; + Ok(SubmitOutcome::Opened(pr_url)) + })(); + let _ = fs::remove_dir_all(work_dir.parent().unwrap_or(&work_dir)); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_entry(repository: &str) -> DefinitionEntryJson { + DefinitionEntryJson { + repository: repository.to_string(), + version: "0.1.0".to_string(), + rev: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string(), + since: "1.0.0".to_string(), + until: None, + source: "community".to_string(), + tier: "T2".to_string(), + capabilities: CapabilitiesJson { + r_shims: false, + extern_raw: false, + }, + } + } + + #[test] + fn repository_field_includes_subdir_when_present() { + let spec = RepoSpec::parse("github:we-data-ch/registry/definitions/dplyr").unwrap(); + assert_eq!(repository_field(&spec), "we-data-ch/registry/definitions/dplyr"); + + let spec = RepoSpec::parse("github:alice/typr-shiny").unwrap(); + assert_eq!(repository_field(&spec), "alice/typr-shiny"); + } + + #[test] + fn upsert_creates_a_new_file_when_none_exists() { + let dir = std::env::temp_dir().join(format!("typr_submit_new_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let changed = upsert_package_entry(&dir, "shiny", sample_entry("alice/typr-shiny")).unwrap(); + assert!(changed); + + let written = fs::read_to_string(dir.join("packages").join("shiny.json")).unwrap(); + let parsed: PackageFileJson = serde_json::from_str(&written).unwrap(); + assert_eq!(parsed.name, "shiny"); + assert_eq!(parsed.definitions.len(), 1); + assert_eq!(parsed.definitions[0].repository, "alice/typr-shiny"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_preserves_sibling_entries_for_other_repositories() { + let dir = std::env::temp_dir().join(format!("typr_submit_sibling_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + let existing = PackageFileJson { + name: "shiny".to_string(), + definitions: vec![sample_entry("bob/other-typr-shiny")], + }; + fs::write( + dir.join("packages").join("shiny.json"), + serde_json::to_string_pretty(&existing).unwrap(), + ) + .unwrap(); + + let changed = upsert_package_entry(&dir, "shiny", sample_entry("alice/typr-shiny")).unwrap(); + assert!(changed); + + let written = fs::read_to_string(dir.join("packages").join("shiny.json")).unwrap(); + let parsed: PackageFileJson = serde_json::from_str(&written).unwrap(); + assert_eq!(parsed.definitions.len(), 2); + assert!(parsed + .definitions + .iter() + .any(|d| d.repository == "bob/other-typr-shiny")); + assert!(parsed.definitions.iter().any(|d| d.repository == "alice/typr-shiny")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_replaces_the_matching_repository_entry_in_place() { + let dir = std::env::temp_dir().join(format!("typr_submit_replace_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + let mut first = sample_entry("alice/typr-shiny"); + first.tier = "T3".to_string(); + let existing = PackageFileJson { + name: "shiny".to_string(), + definitions: vec![first], + }; + fs::write( + dir.join("packages").join("shiny.json"), + serde_json::to_string_pretty(&existing).unwrap(), + ) + .unwrap(); + + let mut updated = sample_entry("alice/typr-shiny"); + updated.tier = "T1".to_string(); + let changed = upsert_package_entry(&dir, "shiny", updated).unwrap(); + assert!(changed); + + let written = fs::read_to_string(dir.join("packages").join("shiny.json")).unwrap(); + let parsed: PackageFileJson = serde_json::from_str(&written).unwrap(); + assert_eq!(parsed.definitions.len(), 1); + assert_eq!(parsed.definitions[0].tier, "T1"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_is_a_no_op_when_the_entry_is_already_identical() { + let dir = std::env::temp_dir().join(format!("typr_submit_noop_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + let entry = sample_entry("alice/typr-shiny"); + let existing = PackageFileJson { + name: "shiny".to_string(), + definitions: vec![entry.clone()], + }; + fs::write( + dir.join("packages").join("shiny.json"), + serde_json::to_string_pretty(&existing).unwrap(), + ) + .unwrap(); + + let changed = upsert_package_entry(&dir, "shiny", entry).unwrap(); + assert!(!changed); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn resolve_spec_prefers_explicit_override_over_typr_lock() { + let dir = std::env::temp_dir().join(format!("typr_submit_resolve_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let spec = resolve_spec_to_submit(&dir, "shiny", Some("github:alice/typr-shiny@abc123")).unwrap(); + assert_eq!(spec, "github:alice/typr-shiny@abc123"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn resolve_spec_falls_back_to_typr_lock_pinned_rev() { + let dir = std::env::temp_dir().join(format!("typr_submit_resolve_lock_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let mut lockfile = Lockfile::default(); + lockfile.upsert(LockedDefinition { + package: "shiny".to_string(), + repository: "github:alice/typr-shiny".to_string(), + version: "0.3.0".to_string(), + rev: "a1b2c3d4e5f6".to_string(), + digest: "sha256:deadbeef".to_string(), + tier: "T2".to_string(), + r_version_seen: None, + }); + lockfile.write(&dir.join(LOCKFILE_NAME)).unwrap(); + + let spec = resolve_spec_to_submit(&dir, "shiny", None).unwrap(); + assert_eq!(spec, "github:alice/typr-shiny@a1b2c3d4e5f6"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn resolve_spec_errors_when_nothing_is_locked_and_nothing_is_given() { + let dir = std::env::temp_dir().join(format!("typr_submit_resolve_missing_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let err = resolve_spec_to_submit(&dir, "shiny", None).unwrap_err(); + assert!(err.contains("typr types add"), "unexpected error: {err}"); + + let _ = fs::remove_dir_all(&dir); + } +} From 7163903f2501c65897295e9b29a86b9d5a9671fb Mon Sep 17 00:00:00 2001 From: Fabrice Date: Mon, 14 Sep 2026 15:13:21 +0200 Subject: [PATCH 16/52] update --- std.ty | 204 --------------------------------------------------------- 1 file changed, 204 deletions(-) delete mode 100644 std.ty diff --git a/std.ty b/std.ty deleted file mode 100644 index 02e434c..0000000 --- a/std.ty +++ /dev/null @@ -1,204 +0,0 @@ -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: integer sum -#! example: 1L + 2L # -> 3L -@`+`: (int, int) -> int; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: numeric sum -#! example: 1.5 + 2.5 # -> 4 -@`+`: (num, num) -> num; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: integer difference -#! example: 5L - 3L # -> 2L -@`-`: (int, int) -> int; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: numeric difference -#! example: 5.0 - 3.0 # -> 2 -@`-`: (num, num) -> num; -#! pkg: base -#! tier: T1 -#! param a: dividend -#! param b: divisor -#! ret: integer quotient -#! example: 10L / 3L # -> 3L -@`/`: (int, int) -> int; -#! pkg: base -#! tier: T1 -#! param a: dividend -#! param b: divisor -#! ret: numeric quotient -#! example: 10.0 / 3.0 # -> 3.333... -@`/`: (num, num) -> num; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: integer product -#! example: 3L * 4L # -> 12L -@`*`: (int, int) -> int; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: numeric product -#! example: 3.0 * 4.0 # -> 12 -@`*`: (num, num) -> num; -#! pkg: base -#! tier: T1 -#! param a: dividend -#! param b: divisor -#! ret: integer modulo -#! note: %% is the modulo operator (not remainder) -#! example: 7L %% 3L # -> 1L -@`%%`: (int, int) -> int; -#! pkg: base -#! tier: T1 -#! param a: dividend -#! param b: divisor -#! ret: numeric modulo -#! example: 7.0 %% 3.0 # -> 1 -@`%%`: (num, num) -> num; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: logical AND -#! example: TRUE && FALSE # -> FALSE -@`&&`: (bool, bool) -> bool; -#! pkg: base -#! tier: T1 -#! param a: left operand -#! param b: right operand -#! ret: logical OR -#! example: TRUE || FALSE # -> TRUE -@`||`: (bool, bool) -> bool; -#! pkg: base -#! tier: T1 -#! param a: left vector -#! param b: right vector -#! ret: element-wise sum of two vectors -#! coercion: vectors must be same length (recycling applies in R) -#! example: c(1,2) + c(3,4) # -> c(4,6) -@`+`: (Vec[#M, T], Vec[#M, T]) -> Vec[#M, T]; -#! pkg: base -#! tier: T1 -#! param a: vector to reduce -#! param f: binary combining function -#! ret: single value after left-fold -#! note: equivalent to base R Reduce() -#! example: reduce(c(1,2,3,4), add) # -> 10 -@reduce: ([#N, T], (T, T) -> T) -> T; -#! pkg: base -#! tier: T1 -#! param a: vector to fold -#! param init: initial accumulator value -#! param f: combining function (accumulator, element) -> accumulator -#! ret: final accumulator -#! note: equivalent to base R Reduce() with init -#! example: fold(c(1,2,3), 0, add) # -> 6 -@fold: ([#N, T], U, (U, T) -> U) -> U; -#! pkg: base -#! tier: T1 -#! param a: vector to extend -#! param b: element to append -#! ret: new vector with b appended -#! example: extend(c(1,2), 3) # -> c(1,2,3) -@extend: ([#N, T], T) -> [#N, T]; -#! pkg: base -#! tier: T3 -#! param desc: test description -#! param expr: test expression -#! ret: invisibly NULL -#! note: testing utility — not for production code -@test_that: (char, Any) -> Empty; -#! pkg: base -#! tier: T3 -#! param condition: expected condition -#! ret: invisibly NULL -#! note: testing utility -@expect_true: (bool) -> Empty; -#! pkg: base -#! tier: T3 -#! param actual: actual value -#! param expected: expected value -#! ret: invisibly NULL -#! note: testing utility -@expect_false: (T, T) -> Empty; -#! pkg: base -#! tier: T3 -#! param actual: actual value -#! param expected: expected value -#! ret: invisibly NULL -#! note: testing utility -@expect_equal: (T, T) -> Empty; -#! pkg: base -#! tier: T2 -#! param values: values to concatenate and print -#! ret: invisibly NULL -#! note: variadic, side-effectful; R's paste(..., sep="") + cat() -#! example: cat("hello", "world") -@cat: (...values: Any) -> Empty; -#! pkg: base -#! tier: T1 -#! param a: TypR value -#! ret: same value as native R -#! note: pass-through for interop boundaries -@to_native: (T) -> T; -#! pkg: base -#! tier: T2 -#! param a: native R value -#! param type: target TypR type name -#! ret: coerced value -#! note: runtime cast — type must match or error -@from_native: (Any, char) -> Any; -#! pkg: base -#! tier: T1 -#! param a: value to convert -#! ret: integer representation -#! coercion: numeric/logical -> integer -#! example: from_int(3.7) # -> 3L -@from_int: (Any) -> int; -#! pkg: base -#! tier: T1 -#! param a: value to convert -#! ret: numeric representation -#! coercion: integer/logical -> numeric -#! example: # noplayground: from_num(3L) # -> 3.0 -@from_num: (Any) -> num; -#! pkg: base -#! tier: T1 -#! param a: value to convert -#! ret: character representation -#! coercion: any atomic -> character -#! example: from_char(42) # -> "42" -@from_char: (Any) -> char; -#! pkg: base -#! tier: T1 -#! param a: value to convert -#! ret: logical representation -#! coercion: numeric -> logical (0 = FALSE, non-zero = TRUE) -#! example: from_bool(1) # -> TRUE -@from_bool: (Any) -> bool; -#! pkg: base -#! tier: T1 -#! param x: numeric vector -#! ret: sum over all elements -#! example: sum(c(1, 2, 3)) # -> 6 -@sum: ([#N, T]) -> T; -#! pkg: base -#! tier: T1 -#! param x: value to display -#! ret: prints to the console, returns nothing -#! example: print(sum(c(1, 2, 3))) -@print: (Any) -> Empty; From 0c4b74d06b20112e3d852f462e5b71ea3d3c2f81 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Tue, 15 Sep 2026 12:54:40 +0200 Subject: [PATCH 17/52] added styler --- crates/typr-cli/src/format_r.rs | 106 ++++++++++++++++++++++++++++++++ crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/main.rs | 1 + crates/typr-cli/src/project.rs | 3 + 4 files changed, 111 insertions(+) create mode 100644 crates/typr-cli/src/format_r.rs diff --git a/crates/typr-cli/src/format_r.rs b/crates/typr-cli/src/format_r.rs new file mode 100644 index 0000000..9664299 --- /dev/null +++ b/crates/typr-cli/src/format_r.rs @@ -0,0 +1,106 @@ +//! Formats generated R code with the `styler` package, so `R/*.R` files stay +//! readable when a user opens them to debug a transpilation. +//! +//! Formatting is best-effort: a missing `Rscript`/`styler` or a styling +//! failure must never fail the build, so callers always get code back +//! (formatted if possible, the original otherwise). + +use std::io::Write; +use std::process::{Command, Stdio}; +use std::sync::OnceLock; + +/// Formats `code` with `styler::style_text()` when available; returns `code` +/// unchanged otherwise (missing Rscript, missing `styler`, or a styling +/// error). +pub fn format_r_code(code: &str) -> String { + if !styler_available() { + return code.to_string(); + } + match run_styler(code) { + Ok(formatted) => formatted, + Err(e) => { + eprintln!("Warning: could not format R output with styler ({e}); writing unformatted code"); + code.to_string() + } + } +} + +/// `Rscript`/`styler` availability rarely changes within a single run, and +/// probing it spawns a process — check once per process instead of once per +/// file. +fn styler_available() -> bool { + static AVAILABLE: OnceLock = OnceLock::new(); + *AVAILABLE.get_or_init(|| { + Command::new("Rscript") + .arg("-e") + .arg("quit(status = if (requireNamespace('styler', quietly = TRUE)) 0L else 1L)") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + }) +} + +fn run_styler(code: &str) -> Result { + let script = "con <- file('stdin'); \ + code <- readLines(con, warn = FALSE); \ + close(con); \ + out <- styler::style_text(code); \ + cat(paste(as.character(out), collapse = '\n'), '\n', sep = '')"; + + let mut child = Command::new("Rscript") + .arg("-e") + .arg(script) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("failed to spawn Rscript: {e}"))?; + + child + .stdin + .take() + .expect("piped stdin") + .write_all(code.as_bytes()) + .map_err(|e| format!("failed to write code to Rscript stdin: {e}"))?; + + let output = child + .wait_with_output() + .map_err(|e| format!("failed to wait on Rscript: {e}"))?; + + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + + let formatted = String::from_utf8(output.stdout).map_err(|e| format!("styler produced non-UTF-8 output: {e}"))?; + if formatted.trim().is_empty() && !code.trim().is_empty() { + return Err("styler produced empty output".to_string()); + } + Ok(formatted) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn leaves_code_unchanged_when_styler_is_unavailable() { + if styler_available() { + return; // exercised by `styles_messy_code_when_available` instead + } + let messy = "f<-function(x){x+1}"; + assert_eq!(format_r_code(messy), messy); + } + + #[test] + fn styles_messy_code_when_available() { + if !styler_available() { + return; // no Rscript/styler on this machine — nothing to check + } + let messy = "f<-function(x){\nx+1\n}"; + let formatted = format_r_code(messy); + assert_ne!(formatted, messy); + assert!(formatted.contains("f <- function(x) {")); + } +} diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index 2ecd166..d9c9394 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -43,6 +43,7 @@ pub mod cache; pub mod cases; pub mod cli; pub mod engine; +pub mod format_r; pub mod fuzz; pub mod gen_types; pub mod io; diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index 41d719a..89e285f 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -6,6 +6,7 @@ mod cache; mod cases; mod cli; mod engine; +mod format_r; mod fuzz; mod gen_types; mod io; diff --git a/crates/typr-cli/src/project.rs b/crates/typr-cli/src/project.rs index 422528c..13e8ef5 100644 --- a/crates/typr-cli/src/project.rs +++ b/crates/typr-cli/src/project.rs @@ -508,6 +508,7 @@ pub fn write_header(context: Context, output_dir: &Path, environment: Environmen app.write_all(types_content.as_bytes()).unwrap(); } _ => { + let types_content = crate::format_r::format_r_code(&types_content); let path = output_dir .join(context.get_environment().to_base_path()) .join("types.R"); @@ -548,6 +549,7 @@ pub fn write_header(context: Context, output_dir: &Path, environment: Environmen app.write_all(generic_content.as_bytes()).unwrap(); } _ => { + let generic_content = crate::format_r::format_r_code(&generic_content); let path = output_dir .join(context.get_environment().to_string()) .join("generic_functions.R"); @@ -599,6 +601,7 @@ pub fn write_to_r_lang(content: String, output_dir: &Path, file_name: &str, envi app.write_all(full_content.as_bytes()).unwrap(); } _ => { + let full_content = crate::format_r::format_r_code(&full_content); cache::write_if_changed(&app_path, &full_content).unwrap(); } } From 586211cc0be7f45f4cb735b3d9b68cf8668dddf2 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Tue, 15 Sep 2026 13:19:25 +0200 Subject: [PATCH 18/52] fixed styler performance with cache and targeted file --- crates/typr-cli/src/format_r.rs | 54 +++++++++++++++++++++++++++++++++ crates/typr-cli/src/project.rs | 15 +++++++-- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/typr-cli/src/format_r.rs b/crates/typr-cli/src/format_r.rs index 9664299..7b2f13d 100644 --- a/crates/typr-cli/src/format_r.rs +++ b/crates/typr-cli/src/format_r.rs @@ -4,8 +4,19 @@ //! Formatting is best-effort: a missing `Rscript`/`styler` or a styling //! failure must never fail the build, so callers always get code back //! (formatted if possible, the original otherwise). +//! +//! Only content that genuinely benefits from re-indentation goes through +//! `styler` at all: `main.R` (the transpiled user program, with real nested +//! control flow) does, but `types.R`/`generic_functions.R` are one +//! definition per line by construction (simple `format!` templates in +//! `project.rs`) and never need it — callers should skip this module for +//! those. `styler` also spawns and loads a fresh R process per call, which +//! dominates build time on a project with several generated files; Project +//! builds should go through [`format_r_code_cached`] so unchanged content +//! (by hash, regardless of which file it ends up in) is never re-styled. use std::io::Write; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::OnceLock; @@ -25,6 +36,29 @@ pub fn format_r_code(code: &str) -> String { } } +/// Same as [`format_r_code`], but content-addressed under +/// `/format/.R`: a `typr build` that regenerates the exact +/// same source for a file (the common case — most files in a project don't +/// change between builds) reuses the previously styled output instead of +/// spawning another `Rscript`. Keyed on the raw pre-format content, so it's +/// shared across files and across builds. +pub fn format_r_code_cached(code: &str, cache_dir: &Path) -> String { + let entry = format_cache_entry_path(cache_dir, code); + if let Ok(cached) = std::fs::read_to_string(&entry) { + return cached; + } + let formatted = format_r_code(code); + if let Some(parent) = entry.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&entry, &formatted); + formatted +} + +fn format_cache_entry_path(cache_dir: &Path, code: &str) -> PathBuf { + cache_dir.join("format").join(format!("{:016x}.R", crate::cache::hash_str(code))) +} + /// `Rscript`/`styler` availability rarely changes within a single run, and /// probing it spawns a process — check once per process instead of once per /// file. @@ -103,4 +137,24 @@ mod tests { assert_ne!(formatted, messy); assert!(formatted.contains("f <- function(x) {")); } + + #[test] + fn cached_format_reuses_disk_entry_without_reformatting() { + let dir = std::env::temp_dir().join(format!("typr_format_cache_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::create_dir_all(&dir); + + let code = "f<-function(x){x+1}"; + let first = format_r_code_cached(code, &dir); + + // Pre-seed a distinguishable value directly at the cache entry, so a + // cache hit (vs. a fresh, indistinguishable-from-`first` styler run) + // is unambiguous. + let entry = format_cache_entry_path(&dir, code); + std::fs::write(&entry, "# from cache\n").unwrap(); + assert_eq!(format_r_code_cached(code, &dir), "# from cache\n"); + + let _ = std::fs::remove_dir_all(&dir); + let _ = first; // first run succeeded without panicking, cache dir was created + } } diff --git a/crates/typr-cli/src/project.rs b/crates/typr-cli/src/project.rs index 13e8ef5..87fd751 100644 --- a/crates/typr-cli/src/project.rs +++ b/crates/typr-cli/src/project.rs @@ -508,7 +508,6 @@ pub fn write_header(context: Context, output_dir: &Path, environment: Environmen app.write_all(types_content.as_bytes()).unwrap(); } _ => { - let types_content = crate::format_r::format_r_code(&types_content); let path = output_dir .join(context.get_environment().to_base_path()) .join("types.R"); @@ -549,7 +548,6 @@ pub fn write_header(context: Context, output_dir: &Path, environment: Environmen app.write_all(generic_content.as_bytes()).unwrap(); } _ => { - let generic_content = crate::format_r::format_r_code(&generic_content); let path = output_dir .join(context.get_environment().to_string()) .join("generic_functions.R"); @@ -601,7 +599,18 @@ pub fn write_to_r_lang(content: String, output_dir: &Path, file_name: &str, envi app.write_all(full_content.as_bytes()).unwrap(); } _ => { - let full_content = crate::format_r::format_r_code(&full_content); + // Project builds are a repeated dev-loop command (`typr build`), + // so cache the styled output by content hash — most files are + // unchanged between builds and shouldn't pay for another + // `Rscript` spawn. One-off StandAlone runs skip the cache: no + // project root to anchor `.typr_cache/` to, and no build loop + // to amortize it over. + let full_content = if environment.is_project() { + let cache_dir = Path::new(cache::CACHE_DIR); + crate::format_r::format_r_code_cached(&full_content, cache_dir) + } else { + crate::format_r::format_r_code(&full_content) + }; cache::write_if_changed(&app_path, &full_content).unwrap(); } } From bd99fac8cc1c446157064ef6008c540ceb932e0e Mon Sep 17 00:00:00 2001 From: Fabrice Date: Tue, 15 Sep 2026 13:30:33 +0200 Subject: [PATCH 19/52] formated code --- crates/typr-cli/src/format_r.rs | 4 +- crates/typr-cli/src/project.rs | 4 +- crates/typr-cli/src/standard_library.rs | 56 +++++++++++-------- crates/typr-cli/src/type_definition.rs | 15 +++-- .../src/components/context/vartype.rs | 10 +++- 5 files changed, 54 insertions(+), 35 deletions(-) diff --git a/crates/typr-cli/src/format_r.rs b/crates/typr-cli/src/format_r.rs index 7b2f13d..2a8e104 100644 --- a/crates/typr-cli/src/format_r.rs +++ b/crates/typr-cli/src/format_r.rs @@ -56,7 +56,9 @@ pub fn format_r_code_cached(code: &str, cache_dir: &Path) -> String { } fn format_cache_entry_path(cache_dir: &Path, code: &str) -> PathBuf { - cache_dir.join("format").join(format!("{:016x}.R", crate::cache::hash_str(code))) + cache_dir + .join("format") + .join(format!("{:016x}.R", crate::cache::hash_str(code))) } /// `Rscript`/`styler` availability rarely changes within a single run, and diff --git a/crates/typr-cli/src/project.rs b/crates/typr-cli/src/project.rs index 87fd751..a2df06c 100644 --- a/crates/typr-cli/src/project.rs +++ b/crates/typr-cli/src/project.rs @@ -1108,7 +1108,9 @@ pub fn build_file(path: &Path, test_mode: bool, checked_mode: bool, strict_mode: let context = crate::standard_library::load_project_type_definitions( Path::new("."), - Context::default().set_test_mode(test_mode).set_checked_mode(checked_mode), + Context::default() + .set_test_mode(test_mode) + .set_checked_mode(checked_mode), ); let step = Step::new("Type checking"); diff --git a/crates/typr-cli/src/standard_library.rs b/crates/typr-cli/src/standard_library.rs index a5fba1d..16b271f 100644 --- a/crates/typr-cli/src/standard_library.rs +++ b/crates/typr-cli/src/standard_library.rs @@ -1070,11 +1070,20 @@ mod tests { extend_context_with_ty_sources(Context::empty(), &[("base.ty", "@base_fn: (int) -> int;")]); assert!(base_skipped.is_empty()); - let (context, skipped) = - load_external_ty_definitions(base_context, &[("shiny.generated.ty", "@fluidPage: (Any) -> Any;")], "T2", "T2"); + let (context, skipped) = load_external_ty_definitions( + base_context, + &[("shiny.generated.ty", "@fluidPage: (Any) -> Any;")], + "T2", + "T2", + ); assert!(skipped.is_empty()); - let names: Vec = context.get_vartype().variables.iter().map(|(v, _)| v.get_name()).collect(); + let names: Vec = context + .get_vartype() + .variables + .iter() + .map(|(v, _)| v.get_name()) + .collect(); assert!( names.contains(&"base_fn".to_string()), "base context signature must survive the merge" @@ -1093,7 +1102,8 @@ mod tests { /// context"). #[test] fn load_external_ty_definitions_sees_types_declared_in_base_context() { - let (base_context, base_skipped) = extend_context_with_ty_sources(Context::empty(), &[("foreign.ty", FOREIGN_TY)]); + let (base_context, base_skipped) = + extend_context_with_ty_sources(Context::empty(), &[("foreign.ty", FOREIGN_TY)]); assert!(base_skipped.is_empty()); let (_context, skipped) = load_external_ty_definitions( @@ -1130,7 +1140,12 @@ mod tests { assert_eq!(skipped.len(), 1); assert_eq!(skipped[0].0, "broken.ty"); - let names: Vec = context.get_vartype().variables.iter().map(|(v, _)| v.get_name()).collect(); + let names: Vec = context + .get_vartype() + .variables + .iter() + .map(|(v, _)| v.get_name()) + .collect(); assert!( names.contains(&"base_fn".to_string()), "base context must survive a skipped external source" @@ -1151,8 +1166,7 @@ mod tests { #! tier: T1 @trusted_fn: (int) -> int;"; - let (context, skipped) = - load_external_ty_definitions(Context::default(), &[("mixed.ty", source)], "T2", "T2"); + let (context, skipped) = load_external_ty_definitions(Context::default(), &[("mixed.ty", source)], "T2", "T2"); assert!(skipped.is_empty()); let untrusted_type = context @@ -1227,7 +1241,10 @@ mod tests { degrade_if_version_out_of_range(context, &[("pkg.ty", source)], "1.11.0", None, Some("1.9.0")); let reason = reason.expect("an observed version below `since` must degrade"); - assert!(reason.contains("older") && reason.contains("1.11.0"), "unexpected reason: {reason}"); + assert!( + reason.contains("older") && reason.contains("1.11.0"), + "unexpected reason: {reason}" + ); let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); assert!(typ.is_unknown_function()); } @@ -1240,16 +1257,14 @@ mod tests { let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); assert!(skipped.is_empty()); - let (context, reason) = degrade_if_version_out_of_range( - context, - &[("pkg.ty", source)], - "1.0.0", - Some("1.5.0"), - Some("2.0.0"), - ); + let (context, reason) = + degrade_if_version_out_of_range(context, &[("pkg.ty", source)], "1.0.0", Some("1.5.0"), Some("2.0.0")); let reason = reason.expect("an observed version above `until` must degrade"); - assert!(reason.contains("newer") && reason.contains("1.5.0"), "unexpected reason: {reason}"); + assert!( + reason.contains("newer") && reason.contains("1.5.0"), + "unexpected reason: {reason}" + ); let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); assert!(typ.is_unknown_function()); } @@ -1262,13 +1277,8 @@ mod tests { let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); assert!(skipped.is_empty()); - let (context, reason) = degrade_if_version_out_of_range( - context, - &[("pkg.ty", source)], - "1.0.0", - Some("2.0.0"), - Some("1.5.0"), - ); + let (context, reason) = + degrade_if_version_out_of_range(context, &[("pkg.ty", source)], "1.0.0", Some("2.0.0"), Some("1.5.0")); assert!(reason.is_none()); let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); diff --git a/crates/typr-cli/src/type_definition.rs b/crates/typr-cli/src/type_definition.rs index 156c70b..2f5799f 100644 --- a/crates/typr-cli/src/type_definition.rs +++ b/crates/typr-cli/src/type_definition.rs @@ -126,8 +126,7 @@ pub fn parse_manifest(source: &str) -> Result { definition's provider to publish one this build supports" )); } - toml::from_str(source) - .map_err(|e| format!("manifest does not match format_version {CURRENT_FORMAT_VERSION}: {e}")) + toml::from_str(source).map_err(|e| format!("manifest does not match format_version {CURRENT_FORMAT_VERSION}: {e}")) } #[cfg(test)] @@ -174,11 +173,8 @@ extern_raw = false let manifest = parse_manifest(SHINY_MANIFEST).unwrap(); assert!(manifest.package.until.is_none()); - let with_until = SHINY_MANIFEST.replacen( - "since = \"1.11.0\"", - "since = \"1.11.0\"\nuntil = \"2.0.0\"", - 1, - ); + let with_until = + SHINY_MANIFEST.replacen("since = \"1.11.0\"", "since = \"1.11.0\"\nuntil = \"2.0.0\"", 1); let manifest = parse_manifest(&with_until).unwrap(); assert_eq!(manifest.package.until.as_deref(), Some("2.0.0")); } @@ -206,7 +202,10 @@ extern_raw = false fn unknown_format_version_is_refused_not_misparsed() { let source = SHINY_MANIFEST.replacen("format_version = 1", "format_version = 2", 1); let err = parse_manifest(&source).unwrap_err(); - assert!(err.contains("unsupported format_version = 2"), "unexpected error: {err}"); + assert!( + err.contains("unsupported format_version = 2"), + "unexpected error: {err}" + ); assert!(err.contains("format_version = 1"), "unexpected error: {err}"); } diff --git a/crates/typr-core/src/components/context/vartype.rs b/crates/typr-core/src/components/context/vartype.rs index 868a2a7..740edcf 100644 --- a/crates/typr-core/src/components/context/vartype.rs +++ b/crates/typr-core/src/components/context/vartype.rs @@ -736,7 +736,10 @@ impl VarType { } }); for name in degraded { - new_variables.insert((Var::from_name(&name).set_type(builder::any_type()), builder::unknown_function_type())); + new_variables.insert(( + Var::from_name(&name).set_type(builder::any_type()), + builder::unknown_function_type(), + )); } Self { variables: Arc::new(new_variables), @@ -955,7 +958,10 @@ mod tests { #[test] fn degrade_to_any_replaces_named_entries_with_any_unknown_function() { let vt = VarType::new().push_var_type(&[ - (Var::from_name("keep_me").set_type(builder::integer_type_default()), builder::integer_type_default()), + ( + Var::from_name("keep_me").set_type(builder::integer_type_default()), + builder::integer_type_default(), + ), ( Var::from_name("degrade_me").set_type(builder::integer_type_default()), builder::integer_type_default(), From e8339cfad4a6ab2f226bd19358664412e1822328 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Fri, 18 Sep 2026 16:09:47 +0200 Subject: [PATCH 20/52] added air formatter --- .github/workflows/ci.yml | 5 ++ crates/typr-cli/src/format_r.rs | 88 ++++++++++++++++----------------- crates/typr-cli/src/project.rs | 10 ++-- 3 files changed, 54 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 551c8ec..2d182a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,6 +192,11 @@ jobs: sudo apt-get install -y r-base-core sudo Rscript -e "install.packages(c('R6','S7','Matrix'), repos='https://cloud.r-project.org')" + - name: Install air (formats the generated R code) + run: | + curl -LsSf https://github.com/posit-dev/air/releases/latest/download/air-installer.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Build typr run: cargo build --release diff --git a/crates/typr-cli/src/format_r.rs b/crates/typr-cli/src/format_r.rs index 2a8e104..cac5958 100644 --- a/crates/typr-cli/src/format_r.rs +++ b/crates/typr-cli/src/format_r.rs @@ -1,36 +1,37 @@ -//! Formats generated R code with the `styler` package, so `R/*.R` files stay -//! readable when a user opens them to debug a transpilation. +//! Formats generated R code with `air` (the Rust-based R formatter/language +//! server, https://posit-dev.github.io/air/), so `R/*.R` files stay readable +//! when a user opens them to debug a transpilation. //! -//! Formatting is best-effort: a missing `Rscript`/`styler` or a styling -//! failure must never fail the build, so callers always get code back -//! (formatted if possible, the original otherwise). +//! Formatting is best-effort: a missing `air` binary or a formatting failure +//! must never fail the build, so callers always get code back (formatted if +//! possible, the original otherwise). //! //! Only content that genuinely benefits from re-indentation goes through -//! `styler` at all: `main.R` (the transpiled user program, with real nested +//! `air` at all: `main.R` (the transpiled user program, with real nested //! control flow) does, but `types.R`/`generic_functions.R` are one //! definition per line by construction (simple `format!` templates in //! `project.rs`) and never need it — callers should skip this module for -//! those. `styler` also spawns and loads a fresh R process per call, which -//! dominates build time on a project with several generated files; Project -//! builds should go through [`format_r_code_cached`] so unchanged content -//! (by hash, regardless of which file it ends up in) is never re-styled. +//! those. `air` is a single native binary (no R process to spawn/load, unlike +//! the `styler` package it replaced), but it's still a subprocess call per +//! file; Project builds should go through [`format_r_code_cached`] so +//! unchanged content (by hash, regardless of which file it ends up in) is +//! never re-formatted. use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::OnceLock; -/// Formats `code` with `styler::style_text()` when available; returns `code` -/// unchanged otherwise (missing Rscript, missing `styler`, or a styling -/// error). +/// Formats `code` with `air format` when available; returns `code` unchanged +/// otherwise (missing `air` binary, or a formatting error). pub fn format_r_code(code: &str) -> String { - if !styler_available() { + if !air_available() { return code.to_string(); } - match run_styler(code) { + match run_air(code) { Ok(formatted) => formatted, Err(e) => { - eprintln!("Warning: could not format R output with styler ({e}); writing unformatted code"); + eprintln!("Warning: could not format R output with air ({e}); writing unformatted code"); code.to_string() } } @@ -61,15 +62,13 @@ fn format_cache_entry_path(cache_dir: &Path, code: &str) -> PathBuf { .join(format!("{:016x}.R", crate::cache::hash_str(code))) } -/// `Rscript`/`styler` availability rarely changes within a single run, and -/// probing it spawns a process — check once per process instead of once per -/// file. -fn styler_available() -> bool { +/// `air` availability rarely changes within a single run, and probing it +/// spawns a process — check once per process instead of once per file. +fn air_available() -> bool { static AVAILABLE: OnceLock = OnceLock::new(); *AVAILABLE.get_or_init(|| { - Command::new("Rscript") - .arg("-e") - .arg("quit(status = if (requireNamespace('styler', quietly = TRUE)) 0L else 1L)") + Command::new("air") + .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() @@ -78,40 +77,41 @@ fn styler_available() -> bool { }) } -fn run_styler(code: &str) -> Result { - let script = "con <- file('stdin'); \ - code <- readLines(con, warn = FALSE); \ - close(con); \ - out <- styler::style_text(code); \ - cat(paste(as.character(out), collapse = '\n'), '\n', sep = '')"; - - let mut child = Command::new("Rscript") - .arg("-e") - .arg(script) +fn run_air(code: &str) -> Result { + // `--stdin-file-path` doesn't need to point at a real file — it's only + // used to locate `air.toml`/exclude patterns. `--no-configuration` makes + // the result independent of any config the target R project happens to + // have, since generated code should format the same way regardless of + // where it lands. + let mut child = Command::new("air") + .arg("format") + .arg("--stdin-file-path") + .arg("generated.R") + .arg("--no-configuration") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() - .map_err(|e| format!("failed to spawn Rscript: {e}"))?; + .map_err(|e| format!("failed to spawn air: {e}"))?; child .stdin .take() .expect("piped stdin") .write_all(code.as_bytes()) - .map_err(|e| format!("failed to write code to Rscript stdin: {e}"))?; + .map_err(|e| format!("failed to write code to air stdin: {e}"))?; let output = child .wait_with_output() - .map_err(|e| format!("failed to wait on Rscript: {e}"))?; + .map_err(|e| format!("failed to wait on air: {e}"))?; if !output.status.success() { return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); } - let formatted = String::from_utf8(output.stdout).map_err(|e| format!("styler produced non-UTF-8 output: {e}"))?; + let formatted = String::from_utf8(output.stdout).map_err(|e| format!("air produced non-UTF-8 output: {e}"))?; if formatted.trim().is_empty() && !code.trim().is_empty() { - return Err("styler produced empty output".to_string()); + return Err("air produced empty output".to_string()); } Ok(formatted) } @@ -121,8 +121,8 @@ mod tests { use super::*; #[test] - fn leaves_code_unchanged_when_styler_is_unavailable() { - if styler_available() { + fn leaves_code_unchanged_when_air_is_unavailable() { + if air_available() { return; // exercised by `styles_messy_code_when_available` instead } let messy = "f<-function(x){x+1}"; @@ -131,8 +131,8 @@ mod tests { #[test] fn styles_messy_code_when_available() { - if !styler_available() { - return; // no Rscript/styler on this machine — nothing to check + if !air_available() { + return; // no `air` binary on this machine — nothing to check } let messy = "f<-function(x){\nx+1\n}"; let formatted = format_r_code(messy); @@ -150,8 +150,8 @@ mod tests { let first = format_r_code_cached(code, &dir); // Pre-seed a distinguishable value directly at the cache entry, so a - // cache hit (vs. a fresh, indistinguishable-from-`first` styler run) - // is unambiguous. + // cache hit (vs. a fresh, indistinguishable-from-`first` air run) is + // unambiguous. let entry = format_cache_entry_path(&dir, code); std::fs::write(&entry, "# from cache\n").unwrap(); assert_eq!(format_r_code_cached(code, &dir), "# from cache\n"); diff --git a/crates/typr-cli/src/project.rs b/crates/typr-cli/src/project.rs index a2df06c..92b1070 100644 --- a/crates/typr-cli/src/project.rs +++ b/crates/typr-cli/src/project.rs @@ -600,11 +600,11 @@ pub fn write_to_r_lang(content: String, output_dir: &Path, file_name: &str, envi } _ => { // Project builds are a repeated dev-loop command (`typr build`), - // so cache the styled output by content hash — most files are - // unchanged between builds and shouldn't pay for another - // `Rscript` spawn. One-off StandAlone runs skip the cache: no - // project root to anchor `.typr_cache/` to, and no build loop - // to amortize it over. + // so cache the formatted output by content hash — most files are + // unchanged between builds and shouldn't pay for another `air` + // spawn. One-off StandAlone runs skip the cache: no project root + // to anchor `.typr_cache/` to, and no build loop to amortize it + // over. let full_content = if environment.is_project() { let cache_dir = Path::new(cache::CACHE_DIR); crate::format_r::format_r_code_cached(&full_content, cache_dir) From 10342f9451e01f0c3db2e6e70dc9278931a19b12 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Fri, 25 Sep 2026 17:31:15 +0200 Subject: [PATCH 21/52] visualization graph POC spec --- .claude/skills/case/SKILL.md | 130 ------ .gitignore | 1 + STORAGE.db | 0 crates/typr-cli/src/format_r.rs | 17 +- crates/typr-cli/src/project.rs | 2 + visualization_graph.md | 698 ++++++++++++++++++++++++++++++++ visualization_graph_v2.md | 517 +++++++++++++++++++++++ 7 files changed, 1228 insertions(+), 137 deletions(-) delete mode 100644 .claude/skills/case/SKILL.md create mode 100644 STORAGE.db create mode 100644 visualization_graph.md create mode 100644 visualization_graph_v2.md diff --git a/.claude/skills/case/SKILL.md b/.claude/skills/case/SKILL.md deleted file mode 100644 index 195e16d..0000000 --- a/.claude/skills/case/SKILL.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -name: case -description: Import a TypR bug into cases/ and drive it from repro to fix. Use when the user hits a bug in a TypR project and wants it turned into a reproducible case (from a `typr case snapshot` bundle, or directly from a live project directory plus a description of what's wrong) and then fixed here. Applies to the typr repo root (cases/ lives there). ---- - -# /case — from a broken project to a fixed, cataloged case - -Shortcuts the transition described in `cases/README.md` and CLAUDE.md's "Reproducible bug -catalog" section: today, going from "I hit a bug in my TypR project" to "Claude has a -reproducible case and is fixing it" needs several manual steps (mark the code, `typr case -snapshot`, move the bundle, hand-write `expect.toml`). This skill does all of that in one -invocation, and — the reason it exists — accepts a **live project directory plus your own prose -description of the problem** as an alternative to pre-marking the code with `#@case` comments. - -## Reading `args` - -`args` is free text, not a fixed flag syntax. It contains, in whatever order the user typed it: - -- **A filesystem path**, to one of two things: - - a `.case/` bundle already produced by `typr case snapshot` (has `case.toml` + - `repro/` at its top level) — **bundle mode**; - - a live TypR project directory (has `TypR/main.ty`, no `case.toml`) that has *not* been - snapshotted — **project mode**. -- **A prose description** of the bug — what's wrong, what should happen instead. In project - mode this is the *only* source of the problem description (no `#@case` marker required). In - bundle mode, treat it as additional context layered on top of whatever `snapshot` already - captured in `.case.md`/`expect.md`. -- Optionally, hints about where the bug lives (a runtime crash vs. wrong generated R vs. a type - error) — use these to pick `cmd`/`layer`, see step 2/3 below. - -If `args` contains no resolvable path, stop and ask the user for one via AskUserQuestion — -don't guess a path. A description alone, with nothing to build a `repro/` from, isn't enough. - -## Steps - -1. **Resolve the path and mode.** Check for `case.toml` at the path's top level → bundle mode. - Check for `TypR/main.ty` with no `case.toml` → project mode. If the path exists but matches - neither shape, say so and ask what it is rather than guessing. - -2. **Bundle mode.** - - Count existing dirs under `cases/` in *this* repo, zero-pad `+1` to 4 digits → `NNNN`. - - `mv cases/-` (slug = the bundle's dirname minus the trailing - `.case`). Don't re-copy or re-curate `repro/` — `snapshot` already curated it - (`TypR/` + `DESCRIPTION`/`NAMESPACE`, no `renv/`/`.git/`). - - If the user's prose in `args` adds anything not already in the bundle's `expect.md`, - append it there under a `## Précisions (session)` heading instead of discarding it. - -3. **Project mode.** - - From the prose, infer: - - `cmd`/`layer`: mentions of running the program, wrong printed/returned values, a crash - "when I run it", wrong dispatch at runtime → `cmd=run`, `layer=r-run`. Mentions of wrong - *generated R* (visible without running it) → `cmd=build`, `layer=transpile`. A pure type - error caught before transpilation → `cmd=check`, `layer=type`. Default, if unclear: - `cmd=build`, `layer=transpile`. - - `slug`: a short kebab-case handle (2-5 words) summarizing the bug. - - From the typr repo root: `cargo build --workspace` (get a current binary), then: - ``` - target/debug/typr case add --from --cmd --layer - ``` - This curates the copy, scaffolds `case.toml`, and captures a real `observed.txt` (a build - or run of the case as it stands right now). - - Rewrite `cases/-/expect.md`: a `# ` heading, a "## Ce qui devrait se - passer" section paraphrasing the user's prose, and an "## Anomalies" section that connects - that prose to what `observed.txt` actually shows going wrong (don't just paste the user's - text verbatim — ground it in the captured output). - -4. **Write `expect.toml` rules** (both modes). Read `observed.txt` and `expect.md`. Translate - each anomaly into `must_contain`/`must_not_contain` rules — substrings of real captured - output, never hand-written expected R (see `cases/README.md`, "L'oracle : on ne décrit jamais - le R attendu en entier"). For `layer = "r-run"` cases, target `file = "@run"` — this greps - the captured stdout+stderr of an actual `Rscript` execution rather than a generated file - (e.g. `must_not_contain = "no applicable method"`). Skim 2-3 existing `cases/*/expect.toml` - first to match the house style before writing new ones. - -5. **Confirm it reproduces**: `target/debug/typr case run ` from the repo root. Expect - `OPEN`. If it comes back `READY`, or fails in a way that doesn't match the described bug, the - rules are wrong (too loose, or targeting the wrong file) — fix them before touching - typr-core. Fixing code against a rule that isn't actually exercising the bug proves nothing. - -6. **Fix it.** Normal typr-core debugging: identify the failing pipeline stage (parsing / type- - checking / transpiling — CLAUDE.md's "Debugging Workflow" section has the playbook), fix it, - `cargo build --workspace`. - -7. **Confirm the fix**: `target/debug/typr case run ` → expect `READY`. Loop 6-7 until it is. - -8. **Freeze**: `target/debug/typr case freeze ` (captures golden `R/*.R`, flips `status` - to `fixed`). Then `target/debug/typr case run --status fixed` as a regression guard — must - print `PASS` and exit 0. - -9. **Report**: the case id, the root cause, what changed and where, and — if this bug class is - generic enough to deserve one — the `cargo test -p typr-core ::` command for a - proper unit test beyond the case harness. - -## Notes - -- Run every `typr case …` command from the typr repo root: `cases/` and the sandboxing in - `cases.rs` are resolved relative to cwd. -- `cases/README.md` and CLAUDE.md's "Reproducible bug catalog" section are the canonical docs - for how the harness works; re-read them if anything here seems out of date with the code. -- The oracle is always rules grepped against real captured output (generated R, or — for - `r-run` cases — real `Rscript` stdout+stderr via `file = "@run"`). Never author the expected R - by hand, and never golden-diff `@run` (its capture includes CLI progress timings that vary - every run — grep only). -- `case.toml` has a `checked = true` field (default `false`): appends `--checked` to the - replayed `cmd` (`build`/`run` only). Set it when the bug is specifically about - `typr_assert_type` runtime boundary checks (soundness_transpilation.md Phase A) — e.g. a - false-positive/false-negative assertion, not just "the program crashes". - -### `@extern`/`Foreign` bugs (interop with real R values) - -If the bug involves a value from outside TypR's own type system — an S3/S4/RC/R6 object, a -third-party package, anything reached via `@extern` — this is the `interop_matrix.md` (repo -root) family, soundness_transpilation.md Phase D. Before scaffolding a new case: - -- **Check the matrix first.** The cell (provenance row × TypR-boundary column) may already be - covered by an existing `cases/00NN-...` — read `interop_matrix.md`'s grid and its "Backlog" - section before assuming this is new. -- **`typr case add --from ` won't carry extra files.** It curates only `TypR/` + - `DESCRIPTION`/`NAMESPACE` (`copy_repro_curated` in `cases.rs`) — any `repro/fixtures/*.rds` the - repro needs must be added by hand into the scaffolded `repro/` afterward, same as the existing - `cases/0018`-`0029` do. -- **Never construct the foreign value from TypR source or a hand-written companion `.R` file - sourced at run.** TypR's project loader (`load_module.R`) sources every `R/*.R` into its own - *isolated* environment, only sharing bindings via `@include` tags TypR itself generates — a - hand-placed R file isn't reachable from `main.ty` without that plumbing. Instead: generate the - value once with a plain R script (extend `tools/gen_interop_fixtures.R` if it's a new fixture, - same "run by hand, `.rds` committed" convention as `tools/gen_r_name_db.R`) and read it back - via `@extern base::readRDS: (path: char) -> Foreign;`. -- **After fixing**, update the matching cell in `interop_matrix.md` to link the new case (or add - a row/column if this opens a genuinely new one), not just `cases/README.md`. diff --git a/.gitignore b/.gitignore index 19fdf11..4cac2d8 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ lcov.info # Agent guidelines AGENTS.md CLAUDE.md +/.claude/ # Documentation function_type_checking.md diff --git a/STORAGE.db b/STORAGE.db new file mode 100644 index 0000000..e69de29 diff --git a/crates/typr-cli/src/format_r.rs b/crates/typr-cli/src/format_r.rs index cac5958..11b62b5 100644 --- a/crates/typr-cli/src/format_r.rs +++ b/crates/typr-cli/src/format_r.rs @@ -78,16 +78,19 @@ fn air_available() -> bool { } fn run_air(code: &str) -> Result { - // `--stdin-file-path` doesn't need to point at a real file — it's only - // used to locate `air.toml`/exclude patterns. `--no-configuration` makes - // the result independent of any config the target R project happens to - // have, since generated code should format the same way regardless of - // where it lands. + // `--stdin-file-path` doesn't need to point at a real file — `air` only + // uses it to walk up the directory tree looking for `air.toml`/exclude + // patterns to apply. `air` has no `--no-configuration` flag (removed + // upstream — the flag existed at design time but is gone as of 0.11.0), + // so the only way to keep generated code formatted the same way + // regardless of the target R project's own `air.toml` is to point this + // at a path outside the project entirely: the system temp dir, whose + // ancestry stops well before any project root. + let stdin_file_path = std::env::temp_dir().join("generated.R"); let mut child = Command::new("air") .arg("format") .arg("--stdin-file-path") - .arg("generated.R") - .arg("--no-configuration") + .arg(&stdin_file_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/crates/typr-cli/src/project.rs b/crates/typr-cli/src/project.rs index 92b1070..5b38c63 100644 --- a/crates/typr-cli/src/project.rs +++ b/crates/typr-cli/src/project.rs @@ -1223,10 +1223,12 @@ fn strip_shebang(content: &str) -> &str { } } +// run the file normally pub fn run_file(path: &Path) { run_file_impl(path, false, false, false, false); } +// run the file while keeping the generated files pub fn run_file_keep(path: &Path, profile: bool, checked_mode: bool, strict_mode: bool) { run_file_impl(path, true, profile, checked_mode, strict_mode); } diff --git a/visualization_graph.md b/visualization_graph.md new file mode 100644 index 0000000..b1df2e8 --- /dev/null +++ b/visualization_graph.md @@ -0,0 +1,698 @@ +Formalisation du système comme une Semantic Graph IR de TypR, située après résolution des noms et suffisamment proche du système de types pour pouvoir représenter tes relations entre expressions, types, interfaces et dépendances. + +Voici une première spécification assez précise, mais encore indépendante de l'implémentation Rust. + +TypR Semantic Graph IR + +1. Objectif + +La Semantic Graph IR (SGIR) est une représentation intermédiaire du programme TypR sous forme de graphe orienté typé. + +Elle représente les entités sémantiques du programme et les relations entre ces entités. + +Elle a trois objectifs : + +1. fournir une représentation exploitable par le compilateur ; +2. permettre des analyses structurelles du code ; +3. fournir une représentation commune aux outils de développement. + +La SGIR n'est pas une représentation syntaxique du programme. Elle représente les relations sémantiques découvertes par le compilateur. + +--- + +2. Modèle mathématique + +Un programme TypR compilé est associé à un graphe : + +[ +G = (V,E,\tau_V,\tau_E,A) +] + +où : + +- V est l'ensemble des nœuds ; +- E \subseteq V \times V est l'ensemble des arêtes orientées ; +- \tau_V : V \rightarrow NodeKind associe un genre à chaque nœud ; +- \tau_E : E \rightarrow EdgeKind associe un genre à chaque arête ; +- A est l'ensemble des attributs associés aux nœuds et aux arêtes. + +Une arête : + +[ +e = (u,v) +] + +signifie que l'entité u entretient la relation \tau_E(e) avec l'entité v. + +Le graphe est orienté. + +Il peut contenir des cycles. + +--- + +3. Identité des nœuds + +Chaque nœud possède un identifiant stable pendant la durée de vie du graphe : + +NodeId + +L'identifiant n'encode pas la nature du nœud. + +Le type du nœud est obtenu par : + +NodeKind + +Exemple : + +NodeId(42) + kind = Function + +Les identifiants peuvent être implémentés avec "petgraph::stable_graph::NodeIndex" ou un mécanisme d'identifiants stable équivalent. + +--- + +4. Nœuds + +Le type fondamental est : + +enum NodeKind { + Module, + Function, + Parameter, + Variable, + Expression, + + Type, + Interface, + + Field, + Constructor, + + Literal, +} + +Cette liste est extensible. + +Un nœud représente une entité sémantique identifiable par le compilateur. + +--- + +4.1 Module + +Un module représente une unité de compilation ou un module TypR. + +Module + +Exemple : + +module math + +--- + +4.2 Function + +Une fonction représente une fonction déclarée ou une fonction générée par le compilateur. + +Function + +Une fonction peut être reliée : + +Function ──HasType────► FunctionType +Function ──Contains───► Expression +Function ──Uses───────► Variable +Function ──Calls──────► Function + +--- + +4.3 Variable + +Une variable représente une définition ou référence résolue. + +Une référence à une variable doit pointer vers le même nœud sémantique que sa définition lorsque celle-ci est connue. + +Exemple : + +let x = 10 +let y = x + +produit notamment : + +x ──UsedBy──► y + +ou, selon la convention retenue : + +y ──Uses──► x + +La seconde convention est recommandée. + +--- + +5. Expressions + +Une expression peut être représentée comme un nœud lorsque son identité est utile à l'analyse. + +Expression + +Exemple : + +x + y + +peut produire : + +Expression(Add) + ├── Uses ──► x + └── Uses ──► y + +Les expressions purement syntaxiques qui n'apportent aucune information utile à l'analyse peuvent être omises. + +La SGIR n'a donc pas obligation de représenter chaque élément de l'AST. + +--- + +6. Types + +Les types sont des entités du graphe. + +Type + +Exemples : + +Int +String +Vector +Person +(Self) -> Empty + +Les relations entre types sont représentées par des arêtes. + +Exemple : + +Vector + │ + └── ElementType ──► Int + +--- + +7. Interfaces + +Une interface TypR est représentée comme un nœud : + +Interface + +Exemple : + +type Printable <- interface { + print: (Self) -> Empty +} + +produit conceptuellement : + +Printable + │ + └── Requires ──► print + │ + └── HasType ──► (Self) -> Empty + +Une implémentation produit : + +Person ──Implements──► Printable + +--- + +8. Relations + +Les relations sont représentées par : + +enum EdgeKind { + Contains, + + Defines, + Uses, + + Calls, + + HasType, + + ElementType, + FieldType, + + Implements, + Requires, + + Refines, + + Returns, + ParameterType, + + Instantiates, +} + +Cette liste est volontairement limitée. + +Une relation doit être ajoutée uniquement lorsqu'elle possède une signification sémantique exploitable. + +--- + +9. Direction des relations + +La direction doit être définie par la sémantique de la relation. + +La convention générale est : + +«L'arête part de l'entité qui dépend ou possède une relation vers l'entité dont elle dépend.» + +Ainsi : + +Function ──Uses──► Variable +Function ──Calls──► Function +Person ──Implements──► Printable +Function ──HasType──► FunctionType +Vector ──ElementType──► Int + +Cette convention permet notamment d'effectuer facilement une analyse des dépendances sortantes. + +--- + +10. Exemple complet + +Pour : + +type Printable <- interface { + print: (Self) -> Empty +} + +type Person = { + name: String +} + +fn print_person(person: Person) -> Empty { + print(person.name) +} + +la SGIR peut contenir : + + ┌──────────────┐ + │ Printable │ + └──────┬───────┘ + ▲ + Implements + │ + ┌──────┴───────┐ + │ Person │ + └──────┬───────┘ + │ + FieldType + │ + ▼ + String + + +┌─────────────────┐ +│ print_person │ +└───────┬─────────┘ + │ + HasType + │ + ▼ + (Person) -> Empty + │ + Uses + ▼ + Person + +--- + +11. Projections + +Le graphe sémantique constitue la représentation fondamentale. + +Les graphes spécialisés sont des projections du graphe. + +Soit : + +[ +G=(V,E) +] + +Une projection est définie par un prédicat : + +[ +P : E \rightarrow {true,false} +] + +et produit : + +[ +G_P=(V,{e\in E\mid P(e)}) +] + +--- + +11.1 Dependency Graph + +Le dependency graph sélectionne les relations représentant une dépendance. + +Uses +Calls +Implements +Requires +HasType + +Exemple : + +Function A ──Uses──► Type B +Function A ──Calls──► Function C + +--- + +11.2 Type Graph + +Le type graph sélectionne principalement : + +HasType +ElementType +FieldType +Implements +Requires +Refines +Instantiates + +Il permet d'explorer la structure du système de types. + +--- + +11.3 Coupling Graph + +Le coupling graph est une analyse dérivée. + +Il ne correspond pas nécessairement à une catégorie d'arêtes unique. + +Le couplage peut être calculé à partir des relations : + +Uses +Calls +HasType +Implements +Requires +Refines + +et de leur évolution dans le temps. + +Ainsi : + +[ +Coupling(A,B) +] + +est une propriété calculée du sous-graphe reliant A et B. + +--- + +12. Abstraction + +L'abstraction n'est pas nécessairement une arête. + +Elle décrit quelles propriétés d'un nœud ou d'une interface sont exposées ou cachées. + +Par exemple : + +Implementation + │ + ▼ + Interface + +L'interface constitue une frontière abstraite. + +Une analyse peut donc déterminer : + +Concrete node + │ + │ Implements + ▼ +Abstract node + +et identifier quelles propriétés peuvent varier sans modifier les consommateurs. + +--- + +13. Construction du graphe + +La SGIR est construite progressivement pendant la compilation. + +Pipeline recommandé : + +Source + │ + ▼ +AST + │ + ▼ +Name Resolution + │ + ▼ +HIR + │ + ├─────────────► Type Checking + │ + ▼ +Semantic Graph Builder + │ + ▼ +SGIR + +Le graphe doit être construit à partir du HIR et des informations produites par la résolution des noms et le type checking. + +Il ne doit pas être construit directement depuis le texte source. + +--- + +14. Propriété importante : référence aux entités du compilateur + +Les nœuds du graphe doivent pouvoir référencer les entités internes existantes. + +Exemple : + +enum NodeKind { + Function(FunctionId), + Variable(VariableId), + Type(TypeId), + Interface(InterfaceId), + Expression(ExprId), +} + +Ainsi le graphe ne duplique pas les informations du compilateur. + +Il fournit une couche relationnelle au-dessus de celles-ci. + +--- + +15. API minimale + +Une API minimale peut être : + +struct SemanticGraph { + graph: StableDiGraph, +} + +avec : + +impl SemanticGraph { + fn add_node(&mut self, node: Node) -> NodeId; + + fn add_edge( + &mut self, + from: NodeId, + to: NodeId, + kind: EdgeKind, + ) -> EdgeId; + + fn node(&self, id: NodeId) -> &Node; + + fn edges_from(&self, id: NodeId) -> impl Iterator; + + fn edges_to(&self, id: NodeId) -> impl Iterator; +} + +Une couche de requête peut ensuite être ajoutée : + +struct GraphQuery<'a> { + graph: &'a SemanticGraph, +} + +avec par exemple : + +fn dependencies_of(node: NodeId) -> Vec; + +fn dependents_of(node: NodeId) -> Vec; + +fn implementations_of(interface: NodeId) -> Vec; + +fn users_of(node: NodeId) -> Vec; + +fn callers_of(function: NodeId) -> Vec; + +--- + +16. Sérialisation + +La SGIR peut être exportée sous une forme indépendante de "petgraph". + +Format conceptuel : + +{ + "nodes": [ + { + "id": 1, + "kind": "Function", + "name": "print_person" + }, + { + "id": 2, + "kind": "Interface", + "name": "Printable" + } + ], + "edges": [ + { + "from": 1, + "to": 2, + "kind": "Uses" + } + ] +} + +L'export doit être considéré comme une représentation externe de la SGIR et non comme son implémentation interne. + +--- + +17. Visualisation + +Le compilateur doit pouvoir produire une représentation Graphviz/DOT : + +typr graph +typr graph --types +typr graph --dependencies +typr graph --coupling + +Exemple : + +typr graph --types --format dot + +La visualisation interactive pourra être implémentée ultérieurement sans modifier le modèle sémantique. + +--- + +18. Évolution future + +La SGIR est conçue pour permettre des analyses temporelles. + +Pour deux versions : + +[ +G_t +] + +et : + +[ +G_{t+1} +] + +on peut définir : + +[ +\Delta G = G_{t+1} - G_t +] + +permettant d'observer : + +- ajout de nœuds ; +- suppression de nœuds ; +- ajout de relations ; +- suppression de relations ; +- modification des types ; +- modification des interfaces ; +- changement de dépendances. + +Cela permet ultérieurement de calculer des propriétés telles que : + +coupling evolution +interface stability +dependency volatility +change propagation +architectural drift + +sans modifier la représentation fondamentale du programme. + +--- + +19. Principe architectural + +La règle fondamentale de la SGIR est : + +«Le graphe représente les relations ; les entités du compilateur restent la source de vérité.» + +Ainsi : + +AST + │ + ├── syntaxe + │ + ▼ +HIR + │ + ├── identité + ├── résolution + ├── expressions + │ + ▼ +Type System + │ + ├── types + ├── interfaces + ├── contraintes + │ + ▼ +Semantic Graph + │ + ├── relations + ├── dépendances + ├── structure + └── historique futur + +La SGIR devient donc une couche relationnelle du compilateur, et non un deuxième AST. + +--- + +20. Implémentation de référence + +Implémentation initiale recommandée : + +[dependencies] +petgraph = "..." +slotmap = "..." +serde = { version = "...", features = ["derive"] } +serde_json = "..." + +Structure : + +typr-core/ +└── src/ + ├── ast/ + ├── hir/ + ├── resolve/ + ├── types/ + └── graph/ + ├── mod.rs + ├── node.rs + ├── edge.rs + ├── graph.rs + ├── builder.rs + ├── query.rs + └── projection.rs + +Le graphe interne utilise "StableDiGraph". + +Les projections et analyses ne manipulent pas directement les détails de "petgraph". + +Cette séparation permet de remplacer ultérieurement "petgraph" sans modifier le modèle conceptuel de TypR.Le point que je trouve particulièrement intéressant pour TypR est que la SGIR pourrait devenir une véritable IR d'analyse, au même titre que ton HIR est une IR de compilation. Ensuite, le couplage, les interfaces, les types, les dépendances et même l'évolution Git ne seraient plus des systèmes séparés : ce seraient des analyses différentes d'une même structure. diff --git a/visualization_graph_v2.md b/visualization_graph_v2.md new file mode 100644 index 0000000..9da737b --- /dev/null +++ b/visualization_graph_v2.md @@ -0,0 +1,517 @@ +# TypR Block Graph — spécification v2 + +Visualiseur interactif du code TypR sous forme de **graphe de blocs hiérarchique**, pensé à la +fois comme outil pédagogique (comprendre un programme et les concepts du langage) et comme outil +de revue de code. Disponible en CLI (`typr graph`) et dans le playground. + +Cette version remplace `visualization_graph.md` (v1). Elle garde de la v1 l'idée d'un graphe +sémantique typé et de projections, mais l'adapte à l'architecture réelle de `typr-core` et au +modèle « tout est un bloc » décidé en discussion. + +> Pas de RFC nécessaire : l'outil ne change pas le sens du langage (voir `rfcs/README.md`). + +--- + +## 0. Décisions prises + +| # | Sujet | Décision | +|---|---|---| +| A | Priorité | Graphe d'architecture/revue d'abord ; chaque relation peut porter une **justification** (`evidence`) pour servir la pédagogie ensuite. | +| B | Granularité | **Les expressions sont des blocs.** `7`, `12 + 3`, une fonction, un module, un type : tout est un bloc. Les déclarations (`let`, `type`, `module`, paramètres) ne créent pas de bloc : elles **nomment** un bloc. | +| C | Précision | Résolution des noms **par nom** (comme le LSP) pour commencer, avec un niveau de confiance explicite sur chaque référence. Un **enregistreur de types** minimal dans `typing()` fournit le type de chaque bloc. Une vraie passe de résolution de noms est un chantier séparé. | +| D | Spans | `HelpData` gagne un **offset de fin** (prérequis, profite aussi au LSP). | +| E | Rendu | **React Flow (xyflow) + ELK.js** dans le playground. Navigation par « entrée » dans les blocs, fil d'Ariane, historique dans l'URL. Navigation clavier prévue. | +| G | Docs | Un bloc ` ```typr graph ` de la documentation ouvre le playground sur la vue graphe. | +| — | Crate | Nouvelle crate `crates/typr-graph`, WASM-safe, dépendant de `typr-core`. | +| Q1 | Frontière | Toute dépendance qui franchit la frontière d'un bloc apparaît comme **port d'entrée implicite** (capture). Le bloc expose honnêtement tout ce dont il dépend : localité du raisonnement et composabilité rendues visibles. | +| Q2 | Appel ≠ contenance | Une fonction est son propre bloc, défini une seule fois ; un appel ne la contient pas, il la **référence** par une relation. Deux gestes distincts : *entrer* et *aller à la définition*. | +| Q3 | Mutation/boucles | Les boucles sont des blocs à **ports d'état** (entrée/sortie par variable mutée). | +| Q4 | Types | Un type est un bloc ; son interface ce sont ses **méthodes** (fonctions qui le prennent en 1ʳᵉ position). D'autres positions (n-ième paramètre, retour) pourront s'ajouter. | + +Les points non tranchés sont listés en §13 et seront discutés **quand le besoin se présente**, pas avant. + +--- + +## 1. Objectifs et non-objectifs + +**Objectifs** + +1. Représenter un programme TypR comme une hiérarchie de blocs explorable de l'intérieur. +2. Rendre visibles les frontières : ce qu'un bloc consomme (entrées), expose (sorties), cache. +3. Rendre visibles les relations du système de types : type d'un bloc, méthodes d'un type, + satisfaction d'interface avec sa justification, sous-typage. +4. Servir la revue de code : dépendances d'un bloc, puis (plus tard) différence entre deux versions. + +**Non-objectifs (pour l'instant)** + +- Éditer le programme depuis le graphe (le graphe est une vue, pas un éditeur). +- Remplacer l'AST ou introduire un HIR : le graphe est dérivé de l'AST + du contexte. +- Analyse historique git, métriques de couplage : reportées (§12, étape 7+). + +--- + +## 2. Écart avec la v1 et contraintes de la base de code + +| Hypothèse v1 | Réalité de `typr-core` | Conséquence | +|---|---|---| +| Pipeline AST → résolution → HIR → typage | `Lang` est l'AST, typé directement ; pas de HIR | Le builder part de `Lang` + `Context` final + enregistreur de types | +| Noms résolus vers des IDs | Résolution par chaîne dans `VarType` (masquage par ordre, surcharge par type du 1ᵉʳ argument) | Références résolues par nom, avec `confidence` (§5.3) | +| Types par expression disponibles | `TypeChecker` ne garde qu'un type par instruction de haut niveau ; `typing()` (`processes/type_checking/mod.rs`) est l'unique point d'entrée récursif | Enregistreur dans `typing()` (§7.2) | +| `Person implements Printable` déclaré | Interfaces **structurelles**, sans `impl` (`interface_satisfaction.rs`) | `Satisfies` est **calculé**, avec justification | +| Types = entités | `Type` est une valeur structurelle portant un `HelpData` | Canonisation (position retirée) pour l'identité | +| Identité = `NodeIndex` | Instable d'une compilation à l'autre | Identité = **clé sémantique** (§6) ; les index restent internes | +| — | La stdlib est préchargée dans le contexte (milliers d'entrées) | Seules les entrées **référencées** apparaissent, comme blocs externes repliés | +| — | Hiérarchie de sous-typage déjà calculée : `Context.subtypes: Graph` (`context/graph.rs`) | Réutilisée pour la relation `Subtype` | +| — | `HelpData` = `offset` + `file_name` seulement | Ajout d'un offset de fin (décision D) | + +Note : le parseur ne désucre ni `|>` ni `.` (ils restent des `Lang::Operator`), donc le graphe +peut refléter la forme du source. + +--- + +## 3. Modèle + +### 3.1 Vocabulaire + +- **Bloc** : unité sémantique délimitée (une expression, une fonction, un type, un module…). + Il a une **interface** (ports) et éventuellement un **intérieur** (un sous-graphe de blocs). +- **Port** : point d'entrée ou de sortie d'un bloc, nommé et (si connu) typé. +- **Fil** (*wire*) : flot de données d'un port de sortie vers un port d'entrée, **à l'intérieur + d'un même bloc parent**. Les fils ne traversent jamais une frontière : ce qui entre dans un bloc + passe par un de ses ports d'entrée. +- **Relation** : arête sémantique entre deux blocs quelconques (référence vers une définition, + typage, satisfaction d'interface…). Contrairement aux fils, les relations peuvent relier des + blocs à des niveaux différents. +- **Nom** : une déclaration (`let`, `type`, `module`, paramètre, liaison de motif) attache un nom + à un bloc. Un bloc anonyme reste un bloc. + +### 3.2 Anatomie d'un bloc + +``` +Block +├── key : BlockKey clé sémantique stable (§6) +├── kind : BlockKind (§4) +├── name : Option donné par la déclaration qui le nomme +├── span : Option début..fin dans le source (None pour stdlib/externe) +├── type : Option type du bloc (enregistreur, §7.2) +├── inputs : Vec explicites (paramètres, opérandes, arguments) +│ + implicites (captures, §3.3) +├── outputs : Vec en général un seul (la valeur) ; +│ plusieurs pour Record, Module, TypeDecl (§4) +├── origin : User | Std | RPackage(name) +└── body : Option intérieur explorable + ├── children : Vec + └── wires : Vec (from: PortRef, to: PortRef) + +Port { name, type: Option, implicit: bool, visibility: Public | Private } +PortRef { block: BlockKey, port: String } // block = le parent lui-même pour ses propres ports +``` + +### 3.3 Règle de frontière (Q1) + +Pour tout bloc `B` possédant un intérieur : + +> **Chaque nom utilisé dans l'intérieur de `B` et défini hors de `B` devient un port d'entrée +> implicite de `B`.** + +Dans l'intérieur de `B`, ce port est la source des fils vers ses utilisateurs. La définition +elle-même est reliée par une relation `Ref` (§5), jamais par un fil. + +Conséquences : + +- l'interface d'un bloc liste **toutes** ses dépendances : on peut raisonner sur un bloc sans + regarder ailleurs, ce qui est la base de la revue locale et de l'enseignement de la composition ; +- les dépendances remontent naturellement : si `norm2` capture `sq`, et que `norm2` est dans un + module `M` où `sq` est aussi défini, `sq` n'est **pas** une entrée de `M` (la capture est + résolue à l'intérieur de `M`) ; +- au niveau du bloc racine `Program`, les entrées implicites sont exactement les dépendances + externes (stdlib, paquets R) du programme. + +Les opérateurs natifs (`+`, `*`, …) ne sont pas des captures : ils font partie du bloc `Operator`. +(Si un opérateur se résout vers une fonction utilisateur, voir §13.) + +Le rendu **peut** replier ces fils (ex. afficher le nom de l'appelé en étiquette du bloc `Apply` +plutôt qu'un fil depuis le port `sq`) : c'est une option d'affichage, le modèle garde le fil. + +--- + +## 4. Catalogue des blocs + +Colonne **Étape** : quand le bloc est implémenté (voir §12). Tout variant de `Lang` non encore +couvert produit un bloc **`Opaque`** (texte source, type si connu, pas d'intérieur) : le builder +est total dès la première étape et ne panique jamais. + +| Kind | Source (`Lang`) | Entrées | Sorties | Intérieur | Étape | +|---|---|---|---|---|---| +| `Program` | racine `Lines` | dépendances externes | — | instructions de haut niveau | 1 | +| `Literal` | `Number`, `Integer`, `Bool`, `Char`, `Null`, `NA`, `Empty` | — | valeur | — | 1 | +| `Operator(op)` | `Operator` (hors `.`/`\|>`), `Not` | opérandes (`lhs`, `rhs`) | valeur | les opérandes | 1 | +| `Apply` | `FunctionApp`, `VecFunctionApp`, `.`/`\|>` (UFCS) | `callee` + `arg0..n` (ou noms) | valeur | les arguments | 1 | +| `Scope` | `Scope`, corps de fonction | captures | valeur de la dernière expression | blocs nommés par `let` + expressions | 1 | +| `Function` | `Function`, `Lambda` | paramètres + captures | retour | le corps (paramètres comme sources) | 1 | +| `Record` | `List` (champs nommés), `ConstructorCall` | un port par champ (+ spreads) | valeur **et** un port par champ | les valeurs des champs | 1 | +| `Access` | accès champ (`$`, `.x`) | la valeur | le champ | — | 1 | +| `TypeDecl` | `Alias` (record, union, opaque) | — | ses **méthodes** (Q4) | les champs / membres (blocs `TypeExpr`) | 1 | +| `TypeExpr` | type en position d'annotation | — | — | sous-types (`int \| char`, `list{…}`) | 1 | +| `Interface` | `Alias` vers `interface { … }` | — | — | signatures requises | 1 | +| `If` | `If` | `cond` + captures | valeur | `cond`, `then`, `else` (chacun explorable) | 1 | +| `Tuple` | `Tuple` | un port par position | valeur + un par position | éléments | 1 | +| `Array` | `Array`, `Vector` | éléments | valeur | éléments | 1 | +| `Opaque` | tout le reste | captures si connues | valeur | — | 1 | +| `Loop` | `ForLoop`, `WhileLoop`, `Loop` | itérable/condition + **état entrant** | **état sortant** | le corps | 5 | +| `Match` | `Match` | le sujet + captures | valeur | un sous-bloc par bras (§13) | 5 | +| `Module` | `Module` | captures | **membres `@pub` seulement** | tous les membres, privés compris | 5 | +| `RCode` | `RBlock`, `ExternBlock`, `RFunction` | — | valeur | — (frontière opaque vers R) | 5 | + +### 4.1 Précisions + +- **Let** ne crée pas de bloc : `let a <- sq(p$x)` nomme le bloc `Apply` ; toute utilisation + ultérieure de `a` dans le même `Scope` est un fil depuis sa sortie. +- **Assign hors boucle** (`x <- x + 1`) : les utilisations suivantes de `x` sont câblées depuis + le nouveau bloc. Aucune étiquette de version n'est affichée : le câblage suffit. +- **Loop (Q3)** : chaque variable définie hors de la boucle et réaffectée dedans devient une + paire de ports `état entrant` / `état sortant` du bloc `Loop`. Le reste du `Scope` englobant + est câblé depuis l'état sortant. +- **Record et Module ont plusieurs sorties** : un record expose un port par champ ; un module + n'expose que ses membres `@pub`. Entrer dans un module montre aussi les membres privés, + marqués comme tels : **le secret se voit de l'intérieur, jamais sur l'interface.** +- **Access** : `p$x` est un bloc `Access(x)` câblé depuis `p`. Quand la source est un bloc + `Record` visible au même niveau, le rendu peut câbler directement depuis le port `x` du record. +- **Types** : un bloc d'expression est relié à son type par `HasType` (§5). Un `TypeDecl` + expose comme sorties ses méthodes, c'est-à-dire les fonctions reliées à lui par + `TypePosition { index: 0 }`. + +--- + +## 5. Relations + +### 5.1 Catalogue + +| Kind | De → vers | Sens | Étape | +|---|---|---|---| +| `Ref` | utilisation (`Apply.callee`, port implicite) → bloc défini | « dépend de la définition » | 1 | +| `HasType` | bloc d'expression → `TypeDecl`/`TypeExpr` | typage | 1 | +| `TypePosition { index }` | `Function` → `TypeDecl` | le type apparaît en paramètre `index` (`0` = méthode, Q4) ; `Return` plus tard | 1 (index 0), 7 (autres) | +| `Satisfies` | `TypeDecl` → `Interface` | satisfaction structurelle, **calculée** | 3 | +| `DeclaredAs` | `TypeDecl` → `Interface` | intersection `Record & Interface` vérifiée à la déclaration | 3 | +| `Subtype` | type → supertype | depuis `Context.subtypes` | 3 | +| `Instantiates` | `Apply` → `Function` générique | instanciation d'un générique | §13 | + +Convention de sens (reprise de la v1) : l'arête part de ce qui **dépend** vers ce dont il dépend. + +### 5.2 Justification (`evidence`) + +Toute relation calculée peut porter une justification lisible, affichée au clic : + +```json +{ "kind": "Satisfies", "from": "type:Point", "to": "type:Printable", + "evidence": [ { "requires": "show: (Self) -> char", "provided_by": "val:show(Point)" } ] } +``` + +C'est le support principal de l'usage pédagogique : le graphe ne dit pas seulement *que* +`Point` satisfait `Printable`, mais *grâce à quelle fonction*. + +### 5.3 Confiance des références (approche C-i) + +La résolution se fait par nom. Chaque `Ref` indique : + +| `confidence` | Signification | +|---|---| +| `exact` | un seul candidat visible | +| `by_name` | plusieurs définitions du nom, choisie par la règle de masquage (la plus récente) | +| `ambiguous` | surcharge : plusieurs candidats selon le type du 1ᵉʳ argument ; `candidates: [...]` listés | + +Le rendu montre visuellement les références non exactes (pointillés). Ce champ disparaîtra, ou +sera toujours `exact`, quand une vraie passe de résolution existera. + +--- + +## 6. Identité : clés sémantiques + +L'identité d'un bloc est une **clé stable** d'une compilation à l'autre, ce que la revue (diff +entre versions) exige. Les index internes du graphe ne sortent jamais de la crate. + +``` +BlockKey := namespace ":" path +namespace := "val" | "type" | "std" | "r" # TypR sépare les espaces de noms valeurs / alias +path := segment ("/" segment)* +segment := nom # bloc nommé + | nom "(" type_1er_param ")" # surcharge : show(Point) + | "#" index # bloc anonyme, position parmi ses frères + | rôle # then, else, cond, arg0, lhs, rhs, body… +``` + +Les modules qualifient avec `::` : `val:Geo::area`. Exemples : +`val:norm2`, `val:norm2/a` (le bloc nommé `a`), `val:norm2/#1` (l'expression finale), +`val:norm2/#1/rhs/arg0`, `type:Point`, `std:print`. + +Les segments nommés sont stables ; les segments positionnels (`#n`) le sont moins, et le diff les +apparie en second (étape 6). + +--- + +## 7. Construction + +### 7.1 Entrées + +``` +source ─parse─► Lang ─typing()─► Context final + │ │ + │ TypeRecorder (span → Type) + ▼ ▼ + typr-graph::build(&Lang, &Context, &TypeTable) ─► BlockGraph +``` + +1. Parcours de `Lang` → arbre de blocs + fils locaux (§3, §4). +2. Résolution par nom des captures, avec portée lexicale simulée pendant le parcours, et + `Context` final pour les noms de haut niveau et la stdlib (§5.3). +3. Types des blocs depuis la `TypeTable` (§7.2) ; types des paramètres depuis leurs annotations. +4. Relations de typage (`HasType`, `TypePosition`) ; plus tard `Satisfies` et `Subtype` (étape 3). +5. Élagage de la stdlib : seules les entrées référencées sont matérialisées (`origin: Std`). + +### 7.2 Enregistreur de types (prérequis dans `typr-core`) + +`typing(context, expr)` est l'unique point d'entrée récursif du typage. On l'enveloppe : + +```rust +// processes/type_checking/type_recorder.rs +thread_local! { static RECORDER: RefCell> = RefCell::new(None); } + +pub fn with_recording(f: impl FnOnce() -> R) -> (R, TypeTable); +// dans typing() : si RECORDER est actif, table.insert(span(expr), result.value.clone()) +``` + +- Désactivé : coût d'une lecture de `thread_local` par appel, aucune allocation. +- Clé : `(file, start, end)`. D'où le prérequis D : sans offset de fin, `a` et `a + sq(…)` + partagent la même clé. +- `typing()` peut être rappelée sur une même expression (essais de dispatch, unification). La + politique retenue au départ est « dernière valeur non-`Failed` ». À valider sur les cas réels à + l'étape 0, en journalisant les conflits. +- Le WASM est mono-thread : le `thread_local` y est sans risque. + +### 7.3 Offset de fin dans `HelpData` (prérequis D) + +`HelpData { offset, end, file_name }`, rempli par le parseur (`nom_locate` donne la position +après chaque combinateur). Les `HelpData` synthétiques gardent `end = offset`. Bénéficiaires +annexes : surlignage exact des diagnostics et des plages LSP. + +--- + +## 8. Projections + +Le graphe plat de la v1 est une **projection** de la hiérarchie : on aplatit l'arbre des blocs, +on remplace chaque chaîne capture → `Ref` par une arête directe, puis on filtre par prédicat sur +les kinds de relation (v1 §11). + +| Projection | Contenu | Étape | +|---|---|---| +| Dépendances | blocs nommés de haut niveau + `Ref` aplaties | 2 (CLI) | +| Types | `TypeDecl`, `Interface`, `HasType`, `TypePosition`, `Satisfies`, `Subtype` | 3 | +| Couplage | dérivée (fan-in/fan-out, cycles), v1 §11.3 | 7 | + +--- + +## 9. Format d'échange (JSON) + +Carte à plat des blocs (plus simple à naviguer côté client qu'un arbre imbriqué), versionnée : + +```json +{ + "format": "typr-block-graph", + "version": 1, + "root": "val:", + "blocks": { + "val:norm2": { + "kind": "Function", "name": "norm2", "origin": "User", + "span": { "file": "main.ty", "start": 98, "end": 170 }, + "type": "(Point) -> int", + "inputs": [ { "name": "p", "type": "Point", "implicit": false }, + { "name": "sq", "type": "(int) -> int", "implicit": true } ], + "outputs": [ { "name": "out", "type": "int" } ], + "body": { + "children": [ "val:norm2/a", "val:norm2/#1" ], + "wires": [ { "from": { "block": "val:norm2", "port": "p" }, + "to": { "block": "val:norm2/a", "port": "arg0" } } ] + } + } + }, + "relations": [ + { "kind": "Ref", "from": "val:norm2", "port": "sq", "to": "val:sq", "confidence": "exact" }, + { "kind": "TypePosition", "from": "val:show(Point)", "to": "type:Point", "index": 0 } + ] +} +``` + +Ce JSON est un **contrat externe** (CLI, WASM, playground, docs) : tout changement incompatible +incrémente `version`. Les types sont transmis sous leur forme imprimée (`Type::pretty()`) ; une +forme structurée pourra suivre si le front en a besoin. + +--- + +## 10. Architecture des crates + +``` +crates/typr-core + HelpData.end (D) + type_recorder.rs (§7.2) +crates/typr-graph NOUVELLE — pure logique, WASM-safe (pas d'E/S, pas de tokio) + src/model.rs Block, Port, Wire, Relation, BlockKey + src/build/ parcours de Lang, portée lexicale, captures, résolution par nom + src/typing.rs HasType, TypePosition, (étape 3) Satisfies, Subtype + src/project.rs projections + src/export/ json.rs (serde), dot.rs (un niveau à la fois) + src/diff.rs (étape 6) +crates/typr-cli commande `typr graph` +crates/typr-wasm fn semantic_graph(source) -> JSON (+ multi-fichiers plus tard) +``` + +Dépendances : `serde`/`serde_json` (déjà dans le workspace). `petgraph` seulement si un +algorithme le justifie (cycles, étape 7) ; il reste caché derrière `model.rs`. + +--- + +## 11. Interface utilisateur (playground) + +**Pile** : React Flow (xyflow) pour les nœuds à ports (chaque bloc est un composant React), +ELK.js pour la mise en page (`elk.portConstraints`), un niveau rendu à la fois. + +``` + Programme › norm2 › #1 [←] [→] +┌──────────────────────────────────────────────────────┐ +│ p: Point ●───► Access(y) ──► sq(·) ──┐ │ +│ ▼ │ +│ a: int ●────────────────────────► ( + ) ──► ● int │ +└──────────────────────────────────────────────────────┘ +``` + +| Geste | Effet | +|---|---| +| clic | sélectionne le bloc, surligne sa plage dans Monaco, panneau de détails (type, ports, relations, justifications) | +| double-clic | **entre** dans le bloc (s'il a un intérieur) avec une transition de zoom animée | +| Alt+clic / « aller à la définition » | navigue vers le bloc défini (`Ref`), par exemple d'un `Apply` vers la `Function` | +| fil d'Ariane | remonte à n'importe quel ancêtre | +| Précédent/Suivant | historique du navigateur | +| clic dans Monaco | synchronisation inverse : le graphe se place sur le bloc le plus interne qui contient le curseur | + +**État dans l'URL** : `?view=graph&focus=` (encodé). Chaque vue a son lien profond +partageable ; Précédent et Suivant marchent sans code supplémentaire. Ce paramètre s'ajoute au +contrat `INTEGRATION.md` (à mettre à jour **dans les deux dépôts à la fois**). + +**Clavier (prévu, étape 7)** — à réserver dès maintenant dans la conception des composants +(nœuds focalisables, ordre de tabulation) : + +| Touche | Effet | +|---|---| +| ←/→/↑/↓ | bloc voisin, en suivant les fils (amont/aval) puis les frères | +| Entrée | entrer | +| Échap / Retour arrière | remonter d'un niveau | +| `g` | aller à la définition | +| `/` | recherche d'un bloc par nom | + +**Documentation (G)** : ` ```typr graph ` sur typr.github.io ajoute un bouton « voir le graphe » +qui ouvre le playground avec `?view=graph`. La variante ` ```typr graph focus=norm2 ` ouvre +directement dans un bloc. + +--- + +## 12. Étapes + +Chaque étape est livrable seule et a des critères d'acceptation vérifiables. + +**Étape 0 — Prérequis dans `typr-core`** +- `HelpData.end` rempli par le parseur. `cargo test --workspace` et `typr case run` restent verts. +- `type_recorder` : `with_recording` renvoie une table non vide sur l'exemple fil rouge (§14) ; + les conflits de réécriture sont journalisés et analysés. +- Aucune régression de temps mesurable sur `typr check` quand l'enregistreur est désactivé. + +**Étape 1 — Crate `typr-graph`, modèle et builder** +- Blocs de l'étape 1 du §4, fils locaux, captures (§3.3), `Ref` avec `confidence`, `HasType`, + `TypePosition{0}`. +- Totalité : tout programme qui passe `typr check` produit un graphe sans panique (test sur tous + les `cases/` et exemples de la doc) ; variants non couverts → `Opaque`. +- Tests par instantané sur des programmes courts, dont l'exemple fil rouge. + +**Étape 2 — CLI** +- `typr graph [--format json|dot] [--focus ] [--projection deps|types]`. +- DOT : un niveau (le bloc `--focus`) avec ses ports. + +**Étape 3 — Relations de types** +- `Satisfies` avec `evidence`, `DeclaredAs`, `Subtype` depuis `Context.subtypes`. + +**Étape 4 — Playground** +- `typr-wasm::semantic_graph`, onglet Graph, navigation et gestes du §11, URL `view`/`focus`, + synchronisation Monaco dans les deux sens, `INTEGRATION.md` mis à jour. + +**Étape 5 — Documentation et blocs restants** +- ` ```typr graph ` sur typr.github.io (dépôts mis à jour ensemble). +- `Loop` (ports d'état), `Match`, `Module` (sorties `@pub`), `RCode`. + +**Étape 6 — Revue** +- `diff(G_old, G_new)` apparié par `BlockKey` : blocs ajoutés, supprimés, modifiés (type, + interface, captures) ; `typr graph diff ` ; vue diff dans le playground (deux + extraits de code). + +**Étape 7 — Ensuite** +- Navigation clavier, dépliage sur place, `TypePosition` pour les autres positions et le retour, + génériques, métriques et cycles, et au besoin l'enregistrement des cibles d'appel exactes + (approche C-ii). + +--- + +## 13. Questions ouvertes (à traiter quand le besoin se présente) + +- **F — Plusieurs fichiers** : proposition par défaut, le playground reste mono-fichier et la CLI + gère `module`/`use`/`import` multi-fichiers. À confirmer à l'étape 5. +- **Match** : un sous-bloc par bras, dont les liaisons du motif sont les entrées ? Comment + représenter le motif lui-même ? +- **Génériques** : un bloc `Function` générique et ses instanciations (`Instantiates`) — une + instance est-elle un bloc à part ou une annotation de l'`Apply` ? +- **Opérateurs résolus vers une fonction utilisateur** : `Operator` avec une `Ref`, ou `Apply` ? +- **Spreads de records** (`...x`, `..x`) : ports d'entrée spéciaux ? +- **Embedding** (`embed field: Type`) : méthodes héritées affichées comme sorties du `TypeDecl` + avec une justification « transmise par `field` » ? +- **Tests** (`Test`, `TestBlock`) : dans le graphe principal ou dans une vue séparée ? +- **Types en tant que blocs** : un `TypeExpr` anonyme partagé (`int`) est-il un seul bloc + canonique ou un bloc par occurrence ? + +--- + +## 14. Exemple fil rouge + +Vérifié avec `typr check` : + +```typr +type Printable <- interface { show: (Self) -> char }; +type Point <- list { x: int, y: int }; + +let sq <- fn(n: int): int { n * n }; + +let norm2 <- fn(p: Point): int { + let a <- sq(p$x); + a + sq(p$y) +}; + +let show <- fn(p: Point): char { "Point" }; + +let p <- Point:{ x = 3, y = 4 }; +let d <- Printable(p); +let total <- norm2(p) + 12 + 3; +``` + +Graphe attendu, dans les grandes lignes : + +``` +Program +├── type:Printable Interface intérieur : show: (Self) -> char +├── type:Point TypeDecl intérieur : x: int, y: int +│ sorties (méthodes) : show ← TypePosition{0} depuis val:show(Point) +│ ──Satisfies──► type:Printable evidence: show ← val:show(Point) (étape 3) +├── val:sq Function entrées : n: int sortie : int +│ intérieur : Operator(*) ← n, n +├── val:norm2 Function entrées : p: Point, sq (implicite) sortie : int +│ intérieur : a = Apply(callee←sq, arg0←Access(x)←p) +│ #1 = Operator(+)(lhs←a, rhs←Apply(sq, Access(y)←p)) +│ ──Ref(exact)──► val:sq +├── val:show(Point) Function entrées : p: Point sortie : char +├── val:p Record entrées : x←3, y←4 sorties : valeur, x, y HasType → type:Point +├── val:d Apply callee : Printable (validateur à la compilation) HasType → type:Printable +└── val:total Operator(+) lhs ← Operator(+)(Apply(norm2, p), 12), rhs ← 3 +``` From 5962f3024bb8bad1a764cc297a1a318d6e7cee96 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Fri, 25 Sep 2026 20:01:18 +0200 Subject: [PATCH 22/52] finished the refactoring --- crates/typr-core/configs/bin/.std_js.bin | Bin 98655 -> 126519 bytes .../typr-core/configs/bin/.std_js_typed.bin | Bin 6074 -> 7506 bytes crates/typr-core/configs/bin/.std_r.bin | Bin 112505 -> 149057 bytes crates/typr-core/configs/bin/.std_r_typed.bin | Bin 60482 -> 74410 bytes .../src/components/error_message/help_data.rs | 29 +- .../typr-core/src/components/language/mod.rs | 72 ++++ .../src/processes/parsing/elements.rs | 363 ++++++++++++++++++ crates/typr-core/src/processes/parsing/mod.rs | 256 ++++++++++++ .../src/processes/type_checking/mod.rs | 7 + .../processes/type_checking/type_recorder.rs | 184 +++++++++ ...ests__typing__function_type_inference.snap | 8 +- .../snapshot_tests__typing__integer_type.snap | 2 + ...ests__typing__let_introduces_variable.snap | 2 + .../snapshot_tests__typing__record_type.snap | 2 + .../snapshot_tests__typing__union_alias.snap | 3 + visualization_graph_v2.md | 22 +- 16 files changed, 946 insertions(+), 4 deletions(-) create mode 100644 crates/typr-core/src/processes/type_checking/type_recorder.rs diff --git a/crates/typr-core/configs/bin/.std_js.bin b/crates/typr-core/configs/bin/.std_js.bin index b7235379979fbea3c865edf2b3c00cd2d631be4b..dc0ecc201e9dff1ea37ba7d7a8bd52bb7aba5be5 100644 GIT binary patch literal 126519 zcmc(o+jAVZvBrH;xt{y|e$h!DV&`EiPExMCk`#HYL=;&hW!aCjvsiM>*}-aNR^;lh zKMi2M;SOf7bgKGW>xDEW^S2FjH_(kn6;;t1b->TMk8i5nF3j7jSL%en zj|nGswYHTZOgDsC7`8AyfhWw5F>Xzst@S4!nzgA62LRh^OnXzTjNRzl-d}b@l$R!} zS&+ZzAR>pjC{r^W;a}|vLZke3M|r<78*^UdwQaNJUftmTsl(JpKuvp86|1Z*Y;{&= zD}6owM+{w7<)$RV;Ow)~)cWfD6NqA~Wl^uxCk7s6U5f|S|M=J{WUZa6e|G%xd22KE zUk>xTn15^Xc9u0waa*w|d2KSnf)4BJWLcPHQJKryu0>N5RHr?OMD!ZhkUxUh~V zn~0i$i4KMp&A)F^?{2_RpN;QnyN%CvKd#G2|?abU1!^wTfx*3@HqidH2$kP1>ta@ zSbf)t#XbPmaMCkEN(w2r7?s;9C*ZKuj&bYlyP~}-^nD$+oiUQNAB5?Scppryzo|w@ z8`G9Wsqd9i$|5jHcBtD~TDD4msrt8W85s{`wRlx6%%i$^Sh%K$ud_x!=>D%gB03;P z%;YSV1pksU_NBniv$gshCV93#&GI{SUP@<*Dlaz+!lIoLuy7_ph#FGb@~tH-4JjC9 zpZXsO>nmY-gwaIo61$ic!4y)gwbeJf)WoefjlRvLR=wIRHo1Nbn9`GgyDDz)^jC(I zmAhC5{avAk1G%HycYl(TEMav4k^+lp!>0%0T{i!eO@dwaq16 zaV`1W>Q-GhQz1%guYxt`?>$qidTXy`9tnAw)Mi(Jel8CxB4-*?E^SkBoJ`6%NXe9x zmwAvNTHPeDv5nn6d1uP<%+@QyQk&FQX55FZ(@50P@0?sAT2|}(ZmfYLtQ7SSk|9Z*^|&KK0u+10 zk${ybPgWL$ga4%KS!at=VjgrPqVTTUj1X2NC9KU_%3jo6UDELgugnM$VRD)5fDBDd z4yqhi)&yHjntnrx4bwP@U-VTbIfIR}2+oR}^fk?9-f&{3pLY@W$o3I(zqSviKFJ6f zp=Z%=*HV>6K_^#eCpAmnEGDy@Ej!C;gG@cFN~`s5J$Rxy|eL6CjZD$_5(9nAEKupbxz)>i*;d0^CaRxJp3 zDhBGx@f!nOqTHt)%micW7m2{$`8DqeJDR~DF}9Y2K@x!ewr`-UZM~^-b>E6ndMP)u zXBEMyA%h3_TnAT%w>Ky10G!VR8Tt!Ma^=+hL>-5O?-Wk|&JFUfW7^rKEWeQhHgy4U zoHs^(X*acgIF3x%n0GE0i;6~?=13)qx|qn-Okt}{OT6DOL9`XI&SuQJCXH#@6kQh_ z^(lPOWrfEAI^0KT=5(=K%6(RKa9Vf7!MRVPPDGw>EUG8!J06^-O#*4YC++S;Opg2c z-=q==lInqwqMjV$^Md1q_Kxs5#SiV3^T4i1yaB~N6T>Pvnyn<(X+d%fC9zHmk7^|e zU+b|P6G;y&$3D^$9^N=8>vUQ`)^eFxy+mn*NVCYa6FBth6$GW)Wx}h2%rVWqBh0bR zy>hY)6h(m1)}XcS2c)Cq#Mrj>)%67lnTVxZ`-kg`H|l%n#7++dhs2>xxFe8Bqx+1! z&efC1i4~MqZ0pq(Vt1k*8ikw#Ks~fs( z^|Cn1m66R7U7&@TZ}bnAaT*AL7KMHyI@U8`Jg)Q);bRRjbxo*=h$**nys3Xujuisa z;wdM#EVFe(DDFf>^8m*vLq|n3Kt;8*>XsAhG@~FRR)aQU^!AL0t7T;0Q7Mf%aS+Db zPjOxoYGp=KQ!_5;#Z&W;)%tan(Bw;Ld?^QLvy5;*6-(L<v$boFs`NKCm=`c8 zlaCdxhZxt`UEPtvVxBOvPy3*MD1|T&oOOTrF8w?S7eA3l9P^}^yy&>TR$x6H6WcpN zuVggvkoJK);TC7(i~Bo6*20vAaD0j2ZU|$yMIF+Xq&|tbn>3_J`H8w+Pm`qExjG5X zeJB&9SK+i!jL2wO<4H}EAeO9&JEoj1^si{bX$(nRr!i!aoyHKxb{az(+i47GOj90` zD5eYp8=^=ChKiVXCc7WHDY80_fec@d6?05uC`p>u5Z19>+E7;*{&81PhO`4H$`Hty zI)trPW?A}IqSkA|Mi|Q(C@$!inrA}rf-N^lqxIJW_o{XXYmDn#axj^R2`7P-SX z%&SWASc-5mkhE8S;<02{7nU@P<*G#eCc4IfemR})>W}SCA>71tIwoOopNA4gcaKk!MdkbONdbF!pCsJx!6$)wSf3=+ zL;IwFB$&+}{}7l2tJ%*yxK9!}5ATyYC}DJw#Sj%OXRTbrqYgJvN~kFLD@xRmf8!*_QLxjszt;Rrx&7_pjif-uNc!1*d6t^PkV-QSt znBDoJ_IQMzOmImR_|TIPk)`vcbFOFWC2A)6TrDV&!O&I z_V8pkrC(l*ZSjPBv`Iwf<=9GnezrS@G-Pj!BP7c-u*tgFuFWDmN=JR@dxt}KhZ}0S zLwWL}gv~H~_9QR2v?sEW(3#}2YCB*L(@@G|*|3L6B<00yIGPlZvE_V5N`m`FZZ07u z>j7zVq#B%b((jPM90 z4*f7(4sz6N2`#SUA$ufDKKzZlOCc4gJ1Omjsm)S-ihkMcHqP9w)Z(KYFWO2P&B!@$ zz1@M={zrs^z|X!Le^b#u!H>F6ur!Q&z|ffo3C``qOiK0juh7HHI!0bv!b_wPWNGt_ zej!IRT5~yASJ(K6A>LKLw@EZu>srn-)dj%{-c9@3Hto4-nvAe3fmiBKuBJ!!LDG3# z-oDHq$aPW1C2Tb=RX6=v3~40$+^*QKNE3k)5sg| ze0c(gAWx(PEBk>V%>;!Y3+|TW^>*D_SATcqU%>e)D>sA!C75c53$N|Gef#Pq&4}c- zn(%H)@%sdeQ3ol%ip^{7s>P&awHtaxFDZCaY{hZ z{nJXaI0UsvVtH4K!R=b$cSs1Aj%4|ixqHy03ib)jj6ZS(pDyykedKR>Teo&RF(uLI_vh#r(tc5w?^6Ax-@Z*NQLpvJy5cbh7AmCBuZ5+sa$=@Ty~&_R)5cnL^s z;+~-kiTy4|rmdmAw;(gwzHmua#}- ziAv)DIFoKP83&H!hWut0fCMl8?4=*~HyPD6aFPK%v$?iK-&O&t`Epa;1D z)K8(usAW+%t=D;Vsp3t#M|E?>A;ycGtLf*EkUE0SI}%m2*q|%%_1DF`EGqrSbmG{< zu{z;c$T+-mEdUFuVuy;KB#EI3dIksFHLcKryq~Tx)NYFgo3tsYY<4U%~D^e=@Gp28%Oo&id7@` zihmf@2&@CCMt~hcH3EsE8u4s}YQ4kER$v`ywgT)BvlU2`*^2#qKuPl;>zd~h7M9pa z1>o`aV5l2QK%r_7D@sGDxQ!Fvo-&hV^0I3$PmnA=pbbpf4uI+RJp^#P!w=w)O#+*M zMbMk5q9zdIXid(ed_jRboHBCCKtSSBCDb*HDw`U|hzn_%=!(W! zHSLRR(@LT83tRn4zho_rtNb+#!Ytx89cIzp#2{c|k1fa1XYOJN0TpXVerNJ~aWKWA zXc;R+y!uHBS#B{Cfg=wtVbw-%x2%ZFI4YJ)gsXxPGsb!^24)VySxC=)A*sh5u{Mh0 z`(&g~>=Lv;HZGPoqtVDu_?>qagK zGejDU9`_V^pnc?%T?B7^Rqxy`^soJmD0ha?VF5duUKV_FN;+f3CKQ}mTgaQ;>T5AB z>-LzpSNf$_@Z&vXVOLVarY_8glUsXV=noot=xG~muaL2?)snp@%L!x zij~}@*5{1Gk%L;o6+F1^eug9+R;*ind9ZbO0=W#;^_?*U*hs2}x}aPD!GhFF#J-2sKHfoI&;Vur1oDHYFnq*JsB(&UNTDwfUJ@*WZ1OzLBm%vQzkSfW%EnabOScP4Z&tE3&I@uE>8==HOxUMM#6x5=Z z%5`%maG(}F=Ywy5L?B`C&Nq_$5cRv5%bg~9r-O*H9b(pfLqH+Y=}~f`E{8@gcTS+e zw0c^b-5V)Cwe^Cb9KcB}lBt{EH7t}cBTDsWU3-O}bYK6^ z6_gbDCy0f*AOA^TTrW=i-ka3_`SJ(hZ+_VQ10$xbzG?bt_siuLQDa0;)B)Hjd4PYo zSRH}RriFC)(SJz~BUCifJ64ul^^gBE%x{&;68QG&l{x{g+koJ@TFPZO!gNEJg*Y{? zFg<}M%#SfMO`Z`tjzP0FmEizjds5lq-td*okh*FivhzLmSP-G;#&CoPx4a-U z3Kk7%L$opHg>+$+x*PqDi`doFMnFw_RLK#K+p|gUD#9fwtoyl-9?38``>d23_3G-3 zMP9ama#eGs{`=uk*0p$G{g02WLe^USF8BE5^VahE6JN}~m3^u_zS|VH`lrS)@{SVP ztEo>itgoX;uA`_7>aEpjNFq+r7#%_3!a80TLaU1vP1RB!?D(j^_rlJo6UIGOdP?e~ zic;zhB^l6*hYDKpQg#6D)SB?p!i#Pb^9)x%wTPnpNwDvVrLHt+NHU2G4~gr`8JT;y z*U?&+6u!`Z$Re@}$!UrjsShN!@!dcc`lE(yeNQ-jl&czzT<6!fawOw=)>&;o5_(vS zIM+tDDf;)@u^;Z_p*$3cD!7i(orQIiWGj7zh=gin=+xCZGF6Eu!~q}5R6}M~T_yy1 z2Y7BfAd1F+RTl)|aG((0^TuKy0I~%SGe09d<&Dg1jFPw_C*ZKuj&Za{!?Ep*k*xh7 zOn1biJQa?RHl|Tt2~R1Dz!(pNr)49&D~P+Ga>T}Q=44@`nlB41nm)#^QwEw zrEMyXlSvr|DVdToe9GJLSlX}sRL$Js!^vG%pGe_^dXNIac3Hx?v<0CE1SzQwR%Z|D z@$|TQe2Z4!`lQ9!oLV%_RmXjufe;x@buK+Zz3%RFL&RVy2&W6*wc? zN67u!KA8H1aXal<^jmq_iGxnA&`xTWyvemoLU7LH?h%tWw#hwLYl(XcvDTo?MK zvdQ&C=243D^m8dm&)|TZH99GyPj3)TE>NNLX0w``dPcj`g4U>_ogCcpj`n&*aV6C!kgBfK8AZp1)_xN^ZhFB-Y|p>B9sX(ym0F_ZJja+WAaJI~s?SvJ=%c|?_M0OVGm zfJ|&p&<|0DLNtV4e?uXJz!sv-^zCIRgb;qCZjvJed7Sb&A#E{Gr`#0!*`~oT<>klU z>d(Z$b1>|BI7*gKo-q)?JnAUOKC?(C1O)%BlN_It~fnDV%H^|8Z;brXZfgB-9GS2&@4P6>LRxfLsYFp16SgwrD{4$gfVby1P$8;k0R`i=*uX_G*j?@7Bm z5tHM7@MP7bpi~cpQ_=*Pgv4Ya-4U^_Vlrd>8;_FGnnh391r5>=(D|0I)N%HawdIOn9qo3^8m*vLq|n3Kt;8*>aHNxX+}XttOhORU8#7u zT1NIAmC~3K2VuL%TR_oVQLX$70dx9LG%`(FMR4i#bjBC@% z8{PV~UL$cT{Y?$#1>#U9A1hi9F|M(@x+8G#o74c{?3rKFr^_Jk07`k!q{z5hqUeqJ#}lt z-J~In@tEf{NxGe@%LL~>l!?--a9SuvWHhbuq^3y_OV-34Q_dFpt0SDoki>NwLl)U- z3}I}iF{H7b#*oG|I%a@?kdWVb^t{g0vS_>u=UC;-5bcWY`rFIgt44~;(~suc_sue*m8qJekp2ka|0n9#Bf0P%p z)pz%Zqro}cJ2oIPR+U)$gu&Flog~ZSb|&4crJDicohF20zBTdjX1Vk&O1>(L{uM7A zqwk{DFyI-dyVg!sVdjL|36{it92d9JQsj%@sha@WQb^IqH1@6ZfF*EuDnTZ={wZj} z)$8v-CCIu{Au%DV%bL03lJYb!o5 z6%WV$o_B3-S8|3yX#0o?)9|MAOpY$pl>ttnL&zzD=z=;0`H7D56oS%=L;uDD6tAba6;U69V7kNX&KI@EBlKi~ORB(!o{WerJpu646{YUvM(CDs zCjCrW>5;j^L2t~_j-HnR6>i> zj8Ndaca&+bgY6ZS^+MQQ%b~nLy_ZO-2SXvkr1~=7B*U(G61*b3&A!+7k{)l~JG#sl zocG!b>lx?0Qz~g!bqBb2&%ikg-)o_iH=Um$5^nYGb@B4tR94rSe%aM8J_&y&9Rj%x z!`_`X@ulazBQ0^Dty{uft>>tm2dDP>Us3_`UfZF(jf`~vQ7hN_@@B@UmK`w&dw9gU zkRuOogq}Xi+ribX{yjAuEP!WNpuSeCo1mhR{OA2yZS-ZreMQJr+Dt!JhWs%gm2DXf zC2(*74B$9K|A=r9_}O>kZz|d+_)+%> zmWFW;7&`MH!MS~yNvXd66?&Li$H+_D==+NZvb6a|zmOvut+^bmt80A35bvtr+awyS zbuDL^>VjYe@1}iioA%r^O-9(2z$XO$x-9oxPsbnXeWm$cpo=entcNs1@HnmnejQOw6Z* zJs+f=!$RPNN1AYtrFpLjum4vGxJOF&{92iX$w@_7u)LIjEt47j7kw_?Z@k_!Ye zW7jq0I@3Nl)Y$_88e2qIfDUC5wB;_=)6V&`IgdTNL0~cgRaEaUl;STsPrGxiDM7P>V#t<XxNKMMDW#|Y|U|7e#>N$kBV7VVw-prQ#Y6hE-OG{x%{$W%junwde0d@%02qcPX#IqHu z^$s&zfpwtS3a~@WRv=MkEB5mNCC!7ZYo1G3SYjs?fXCZ|p>8Yzg{ncUC=I3JHcotd z%1oBY%dWjVL9+OOHZWy70H)vf5Ww*cKY&9v32XuuL2sgpnm~-BH93#+1qJSK%E&1L z0f|eMP}eZ3Y-$`ME~I6mD;j5&PsDp?TgkGQ-@o|Ov@fzvD}~B0Z1pSslC?Ol^4Bm3 zvxwVtm_>IJgMf)Wwj4*Fxr-$PRIDNSoyqUT!4!+4WvmeK>L)2=xy4Kbjy$-8RU5h8 zvLZ6$s8}u$t_nuX80)<2;Of1`HP!SdA1RRFc*r zbd!LIb)eJto2+~znn+M|c?`to4w@C0%hs_Q#r2(Xb<4|PGg0D=Xnm&$`m#=do=c&n9%2b6u6L_X^1P`(8DAVP&=XU;(^ zTzV%|IC%J)wh#_5->73EZCJ}H`1OT9Gd~3C8xjg$b})DZ=NDNm@epc~L^*@%>tWq( zL}?3U+}D?8nQcl&7Ou~Zd7SIe<4$F=Ti>0EFlGHgRL^@Q*F2@&On6sUFO}=&PT)W-dd>&m{)j-r;GJ(I z`623eF_$|{@=ga4Wjn;I`-Xr*q|>A1L|qPzT<)AegK71&HoG@ceroFlLpgwxS|n39 d!E0RbrVSx#FWsi8<$wrjw{{eT%u}}a2 literal 98655 zcmcJY*>mK$k;Z2oULSkk_tCq4@QfcSJho%PuPwE-({`(+kkoTLvREjIBNkahvRYFA z^_u|rQALobp2!dKOSjvOUnLWni9{j+y!z26?ms{Fe|%!zXXPjV*8i77fBe(T|M;{1 z-?mxPn%7mQ2L0JS=(MaeJ?c+;RMEc8-l_${mlf@4Q5Bt0gMQwNW2(GfnRl13)ug`* zNyqhOTUUlqJA_-ARKg7;Ubvs&d)qwQ>IVQLxvLBZf{nJOyDm1S-sx@hFZxf*OOrJ$ z?9Y3c$R#dl8pfgja$hi-_E$aa`<>aDvm$TmE^F`94)BjXt}y~^yO&k5$-1JhPMd6_ zw~v3oCudb|%0y5Ep{z8G-b{W9(duelG#j{pMQWnJpwfm?{&qV=xs9=GOvtyXtnZ?{=1!>Ohr9qM&k=)d;w$Eqya zlZVb!ZLuu%-tgBU^R%fy82#5ycvnWItCd=N$U}PV-I+(d{{ChBt=q=Dm(bKJv99#s zK*&};S^dF*NlX6q3??nvoFqGH8IiQUY?|zGVI!y-1z+>1qIG|&X-LaGt=5MCOcEZn zMNnA~OGqqrLu6i8Ie|s!o?^F;Ul-kNq4#B^2O*lB&tc*d?t9bdlNqGkA$47pdT)#k znm}byq_)$ztd%}g{%arJrV6N5ud9`LP>YMeIY)kzwff}$KM#m(!JODpEtiD9#hhX- z(6el-)^U_uHz!$stLDbWuBh^Iw<0Ein81ZKH6r_pjoNoL5z1mvDxdiGi1n7RTtW-T z^+K215GEF5Zfm{S#uU5VwR)S4Mcu}%cDX*+jg6PbUKTgE`bZY@2Ag9_8C&hXvAMFn zE!KKzqtvn>^c%BMPMRv3`psB?5N3eio5AHOE|eS8+}kGdglH7W2A$N&eNQhN;3^tXtw8 z-)7CR>^7;H5Okj@ybGDv^^puq(H`1l_iFiqZ%(r4gDv&qJu!39=1cu&V=9~F+EF6d zsMt-}7%c)GR|vNZMx2=lZK_pk*THJDh?40H8bNm=Gn+zVSv3W>Uz@T#t(y%I(xdi~ zBJaf%IuX5eGpOAF&Ki9TLJXeZWz&yXWJPVnVP6sj2+a8tfh!rrtSpF)kfba|;a+tak+wx8Zp>D;*VHZ^bwuHo8Ici3!*ma3EF-E6DL*%aEk|9XrE=pqR`Njo z&_y-eT1(?1f7JKd-LmC!%>V6I3KJi}QsGT~Z<^zbSUG<5C*Eyk_n7*L=uBY0f|WlS zQ)TJEDgS`jyiTHXZ&xRnd$Dkz+D%jFH({djPxLICA7lk2|>28C`7 zMVi@v_S3qot4|($|6@UH;=(v*@TbKCk$a=lWCon1#Dvf|!@p=uUbNB~r%tmF(P!Xf zM33s54F=dyKFhk>MYhy$nWGEDgYsHWLd9bs>6f-+p;}^pWh|7CQVG>%dV3oSC8Xb| zO?QGy^nLk~SPqSbFE@pLn|t(mdHLbDdQFWyhOzVU*VV*!(nuuppx^8osYyVa&(j#x zx~z%rKy)9ufFR8{J0f8lec|onw$`uAM&nm(c6#d^jbB;oH&8}jC!o)Noo4(jN^eyo zMF}dA?`<-D!^cSD8)9AfE9N;>=(6=CF%$${J&Cyh++u8-9q)a|@XLnEZL zTHAk7V^PSM!|HngQE!-1Pj_YctrSMp0;AkIMSofE8htvCmFSdvCXI_lD~IT02TLtB z)?9P=YS$&`|C%A!Rf7GtDff!hMckC_X*souU-Z?EslXQdK@L-%uh-JYt^R@d7>Z-t zUsaQ_Zk~$n$a+Ua6?7I%`(oV9MQqL;|0g<_f==R*D6!0{=B(hH@}OqUD8D=?_cOVs zcnxLW5p@Wbw!z4JAZX42BlCf9lUQFQmN-0TdU1ttb{CiU`HYU558?u|;W`pkW$=Vd zTXfu6o-3+)!IRi^Bs$?2IMY6;8P2*7%FP-$HphdhrmO8gkR|tsPP+Q_)j5fdtPQ&Q z4_D`J)wxb&X2*tm0+mGE6Td)eLatqj@m;8bp*TOFVw%F_D$F36wglOE0)m2W}g1sMfWH6l?v`1vpuEw{>ak zJrK-w8#v$!oV%Sx9mFOysrKuK7pGhY*(~kS$fTJ}z?E6<^aXoVeIekg&=+vRaTV6X zM!zW$j)Z9%Vh<;z-AGwlzoUT21+_Waab1?#wk4Vm(8)5wGVMX=WC5_K*0tL5LR(=r zio)UOGUkqj@H0E6+JsIR=*+^HF_`1MA@&kRpimNULWTl_Ppf|9nK|tgjV8&jMq;^zT+!Idb+4uS)Cel{$w!nk&j&C4 zy?`Jy(JmZqVWk-Dy~WtW<3et$kz>|Otplm3yRPMEw_dv=R9_WF-};H|gMH){hQ8oV zrS9#vXI99)BvRbzsJM}%P+$B`otWdej%+T?PQdD+hd3g5Y!dsK-B_ zj|%@8eH8TP@ll~amydchVU7p<$g>G+Jj{MJ9~HTu&qsSWp%tu}#3agv%$&zrRab@F zdFD^0>PuT#-#D<_!eTB`M{&o|Y{!S~K(N>05SNEw&E%PdTp^b|a#yA!Cv(I%Mca`Q zBBS>_caM&G`Wt<%Kgdf;hh0S{-9@=;vhs3QCTL7|z)K_dvKH@C3^cYu9QVrHY^2sk z9OFj^guerSAtg5TpukCblvKfFOw=^2hkDvms!cb2AWBTgY)=go;FAGq*^n{PcZy-4 z2bzt%dK?x3J#Js0NzUwJyk_`h2NcF@LT1Mc;1k<4vA5<*W?`ptp3A=Cv&YV;${02o zXTVFju1)Q12e>kO>f2M0rp(AoD;X=dqgwi-vV9%X=)smJXx8K~sb0(5AS-{FY?6ly z_ISj512BU<+Ebh$k`u(9S#Dm_PXu7$H_K=DqF`t9aO$xD*x4+adNBZw7b7z%!$+hh z!VuQj5vkb+1W-we;;B~zaMRgAI`@>oK|7b!jk==$pmyk6Mh}Y4-ZXm9BB>ku50a^U zUTn2sFf5qHe6oh@1*JJ><&$-PI=#a`$_6qwjY`!3=|ZjPm%levOQJ3}98=F$a85cms|om7(qgR4c`4z0yMClX#kJ0PZv5 zl4TTq@tGF(vVA13%n3jBffxk%neADVPK&*g&&o7L{|4YM``Jj|YPT1yyH}y*Ae_|W zo5#H$Y1}^%h2YPA*zfEr`j_y4^x80e@ff>%Sv=JD3u(F1AIU(R=vjGL6E|K5uyvj9 z^czpXS1x5^LhXzJOQMASY*+A+TX|7PpWPzz_v+u)ZFgqcHX~*ycw>*Wdx0|V$xTCY z_x~gDP%UCm&y^j!{PxVO3jV$Co+z(0%LzI47^A*8q8;gT;aT3b{&-x#YfLL|N2HU_ z^9cDcm12DB_l$HiG(tA?-sR1syyI>6w=dldgkN#b=)$CXtiRRp-MiPXXd{#^SSdk0?Yj;H2gwfe&y7L-mBB_1?z=CCp_44Y;a{`M^osh54 zqrkE+@DujPH6^GA?qhJfWLLuxn|!ai*uv_#lxx$}JPh4RG61_vZ$0*;H1@AYkFQss zex@d)&NPs}-^rWBS8^OKm(-sCW9dB)mUm(ZHf|0X*iY(cj~+FW)!}{j=Bz!Wac_WVrnciRstxU|&gRjj)_g0=#Bgm5x_2gH*y`MIj>rNql0d>zo z?y)ad z-^ndI6&5*!*rCgg`PK!V4Q}m>fQOdm17m7Sqr+`z7k#i|<(z)Kd%4uUd`##xj4_gj zRfb%_;h1^oHL<}QPAQ)F#;o;1&VTuZoA}kX6%JAE3@Ysqd1m4v^3aq+Y^9@bx(w(VWHf!Wf}q7&40uXgc+3o0l$jGZxDDS>wVXRo&WZ`Z3uFhtxEmUZKT*@ zrjcqGfkqQE%)@IV6+O&_S8^oZHVd)aKw&OZR}yyfyBD9D?nSoiqygqdUHwYGwH)S~ zu6>QrGP-7=W!u3<;KIlwW&Ts!U`HUsk;-pPekTrBtcs3_Xkqk~5{q=*Cjd_(k+|DV zy7@K<*kLv=&A*$1$&EvM5(70Uc`LcoiaA*mj=mU_D+aiN2w_#Bp(faBz;-)asqf^+ zcKb!$7WS}t0-^1aYmv4_?vN=8cQ7aqMh%1i4E0ZaEgsxMUay!rs;mY?)odZA{&fopO~n`)+1f3N>U+x^9xt_MhNPK^o6i* z)RV$2?prl37?rI=+i%XfSHNeG-;zi~i;)fq=MJnpL2+`HNYy5X{tMd`EXO#F{qvPs zXSNJl@0~Uf%{t8q~+L~zwMs?0ieTke)})44v$z}(n>tUYX1Qg^41O;cVDGO_RZJ#7o8^UX#$vt8vE8^8_4vP?M z7ul|C_&T_mqY;bw?JP6Fz0y2Y0jt=yR*AzUra&!Oikmr(oe^0k_T zP-8%{`=iptN~j&et;A{52se;;;eG6YpL!^fsk*b> zEDTq2h+HmYG7gQj(g{Y>5VYi&;m({Daw~=Gv*}OVV-{qD z_Qj39iVmaiY5sVyTG_DDo|fp9I4sKNsFGu=rG<<3+&*Pj;{sK#<>Ivu`neM`UN4-z zLvP(xF}fh5l<%JsL0t@1)0aPGDq~u{afko(^V(G^VkRF6+c0PxRPq@^R1xUF=D)m}H zY|sSCJX8>umAFqAz0Okwu+I}9aL$q6$dN*|zoOGQ2Id|mh-z7$yi$uCbBeV<suRUe!EcaZ0xh`_Okj*@g5MyqM({M4x zZg=gi+81I`mwdt@HeM-SZ-{vV`*1^Su551$;=o~)DknDdYl)aGP;WKFp~N!{F$0sw zDH#ReFjMHXiJoSNrFscoWr)oZ^n(nsq(s0@*ZP&=0g<^^9%8P%t2n2N86CM8i#Z7O zW(1}R|3Kox7Riopv*uVfbktEDg6=bgcTpF_W)qg8J+#U0)$#@3oMh1lTk6GoV#*sA z%}ae2im7atYe$J-qhdE@W3&i(Tp`>t7;$DIwA@nF+FO3qW)UUR88jl;L}oUH#JSr=g)5D4MA{aW*xp7*tm&hU*dLivt2i2_doW`eQDsQ^xykkM zF6tuOYi@C@aaycfRXQfG|5y;4xG>Hc{Auw()e%R!60Xr(hw z4M#+ufsRSZR1>!+@ttX-4F_84B-dL!XI5R#LN=T`M>N35( zjfE1@Z`7tcK_&XWd`T>aM#GnzLchu!eO_LE_^n=3BadP1eEfAav7Iy$$vo)yXh&)i z(B|_r2DL70;(bqaAGv@am0Gz!G>urdkK0IwvDxXZb2NTst-oP-G=2j5{MTs| zWl?&o8YxOpiF|L9J*dBKr11?gFN{FjT3?(W8M%K6Q2liyGvNGXG-z&s0bs6Ob+DMIBFXo$@K2}SPeqm z&dW5clcd$!{)-xmLdG0cf14EQ4O8mrt}MTm!l+tclv}6hFY8^SPv@}`opR5LvMl7N zYArihYO%5An!{J}#*UsGSIC*%eW!QKxWreaF5;$aPs^!QjMEuY67?a@)sfzoZfGbnqdN&qh=^Ei&RHsxxj8I z!MZQajchf_4SP6SXdXz6LT z$-29(OSx^nwGWf32L^}9fpeFjq@zi-UoRU@xel^fn%p`?+;A9v?#e88`hq>Gz7TL# z=nFXExC-lGqu-PWKh!i0v4<1VZlo-&-%&v1g4!JISZ)u@wk^?ofKHYXmT3<{Ckuc@ zwXW5!5!wo~Q4|hGN8MW)erCs1o6rdZomm(&26MbO#2(v3b&b5dtRB=*b@y4L?+1W| zT*zq_DXcCsqAwevbB}dpI(ZX7zxO&3uhLIq&>O^~1_6 z1ZgXH+aaTpdliUAXe2*V?z*VNcJ!lz_&gkxG`_ioK-SGBpJR&hcI0elvGNd<# zNcNEGfSBm!$|=?+B$7|bk)}?cm>~s+w2UjEEntt8_EyMH2O_K}fW9g&)B$6i2&vFP z(I&jI5YYO*`lH<7ub%Od=YyC2UOoM!m$%6p=f{|C?s+4pvCW@Hz=|6Z+jSj@cI1^xLK&&MVdepqg6D|(&mZ8XoQ z+p-Czd>l?_<$#Y$5PVJ__4w!WQQ<$MkAnU@J}UI*@==c_%<+I9c{X8Z*_fss2=|J~_ks#(~`yR!3~Jp}6B{w&TNgAlU10h|5E; zX7bEJu8_+fxhvC=lR2V&M~jpY8NKJZW5Vj`Z}hePATKE$b`_m;7v-+W%F7+|?i}_5 z9Pm=!oa>7BDFzzbAdY)wZZ=YDBaZQ-1H#{dzmO7}dQjjbJxZ!zGA3#o)E*v~0*2={v%N;wYOTT2M!@;k4L;W05jO5?OCpVuN|>xmYdh~69HKG&GOm3 zDA?INoO&z(b~cNqUJQWa#mG#`@DZtrFoZRBL~8Z{0aTKrcadCaW5`(6nSPCodJJGy z-7yDzPv;*w<)eeszV_Og8>uFMHP^??`!_?hil zlTM4ha>U9sM*jrUFZK z(-)7iyO+g7eZP>FEB%oS#EG7jmvyW6p8&S5^PPU^VWHKr{-m~`@a9w8s5QjBl?o{?^bM#zTVyS#bacD3E# zzRV={ZNAFN9dUyeOuEPVTMgg6d;Lo7&lqIsf|a7wCaZGpLOP_H`@Buo+^Ge`2K)$U zvUW$LO&FbhraP~3CX(tW04!LhSTC=hJSVW&)Cu_tJqj%Q0zYAoTvLL2;64VoOLjFJ zX6t*+#THh_rCgh)=3(eok^$IVdh4+#rLliKdVIb5^fNUXb*6#*{mwLxS8^OKm(-sC zW9dB)mUm(ZHf|0X*iY(cj~+FW)!}{j=Bz!Wac_WVrnciRs ztxU|&gRjj)_g0=#Bgm5x_2gH*y`MIj>rNql0d>zoXguryz#{%Xy&`SWJizcwfD)y^(E9UZ1^DCypVpmN}FoVkrgXc9K>ci&|x}OD3iJZkw;Di8!PSDWwa~RIB6LqGa3w z>(Vhb;eg8-Kr1If48x)ryBtkDGO{jbk|$)^_;QbZx%y6S*{QI|A;b<{cFeaf@N95v zX9PU7G#?mKTN)j1L%ZmM6)WfT>*X9mZtTm)giga4BY9Y5$h8Y<&ahy){7)>`LX!m< zk38}Dx|$jWncM~~kyQ;0zuW=shwDp8A#zg{Oe*xJZyr?Fkn!g`K2jtZuR+fIA$JtO>8iSQ;H|P zF>Ae$^Iv}9CVsVTg+r7(gGxI@o|$-vJT&DHc_KALp*H<)cv@|GW@2r6Xi9B*BBjkR zE_tLJO04ZABSgRpozsJl9+M-lYXXVgcSO3D>ih@`J&v}JaD35U_e4k<7Ro(arV+5+ z{73vim_hj;@T*At27yPi-giyd`ES45h9HO8s?;ytMv5(F8mWd6Xf!dyJiIni(ZgJL zB}ejYvkL*p4Rk0r&1wzjcbZGr*+tR`!n7!cBO2UEdY@kA)Eq@=5#H z>hY>$9

hi&K8Zxc*Dx!6dLzn(V{PYUBit1Uv!pg~#I7TZoQAEuTkpk0Ap-5Ntc zCsN)w1=R?LfH+9$fF+YhOa|JX&LO6i$v3-BECM)jmIUjF$!KS*dds$0VncNpoMK~F z5#*$bX}VvpyZ#EU$Oa?YCnhSf^+?yMl2l0F{K6H!5rTRleIe`{^`tP1`&Nw$MrA9} z_M3CJhmaEaEr~?580nC3?!dYe6enkiRBdwTzp!1wa*Wg1KVO-3wkw&{@vxe8SbM_H zR`;?ScWRVS=}@2?xUZ#6T8_Q>+wS=vK&s~Su;{WzYKUqZMRfPDeAn34j%$uNY!W*S zbj&#uB;#JZ;{#g*!b%-h(z7i*IFroGO z*t>?3ZqUqz*9+G{(LHnl6v5fxVux2X-(1hS6}{qtPy%ON07WozMd=cXb4YjSiq3hu zu%D?_bis8{beCNKMNq0}vs15kD>~(YPy+whc?bs-fmPH^qyT9*w9lK(QM!cU95gog z8Fz=Gn{)vb!L(=K)9H0uZ)e?gWr4r^ODNGlTmVHdQUz>TM|B@`R)yc51(Ym)x&Vrx zGzIcjXRip`9t3V3n;qpE`TB^O@(Zulh%`o5an|ydFl>= z&)x#XdZeu-s_W2m^wttMXO=;^|KhE~&0u7jfc-~uQD(rRZL$z%{SGnbxuB$UWUE`TDi zl41|BPd!*LTn>dEa2|MLQFLB@?j1u(zK?T7lJ$n7^U6zag#DaP1&!rwSE)(b_zl&#j@#qHU5yh|vKwjk{E+JQSKFYPt!`$T9$M6Rk*!-4A{)-X!9=&3*% zGvjP@BCmXN?g+*409J9{#|&l6b}sB_)S8`k+-yAGi%6h&9>6+Y@-aghGqW+RR^zJ3 za)sh}0IO(QwjEH$e5Ut(GUdFVHM3T&lAQC;qabRxA(MNZW+kt^ZC)L#%foo;IH!xP zR=PP}`HpU4dh?t~x%*oq^Z5@&V3%`yaq|L2Sal~T0vcEl*T!BD?2yp%v?J|7--|bR z|NhYs;>*qhZ&ZrTM>p&PXH&6Ehx&${z3pyeJs)##i~4O{UVPWbUVPkTl$XfB#b-iW zpfz_a4JkFxK$@{w+oYZ;V|*HvQ;%XcO0hw!_N~ z$jL3vLmTu?SG7mc@s}zT0SyfL=Kc@b)(MXjAZe8c%;N+oIvyuL5zxSZZ;d_Rp?4b_ mFi)f)X%z=to;Cxo@N8c^k%Ik9ZQAfe3W|;=Qcwgw?~=b_Z44{` literal 6074 zcmeHLyKWOf6kT2kkBC%A0a|EMM1X+`iWC6};RNw05JJ|n9uyu1Q5n%MVb~ z@&Tl!GwVrYDMlG-J@%gaJag~Nt{0+WESgzu4^*OCX`_{N<1~#f z(f#JJ0z6}6Qr&Jnc(S6$cMx!rB##^E6J|EDQEYyflh*}F*#=&fn&t}&u=U3%9Kx39 zdV%nV2slStJPa#(JWx3!SCPUJ0)iQVE-0Ld8Cgc*kP&92f$%E`uo&rQ-EPjvJW}{g zZg@Dw!;BauC!si3seEvaKo^8@GK(mLIRq>i4`z8^$K6!rb;}Ar4d?{^9RWcZaeH{( z5YAcsLf(ELz*-_b%Kj#`iL&z9QEHXymfnnY9td->gH*-{2;xKbTIhzn)N;bz{2qlv z_nDIq2!DqFahs}PG)F>G8O>)%;}HUw5-}&Un2U{4IZfuPVo@wP2U*q9`g7wzmbyH3 z_pp?d!I&>qOB*jemQEAK@m!>fOO2_sY;ixD zH}iefSEEi6oj1QHnY`U79`9xSM0d9O*Mdy{4-^actD{jo4om;&Fx>xPXcyk~;KGv~ zLS{LxV!J8+d0v1Iccf)oat^+#rK1O~g6Hx%QyJc6WlNyS+TnQMlAd4d4Rm17d*3xA zb=ggC1Q)fOW_Bq$Ma=?S@jHlhx^EkC>UlaZRyGTb>9OEF+a_V4&cMN5x1|nl1~VX= znZ1Ng9dN<0@Gf0Eg#5Dm=pH8#{uhc^k|b_TFNx{=U1uk7J2-)fyO9$(r2oKDWcm05 DXujkf diff --git a/crates/typr-core/configs/bin/.std_r.bin b/crates/typr-core/configs/bin/.std_r.bin index 064f9efefa73a630252abf59a3fbc872c5109003..27a72b8e675a77cac9a0d1fbae5506106b9072f9 100644 GIT binary patch literal 149057 zcmchAOLH7aa^~!6A6jXjv#T+g4!ZS47foa4(3d%Y0N4!)1UM*=-8#XtGNTIF$jV6O z0}tB#aozTdtol5o*gZ0nnSaeo@j$v~xO;f`+yC`H{%Zd77yF-Ief1At{p#-yfB*OV z{N~^I#|HZ!{bBzne)pgLBm`c!{=khYQ3(6RAGT5a4}b6n{#OTf7VC8%p6X%@GHEQ< zgF9@}rYNI7@c&HO0QiIZr~i#XAM2;^rf#dc-M*dLa;#(Pj?KM@eNjT8x-Jm<4>rZA)$S$``GS6>wic7XGX8^()D2&6h>=Ku??=o(vJJi>7GHpt6Q&uB?k8 zAmZVEqsi<9U*gKy0T4q8&87PRj^Y2482&$t;s1*}d}^b07z=mcRH^HIcK32PMRr$k z*?xlODwdA|r_e4n&NGIWv5LDwU^|-3*5mW3AYYNgzmEQ<9Utm7zW+w|#wGBphk7$! zxeG3zAVYEMjv{Bm4}!?9UF=x-=j@EbpQb1%nV}ttnw-MFioz)0$LB#{T>S4t2w}a4 ze-=^NA}MMywYNvK3gdZLPyWeedYgv>-9huYUk5WZrn(vH*57PB)E)k6ExZk%H?<(1 z^B)fK)|N0}5K)1&4Z0lB=ekOTf?0+`pZHU(ml;*NC#z zLVvA<3BQSrzrRLpT_!WnokrBwWdMO%f8}O8KqWI3q1XW_%ROt@eq4z9uoMQZg>~Q zhv=^^{+5sBF&1mY6#e^YczC0%D=r%a>zPWOp`YR*jctpJ4k~8+ z>1CFN@(RZ0T_^+}Z+AR4Dc0ss9n*lZSxq~C_;eIdaJR!Vib7X^9LpJYlG?5ezO-mC z6(u4*e;bAoubn@gOmoovJW~{N($_7Cgej^s^ey5tt5I}E;;nCcy z0)(~qhflku3Qh4Es#A0d2x^9wQ*=cy@ZRZ}RAGkywL2TAld9k!A*R*_Y(?FWsiS~C zrd{0@f`${*6k7p(40v7mhgTQin~l5Fm{_WMmh4{D+X4oW`vFYsYkzBEAeqyq3J zJcY*JLbvaSALFib?E7^i28_M0E&Lew6A*kTBz^j{oI|}0{uS8NJYm<3uZogQ@;9{Q zNn1$j?d%LdF^!vV+&N753ui#H=nBCsLFSjIxJXEp2r#N*_`V+fOV;^HhE`LDMblQ< zOU>+1hU0P&)o(Hhnc=!t^rzB0#-Nzk>*~MwOoS&vpRj2`0RQBE!TISIFTua8$=-(- zOd9fkooV-?R>YkyM)j9&=kI0P)1aGKV50vruHt6=C~7liO`QL<7z&SQ-~#=EJ@{I1 zae1{iwIDyR02dTGlJsv;0xGD><+s08l&R5T*#P)FB21x^dASmj_xq;w3qdImpZ$d9 zke`+*x=j-W#qU(3n+7E_?%&P_V59u<*bAcFCZ!2FT1zA~0kld%o?RjEj;SVQ`2ODI z(dUo;MZa`A_Md<5-@KAB`MKyTO@E`ELNzpqS6CBxWjDZ9XMJuWx;SVK{L|K@F-*X8`tZ-9MazfdE`w$;iYZFeNRG_{8~@zpkJ~afHQRr1Ha9^`|>? zSo@2^fL6C*3->P#2f9UDOWg{}#q6ep@uCTma>R^sh$f&YR8n2XGCol5;-!OL3Dw7< znfwKTk1G&;7o&e~Uq-Gg%q9pG1i?Q7z!w&$Nbw`h{1pbqawL1^_#f!!a9!?_Ao%0T zWX*_osiY6(}}=g5`6yx5pyEK`0U?nWKM+t zfHiJM*ftRpWDW-4cj&2T6l%q;-;52}5gn&{OlDQY{7sZ*1f73H3ZsXT)e9!4lFbWt zjR9TJx4FRa@BBufpd`0E2|oEf@g-YGqS zx}3g+3muzAQJ|b*!T1do7Nb^$Y37ot(7eL+jA8Vjk-@jJ_J8Dg#p(@evHV?De)*+; ziKa=<>}nxx6cy`xGD9M(oZG zu4cqh7A$jqJ4rE$U;SSS;|~UWYqseyY+EM`W84Ww?wiH}ic-;dZR}47Q1!bsDNM$dyh)Bs#j~mowR6iVZ>9UVZis415t)%v9T zI>hyl3a=+WhI*q>x(>h8Mx#A0(MF@(1n&BJhw+&Lmp8xSf_+04W(8MtL#7iS6x)n) z7W4sgO9llC@(U>5nUn}MXm=(h6q=n$x%Dx%!(7JW?ib|D5G`h6bznXCKdH-gE=EC^ zd-2ZRVBD@%g9y2>pTHt8(H7g_?djF(!u<$~v|geCGi~S}${5 zNk*1ju5KhVgGj9de90Q%!y9jS(jT z@(@Gw^>16v#6#Y=tKzL8@Z?|WMlo%QwPN7O0$wuFgJFoK z`23olW_PvCU^x+-zmXKV(YjpFY=UCcKb_J>vK5T7+F+KwV&1q3Q)st>p&Q188N4oD zZDgA9_}7vz@YBPlgMW2#cXx5?Ulg*iSu%rZVRHhn+aJKkq0vnQFiL59DNXs+>omDm z)sJBEDte_C_$9^;`PY#h!57r2E%1l&Cx)6UF+h*B1J==4V$?CgAli4%$fa#QBOffCCa{)(hq zlaWiLG~Ma6aAEtke`9kF-G8jna<#Yq&Ey$y|Ly?Os&OkRytd~*J_qa{Lo~)+a9F@B z7{$T6yDJ`1@zP3`)-X=I$1%0_CqZ8_W4H;)eo3)P710>mX@*zwMc5TR#2)4bzD$CJhSDzVV*iRlkv>Xx+S)V^}-GQ5QqM~2o9hme?JM??|yZvSc?BOG{fWl?L@DEn6pff?- z%sgX2U#|ZX$)}+GEO{fz>V0E{|L0RyV^e7iW4;DA%@EsbNd(l+fOuYbsr_9}YKy3- z$TSYmgWdc&OwVNQn{n55_Vqn>u?niJ?d!1%+}{GFqWh)#eGYgoo-n7zofY(Q4oJQ$ zmU?S>b*jNyV8owCtz%EkA znZ|>l4M-fCnFPH3nyszBK13mA==0MAo(F8#@Qd3Klg3&RQ3Uh z6tuhhXYZMW6>Og8>)q9zKj{s46a6*1@l}(5vu{_dvR{A;;`F9MWAoZa0p*!p?PBUi z#1d3RK^IT)J`U*375G;sKRpJ>`q%B#eM71Z;IAkDNQ0?OEI9PPUZj7AJuH$8G<_dr z2}_7ky%Xx6UXW5U#gA34s#1v@2Rd?b4g6goGxPiSgb9x4wP@qB2^5ytOZH(K0<3L% z1N+q7l3=ZJcR+BZ=@H<1I{(Fqv?Bzg2uS=-6Z{WFM*yG9Pm>^qK~<`WH`5)soyVZT zg0Ak@{tpg|ly5PE0G94f7MLWlddw)&>1;svzrlR-drkcuD;Sj1G5{M(VbL)uF^cH* z9bwUIbD_ons?|fi@wZ@TfdPRyfIee5Q)GeduB}nr?*5<|boj0Q(D~Zj`YQ=zZ73>q zSC#&Ap&7dEfdSRL?xZ*A2YiX-Hb_Rf zF5s(4(04|Y7mG3G;gm&#jJAIed*QTZ!N(DN!F~_gULe!FHg5Gh%*J>SIGt=Y_!5!7 zTS)gDnC1YRpz@5e+oN7sFnrBynHhY(@W}m=o8cGy+n*NM?d}`F(3=c?g9yJnZI-vX zYtRKHi105bfO9C=X}a9*8NMY$+)1@a?2yuU=V2)OTC>t@QS39Sx1x2+ixwHNx&T`) z{llP(7QsSWe?yHSID~NlUgHWa>cf9oj=>fr?r7l)%IJ8fZ?C_7RJcAAOu$zaaG7_N zlK<|y!uTpE05snrKEFfzmcMb4A(+d~-Kk?rAwCa+)C;8()t8d~MSlKJq|t`%)S*t> zgHQ9*{i83-5hPQY7!Do^f#pc{Cg2LklbT_GOX$yCBTXsjyfn6kqAwqGk~xF#P&)0e ze2hLAlIVT_s^ua0r*CLU1NMEYi^}v*bnPp zfoO#eb5s)Cq2l5y4Zas%j8TWg-cZSQW)?*Z4>3!(`M*UbUk`trq98P4Z(Y6hca4om z0FFJ=$RTMfwNN`=W0Z^8gs4n$J>i zUQaX&{mfz$Wckcm27Q#4e%<<;;*07K6k?e6u3JG-vsq>cIAgRF;zz-Mf{r1X4ZqW} z44wZ|YWBg5L|FK7e3}&&5DNCQn%j1IfL#mJW4c>TW)1+jqDIX0 zQJi`~s>djgOplN_*w@sK{?3GP_LG3KMtO~$XM)q0lBQ}?4}17*h{ot&PGCVF0IxT#pgqW5`7!obVTu8LilJL(wvYbOW1h#@p9J0dVvAk@ zOmybvXOpTD}v|t0t5V5kC?#VPM!P&0xf4RK{^3UYJdH+uvaLW(+-1#{S8I) zyr*uSmU@V#`CGkze`vHH4v5~Pbq&wv@gN6(v|pa@5XJLHWGTOCz=W6zbB18>O`Txc zkcpdMhm{$W&q44G*-d}06)TzSroW!Mg@h_eCST^`z|&D^Ngjqc(io_ezHt|T zFA-fKEHWwX|B>DY)kJ!q@BN-|jly;$|A%yE22En;7zYBvlmE3mdR!#IB_@wEhzUjzh|CU;m!&RU(Mi8+~IRli+`?@knh~T znU3rz*$>ZvDxgn|!hs@!yP|F+o-Bh1NZ`5Co{1g6_OI7j7^^6$;Y}SilT%opIznBZ z6-~275bRyi;^p_x?oh9dWgmwDBoLV|iR@g7pi15QrAg$MjQb1a_xBt;e~|IB5@e48{Oe zBQu@fbOdB$kN^6d%KFm@Ayz*LUB}l;m&-E{lJMvjz%Rr~ z!wTGW5rbaz5g~sgFX0PF50^vh9FwEM-0=r`Q|R7yv1F5Xm&lJ?7q4X7^OVIjNrrF+i%>D zzzcvq?5GNM2hRh1oWU!0fm6Y$h6NG+v#6Ppmh}O?uFI9edO+mwpBVy-@2HAbe*`q_ zG%1e^y09Y@b!7e#W?EYb`00fH0mW0}R7(k$pihJmodo{i z$@+^c&Buh~V?ESsfmoT)_O&1;Wj8uOR}5R`^YEMyNTw~*0-G^trXkZxdJ(*z+*tS0 zRTyc8Is%xfg{nZ2!t^59XU{AB@>!A?p{#x`x?uH{mndX6fIEI{gmEgw9szfs*1erELC-V>aK7Y zh%Bl~Isn{Gu~oDw@=YrebpI#EKEA>5dUqDsEfYMJ#@+@wePL%2J)fd|J)aI^%>AoI zj1TRr;3^n�dS{@k~S!f)q?UUOv8S{CoW{f`ek*(k`o8cd3vJzb?$*iq zTX)rPgy2#jHgx<7xHOvSVI0|S7<3a&jaoR2r1a6WTta|tRxkhrbwG?)B#MuD#M52D!c}+EAy>ZxB3^2fh#{ zjf!^%Z{M!y4K%R?@Duk>&!PJZz0rLf3qhB#Igc}V-Mw1)E10%sYI8(+<{IPT{G)+p ze4{oe)m^(wk)7dyKoA|``;}U_2FU<#h6vULW^R^2Wew3>Sr^!>BkTuE@FlLC9RTZM zLUZXZOvmv5Neus=#qj^d9X_?uI*f%oaH`bxKD&E4oX*K!J)WyH`rltO*`>yLQqOV~ zcZI-qG?}f(=TkwxB8Pu17|qOwdX4YD@h`*X`;fq|9_r0_JjB4@%6g2=92 z>{$8d?2N;oB>SXjN1`UD@Ncm;%BLbkfpNhi`1KyXt_a@L>mJc6V3)U|fs)?l;XrrL zeD2qQR^@uCo3R!w1LhX#weU7*ia}XZ>E9+!0V<0QYsWD=4toYV!LA%uKA;udU2v%9 zB)Mwpxdi;|%>COL_yzrob&YK{wa{NHVZv`><8KvFTbIeqbEgrtbs0b)!SrzB0ZI|( zY%aP$+z&y2Q&;bhV?bI@5Fg1g+{If#Y}{@eSRsgwm?S+so<~75y=kHcLByT!V+r6V zwZPL~h##Y%L)M;}hXnqn8$^Awe|u;=0G)C?x&*FiKgnzi7E6^4R+Yb>hKDzbp+n0V z6s%_|wQK9pw(ku<-6^zZhPzXNW?s#lctj(V~rU7HKns)y1=_sJ! zZiiiNH$X>ZP2(|&6=*Y%>QoEjE zSXudqeZzvy2xbMQeMkNk(>Zuy;Jlo6l7Xw)=MR&AcMH=C<Z}^(d+{^ey5t+Bu>#nut8QJDF(i zRRO}<`@^RhR)wZ`4b|x&sTo>MfpsPV@134W6=wKfyR(5hsS5rPVrp%`R@4odI>J(v zepk1Jpy9+c1@=?9)82sBg|0C%wH3L4xrwE!XUXoBXvnaMy9ONF;-B=9+BP(l^uf7^L*w4ChEG&mT5r%|KtvxpMLQY z{L7l`eR#p7A^+Ezb}u^r-RWXff9ZDqUba0AFY~f_g-n|0zl^K689yR`sX)V;IR9xe z6duvQ1^NYh@U`IL@@j2rL4IHXE@)gO>9O}!F%pK~{#H?@MvG+w;PZ$ug-+(>N=V-C zo6;`?r9gc46B^ZO94fj^69vWZ3v`ssxGQNs09b704%@yw_JU})Noj(P))Glg0Ikxr z_p1;~BC ztj{gh>j_?98W7tw4=PpdyVHhNqG333M?np$OlJW0Z{0tfgMk2CT*=79AuuH^jrhd* zmA|f_MRA11U!?NGSoNnnb6ER}!+=(|VGH*!4F|eKTT9&v%Ej!agz=&Yl5)h1a)>6N zD0INKVt2~1j1QE%c5LiMp|CVxbHT!HAj82x+uGICvEHbJN$2>uZOzOXn&iXROa z5amxQ2F7wEd*=8b=;v@v)eO_XK? zoqu44(L>4V1(Q?B<^{XPfUfA`xGD9M(oaxzdMXmz)=<~bACHXF^XUPUkc+7CU9%E=`d_tCk$iU)&5B!(^x=J zDjKhi{Rx45i%z1HdW%lNv2VB2wDjd%7ww-h6^x?e-q{rD6c=(s8RASD>3j>Qy2c% zf#&5SMkV+^ro3#AeZ)lHM*(?=q51l^t!CmOZ`@UJ-_+~A=>0Pq846YDAzucZ;(mDY zFLk4sHpN;o@MHnsb%K-}RyQGsA=_v|GW@4P(L#UKg)6GR=7W>+l!&>0#5szq+`)ySVi)3R&1JnZdNMIf2*h z58&g_=q3Ufr8K>iru^!4np~^uM=*I6z0wQ(5@Uz_>&TAa3+mJs_``T|?mrmA9#M4F zohWL==~$(dll!BIPOpQGhf{C~M_IJ_W zgEOLIBCtDQOjToC`-A`GHE|y|y?+}S)lag&ZZ>CS)CnW3j?D*Hhv?ri=E zp8b#$cRas2w9VLg_IPI22<8wm-TDQ(w|(>%wm);U{W@3O&1qrJ3ZbVK8h0t6#zJ|E z&N#b1ySZ}za;n4YuvK(T({gRY^W4PZ{{2RI#nciyKi2>*+G1=3I!s1!ClC#RRQ=p6Bb`)tx`-4R{m%HM;RtlYg^sSFEyM zfD7XErb1)$+D8H9nO*H->PEy8R7F7-Pw_qungwVkKRpJ>`q%B#eM71Z;IAkDNQ0?O zEI9PPUZj7AJuH$8G<_dr2}_7ky%Xx6UXW5U#gA34s#1v@2Rd>w`OV)2GBdxAPnh6% zUW+z9n?PZSy<{zmA;8+EH?U9LEeX~tcLxMlnjQhJr}JN&NIOC>ih#ubG{OH+bOi9p z{4@z-7*wU2cr)FB+j$HcEa>Wf?f>AgNck2s2w>^%WPwQ%tH+EYoz4by{~OFVzt_~y zv4TN4Ed#Kz6c!zmk~yIC`i`(@wz*Ja0M+WD-uPQEw7`Hs96+BjoGG%vcGuP@Zg+ps z3_AQ)f9QN|ZvB;nu{IPHx~oe6xzG&V_P~H@UU$-)^aH*`a&iP%0H)=EW?f0n1I1)$ z`mu?}w})|a~mY1To>@wB~{B!VCYQ-zfr8BTi)ufK^K%D!oQpV&Y@tZ>2kYg_?8TD7o$aDhm^)U4@244nw4gY zVxLjH6|Gxdw8)6n1=w=w9|m2t2o~D<8)^)}A&d*~8dqphAO6d747MO~M+;w2M#npS zd;RUB!u6qG0=}w%%eb$LVxZWX-Yxo zrLi>>efgl1%o%)#(rJI?WAwq0ME3(wEf2vzeM3tcu`}mw{oD%(0>WN(8#m zNDHz0dX8GSvzp-}ENg}dvBGo^VMFI2fW43ib#Oxv6sVda4P#Yr{G+ssypE7q!F-wQ z0FssSbS=SNb1NmoepvqsL@RWdqmtkb6&F`&@V)S2j5;LthDx?GvnXPCh*`SL|1C24 zdidiM1)&jp>*}q)YivXUaO{~z4oO?7g~E|mumW^BMl!Z_$1tnAPIxrc;4C#5W*n+2 z@MA3B7k#Uk2fz^3e3p9idZJnAXBL|v%V*Xy=%cjs>(<{CUsQ*n5W}>0-3p4D%`!v4 z8Kb2TKMMX6bPUOC_??zz==`5jvkzt@!orW^)2y(7P_UoX^u~OeWE;fEpJpaou@S@u z>{_56)7^41a{#~Bth0+cBTI(U|w*7t(in(X@riOrII{}}zJ z^V+jN_UO^_udT@7Kci;eT|n&;PyWM~jr7x65j?jS7~scx!~_O+>f|R7XgPZc(g|Qv z`|F>Dy+YBPb{Hh=Zz!7QJ$3W6)I%)I-|GGQL!jGX#Tg>IBn(rb-NvYz^LBKS{bVC|u2`5cv44+4r@Us?r`|8L$N3k69Z7O3BveT<`7$2| zo{mCG@-W1a#z3X?jk^GRiRcPpkx6m?kMusMCer(S@Argj6t*MzKcqV|Xc9ZeI1mt? z{IBKF<01(rQBTgyNEkk2^RT-JAF+Je6@$NSIXcMHiZ1=pL7pFKLAkH_J@dp0cUD;V zY6gGe4xf`+{BxazeCPhnbYw@#es~5{0exx|4ipjG6?G%=WEn(20?(cHOzZ%*f4$Da zSVc(QSo35Pgm9cN8l|; z@Fn?vnnxqn6bP%1jO|BgG)J`UoYFI_!w@(T;b%-29u%JE=2z_Fg~ACdxqz^ zd4IFI{Gsvp5~#8vN|L>83;ZgqC;$8~uNwC9Mt^IYJQi*J%|2d`%UnKQ5}nM!5B`G5 z==#)Zm&-E{lJMvjz%Rr~!wTGW5rbaz5g~sgFX0PF50^vh9FwEM-0=r`Q|R7yv1F5Xm&lJ z?7q4X7^OVIjNrrF+i%>Dzzcvq?5GNM2hRh1oWU!0fm6Y$h6NG+v#6Ppmh}O?uFI9e zdO+mwpBVy-@2HAbe*`q_G%1e^y09Y@b!7e#W?EYb`00fH0mW0}R7(k$pihJmodo{i$@+^c&Buh~V?ESsfmoT)_O&1;Wj8uOR}5R`^YEMyNTw~* z0-G^trXkZxdJ(*z+*tS0RTyc8Is%xfg{nZ2!t^59XU{AB@>!A?p{#x`x?u5hqn9Cf{0N4!)4NxdFdvtMO4v%}A?zWV#Ge)YGgxwz3!{$x)>|@mW%b^ z#^9NXGWbFMGtUH&M>i>dWhi6)Tzylwo4Vb-pW1S)L+eI0+BTb;GdIfrB5j+^eK9

+%QH=QxBsXg<1;MxG{{)O%qE$@K8u+`}LPd4-cDaTTG2#@$Hl#u2i(&v^4@f z7JX5|0Jxd|m)S|nuabV?nAq;TEC3_&+MGlYu8u9pg0BjNw*NQ;1{#}2DYlRlFRI4^|}um z{}tp|HJ;L)pi8;DN=`yfR1}$V%=jUseQtWmTJfZ)S348?7&2-NC zcG_2cUApbz|2?5@#(H?JdPyg9`a(G_Ci|h^RK4K!UoawZ3?9%WQ5g27FlxuiFXK+kPGVMlAR2#zJ{= zbE2Lr=}j|V9DH)~+kA0=_=5sTAa7uo2PmNbMN9&TPeH$qB<`$!>*h`d@;f(3I?zz> z{5NH^jD;V>t{Wil+$YLW{6b@V8ONgZ!`oG z6ZVDhW}7V3KONQwob!N`l9Ws`^*sQts_~*)PyV1c zz1t&!_t0RsIN8H8)y-J9ez)M5hyK-i0crZYt%dKb|8TrNf1rXFRu*a(?cng@Fk0r&!ncDykRoCrh|C+tB#^Nm`LIKD+L-(TbE6 z4%YgW)OduH#KQxk&xJq~{aZc1ks~4eDSYSTNcZ7Rh_RSdf>pvdj@{hj(?t-{zou!A zLWG8PwFJn!TJZc6b3z#W-!*2^9wW?cH+)}>kHN1de?udCil+yhO^cgi@V`b(3YJY( z^dF|-@tY;;F3Lu^0)~WVlrtFM(6)%UAtU8Kr@bsoh-zrQuL^+}lljoZZ&d%OOp`;i zn)ZGiI!Z|7?qPmWXyC_6H0`CFWQMayaycfc5zo1+h7s1&e?HR~y1VB}Ql36^Nwn_~ zZ$J*j{VZqXZBdak-c5}lVD@%}c1H8FjgJMC`6u_`%vT8QAv_m)KSjwT_%V1YsWQ(j zc^JCd%^SO_J`6+G3r`lU!8tUge0r$cZE#a%ukr-T;?E%GFbf1=%4sigC?jW>{8eTQ zFO^f@d|Qp*RRwaXeql6$7QVoE_lGi^Qt~ZMv9yvNYW{&Pu^xe13oyT-rPgkw5CuhG zM%m#zYBefE(Fn|N0daY^;_~_3R1kvRkHhz9Q#Hj~wK+p&k&sJERCPr!c=`0eHr0&t zwVO@UxlQGdSkRgPL&>L^x>cy_+t+O&6oi?E?1Xa}NL~2D0|{iib>EU*w_3wz@>lN) zJZNr$Ol)fZ>yZw%*S;bWG5#DtVoWck-Rw|7tadd^2v_m^u@agIOg*)lO%r4#Kx(x7 zvy?|LTKeE`k6^B51CXYAt{T7RV)Vc-bL*7Rqqia$o8MaaWp0WHQx#JEKRu1A-c|kz z5^7ep@5VPJ#ZEp~ZTYS(q1gL4;{@%@Lx`&_%MZGJ0910O>Qgs=@mHYcx8AUe8a`OjXZ9UA1X5Kt8zz zo}^s9R{r#z*{4@@runTm{RiZ3+~+Z_ymouPH*Tg$?_n^}c^y|_JAM?)ubFH0cRTU2 z#D)s`MF*H#5M+wrwie<;2}DR_lQr*=niuLBNXvUAEy|Z@3y`_RQn3{j!U*=^P#k|L zq{wLgBiu0#E-UP|2|{rV)qdL`_2j07257qkC7~ChCU*M~1tnfmZh)^ojQ3Tzq134G|VzG?bFGau6>RF8{waJVktpx~JCA8CD_gyJP@cCfuV$VaY z@^PyvUuQ#l=Z2!jFA<&s#&6t&u#pTP3uUSqFa=_?9{B#axc2J^Uyu_t{}txMSytZ7 zJk~~lIGpjW+QAKt&182dBdc2>ZE2ELjh9U&rJ=JE<0}HmdMP{0bT3YP`PxCBs?Eou znfw<*qYS|BLh#pwX2-lnuR^uKh4{Y+XqiD({C?B=iuj?KgB_UXC-9drulDgm{FvF@ z+M%zM-03d?N}t1-u`&vOKn1Y>%)i7(^;78tGlrK|a}S`zN_)hr@wSs{l<7l58t<0s z$pzM`FRvw&_y3M{X28lv=*;)*Hqe=MiR}+=96DDZF5>tTIp$ndoKP@`rPX$}BAq`I8Gg=-#~ zMk&Fw6rM+`0zeBeZoHW7zjR-CL82URK4h<=78G?=EV z16>1((%mf2knY1b3eQiFepn)liZ+aRx`&5e2+Etzw9ny*W!G+Yaa1cvOK5X^JxT|P z5B!$O_=iC%+VnSu9qNcQhP^NX$TSjArBYyS*ARggoB9?{xYX3Qh;=CI)JjBi?u+x) z%w&MHN(>+|ryY-(<+a_Kz=+MUczw0g@?$bmMlk)nxe&*9RGCnh9~bjJc9YVglG`@PCC>#ePFN8pJ><+IqWB%PtkN+$4gdk;*Uw&`u*u7kw!ulu0yUWR1YC>KVr==(4 z9WccSdAW$JfWtDg z_9B!vYcGForgoUqWNv>cXO=j)k6Gt>@SEZ!-o+?{SCUux4aPxOHfI4Y9XgSOr!>70 z@6WDRmu`}h6kMSYH%^!q9Li*nbzU)zRu6`C$eFs2N|s%%Zl#_yp7MwzjjK+AdW#wO z?xUrzYqjExLGpT7{mxBJvIeWFdXgrBlG&i=4Xy5|IrrDMD05xx@~UYtp+C0;XOZ0@erOK8Uwv7K6#-EbVu(`x8si5M)DYcgwr3$Awd6*j|Jz{3?s1)* z8_SFgu#j4p7uxVk9idz|O^Hjpul)t$If}nME{XciUo@YA_^XV}3M{l<2Ak+A%zeby zll!7r;32fgP?q<_6EaP$G?a~Tiv!lw)}Ms>ZpIoS*kL%fs%m^R_gYgrEz-W|F}6mH z_|Yjiph>gr68lHYqlIW8=;szKYOgi{G_f3F?jM51uNDl_irII+<)$InV~U}hH6{Sn zK87k@Z&vkZzmxJM+D8e^)VbQ9BXSt`nJsb7iz>z;B{V*C`)VKhHzaiWAkA<#0j+vr zXdsQc+TLP*maaV4TgPUzkl?#;vd& zmFgAW$=mI@G$f7`X>TsoYDS4$j1CP#Zu$8c-k~|jjmItGM&AK%W}VRI#<1}hu=IX#26dYOyN-1Hb{ zlx`n7b2k7Hr)S&2j-5m9gYiLV#Yt6$6SPS@#%T2RyAMUDshtM|!2SAA2ys;-3EMZ^ zBoXB1?)FDFh}}9WgCy_BZcALqY0tp{M_y3LtPv`f7ApRpkx&oqH?1^9?PtlhlI4d2 zDnB`t0W7-E91L2c+hz#ujr3)2G~s)PXz|XVm$KiLGGo!_;mr3}}K=8i8v@CzT zCJ}~_9c}^Ro6{9Q@yFQh{z~kiLc8d;_DB60Oco~b(+BG6&d(YHX%<-U*J<%fqeU6v zpFo7Tj;VZT-ufVLhEd`!#A;Ox8&(pLr0PQ$v;}yk4?PAY`0Ib@ULsTpm>Xe3fvGtL zs`?{(N&4z8s>aC9bXBFQ8vbY{~#VotgqMBL*x~JWJb-%>C)ZGdj61YH>oi zT_A?{OLNZ+NZmBWnvL!Vbgk7CaXhDgAS>Sup=E*0`82`LQYwgOYClcFHwsl>i*wK& zx|`cY;c;vpgvFOE*0@8j0z8Jj{2|Um_ULKTZ3$5P6S!#mdo3@^jKvb&D!{d*(Q#lX@H7>&=Z46k>BV{58{o}~%yJOqBo8dHVca=2C zAi-=Vp&pX10|r{n&E-Xd$hkjAk~ppn+B(4z=4|ne#PQ~FnycTUH$~ZC$`Yh60sRq2 zy754qKTL%TC}pBYJ%!M8YH!pGlMm2y3%xzWmA~vS$wc?i2u)ZSPJ_sg`%KA}ci1hd zWGoX!a8iYt!b#d^91Cu_kC-H3-V=IJ4P{?zBO{Z+nIYec9+m^V$vQA zcJ@E0{B%XQ0Wg|tm(<2T^vy7Y04F^kAw5eOzq|SNNm(ht3kQzc&ShRRgK?kAlp(w*OFen(phv1>B{@S8Tc6#hqHZ2w;(Qr@950b1b>A^O zF%-g!O*Tgm#nGik3~){TT(%>XLPf2y7pvGy|27P}@foH=Nr>N5vvZ+Y+AV@K7enO_ zuhH`W#zWBrp`??B2S}6DS4F>=KzeIjI%JQI09vOvuWK6E(fF3v3 zl3%JURr(3|DLB(sZ{OZ&14r#;6{4Z$b>Lh}ctCaQcU{cg zsD(s|S(UmKia5;BA<%)AKzzSd{?07}n;+t{vaCA43AT4)23sIOb4uL`15bsy#-=Yb zb@7eBG9UMIV|N)E;akDP3VC%mDfTP?M9E>AzJ*EIG-sSaByNF2VQyb5?By^He-b!s zoYCH0yQsdDipNbY4mfbg3%^rg58MH)44uEc$N1Ev-p%g-+Z#S&Z3*`0eOt8{{_gLU zWteIXa%%gIO}IxT#%z2VH(1@}R{TT^<~g`gGGY{iXo@xpo7?SU3?TKk70T$$+G95$ zMgpS|SZQ)JcV_h8mU&oX{|*a@W3#TZJixvZ0T%}!yYD{(+De6M84%JELIqrs$R2xe zmN?iTy+s*4WAIOdF!Mk5s5SKGMlzfi)a1DNV?r&g_A%B>Yb6L{78)>PJ)*U z9=~$^fEF5^26Q+b_F(r@Cv>OT^$Ifx?IC}u1bb(a|57WX`AqWPP=GF##h6wT$$xKz zTFp?8c+A}fg6Er|E)+M8M4{vp^*X&WzTUiWgYxsrT%Q?B-6uiHW_F?3S!qY03q&jo z=u&c9Uy|0<3rR)Xow$sAVQEHiBboJ!7a0evA=CXX2aitCb`nRd z*wz=rZtjct67X8x_i|#7a9YtG@jW;kxKZA6-op@OZqNd9r&tJFOn&h_c|4_$JnH+* ztcdg(6R+K(al-m(UkrYoIw1^89yBF5A?)R`7Ro2gzneQu+^n+C#~J3G8;6}q{unP2 z?%g!dan`naFmM5sN!p!Cp!=e3B+r>26$pU3dBcDlF#LJ1#9$?5Madnt4kDHfj8N}$ ztR7tBWApo>g-7I%%27YZvJb<6c1gbrNyS3@SGTGCBvFbYj+7_h^ASV3 zBT0y^Az&oL{Ic`4!&VtwmCh&j6PrLH();4~x6=LNJAQ#%1B$|EdzXY5WMc8#d7%WV z_&G54Z&&&9T{oNT&fdBz_|um(yHk4(^WCW1L1{{Gb<#Y`gflPFa&w6yiTTmN@12m> z;uPwP7$Jt6h>^a=bC|*J7BfmSwaX0E=Lb#1(+A?GL&l~Cw-oWCG=5;IHN{D?wLD+K znIB5`+2~VFt=tDigFCgK-Q?Jfb8ii&86_@lcX8whx_-xcauZ`5|>nxppU5H3%$}rKt)xX&WQ6O z7SGZXIpXM5#}tAipx=KqAhgMBiZ?$HA6hMhm7y$9(ThB%KeEABF@l^;s5g=ZN%7k_ zqQ%ezK54!~@miw{%c0suOrgGuUs@67xara_27I<7*vERP*TOZLF!Z(X?Pn4mQIz2u zG*2%PNSeOEmvTnZre^S!dliOdasuaTw?XSC@(FMQuryIi*YWaRrFs1n@z>8%po=o_ zx#)&Hw9oz0F(S=}ft9EHq&rr-{JTGqGsL<7@_+v8v3r~M`qkfmuUi}!xkw5&w4?+u2tk-?@TrUxFDb{|g%`+8c@Pkm}BZ&Xm<>cO? z9I0za_``OEHV5>SSxy(LRuTo=Dj)gwSN-2fN1PGge++xLUgedSWfOIpRvmTpwq_#=Mv<}qGSn^oooW4+wi^=}HB1V|q zlArasR75^&ZZx`Lp-mw1GUkl3&oHoCno_%NfcPtia}>YO7+=P*5UQ%piJf6|`Ad8jx=&5bN2J-;XovNO?aWz0 zjw{xaxaH7jIsyq_ZM2866mgISsPL0vU7!cPtkldeg@@t+6Ip~YE+?%j3 zgg4t{p&nn(gm_hsgSCEccF~ZpLC>wfY@YTh!pyrjuH6*<4C>Ael{4v&LK4>mJ#73T zT;sI1I*0E!a$AMJl-xKIlhVRt!s46t0Y@*sF>`PLTvg*mwVwPzZ+f>!0`H;0ZgJvk zzMkr4tXscZz$xOw&Lfj1EqTvexxc)f7giP>#=N5k7ITa`VYVAH9VnG|UljFKij|YY z5#;>bO$!4N;7_rxF_o=ez@IGP+HA1{*-aI#NEx49HwvvtS>a&9losO=QWA6Y8P!{E zqUhi1`HdV&Ta}q3-G@6N#$r+lRteuYc5{zU7eOfAH%)sKA~dwCB|zTQg6E%@6M|5g zY$ok7!rXR)SfBhi$3i5WFUQjZ&ZfmuZwixwWrNZ1AJi3$i6-R=7!ocUpq#+~+rl~j zt7|XI5~3QKW%g5)p^4w9{!^JIhelc~K}QLR+&#=M3JsQ^X)om@)7h3uF2^J_;yH35 zu4xPhZ=WkAIrO1RqAfL?8h~5ODIV{pMi4N2yN+mnvEX9?W&X+iyqm8O(BKopbD{TB zluUvjgQt=z^UP|7L6}Z$S0%U8(;A#(Y-{<1IoHA@C3}_Q{5=1(>>Or+08BaUrGWz( zIm%ujhL_5zZ@$H<2n;1s+7?Wpg)cDP{hw7)L(yHF6`3JhhdIV}Mz$&*bqSk6X zy%d2NWry#mW%}`FxPZ94`*?hQHx-0H*v7}_a#JzSy+I55G>=U2z3 znsL5%vxz#l5fzJDu~stbcZ%@70(|lVe*Qpr&hCRf~*8cjTUVm zg3;0oBjcH?L2nzXf1j&HccVA(%iQM1=+RpdjLmN?{4zI1gsHUYTl6%ldM7OWrDkEa z)%d2Q*vV&X%6r!qv6;xtAfpLTv4P(WME4LhVcUQ>LW)#j20(~uX*X2_LJNtu{DRiC zYPI|!U4$iS#u=r{u&xF%-_GD`hIymRI-z%sMyJpt@*r+XquH!5*}*Lorp*BHFTW%y zm#>vSeP{OR6`g5*>rMXwodY^oUc0^D8#mLWZ}~9Mc^y|_JATB6xiWQ$xz_h?4)L)WbrVDp+}1*TD1itC!(bdl5zul_2XQ{Lh!Ds z9y5;Ln>`_C^cO|YD>{&K;V%};n2m8g8?Bzzs8XBEC`h{k5pTCRvPx*DzwSGX`4lWP zAB_hK#bM{-yav(ejVWpa)Rc+ z!kjqE%Db7z+6WMbGu~A@xS>^FZSGJ;R<}ah(j=`KFPlnALuV((R|JyvgR%#N>0X@p z^0k9LRhy4RGx;xsMj3$Lh2XCV&5n7EUWICd3-Nyu&@zLn`29AZvCn@*erV=k2j=++ z{3Xn*eY_AqW_GuBXlYsdF9AxQ!T<8 zm8dya{*PZyQ;G|#istxG%Yz3^OlMR?!E}!Im`tF=i0tZSpw9n+#jk5=B$M60G?B@~ z8YoIVZsJXtIweqSycvXzpvIfesQq#a4nG!m%k&ob8|QeO8mIB=0X1qyk!EYqL#oT^ zTe#+-krviuXlQd$(d?8POo5T6eo&+K^3mk2&^B1oHvL(eEq)`^3vmhjjNOGsFz zs{>sF#%a4*o*~_bZxo)NApNjJ78Pw6@pKpnA_V14XBOEI^0&t}3Xm3_Sj0WY*AMe7 zh4FgEKTJT;roS=lP)DRO>}!AU&NLEGrBYyS*ARggoB9?{xYX3Qh;=CI)JjBi?u+x) z%>00~N(>+|ryY-(<+a_0gAu(MPtSAaToY4^Cs1{(%ob-1ARZFgDnK2FFExIP%b?VZ zsYdy8FucCnY56f3DI=JE-du>|+ow#Z%a4nBAG=8jg66hOa>;XnixXCaNe?-*mYSRw zuT!j4Hy0RCtv{QTbG%gwYRtcz>G6MMo)DxA^2_f{9lMu{Q&|6`cy~EjOHIg&;D859=fg1XSGy(kF7 zGPCv~ls0QGe{Ha)Y)+H8uP|qpIJl2l=X&s);w0Y1D1=v%SNRRbL0C3t0WKXnk%Xr- z>CF4H>(!;3q$CAbD8!9Tz(TiHGRQiwm`1Ay!#d9L1nwAMvJq*wOlq;{lDb`A(P=e`{jT-#?Xo}Bo(R91b34<{Ny7)#)w(?a^aN9~* z)J$z@q$!P*f#xv7#?36dysF&^`vZ+hGmIW5vE$8n{`#MU6Ah(~bA5S#e|hIm>Lhkc zy?Ke<5k{X|MB~)x9SC+pt=86*xL(Ie3~K$7$$@Ca6HbY?Hh=!}1V_lDCY)hhVr@?h z{k>CaT<8$S(4X5VvB>TaKQxEluf8nAih!sIF+{0;jq!sBYKZPL+p`dmT5_Y%|6O9Y zxfvPE>qTn4hF>OC?ea?}-w-WN~EG_}%DHpVRuSW{bn66(7dYlvWn;n>H`jqVHNqq*0b(rJ)V)-x8K1yh&&ei@Lk;5Kc8*UNED7&a)98yB#L$|N?p?^a{rw`HsW)sk= z-{~%*+TLP{doB&2B}LksOSPI&Vz<$uLCDSSpWz*vgWPz3eJo!y`VM$A>x4cxhK;{~ zecuOvt?R!*>R}ABE_p5dg#}PzDVzH?sF6r3>5u5m`R%ov5~}r0wNqvh(lcnQm$}Hy zO^Gq*BcLNY{dbS;_8c5=@{>auz@iJy!Jsv|ZHCa^NMH6w6TWw=*V^ymQcHr8SvOA44Xu7o(*v99)Z992 z)byBPEL4veHJH`zf8nXHOV`UC@h+d0ZQJzPj$q#vsgA_Weny>-nAi&TK1}WAG7dg- zgR(C92#V3f7y#0#6Y8l&5mjn@NkC!&Bfn=b3mEsRc~E?liKX>4P#d&q$G<*)eatAc z2?XyeOw00rk0!z}vcoN4d~>=2DE=6`-Cv0vRA?97*8ZqJgUP}qep>gg?)zK-i=B*C`XBZ{!LabKBuwf+;Nvb}C0kzKyG+!ges1%3Ny*$9& zO#UwnQ}Z4jByKe)e>%)DlOo{shwkQfQFt7i2VwCgi#6`hs{oH-FMo*hkUe_ZbXx)x{{$}jLcNxkWyWHO zZWUl)Xfi5frALk4=LwoN^^BSV$a&$g`(@%w37~}nF*DM+5}h`gl-6?^3jASz?uu6J z&aWKCVr(};^wGQlOEtmHW0@#|lPb&I_Qz=SV_)M#MWo`si>O=m^fd? zAID21N!@o0PYi|dVw24gL~(Sf5d&OPKbP%DrBG38?8Pef(!UMEZhVI6P!i(z)a+bn zmUfFE&Bajp!)x?Bfbmc?K`7~@;Q`Vl^;OX?CXn75mk!xuF^^JC&CfRs_r{a4LRZZK z1-XNOk&cG^>QD&<0;c%Y7}t5ar83FE2uTq7pfZA_5&U#z!De$~rKT~~831sF3V37` z?vXLMjstq!TuXvA>R@JjrD@(wb_|ap3s?GWp55o;GD|k4g~xU{Y)!_Tae0NEy)nQO zM)M}JqI05?Wp7p;@wBQHrW}SDORZKpMfxA|DB>_fhd>8f0`dJ;`8&4^Y<`H(%ChSGCfMGI8EkShCU{08-V4}W(o;2(~FAJ94h zb9>s5;fMc{-s4xUAJ9Uh(|``A!yfE@>V)nzyIx@ip*`d;m0<5o@?UCYG@nWS8w${+ zvKZ59BKhx)P^%g05s$gsK=6Ds)P>^4ktmdWqF$$0#@Cw{Zcu(+nd>uSsrw{I*~~69 zJ1gx7bb*M40bNRN>r2wQdLgNZyAzj@FD%UnZX~mQ@gn1ZHAH*%%6nH7;fW8$@2G)`DQ?Tf*$QzwLB$%Cc@CxpE`)+#6+Z{Y{_QGXzUyX_-Pv1L1%LXIW_N1OVZNIWx2vl^3fnzi6V*l(F+We5T{UQ#0W85Ui{QbU*kE<;CG7|r2#dI4AticO~i|XjoLW<-+le* zkg=)34a1Mp_<^O?6er2n@_Y$rekk2%qfa@tavu~8?$myElVdm5Nh{p!RMDZqOp)H7 z3AI!8{wb%1AuJA|-|)jwiQ_AKBom7(vb^)Eh~Ir1)(d(PC%A3O|>mKw_73vYu11V!V$r@PO+x1}HV_JfyL zF*WfOMW=WqgG8k)2Nfrn2T2){N=(X3i4}o?XyNl3#fdQ*BN;Ry6`i%t`TtIzz1H6M z?6Y^@+sNEq)&1+<>;J89ed~MdefHUBpVxK<+o9sh((-EOk@nBS?B@}p+otZ61iI0; zrRCXYtX}qL`|FVY|DDd+%d`5c)A_7Q!D6X9WQ9fRZL-3m)r}scZ`92H-NEks`$5t& z3#>Gfuc*ba*y5@je_f?uvDBAkg+=S_vcjU3|JD}g_N?{FWB!Jc4=J-gE*`FaICIai zL%yX#uvq9`Sz*!oC0Sw7>g{0|NI3j|vSC4kV6h(Wj16v>c`O|j_o*GQRv$S{ywj7F8g)81|akd__pG%Y+<=d zb{Oq$rXLjcu-3}{-p?^xNHLUYZ&TUUXuQ@-?Vw-AcEJagVHh)PQMb(3@2dtFYq(B! z80{o;p}V_nC|{H>7_0iM>@eCr(gQu*h!w_|EF>~SXK(&+JHKqO`#u(RSkjtW!>zJ{ z4(lY0HGE##g3PbRRv>JkCPond!wmR>(Eqvwo9xq#$*|;)f-2sZ7}+A zC%*O1qU*W*@SsEoMwgA!RjV;pam%#+kz$82_M2sg(f(7icUQJ;o7vS}?#`_Cmis|* zbb7JvT3S}t3kiLN%4WPoh2f(l+0pl9uWe430471BoO>?!YuM)Br?DsFJXHi!3(k}P zCIKf~i}SsUd-E%;lYWJUK1>xZO8}E#Q<6@&Fh!+=d(z*o7Qm#vTmqN`437=U`4>E8 zw!u{2|CRtI!KUP#aAAtdn&g>e)>qE-d(=Fb9Q~wc$2MPI2D88OMEmzhm4m6=BPD=I z;C7uTrQ?0qYb@JpRc!@1R@46JGH1Tems1z{utGfbM8ytM?5rWjNC=bgXbE5vShdxQ zUTJX8l1rFe65MGL!X$jA1TYCYCrSa6;s^;~5F@`+X{_|w@6if~t zF9A#fk6_|)RJ(d}t^kI^W3sVyqKd;5zuP^Wr%MQv@M#jjBrqovj-?mMB}^^}?nM&9 zBz%DcFbO)(kpd>gaT35JVCmN~8QjC|=VzpVNpYwIFbjMf*w(wWH`8w^ z=Wyp*=YK|HPWb#?-LrUWnvd{AlNrT*^@>j{5&@*9U_Wl8o0iVmje zUM>Ml0$yiK4_bW6(DjFuW0*?5R|1#>jWR>as1_#gmW6#~@%I!ROyzHs044$b6BDzl z-iE2*MH0Xy@Wkl+xBieJF;okacMT82a9mFNKV@6#na3LxKTI_c%CAZYlkjp0U=mnN z8lb~iXUgLAebo9EIfZqc&W>~XPC12joc6{!y-H4Dl~b2pFD*>k6$xMx$lfWxIb!SW zvO`Yz=jRr;b?0~5Qdc?{?p86Fi;ceisJyInm)yYY<}*^jtoXDPFe@6ZIAzU#2xQB~ zR9VZWzN#$3oW-(YTfd=VFc+&e4(^p3nBDA^0%pZuO98WjHDz|GVloU@8@b9Z%93!- z7Rg~Q<^unDl$0r@-beMZE@Uo2fOp{(jKA}FjVAT+PB7>#3-k8e1kouQNBm%fE7`` zPcj%}*U1cnxg^S&swllVo|g~GB^>W^w#p?9celQ|=a0%g%(mnYg|B_t7YL`Chd|R>`n|91ET{8GD z|Hkvz{0;$bV}ha7KgcNz<|}1}!R!|$d|O@GwzxJw-<_Tx+%d#e z&9ir}+8tZW{aPo@oj+9_kk@`oeP4Dgx>*941fBxwjB7x*A8*MoZ_0iR+x+`GJ+~=( zn4-T`0+?)y$P0H&gUApuN+Myab+wS`xmV)w9DvB4Bu=krp)q_{%@ zm;_9YmDQ!)!`1H&tK2u3O5HZFcVH4U${LF=)xzZ6YWInJkD`D{ce?~I36!1A+M-e zHj@8SR1}7y$IA?Z*_*F5vhUIRfVHP`t*KeHxr)ozwEHjHaQPgC3q!b8l#HGsB@D`w zWro4bYYZPGmeQr&mkks8Yrf9g21Cg;nPD)uN+?yr;M;idWKGh1n%3{gjk2C_gSU z4Ca{91v}Fkj?xDdHVkFIEi(*eub62b+dp>NQzZ<(%{A>mL=A*N`LC)P26M6fVcDBg zSs2Q`MrIhyt=dnOF!&y6|FWWhLAgt27|g}?_b#1VSzQ_SKd1aKl>L-(hStFujNBVuX^?xX87~G6)>+h;O4CQI-Et0_?`&?$SZcq?%0yW#)(OsMO zYplmg0kgux)$s3U(mg6uo$kPW{!P267-siY*2TWzb$ak`{*Bj@6*MgM>M^^==SmE- z_#`P{R*>M{;_S)=bE~_CL#q4OL6k5Ty+8_>6|E9X#W1_K04GlHP6Z81y^`SF62mOs zE(OdA_Sk0?mRE}@zCo8BAky_Qe2o+^D_W(PieYx&B*ix>Xjtl%6n{)&n8ojx0%irv z&g+-v7E9lmn)(}^yTI&&33!i`Fe~qt0%pZ$q<~q`sMktQ5@-?3b!}wJnhb}%$_C64 z%BwGF-;@|;@m*5DtYA%8>Fp`pv;DIBbOr}=(eFqBv%-6h3LvX{*KDQR=xJSzD9lH6 zU3uUbb^Y@1D}0#4FL6Cn8nW#ib$!+Yat%veb1*sDO8W$N`pJdUTQutN^k&}eVwBmKEqJ%kUSK8(t&zBWu@k_J|W(5&? zZr!K<{En{3!(8gaQoyWel-v3NSd>KWg?y{bEna_LLBkxhc|m-u#4wBhrxY+Nh;U(T zz8|6cK3b(jln|7-sSFrGQyMgx%R$^El|W_xkCn zZ7`Sm?^3|5Xp~#8)Eh>{FuOMqk$VB(D%S-!y66K68kTxRe3Qg5i+@`Rm=#3mx%B}2 zXNyX~Txxi?ij-N=D7O;?FhOqd`jZ3;J&(4nUqP~e5Iv9U%Yq1CNWmt%wg?qpZ>xY2_62==|p* zB?9I|lz5%7eWhEi-m#LUdAdZQz#K{~_ZQ0z%x=0;z^q`b+BO98XO1rP5BlSe>T`x7>|Q5H_^vFgoZHpN1f&8wAsbL@F@=VvR+FlYIt zQoyV*H>Fv9wMxQVvR1UOk{g)ayg&+=739yK7+D(YsZzk~qE@V@$PLVHcoG533UAED z3~L$#b6p$MD2td3Gh!>3U~e9xSg7@|9&JzM10kQMYGF=G)rfwXO2S;SRsb)R8<^dk zECtL8)@pl%%*FPx`_e)EY7HZpy^Mxic_Hsras#uQbEJS-(Wuuqw^3DtfF}^b+}K)K zomI|Yc5{IgFe~&%U;jBm>(410t^N#aMd`Lja}Z|djk>H>-4>9KZ;yb_mBSncpLd5@ z!BkjTT3eo3zrpS%wHxMwACLlOMWY(XJ)3v$D2O zzWm*PgwUjfc{{4UD1V$&{l^sTVqb#};pB;r4R5Sy|nl_|4R%tCi|Ey-i z7>us-cKV+YnO<6&@4sKROQm2e^((T&Xm6BEACgoEqhpi!V(U5Itr%dG?vx!yd!gmN zDEC|bn~yiww!v8H9kRn{Z`^V!gwe6Z#Cr8U#Q>x9TG?T=^S?e9w9s$(7Y^^Yy8{ui z3f(C?jP}M2r$QJV55nqq`PUQ!jMC4`4x^p_`weILJxqllA{zb(*(F!gB((^}7Ck|B80{gKJG%2L zy?VLauGda5dj9dtEY+=ilBwyd-02Dy#$c7r z7fJ@B>{+tIXy-+F@6uJfQ@;EK+MI?Qj6Q0uB)jAUMkm{4htb}sbKHq9goPJqGy}$( zHl#UL>4vYwrLDPCd4r8wbBUb5=wzqtFxtIyeA}@!UonQR*QF2`J=9A2d*lR0C)dah zqus-#S0Km9ZodH~zuZY0Lo@@%nl_|4BheCNk(R^zfMXhF4 zUi-0lYgK%sh6jwDR@cvU@<E*3{+R?AlDl*DttV4RGe4;+~0OfqUpquimr5E0j@Y9)PI zrC@YoQL?L$qBZS>h$gChk4nMl=xdqE)8hZf8=d=QbB|ec}Bq@y2*T@c|eVEV_PFPRqtvY(aSm+knVYIhOD3!wK zyh%bop@3nDPZG*gfnb#Wv0TAuA13tpos-YB7UD>q&35sJ@a z6fledyUsQr@h3JI(qExy!^Fcx}~>@eCJCA4nk7FX?r;`1E}7{-9j zC(^4Wg;Dxu*;*)Pc zf-zw8iS%T(4MypcWrxvDd$&Jxd%unD_ifq5`SjZBRBke2cDZkr`E;X; zu2cya19qKlF1=P#7^QEO9Y#B`c|P5r|L}xQ7|VQ9gAYb~qkPt_+~OlL)I$|E1Pke4uz%T~v zI@{dePDx>ua!VtOc4G5<(no)yKw&KNN3z3cZ;%!^G(vx^r=Ney#Uq zS^u`m!+vjrMd%R`W#x4})AYBqv?lXE2_C#*+&bsqmwT8}%5%p#e@KF|2~f!ScU8qd zi^)UVoD(}t6&&feTUoMa`?gKWcjOAD{Yb+vKId59KIE-c z?|07euP|bJg!V$ZTIDD+U-c2Kt*_CM2 zLB(A@`vr4xKO=LwpA}H-z%|b*B>*aS@)<2yGweAf3Fcz8dS#c~!0d((f5EJxdpzL*7OH(ya`oS$XIK8M!dt2oTX>Dj{2qFFE(+o0xI#!Ca> z91rFIC^kAG|4prhxm2yNACMcE-P|t)%nF9mksN!q;{uVz>?OU$-tyc`NB81VY$Dcb z6bt){*A{2|dwZVMs!AVp%E9pDjGpLL1@8j?d9wu2yV5t_n6^L38a}7{f|6hO{0-S) ziBF`w42Dtq>v9F7J!bOc1D{Ec(3`{6As9n7N$pJHlM6Jk#3xcN(7-6=0u7Azn8{NP zd?tMmdnS4F8pcpfQah9QUed-zZQEXjARH1eSU82!XSdwT0Z>nDD`#o;j7 zpykt0FvdOF_{!^Be7FlnCnZtV8nl%annSN6O+h{l1!Ew!TEnNIV06N#p;#_Hn8dd`6#Sv~~4FZcu|jG>w&j@HU?Z~#9H88i5wTW8Rd;ahBN z<=phi?1lIQFD%L6Mtqo-t4&~=1D0q%6L)=jP^8U+P|2dc+7Y^c+d`vahk;a`VSA9^CFiLq^8jSYTxZ?xn zlC=Wh#tfL<@N{0773>*yQu}jTUv_=-vE&PvGxs;j(U$#cPBX?sc&%5%YCE?Fx8o(m4M8=eaevw}UtLD&BE z#eVA*P9pdc0!SOy$Ky+Vbl3*7f<41_w|`NvF_Y~V6*?r)^~Q~kjmi~~FU)}1jio2f zo6VVQ8`-VasL~n7cT2#Wj#^peyCqX&cOL%oroB9C_Xp|KS^WU@fmRRu>i1KRu+(ddHVr&!tPv?7(%r~G6nZa34`*pGQ(izNILl3d;g{# zC8=;FvGQWg!=?U={Bj@A@T2XTS_%FV!#7`X86D@^hqi0IDEcYY3RA74UZ?C6$^I_P z6IBSNLOjI@&VI?nzYa3!#+}i727P7W@_yZsZ(cDigC=j4MYg}|~2-0z_H#1PIq&R=Z(Dd?($onPJE zt9oFHk3%_3f(Of)MEh$3aRBpwx8Fe}zdV6=DuFQN$21Sg4!A=NhpFLdiD`c&fF9up z%Kw!;RF(N!YbQeLL=I}oS+d5E3f#YsvU;fX~WHu!63UyW*E%8zB{kGv)#3w zvJuI%cwwlUhq}OEjlw^!g=1{Dl41{Dlua}cAP zcF$#7X_W6#$HVNuALaWbgF$wk%rKZOni%CwS%kjNg`SrW$|W4{a@LENZ8V&&anDQ_ znBDiIV7kB{W4gd#wt8Z_du0LozOo?yP&t6veLu*LO9q4N7MWo%mjt<^Do8r#&*T!0 zce!(%%TLNB9Pe`1IG3N6OE});+&Gta$|W4{^7Z3fa)A(zcX?5jOApQm`Ii(Wtaj<6 z&bOtDd&@iKmo6Fnmw)4#=V!s}dE;03E7ckp>g3qef9T!l^PhNP>Ge{Ca=DR%J$@Dv zj{wwh>HOTnb2O#7^)k3Eo5wFu5g3Yiz20qSNCt!K`7*;`HU|<6FUMYH;c|oKSu%!F zW#LXq3PTaXeWheD$heXNgLx!e!k%B+`6~T=p%@DJUeDBPWrjgE;+vjZ=*}(9sML;q zJKrTUtadJ8XJu`ANq_Izx9=61VYP1pzjVGkeYr}#ao^5=Rc1Ke`DNz(Eh_c)eLH`L z%&^+Igq`l<;#K=z zx_5-iz);49WPjxT2gzWNJzi!Q%p(JvU@tpHMPMjW8tkzu149{tJzg>xWKWVA2J=X; zblXL9i?ctkQqL4aDc|dD`0+^L2N&&OtDN?|!_@UM08_bHrderV+tg?)3yDS=3=$3McyShFuVDT6fi5;GhB+=-?E}Q(-iCudMp1f%h5w1wbszGmQ8(C34(ON zz89CR-%v4_i`5!5_sR{-ZuUw6v*NF%fLT$PcIG<#uAw0mc=GzH&R%3+R-Vx#%yf#Wcj zs+G70R5{FU?w0~)1$%~bqTM?;8#gC-mOLcT3FidwroilGWKI+oF1i%vk`MB(WlqFr z%F~cxm$!S-=w0r^)wkNqpbLFQtZG_7-WpxGY-P2#Ft~*yI-T~K$~O9Av>T`1pc-JR zflzp|15CopMGEUU?TvGKm7KyVr!Ko*T9~ve z62K(jGRV38flxd_{;&#j{Vx@+YKjZvQ1#*KTX%Wq%58GYIaYF9I>PQyDVR#p*Sz$A zNytkYm;@FlBa8&iEG;Z_7iVu(5T6iJQG)nO31AYA)WOly9?QM7w)#bt{OY(Wz9s>z zSw(Mo`R`ToAIDYkPZGd=RY8wiE9Mq|po0H4u7-b?0G8G8d(yz9NlR_J*TapDu5|o| zbS?l>u-5Uuk8~Z#f%fA%2@g(?PN&rbxlw}>ra_rzP`ekljb>2UtoU&qu?G(uw#0QF zlwRlbUt2&q*FeV2Pw!5#*@9!_RnAm7E{|}j#n0D6*d}Yw8HauCQ?(VSC@CV(! zMI~X%m6cP64|{Ps_!|COdbau))c{isgmRdKFbN+m0Zal5q(OHaDz`AXP5ssWer8pf zQd`+y-l6{UWOW8i!J4LBf1Rk}Fx5bRJzYYWgin(ICPB0QI!A)j_3bZaO?=)pd7oPAV{WI_%?Y*AWTpHe4P1nVFLS@2r?=Mc>h7#qmj z$1vJc0yg0l+5TM_4%k?(d=mD(>LVEYz`Akr6ntJzVRSmYGPAVYo7=fK`H9f$G_{sQ zM#_$X!!f5q7pWA?r6uT`xu@_oUXRuOzti~_=S)k>v-_yh9{?679u@TVJV}2ZD;9lv zMQG#krfas+%@;96*g3a1zvC(L0E<A z_z@DoqJZ$vUY;#<*Pp9Y`|e`E+Pk8=yUV@12`3`j2o+V_-xSWN%dWram6$JN23+uN-IrDNkx z4RTnlzHDp|(!oQ$&9}kPl0!T!CedUZ;$cy6hxz~(t?5{>32zM?96IUDp0h((>@@3h zKN$EAXNRyTWXQmxwdK&U;-x+J6%9C8tn}cdKM+0lW%U6p_QCD4!lKpU>u>)LX%*s@ literal 60482 zcmeHQ3y@@0d2WP75FZPR1V|+Ej9^rjhY&3>6u}2N!x~r@m9TSXre}BDneN%{o_zq8 zS_(eV6i5sbjYSdU5iu%SNGwH5Q!b^jftbPKHH2`M%IcklVTZ=dsj_ukXn zJsHWIs($_dfBwh!|L5FupM7yHYx}v+cC*!~9UwpUk=Oq26TI==P6R}>W^4NSot+2C zw|&#!wc7cuY4@#G`=%*@7%3jAb)HMKVnk9Yf|B-rpXC(e^*P}$Q))_d&~?3 zSu+s&r78R~llrhp)kI9!TeI8hHP~4tFSH9fNvE#pfvDdvOxgLj>s>*h4$<1^Dg*Lr zLI+vHac*_B2YDH?Ph#3PISHThEvjycp~>*1(A`K75^n~K`%^^Yb%d^9%qI(jn*5H_ z;ivzbgbq(eoqpz%nAV6jDNC3B_4y*hcb&|9-fGV_ z8`aE)Gr%uhK7F%4C3KX4;_v5USTg|g$(e@veF-W*CvNL>xwtSbGu)tHZ6ew)A@rpf zFU%)^Bri10*JSpy_a2qZy_zl;2twk^;qrPDVb=*=BWu??gXZ!&hl*eL*AV(r0rfYlzS2gqE(JidEd4 zNv4bJZWmc_MTK4Hq-Y%wmd_I3ZYT7oIeOCGxN&N8(n_W}^;T+JXWN$vso8R<^#Sv0 z*SGiaa@XlVN193d3N&XhMigeiWT=>?#* z7-LC5qi#9C0NT5lhnF&jrf)AX`!*nu7GU;|jIkt`QMbfZAflrv7 zx1WM2rD1$# zqp5bJ{5+J&AHW!(yA5%$9ZFy!-wI8Lpl(w%p2>V?e8&bEOr%?d#*bva=@>kU2^_%~ zqMlC*rV}SKRqDiPOyC8KQ9F@w)-c91#BsEw(P(0KU2_Yp@H*BZ9MtzAi~&oP^IK|0 zlsQA)^i|98iOfIHVfJ}U;5m#Tog#DmQl?6sIEM+G#Td0S8Rra1djNFOjJ|hN)5~PGAB@Ge+%q8D~GrK%S)4 zchslS2y`uQ!OnK4K7UHamG@UWs5PhUlrIaSa~T84c7+h+De3BUUjSaTY3ZY2ZD3xU z#~8Y&HJL?nN>|m#JgmBrewZ<$B2|y68HuV0jZZQ!Zek3@)ep4e4byI*TNnd@y7f?b zN;Obw#-&d_x^*&%ANgi(`7UGrsmV7pflC=fY`Q|}`ZAUG1K(Bql@Us&nUeQLD5*0g zSC=RO`+6qRW(-HK*(c4$?sa@!(9O*@HYRhMWGXO|^#B#muOoxq;`?d0Z&1!XloK^V ztuU5x)RJS%VB9BTIQjP^O`*eT&>bOvey2@_7f*#oX@1=#8M*gvB05wr4KF5p_OoxM7ay|2Ll0o}5u1IT zO_mNm7E{oaZxDdy8!M}r&UeRm4Fqb?py@WMF12>M&A!zpi#|V&U=3qA@foTvb>dE& zeTPjpoR|?>UsEu@P+7&MzW(w4P4t86lMzR)_&jg7^* zxny!KJ435SLC#<3$Qf=OJB5p*+UK1oy4T!H`^sE;J7XXu)$Na=B=sxz23LpbmgvI~ z(_PHFyBI^yzRa|m^F7$0YqxVSfZ$7v5d}e^2{F$hLeD+ShuXIo=RU^JVr+Ms3*A}a zJ~4t8M87Mehm5G`b@HKR#0mz+gUs5!jNziJEjDETZc8_wdmP=>L2YJs4p(S>*(MJZ z3o#*%>ZI_1ZEcrL*6}<&+mbVbtvar_P~1+jyBu$m5ssTPQVqaP$7(P(OH4>te|S$q zU%sh|Gi`T7R1rDbmRM_(bxnxm2_(&hon1iQiup{Mm)}L3JP;tngcz+1$UAIzM1=zK z=eERKY_cvyGeBDPMl#>4kHvt-Z)5Yanu!m;_>#(EAW`Dsg}v?XYXCLtffwgEPR577g3>;C`SRRH>TSgW;>g^DNdfAc@YA%fpP`{ zh#L|IDg(5MIwe{Zpam*4K{*<~T)Wrm^h5Mfw~M7By&C!epUr_f9Ra1Q~ z$&b9MS3O>&N+-)Bq!;ECd}8BVAUAq2qu1L}}4Q6j1#IIDg%?WxA6 zXSa!hOvNkQ*bIm0Ig}FxNx31BP|q}|02b9UMxBC$G-y~)gH)BU4!l0@j_A2g zwch6VX3B}erreNtp`Jk)Ex<;#e2O|Hy;SHfD)hIMqaMsJ&ZQne*AZ0uP|AtI29}Bq ziGX?rL9_rH5Wzf(It2-75ITVh9Zosw0kDA%ye@asdRRBxDJKe>azo;UdIn*%02|fv zR_c`WQlWQIq01;ochlx)8;kAK2WZ>l_Q^tY9;BQobYQ92kO-8bqgo!JPC-H%O#Ork zJw!SE=qS!-QTa0{CkmZ%LlRFZI;!Oy>XbtUg0G@NFQFXuAeo*P4H?QG#yUq@SUG=5 zIZ@a^IUMGOgsGlE5G@#2AcA=VbqW&FAapAg`YXy&4}cAH;I)cM>)FE)wJ2;V7^;Ac zYIzEGH;V^?hftyYD915yjB8_JUtl&q<%V8}Na4b2l6HGN1Lwx zI@+Vqn~P|-vnWU7#}hJ4Uru!rk>zv>?WT7qg$m*1-j&nPofVH1A+4BT>VezJ^o66# zDWBlr0T?f)pt4GOCGAmY=oPfvTFTM6Np7XwCb#XF#q>It-Z1xhcY*^17twC(C?_f_ zOXDm{l0Ly95|wEh`v%H!2e8xoU3>SV=fOW)yzFaRU2TWnML}F>Lkyy>U`g*|3C-#K zGa*Myuiad1P4zAaxYflGcDR9ZqGD2?xgoKGE1f<~?cG8-01R!RHQUGz2EP5!jDl)K zYj>tM{;8avrw3e(Q-dA_)_h5cc_muHy`sjAuv` zPCV~%7fARJMWwaSg2YhBzm|BQ08Yu_5diUPccp^w>Aw?tR0Kr^q$IH&+@JXQ7_slp z1Iq2}3-zha#`@O9WUd&A1Bs@nvMj_}!cbUgU6v^T7SAT09!BUM7Bk6QyIzdNgj=)v z*vog zHxxzv)T&BS2Xk*GT0?a+=Z$(1ta9>c2##zS3Z`-qq+g@zIM!OByTLxF_)}=$CX%+Y ze%FzHI+wKS(~DE&@#B}c={KtG{)}{5Pv}uOFzarpZVH}$^#_TJayg$>E9<4!$`lmx zX9((E*#wSVO!}#}6VILK?)l`mKVz?V#S&FBuO*#cL+DWnP)|nZW|f*Ty_g~K6{d8O zbP_L2Ba1}7(8yNOX+5DMBWM$sUp~=YabE1MY*4p!7x}e>9+g&IaWW&~j20{1fT)g1 z;*jW2ku5~z4TRnWAO5Y=%MaR_{AsIU`=$l-+E2M844btOinG#}Ns5PB3KWk$qq zDIlujJ;Wi=p(581kt+yYZBLvuAzCs$Uven&)P(4+7uGa@mlPtF{# zkGqLO4hwL=!_z&&3KDR^jzw_8&~;pqNn~1rW`ee$KZy?c=@&o`Vj2S1|$b zoQ!a?Eg2An9_X?P?e?{yQO2ldl|%PYJi*w5 zH~+?YMyX@4~;Ss6Y_IFfIUS;RlrMP2na5Jtu<5258FdI&4aR$?wF44no zV^pD)G&PX;*Y@`Oz^Vpf*lMZx@ZA>WYDkUX)4wLS#I0Exd?A{fX*d7k-kyDNuCHmT zr)a8m7c9(Kp>z6x%COr*)a}Al|A=d7>vrsYdc$EZvQ}BA58i zfPvQ)jsd9A^t6L~)h(mym`nw7Z>rTW%f;QLp}TIr|HzurD^oRR4IAWizm)GT0fts zjV_;sn}jNZ`NeWZbg@)kq?*Mt)Qffn zJilb4(Ko$ZlB;2LMRe}`LML)Xgo7XKl4?K0XO1qz03T(~u28R%pAlRQ85(A>RQQKoGwZQ8|ZzU<5aG z1|h`n21JE0f}3lD5aL(2Q6Y@rM&}@e_%&Bl2qU=bJP09vQBgSrb3wK&o+1#SFv5j} zPHMNx%z&o0dk-ZQBDSob23J}ra7)H8I4Y~li&Ns47nReQ-~62IJj#D)po2WRB#2{# zs|2$w^?EdQq?et1*ukpGKW*3RCdinE{4;939)*Vd6Jfn>{3$mYOSd@U*Diavi@5R} zD(_(05JAVC4d-y5CG&!hNWcmmlM zE}S?-cyHjB?&dVMgwN&+Dj?e5!xsBf` zs64u{YsUXbph`}VG~{Fy&-mA4i0N6?xYZAe7H#FvC;B zB>U*S=TES${QF6Y%gVo>^j7qJQ$gIC-Q0eS<{ylL0TX{Dq4LzdgkecF`r|j)HtTj^ zWPi*QU>kpYqB6D`%rgILF&~msQeRUc$DeivWSL&DGyCt7+ZTK2W~{#yl(xLugE zURc?;0L=KkWq;H0gaT75L_g)OHy=VB^+UVaEy&5<@8v!rbAsD@&~mrHWrW^)*;pv! z_cY13ATROzd{5|!2NMATtb9kDZr9c~qVJkulQNFl>~VUlZ&H?eixt1`;}?$pP9)yG z1^{+50EG8j1>AiskYFgr@9`*tIYSz*25z&hsBgF1>|1TJu9q)N zHpT5@T9u&Bw((HYn+iR$KMGEZa{!*h3AEBtpg1BGiskrFlWce~3D@$?^x{KyV$cts ze#B;9XOpFakHr);M)#dmJ^?$F{L5jX|wOJ z$zsfRVkYkZzAKntsH|cWQ}LC`DmF6}_f%Ff%T(;Htm2JKg?|8{H+lMSZt+#XIEw1K zrCuQWXw7Z)*34XUd-kjMuA21uB;MpBf_p!MU1fWzb833ie*QjPed0*yOV4llhRvQo zv#HMcWVSKoVD!{zDQ>peqEzA6&-UVE(-sC5d1%}0oGx{2$6PXbsV%L_-fXiCWjjUL zmA14hdymb|DRTm~q|vzA7Sg^BP(yHtRo1Igpv*wP~wx~{QeH*Ja zwyda46ZJbb`w*LKIJ5I8Thw&sXj@jDIo@VJ+a~9nQJ8nk&CZ``ORGN$B49tu7FBo7 zw%ISS$%Z>S#U1^7Ja5oh?u=8#X{ughv&U?*;m(%X#`GWB(x3tv{mVYj+nh4jzc$z0 zbWyRci*2^(da}*wTkpmlkyAMHr9YNNB<6I%*n2L9CBANzD~(Vb47?7c68llkZ=@eu zs8uO?<>6`Z=Stjmq&q=7FT#`QR=~?09|28^P2-wOq0GHmajiR;Cc_dRKa7flftMb} z*NpWF>6x_eYRVCXmLFrs(;k-ixIPpxbP9U#dOlTnBIO)MIlmT-XraBnaT|Bk){1QG zUh0D-KK=j|FFaOpKkfDn%F)=nLpJ`@pwk_cc9Nmnd$(d;#?Xl4mdE4Yr(jy*V~uLBhCA7Y# z+V#$^K)Xmo2F=;2O=mxQ@2cdz5uOAGI918jB}%})p2@TsLw5}?NE6k+0J@oV>P47+ z12}%EsN$*S{Cv`w)`xuuIBIxbE-g#LL;nFIflG_$wwL8b8T+!ITrX z{}8nmP)9%JcAKpPfu>>qe@&Lgy<7*+FeeIHm^ZN^2`V;j2O+9rOR91CjdLxXVWvoR zF4uA#;#^Lv(M`&jMK?G+g4ydl#!v@emP~b;t$lE2$xX+7S53ZJ=zY!KhRme>v!z*| zAhhB2bEj5a>vqs`$UKNUU!Q*%ch+BRYY_UoduXb=I~%wEgaUJ;zPq7T8%fuQ`vS1R zT;0Jf2ix4!M|cyltb(QEluK%;) z(}RFUxB?#Th#FZ911r#XPY{ZIF<3|%>;__4TBpU&@QOY%^YW{ z6sE6fGVPLk^EI8sG=YwxbgC(EtVtE|H6}ydI=?mD2k#e6>72XiR|{S(7&)u3khJRe zn~L@d``HHqsYwJZns_SAFtLlr>0jnIdYp#n& Option { #[derive(Debug, PartialEq, Serialize, Deserialize, Eq, Clone, Hash, Default)] pub struct HelpData { offset: usize, + #[serde(default)] + end: usize, file_name: String, } @@ -43,6 +45,20 @@ impl HelpData { self.offset } + /// Offset just past the last byte of this node's source range. + /// Filled in by the parser once the node's production has fully consumed its input; + /// equal to `offset` for synthetic (non-source-derived) nodes. + pub fn get_end(&self) -> usize { + self.end + } + + /// Sets the end offset. The parser calls this once a production completes, using the + /// position of the remaining input right after this node's own text (see the `_impl`/ + /// wrapper split in `processes/parsing`). + pub fn set_end(&mut self, end: usize) { + self.end = end; + } + pub fn get_file_name(&self) -> String { self.file_name.clone() } @@ -84,6 +100,7 @@ impl HelpData { pub fn random() -> Self { HelpData { offset: 7_usize, + end: 7_usize, file_name: "asfdlwone".to_string(), } } @@ -91,8 +108,10 @@ impl HelpData { impl From> for HelpData { fn from(ls: LocatedSpan<&str, String>) -> Self { + let offset = ls.location_offset(); HelpData { - offset: ls.location_offset(), + offset, + end: offset, file_name: ls.extra, } } @@ -101,7 +120,13 @@ impl From> for HelpData { impl From> for HelpData { fn from(val: Vec) -> Self { if !val.is_empty() { - val[0].clone().into() + let first = val[0].get_help_data(); + let last = val[val.len() - 1].get_help_data(); + HelpData { + offset: first.offset, + end: last.end, + file_name: first.file_name, + } } else { HelpData::default() } diff --git a/crates/typr-core/src/components/language/mod.rs b/crates/typr-core/src/components/language/mod.rs index 1bfad3b..6bea4b5 100644 --- a/crates/typr-core/src/components/language/mod.rs +++ b/crates/typr-core/src/components/language/mod.rs @@ -1035,6 +1035,78 @@ impl Lang { .clone() } + /// Sets the end offset (§7.3 of `visualization_graph_v2.md`) of this node's `HelpData` in + /// place. Called by the parser's per-production wrappers (the `_impl`/public-name split in + /// `processes/parsing`) once a node's own production has fully consumed its input. + pub fn set_help_data_end(&mut self, end: usize) { + let h: &mut HelpData = match self { + Lang::Number { help_data: h, .. } => h, + Lang::Integer { help_data: h, .. } => h, + Lang::Char { help_data: h, .. } => h, + Lang::Bool { help_data: h, .. } => h, + Lang::Union(_, _, h) => h, + Lang::Scope { help_data: h, .. } => h, + Lang::Function { help_data: h, .. } => h, + Lang::Module { help_data: h, .. } => h, + Lang::Variable { help_data: h, .. } => h, + Lang::FunctionApp { help_data: h, .. } => h, + Lang::VecFunctionApp { help_data: h, .. } => h, + Lang::ArrayIndexing { help_data: h, .. } => h, + Lang::Let { help_data: h, .. } => h, + Lang::Array { help_data: h, .. } => h, + Lang::List { help_data: h, .. } => h, + Lang::DataFrame { help_data: h, .. } => h, + Lang::Alias { help_data: h, .. } => h, + Lang::Tag { help_data: h, .. } => h, + Lang::If { help_data: h, .. } => h, + Lang::Match { help_data: h, .. } => h, + Lang::Tuple { help_data: h, .. } => h, + Lang::Lines { help_data: h, .. } => h, + Lang::Assign { help_data: h, .. } => h, + Lang::Comment { help_data: h, .. } => h, + Lang::ModuleImport { help_data: h, .. } => h, + Lang::ImportFrom { help_data: h, .. } => h, + Lang::Import { help_data: h, .. } => h, + Lang::GenFunc { help_data: h, .. } => h, + Lang::Test { help_data: h, .. } => h, + Lang::Return { help_data: h, .. } => h, + Lang::VecBlock { help_data: h, .. } => h, + Lang::RBlock { help_data: h, .. } => h, + Lang::Lambda { help_data: h, .. } => h, + Lang::Library { help_data: h, .. } => h, + Lang::Exp { help_data: h, .. } => h, + Lang::Empty(h) => h, + Lang::Signature { help_data: h, .. } => h, + Lang::TypeConstructor { help_data: h, .. } => h, + Lang::ForLoop { help_data: h, .. } => h, + Lang::RFunction { help_data: h, .. } => h, + Lang::ExternBlock { help_data: h, .. } => h, + Lang::KeyValue { help_data: h, .. } => h, + Lang::Vector { help_data: h, .. } => h, + Lang::Not { help_data: h, .. } => h, + Lang::Sequence { help_data: h, .. } => h, + Lang::TestBlock { help_data: h, .. } => h, + Lang::JSBlock(_, _, h) => h, + Lang::Use { help_data: h, .. } => h, + Lang::WhileLoop { help_data: h, .. } => h, + Lang::Loop { help_data: h, .. } => h, + Lang::Break(h) => h, + Lang::Next(h) => h, + Lang::Operator { help_data: h, .. } => h, + Lang::TypePattern { help_data: h, .. } => h, + Lang::Null(h) => h, + Lang::NA(h) => h, + Lang::Dots(h) => h, + Lang::UseModule { help_data: h, .. } => h, + Lang::ConstructorCall { help_data: h, .. } => h, + Lang::UnionConstructor { help_data: h, .. } => h, + Lang::ArrayConstructorCall { help_data: h, .. } => h, + Lang::ValidatingCast { help_data: h, .. } => h, + Lang::PartialApp { help_data: h, .. } => h, + }; + h.set_end(end); + } + pub fn linearize_array(&self) -> Vec { match self { Lang::Array { value: v, .. } => v.iter().fold(Vec::::new(), |acc, x: &Lang| { diff --git a/crates/typr-core/src/processes/parsing/elements.rs b/crates/typr-core/src/processes/parsing/elements.rs index ec80778..2e7b6e5 100644 --- a/crates/typr-core/src/processes/parsing/elements.rs +++ b/crates/typr-core/src/processes/parsing/elements.rs @@ -62,6 +62,12 @@ pub fn is_pascal_case(name: &str) -> bool { } fn number_helper(s: Span) -> IResult { + let (rest, mut lang) = number_helper_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn number_helper_impl(s: Span) -> IResult { let res = (opt(tag("-")), digit1, tag("."), digit1).parse(s); match res { Ok((s, (sign, d1, _dot, d2))) => { @@ -80,10 +86,22 @@ fn number_helper(s: Span) -> IResult { } pub fn number(s: Span) -> IResult { + let (rest, mut lang) = number_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn number_impl(s: Span) -> IResult { terminated(number_helper, multispace0).parse(s) } fn integer(s: Span) -> IResult { + let (rest, mut lang) = integer_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn integer_impl(s: Span) -> IResult { let res = terminated((opt(tag("-")), digit1), multispace0).parse(s); match res { Ok((s, (minus, d))) => { @@ -120,6 +138,12 @@ fn get_value(l: LocatedSpan<&str, String>) -> Lang { } fn null_value(s: Span) -> IResult { + let (rest, mut lang) = null_value_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn null_value_impl(s: Span) -> IResult { let res = alt(( terminated(terminated(tag("NULL"), not(body_char)), multispace0), terminated(terminated(tag("null"), not(body_char)), multispace0), @@ -132,6 +156,12 @@ fn null_value(s: Span) -> IResult { } fn na_value(s: Span) -> IResult { + let (rest, mut lang) = na_value_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn na_value_impl(s: Span) -> IResult { let res = alt(( terminated(terminated(tag("NA"), not(body_char)), multispace0), terminated(terminated(tag("na"), not(body_char)), multispace0), @@ -144,6 +174,12 @@ fn na_value(s: Span) -> IResult { } fn boolean(s: Span) -> IResult { + let (rest, mut lang) = boolean_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn boolean_impl(s: Span) -> IResult { let res = alt(( terminated(terminated(tag("true"), not(body_char)), multispace0), terminated(terminated(tag("TRUE"), not(body_char)), multispace0), @@ -158,6 +194,12 @@ fn boolean(s: Span) -> IResult { } pub fn chars(s: Span) -> IResult { + let (rest, mut lang) = chars_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn chars_impl(s: Span) -> IResult { terminated(alt((double_quotes, single_quotes)), multispace0).parse(s) } @@ -191,6 +233,12 @@ pub fn decode_escapes(s: &str) -> String { } pub fn double_quotes(input: Span) -> IResult { + let (rest, mut lang) = double_quotes_impl(input)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn double_quotes_impl(input: Span) -> IResult { let res = delimited(char('"'), opt(escaped(is_not("\\\""), '\\', anychar)), char('"')).parse(input); match res { Ok((s, st)) => { @@ -209,6 +257,12 @@ pub fn double_quotes(input: Span) -> IResult { } pub fn single_quotes(input: Span) -> IResult { + let (rest, mut lang) = single_quotes_impl(input)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn single_quotes_impl(input: Span) -> IResult { let res = delimited(char('\''), opt(escaped(is_not("\\'"), '\\', anychar)), char('\'')).parse(input); match res { Ok((s, st)) => { @@ -380,6 +434,12 @@ fn parse_nested_braces(input: Span) -> IResult { } pub fn r_function(s: Span) -> IResult { + let (rest, mut lang) = r_function_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn r_function_impl(s: Span) -> IResult { let res = ( terminated(alt((tag("function"), tag("\\"))), multispace0), terminated(tag("("), multispace0), @@ -418,6 +478,12 @@ pub fn r_function(s: Span) -> IResult { /// straight through at transpile time with no wrapper/call — see /// `Lang::RBlock` in `transpiling/mod.rs`. pub fn r_block(s: Span) -> IResult { + let (rest, mut lang) = r_block_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn r_block_impl(s: Span) -> IResult { let res = (terminated(tag("R"), multispace0), terminated(parse_block, multispace0)).parse(s); match res { Ok((s, (kw, body))) => Ok(( @@ -446,6 +512,12 @@ fn raw_r_string(s: Span) -> IResult { // Return type must be a single type token (not a union/function type inline) — // use a type alias for complex return types. pub fn extern_block(s: Span) -> IResult { + let (rest, mut lang) = extern_block_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn extern_block_impl(s: Span) -> IResult { let res = ( terminated(tag("extern"), multispace1), terminated(tag("("), multispace0), @@ -471,6 +543,12 @@ pub fn extern_block(s: Span) -> IResult { } pub fn simple_function(s: Span) -> IResult { + let (rest, mut lang) = simple_function_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn simple_function_impl(s: Span) -> IResult { let res = ( terminated(tag("fn"), multispace0), terminated(tag("("), multispace0), @@ -511,10 +589,22 @@ pub fn simple_function(s: Span) -> IResult { } fn function(s: Span) -> IResult { + let (rest, mut lang) = function_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn function_impl(s: Span) -> IResult { simple_function.parse(s) } fn key_value(s: Span) -> IResult { + let (rest, mut lang) = key_value_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn key_value_impl(s: Span) -> IResult { let res = (recognize(variable), terminated(tag("="), multispace0), single_element).parse(s); match res { Ok((s, (v, _eq, el))) => Ok(( @@ -530,6 +620,13 @@ fn key_value(s: Span) -> IResult { } fn values(s: Span) -> IResult> { + let (rest, mut v) = values_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn values_impl(s: Span) -> IResult> { many0(terminated( alt((key_value, parse_elements)), terminated(opt(tag(",")), multispace0), @@ -538,6 +635,12 @@ fn values(s: Span) -> IResult> { } pub fn variable2(s: Span) -> IResult { + let (rest, mut lang) = variable2_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn variable2_impl(s: Span) -> IResult { let res = variable.parse(s); match res { Ok((s, (lang, _))) => Ok((s, lang)), @@ -546,6 +649,12 @@ pub fn variable2(s: Span) -> IResult { } fn array_indexing(s: Span) -> IResult { + let (rest, mut lang) = array_indexing_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn array_indexing_impl(s: Span) -> IResult { let res = (alt((scope, variable2)), array).parse(s); match res { @@ -562,6 +671,12 @@ fn array_indexing(s: Span) -> IResult { } fn dataframe_exp(s: Span) -> IResult { + let (rest, mut lang) = dataframe_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn dataframe_exp_impl(s: Span) -> IResult { let res = ( alt((tag("data__frame"), tag("data.frame"))), terminated(tag("("), multispace0), @@ -582,6 +697,12 @@ fn dataframe_exp(s: Span) -> IResult { } fn function_application(s: Span) -> IResult { + let (rest, mut lang) = function_application_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn function_application_impl(s: Span) -> IResult { let res = ( alt((scope, variable2)), terminated(tag("("), multispace0), @@ -603,6 +724,12 @@ fn function_application(s: Span) -> IResult { } fn array(s: Span) -> IResult { + let (rest, mut lang) = array_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn array_impl(s: Span) -> IResult { let res = ( terminated(tag("["), multispace0), values, @@ -622,6 +749,12 @@ fn array(s: Span) -> IResult { } pub fn vector(s: Span) -> IResult { + let (rest, mut lang) = vector_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn vector_impl(s: Span) -> IResult { let res = ( terminated(tag("c("), multispace0), values, @@ -641,6 +774,12 @@ pub fn vector(s: Span) -> IResult { } fn sequence(s: Span) -> IResult { + let (rest, mut lang) = sequence_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn sequence_impl(s: Span) -> IResult { let res = ( terminated(tag("seq["), multispace0), values, @@ -728,6 +867,12 @@ fn constructor_field(s: Span) -> IResult { } fn constructor_call(s: Span) -> IResult { + let (rest, mut lang) = constructor_call_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn constructor_call_impl(s: Span) -> IResult { let res = ( many0(terminated(variable_exp, tag("$"))), pascal_case, @@ -773,6 +918,12 @@ fn constructor_call(s: Span) -> IResult { } fn array_constructor_call(s: Span) -> IResult { + let (rest, mut lang) = array_constructor_call_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn array_constructor_call_impl(s: Span) -> IResult { let res = ( pascal_case, tag(":["), @@ -828,6 +979,12 @@ fn record_field(s: Span) -> IResult { } pub fn record(s: Span) -> IResult { + let (rest, mut lang) = record_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn record_impl(s: Span) -> IResult { let res = ( opt(terminated(record_identifier, multispace0)), terminated(alt((tag("{"), tag("("))), multispace0), @@ -880,6 +1037,12 @@ pub fn record(s: Span) -> IResult { /// heavily-used way to build positional tuples (see `tuple_exp`, which keeps handling it /// unchanged) and must not be affected. fn keyword_positional_record_exp(s: Span) -> IResult { + let (rest, mut lang) = keyword_positional_record_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn keyword_positional_record_exp_impl(s: Span) -> IResult { let res = ( terminated(alt((tag("list"), tag("record"), tag("object"))), multispace0), terminated(tag("{"), multispace0), @@ -921,6 +1084,12 @@ fn pascal_case(s: Span) -> IResult { } fn union_constructor(s: Span) -> IResult { + let (rest, mut lang) = union_constructor_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn union_constructor_impl(s: Span) -> IResult { let res = ( pascal_case, terminated(tag("."), multispace0), @@ -957,6 +1126,12 @@ fn union_constructor(s: Span) -> IResult { } fn parenthese_value(s: Span) -> IResult { + let (rest, mut lang) = parenthese_value_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn parenthese_value_impl(s: Span) -> IResult { delimited( terminated(tag("("), multispace0), parse_elements, @@ -966,6 +1141,12 @@ fn parenthese_value(s: Span) -> IResult { } pub fn tag_exp(s: Span) -> IResult { + let (rest, mut lang) = tag_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn tag_exp_impl(s: Span) -> IResult { let res = terminated((tag("."), pascal_case, opt(parenthese_value)), multispace0).parse(s); match res { Ok((s, (dot, (n, _h), None))) => Ok(( @@ -989,6 +1170,12 @@ pub fn tag_exp(s: Span) -> IResult { } fn dotdotdot(s: Span) -> IResult { + let (rest, mut lang) = dotdotdot_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn dotdotdot_impl(s: Span) -> IResult { let res = terminated(tag("..."), multispace0).parse(s); match res { Ok((s, d)) => Ok((s, Lang::Empty(d.into()))), @@ -997,6 +1184,12 @@ fn dotdotdot(s: Span) -> IResult { } fn else_exp(s: Span) -> IResult { + let (rest, mut lang) = else_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn else_exp_impl(s: Span) -> IResult { let res = ( terminated(tag("else"), multispace0), terminated(tag("{"), multispace0), @@ -1011,10 +1204,22 @@ fn else_exp(s: Span) -> IResult { } fn else_if_exp(s: Span) -> IResult { + let (rest, mut lang) = else_if_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn else_if_exp_impl(s: Span) -> IResult { preceded(terminated(tag("else"), multispace1), if_exp).parse(s) } fn if_exp(s: Span) -> IResult { + let (rest, mut lang) = if_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn if_exp_impl(s: Span) -> IResult { let res = ( terminated(tag("if"), multispace0), terminated(tag("("), multispace0), @@ -1042,6 +1247,12 @@ fn if_exp(s: Span) -> IResult { /// Parse a tag pattern with a variable binding in parentheses: `.Some(a)` fn tag_pattern_with_var(s: Span) -> IResult { + let (rest, mut lang) = tag_pattern_with_var_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn tag_pattern_with_var_impl(s: Span) -> IResult { let res = ( tag("."), pascal_case, @@ -1067,6 +1278,12 @@ fn tag_pattern_with_var(s: Span) -> IResult { /// Parse a tag pattern without binding: `.None` fn tag_pattern_no_var(s: Span) -> IResult { + let (rest, mut lang) = tag_pattern_no_var_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn tag_pattern_no_var_impl(s: Span) -> IResult { let res = (tag("."), pascal_case).parse(s); match res { Ok((s, (dot, (n, _h)))) => Ok(( @@ -1083,6 +1300,12 @@ fn tag_pattern_no_var(s: Span) -> IResult { /// Parse a wildcard pattern: `_` fn wildcard_pattern(s: Span) -> IResult { + let (rest, mut lang) = wildcard_pattern_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn wildcard_pattern_impl(s: Span) -> IResult { let res = terminated(tag("_"), multispace0).parse(s); match res { Ok((s, underscore)) => Ok(( @@ -1100,6 +1323,12 @@ fn wildcard_pattern(s: Span) -> IResult { /// Parse a type pattern with a variable binding: `x as int`, `y as bool`, etc. fn type_pattern(s: Span) -> IResult { + let (rest, mut lang) = type_pattern_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn type_pattern_impl(s: Span) -> IResult { let res = ( terminated(variable_exp, multispace0), terminated(tag("as"), multispace1), @@ -1121,6 +1350,12 @@ fn type_pattern(s: Span) -> IResult { /// Parse a match pattern: `.Some(a)`, `.None`, `x as int`, `:{nom: n}`, `:{a, b}`, `_`, or a variable fn match_pattern(s: Span) -> IResult { + let (rest, mut lang) = match_pattern_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn match_pattern_impl(s: Span) -> IResult { terminated( alt(( tag_pattern_with_var, @@ -1155,6 +1390,12 @@ fn pattern_branch(s: Span) -> IResult)> { /// Parse a match expression with pattern matching: /// `match expr { .Some(a) => a, .None => 0, _ => default }` fn match_exp(s: Span) -> IResult { + let (rest, mut lang) = match_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn match_exp_impl(s: Span) -> IResult { let res = ( terminated(tag("match"), multispace1), terminated(alt((scope, variable2)), multispace0), @@ -1177,6 +1418,12 @@ fn match_exp(s: Span) -> IResult { } pub fn tuple_exp(s: Span) -> IResult { + let (rest, mut lang) = tuple_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn tuple_exp_impl(s: Span) -> IResult { let res = ( terminated(alt((tag("list"), tag(":"))), multispace0), terminated(alt((tag("{"), tag("("))), multispace0), @@ -1197,6 +1444,12 @@ pub fn tuple_exp(s: Span) -> IResult { } fn int_or_var(s: Span) -> IResult { + let (rest, mut lang) = int_or_var_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn int_or_var_impl(s: Span) -> IResult { alt((integer, variable2)).parse(s) } @@ -1224,6 +1477,12 @@ fn create_range(params: &[Lang]) -> Lang { } fn range(s: Span) -> IResult { + let (rest, mut lang) = range_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn range_impl(s: Span) -> IResult { let res = (int_or_var, tag(":"), opt(terminated(int_or_var, tag(":"))), int_or_var).parse(s); //from_name().to_language() match res { @@ -1234,6 +1493,12 @@ fn range(s: Span) -> IResult { } fn function_application2(s: Span) -> IResult { + let (rest, mut lang) = function_application2_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn function_application2_impl(s: Span) -> IResult { let res = recognize(function_application).parse(s); match res { Ok((s, fun_app)) => Ok(( @@ -1248,6 +1513,12 @@ fn function_application2(s: Span) -> IResult { } fn dot_variable(s: Span) -> IResult { + let (rest, mut lang) = dot_variable_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn dot_variable_impl(s: Span) -> IResult { let res = preceded(tag("."), variable2).parse(s); match res { Ok(( @@ -1295,6 +1566,12 @@ fn element_operator2(s: Span) -> IResult { } fn vectorial_bloc(s: Span) -> IResult { + let (rest, mut lang) = vectorial_bloc_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn vectorial_bloc_impl(s: Span) -> IResult { let res = ( terminated(tag("@{"), multispace0), recognize(many1(element_operator2)), @@ -1319,6 +1596,12 @@ fn vectorial_bloc(s: Span) -> IResult { /// trying this combinator first and falling back to `lambda` on failure /// (via the `alt()` in `single_element`) disambiguates the two unambiguously. fn partial_application(s: Span) -> IResult { + let (rest, mut lang) = partial_application_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn partial_application_impl(s: Span) -> IResult { let res = ( tag("\\"), variable2, @@ -1351,6 +1634,12 @@ fn partial_application(s: Span) -> IResult { /// `argument_val`'s grammar (same as `constructor_call`), so `:` or `=` both /// work as the field separator. fn partial_constructor_application(s: Span) -> IResult { + let (rest, mut lang) = partial_constructor_application_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn partial_constructor_application_impl(s: Span) -> IResult { let res = ( tag("\\"), pascal_case, @@ -1389,6 +1678,12 @@ fn partial_constructor_application(s: Span) -> IResult { } fn lambda(s: Span) -> IResult { + let (rest, mut lang) = lambda_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn lambda_impl(s: Span) -> IResult { let res = ( tag("\\"), terminated(tag("("), multispace0), @@ -1411,6 +1706,12 @@ fn lambda(s: Span) -> IResult { } fn not_exp(s: Span) -> IResult { + let (rest, mut lang) = not_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn not_exp_impl(s: Span) -> IResult { let res = ( tag("!"), alt(( @@ -1451,10 +1752,22 @@ fn not_exp(s: Span) -> IResult { } fn array_variant(s: Span) -> IResult { + let (rest, mut lang) = array_variant_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn array_variant_impl(s: Span) -> IResult { alt((vector, sequence)).parse(s) } fn js_block(s: Span) -> IResult { + let (rest, mut lang) = js_block_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn js_block_impl(s: Span) -> IResult { let res = (terminated(tag("JS"), multispace0), scope).parse(s); match res { @@ -1464,10 +1777,22 @@ fn js_block(s: Span) -> IResult { } fn primitive(s: Span) -> IResult { + let (rest, mut lang) = primitive_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn primitive_impl(s: Span) -> IResult { alt((null_value, na_value, boolean, number, integer, chars)).parse(s) } pub fn return_exp(s: Span) -> IResult { + let (rest, mut lang) = return_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn return_exp_impl(s: Span) -> IResult { let res = terminated(delimited(tag("return "), parse_elements, tag(";")), multispace0).parse(s); match res { Ok((s, el)) => Ok(( @@ -1482,6 +1807,13 @@ pub fn return_exp(s: Span) -> IResult { } pub fn break_exp(s: Span) -> IResult> { + let (rest, mut v) = break_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn break_exp_impl(s: Span) -> IResult> { let res = tag("break;").parse(s); match res { Ok((s, el)) => Ok((s, vec![Lang::Break(el.into())])), @@ -1490,6 +1822,13 @@ pub fn break_exp(s: Span) -> IResult> { } pub fn next_exp(s: Span) -> IResult> { + let (rest, mut v) = next_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn next_exp_impl(s: Span) -> IResult> { let res = tag("next;").parse(s); match res { Ok((s, el)) => Ok((s, vec![Lang::Next(el.into())])), @@ -1499,6 +1838,12 @@ pub fn next_exp(s: Span) -> IResult> { // main pub fn single_element(s: Span) -> IResult { + let (rest, mut lang) = single_element_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn single_element_impl(s: Span) -> IResult { alt(( alt(( not_exp, @@ -1538,6 +1883,12 @@ pub fn single_element(s: Span) -> IResult { } pub fn scope(s: Span) -> IResult { + let (rest, mut lang) = scope_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn scope_impl(s: Span) -> IResult { let res = ( terminated(alt((tag("("), tag("{"))), multispace0), opt(base_parse), @@ -1641,6 +1992,12 @@ fn operator_like_token(s: Span) -> IResult { } pub fn elements(s: Span) -> IResult { + let (rest, mut lang) = elements_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn elements_impl(s: Span) -> IResult { let res = ( single_element_token, many0(pair(operator_like_token, single_element_token)), @@ -1665,6 +2022,12 @@ pub fn elements(s: Span) -> IResult { // main pub fn parse_elements(s: Span) -> IResult { + let (rest, mut lang) = parse_elements_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn parse_elements_impl(s: Span) -> IResult { alt((vectorial_bloc, elements)).parse(s) } diff --git a/crates/typr-core/src/processes/parsing/mod.rs b/crates/typr-core/src/processes/parsing/mod.rs index b44d888..5a75767 100644 --- a/crates/typr-core/src/processes/parsing/mod.rs +++ b/crates/typr-core/src/processes/parsing/mod.rs @@ -153,6 +153,12 @@ fn pattern_var(s: Span) -> IResult, Option)> { } fn single_parse(s: Span) -> IResult { + let (rest, mut lang) = single_parse_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn single_parse_impl(s: Span) -> IResult { let res = (parse_elements, opt(terminated(tag(";"), multispace0))).parse(s); match res { Ok((s, (exp, Some(_)))) => Ok((s, exp)), @@ -169,6 +175,13 @@ fn equality_operator(s: Span) -> IResult { } fn base_let_exp(s: Span) -> IResult> { + let (rest, mut v) = base_let_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn base_let_exp_impl(s: Span) -> IResult> { let res = ( terminated(tag("let"), multispace0), pattern_var, @@ -289,6 +302,13 @@ fn base_let_exp(s: Span) -> IResult> { } fn let_tuple_exp(s: Span) -> IResult> { + let (rest, mut v) = let_tuple_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn let_tuple_exp_impl(s: Span) -> IResult> { let res = ( terminated(tag("let"), multispace0), tuple_exp, @@ -377,6 +397,13 @@ fn let_tuple_exp(s: Span) -> IResult> { } fn let_exp(s: Span) -> IResult> { + let (rest, mut v) = let_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn let_exp_impl(s: Span) -> IResult> { let res = ( opt(terminated( alt((tag("@export"), tag("@pub"), tag("@testable"))), @@ -431,6 +458,13 @@ fn let_exp(s: Span) -> IResult> { /// `typeconstructor Tibble[N] record;` /// `typeconstructor Matrix[N, M, T] recursive;` fn typeconstructor_exp(s: Span) -> IResult> { + let (rest, mut v) = typeconstructor_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn typeconstructor_exp_impl(s: Span) -> IResult> { let res = ( terminated(tag("typeconstructor"), multispace0), pascal_case_no_space, @@ -473,6 +507,13 @@ fn typeconstructor_exp(s: Span) -> IResult> { /// lost from the AST, but flags it as a fatal `SingleLetterTypeName` error: single-letter /// alias names are forbidden outright (not legalized) to avoid colliding with generics. fn single_letter_type_name_exp(s: Span) -> IResult> { + let (rest, mut v) = single_letter_type_name_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn single_letter_type_name_exp_impl(s: Span) -> IResult> { let res = ( opt(terminated(alt((tag("@export"), tag("@pub"))), multispace0)), terminated(alt((tag("type"), tag("opaque"))), multispace0), @@ -514,6 +555,12 @@ fn single_letter_type_name_exp(s: Span) -> IResult> { } fn base_type_exp(s: Span) -> IResult { + let (rest, mut lang) = base_type_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn base_type_exp_impl(s: Span) -> IResult { let res = ( terminated(tag("type"), multispace0), type_alias, @@ -550,6 +597,13 @@ fn base_type_exp(s: Span) -> IResult { } fn type_exp(s: Span) -> IResult> { + let (rest, mut v) = type_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn type_exp_impl(s: Span) -> IResult> { let res = ( opt(terminated(alt((tag("@export"), tag("@pub"))), multispace0)), base_type_exp, @@ -618,6 +672,13 @@ fn type_exp(s: Span) -> IResult> { /// Detects `type <- ` which should be `let` instead. /// Parses the full expression and returns a `Lang::Let` with a push_parse_error. fn type_instead_of_let_exp(s: Span) -> IResult> { + let (rest, mut v) = type_instead_of_let_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn type_instead_of_let_exp_impl(s: Span) -> IResult> { let res = ( opt(terminated(tag("@pub"), multispace0)), terminated(tag("type"), multispace0), @@ -656,6 +717,12 @@ fn type_instead_of_let_exp(s: Span) -> IResult> { } fn base_opaque_exp(s: Span) -> IResult { + let (rest, mut lang) = base_opaque_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn base_opaque_exp_impl(s: Span) -> IResult { let res = ( terminated(tag("opaque"), multispace0), type_alias, @@ -688,6 +755,13 @@ fn base_opaque_exp(s: Span) -> IResult { } fn opaque_exp(s: Span) -> IResult> { + let (rest, mut v) = opaque_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn opaque_exp_impl(s: Span) -> IResult> { let res = ( opt(terminated(alt((tag("@export"), tag("@pub"))), multispace0)), base_opaque_exp, @@ -759,6 +833,13 @@ fn opaque_exp(s: Span) -> IResult> { } pub fn module(s: Span) -> IResult> { + let (rest, mut v) = module_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn module_impl(s: Span) -> IResult> { let res = ( terminated(tag("module"), multispace0), terminated(variable_recognizer, multispace0), @@ -784,6 +865,13 @@ pub fn module(s: Span) -> IResult> { } fn import_module(s: Span) -> IResult> { + let (rest, mut v) = import_module_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn import_module_impl(s: Span) -> IResult> { let res = ( terminated(tag("import"), multispace0), terminated(variable_recognizer, multispace0), @@ -828,6 +916,13 @@ fn import_module(s: Span) -> IResult> { } fn import_from_exp(s: Span) -> IResult> { + let (rest, mut v) = import_from_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn import_from_exp_impl(s: Span) -> IResult> { let res = ( terminated(tag("@importFrom"), multispace1), terminated( @@ -855,6 +950,13 @@ fn import_from_exp(s: Span) -> IResult> { } fn assign(s: Span) -> IResult> { + let (rest, mut v) = assign_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn assign_impl(s: Span) -> IResult> { let res = ( variable, alt((terminated(tag("="), multispace0), terminated(tag("<-"), multispace0))), @@ -885,6 +987,13 @@ fn assign(s: Span) -> IResult> { } fn comment(s: Span) -> IResult> { + let (rest, mut v) = comment_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn comment_impl(s: Span) -> IResult> { let res = (tag("#"), not_line_ending, opt(line_ending), multispace0).parse(s); match res { Ok((s, (_hashtag, txt, _, _))) => Ok(( @@ -909,6 +1018,13 @@ fn comment(s: Span) -> IResult> { /// from the AST instead — this dedicated parser keeps the common typo both /// harmless (no code loss) and visible (a located warning). fn wrong_comment(s: Span) -> IResult> { + let (rest, mut v) = wrong_comment_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn wrong_comment_impl(s: Span) -> IResult> { let res = (tag("//"), not_line_ending, opt(line_ending), multispace0).parse(s); match res { Ok((s, (slashes, txt, _, _))) => { @@ -926,6 +1042,13 @@ fn wrong_comment(s: Span) -> IResult> { } pub fn simple_exp(s: Span) -> IResult> { + let (rest, mut v) = simple_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn simple_exp_impl(s: Span) -> IResult> { let res = (parse_elements, opt(terminated(tag(";"), multispace0))).parse(s); match res { Ok((s, (lang, Some(_)))) => Ok((s, vec![lang])), @@ -938,6 +1061,13 @@ pub fn simple_exp(s: Span) -> IResult> { } fn mod_imp(s: Span) -> IResult> { + let (rest, mut v) = mod_imp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn mod_imp_impl(s: Span) -> IResult> { let res = ( terminated(tag("mod"), multispace0), terminated(variable_exp, multispace0), @@ -957,6 +1087,13 @@ fn mod_imp(s: Span) -> IResult> { } fn import_var(s: Span) -> IResult> { + let (rest, mut v) = import_var_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn import_var_impl(s: Span) -> IResult> { let res = ( terminated(tag("use"), multispace0), variable, @@ -976,6 +1113,13 @@ fn import_var(s: Span) -> IResult> { } fn import_type(s: Span) -> IResult> { + let (rest, mut v) = import_type_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn import_type_impl(s: Span) -> IResult> { let res = ( terminated(tag("use"), multispace0), type_alias, @@ -996,6 +1140,13 @@ fn import_type(s: Span) -> IResult> { } fn tests(s: Span) -> IResult> { + let (rest, mut v) = tests_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn tests_impl(s: Span) -> IResult> { let res = (tag("Test"), delimited(tag("["), base_parse, tag("]"))).parse(s); match res { Ok((s, (_t, body))) => Ok(( @@ -1010,6 +1161,13 @@ fn tests(s: Span) -> IResult> { } fn library(s: Span) -> IResult> { + let (rest, mut v) = library_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn library_impl(s: Span) -> IResult> { let res = (tag("library("), variable_exp, tag(")"), opt(tag(";")), multispace0).parse(s); match res { @@ -1072,6 +1230,13 @@ fn use_selector_exp(s: Span) -> IResult IResult> { + let (rest, mut v) = use_module_directive_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn use_module_directive_impl(s: Span) -> IResult> { use crate::components::language::use_lang::UseSelector; // Parse: use FirstSeg :: ... @@ -1130,6 +1295,13 @@ fn use_module_directive(s: Span) -> IResult> { } fn use_exp(s: Span) -> IResult> { + let (rest, mut v) = use_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn use_exp_impl(s: Span) -> IResult> { let res = ( tag("use("), chars, @@ -1160,11 +1332,25 @@ fn custom_operators(s: Span) -> IResult { } fn return_stmt(s: Span) -> IResult> { + let (rest, mut v) = return_stmt_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn return_stmt_impl(s: Span) -> IResult> { let (s, e) = return_exp(s)?; Ok((s, vec![e])) } fn stmt_exp(s: Span) -> IResult> { + let (rest, mut v) = stmt_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn stmt_exp_impl(s: Span) -> IResult> { let res = (parse_elements, terminated(tag(";"), multispace0)).parse(s); match res { Ok((s, (lang, _))) => Ok((s, vec![lang])), @@ -1185,6 +1371,13 @@ fn head_lang(lang: &Lang) -> Option { } fn implicit_mutate(s: Span) -> IResult> { + let (rest, mut v) = implicit_mutate_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn implicit_mutate_impl(s: Span) -> IResult> { let res = (parse_elements, terminated(tag("!;"), multispace0)).parse(s); match res { Ok((s, (expr, excl))) => match head_lang(&expr) { @@ -1206,6 +1399,13 @@ fn implicit_mutate(s: Span) -> IResult> { } fn signature_variable(s: Span) -> IResult> { + let (rest, mut v) = signature_variable_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn signature_variable_impl(s: Span) -> IResult> { let res = ( tag("@"), alt((variable_recognizer, custom_operators)), @@ -1233,6 +1433,13 @@ fn signature_variable(s: Span) -> IResult> { } fn signature_opaque(s: Span) -> IResult> { + let (rest, mut v) = signature_opaque_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn signature_opaque_impl(s: Span) -> IResult> { let res = ( tag("@"), type_alias, @@ -1261,6 +1468,13 @@ fn signature_opaque(s: Span) -> IResult> { } fn signature_extern(s: Span) -> IResult> { + let (rest, mut v) = signature_extern_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn signature_extern_impl(s: Span) -> IResult> { let res = ( tag("@extern"), multispace1, @@ -1294,10 +1508,24 @@ fn signature_extern(s: Span) -> IResult> { } pub fn signature(s: Span) -> IResult> { + let (rest, mut v) = signature_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn signature_impl(s: Span) -> IResult> { alt((signature_extern, signature_opaque, signature_variable)).parse(s) } fn for_loop(s: Span) -> IResult> { + let (rest, mut v) = for_loop_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn for_loop_impl(s: Span) -> IResult> { let res = ( terminated(tag("for"), multispace0), terminated(tag("("), multispace0), @@ -1324,6 +1552,13 @@ fn for_loop(s: Span) -> IResult> { } fn while_loop(s: Span) -> IResult> { + let (rest, mut v) = while_loop_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn while_loop_impl(s: Span) -> IResult> { let res = ( terminated(tag("while"), multispace0), terminated(tag("("), multispace0), @@ -1347,6 +1582,13 @@ fn while_loop(s: Span) -> IResult> { } fn loop_loop(s: Span) -> IResult> { + let (rest, mut v) = loop_loop_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn loop_loop_impl(s: Span) -> IResult> { let res = ( terminated(tag("loop"), multispace0), scope, @@ -1366,6 +1608,13 @@ fn loop_loop(s: Span) -> IResult> { } fn test_block(s: Span) -> IResult> { + let (rest, mut v) = test_block_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn test_block_impl(s: Span) -> IResult> { let res = (terminated(tag("Test"), multispace0), scope).parse(s); //parse_block).parse(s); @@ -1383,6 +1632,13 @@ fn test_block(s: Span) -> IResult> { // main pub fn base_parse(s: Span) -> IResult> { + let (rest, mut v) = base_parse_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn base_parse_impl(s: Span) -> IResult> { let res = ( opt(multispace0), many0(alt(( diff --git a/crates/typr-core/src/processes/type_checking/mod.rs b/crates/typr-core/src/processes/type_checking/mod.rs index 2daebaa..fc3d38d 100644 --- a/crates/typr-core/src/processes/type_checking/mod.rs +++ b/crates/typr-core/src/processes/type_checking/mod.rs @@ -17,6 +17,7 @@ pub mod type_arithmetic; pub mod type_checker; pub mod type_comparison; pub mod type_context; +pub mod type_recorder; pub mod unification; pub mod unification_map; pub mod vectorizability; @@ -1134,6 +1135,12 @@ pub fn validate_forced_dispatch(context: &Context, var: &Var) -> Option TypeContext { + let result = typing_impl(context, expr); + type_recorder::record(expr, &result.value); + result +} + +fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { match expr { Lang::Number { value: n, help_data: h } => ( Type::Number(crate::components::r#type::tnumber::Tnum::Val(*n), h.clone()), diff --git a/crates/typr-core/src/processes/type_checking/type_recorder.rs b/crates/typr-core/src/processes/type_checking/type_recorder.rs new file mode 100644 index 0000000..3df1ee6 --- /dev/null +++ b/crates/typr-core/src/processes/type_checking/type_recorder.rs @@ -0,0 +1,184 @@ +//! Records the type computed for every expression `typing()` is called on, keyed by source span +//! (see `visualization_graph_v2.md` §7.2 — prerequisite for the block-graph builder and, longer +//! term, for LSP hover/inlay hints). Disabled by default: `typing()` still checks a thread-local +//! on every call, but records nothing unless a `with_recording` scope is active, so the cost when +//! off is one thread-local read and no allocation. +//! +//! WASM is single-threaded, so a `thread_local!` here carries no cross-thread risk. + +use crate::components::error_message::help_data::HelpData; +use crate::components::language::Lang; +use crate::components::r#type::Type; +use std::cell::RefCell; +use std::collections::HashMap; + +/// Identifies a node's source range for recording purposes: `(file, start, end)`. Two distinct +/// nodes at the same offset (there are none in a well-formed parse) would collide; see prereq D +/// (`HelpData.end`) for why `start` alone isn't enough — it's what previously made every prefix of +/// an expression share its parent's key. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SpanKey { + pub file: String, + pub start: usize, + pub end: usize, +} + +impl From<&HelpData> for SpanKey { + fn from(h: &HelpData) -> Self { + SpanKey { + file: h.get_file_name(), + start: h.get_offset(), + end: h.get_end(), + } + } +} + +/// A recorded conflict: `typing()` was called more than once on the same span and produced two +/// different, both non-`Failed`, types. Kept for analysis while validating the recording policy +/// on real cases (§7.2); not surfaced as an error. +#[derive(Debug, Clone)] +pub struct TypeConflict { + pub key: SpanKey, + pub previous: Type, + pub replacement: Type, +} + +#[derive(Debug, Clone, Default)] +pub struct TypeTable { + entries: HashMap, + conflicts: Vec, +} + +/// A type that carries no real information about the node it's attached to — the placeholder +/// `typing()` produces mid-way through a trial (dispatch candidate, forward reference, empty +/// block) rather than the node's actual type. Neither overwrites a firm value, nor counts as a +/// conflict when a firm value overwrites it: observed on the §14 walkthrough example, a function +/// body's `Lines` node is first typed as `UnknownFunction` while its own signature is still being +/// resolved, then re-typed to its real type once that trial completes (see §7.2's "last +/// non-`Failed` value" policy, broadened here after that observation). +fn is_placeholder(t: &Type) -> bool { + matches!(t, Type::Failed(..) | Type::UnknownFunction(..)) +} + +impl TypeTable { + pub fn new() -> Self { + Self::default() + } + + /// Inserts under the policy of §7.2, "last non-`Failed` value wins" (broadened to + /// [`is_placeholder`]): a placeholder result never overwrites a previously recorded firm + /// value, since it carries no information (typing() retries dispatch/unification candidates, + /// and re-types a node while its own signature is still being resolved). Two different firm + /// values for the same span are logged as a conflict but the later one still wins. + pub fn insert(&mut self, key: SpanKey, value: Type) { + match self.entries.get(&key) { + Some(previous) if is_placeholder(&value) && !is_placeholder(previous) => { + // Keep the earlier good value; a placeholder retry carries no information. + } + Some(previous) if *previous != value && !is_placeholder(previous) && !is_placeholder(&value) => { + self.conflicts.push(TypeConflict { + key: key.clone(), + previous: previous.clone(), + replacement: value.clone(), + }); + self.entries.insert(key, value); + } + _ => { + self.entries.insert(key, value); + } + } + } + + pub fn get(&self, key: &SpanKey) -> Option<&Type> { + self.entries.get(key) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn conflicts(&self) -> &[TypeConflict] { + &self.conflicts + } +} + +thread_local! { + static RECORDER: RefCell> = const { RefCell::new(None) }; +} + +/// Runs `f` with recording turned on and returns its result alongside the table filled in by every +/// `typing()` call made while `f` ran. Any recording already in progress on this thread (there is +/// none in current callers) is suspended for the duration and restored afterwards. +pub fn with_recording(f: impl FnOnce() -> R) -> (R, TypeTable) { + let previous = RECORDER.with(|cell| cell.replace(Some(TypeTable::new()))); + let result = f(); + let table = RECORDER.with(|cell| cell.replace(previous)); + (result, table.unwrap_or_default()) +} + +/// Called by `typing()` after computing `value` for `expr`. A no-op (one thread-local read, no +/// allocation) unless a `with_recording` scope is active. +pub fn record(expr: &Lang, value: &Type) { + RECORDER.with(|cell| { + if let Some(table) = cell.borrow_mut().as_mut() { + table.insert(SpanKey::from(&expr.get_help_data()), value.clone()); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::context::Context; + use crate::processes::parsing::parse_from_string; + use crate::processes::type_checking::typing_with_errors; + + // The walkthrough example from `visualization_graph_v2.md` §14, verified there with `typr check`. + const FIL_ROUGE: &str = r#" +type Printable <- interface { show: (Self) -> char }; +type Point <- list { x: int, y: int }; + +let sq <- fn(n: int): int { n * n }; + +let norm2 <- fn(p: Point): int { + let a <- sq(p$x); + a + sq(p$y) +}; + +let show <- fn(p: Point): char { "Point" }; + +let p <- Point:{ x = 3, y = 4 }; +let d <- Printable(p); +let total <- norm2(p) + 12 + 3; +"#; + + #[test] + fn recording_is_disabled_by_default() { + let lang = parse_from_string(FIL_ROUGE, "fil_rouge"); + let _ = typing_with_errors(&Context::default(), &lang); + // No with_recording scope was opened: nothing observable from outside, this just + // exercises the no-op path on the same program the next test records. + } + + #[test] + fn with_recording_captures_every_subexpression_of_the_walkthrough_example() { + let lang = parse_from_string(FIL_ROUGE, "fil_rouge"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + + assert!( + !result.has_errors(), + "walkthrough example should typecheck cleanly: {:?}", + result.display_errors() + ); + assert!(!table.is_empty(), "recording should have captured at least one span"); + assert!( + table.conflicts().is_empty(), + "no dispatch/unification conflicts expected on this example: {:?}", + table.conflicts() + ); + } +} diff --git a/crates/typr-core/tests/snapshots/snapshot_tests__typing__function_type_inference.snap b/crates/typr-core/tests/snapshots/snapshot_tests__typing__function_type_inference.snap index 21bd62b..e6a226e 100644 --- a/crates/typr-core/tests/snapshots/snapshot_tests__typing__function_type_inference.snap +++ b/crates/typr-core/tests/snapshots/snapshot_tests__typing__function_type_inference.snap @@ -1,6 +1,6 @@ --- source: crates/typr-core/tests/snapshot_tests.rs -assertion_line: 283 +assertion_line: 287 expression: ty --- Function( @@ -12,6 +12,7 @@ Function( ), HelpData { offset: 15, + end: 15, file_name: "", }, ), @@ -19,6 +20,7 @@ Function( Unknown, HelpData { offset: 17, + end: 17, file_name: "", }, ), @@ -33,6 +35,7 @@ Function( ), HelpData { offset: 23, + end: 23, file_name: "", }, ), @@ -40,6 +43,7 @@ Function( Unknown, HelpData { offset: 25, + end: 25, file_name: "", }, ), @@ -52,11 +56,13 @@ Function( Unknown, HelpData { offset: 31, + end: 31, file_name: "", }, ), HelpData { offset: 0, + end: 45, file_name: "", }, ) diff --git a/crates/typr-core/tests/snapshots/snapshot_tests__typing__integer_type.snap b/crates/typr-core/tests/snapshots/snapshot_tests__typing__integer_type.snap index 5e094b9..6eeae3b 100644 --- a/crates/typr-core/tests/snapshots/snapshot_tests__typing__integer_type.snap +++ b/crates/typr-core/tests/snapshots/snapshot_tests__typing__integer_type.snap @@ -1,5 +1,6 @@ --- source: crates/typr-core/tests/snapshot_tests.rs +assertion_line: 267 expression: ty --- Integer( @@ -8,6 +9,7 @@ Integer( ), HelpData { offset: 0, + end: 2, file_name: "", }, ) diff --git a/crates/typr-core/tests/snapshots/snapshot_tests__typing__let_introduces_variable.snap b/crates/typr-core/tests/snapshots/snapshot_tests__typing__let_introduces_variable.snap index 36653f0..a690447 100644 --- a/crates/typr-core/tests/snapshots/snapshot_tests__typing__let_introduces_variable.snap +++ b/crates/typr-core/tests/snapshots/snapshot_tests__typing__let_introduces_variable.snap @@ -1,5 +1,6 @@ --- source: crates/typr-core/tests/snapshot_tests.rs +assertion_line: 278 expression: ty --- Alias( @@ -8,6 +9,7 @@ Alias( false, HelpData { offset: 8, + end: 8, file_name: "", }, ) diff --git a/crates/typr-core/tests/snapshots/snapshot_tests__typing__record_type.snap b/crates/typr-core/tests/snapshots/snapshot_tests__typing__record_type.snap index c6b16d6..fea5b66 100644 --- a/crates/typr-core/tests/snapshots/snapshot_tests__typing__record_type.snap +++ b/crates/typr-core/tests/snapshots/snapshot_tests__typing__record_type.snap @@ -1,5 +1,6 @@ --- source: crates/typr-core/tests/snapshot_tests.rs +assertion_line: 298 expression: ty --- Alias( @@ -8,6 +9,7 @@ Alias( false, HelpData { offset: 5, + end: 22, file_name: "", }, ) diff --git a/crates/typr-core/tests/snapshots/snapshot_tests__typing__union_alias.snap b/crates/typr-core/tests/snapshots/snapshot_tests__typing__union_alias.snap index 47a7e9c..e914c93 100644 --- a/crates/typr-core/tests/snapshots/snapshot_tests__typing__union_alias.snap +++ b/crates/typr-core/tests/snapshots/snapshot_tests__typing__union_alias.snap @@ -1,5 +1,6 @@ --- source: crates/typr-core/tests/snapshot_tests.rs +assertion_line: 309 expression: ty --- Tag( @@ -10,11 +11,13 @@ Tag( ), HelpData { offset: 0, + end: 13, file_name: "", }, ), HelpData { offset: 0, + end: 13, file_name: "", }, ) diff --git a/visualization_graph_v2.md b/visualization_graph_v2.md index 9da737b..75718ac 100644 --- a/visualization_graph_v2.md +++ b/visualization_graph_v2.md @@ -416,12 +416,32 @@ directement dans un bloc. Chaque étape est livrable seule et a des critères d'acceptation vérifiables. -**Étape 0 — Prérequis dans `typr-core`** +**Étape 0 — Prérequis dans `typr-core`** — fait (2026-09-25) - `HelpData.end` rempli par le parseur. `cargo test --workspace` et `typr case run` restent verts. - `type_recorder` : `with_recording` renvoie une table non vide sur l'exemple fil rouge (§14) ; les conflits de réécriture sont journalisés et analysés. - Aucune régression de temps mesurable sur `typr check` quand l'enregistreur est désactivé. +Notes d'implémentation (à connaître pour la suite) : +- Chaque fonction de parsing candidate (`fn foo(s: Span) -> IResult` ou + `IResult>` dans `elements.rs`/`mod.rs`, ~97 sites) est scindée en `foo` (fine + couche : appelle `foo_impl`, met `end` via `Lang::set_help_data_end`) + `foo_impl` (corps + original inchangé). Un nouveau parseur suivra ce même patron. +- `Lang::set_help_data_end` (dans `components/language/mod.rs`) fait pendant à `get_help_data` : + toute nouvelle variante de `Lang` doit être ajoutée aux deux matches. +- `type_recorder.rs` traite `Type::Failed` **et** `Type::UnknownFunction` comme des + placeholders (ni conflit, ni écrasement d'une valeur ferme) — découvert en step 0 sur + l'exemple fil rouge : le corps `Lines` d'une fonction est d'abord typé `UnknownFunction` + pendant la résolution de sa propre signature, avant d'être retypé pour de bon. +- Les `.bin` de la stdlib (`crates/typr-core/configs/bin/*.bin`) sont sérialisés en bincode + (positionnel, pas de champ nommé) : toute évolution de `HelpData`/`Type`/`Lang` exige de les + régénérer avec `typr std` puis un rebuild (`include_bytes!` les fige à la compilation), + sinon `VarType::load_r()...unwrap()` panique avec une erreur de désérialisation. +- 4 régressions `typr case run` (`0004/0005/0006/0008-was-declared-as-a-public-function`) et un + test flaky (`registry_validate::tests::formals_diff_flags_a_missing_export_and_an_arity_mismatch`, + fail seulement en parallèle) préexistent sur `develop` — vérifié en stashant ce travail ; + aucun lien avec ce chantier. + **Étape 1 — Crate `typr-graph`, modèle et builder** - Blocs de l'étape 1 du §4, fils locaux, captures (§3.3), `Ref` avec `confidence`, `HasType`, `TypePosition{0}`. From 1cd1a0f43211322dec3ab18e7f14e0a27cd6d1fc Mon Sep 17 00:00:00 2001 From: Fabrice Date: Fri, 25 Sep 2026 21:20:32 +0200 Subject: [PATCH 23/52] added new crate typr-graph --- Cargo.lock | 10 + Cargo.toml | 2 + crates/typr-graph/Cargo.toml | 17 + crates/typr-graph/src/build/blocks.rs | 449 ++++++++ crates/typr-graph/src/build/mod.rs | 353 ++++++ crates/typr-graph/src/build/refs.rs | 59 + crates/typr-graph/src/build/scope.rs | 80 ++ crates/typr-graph/src/key.rs | 82 ++ crates/typr-graph/src/lib.rs | 20 + crates/typr-graph/src/model.rs | 215 ++++ ...ough_example_builds_without_panicking.snap | 1010 +++++++++++++++++ crates/typr-graph/tests/totality.rs | 53 + crates/typr-graph/tests/walkthrough.rs | 46 + visualization_graph_v2.md | 19 +- 14 files changed, 2414 insertions(+), 1 deletion(-) create mode 100644 crates/typr-graph/Cargo.toml create mode 100644 crates/typr-graph/src/build/blocks.rs create mode 100644 crates/typr-graph/src/build/mod.rs create mode 100644 crates/typr-graph/src/build/refs.rs create mode 100644 crates/typr-graph/src/build/scope.rs create mode 100644 crates/typr-graph/src/key.rs create mode 100644 crates/typr-graph/src/lib.rs create mode 100644 crates/typr-graph/src/model.rs create mode 100644 crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap create mode 100644 crates/typr-graph/tests/totality.rs create mode 100644 crates/typr-graph/tests/walkthrough.rs diff --git a/Cargo.lock b/Cargo.lock index e5fae38..09aa59f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1957,6 +1957,16 @@ dependencies = [ "typr-core", ] +[[package]] +name = "typr-graph" +version = "0.5.12" +dependencies = [ + "insta", + "serde", + "serde_json", + "typr-core", +] + [[package]] name = "typr-lsp" version = "0.5.12" diff --git a/Cargo.toml b/Cargo.toml index 03e089d..e6655cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/typr-lsp", "crates/typr-wasm", "crates/typr-mcp", + "crates/typr-graph", ] [package] @@ -69,6 +70,7 @@ typr-cli = { path = "crates/typr-cli", version = "0.5" } typr-lsp = { path = "crates/typr-lsp", version = "0.5" } typr-wasm = { path = "crates/typr-wasm", version = "0.5" } typr-mcp = { path = "crates/typr-mcp", version = "0.5" } +typr-graph = { path = "crates/typr-graph", version = "0.5" } [profile.release] opt-level = 3 diff --git a/crates/typr-graph/Cargo.toml b/crates/typr-graph/Cargo.toml new file mode 100644 index 0000000..908012a --- /dev/null +++ b/crates/typr-graph/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "typr-graph" +description = "Builds a hierarchical block-graph view of a TypR program (see visualization_graph_v2.md)" +readme = "../../README.md" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +typr-core.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +insta = { version = "1", features = ["yaml"] } diff --git a/crates/typr-graph/src/build/blocks.rs b/crates/typr-graph/src/build/blocks.rs new file mode 100644 index 0000000..5276579 --- /dev/null +++ b/crates/typr-graph/src/build/blocks.rs @@ -0,0 +1,449 @@ +//! Per-`Lang`-variant block construction (spec §4). [`Builder::build_expr`] is total: every +//! `Lang` variant not explicitly matched here — and every shape inside a matched variant that +//! doesn't fit the common case — falls back to a plain `Opaque` block rather than panicking, +//! which is the étape-1 acceptance criterion (spec §12). + +use super::{variable_name, Builder}; +use crate::key::{BlockKey, Namespace}; +use crate::model::*; +use std::collections::HashSet; +use typr_core::components::language::argument_value::ArgumentValue; +use typr_core::components::language::operators::Op; +use typr_core::components::language::Lang; +use typr_core::components::r#type::argument_type::ArgumentType; +use typr_core::components::r#type::tchar::Tchar; +use typr_core::components::r#type::type_system::TypeSystem; +use typr_core::components::r#type::Type; + +/// A parameter/field/method name, read the same way `ArgumentType::get_argument_str` does but +/// without its `panic!` on an unexpected shape (a generic or embedded parameter, say) — falls +/// back to `"_"` instead, since totality here matters more than a perfect label. +fn safe_argument_name(arg: &ArgumentType) -> String { + match arg.get_argument() { + Type::Char(Tchar::Val(s), _) => s.to_string(), + Type::LabelGen(s, _) => s.to_uppercase(), + _ => "_".to_string(), + } +} + +fn alias_name(ty: &Type) -> Option { + match ty { + Type::Alias(name, ..) => Some(name.clone()), + _ => None, + } +} + +fn body_statements(body: &Lang) -> Vec<&Lang> { + match body { + Lang::Scope { body, .. } => body.iter().collect(), + other => vec![other], + } +} + +impl<'a> Builder<'a> { + pub(super) fn build_expr(&mut self, lang: &Lang, key: BlockKey, name_hint: Option<&str>) -> PortRef { + let name = name_hint.map(|s| s.to_string()); + match lang { + Lang::Number { .. } + | Lang::Integer { .. } + | Lang::Bool { .. } + | Lang::Char { .. } + | Lang::Null(_) + | Lang::NA(_) + | Lang::Empty(_) => { + let ty = self.pretty_type(lang); + self.finish_block(lang, key, BlockKind::Literal, name, Vec::new(), vec![Port::explicit("out", ty)], Origin::User, None) + } + + // Reached only for a *named* position whose RHS is a bare rename (`let x <- y;`) — + // an unnamed use is short-circuited by `build_operand` before it calls `build_expr`. + // Modeled as a reference to the real definition rather than a copy of it. + Lang::Variable { name: var_name, .. } => { + if let Some((target, confidence)) = self.resolve_definition(var_name, None) { + self.graph.relations.push(Relation::r#ref(key.clone(), "value", target, confidence)); + } + let ty = self.pretty_type(lang); + self.finish_block(lang, key, BlockKind::Opaque, name, Vec::new(), vec![Port::explicit("out", ty)], Origin::User, None) + } + + // `Lang::Operator`'s own field names are swapped from what they read as: `Op::combine` + // (operators.rs) builds `Operator{lhs: right_operand, rhs: left_operand}`, and every + // other constructor (e.g. `to_module_helper`, and the `dollar_access`/`dot_pipe_access` + // call sites in `processes/type_checking/mod.rs`) follows the same convention. So the + // *syntactic* first operand is `rhs`, the second is `lhs` — swapped here once, at the + // single place this `Lang` shape is taken apart, so every helper below can use + // `lhs`/`rhs` in their ordinary, non-swapped sense. + Lang::Operator { operator, lhs, rhs, .. } => self.build_operator_like(operator, rhs, lhs, lang, key, name), + + Lang::Not { value, .. } => { + let mut children = Vec::new(); + let mut wires = Vec::new(); + self.build_operand(value, key.role("value"), &key, "value", &mut children, &mut wires); + let inputs = vec![Port::explicit("value", self.pretty_type(value))]; + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Operator, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires }), + ) + } + + Lang::FunctionApp { identifier, arguments, .. } | Lang::VecFunctionApp { identifier, arguments, .. } => { + self.build_apply(identifier, arguments, lang, key, name) + } + + Lang::Function { parameters, return_type, body, .. } => { + self.build_function(parameters, return_type, body, lang, key, name) + } + Lang::Lambda { parameters, body, .. } => self.build_lambda(parameters, body, lang, key, name), + + Lang::List { value, .. } => self.build_record(value, lang, key, name), + Lang::ConstructorCall { fields, .. } => self.build_record(fields, lang, key, name), + + Lang::Alias { identifier, target_type, .. } => self.build_alias(identifier, target_type, lang, key, name), + + Lang::If { condition, if_block, else_block, .. } => self.build_if(condition, if_block, else_block, lang, key, name), + + Lang::Tuple { value, .. } => self.build_sequence(BlockKind::Tuple, value, lang, key, name), + Lang::Array { value, .. } | Lang::Vector { value, .. } => self.build_sequence(BlockKind::Array, value, lang, key, name), + + Lang::Scope { body, .. } => { + self.scope.push_plain(); + let stmts = body_statements_from_vec(body); + let children = self.build_nested_body(&stmts, &key); + self.scope.pop(); + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Scope, + name, + Vec::new(), + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires: Vec::new() }), + ) + } + + // Everything else (Module, Loop, Match, RCode, and any construct not yet modeled) — + // étape 5 territory, or genuinely unhandled: falls back to a total, panic-free Opaque. + other => { + let ty = self.pretty_type(other); + self.finish_block(other, key, BlockKind::Opaque, name, Vec::new(), vec![Port::explicit("out", ty)], Origin::User, None) + } + } + } + + fn build_operator_like(&mut self, op: &Op, lhs: &Lang, rhs: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + match op { + Op::Dollar(_) => self.build_access(lhs, rhs, lang, key, name), + Op::Dot(_) => match rhs { + Lang::FunctionApp { identifier, arguments, .. } | Lang::VecFunctionApp { identifier, arguments, .. } => { + self.build_ufcs_apply(lhs, identifier, arguments, lang, key, name) + } + Lang::Variable { .. } => self.build_access(lhs, rhs, lang, key, name), + _ => self.build_binary_operator(lhs, rhs, lang, key, name), + }, + Op::Pipe(_) => match rhs { + Lang::FunctionApp { identifier, arguments, .. } | Lang::VecFunctionApp { identifier, arguments, .. } => { + self.build_ufcs_apply(lhs, identifier, arguments, lang, key, name) + } + other => self.build_ufcs_apply(lhs, other, &[], lang, key, name), + }, + _ => self.build_binary_operator(lhs, rhs, lang, key, name), + } + } + + fn build_binary_operator(&mut self, lhs: &Lang, rhs: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + self.build_operand(lhs, key.role("lhs"), &key, "lhs", &mut children, &mut wires); + self.build_operand(rhs, key.role("rhs"), &key, "rhs", &mut children, &mut wires); + let inputs = vec![Port::explicit("lhs", self.pretty_type(lhs)), Port::explicit("rhs", self.pretty_type(rhs))]; + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Operator, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires }), + ) + } + + fn build_access(&mut self, lhs: &Lang, rhs: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + self.build_operand(lhs, key.role("value"), &key, "value", &mut children, &mut wires); + let field_name = variable_name(rhs).unwrap_or_else(|| "field".to_string()); + let inputs = vec![Port::explicit("value", self.pretty_type(lhs))]; + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Access, + name, + inputs, + vec![Port::explicit(field_name, out_ty)], + Origin::User, + Some(Body { children, wires }), + ) + } + + /// `lhs.f(args)` / `lhs |> f(args)`: UFCS desugars to `f(lhs, args)` at this level already + /// (the parser keeps `.`/`|>` as plain operators, spec §2's note), so the graph builds it + /// directly as the `Apply` it becomes rather than as a nested `Access` + call. + fn build_ufcs_apply(&mut self, receiver: &Lang, callee: &Lang, rest: &[Lang], lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = vec![Port::explicit("callee", None)]; + match callee { + Lang::Variable { name: callee_name, .. } => { + let arg0_ty = self.recorded_type(receiver); + if let Some((target, confidence)) = self.resolve_definition(callee_name, arg0_ty.as_ref()) { + self.graph.relations.push(Relation::r#ref(key.clone(), "callee", target, confidence)); + } + } + _ => self.build_operand(callee, key.role("callee"), &key, "callee", &mut children, &mut wires), + } + let all_args: Vec<&Lang> = std::iter::once(receiver).chain(rest.iter()).collect(); + for (i, arg) in all_args.iter().enumerate() { + let port = format!("arg{i}"); + inputs.push(Port::explicit(&port, self.pretty_type(arg))); + self.build_operand(arg, key.role(&port), &key, &port, &mut children, &mut wires); + } + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Apply, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires }), + ) + } + + fn build_apply(&mut self, identifier: &Lang, arguments: &[Lang], lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = vec![Port::explicit("callee", None)]; + match identifier { + Lang::Variable { name: callee_name, .. } => { + let arg0_ty = arguments.first().and_then(|a| self.recorded_type(a)); + if let Some((target, confidence)) = self.resolve_definition(callee_name, arg0_ty.as_ref()) { + self.graph.relations.push(Relation::r#ref(key.clone(), "callee", target, confidence)); + } + } + _ => self.build_operand(identifier, key.role("callee"), &key, "callee", &mut children, &mut wires), + } + for (i, arg) in arguments.iter().enumerate() { + let port = format!("arg{i}"); + inputs.push(Port::explicit(&port, self.pretty_type(arg))); + self.build_operand(arg, key.role(&port), &key, &port, &mut children, &mut wires); + } + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Apply, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires }), + ) + } + + fn build_function(&mut self, parameters: &[ArgumentType], return_type: &Type, body: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + self.scope.push_boundary(key.clone()); + let mut inputs = Vec::new(); + for p in parameters { + let pname = safe_argument_name(p); + self.scope.bind(&pname, PortRef { block: key.clone(), port: pname.clone() }); + inputs.push(Port::explicit(pname, Some(p.get_type().pretty()))); + } + let stmts = body_statements(body); + let children = self.build_nested_body(&stmts, &key); + self.scope.pop(); + let (captured, refs) = self.take_captures(&key); + inputs.extend(captured); + self.graph.relations.extend(refs); + self.finish_block( + lang, + key, + BlockKind::Function, + name, + inputs, + vec![Port::explicit("out", Some(return_type.pretty()))], + Origin::User, + Some(Body { children, wires: Vec::new() }), + ) + } + + fn build_lambda(&mut self, parameters: &[Lang], body: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + self.scope.push_boundary(key.clone()); + let mut inputs = Vec::new(); + for (i, p) in parameters.iter().enumerate() { + let pname = variable_name(p).unwrap_or_else(|| format!("arg{i}")); + self.scope.bind(&pname, PortRef { block: key.clone(), port: pname.clone() }); + inputs.push(Port::explicit(pname, self.pretty_type(p))); + } + let stmts = body_statements(body); + let children = self.build_nested_body(&stmts, &key); + self.scope.pop(); + let (captured, refs) = self.take_captures(&key); + inputs.extend(captured); + self.graph.relations.extend(refs); + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Function, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires: Vec::new() }), + ) + } + + fn build_record(&mut self, fields: &[ArgumentValue], lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = Vec::new(); + let mut outputs = vec![Port::explicit("out", self.pretty_type(lang))]; + for field in fields { + let field_name = field.get_argument(); + let value = field.get_value(); + let ty = self.pretty_type(&value); + inputs.push(Port::explicit(field_name.clone(), ty.clone())); + outputs.push(Port::explicit(field_name.clone(), ty)); + self.build_operand(&value, key.named(&field_name), &key, &field_name, &mut children, &mut wires); + } + self.finish_block(lang, key, BlockKind::Record, name, inputs, outputs, Origin::User, Some(Body { children, wires })) + } + + fn build_alias(&mut self, _identifier: &Lang, target_type: &Type, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + match target_type { + Type::Interface(methods, _) => self.build_interface(methods, lang, key, name), + other => self.build_type_decl(other, lang, key, name), + } + } + + fn build_interface(&mut self, methods: &HashSet, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + for m in methods { + children.push(self.insert_type_expr_leaf(&key, m)); + } + self.finish_block(lang, key, BlockKind::Interface, name, Vec::new(), Vec::new(), Origin::User, Some(Body { children, wires: Vec::new() })) + } + + fn build_type_decl(&mut self, target_type: &Type, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + if let Type::Record(fields, _) = target_type { + for f in fields { + children.push(self.insert_type_expr_leaf(&key, f)); + } + } + let mut outputs = vec![Port::explicit("out", self.pretty_type(lang))]; + if let Some(type_name) = &name { + // Q4: a TypeDecl's interface is its methods — functions taking it as their 1st + // parameter, discovered via `TypePosition{index: 0}` rather than a declared `impl`. + let methods: Vec<(String, String, BlockKey)> = self + .context + .variables() + .filter_map(|(var, ty)| match ty { + Type::Function(params, _, _) => { + let p0 = params.first()?; + if alias_name(&p0.get_type()).as_deref() == Some(type_name.as_str()) { + let method_name = var.get_name(); + Some((method_name.clone(), ty.pretty(), BlockKey::top_level(Namespace::Val, &method_name))) + } else { + None + } + } + _ => None, + }) + .collect(); + for (method_name, pretty, method_key) in methods { + outputs.push(Port::explicit(method_name, Some(pretty))); + self.graph.relations.push(Relation::type_position(method_key, key.clone(), 0)); + } + } + self.finish_block(lang, key, BlockKind::TypeDecl, name, Vec::new(), outputs, Origin::User, Some(Body { children, wires: Vec::new() })) + } + + fn insert_type_expr_leaf(&mut self, parent: &BlockKey, member: &ArgumentType) -> BlockKey { + let member_name = safe_argument_name(member); + let child_key = parent.named(&member_name); + let ty = member.get_type(); + self.graph.insert(Block { + key: child_key.clone(), + kind: BlockKind::TypeExpr, + name: Some(member_name), + span: None, + r#type: Some(ty.pretty()), + inputs: Vec::new(), + outputs: vec![Port::explicit("out", Some(ty.pretty()))], + origin: Origin::User, + body: None, + }); + child_key + } + + fn build_if(&mut self, condition: &Lang, if_block: &Lang, else_block: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + self.build_operand(condition, key.role("cond"), &key, "cond", &mut children, &mut wires); + // `then`/`else` are always materialized (never elided as a bare-variable read) so both + // branches stay independently explorable, per the catalog's "chacun explorable". + let then_key = key.role("then"); + self.build_expr(if_block, then_key.clone(), None); + children.push(then_key); + let else_key = key.role("else"); + self.build_expr(else_block, else_key.clone(), None); + children.push(else_key); + let inputs = vec![Port::explicit("cond", self.pretty_type(condition))]; + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::If, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires }), + ) + } + + fn build_sequence(&mut self, kind: BlockKind, elements: &[Lang], lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = Vec::new(); + let mut outputs = vec![Port::explicit("out", self.pretty_type(lang))]; + for (i, el) in elements.iter().enumerate() { + let port = format!("elem{i}"); + let ty = self.pretty_type(el); + inputs.push(Port::explicit(&port, ty.clone())); + if kind == BlockKind::Tuple { + outputs.push(Port::explicit(&port, ty)); + } + self.build_operand(el, key.role(&port), &key, &port, &mut children, &mut wires); + } + self.finish_block(lang, key, kind, name, inputs, outputs, Origin::User, Some(Body { children, wires })) + } +} + +fn body_statements_from_vec(body: &[Lang]) -> Vec<&Lang> { + body.iter().collect() +} diff --git a/crates/typr-graph/src/build/mod.rs b/crates/typr-graph/src/build/mod.rs new file mode 100644 index 0000000..429a9aa --- /dev/null +++ b/crates/typr-graph/src/build/mod.rs @@ -0,0 +1,353 @@ +//! Turns `Lang` + the final `Context` + a recorded `TypeTable` into a [`BlockGraph`] (spec §7.1). + +mod blocks; +mod refs; +mod scope; + +use crate::key::{BlockKey, Namespace}; +use crate::model::*; +use scope::ScopeStack; +use std::collections::HashMap; +use typr_core::components::context::Context; +use typr_core::components::language::Lang; +use typr_core::components::r#type::type_system::TypeSystem; +use typr_core::components::r#type::Type; +use typr_core::processes::type_checking::type_recorder::{SpanKey, TypeTable}; + +pub fn build(lang: &Lang, context: &Context, types: &TypeTable) -> BlockGraph { + let root_key = BlockKey::program_root(); + let mut b = Builder { + context, + types, + graph: BlockGraph::new(root_key.clone()), + scope: ScopeStack::new(), + pending_captures: HashMap::new(), + reference_blocks: HashMap::new(), + }; + b.scope.push_boundary(root_key.clone()); + + let items = top_level_items(lang); + + // Pre-pass: every top-level declaration's name is registered before any body is built, so a + // forward or mutual reference still resolves (spec §7.1 step 2). + for item in &items { + if let Some((namespace, name)) = top_level_name(item) { + let key = BlockKey::top_level(namespace, &name); + b.scope.bind(&name, PortRef { block: key, port: "out".to_string() }); + } + } + + let mut children = Vec::new(); + for (index, item) in items.iter().enumerate() { + b.build_top_level_item(item, index, &mut children); + } + + b.scope.pop(); + let (inputs, refs) = b.take_captures(&root_key); + b.graph.relations.extend(refs); + b.graph.insert(Block { + key: root_key.clone(), + kind: BlockKind::Program, + name: None, + span: None, + r#type: None, + inputs, + outputs: Vec::new(), + origin: Origin::User, + body: Some(Body { children, wires: Vec::new() }), + }); + b.graph +} + +fn top_level_items(lang: &Lang) -> Vec<&Lang> { + match lang { + Lang::Lines { value, .. } => value.iter().collect(), + other => vec![other], + } +} + +fn top_level_name(item: &Lang) -> Option<(Namespace, String)> { + match item { + Lang::Let { variable, .. } => variable_name(variable).map(|n| (Namespace::Val, n)), + Lang::Alias { identifier, .. } => variable_name(identifier).map(|n| (Namespace::Type, n)), + _ => None, + } +} + +pub(super) fn variable_name(lang: &Lang) -> Option { + match lang { + Lang::Variable { name, .. } => Some(name.clone()), + Lang::TypeConstructor { name, .. } => Some(name.clone()), + _ => None, + } +} + +pub(super) fn span_of(lang: &Lang) -> Span { + let h = lang.get_help_data(); + Span { + file: h.get_file_name(), + start: h.get_offset(), + end: h.get_end(), + } +} + +/// The outcome of resolving a free name (spec §5.3): where it's really defined, how confident +/// that resolution is, and which boundary (if any) it crosses to get from there to here. +struct Resolved { + target: PortRef, + confidence: Confidence, + crosses: Option, +} + +struct Builder<'a> { + context: &'a Context, + types: &'a TypeTable, + graph: BlockGraph, + scope: ScopeStack, + /// Captures discovered so far for a boundary block that hasn't been finalized yet (it's + /// still being walked) — folded into its `inputs`/`Ref` relations by [`Builder::take_captures`] + /// once its own body is fully built. + pending_captures: HashMap)>>, + /// Memoizes synthesized leaf blocks for names resolved outside the program (stdlib, R + /// packages) so the same name doesn't get a fresh block every time it's referenced. + reference_blocks: HashMap, +} + +impl<'a> Builder<'a> { + fn recorded_type(&self, lang: &Lang) -> Option { + self.types.get(&SpanKey::from(&lang.get_help_data())).cloned() + } + + fn pretty_type(&self, lang: &Lang) -> Option { + self.recorded_type(lang).map(|t| t.pretty()) + } + + fn build_top_level_item(&mut self, item: &Lang, index: usize, children: &mut Vec) { + match top_level_name(item) { + Some((namespace, name)) => { + let key = BlockKey::top_level(namespace, &name); + let expr = match item { + Lang::Let { expression, .. } => expression.as_ref(), + _ => item, + }; + self.build_expr(expr, key.clone(), Some(&name)); + children.push(key); + } + None => { + let key = BlockKey::top_level(Namespace::Val, &format!("#{index}")); + self.build_expr(item, key.clone(), None); + children.push(key); + } + } + } + + /// A statement list belonging to some enclosing block `owner` (a function body, a bare + /// `{ ... }` scope): named lets are keyed and bound under `owner`, everything else gets an + /// anonymous positional key (spec §6). + fn build_nested_body(&mut self, stmts: &[&Lang], owner: &BlockKey) -> Vec { + let mut children = Vec::new(); + for (index, stmt) in stmts.iter().enumerate() { + match stmt { + Lang::Let { variable, expression, .. } => match variable_name(variable) { + Some(name) => { + let key = owner.named(&name); + let port = self.build_expr(expression, key.clone(), Some(&name)); + self.scope.bind(&name, port); + children.push(key); + } + None => { + // Pattern-destructuring let: not modeled yet (étape 1 keeps bindings + // simple); built anonymously so nothing is lost, just unnamed. + let key = owner.anonymous(index); + self.build_expr(expression, key.clone(), None); + children.push(key); + } + }, + other => { + let key = owner.anonymous(index); + self.build_expr(other, key.clone(), None); + children.push(key); + } + } + } + children + } + + /// Builds `lang` as an operand feeding `owner`'s `port` input. A bare name reference doesn't + /// get its own block (spec §3.2's "un fil... à l'intérieur d'un même bloc parent" collapses a + /// pure variable read into a direct wire from its definition); anything else recurses into a + /// real child block first. + fn build_operand( + &mut self, + lang: &Lang, + key: BlockKey, + owner: &BlockKey, + port: &str, + children: &mut Vec, + wires: &mut Vec, + ) { + let src = match lang { + Lang::Variable { name, .. } => self.resolve_and_wire(name, None), + _ => { + let src = self.build_expr(lang, key.clone(), None); + children.push(key); + src + } + }; + wires.push(Wire { + from: src, + to: PortRef { block: owner.clone(), port: port.to_string() }, + }); + } + + fn resolve_name(&mut self, name: &str, arg0: Option<&Type>) -> Option { + if let Some((port, is_local)) = self.scope.resolve(name) { + let crosses = if is_local { None } else { self.scope.nearest_boundary() }; + return Some(Resolved { target: port, confidence: Confidence::Exact, crosses }); + } + let (ty, confidence) = refs::resolve_by_name(self.context, name, arg0)?; + let target_key = self.ensure_reference_block(name, &ty); + Some(Resolved { + target: PortRef { block: target_key, port: "out".to_string() }, + confidence, + crosses: self.scope.nearest_boundary(), + }) + } + + /// Resolves a name for a **reference** (e.g. `Apply.callee`, spec Q2): the `Ref` returned + /// always points straight at the real definition, never at a capture port — a call always + /// "goes to the definition". But the name is still genuinely free in the enclosing boundary's + /// interior, so per Q1 that boundary still gets an honest implicit port for it (with its own + /// separate boundary-level `Ref`) even though nothing wires through it for a call. + fn resolve_definition(&mut self, name: &str, arg0: Option<&Type>) -> Option<(BlockKey, Confidence)> { + let resolved = self.resolve_name(name, arg0)?; + if let Some(boundary) = resolved.crosses.clone() { + self.ensure_capture_port(boundary, name, resolved.target.clone(), resolved.confidence.clone(), None); + } + Some((resolved.target.block, resolved.confidence)) + } + + /// Resolves a name for a **value read** (a bare variable used as data): if it crosses a + /// boundary, wires through that boundary's capture port instead of reaching past it directly + /// (spec §3.3). + fn resolve_and_wire(&mut self, name: &str, arg0: Option<&Type>) -> PortRef { + match self.resolve_name(name, arg0) { + Some(Resolved { target, confidence, crosses: Some(boundary) }) => { + self.ensure_capture_port(boundary.clone(), name, target, confidence, None); + PortRef { block: boundary, port: name.to_string() } + } + Some(Resolved { target, crosses: None, .. }) => target, + None => { + let key = self.ensure_unresolved_block(name); + PortRef { block: key, port: "out".to_string() } + } + } + } + + fn ensure_reference_block(&mut self, name: &str, ty: &Type) -> BlockKey { + let cache_key = format!("std:{name}"); + if let Some(key) = self.reference_blocks.get(&cache_key) { + return key.clone(); + } + let key = BlockKey::top_level(Namespace::Std, name); + self.graph.insert(Block { + key: key.clone(), + kind: BlockKind::Opaque, + name: Some(name.to_string()), + span: None, + r#type: Some(ty.pretty()), + inputs: Vec::new(), + outputs: vec![Port::explicit("out", Some(ty.pretty()))], + origin: Origin::Std, + body: None, + }); + self.reference_blocks.insert(cache_key, key.clone()); + key + } + + /// A name that type-checking accepted but this best-effort by-name pass couldn't find at all + /// (should not happen on a program that passed `typr check` — kept only so the builder stays + /// total rather than panicking on a gap in this pass). + fn ensure_unresolved_block(&mut self, name: &str) -> BlockKey { + let cache_key = format!("unresolved:{name}"); + if let Some(key) = self.reference_blocks.get(&cache_key) { + return key.clone(); + } + let key = BlockKey::top_level(Namespace::Std, &format!("@unresolved-{name}")); + self.graph.insert(Block { + key: key.clone(), + kind: BlockKind::Opaque, + name: Some(name.to_string()), + span: None, + r#type: None, + inputs: Vec::new(), + outputs: vec![Port::explicit("out", None)], + origin: Origin::User, + body: None, + }); + self.reference_blocks.insert(cache_key, key.clone()); + key + } + + fn ensure_capture_port( + &mut self, + boundary: BlockKey, + name: &str, + target: PortRef, + confidence: Confidence, + ty: Option, + ) { + let entry = self.pending_captures.entry(boundary).or_default(); + if entry.iter().any(|(n, ..)| n == name) { + return; + } + entry.push((name.to_string(), target, confidence, ty)); + } + + fn take_captures(&mut self, boundary: &BlockKey) -> (Vec, Vec) { + let entries = self.pending_captures.remove(boundary).unwrap_or_default(); + let mut inputs = Vec::with_capacity(entries.len()); + let mut relations = Vec::with_capacity(entries.len()); + for (name, target, confidence, ty) in entries { + inputs.push(Port::implicit(name.clone(), ty)); + relations.push(Relation::r#ref(boundary.clone(), &name, target.block, confidence)); + } + (inputs, relations) + } + + /// Common tail of every `build_*` arm: records the recorded type, links a named-alias result + /// to its `TypeDecl`/`Interface` via `HasType`, inserts the block, and returns a `PortRef` to + /// its default value port (`outputs[0]`, `"out"` by convention — except `Access`, whose sole + /// output is the field itself). + fn finish_block( + &mut self, + lang: &Lang, + key: BlockKey, + kind: BlockKind, + name: Option, + inputs: Vec, + outputs: Vec, + origin: Origin, + body: Option, + ) -> PortRef { + let ty = self.recorded_type(lang); + if let Some(Type::Alias(alias_name, _, _, _)) = &ty { + self.graph + .relations + .push(Relation::has_type(key.clone(), BlockKey::top_level(Namespace::Type, alias_name))); + } + let default_port = outputs.first().map(|p| p.name.clone()).unwrap_or_else(|| "out".to_string()); + self.graph.insert(Block { + key: key.clone(), + kind, + name, + span: Some(span_of(lang)), + r#type: ty.map(|t| t.pretty()), + inputs, + outputs, + origin, + body, + }); + PortRef { block: key, port: default_port } + } +} diff --git a/crates/typr-graph/src/build/refs.rs b/crates/typr-graph/src/build/refs.rs new file mode 100644 index 0000000..38e8231 --- /dev/null +++ b/crates/typr-graph/src/build/refs.rs @@ -0,0 +1,59 @@ +//! By-name resolution against the final `Context`, for names not bound anywhere in the current +//! `Lang` walk (spec §5.3) — stdlib functions, R package functions, anything not reachable +//! through the scope stack. There is no name-resolution pass in `typr-core` to delegate to (the +//! real dispatch machinery, `Context::get_type_from_variable`/`Var::match_with`, picks a single +//! winner without reporting how many candidates it had to choose from), so this replicates just +//! enough of it to produce a `Confidence`. + +use crate::model::Confidence; +use typr_core::components::context::Context; +use typr_core::components::r#type::type_system::TypeSystem; +use typr_core::components::r#type::Type; + +fn first_param_type(ty: &Type) -> Option { + match ty { + Type::Function(params, _, _) => params.first().map(|p| p.get_type()), + _ => None, + } +} + +/// Resolves `name` against every top-level binding in `context` (stdlib included). `arg0` is the +/// type of the call's first argument, if any, used to narrow an overload set the same way +/// `Context::get_type_from_variable` does (via `is_subtype`), but without silently picking a +/// winner when it can't: that case is reported as `Ambiguous`. +pub fn resolve_by_name(context: &Context, name: &str, arg0: Option<&Type>) -> Option<(Type, Confidence)> { + let candidates: Vec<&Type> = context + .variables() + .filter(|(v, _)| v.get_name() == name) + .map(|(_, t)| t) + .collect(); + + match candidates.as_slice() { + [] => None, + [only] => Some(((*only).clone(), Confidence::Exact)), + many => { + let Some(arg0) = arg0 else { + return Some((many[0].clone(), Confidence::ByName)); + }; + let matching: Vec<&Type> = many + .iter() + .filter(|t| { + first_param_type(t) + .map(|p| arg0.is_subtype(&p, context).0 || p.is_subtype(arg0, context).0) + .unwrap_or(false) + }) + .copied() + .collect(); + match matching.as_slice() { + [only] => Some(((*only).clone(), Confidence::Exact)), + [] => Some((many[0].clone(), Confidence::ByName)), + _ => Some(( + many[0].clone(), + Confidence::Ambiguous { + candidates: Vec::new(), + }, + )), + } + } + } +} diff --git a/crates/typr-graph/src/build/scope.rs b/crates/typr-graph/src/build/scope.rs new file mode 100644 index 0000000..2e15893 --- /dev/null +++ b/crates/typr-graph/src/build/scope.rs @@ -0,0 +1,80 @@ +//! Lexical scope tracking during the `Lang` walk (spec §3.3, §7.1 step 2). A "frame" is pushed +//! for every name-binding position (the `Program` root, each `Function`); a name found in the +//! current boundary's own frames is local, one found further out is a capture crossing that +//! boundary. +//! +//! Simplification (documented, not yet needed by any real case): a capture only ever attaches to +//! the *nearest* enclosing boundary, even when the definition lives further out still (e.g. a +//! `Function` nested inside another `Function`, itself referencing something from `Program`). +//! Full bubbling through every crossed boundary (spec §3.3's "les dépendances remontent +//! naturellement") is deferred until a real nested case needs it. + +use crate::model::PortRef; +use std::collections::HashMap; + +use crate::key::BlockKey; + +struct Frame { + bindings: HashMap, + /// `Some(key)` if this frame is a capture boundary (the block `key` owns captures crossing + /// into it); `None` for a plain nested scope (e.g. an `if` branch) that shares its parent's + /// boundary. + boundary: Option, +} + +#[derive(Default)] +pub struct ScopeStack { + frames: Vec, +} + +impl ScopeStack { + pub fn new() -> Self { + ScopeStack { frames: Vec::new() } + } + + pub fn push_boundary(&mut self, key: BlockKey) { + self.frames.push(Frame { + bindings: HashMap::new(), + boundary: Some(key), + }); + } + + pub fn push_plain(&mut self) { + self.frames.push(Frame { + bindings: HashMap::new(), + boundary: None, + }); + } + + pub fn pop(&mut self) { + self.frames.pop(); + } + + pub fn bind(&mut self, name: &str, port: PortRef) { + if let Some(frame) = self.frames.last_mut() { + frame.bindings.insert(name.to_string(), port); + } + } + + fn nearest_boundary_index(&self) -> Option { + self.frames.iter().rposition(|f| f.boundary.is_some()) + } + + pub fn nearest_boundary(&self) -> Option { + self.frames.iter().rev().find_map(|f| f.boundary.clone()) + } + + /// Looks a name up from the innermost frame outward. Returns the bound `PortRef` and whether + /// it was found without crossing the nearest boundary (`true`) or beyond it (`false`, a + /// capture). + pub fn resolve(&self, name: &str) -> Option<(PortRef, bool)> { + let boundary_idx = self.nearest_boundary_index(); + for (idx, frame) in self.frames.iter().enumerate().rev() { + if let Some(port) = frame.bindings.get(name) { + let is_local = boundary_idx.is_none_or(|b| idx >= b); + return Some((port.clone(), is_local)); + } + } + None + } +} diff --git a/crates/typr-graph/src/key.rs b/crates/typr-graph/src/key.rs new file mode 100644 index 0000000..34eaade --- /dev/null +++ b/crates/typr-graph/src/key.rs @@ -0,0 +1,82 @@ +//! `BlockKey`: the stable semantic identity of a block (spec §6). Grown one segment at a time +//! while the builder walks down into `Lang`. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// `val:` / `type:` / `std:` / `r:` — TypR keeps values and aliases in separate namespaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Namespace { + Val, + Type, + Std, + R, +} + +impl fmt::Display for Namespace { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Namespace::Val => "val", + Namespace::Type => "type", + Namespace::Std => "std", + Namespace::R => "r", + }; + write!(f, "{s}") + } +} + +/// A block's stable identity, e.g. `val:norm2/a`, `val:norm2/#1/rhs/arg0`, `type:Point`. +/// Opaque on purpose (a plain `String` newtype): callers build one path segment at a time via +/// the methods below rather than constructing the string by hand. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct BlockKey(String); + +impl BlockKey { + pub fn as_str(&self) -> &str { + &self.0 + } + + /// A top-level declaration's own key (`val:norm2`, `type:Point`) — top-level names live + /// directly under their namespace, not nested under the synthetic `Program` block. + pub fn top_level(namespace: Namespace, name: &str) -> Self { + BlockKey(format!("{namespace}:{name}")) + } + + /// The synthetic root block representing the whole program. `@` can't start a TypR + /// identifier, so this can never collide with a real top-level name. + pub fn program_root() -> Self { + BlockKey("val:@program".to_string()) + } + + fn extended(&self, segment: &str) -> Self { + BlockKey(format!("{}/{}", self.0, segment)) + } + + /// A name introduced by a declaration (`let`, parameter, pattern binding). + pub fn named(&self, name: &str) -> Self { + self.extended(name) + } + + /// A fixed structural role (`then`, `else`, `cond`, `lhs`, `rhs`, `arg0`, `value`…). + pub fn role(&self, role: &str) -> Self { + self.extended(role) + } + + /// An anonymous child's position among its siblings. + pub fn anonymous(&self, index: usize) -> Self { + self.extended(&format!("#{index}")) + } + + /// An overloaded name, disambiguated by the type of its first parameter + /// (`show(Point)`), spec §6. + pub fn overload(&self, name: &str, first_param_type: &str) -> Self { + self.extended(&format!("{name}({first_param_type})")) + } +} + +impl fmt::Display for BlockKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/crates/typr-graph/src/lib.rs b/crates/typr-graph/src/lib.rs new file mode 100644 index 0000000..2ed30ef --- /dev/null +++ b/crates/typr-graph/src/lib.rs @@ -0,0 +1,20 @@ +//! Builds a hierarchical block-graph view of a TypR program (`visualization_graph_v2.md`). +//! +//! Entry point: [`build`], which turns `Lang` + the `Context` produced by type-checking it + a +//! recorded [`TypeTable`] into a [`model::BlockGraph`] — a JSON-serializable, versioned external +//! contract (spec §9). Pure logic, no I/O, WASM-safe. + +mod build; +pub mod key; +pub mod model; + +pub use key::{BlockKey, Namespace}; +pub use model::BlockGraph; + +use typr_core::components::context::Context; +use typr_core::components::language::Lang; +use typr_core::processes::type_checking::type_recorder::TypeTable; + +pub fn build(lang: &Lang, context: &Context, types: &TypeTable) -> BlockGraph { + build::build(lang, context, types) +} diff --git a/crates/typr-graph/src/model.rs b/crates/typr-graph/src/model.rs new file mode 100644 index 0000000..c5ca75a --- /dev/null +++ b/crates/typr-graph/src/model.rs @@ -0,0 +1,215 @@ +//! The block-graph data model (spec §3.2, §4, §5, §9). This is the external JSON contract +//! (CLI, WASM, playground, docs): field names mirror the spec's example verbatim, and any +//! incompatible change must bump [`FORMAT_VERSION`]. + +use crate::key::BlockKey; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const FORMAT: &str = "typr-block-graph"; +pub const FORMAT_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Span { + pub file: String, + pub start: usize, + pub end: usize, +} + +/// Every block kind of the catalogue (spec §4). Variants not yet built at this step (`Loop`, +/// `Match`, `Module`, `RCode` — étape 5) exist so the JSON contract is stable ahead of time, but +/// no constructor emits them yet: the `Lang` shapes that would need them fall back to `Opaque`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BlockKind { + Program, + Literal, + Operator, + Apply, + Scope, + Function, + Record, + Access, + TypeDecl, + TypeExpr, + Interface, + If, + Tuple, + Array, + Opaque, + Loop, + Match, + Module, + RCode, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Origin { + User, + Std, + RPackage(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Visibility { + Public, + Private, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Port { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + pub implicit: bool, + pub visibility: Visibility, +} + +impl Port { + pub fn explicit(name: impl Into, r#type: Option) -> Self { + Port { + name: name.into(), + r#type, + implicit: false, + visibility: Visibility::Public, + } + } + + pub fn implicit(name: impl Into, r#type: Option) -> Self { + Port { + name: name.into(), + r#type, + implicit: true, + visibility: Visibility::Public, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PortRef { + pub block: BlockKey, + pub port: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Wire { + pub from: PortRef, + pub to: PortRef, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Body { + pub children: Vec, + pub wires: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Block { + pub key: BlockKey, + pub kind: BlockKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub span: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + pub inputs: Vec, + pub outputs: Vec, + pub origin: Origin, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +/// Confidence of a by-name reference (spec §5.3) — a stand-in until a real name-resolution pass +/// exists (étape 7). `Exact` unifies "only one candidate" and "several candidates, but the type +/// of the first argument at the call site narrows it to one." +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "confidence", rename_all = "snake_case")] +pub enum Confidence { + Exact, + ByName, + Ambiguous { candidates: Vec }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RelationKind { + Ref, + HasType, + TypePosition, + Satisfies, + DeclaredAs, + Subtype, + Instantiates, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Relation { + pub kind: RelationKind, + pub from: BlockKey, + pub to: BlockKey, + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, +} + +impl Relation { + pub fn r#ref(from: BlockKey, port: &str, to: BlockKey, confidence: Confidence) -> Self { + Relation { + kind: RelationKind::Ref, + from, + to, + port: Some(port.to_string()), + index: None, + confidence: Some(confidence), + } + } + + pub fn has_type(from: BlockKey, to: BlockKey) -> Self { + Relation { + kind: RelationKind::HasType, + from, + to, + port: None, + index: None, + confidence: None, + } + } + + pub fn type_position(from: BlockKey, to: BlockKey, index: usize) -> Self { + Relation { + kind: RelationKind::TypePosition, + from, + to, + port: None, + index: Some(index), + confidence: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlockGraph { + pub format: String, + pub version: u32, + pub root: BlockKey, + pub blocks: BTreeMap, + pub relations: Vec, +} + +impl BlockGraph { + pub fn new(root: BlockKey) -> Self { + BlockGraph { + format: FORMAT.to_string(), + version: FORMAT_VERSION, + root, + blocks: BTreeMap::new(), + relations: Vec::new(), + } + } + + pub fn insert(&mut self, block: Block) { + self.blocks.insert(block.key.clone(), block); + } +} diff --git a/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap b/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap new file mode 100644 index 0000000..7ba05fd --- /dev/null +++ b/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap @@ -0,0 +1,1010 @@ +--- +source: crates/typr-graph/tests/walkthrough.rs +assertion_line: 45 +expression: json +--- +{ + "format": "typr-block-graph", + "version": 1, + "root": "val:@program", + "blocks": { + "type:Point": { + "key": "type:Point", + "kind": "TypeDecl", + "name": "Point", + "span": { + "file": "fil_rouge", + "start": 61, + "end": 341 + }, + "type": "UnknownFunction", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "UnknownFunction", + "implicit": false, + "visibility": "Public" + }, + { + "name": "show", + "type": "fn(p: Point) -> char", + "implicit": false, + "visibility": "Public" + }, + { + "name": "norm2", + "type": "fn(p: Point) -> int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "type:Point/x", + "type:Point/y" + ], + "wires": [] + } + }, + "type:Point/x": { + "key": "type:Point/x", + "kind": "TypeExpr", + "name": "x", + "type": "int", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "type:Point/y": { + "key": "type:Point/y", + "kind": "TypeExpr", + "name": "y", + "type": "int", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "type:Printable": { + "key": "type:Printable", + "kind": "Interface", + "name": "Printable", + "span": { + "file": "fil_rouge", + "start": 7, + "end": 341 + }, + "type": "int", + "inputs": [], + "outputs": [], + "origin": "User", + "body": { + "children": [ + "type:Printable/show" + ], + "wires": [] + } + }, + "type:Printable/show": { + "key": "type:Printable/show", + "kind": "TypeExpr", + "name": "show", + "type": "fn(a: SELF) -> char", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "fn(a: SELF) -> char", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "val:@program": { + "key": "val:@program", + "kind": "Program", + "inputs": [], + "outputs": [], + "origin": "User", + "body": { + "children": [ + "type:Printable", + "type:Point", + "val:sq", + "val:norm2", + "val:show", + "val:p", + "val:d", + "val:total" + ], + "wires": [] + } + }, + "val:d": { + "key": "val:d", + "kind": "Apply", + "name": "d", + "span": { + "file": "fil_rouge", + "start": 304, + "end": 309 + }, + "type": "Point", + "inputs": [ + { + "name": "callee", + "implicit": false, + "visibility": "Public" + }, + { + "name": "arg0", + "type": "Point", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "Point", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [], + "wires": [ + { + "from": { + "block": "val:p", + "port": "out" + }, + "to": { + "block": "val:d", + "port": "arg0" + } + } + ] + } + }, + "val:norm2": { + "key": "val:norm2", + "kind": "Function", + "name": "norm2", + "span": { + "file": "", + "start": 0, + "end": 208 + }, + "type": "fn(p: Point) -> int", + "inputs": [ + { + "name": "p", + "type": "Point", + "implicit": false, + "visibility": "Public" + }, + { + "name": "sq", + "implicit": true, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:norm2/a", + "val:norm2/#1" + ], + "wires": [] + } + }, + "val:norm2/#1": { + "key": "val:norm2/#1", + "kind": "Operator", + "span": { + "file": "fil_rouge", + "start": 193, + "end": 204 + }, + "type": "int", + "inputs": [ + { + "name": "lhs", + "type": "int", + "implicit": false, + "visibility": "Public" + }, + { + "name": "rhs", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:norm2/#1/rhs" + ], + "wires": [ + { + "from": { + "block": "val:norm2/a", + "port": "out" + }, + "to": { + "block": "val:norm2/#1", + "port": "lhs" + } + }, + { + "from": { + "block": "val:norm2/#1/rhs", + "port": "out" + }, + "to": { + "block": "val:norm2/#1", + "port": "rhs" + } + } + ] + } + }, + "val:norm2/#1/rhs": { + "key": "val:norm2/#1/rhs", + "kind": "Apply", + "span": { + "file": "fil_rouge", + "start": 197, + "end": 204 + }, + "type": "int", + "inputs": [ + { + "name": "callee", + "implicit": false, + "visibility": "Public" + }, + { + "name": "arg0", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:norm2/#1/rhs/arg0" + ], + "wires": [ + { + "from": { + "block": "val:norm2/#1/rhs/arg0", + "port": "y" + }, + "to": { + "block": "val:norm2/#1/rhs", + "port": "arg0" + } + } + ] + } + }, + "val:norm2/#1/rhs/arg0": { + "key": "val:norm2/#1/rhs/arg0", + "kind": "Access", + "span": { + "file": "fil_rouge", + "start": 200, + "end": 202 + }, + "type": "int", + "inputs": [ + { + "name": "value", + "type": "Point", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "y", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [], + "wires": [ + { + "from": { + "block": "val:norm2", + "port": "p" + }, + "to": { + "block": "val:norm2/#1/rhs/arg0", + "port": "value" + } + } + ] + } + }, + "val:norm2/a": { + "key": "val:norm2/a", + "kind": "Apply", + "name": "a", + "span": { + "file": "fil_rouge", + "start": 180, + "end": 192 + }, + "type": "int", + "inputs": [ + { + "name": "callee", + "implicit": false, + "visibility": "Public" + }, + { + "name": "arg0", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:norm2/a/arg0" + ], + "wires": [ + { + "from": { + "block": "val:norm2/a/arg0", + "port": "x" + }, + "to": { + "block": "val:norm2/a", + "port": "arg0" + } + } + ] + } + }, + "val:norm2/a/arg0": { + "key": "val:norm2/a/arg0", + "kind": "Access", + "span": { + "file": "fil_rouge", + "start": 183, + "end": 185 + }, + "type": "int", + "inputs": [ + { + "name": "value", + "type": "Point", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "x", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [], + "wires": [ + { + "from": { + "block": "val:norm2", + "port": "p" + }, + "to": { + "block": "val:norm2/a/arg0", + "port": "value" + } + } + ] + } + }, + "val:p": { + "key": "val:p", + "kind": "Record", + "name": "p", + "span": { + "file": "fil_rouge", + "start": 267, + "end": 286 + }, + "type": "Point", + "inputs": [ + { + "name": "x", + "type": "3", + "implicit": false, + "visibility": "Public" + }, + { + "name": "y", + "type": "4", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "Point", + "implicit": false, + "visibility": "Public" + }, + { + "name": "x", + "type": "3", + "implicit": false, + "visibility": "Public" + }, + { + "name": "y", + "type": "4", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:p/x", + "val:p/y" + ], + "wires": [ + { + "from": { + "block": "val:p/x", + "port": "out" + }, + "to": { + "block": "val:p", + "port": "x" + } + }, + { + "from": { + "block": "val:p/y", + "port": "out" + }, + "to": { + "block": "val:p", + "port": "y" + } + } + ] + } + }, + "val:p/x": { + "key": "val:p/x", + "kind": "Literal", + "span": { + "file": "fil_rouge", + "start": 274, + "end": 275 + }, + "type": "3", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "3", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "val:p/y": { + "key": "val:p/y", + "kind": "Literal", + "span": { + "file": "fil_rouge", + "start": 281, + "end": 283 + }, + "type": "4", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "4", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "val:show": { + "key": "val:show", + "kind": "Function", + "name": "show", + "span": { + "file": "", + "start": 0, + "end": 253 + }, + "type": "fn(p: Point) -> char", + "inputs": [ + { + "name": "p", + "type": "Point", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "char", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:show/#0" + ], + "wires": [] + } + }, + "val:show/#0": { + "key": "val:show/#0", + "kind": "Literal", + "span": { + "file": "fil_rouge", + "start": 242, + "end": 249 + }, + "type": "\"Point\"", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "\"Point\"", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "val:sq": { + "key": "val:sq", + "kind": "Function", + "name": "sq", + "span": { + "file": "", + "start": 0, + "end": 133 + }, + "type": "fn(n: int) -> int", + "inputs": [ + { + "name": "n", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:sq/#0" + ], + "wires": [] + } + }, + "val:sq/#0": { + "key": "val:sq/#0", + "kind": "Operator", + "span": { + "file": "fil_rouge", + "start": 124, + "end": 129 + }, + "type": "int", + "inputs": [ + { + "name": "lhs", + "type": "int", + "implicit": false, + "visibility": "Public" + }, + { + "name": "rhs", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [], + "wires": [ + { + "from": { + "block": "val:sq", + "port": "n" + }, + "to": { + "block": "val:sq/#0", + "port": "lhs" + } + }, + { + "from": { + "block": "val:sq", + "port": "n" + }, + "to": { + "block": "val:sq/#0", + "port": "rhs" + } + } + ] + } + }, + "val:total": { + "key": "val:total", + "kind": "Operator", + "name": "total", + "span": { + "file": "fil_rouge", + "start": 323, + "end": 341 + }, + "type": "int", + "inputs": [ + { + "name": "lhs", + "type": "int", + "implicit": false, + "visibility": "Public" + }, + { + "name": "rhs", + "type": "3", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:total/lhs", + "val:total/rhs" + ], + "wires": [ + { + "from": { + "block": "val:total/lhs", + "port": "out" + }, + "to": { + "block": "val:total", + "port": "lhs" + } + }, + { + "from": { + "block": "val:total/rhs", + "port": "out" + }, + "to": { + "block": "val:total", + "port": "rhs" + } + } + ] + } + }, + "val:total/lhs": { + "key": "val:total/lhs", + "kind": "Operator", + "span": { + "file": "fil_rouge", + "start": 323, + "end": 331 + }, + "type": "int", + "inputs": [ + { + "name": "lhs", + "type": "int", + "implicit": false, + "visibility": "Public" + }, + { + "name": "rhs", + "type": "12", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:total/lhs/lhs", + "val:total/lhs/rhs" + ], + "wires": [ + { + "from": { + "block": "val:total/lhs/lhs", + "port": "out" + }, + "to": { + "block": "val:total/lhs", + "port": "lhs" + } + }, + { + "from": { + "block": "val:total/lhs/rhs", + "port": "out" + }, + "to": { + "block": "val:total/lhs", + "port": "rhs" + } + } + ] + } + }, + "val:total/lhs/lhs": { + "key": "val:total/lhs/lhs", + "kind": "Apply", + "span": { + "file": "fil_rouge", + "start": 323, + "end": 331 + }, + "type": "int", + "inputs": [ + { + "name": "callee", + "implicit": false, + "visibility": "Public" + }, + { + "name": "arg0", + "type": "Point", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [], + "wires": [ + { + "from": { + "block": "val:p", + "port": "out" + }, + "to": { + "block": "val:total/lhs/lhs", + "port": "arg0" + } + } + ] + } + }, + "val:total/lhs/rhs": { + "key": "val:total/lhs/rhs", + "kind": "Literal", + "span": { + "file": "fil_rouge", + "start": 333, + "end": 336 + }, + "type": "12", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "12", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "val:total/rhs": { + "key": "val:total/rhs", + "kind": "Literal", + "span": { + "file": "fil_rouge", + "start": 338, + "end": 339 + }, + "type": "3", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "3", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + } + }, + "relations": [ + { + "kind": "TypePosition", + "from": "val:show", + "to": "type:Point", + "index": 0 + }, + { + "kind": "TypePosition", + "from": "val:norm2", + "to": "type:Point", + "index": 0 + }, + { + "kind": "Ref", + "from": "val:norm2/a", + "to": "val:sq", + "port": "callee", + "confidence": { + "confidence": "exact" + } + }, + { + "kind": "Ref", + "from": "val:norm2/#1/rhs", + "to": "val:sq", + "port": "callee", + "confidence": { + "confidence": "exact" + } + }, + { + "kind": "Ref", + "from": "val:norm2", + "to": "val:sq", + "port": "sq", + "confidence": { + "confidence": "exact" + } + }, + { + "kind": "HasType", + "from": "val:p", + "to": "type:Point" + }, + { + "kind": "Ref", + "from": "val:d", + "to": "type:Printable", + "port": "callee", + "confidence": { + "confidence": "exact" + } + }, + { + "kind": "HasType", + "from": "val:d", + "to": "type:Point" + }, + { + "kind": "Ref", + "from": "val:total/lhs/lhs", + "to": "val:norm2", + "port": "callee", + "confidence": { + "confidence": "exact" + } + } + ] +} diff --git a/crates/typr-graph/tests/totality.rs b/crates/typr-graph/tests/totality.rs new file mode 100644 index 0000000..0cd3d5a --- /dev/null +++ b/crates/typr-graph/tests/totality.rs @@ -0,0 +1,53 @@ +//! Étape 1 acceptance criterion (spec §12): "tout programme qui passe `typr check` produit un +//! graphe sans panique." Replays every `cases/*/repro/TypR/main.ty` — parsed standalone, so a +//! multi-file case that only type-checks as part of its whole project is simply skipped (not a +//! false negative: we only claim totality over programs that *do* pass type-checking here). + +use std::fs; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use typr_core::components::context::Context; +use typr_core::processes::parsing::parse_from_string; +use typr_core::processes::type_checking::type_recorder::with_recording; +use typr_core::processes::type_checking::typing_with_errors; + +fn cases_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../cases") +} + +#[test] +fn builds_every_typechecking_case_without_panicking() { + let cases_dir = cases_dir(); + let mut checked = 0usize; + let mut skipped = 0usize; + + for entry in fs::read_dir(&cases_dir).expect("cases/ directory should exist at the workspace root") { + let entry = entry.expect("readable cases/ entry"); + let main_ty = entry.path().join("repro/TypR/main.ty"); + if !main_ty.is_file() { + continue; + } + let source = fs::read_to_string(&main_ty).expect("readable main.ty"); + let file_name = main_ty.to_string_lossy().to_string(); + + let lang = catch_unwind(AssertUnwindSafe(|| parse_from_string(&source, &file_name))); + let Ok(lang) = lang else { + skipped += 1; + continue; + }; + + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + if result.has_errors() { + skipped += 1; + continue; + } + + // Built from the originally parsed tree, not `result.type_context.lang` — `typing()` on + // a `Lines` node returns only its last statement's rewritten form, not the whole program. + let outcome = catch_unwind(AssertUnwindSafe(|| typr_graph::build(&lang, &result.type_context.context, &table))); + assert!(outcome.is_ok(), "typr-graph panicked building {}", main_ty.display()); + checked += 1; + } + + assert!(checked > 0, "expected at least one cases/ repro to typecheck standalone and be built"); + eprintln!("totality: built {checked} case(s) without panicking, skipped {skipped} (parse/typecheck failure or multi-file dependency)"); +} diff --git a/crates/typr-graph/tests/walkthrough.rs b/crates/typr-graph/tests/walkthrough.rs new file mode 100644 index 0000000..ac7dcdb --- /dev/null +++ b/crates/typr-graph/tests/walkthrough.rs @@ -0,0 +1,46 @@ +//! Snapshot test on the walkthrough example (`visualization_graph_v2.md` §14), the same program +//! already used in `typr-core`'s `type_recorder` tests to validate recording. Run `cargo insta +//! review` after a deliberate change to the builder to re-freeze the snapshot. + +use typr_core::components::context::Context; +use typr_core::processes::parsing::parse_from_string; +use typr_core::processes::type_checking::type_recorder::with_recording; +use typr_core::processes::type_checking::typing_with_errors; + +const FIL_ROUGE: &str = r#" +type Printable <- interface { show: (Self) -> char }; +type Point <- list { x: int, y: int }; + +let sq <- fn(n: int): int { n * n }; + +let norm2 <- fn(p: Point): int { + let a <- sq(p$x); + a + sq(p$y) +}; + +let show <- fn(p: Point): char { "Point" }; + +let p <- Point:{ x = 3, y = 4 }; +let d <- Printable(p); +let total <- norm2(p) + 12 + 3; +"#; + +#[test] +fn walkthrough_example_builds_without_panicking() { + let lang = parse_from_string(FIL_ROUGE, "fil_rouge"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + assert!(!result.has_errors(), "walkthrough example should typecheck cleanly: {:?}", result.display_errors()); + + // `typing()` on a `Lines` node returns the *last statement's* rewritten `Lang` in + // `type_context.lang` (mirroring a `Scope`'s "value is its last expression"), not the whole + // program — so the graph is built from the originally parsed tree, whose spans are still + // what the `TypeTable` was recorded against (spec §7.1's `build(&Lang, &Context, &TypeTable)` + // takes the parsed AST, not `typing()`'s return value). + let graph = typr_graph::build(&lang, &result.type_context.context, &table); + + assert!(graph.blocks.contains_key(&typr_graph::BlockKey::top_level(typr_graph::Namespace::Val, "norm2"))); + assert!(graph.blocks.contains_key(&typr_graph::BlockKey::top_level(typr_graph::Namespace::Type, "Point"))); + + let json = serde_json::to_string_pretty(&graph).unwrap(); + insta::assert_snapshot!(json); +} diff --git a/visualization_graph_v2.md b/visualization_graph_v2.md index 75718ac..b5153b5 100644 --- a/visualization_graph_v2.md +++ b/visualization_graph_v2.md @@ -442,13 +442,30 @@ Notes d'implémentation (à connaître pour la suite) : fail seulement en parallèle) préexistent sur `develop` — vérifié en stashant ce travail ; aucun lien avec ce chantier. -**Étape 1 — Crate `typr-graph`, modèle et builder** +**Étape 1 — Crate `typr-graph`, modèle et builder** — fait (2026-09-25) - Blocs de l'étape 1 du §4, fils locaux, captures (§3.3), `Ref` avec `confidence`, `HasType`, `TypePosition{0}`. - Totalité : tout programme qui passe `typr check` produit un graphe sans panique (test sur tous les `cases/` et exemples de la doc) ; variants non couverts → `Opaque`. - Tests par instantané sur des programmes courts, dont l'exemple fil rouge. +Notes d'implémentation (à connaître pour la suite) : +- `Lang::Operator{lhs, rhs}` a ses champs inversés par rapport à l'ordre syntaxique dans tout + `typr-core` (`Op::combine`, `to_module_helper`, les bras `dollar_access`/`dot_pipe_access` de + `processes/type_checking/mod.rs`) : `rhs` porte l'opérande *gauche*, `lhs` l'opérande *droite*. + `typr-graph` compense une fois, à l'unique endroit où `Lang::Operator` est déstructuré + (`crates/typr-graph/src/build/blocks.rs`). +- `typing()` sur `Lines`/`Scope` ne renvoie dans `TypeContext.lang` que la dernière instruction + réécrite (comme la valeur d'un bloc), pas le programme entier : le builder part donc du `Lang` + original (`parse_from_string`), pas de `result.type_context.lang`. +- `BlockGraph.blocks` est un `BTreeMap` (pas `HashMap`) pour un JSON déterministe d'un run à + l'autre — nécessaire pour que les instantanés `insta` soient reproductibles. +- Simplifications assumées, à revoir si un cas réel l'exige : une capture ne remonte que jusqu'à + la frontière la *plus proche* (pas de bubbling complet à travers des fonctions imbriquées, + §3.3) ; les fils (`wires`) ne sont enregistrés qu'entre un bloc composé et ses enfants directs, + pas au travers de plusieurs niveaux ; `Access` reçoit un corps (même vide) pour rester + cohérent avec les autres blocs à opérandes, contrairement à la case « Intérieur : — » du §4. + **Étape 2 — CLI** - `typr graph [--format json|dot] [--focus ] [--projection deps|types]`. - DOT : un niveau (le bloc `--focus`) avec ses ports. From 80a6c1e94d6032e432686847fc6c3e3bc8573737 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Fri, 25 Sep 2026 21:49:29 +0200 Subject: [PATCH 24/52] =?UTF-8?q?typr-graph:=20add=20`typr=20graph`=20CLI?= =?UTF-8?q?=20command=20(=C3=A9tape=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typr graph [--format json|dot] [--focus ] [--projection deps|types] exposes the typr-graph builder from the CLI. Adds BlockGraph::one_level (the one-level view shared by DOT export and JSON --focus), BlockKey helpers (from_raw/is_top_level/top_level_ancestor), the deps/types projections from spec §8, and JSON/DOT exporters (DOT renders one level at a time as Graphviz records with ports). Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 1 + crates/typr-cli/Cargo.toml | 1 + crates/typr-cli/src/cli.rs | 25 ++++ crates/typr-cli/src/graph.rs | 86 ++++++++++++ crates/typr-cli/src/lib.rs | 1 + crates/typr-cli/src/main.rs | 1 + crates/typr-graph/src/export/dot.rs | 120 +++++++++++++++++ crates/typr-graph/src/export/json.rs | 7 + crates/typr-graph/src/export/mod.rs | 5 + crates/typr-graph/src/key.rs | 23 ++++ crates/typr-graph/src/lib.rs | 2 + crates/typr-graph/src/model.rs | 30 ++++- crates/typr-graph/src/project.rs | 193 +++++++++++++++++++++++++++ 13 files changed, 494 insertions(+), 1 deletion(-) create mode 100644 crates/typr-cli/src/graph.rs create mode 100644 crates/typr-graph/src/export/dot.rs create mode 100644 crates/typr-graph/src/export/json.rs create mode 100644 crates/typr-graph/src/export/mod.rs create mode 100644 crates/typr-graph/src/project.rs diff --git a/Cargo.lock b/Cargo.lock index 09aa59f..8b8edc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1931,6 +1931,7 @@ dependencies = [ "tokio", "toml", "typr-core", + "typr-graph", "typr-lsp", "typr-mcp", ] diff --git a/crates/typr-cli/Cargo.toml b/crates/typr-cli/Cargo.toml index 7e2bb0c..8a5eb7f 100644 --- a/crates/typr-cli/Cargo.toml +++ b/crates/typr-cli/Cargo.toml @@ -21,6 +21,7 @@ path = "src/lib.rs" # Use typr-core for core logic. `fuzz-gen` exposes utils::program_gen (Phase B # Stage 2's `typr fuzz`, soundness_transpilation.md) — see typr-core/Cargo.toml. typr-core = { workspace = true, features = ["fuzz-gen"] } +typr-graph.workspace = true # Pure dependencies (needed for parsing/type manipulation) nom.workspace = true diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index c071e89..2cd3062 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -45,6 +45,25 @@ enum Commands { #[arg(value_name = "FILE")] file: Option, }, + /// Emit the block-graph view of a file (`visualization_graph_v2.md`): the program broken + /// into typed blocks with their ports, wires, and cross-block `Ref`/`HasType`/`TypePosition` + /// relations — for code review and understanding, not for building. + Graph { + #[arg(value_name = "FILE")] + file: PathBuf, + /// `json` (the full block map, spec §9) or `dot` (one level, rendered with Graphviz). + #[arg(long, value_name = "FORMAT", default_value = "json")] + format: String, + /// Restrict the view to one block and its direct children (a `BlockKey` like + /// `val:norm2`, printed by a previous `--format json` run). Required in practice for + /// `--format dot` on anything but the top level; defaults to the whole program there. + #[arg(long, value_name = "KEY")] + focus: Option, + /// `deps` (top-level blocks + flattened `Ref` edges) or `types` (`TypeDecl`/`Interface` + /// blocks + type relations). + #[arg(long, value_name = "PROJECTION")] + projection: Option, + }, Build { #[arg(value_name = "FILE")] file: Option, @@ -433,6 +452,12 @@ pub fn start() { Some(path) => check_file(&path), _ => check_project(), }, + Some(Commands::Graph { + file, + format, + focus, + projection, + }) => crate::graph::graph_file(&file, &format, focus.as_deref(), projection.as_deref()), Some(Commands::Build { file, test, diff --git a/crates/typr-cli/src/graph.rs b/crates/typr-cli/src/graph.rs new file mode 100644 index 0000000..45c4536 --- /dev/null +++ b/crates/typr-cli/src/graph.rs @@ -0,0 +1,86 @@ +//! `typr graph` — emits the block-graph view of a single TypR file +//! (`visualization_graph_v2.md` §12 étape 2). +//! +//! Deliberately mirrors `typr-graph`'s own tests (spec §7.1) rather than the project-wide +//! machinery `typr check`/`typr build` use: `parse_from_string` → `with_recording(|| +//! typing_with_errors(...))` → `typr_graph::build`. Multi-file projects (`module`/`use`/`import`) +//! are out of scope until étape 5 (spec §13 open question F). + +use std::path::Path; +use typr_core::components::context::Context; +use typr_core::processes::parsing::parse_from_string; +use typr_core::processes::type_checking::type_recorder::with_recording; +use typr_core::processes::type_checking::typing_with_errors; +use typr_graph::export::{dot, json}; +use typr_graph::project::{project, Projection}; +use typr_graph::{BlockGraph, BlockKey}; + +pub fn graph_file(path: &Path, format: &str, focus: Option<&str>, projection: Option<&str>) { + let source = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + eprintln!("Error reading file {:?}: {}", path, e); + std::process::exit(1); + } + }; + + let lang = parse_from_string(&source, &path.to_string_lossy()); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + if result.has_errors() { + eprintln!("Type errors found:"); + for err in result.display_errors() { + eprintln!(" - {}", err); + } + std::process::exit(1); + } + + let mut graph = typr_graph::build(&lang, &result.type_context.context, &table); + + if let Some(name) = projection { + graph = match name { + "deps" | "dependencies" => project(&graph, Projection::Dependencies), + "types" => project(&graph, Projection::Types), + other => { + eprintln!("Unknown projection {:?} (expected \"deps\" or \"types\")", other); + std::process::exit(1); + } + }; + } + + match format { + "json" => print_json(&graph, focus), + "dot" => print_dot(&graph, focus), + other => { + eprintln!("Unknown format {:?} (expected \"json\" or \"dot\")", other); + std::process::exit(1); + } + } +} + +fn print_json(graph: &BlockGraph, focus: Option<&str>) { + let view = match focus { + Some(key) => match graph.one_level(&BlockKey::from_raw(key)) { + Some(g) => g, + None => { + eprintln!("No block with key {:?} in this graph", key); + std::process::exit(1); + } + }, + None => graph.clone(), + }; + println!( + "{}", + json::to_string_pretty(&view).expect("BlockGraph always serializes") + ); +} + +fn print_dot(graph: &BlockGraph, focus: Option<&str>) { + let focus_key = focus.map(BlockKey::from_raw).unwrap_or_else(BlockKey::program_root); + match dot::to_dot(graph, &focus_key) { + Some(rendered) => println!("{}", rendered), + None => { + eprintln!("No block with key {:?} in this graph", focus_key.as_str()); + std::process::exit(1); + } + } +} diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index d9c9394..fdfdc8f 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -46,6 +46,7 @@ pub mod engine; pub mod format_r; pub mod fuzz; pub mod gen_types; +pub mod graph; pub mod io; pub mod md_renderer; pub mod metaprogramming; diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index 89e285f..ec750f1 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -9,6 +9,7 @@ mod engine; mod format_r; mod fuzz; mod gen_types; +mod graph; mod io; mod md_renderer; mod metaprogramming; diff --git a/crates/typr-graph/src/export/dot.rs b/crates/typr-graph/src/export/dot.rs new file mode 100644 index 0000000..c7e5fa9 --- /dev/null +++ b/crates/typr-graph/src/export/dot.rs @@ -0,0 +1,120 @@ +//! DOT export — one level at a time (spec §12 étape 2: "un niveau (le bloc `--focus`) avec ses +//! ports"). Renders [`BlockGraph::one_level`]: the focus block's direct children as record nodes +//! with their ports as fields, plus solid wire edges and dashed `Ref` edges. +//! +//! A wire's `to` is always some port inside the block that was being built when the builder +//! wired it in (`build_operand`'s `owner`) — sometimes that's the focus block's own input feeding +//! straight into a child (e.g. an `Apply`'s own body wires an argument in), sometimes it's a +//! child's own body wiring one of *its* ports from an ancestor's port or a sibling's output +//! (spec §7.1, see `typr-graph` builder notes). So a one-level view has to collect wires from +//! every block in the level, not just the focus block's own `body.wires`. + +use crate::key::BlockKey; +use crate::model::{Block, RelationKind}; +use crate::BlockGraph; +use std::fmt::Write as _; + +/// `None` if `focus` isn't a block of `graph`. +pub fn to_dot(graph: &BlockGraph, focus: &BlockKey) -> Option { + let level = graph.one_level(focus)?; + let mut out = String::new(); + let _ = writeln!(out, "digraph \"{}\" {{", escape(focus.as_str())); + let _ = writeln!(out, " rankdir=LR;"); + let _ = writeln!(out, " node [shape=record, fontsize=10];"); + + for (key, block) in &level.blocks { + write_node(&mut out, key, block); + } + + for block in level.blocks.values() { + let Some(body) = &block.body else { continue }; + for wire in &body.wires { + let _ = writeln!( + out, + " \"{}\":\"{}\" -> \"{}\":\"{}\";", + escape(wire.from.block.as_str()), + escape(&wire.from.port), + escape(wire.to.block.as_str()), + escape(&wire.to.port), + ); + } + } + + for rel in &level.relations { + if rel.kind != RelationKind::Ref { + continue; + } + let label = rel.port.as_deref().unwrap_or(""); + let _ = writeln!( + out, + " \"{}\" -> \"{}\" [style=dashed, label=\"{}\"];", + escape(rel.from.as_str()), + escape(rel.to.as_str()), + escape(label), + ); + } + + let _ = writeln!(out, "}}"); + Some(out) +} + +fn write_node(out: &mut String, key: &BlockKey, block: &Block) { + let name = block.name.clone().unwrap_or_else(|| format!("{:?}", block.kind)); + let inputs = ports_field(block.inputs.iter().map(|p| p.name.as_str())); + let outputs = ports_field(block.outputs.iter().map(|p| p.name.as_str())); + let label = match (inputs.is_empty(), outputs.is_empty()) { + (true, true) => escape(&name), + (false, true) => format!("{{{}}}|{}", inputs, escape(&name)), + (true, false) => format!("{}|{{{}}}", escape(&name), outputs), + (false, false) => format!("{{{}}}|{}|{{{}}}", inputs, escape(&name), outputs), + }; + let _ = writeln!(out, " \"{}\" [label=\"{}\"];", escape(key.as_str()), label); +} + +/// A `{label|label|...}` record field group. +fn ports_field<'a>(ports: impl Iterator) -> String { + ports + .map(|p| format!("<{}> {}", escape(p), escape(p))) + .collect::>() + .join("|") +} + +fn escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::key::Namespace; + use typr_core::components::context::Context; + use typr_core::processes::parsing::parse_from_string; + use typr_core::processes::type_checking::type_recorder::with_recording; + use typr_core::processes::type_checking::typing_with_errors; + + #[test] + fn one_level_dot_view_has_child_nodes_and_wires() { + let source = "let sq <- fn(n: int): int { n * n };"; + let lang = parse_from_string(source, "dot_test"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + assert!(!result.has_errors(), "{:?}", result.display_errors()); + let graph = crate::build(&lang, &result.type_context.context, &table); + + let sq = BlockKey::top_level(Namespace::Val, "sq"); + let dot = to_dot(&graph, &sq).expect("sq is a block of this graph"); + assert!(dot.starts_with("digraph")); + assert!(dot.contains(&format!("\"{}\"", sq.as_str()))); + assert!(dot.contains(" -> "), "expected at least one edge in:\n{dot}"); + } + + #[test] + fn missing_focus_returns_none() { + let source = "let x <- 1;"; + let lang = parse_from_string(source, "dot_missing_test"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + assert!(!result.has_errors()); + let graph = crate::build(&lang, &result.type_context.context, &table); + + assert!(to_dot(&graph, &BlockKey::from_raw("val:does-not-exist")).is_none()); + } +} diff --git a/crates/typr-graph/src/export/json.rs b/crates/typr-graph/src/export/json.rs new file mode 100644 index 0000000..39cf0f4 --- /dev/null +++ b/crates/typr-graph/src/export/json.rs @@ -0,0 +1,7 @@ +//! JSON export — the external contract of spec §9, verbatim: `serde_json` over [`BlockGraph`]. + +use crate::model::BlockGraph; + +pub fn to_string_pretty(graph: &BlockGraph) -> serde_json::Result { + serde_json::to_string_pretty(graph) +} diff --git a/crates/typr-graph/src/export/mod.rs b/crates/typr-graph/src/export/mod.rs new file mode 100644 index 0000000..127a3d2 --- /dev/null +++ b/crates/typr-graph/src/export/mod.rs @@ -0,0 +1,5 @@ +//! Serializations of a [`crate::model::BlockGraph`] (spec §10): `json` for the external contract +//! (§9), `dot` for a one-level Graphviz view (§12 étape 2). + +pub mod dot; +pub mod json; diff --git a/crates/typr-graph/src/key.rs b/crates/typr-graph/src/key.rs index 34eaade..ba74499 100644 --- a/crates/typr-graph/src/key.rs +++ b/crates/typr-graph/src/key.rs @@ -37,12 +37,35 @@ impl BlockKey { &self.0 } + /// Reconstructs a key from its printed form (`"val:norm2/a"`) — for reading one back (CLI + /// `--focus`, a key round-tripped through JSON), never for synthesizing a fresh one; use the + /// segment-by-segment constructors below for that. + pub fn from_raw(raw: impl Into) -> Self { + BlockKey(raw.into()) + } + /// A top-level declaration's own key (`val:norm2`, `type:Point`) — top-level names live /// directly under their namespace, not nested under the synthetic `Program` block. pub fn top_level(namespace: Namespace, name: &str) -> Self { BlockKey(format!("{namespace}:{name}")) } + /// Whether this key names a top-level declaration directly (no `/` segment) — used by the + /// "dependencies" projection (spec §8) to pick which blocks survive flattening. + pub fn is_top_level(&self) -> bool { + !self.0.contains('/') + } + + /// The top-level declaration a nested key lives under (`val:norm2/a` → `val:norm2`); a + /// top-level key is its own ancestor. Used by the "dependencies" projection to collapse a + /// capture chain down to an edge between two top-level blocks (spec §8). + pub fn top_level_ancestor(&self) -> Self { + match self.0.split_once('/') { + Some((head, _)) => BlockKey(head.to_string()), + None => self.clone(), + } + } + /// The synthetic root block representing the whole program. `@` can't start a TypR /// identifier, so this can never collide with a real top-level name. pub fn program_root() -> Self { diff --git a/crates/typr-graph/src/lib.rs b/crates/typr-graph/src/lib.rs index 2ed30ef..df9a64b 100644 --- a/crates/typr-graph/src/lib.rs +++ b/crates/typr-graph/src/lib.rs @@ -5,8 +5,10 @@ //! contract (spec §9). Pure logic, no I/O, WASM-safe. mod build; +pub mod export; pub mod key; pub mod model; +pub mod project; pub use key::{BlockKey, Namespace}; pub use model::BlockGraph; diff --git a/crates/typr-graph/src/model.rs b/crates/typr-graph/src/model.rs index c5ca75a..0eea8fb 100644 --- a/crates/typr-graph/src/model.rs +++ b/crates/typr-graph/src/model.rs @@ -4,7 +4,7 @@ use crate::key::BlockKey; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; pub const FORMAT: &str = "typr-block-graph"; pub const FORMAT_VERSION: u32 = 1; @@ -212,4 +212,32 @@ impl BlockGraph { pub fn insert(&mut self, block: Block) { self.blocks.insert(block.key.clone(), block); } + + /// The one-level view centered on `focus` (spec §11's "un niveau rendu à la fois", spec §12 + /// étape 2's DOT export): `focus` itself plus its direct children, and every relation with at + /// least one endpoint in that set. `None` if `focus` isn't a block of this graph. + pub fn one_level(&self, focus: &BlockKey) -> Option { + let block = self.blocks.get(focus)?; + let mut out = BlockGraph::new(focus.clone()); + out.blocks.insert(focus.clone(), block.clone()); + + let mut scope: BTreeSet = BTreeSet::new(); + scope.insert(focus.clone()); + if let Some(body) = &block.body { + for child in &body.children { + if let Some(child_block) = self.blocks.get(child) { + out.blocks.insert(child.clone(), child_block.clone()); + scope.insert(child.clone()); + } + } + } + + out.relations = self + .relations + .iter() + .filter(|r| scope.contains(&r.from) || scope.contains(&r.to)) + .cloned() + .collect(); + Some(out) + } } diff --git a/crates/typr-graph/src/project.rs b/crates/typr-graph/src/project.rs new file mode 100644 index 0000000..604091d --- /dev/null +++ b/crates/typr-graph/src/project.rs @@ -0,0 +1,193 @@ +//! Projections (spec §8): flatten the block hierarchy and filter by relation/block kind. A +//! projection always drops nested bodies — it's a flat map of the blocks it keeps, plus the +//! relations whose kind it keeps, each re-pointed at whichever kept block is its nearest match +//! ("on remplace chaque chaîne capture → `Ref` par une arête directe", spec §8). + +use crate::key::BlockKey; +use crate::model::{BlockGraph, BlockKind, Relation, RelationKind}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Projection { + /// Top-level named blocks + flattened `Ref` edges (étape 2). + Dependencies, + /// `TypeDecl`/`Interface` blocks plus the type-relation kinds that touch them. `Satisfies`, + /// `DeclaredAs` and `Subtype` don't exist in the graph yet (étape 3): until then this + /// projection only ever has `HasType`/`TypePosition` edges to show. + Types, +} + +pub fn project(graph: &BlockGraph, projection: Projection) -> BlockGraph { + match projection { + Projection::Dependencies => dependencies(graph), + Projection::Types => types(graph), + } +} + +fn dependencies(graph: &BlockGraph) -> BlockGraph { + let mut out = BlockGraph::new(graph.root.clone()); + for (key, block) in &graph.blocks { + // The synthetic Program root is top-level-shaped (no `/`) but isn't itself a "named" + // declaration (spec §8) — left out so the projection is purely the named blocks. + if key.is_top_level() && *key != graph.root { + let mut flat = block.clone(); + flat.body = None; + out.blocks.insert(key.clone(), flat); + } + } + + let mut seen: BTreeSet<(BlockKey, BlockKey)> = BTreeSet::new(); + for rel in &graph.relations { + if rel.kind != RelationKind::Ref { + continue; + } + let from = rel.from.top_level_ancestor(); + let to = rel.to.top_level_ancestor(); + if from == to || !out.blocks.contains_key(&from) || !out.blocks.contains_key(&to) { + continue; + } + if !seen.insert((from.clone(), to.clone())) { + continue; + } + out.relations.push(Relation { + kind: RelationKind::Ref, + from, + to, + port: None, + index: None, + confidence: rel.confidence.clone(), + }); + } + out +} + +fn types(graph: &BlockGraph) -> BlockGraph { + let mut out = BlockGraph::new(graph.root.clone()); + + let kept: Vec<&Relation> = graph + .relations + .iter() + .filter(|r| { + matches!( + r.kind, + RelationKind::HasType + | RelationKind::TypePosition + | RelationKind::Satisfies + | RelationKind::DeclaredAs + | RelationKind::Subtype + ) + }) + .collect(); + + // Every TypeDecl/Interface, plus whichever value blocks a kept relation points from (e.g. a + // `HasType` edge's `from` is the value block that has the type, not a type block itself) — + // otherwise those relations would dangle at a block this projection dropped. + let mut keys: BTreeSet = graph + .blocks + .iter() + .filter(|(_, b)| matches!(b.kind, BlockKind::TypeDecl | BlockKind::Interface)) + .map(|(k, _)| k.clone()) + .collect(); + for rel in &kept { + keys.insert(rel.from.clone()); + keys.insert(rel.to.clone()); + } + + for key in keys { + if let Some(block) = graph.blocks.get(&key) { + let mut flat = block.clone(); + flat.body = None; + out.blocks.insert(key, flat); + } + } + out.relations = kept.into_iter().cloned().collect(); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::key::Namespace; + use typr_core::components::context::Context; + use typr_core::processes::parsing::parse_from_string; + use typr_core::processes::type_checking::type_recorder::with_recording; + use typr_core::processes::type_checking::typing_with_errors; + + const SOURCE: &str = r#" +type Point <- list { x: int, y: int }; + +let sq <- fn(n: int): int { n * n }; + +let norm2 <- fn(p: Point): int { + let a <- sq(p$x); + a + sq(p$y) +}; + +let total <- norm2(Point:{ x = 3, y = 4 }); +"#; + + fn build_graph() -> BlockGraph { + let lang = parse_from_string(SOURCE, "project_test"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + assert!(!result.has_errors(), "{:?}", result.display_errors()); + crate::build(&lang, &result.type_context.context, &table) + } + + #[test] + fn dependencies_flattens_captures_into_top_level_edges() { + let graph = build_graph(); + let deps = project(&graph, Projection::Dependencies); + + // Every kept block is top-level and the synthetic root is gone. + assert!(deps.blocks.keys().all(|k| k.is_top_level())); + assert!(!deps.blocks.contains_key(&graph.root)); + + // `norm2`'s body calls `sq` twice, nested two levels deep (`norm2/a` and `norm2/#1/lhs` + // or similar) — the projection must still surface a single top-level `norm2` -> `sq` + // edge, not one per call site and not one that still points at the nested key. + let norm2 = BlockKey::top_level(Namespace::Val, "norm2"); + let sq = BlockKey::top_level(Namespace::Val, "sq"); + let norm2_to_sq: Vec<_> = deps + .relations + .iter() + .filter(|r| r.kind == RelationKind::Ref && r.from == norm2 && r.to == sq) + .collect(); + assert_eq!( + norm2_to_sq.len(), + 1, + "expected exactly one flattened norm2 -> sq edge, got {:?}", + norm2_to_sq + ); + + // `total` calls `norm2`. + let total = BlockKey::top_level(Namespace::Val, "total"); + assert!(deps + .relations + .iter() + .any(|r| r.kind == RelationKind::Ref && r.from == total && r.to == norm2)); + + // Flattening never introduces a self-loop. + assert!(deps.relations.iter().all(|r| r.from != r.to)); + } + + #[test] + fn types_keeps_type_decls_and_has_type_edges() { + let graph = build_graph(); + let types_view = project(&graph, Projection::Types); + + let point = BlockKey::top_level(Namespace::Type, "Point"); + assert!(types_view.blocks.contains_key(&point)); + assert!(types_view + .relations + .iter() + .any(|r| r.kind == RelationKind::HasType && r.to == point)); + assert!(types_view.relations.iter().all(|r| matches!( + r.kind, + RelationKind::HasType + | RelationKind::TypePosition + | RelationKind::Satisfies + | RelationKind::DeclaredAs + | RelationKind::Subtype + ))); + } +} From c0b55b4e59fecd6c8e66de8e9634ec6741906a8e Mon Sep 17 00:00:00 2001 From: Fabrice Date: Fri, 25 Sep 2026 22:00:50 +0200 Subject: [PATCH 25/52] =?UTF-8?q?typr-graph:=20add=20Satisfies/DeclaredAs/?= =?UTF-8?q?Subtype=20type=20relations=20(=C3=A9tape=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New post-pass (build/type_relations.rs) over the program's top-level Lang::Alias declarations, run once every TypeDecl/Interface block already exists: - Satisfies: computed with typr-core's own interface_satisfaction::check_interface_satisfaction (not reimplemented), with per-method evidence pointing at the free function that provides it, or at the TypeDecl itself when the requirement is met structurally. - DeclaredAs: detected directly on an alias's as-written target_type for a `Record & NamedInterface` intersection. - Subtype: pairwise TypeSystem::is_subtype between declared type aliases (deliberately not a walk of Context.subtypes' internal tree, which only holds reduced/anonymous structural types and would need an unreliable reverse name lookup — see visualization_graph_v2.md §12 étape 3 for the full rationale). Also factors discover_methods (TypeDecl's Q4 method discovery) out of build_type_decl into a shared helper, since Satisfies evidence needs the same lookup. Co-Authored-By: Claude Sonnet 5 --- crates/typr-graph/src/build/blocks.rs | 44 ++-- crates/typr-graph/src/build/mod.rs | 3 + crates/typr-graph/src/build/type_relations.rs | 190 ++++++++++++++++++ crates/typr-graph/src/model.rs | 57 ++++++ crates/typr-graph/src/project.rs | 1 + ...ough_example_builds_without_panicking.snap | 11 + visualization_graph_v2.md | 36 +++- 7 files changed, 323 insertions(+), 19 deletions(-) create mode 100644 crates/typr-graph/src/build/type_relations.rs diff --git a/crates/typr-graph/src/build/blocks.rs b/crates/typr-graph/src/build/blocks.rs index 5276579..57eee25 100644 --- a/crates/typr-graph/src/build/blocks.rs +++ b/crates/typr-graph/src/build/blocks.rs @@ -7,6 +7,7 @@ use super::{variable_name, Builder}; use crate::key::{BlockKey, Namespace}; use crate::model::*; use std::collections::HashSet; +use typr_core::components::context::Context; use typr_core::components::language::argument_value::ArgumentValue; use typr_core::components::language::operators::Op; use typr_core::components::language::Lang; @@ -18,7 +19,7 @@ use typr_core::components::r#type::Type; /// A parameter/field/method name, read the same way `ArgumentType::get_argument_str` does but /// without its `panic!` on an unexpected shape (a generic or embedded parameter, say) — falls /// back to `"_"` instead, since totality here matters more than a perfect label. -fn safe_argument_name(arg: &ArgumentType) -> String { +pub(super) fn safe_argument_name(arg: &ArgumentType) -> String { match arg.get_argument() { Type::Char(Tchar::Val(s), _) => s.to_string(), Type::LabelGen(s, _) => s.to_uppercase(), @@ -26,13 +27,35 @@ fn safe_argument_name(arg: &ArgumentType) -> String { } } -fn alias_name(ty: &Type) -> Option { +pub(super) fn alias_name(ty: &Type) -> Option { match ty { Type::Alias(name, ..) => Some(name.clone()), _ => None, } } +/// A type's interface per Q4 ("l'interface d'un type = ses méthodes"): every top-level function +/// whose first parameter is `type_name`, discovered via `TypePosition{index: 0}` rather than a +/// declared `impl`. Shared between `build_type_decl` (its `TypeDecl` outputs) and the type +/// relations pass (étape 3's `Satisfies` evidence). +pub(super) fn discover_methods(context: &Context, type_name: &str) -> Vec<(String, String, BlockKey)> { + context + .variables() + .filter_map(|(var, ty)| match ty { + Type::Function(params, _, _) => { + let p0 = params.first()?; + if alias_name(&p0.get_type()).as_deref() == Some(type_name) { + let method_name = var.get_name(); + Some((method_name.clone(), ty.pretty(), BlockKey::top_level(Namespace::Val, &method_name))) + } else { + None + } + } + _ => None, + }) + .collect() +} + fn body_statements(body: &Lang) -> Vec<&Lang> { match body { Lang::Scope { body, .. } => body.iter().collect(), @@ -358,22 +381,7 @@ impl<'a> Builder<'a> { if let Some(type_name) = &name { // Q4: a TypeDecl's interface is its methods — functions taking it as their 1st // parameter, discovered via `TypePosition{index: 0}` rather than a declared `impl`. - let methods: Vec<(String, String, BlockKey)> = self - .context - .variables() - .filter_map(|(var, ty)| match ty { - Type::Function(params, _, _) => { - let p0 = params.first()?; - if alias_name(&p0.get_type()).as_deref() == Some(type_name.as_str()) { - let method_name = var.get_name(); - Some((method_name.clone(), ty.pretty(), BlockKey::top_level(Namespace::Val, &method_name))) - } else { - None - } - } - _ => None, - }) - .collect(); + let methods = discover_methods(self.context, type_name); for (method_name, pretty, method_key) in methods { outputs.push(Port::explicit(method_name, Some(pretty))); self.graph.relations.push(Relation::type_position(method_key, key.clone(), 0)); diff --git a/crates/typr-graph/src/build/mod.rs b/crates/typr-graph/src/build/mod.rs index 429a9aa..2a1eee8 100644 --- a/crates/typr-graph/src/build/mod.rs +++ b/crates/typr-graph/src/build/mod.rs @@ -3,6 +3,7 @@ mod blocks; mod refs; mod scope; +mod type_relations; use crate::key::{BlockKey, Namespace}; use crate::model::*; @@ -42,6 +43,8 @@ pub fn build(lang: &Lang, context: &Context, types: &TypeTable) -> BlockGraph { b.build_top_level_item(item, index, &mut children); } + type_relations::build(&mut b.graph, b.context, &items); + b.scope.pop(); let (inputs, refs) = b.take_captures(&root_key); b.graph.relations.extend(refs); diff --git a/crates/typr-graph/src/build/type_relations.rs b/crates/typr-graph/src/build/type_relations.rs new file mode 100644 index 0000000..036eeaf --- /dev/null +++ b/crates/typr-graph/src/build/type_relations.rs @@ -0,0 +1,190 @@ +//! Type relations pass (spec §5.1, étape 3: `Satisfies`, `DeclaredAs`, `Subtype`). +//! +//! A post-pass over the program's top-level `Lang::Alias` declarations, run once every +//! `TypeDecl`/`Interface` block already exists (spec §7.1 step 4). Not folded into `build_expr`'s +//! per-node recursion because these relations compare *pairs* of top-level declarations against +//! each other, not a single node against its children. + +use super::blocks::{alias_name, discover_methods, safe_argument_name}; +use super::variable_name; +use crate::key::{BlockKey, Namespace}; +use crate::model::{BlockGraph, Evidence, Relation}; +use std::collections::HashSet; +use typr_core::components::context::Context; +use typr_core::components::language::Lang; +use typr_core::components::r#type::argument_type::ArgumentType; +use typr_core::components::r#type::type_operator::TypeOperator; +use typr_core::components::r#type::type_system::TypeSystem; +use typr_core::components::r#type::Type; +use typr_core::processes::type_checking::interface_satisfaction::check_interface_satisfaction; + +struct TypeAlias { + name: String, + key: BlockKey, + /// The type exactly as written in `type <- ;` — not reduced, so an + /// intersection member's own alias name is still visible for `DeclaredAs`. + target_type: Type, + /// `Interface`'s required methods, once reduced — `None` for every other kind of alias. + interface_methods: Option>, +} + +pub(super) fn build(graph: &mut BlockGraph, context: &Context, items: &[&Lang]) { + let aliases = collect_type_aliases(context, items); + let (interfaces, type_decls): (Vec<&TypeAlias>, Vec<&TypeAlias>) = + aliases.iter().partition(|a| a.interface_methods.is_some()); + + for decl in &type_decls { + declared_as(graph, context, decl); + for iface in &interfaces { + satisfies(graph, context, decl, iface); + } + for other in &type_decls { + if other.key != decl.key && decl.target_type.is_subtype(&other.target_type, context).0 { + graph.relations.push(Relation::subtype(decl.key.clone(), other.key.clone())); + } + } + } +} + +fn collect_type_aliases(context: &Context, items: &[&Lang]) -> Vec { + items + .iter() + .filter_map(|item| match item { + Lang::Alias { identifier, target_type, .. } => { + let name = variable_name(identifier)?; + let key = BlockKey::top_level(Namespace::Type, &name); + let interface_methods = match target_type.reduce(context) { + Type::Interface(methods, _) => Some(methods), + _ => None, + }; + Some(TypeAlias { name, key, target_type: target_type.clone(), interface_methods }) + } + _ => None, + }) + .collect() +} + +/// `type Foo <- Record & Printable;`: the intersection's interface member is named directly in +/// the declaration (`type_arithmetic::norm_intersection` keeps a `Record & Interface` mix +/// symbolic rather than merging it), so `DeclaredAs` doesn't need structural satisfaction — the +/// declaration itself names the interface it commits to. +fn declared_as(graph: &mut BlockGraph, context: &Context, decl: &TypeAlias) { + let Type::Operator(TypeOperator::Intersection, a, b, _) = &decl.target_type else { + return; + }; + for member in [a.as_ref(), b.as_ref()] { + if let Some(member_name) = alias_name(member) { + if matches!(member.reduce(context), Type::Interface(..)) { + let iface_key = BlockKey::top_level(Namespace::Type, &member_name); + graph.relations.push(Relation::declared_as(decl.key.clone(), iface_key)); + } + } + } +} + +/// Structural satisfaction (spec §5.1 `Satisfies`), computed with the same primitive the type +/// checker itself uses for `Record & Interface` declarations and `I(x)` interface-constructor +/// calls (`interface_satisfaction::check_interface_satisfaction`) — this pass doesn't reimplement +/// the check, only renders its result plus a per-method evidence trail (spec §5.2). Evidence +/// falls back to the `TypeDecl` itself (rather than a method block) when a requirement is met +/// structurally — e.g. a record field read as a trivial getter — instead of by a discovered +/// free-function block. +fn satisfies(graph: &mut BlockGraph, context: &Context, decl: &TypeAlias, iface: &TypeAlias) { + let Some(required) = &iface.interface_methods else { + return; + }; + // Nominal reference, matching how a parameter declared `p: ` would be typed — see + // `discover_methods`'s own by-name match and the doc comment on + // `interface_satisfaction::candidate_methods` (`typr-core`). + let concrete = Type::Alias(decl.name.clone(), Vec::new(), false, Default::default()); + if check_interface_satisfaction(context, &concrete, required).is_err() { + return; + } + let provided = discover_methods(context, &decl.name); + let mut evidence: Vec = required + .iter() + .map(|req| { + let name = safe_argument_name(req); + let provided_by = provided + .iter() + .find(|(method_name, ..)| *method_name == name) + .map(|(_, _, key)| key.clone()) + .unwrap_or_else(|| decl.key.clone()); + Evidence { requires: format!("{name}: {}", req.get_type().pretty()), provided_by } + }) + .collect(); + evidence.sort_by(|a, b| a.requires.cmp(&b.requires)); + graph.relations.push(Relation::satisfies(decl.key.clone(), iface.key.clone(), evidence)); +} + +#[cfg(test)] +mod tests { + use crate::key::Namespace; + use crate::model::{BlockGraph, RelationKind}; + use crate::BlockKey; + use typr_core::components::context::Context; + use typr_core::processes::parsing::parse_from_string; + use typr_core::processes::type_checking::type_recorder::with_recording; + use typr_core::processes::type_checking::typing_with_errors; + + fn build_graph(source: &str) -> BlockGraph { + let lang = parse_from_string(source, "type_relations_test"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + assert!(!result.has_errors(), "{:?}", result.display_errors()); + crate::build(&lang, &result.type_context.context, &table) + } + + #[test] + fn satisfies_links_a_type_to_the_interface_its_method_satisfies() { + let graph = build_graph( + r#" +type Printable <- interface { show: (Self) -> char }; +type Point <- list { x: int, y: int }; +let show <- fn(p: Point): char { "Point" }; +"#, + ); + let point = BlockKey::top_level(Namespace::Type, "Point"); + let printable = BlockKey::top_level(Namespace::Type, "Printable"); + let rel = graph + .relations + .iter() + .find(|r| r.kind == RelationKind::Satisfies && r.from == point && r.to == printable) + .expect("expected a Satisfies relation from Point to Printable"); + let evidence = rel.evidence.as_ref().expect("Satisfies carries evidence"); + assert_eq!(evidence.len(), 1); + assert_eq!(evidence[0].provided_by, BlockKey::top_level(Namespace::Val, "show")); + } + + #[test] + fn declared_as_links_an_intersection_alias_to_its_named_interface_member() { + let graph = build_graph( + r#" +type Printable <- interface { show: (Self) -> char }; +type Point <- list { x: int, y: int }; +type Combined <- Point & Printable; +"#, + ); + let combined = BlockKey::top_level(Namespace::Type, "Combined"); + let printable = BlockKey::top_level(Namespace::Type, "Printable"); + assert!(graph + .relations + .iter() + .any(|r| r.kind == RelationKind::DeclaredAs && r.from == combined && r.to == printable)); + } + + #[test] + fn subtype_links_a_wider_record_to_a_structurally_narrower_one() { + let graph = build_graph( + r#" +type Named <- list { name: char }; +type Person <- list { name: char, age: int }; +"#, + ); + let named = BlockKey::top_level(Namespace::Type, "Named"); + let person = BlockKey::top_level(Namespace::Type, "Person"); + assert!(graph + .relations + .iter() + .any(|r| r.kind == RelationKind::Subtype && r.from == person && r.to == named)); + } +} diff --git a/crates/typr-graph/src/model.rs b/crates/typr-graph/src/model.rs index 0eea8fb..fb3fad9 100644 --- a/crates/typr-graph/src/model.rs +++ b/crates/typr-graph/src/model.rs @@ -141,6 +141,17 @@ pub enum RelationKind { Instantiates, } +/// Why a `Satisfies`/`DeclaredAs` relation holds (spec §5.2) — the pedagogical payload: not just +/// *that* a type satisfies an interface, but *through which method*. `provided_by` is the block +/// supplying the required method: a free function's block when one was found by name, or the +/// `TypeDecl` itself when the requirement is met structurally (e.g. a record field read as a +/// trivial getter) rather than through a discovered function block. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Evidence { + pub requires: String, + pub provided_by: BlockKey, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Relation { pub kind: RelationKind, @@ -152,6 +163,8 @@ pub struct Relation { pub index: Option, #[serde(skip_serializing_if = "Option::is_none")] pub confidence: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub evidence: Option>, } impl Relation { @@ -163,6 +176,7 @@ impl Relation { port: Some(port.to_string()), index: None, confidence: Some(confidence), + evidence: None, } } @@ -174,6 +188,7 @@ impl Relation { port: None, index: None, confidence: None, + evidence: None, } } @@ -185,6 +200,48 @@ impl Relation { port: None, index: Some(index), confidence: None, + evidence: None, + } + } + + /// `TypeDecl` → `Interface`, structurally computed (spec §5.1), with the evidence backing it. + pub fn satisfies(from: BlockKey, to: BlockKey, evidence: Vec) -> Self { + Relation { + kind: RelationKind::Satisfies, + from, + to, + port: None, + index: None, + confidence: None, + evidence: Some(evidence), + } + } + + /// `TypeDecl` → `Interface`, for a `Record & Interface` intersection alias: the interface + /// member named directly in the declaration, checked at declaration time (spec §5.1). + pub fn declared_as(from: BlockKey, to: BlockKey) -> Self { + Relation { + kind: RelationKind::DeclaredAs, + from, + to, + port: None, + index: None, + confidence: None, + evidence: None, + } + } + + /// type → supertype (spec §5.1), from the structural subtyping relation between two declared + /// type aliases. + pub fn subtype(from: BlockKey, to: BlockKey) -> Self { + Relation { + kind: RelationKind::Subtype, + from, + to, + port: None, + index: None, + confidence: None, + evidence: None, } } } diff --git a/crates/typr-graph/src/project.rs b/crates/typr-graph/src/project.rs index 604091d..15c8c9a 100644 --- a/crates/typr-graph/src/project.rs +++ b/crates/typr-graph/src/project.rs @@ -56,6 +56,7 @@ fn dependencies(graph: &BlockGraph) -> BlockGraph { port: None, index: None, confidence: rel.confidence.clone(), + evidence: None, }); } out diff --git a/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap b/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap index 7ba05fd..a2fdb38 100644 --- a/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap +++ b/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap @@ -1005,6 +1005,17 @@ expression: json "confidence": { "confidence": "exact" } + }, + { + "kind": "Satisfies", + "from": "type:Point", + "to": "type:Printable", + "evidence": [ + { + "requires": "show: fn(a: SELF) -> char", + "provided_by": "val:show" + } + ] } ] } diff --git a/visualization_graph_v2.md b/visualization_graph_v2.md index b5153b5..6b1293c 100644 --- a/visualization_graph_v2.md +++ b/visualization_graph_v2.md @@ -470,8 +470,42 @@ Notes d'implémentation (à connaître pour la suite) : - `typr graph [--format json|dot] [--focus ] [--projection deps|types]`. - DOT : un niveau (le bloc `--focus`) avec ses ports. -**Étape 3 — Relations de types** +**Étape 3 — Relations de types** — fait (2026-09-25) - `Satisfies` avec `evidence`, `DeclaredAs`, `Subtype` depuis `Context.subtypes`. +- Nouveau module `crates/typr-graph/src/build/type_relations.rs`, appelé en post-passe (après le + parcours principal, avant `b.scope.pop()`) sur les `Lang::Alias` de haut niveau uniquement — + ces relations comparent des déclarations entre elles, pas un nœud et ses enfants, donc ça ne + rentre pas dans la récursion par nœud de `build_expr`. +- `Satisfies` : calculée avec le même primitif que le vérificateur de types + (`interface_satisfaction::check_interface_satisfaction`, déjà `pub` dans `typr-core`), pas + réimplémentée. L'`evidence` associe chaque méthode requise à son bloc fournisseur — trouvé via + `discover_methods` (nouvel helper `pub(super)`, extrait de `build_type_decl` pour être partagé), + ou, si la méthode est satisfaite structurellement (ex. un champ de record lu comme accesseur + trivial) sans fonction libre correspondante, l'`evidence` pointe vers le `TypeDecl` lui-même. +- `DeclaredAs` : détectée directement sur le `target_type` **tel qu'écrit** (pas réduit) d'un + alias — si c'est un `Type::Operator(Intersection, a, b)` dont un membre est une référence + nommée (`Type::Alias`) qui réduit vers `Type::Interface`, ça donne `DeclaredAs` vers cette + interface. Ne se déclenche que pour une interface *nommée* (`Point & Printable`), pas pour un + `interface { ... }` écrit en ligne dans l'intersection (rare, non couvert). +- `Subtype` : **simplification volontaire par rapport à la lettre du spec**. Le spec dit + « depuis `Context.subtypes` », mais `Context.subtypes` (`components/context/graph.rs`) est un + arbre construit par petits bouts à chaque `push_var_type`/`push_types`/`hoist_aliases` sur des + types **réduits et anonymes** (des `Type::Record`, pas des `Type::Alias` nommés) — remonter + jusqu'à un nom d'alias depuis là demanderait une correspondance inverse peu fiable, et les + associations `union member → alias` passent en réalité par un mécanisme séparé + (`subtype_cache`/`cache_subtype`, jamais vu par `get_supertypes`/`get_ordered_supertypes`) donc + un vrai parcours de l'arbre les aurait de toute façon manquées. À la place : pour chaque paire + de types déclarés dans le fichier (hors interfaces, déjà couvertes par `Satisfies`), + `target_type.is_subtype(&other.target_type, context)` — la même primitive publique + (`TypeSystem::is_subtype`) que le vérificateur de types utilise partout ailleurs, appliquée + directement aux types tels qu'écrits. Toujours « depuis » la même machinerie de sous-typage, + mais en interrogeant des paires de blocs réels du graphe plutôt qu'en essayant de parcourir + l'arbre interne et de faire correspondre le résultat après coup. Vérifié sur un cas structurel + simple (`list { name: char }` / `list { name: char, age: int }` → sous-typage par largeur). +- Tests unitaires dans `type_relations.rs` (un par relation) + le fil rouge (`walkthrough.rs`, + instantané mis à jour) exerce `Satisfies` de bout en bout, CLI comprise + (`typr graph --projection types`). `cargo test --workspace` et `typr case run` toujours verts + (mêmes régressions préexistantes). **Étape 4 — Playground** - `typr-wasm::semantic_graph`, onglet Graph, navigation et gestes du §11, URL `view`/`focus`, From d6d13acd26536d7c08e2ca633c474f5be51275d2 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Fri, 25 Sep 2026 22:46:48 +0200 Subject: [PATCH 26/52] =?UTF-8?q?typr-wasm:=20add=20semanticGraph=20export?= =?UTF-8?q?=20(=C3=A9tape=204a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes typr-graph's BlockGraph as JSON from WASM, for the playground's upcoming Graph tab. Mirrors compile()'s pipeline (Compiler::new_wasm, parse, with_recording around type_check) and typeCheck()'s error shape: when the source has type errors, has_errors is set and graph_json is left empty, since the builder's totality guarantee only holds for a program that type-checks. Verified byte-for-byte identical (modulo the source filename) against `typr graph --format json` on the spec's walkthrough example. Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 1 + crates/typr-wasm/Cargo.toml | 1 + crates/typr-wasm/src/lib.rs | 55 +++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 8b8edc3..bfc0313 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2015,6 +2015,7 @@ dependencies = [ "serde-wasm-bindgen", "serde_json", "typr-core", + "typr-graph", "wasm-bindgen", "web-sys", ] diff --git a/crates/typr-wasm/Cargo.toml b/crates/typr-wasm/Cargo.toml index e4f6f94..208a9aa 100644 --- a/crates/typr-wasm/Cargo.toml +++ b/crates/typr-wasm/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["cdylib"] [dependencies] typr-core = { workspace = true, features = ["wasm"] } +typr-graph.workspace = true wasm-bindgen = "0.2" serde.workspace = true serde_json.workspace = true diff --git a/crates/typr-wasm/src/lib.rs b/crates/typr-wasm/src/lib.rs index fb8a1ef..0ee52b3 100644 --- a/crates/typr-wasm/src/lib.rs +++ b/crates/typr-wasm/src/lib.rs @@ -18,6 +18,7 @@ use typr_core::processes::transpiling::clear_generated_files; use typr_core::processes::type_checking::type_checker::TypeChecker; +use typr_core::processes::type_checking::type_recorder::with_recording; use typr_core::{Compiler, InMemorySourceProvider}; use wasm_bindgen::prelude::*; @@ -262,6 +263,60 @@ pub fn compile_multiple(files_json: &str) -> Result { }) } +/// Build the block-graph view of TypR source code (`visualization_graph_v2.md`) +/// +/// The graph is only built for source that type-checks: totality of the builder (no panic) is +/// only guaranteed for a program that passes `typr check` (spec §12 étape 1). When there are +/// type errors, `has_errors` is set and `graph_json` is left empty, matching `typeCheck()`. +#[wasm_bindgen(js_name = semanticGraph)] +pub fn semantic_graph(source: &str) -> Result { + let mut sources = InMemorySourceProvider::new(); + sources.add_source("main.ty", source); + + let compiler = Compiler::new_wasm(sources); + let ast = compiler + .parse("main.ty") + .map_err(|e| JsValue::from_str(&format!("{}", e)))?; + + let (result, table) = with_recording(|| compiler.type_check(&ast)); + + if result.has_errors() { + return Ok(GraphResult { + graph_json: String::new(), + has_errors: true, + errors: result + .get_errors() + .iter() + .map(|e| e.clone().display()) + .collect::>() + .join("\n\n"), + }); + } + + let graph = typr_graph::build(&ast, &result.type_context.context, &table); + let graph_json = + typr_graph::export::json::to_string_pretty(&graph).map_err(|e| JsValue::from_str(&e.to_string()))?; + + Ok(GraphResult { + graph_json, + has_errors: false, + errors: String::new(), + }) +} + +/// Result of building the block-graph view +#[wasm_bindgen] +pub struct GraphResult { + /// Pretty-printed JSON of the `BlockGraph` (spec §9), empty when `has_errors` is set + #[wasm_bindgen(getter_with_clone)] + pub graph_json: String, + /// Whether type errors prevented building the graph + pub has_errors: bool, + /// Formatted type error messages (empty string if no errors) + #[wasm_bindgen(getter_with_clone)] + pub errors: String, +} + /// Result of compilation #[wasm_bindgen] pub struct CompileResult { From 03e9cec831039e1ea77892de489c1b6cfdd556c6 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 26 Sep 2026 00:28:06 +0200 Subject: [PATCH 27/52] =?UTF-8?q?typr-graph:=20build=20Loop/Match/Module/R?= =?UTF-8?q?Code=20blocks=20(=C3=A9tape=205,=20part=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills in the four block kinds reserved since étape 1 but never emitted until now (they fell back to Opaque): - Loop (ForLoop/WhileLoop/Loop): a for-loop's iterable feeds a port named after its own loop variable (mirroring a Function parameter); every outer variable reassigned directly in the loop's body gets a paired `{name}_in`/`{name}_out` port (spec §4.1 Q3). - Match: one Scope-kind sub-block per branch, with whatever names its pattern binds exposed as that sub-block's own input ports (resolves the open question in spec §13 that way). - Module: only `@pub` members get an output port; all members (public and private) stay in the body, since privacy is enforced solely by omitting the port, never by hiding the block. - RCode (RBlock/RFunction/ExternBlock): a plain opaque boundary, no inputs/wires derived from the untouched R text, per spec §4. Both Loop's state ports and a plain top-level `x <- expr;` (spec §4.1's "Assign hors boucle", previously unhandled) needed the same fix: a bare Assign now rebinds its target the same way a Let does, so later reads wire from the reassignment rather than the original declaration. cargo test --workspace and typr case run stay at the same pre-existing regressions/flake (0004/0005/0006/0008-was-declared-as-a-public-function, registry_validate::tests::formals_diff_flags_a_missing_export_and_an_arity_mismatch). Co-Authored-By: Claude Sonnet 5 --- crates/typr-graph/src/build/blocks.rs | 368 +++++++++++++++++++++++++- crates/typr-graph/src/build/mod.rs | 34 +++ crates/typr-graph/src/model.rs | 4 +- 3 files changed, 400 insertions(+), 6 deletions(-) diff --git a/crates/typr-graph/src/build/blocks.rs b/crates/typr-graph/src/build/blocks.rs index 57eee25..fd3697b 100644 --- a/crates/typr-graph/src/build/blocks.rs +++ b/crates/typr-graph/src/build/blocks.rs @@ -3,13 +3,14 @@ //! doesn't fit the common case — falls back to a plain `Opaque` block rather than panicking, //! which is the étape-1 acceptance criterion (spec §12). -use super::{variable_name, Builder}; +use super::{span_of, top_level_name, variable_name, Builder}; use crate::key::{BlockKey, Namespace}; use crate::model::*; use std::collections::HashSet; use typr_core::components::context::Context; use typr_core::components::language::argument_value::ArgumentValue; use typr_core::components::language::operators::Op; +use typr_core::components::language::var::Var; use typr_core::components::language::Lang; use typr_core::components::r#type::argument_type::ArgumentType; use typr_core::components::r#type::tchar::Tchar; @@ -153,8 +154,24 @@ impl<'a> Builder<'a> { ) } - // Everything else (Module, Loop, Match, RCode, and any construct not yet modeled) — - // étape 5 territory, or genuinely unhandled: falls back to a total, panic-free Opaque. + Lang::Module { name: module_name, body, .. } => self.build_module(module_name, body, lang, key, name), + + Lang::ForLoop { identifier, expression, body, .. } => self.build_for_loop(identifier, expression, body, lang, key, name), + Lang::WhileLoop { condition, body, .. } => self.build_while_loop(condition, body, lang, key, name), + Lang::Loop { body, .. } => self.build_bare_loop(body, lang, key, name), + + Lang::Match { target, branches, .. } => self.build_match(target, branches, lang, key, name), + + // Raw R (spec §4: "frontière opaque vers R") — the body text is never parsed as + // TypR, so unlike every other block above, no inputs/wires are derived from it. + Lang::RBlock { .. } | Lang::RFunction { .. } | Lang::ExternBlock { .. } => { + let ty = self.pretty_type(lang); + self.finish_block(lang, key, BlockKind::RCode, name, Vec::new(), vec![Port::explicit("out", ty)], Origin::User, None) + } + + // Everything else — genuinely unhandled, or not worth a dedicated shape (`Assign` + // outside a body list, raw `Tag`/`Sequence`, …): falls back to a total, panic-free + // Opaque. other => { let ty = self.pretty_type(other); self.finish_block(other, key, BlockKind::Opaque, name, Vec::new(), vec![Port::explicit("out", ty)], Origin::User, None) @@ -450,8 +467,353 @@ impl<'a> Builder<'a> { } self.finish_block(lang, key, kind, name, inputs, outputs, Origin::User, Some(Body { children, wires })) } + + /// `module Name { ... }` (spec §4, §4.1, étape 5). A capture boundary like `Function`, whose + /// members (`Let`/`Alias`, `@pub` or not) are pre-bound before any of them is built so they + /// can forward/mutually reference each other — the same two-pass shape as the program root + /// itself (spec §7.1 step 2). Every member is a body child (`is_public` or not — "le secret + /// se voit de l'intérieur"), but only `@pub` members get an output port: that's the only + /// place privacy is actually enforced, since a private member simply isn't reachable from + /// outside the module. + fn build_module(&mut self, _module_name: &str, members: &[Lang], lang: &Lang, key: BlockKey, name: Option) -> PortRef { + self.scope.push_boundary(key.clone()); + + for item in members { + if let Some((_, member_name)) = top_level_name(item) { + self.scope.bind(&member_name, PortRef { block: key.named(&member_name), port: "out".to_string() }); + } + } + + let mut children = Vec::new(); + let mut outputs = Vec::new(); + for (index, item) in members.iter().enumerate() { + match top_level_name(item) { + Some((_, member_name)) => { + let member_key = key.named(&member_name); + let expr = match item { + Lang::Let { expression, .. } => expression.as_ref(), + _ => item, + }; + if is_public_member(item) { + outputs.push(Port::explicit(member_name.clone(), self.pretty_type(expr))); + } + self.build_expr(expr, member_key.clone(), Some(&member_name)); + children.push(member_key); + } + None => { + let member_key = key.anonymous(index); + self.build_expr(item, member_key.clone(), None); + children.push(member_key); + } + } + } + + self.scope.pop(); + let (captured, refs) = self.take_captures(&key); + self.graph.relations.extend(refs); + self.finish_block(lang, key, BlockKind::Module, name, captured, outputs, Origin::User, Some(Body { children, wires: Vec::new() })) + } + + /// `for i in xs { ... }`: the iterable feeds a port named after the loop variable itself + /// (mirroring a `Function` parameter — the loop variable's port *is* its binding inside the + /// body), plus the state-port pair of every outer variable reassigned in the loop (spec + /// §4.1 Q3, `build_loop_state_and_body`). + fn build_for_loop(&mut self, identifier: &Var, expression: &Lang, body: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let var_name = identifier.get_name(); + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = vec![Port::explicit(&var_name, self.pretty_type(expression))]; + self.build_operand(expression, key.role(&var_name), &key, &var_name, &mut children, &mut wires); + + let outputs = self.build_loop_state_and_body(body, &key, Some((&var_name, &var_name)), &mut inputs, &mut wires, &mut children); + self.finish_block(lang, key, BlockKind::Loop, name, inputs, outputs, Origin::User, Some(Body { children, wires })) + } + + /// `while (cond) { ... }`: the condition feeds a `cond` input port (same convention as + /// `If`), re-read every iteration — no loop-variable binding, unlike `ForLoop`. + fn build_while_loop(&mut self, condition: &Lang, body: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = vec![Port::explicit("cond", self.pretty_type(condition))]; + self.build_operand(condition, key.role("cond"), &key, "cond", &mut children, &mut wires); + + let outputs = self.build_loop_state_and_body(body, &key, None, &mut inputs, &mut wires, &mut children); + self.finish_block(lang, key, BlockKind::Loop, name, inputs, outputs, Origin::User, Some(Body { children, wires })) + } + + /// `loop { ... }`: no condition or iterable at all, just the state-port pairs (the loop is + /// only ever left via `break`, not modeled as a port here — spec §7 doesn't ask for it). + fn build_bare_loop(&mut self, body: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = Vec::new(); + let outputs = self.build_loop_state_and_body(body, &key, None, &mut inputs, &mut wires, &mut children); + self.finish_block(lang, key, BlockKind::Loop, name, inputs, outputs, Origin::User, Some(Body { children, wires })) + } + + /// Shared tail of the three loop shapes (spec §4.1 Q3): every outer variable reassigned by a + /// bare `x <- expr;` directly among the loop's own top-level statements becomes a paired + /// `{name}_in`/`{name}_out` port — entering, it's what the body reads; leaving, it's + /// whatever the last reassignment inside the loop wrote (or unchanged, if the loop body + /// never ran — not distinguished here, same "no version label" stance as a plain + /// reassignment, spec §4.1). `lead_binding` is `ForLoop`'s extra local name (the loop + /// variable), bound in the same fresh frame as the state-entering ports before the body is + /// walked. + /// + /// Simplification, in the same spirit as the capture-boundary one (`scope.rs`): only a + /// direct `Assign` among the loop's immediate statements is seen, not one nested inside an + /// `if` inside the loop — revisit if a real case needs it. + fn build_loop_state_and_body( + &mut self, + body: &Lang, + key: &BlockKey, + lead_binding: Option<(&str, &str)>, + inputs: &mut Vec, + wires: &mut Vec, + children: &mut Vec, + ) -> Vec { + let stmts = body_statements(body); + let mut state_names = Vec::new(); + for stmt in &stmts { + if let Lang::Assign { identifier, .. } = stmt { + if let Some(n) = variable_name(identifier) { + if self.scope.resolve(&n).is_some() && !state_names.contains(&n) { + state_names.push(n); + } + } + } + } + + for n in &state_names { + let in_port = format!("{n}_in"); + let src = self.resolve_and_wire(n, None); + inputs.push(Port::implicit(in_port.clone(), None)); + wires.push(Wire { from: src, to: PortRef { block: key.clone(), port: in_port } }); + } + + self.scope.push_plain(); + if let Some((bind_name, port)) = lead_binding { + self.scope.bind(bind_name, PortRef { block: key.clone(), port: port.to_string() }); + } + for n in &state_names { + self.scope.bind(n, PortRef { block: key.clone(), port: format!("{n}_in") }); + } + children.extend(self.build_nested_body(&stmts, key)); + + let mut outputs = Vec::with_capacity(state_names.len()); + for n in &state_names { + let (final_port, _) = self.scope.resolve(n).expect("a name just bound in this frame resolves in it"); + outputs.push(Port::implicit(format!("{n}_out"), None)); + wires.push(Wire { from: final_port, to: PortRef { block: key.clone(), port: format!("{n}_out") } }); + } + self.scope.pop(); + outputs + } + + /// `match target { pattern => body, ... }` (spec §4, §13). Each branch is its own explorable + /// sub-block: whatever names its pattern binds become that sub-block's own input ports (the + /// open question in §13 — "un sous-bloc par bras, dont les liaisons du motif sont les + /// entrées" — resolved that way), with the sub-block's body wired up exactly like a + /// function's or scope's. The pattern itself isn't rendered as a block, only the names it + /// introduces. + fn build_match(&mut self, target: &Lang, branches: &[(Lang, Box)], lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + self.build_operand(target, key.role("target"), &key, "target", &mut children, &mut wires); + let inputs = vec![Port::explicit("target", self.pretty_type(target))]; + + for (index, (pattern, body)) in branches.iter().enumerate() { + let branch_key = key.role(&format!("branch{index}")); + let bindings = pattern_bindings(pattern); + + self.scope.push_plain(); + let mut branch_inputs = Vec::with_capacity(bindings.len()); + for n in &bindings { + self.scope.bind(n, PortRef { block: branch_key.clone(), port: n.clone() }); + branch_inputs.push(Port::implicit(n.clone(), None)); + } + let stmts = body_statements(body); + let branch_children = self.build_nested_body(&stmts, &branch_key); + self.scope.pop(); + + let branch_out_ty = self.pretty_type(body); + self.graph.insert(Block { + key: branch_key.clone(), + kind: BlockKind::Scope, + name: None, + span: Some(span_of(body)), + r#type: branch_out_ty.clone(), + inputs: branch_inputs, + outputs: vec![Port::explicit("out", branch_out_ty)], + origin: Origin::User, + body: Some(Body { children: branch_children, wires: Vec::new() }), + }); + children.push(branch_key); + } + + let out_ty = self.pretty_type(lang); + self.finish_block(lang, key, BlockKind::Match, name, inputs, vec![Port::explicit("out", out_ty)], Origin::User, Some(Body { children, wires })) + } +} + +/// Whether a module member's own `@pub` flag is set (spec §4.1: "un module n'expose que ses +/// membres `@pub`"). +fn is_public_member(item: &Lang) -> bool { + match item { + Lang::Let { is_public, .. } => *is_public, + Lang::Alias { is_public, .. } => *is_public, + _ => false, + } +} + +/// The names a match-branch pattern binds, mirroring `match_expression::build_match_branch_context` +/// (the type checker's own pattern-binding pass) but collecting names only — no types, no +/// errors: this pass runs after type-checking already accepted the program. +fn pattern_bindings(pattern: &Lang) -> Vec { + match pattern { + Lang::Tag { value: inner, .. } => match inner.as_ref() { + Lang::Variable { name, .. } if name != "_" => vec![name.clone()], + _ => Vec::new(), + }, + Lang::TypePattern { variable_name: name, .. } => vec![name.clone()], + Lang::Tuple { value: elements, .. } => elements + .iter() + .filter_map(|e| match e { + Lang::Variable { name, .. } if name != "_" => Some(name.clone()), + _ => None, + }) + .collect(), + Lang::List { value: fields, .. } => fields + .iter() + .filter_map(|f| match f.get_value() { + Lang::Variable { name, .. } if name != "_" => Some(name), + _ => None, + }) + .collect(), + Lang::Variable { name, .. } if name != "_" => vec![name.clone()], + _ => Vec::new(), + } } fn body_statements_from_vec(body: &[Lang]) -> Vec<&Lang> { body.iter().collect() } + +#[cfg(test)] +mod tests { + use crate::key::Namespace; + use crate::model::{BlockGraph, BlockKind}; + use crate::BlockKey; + use typr_core::components::context::Context; + use typr_core::processes::parsing::parse_from_string; + use typr_core::processes::type_checking::type_recorder::with_recording; + use typr_core::processes::type_checking::typing_with_errors; + + fn build_graph(source: &str) -> BlockGraph { + let lang = parse_from_string(source, "blocks_test"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + assert!(!result.has_errors(), "{:?}", result.display_errors()); + crate::build(&lang, &result.type_context.context, &table) + } + + // `while`'s own condition parser (`single_element`) can't take a bare comparison — this is a + // pre-existing `typr-core` parser quirk, unrelated to this crate, that double parens work + // around; not this crate's bug to fix. + #[test] + fn while_loop_gets_state_in_out_ports_for_reassigned_outer_variables() { + let graph = build_graph( + r#" +let total <- fn(seed: int): int { + let acc <- seed; + let i <- 0; + while ((i < 3)) { + acc <- acc + i; + i <- i + 1; + }; + acc +}; +"#, + ); + let loop_key = BlockKey::top_level(Namespace::Val, "total").role("#2"); + let block = graph.blocks.get(&loop_key).expect("the while loop is built as a top-level function's 3rd body statement"); + assert_eq!(block.kind, BlockKind::Loop); + let input_names: Vec<_> = block.inputs.iter().map(|p| p.name.as_str()).collect(); + assert!(input_names.contains(&"acc_in"), "{input_names:?}"); + assert!(input_names.contains(&"i_in"), "{input_names:?}"); + let output_names: Vec<_> = block.outputs.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(output_names, vec!["acc_out", "i_out"]); + } + + #[test] + fn for_loop_binds_its_variable_to_a_port_named_after_it() { + let graph = build_graph( + r#" +let total <- fn(seed: int): int { + let acc <- seed; + for (i in [1, 2, 3]) { + acc <- acc + i; + }; + acc +}; +"#, + ); + let loop_key = BlockKey::top_level(Namespace::Val, "total").role("#1"); + let block = graph.blocks.get(&loop_key).expect("the for loop is built as a top-level function's 2nd body statement"); + assert_eq!(block.kind, BlockKind::Loop); + let input_names: Vec<_> = block.inputs.iter().map(|p| p.name.as_str()).collect(); + assert!(input_names.contains(&"i"), "{input_names:?}"); + assert!(input_names.contains(&"acc_in"), "{input_names:?}"); + assert_eq!(block.outputs.iter().map(|p| p.name.as_str()).collect::>(), vec!["acc_out"]); + } + + #[test] + fn match_branch_gets_its_pattern_bindings_as_its_own_input_ports() { + let graph = build_graph( + r#" +type Shape <- .Circle(int) | .Square(int); +let area <- fn(s: Shape): int { + match s { + .Circle(r) => r * r, + .Square(side) => side * side, + } +}; +"#, + ); + let match_key = BlockKey::top_level(Namespace::Val, "area").role("#0"); + let block = graph.blocks.get(&match_key).expect("the match is the function's sole body statement"); + assert_eq!(block.kind, BlockKind::Match); + assert_eq!(block.inputs.iter().map(|p| p.name.as_str()).collect::>(), vec!["target"]); + + let branch0 = graph.blocks.get(&match_key.role("branch0")).expect("first branch sub-block"); + assert_eq!(branch0.inputs.iter().map(|p| p.name.as_str()).collect::>(), vec!["r"]); + let branch1 = graph.blocks.get(&match_key.role("branch1")).expect("second branch sub-block"); + assert_eq!(branch1.inputs.iter().map(|p| p.name.as_str()).collect::>(), vec!["side"]); + } + + #[test] + fn module_exposes_only_pub_members_as_outputs_but_keeps_all_of_them_in_its_body() { + let graph = build_graph( + r#" +module Reporter { + let helper <- fn(x: int): int { x + 1 }; + @pub let describe <- fn(x: int): int { helper(x) }; +}; +"#, + ); + let module_key = BlockKey::top_level(Namespace::Val, "Reporter"); + let block = graph.blocks.get(&module_key).expect("the module is built at the top level, named after itself"); + assert_eq!(block.kind, BlockKind::Module); + assert_eq!(block.outputs.iter().map(|p| p.name.as_str()).collect::>(), vec!["describe"]); + assert!(block.body.as_ref().unwrap().children.contains(&module_key.named("helper"))); + assert!(block.body.as_ref().unwrap().children.contains(&module_key.named("describe"))); + } + + #[test] + fn r_block_is_a_total_opaque_boundary_with_no_inputs() { + let graph = build_graph("let total <- R { sum(c(1, 2, 3)) };\n"); + let block = graph.blocks.get(&BlockKey::top_level(Namespace::Val, "total")).expect("top-level R block"); + assert_eq!(block.kind, BlockKind::RCode); + assert!(block.inputs.is_empty()); + assert!(block.body.is_none()); + } +} diff --git a/crates/typr-graph/src/build/mod.rs b/crates/typr-graph/src/build/mod.rs index 2a1eee8..1ff3bb2 100644 --- a/crates/typr-graph/src/build/mod.rs +++ b/crates/typr-graph/src/build/mod.rs @@ -73,6 +73,10 @@ fn top_level_name(item: &Lang) -> Option<(Namespace, String)> { match item { Lang::Let { variable, .. } => variable_name(variable).map(|n| (Namespace::Val, n)), Lang::Alias { identifier, .. } => variable_name(identifier).map(|n| (Namespace::Type, n)), + // `module Name { ... }` (spec §4, étape 5) parses straight to `Lang::Module`, never + // wrapped in a `Let` — it names itself directly, in the value namespace (it's accessed + // like a record, via `$`/`use`). + Lang::Module { name, .. } => Some((Namespace::Val, name.clone())), _ => None, } } @@ -126,6 +130,19 @@ impl<'a> Builder<'a> { } fn build_top_level_item(&mut self, item: &Lang, index: usize, children: &mut Vec) { + // `x <- expr;` outside a loop (spec §4.1's "Assign hors boucle"): not a fresh + // declaration, so it gets an anonymous positional key like any unnamed statement, but + // rebinds `x` going forward — later top-level reads of `x` wire from this block, not + // from whatever defined it before. + if let Lang::Assign { identifier, expression, .. } = item { + if let Some(reassigned) = variable_name(identifier) { + let key = BlockKey::top_level(Namespace::Val, &format!("#{index}")); + let port = self.build_expr(expression, key.clone(), Some(&reassigned)); + self.scope.bind(&reassigned, port); + children.push(key); + return; + } + } match top_level_name(item) { Some((namespace, name)) => { let key = BlockKey::top_level(namespace, &name); @@ -166,6 +183,23 @@ impl<'a> Builder<'a> { children.push(key); } }, + // `x <- expr;` (spec §4.1's "Assign hors boucle", also the mechanism behind a + // `Loop`'s state ports, étape 5): rebinds `x` going forward, same as a `Let`, + // but keyed anonymously since the name is already claimed by its original + // declaration. + Lang::Assign { identifier, expression, .. } => { + let key = owner.anonymous(index); + match variable_name(identifier) { + Some(reassigned) => { + let port = self.build_expr(expression, key.clone(), Some(&reassigned)); + self.scope.bind(&reassigned, port); + } + None => { + self.build_expr(expression, key.clone(), None); + } + } + children.push(key); + } other => { let key = owner.anonymous(index); self.build_expr(other, key.clone(), None); diff --git a/crates/typr-graph/src/model.rs b/crates/typr-graph/src/model.rs index fb3fad9..b9a635f 100644 --- a/crates/typr-graph/src/model.rs +++ b/crates/typr-graph/src/model.rs @@ -16,9 +16,7 @@ pub struct Span { pub end: usize, } -/// Every block kind of the catalogue (spec §4). Variants not yet built at this step (`Loop`, -/// `Match`, `Module`, `RCode` — étape 5) exist so the JSON contract is stable ahead of time, but -/// no constructor emits them yet: the `Lang` shapes that would need them fall back to `Opaque`. +/// Every block kind of the catalogue (spec §4). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum BlockKind { Program, From a8fa9a0c4396a0a28ff1f01aa67102cbff213bf1 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 26 Sep 2026 00:28:35 +0200 Subject: [PATCH 28/52] =?UTF-8?q?docs:=20record=20=C3=A9tape=205=20(blocks?= =?UTF-8?q?)=20progress=20and=20pre-existing=20pitfalls=20in=20the=20graph?= =?UTF-8?q?=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- visualization_graph_v2.md | 48 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/visualization_graph_v2.md b/visualization_graph_v2.md index 6b1293c..f7e5f96 100644 --- a/visualization_graph_v2.md +++ b/visualization_graph_v2.md @@ -512,8 +512,52 @@ Notes d'implémentation (à connaître pour la suite) : synchronisation Monaco dans les deux sens, `INTEGRATION.md` mis à jour. **Étape 5 — Documentation et blocs restants** -- ` ```typr graph ` sur typr.github.io (dépôts mis à jour ensemble). -- `Loop` (ports d'état), `Match`, `Module` (sorties `@pub`), `RCode`. +- ` ```typr graph ` sur typr.github.io (dépôts mis à jour ensemble). — pas commencé. +- `Loop` (ports d'état), `Match`, `Module` (sorties `@pub`), `RCode`. — fait (2026-09-26). + +Notes d'implémentation (blocs restants) : +- `Loop` (`ForLoop`/`WhileLoop`/`Loop`) : le port d'entrée d'un `ForLoop` porte le nom de sa + propre variable de boucle (même convention qu'un paramètre de `Function` — le port *est* la + liaison locale) ; `WhileLoop` a un port `cond` (même convention que `If`) ; `Loop` (boucle + infinie) n'a ni l'un ni l'autre. Les ports d'état (Q3) sont détectés par un balayage superficiel + des instructions *directement* dans le corps de la boucle à la recherche d'`Assign` dont la + cible est déjà liée à l'extérieur — même simplification assumée que les captures (pas de + traversée dans un `if` imbriqué dans la boucle, § scope.rs). Un fil `Wire` peut désormais pointer + vers un port de **sortie** du bloc propriétaire (`{name}_out`), ce qui n'avait pas de précédent + explicite avant cette étape mais est cohérent avec le renderer du playground (qui résout déjà le + côté WEST/EAST d'une extrémité par lookup, pas par position, cf. notes étape 4). +- Prérequis découvert en cours de route : les ports d'état dépendaient d'un mécanisme qui + n'existait pas encore — un `Assign` nu (`x <- expr;`, hors boucle aussi bien que dedans) ne + rebindait pas `x` dans le scope du builder (`build_nested_body`/`build_top_level_item` ne + traitaient que `Let`). Corrigé pour les deux (spec §4.1 « Assign hors boucle », qui n'avait pas + d'étape propre mais dont Loop dépend directement) : un `Assign` est désormais keyé anonymement + (le nom est déjà pris par la déclaration d'origine) mais rebind le nom, comme un `Let`. +- `Match` : question ouverte du §13 tranchée — un sous-bloc par bras (`BlockKind::Scope`, pas de + nouveau `BlockKind` dédié), dont les noms liés par le motif (mêmes formes que + `match_expression::build_match_branch_context` côté vérificateur de types : `Tag`, `TypePattern`, + `Tuple`, `List`, variable catch-all) deviennent ses propres ports d'entrée (implicites, sans fil + tracé depuis la cible — le motif lui-même n'est pas rendu comme bloc). +- `Module` : `module Name { ... }` n'était même pas nommable dans le graphe avant cette étape + (`top_level_name` ne connaissait que `Let`/`Alias`) — corrigé. Frontière de capture comme + `Function`, pré-passe à deux temps comme la racine du programme (§7.1 étape 2) pour que les + membres se référencent mutuellement. Seuls les membres `@pub` ont un port de sortie ; tous les + membres (publics et privés) restent des enfants du corps — la confidentialité tient uniquement à + l'absence de port, jamais à l'absence du bloc. +- `RCode` (`RBlock`/`RFunction`/`ExternBlock`) : implémentation triviale, identique à l'ancien + fallback `Opaque` à l'étiquette de `kind` près — aucune entrée dérivée, le texte R n'est jamais + reparsé (frontière opaque, cohérent avec le spec §4). +- Bug pré-existant découvert (hors scope, non corrigé) : une fonction `let`-liée **imbriquée** dans + le corps d'une autre fonction (ou d'un module) perd son nom de fichier dans son `HelpData` + (`span.file` vide) — reproductible sans aucun rapport avec ce chantier + (`let outer <- fn(a: int): int { let inner <- fn(b: int): int { b + 1 }; inner(a) };`). +- Bug pré-existant découvert (hors scope, non corrigé) : le parseur de `while (cond) { ... }` ne + digère pas une condition qui est elle-même un opérateur binaire nu (`while (i < n) { ... }` + échoue avec « Unknown element `{` », alors que `while ((i < n)) { ... }` avec une parenthèse + surnuméraire fonctionne) ; de même `for i in expr { ... }` sans les parenthèses autour de + `i in expr` échoue entièrement (la forme correcte est `for (i in expr) { ... }`, jamais + documentée nulle part ni testée dans `typr-core` — aucun test de parsing n'existe pour + `while_loop`/`for_loop`/`loop_loop`). Contourné dans les tests de `typr-graph` avec la syntaxe + qui fonctionne ; à signaler si quelqu'un écrit un jour un `.typr` avec ces formes naturelles. **Étape 6 — Revue** - `diff(G_old, G_new)` apparié par `BlockKey` : blocs ajoutés, supprimés, modifiés (type, From 44ce2eb33e38bdf0ba0703db326a0c2138d39f72 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 26 Sep 2026 00:38:44 +0200 Subject: [PATCH 29/52] =?UTF-8?q?docs:=20record=20=C3=A9tape=205=20complet?= =?UTF-8?q?ion=20(graph=20fence=20in=20typr.github.io)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- visualization_graph_v2.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/visualization_graph_v2.md b/visualization_graph_v2.md index f7e5f96..93c51a3 100644 --- a/visualization_graph_v2.md +++ b/visualization_graph_v2.md @@ -511,9 +511,27 @@ Notes d'implémentation (à connaître pour la suite) : - `typr-wasm::semantic_graph`, onglet Graph, navigation et gestes du §11, URL `view`/`focus`, synchronisation Monaco dans les deux sens, `INTEGRATION.md` mis à jour. -**Étape 5 — Documentation et blocs restants** -- ` ```typr graph ` sur typr.github.io (dépôts mis à jour ensemble). — pas commencé. -- `Loop` (ports d'état), `Match`, `Module` (sorties `@pub`), `RCode`. — fait (2026-09-26). +**Étape 5 — Documentation et blocs restants** — fait (2026-09-26) +- ` ```typr graph ` sur typr.github.io (dépôts mis à jour ensemble). — fait. +- `Loop` (ports d'état), `Match`, `Module` (sorties `@pub`), `RCode`. — fait. + +Notes d'implémentation (partie doc, branche `graph-fence-block` dans les deux dépôts, pas encore +mergée) : +- `typr.github.io` : `src/playground/meta.tsx` lit `graph`/`focus=` dans la metastring de la + fence (même mécanisme que `autorun`/`noplayground`/`compile_fail`, § déjà en place à l'étape 4) ; + `src/playground/url.ts` ajoute `view`/`focus` à `buildPlaygroundUrl` ; `PlaygroundButton` change + d'icône et d'infobulle en mode `graph` et ignore `autorun` (même traitement que pour + `compile_fail`). `focus=` sans `:` est sous-entendu `val:` — évite d'épeler le + namespace dans le cas courant, `focus=type:Point` reste possible pour un type. Exemple réel + ajouté dans `docs/reference/modules.md` (juste après le passage qui explique `@pub`, l'endroit + où la frontière module se voit le mieux visuellement). +- `typr-playground.github.io` : aucun changement de code — `view=graph`/`focus=` existaient déjà + depuis l'étape 4, seul `INTEGRATION.md` documente qu'ils sont maintenant aussi produits par un + mot-clé de fence, pas seulement par la navigation interne du playground. +- Vérifié : `tsc` (typecheck), `docusaurus build` complet, `check-typr-blocks.mjs` (le nouveau bloc + passe `typr check` comme les autres), et le DOM réellement rendu de la page modules (Chrome + headless, pas seulement le build) pour confirmer que les deux boutons (normal et graph) portent + le bon `href`/`title`. Notes d'implémentation (blocs restants) : - `Loop` (`ForLoop`/`WhileLoop`/`Loop`) : le port d'entrée d'un `ForLoop` porte le nom de sa From 9ab0f567d2c1ebac5f12b96a092416d3b071e154 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 26 Sep 2026 01:05:10 +0200 Subject: [PATCH 30/52] finished diff.rs + wiring CLI --- crates/typr-cli/src/cli.rs | 39 +- crates/typr-cli/src/graph.rs | 66 ++- .../src/components/type/type_printer.rs | 26 +- crates/typr-graph/src/diff.rs | 396 ++++++++++++++++++ crates/typr-graph/src/lib.rs | 1 + 5 files changed, 500 insertions(+), 28 deletions(-) create mode 100644 crates/typr-graph/src/diff.rs diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index 2cd3062..bbcb7d7 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -47,10 +47,13 @@ enum Commands { }, /// Emit the block-graph view of a file (`visualization_graph_v2.md`): the program broken /// into typed blocks with their ports, wires, and cross-block `Ref`/`HasType`/`TypePosition` - /// relations — for code review and understanding, not for building. + /// relations — for code review and understanding, not for building. `typr graph diff` (see + /// `typr graph diff --help`) compares two versions of a program instead of viewing one. Graph { + #[command(subcommand)] + graph_command: Option, #[arg(value_name = "FILE")] - file: PathBuf, + file: Option, /// `json` (the full block map, spec §9) or `dot` (one level, rendered with Graphviz). #[arg(long, value_name = "FORMAT", default_value = "json")] format: String, @@ -295,6 +298,23 @@ enum TypesCommands { }, } +#[derive(Subcommand, Debug)] +enum GraphCommands { + /// Diff the block graphs of two versions of a program, paired by `BlockKey` + /// (`visualization_graph_v2.md` §12 étape 6): blocks added, removed, or modified + /// (`type`/`interface`/`captures` changed). Both files are built through the same pipeline + /// as `typr graph`, independently — they need not be related by git history. + Diff { + #[arg(value_name = "OLD_FILE")] + old_file: PathBuf, + #[arg(value_name = "NEW_FILE")] + new_file: PathBuf, + /// `text` (human-readable, default) or `json` (the machine contract). + #[arg(long, value_name = "FORMAT", default_value = "text")] + format: String, + }, +} + #[derive(Subcommand, Debug)] enum CaseCommands { /// List the catalog (filterable by --status). @@ -453,11 +473,24 @@ pub fn start() { _ => check_project(), }, Some(Commands::Graph { - file, + graph_command: Some(GraphCommands::Diff { old_file, new_file, format }), + .. + }) => crate::graph::graph_diff(&old_file, &new_file, &format), + Some(Commands::Graph { + graph_command: None, + file: Some(file), format, focus, projection, }) => crate::graph::graph_file(&file, &format, focus.as_deref(), projection.as_deref()), + Some(Commands::Graph { + graph_command: None, + file: None, + .. + }) => { + eprintln!("typr graph requires a FILE, or use `typr graph diff `"); + std::process::exit(1); + } Some(Commands::Build { file, test, diff --git a/crates/typr-cli/src/graph.rs b/crates/typr-cli/src/graph.rs index 45c4536..898251e 100644 --- a/crates/typr-cli/src/graph.rs +++ b/crates/typr-cli/src/graph.rs @@ -16,25 +16,7 @@ use typr_graph::project::{project, Projection}; use typr_graph::{BlockGraph, BlockKey}; pub fn graph_file(path: &Path, format: &str, focus: Option<&str>, projection: Option<&str>) { - let source = match std::fs::read_to_string(path) { - Ok(s) => s, - Err(e) => { - eprintln!("Error reading file {:?}: {}", path, e); - std::process::exit(1); - } - }; - - let lang = parse_from_string(&source, &path.to_string_lossy()); - let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); - if result.has_errors() { - eprintln!("Type errors found:"); - for err in result.display_errors() { - eprintln!(" - {}", err); - } - std::process::exit(1); - } - - let mut graph = typr_graph::build(&lang, &result.type_context.context, &table); + let mut graph = build_graph_or_exit(path); if let Some(name) = projection { graph = match name { @@ -84,3 +66,49 @@ fn print_dot(graph: &BlockGraph, focus: Option<&str>) { } } } + +/// `typr graph diff ` (spec §12 étape 6): builds each file's block graph +/// independently through the same pipeline as `graph_file`, then pairs blocks up by `BlockKey`. +/// Unlike `typr diff`-style tools this never touches git — the two arguments are just two +/// source files (e.g. two working-tree revisions checked out to temp paths, or simply an +/// old/new copy of the same program), the same "two code excerpts" the spec's playground diff +/// view compares. +pub fn graph_diff(old_file: &Path, new_file: &Path, format: &str) { + let old_graph = build_graph_or_exit(old_file); + let new_graph = build_graph_or_exit(new_file); + let d = typr_graph::diff::diff(&old_graph, &new_graph); + + match format { + "text" => print!("{}", typr_graph::diff::to_text(&d)), + "json" => println!( + "{}", + serde_json::to_string_pretty(&d).expect("GraphDiff always serializes") + ), + other => { + eprintln!("Unknown format {:?} (expected \"text\" or \"json\")", other); + std::process::exit(1); + } + } +} + +fn build_graph_or_exit(path: &Path) -> BlockGraph { + let source = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + eprintln!("Error reading file {:?}: {}", path, e); + std::process::exit(1); + } + }; + + let lang = parse_from_string(&source, &path.to_string_lossy()); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + if result.has_errors() { + eprintln!("Type errors found in {:?}:", path); + for err in result.display_errors() { + eprintln!(" - {}", err); + } + std::process::exit(1); + } + + typr_graph::build(&lang, &result.type_context.context, &table) +} diff --git a/crates/typr-core/src/components/type/type_printer.rs b/crates/typr-core/src/components/type/type_printer.rs index f29275f..2cb8fa3 100644 --- a/crates/typr-core/src/components/type/type_printer.rs +++ b/crates/typr-core/src/components/type/type_printer.rs @@ -1,4 +1,5 @@ use crate::components::error_message::help_data::HelpData; +use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::intersection_type::IntersectionType; use crate::components::r#type::pretty; use crate::components::r#type::tchar::Tchar; @@ -8,6 +9,19 @@ use crate::components::r#type::type_operator::TypeOperator; use crate::components::r#type::type_system::TypeSystem; use crate::components::r#type::vector_type::VecType; use crate::components::r#type::Type; +use std::collections::HashSet; + +/// A record's fields in a deterministic order for printing. `Type::Record` stores its fields in +/// a `HashSet`, whose iteration order is randomized per-process (std's default `RandomState`) — +/// printing them as found makes every `list{...}` string (and anything built from it: error +/// messages, `typr graph`'s recorded block types) flip field order from run to run even when the +/// type itself never changed. Sorting by field name here only changes what gets printed, not the +/// set itself or any equality/subtyping decision. +fn sorted_fields(fields: &HashSet) -> Vec<&ArgumentType> { + let mut v: Vec<&ArgumentType> = fields.iter().collect(); + v.sort_by(|a, b| a.get_argument_str().cmp(&b.get_argument_str())); + v +} fn simplify_for_dataframe(ty: &Type) -> String { let fmt = |t: &Type| format(t); @@ -17,8 +31,8 @@ fn simplify_for_dataframe(ty: &Type) -> String { Type::Vec(VecType::Array, _, inner, _) => simplify_for_dataframe(inner), Type::Vec(VecType::S3, _, inner, _) => simplify_for_dataframe(inner), Type::Record(fields, _) => { - let formatted_fields = fields - .iter() + let formatted_fields = sorted_fields(fields) + .into_iter() .map(|arg_typ| { format!( "{}: {}", @@ -66,8 +80,8 @@ pub fn format(ty: &Type) -> String { } } Type::Record(fields, _) => { - let formatted_fields = fields - .iter() + let formatted_fields = sorted_fields(fields) + .into_iter() .map(|arg_typ| format!("{}: {}", arg_typ.get_argument_str(), format(&arg_typ.get_type()))) .collect::>(); format!("list{{{}}}", formatted_fields.join(", ")) @@ -160,8 +174,8 @@ pub fn verbose(t: &Type) -> String { Type::Null(_) => "null".to_string(), Type::Char(_tchar, _) => "char".to_string(), Type::Record(fields, _) => { - let formatted_fields = fields - .iter() + let formatted_fields = sorted_fields(fields) + .into_iter() .map(|arg_typ| format!("{}: {}", format(&arg_typ.get_argument()), format(&arg_typ.get_type()))) .collect::>(); format!("list{{{}}}", formatted_fields.join(", ")) diff --git a/crates/typr-graph/src/diff.rs b/crates/typr-graph/src/diff.rs new file mode 100644 index 0000000..a48f2c2 --- /dev/null +++ b/crates/typr-graph/src/diff.rs @@ -0,0 +1,396 @@ +//! `diff(old, new)` (spec §12 étape 6): two block-graphs of the *same program at two points in +//! time*, paired up by [`BlockKey`] — the whole point of a stable semantic identity (§6) is that +//! it survives an edit, so a block that didn't move keeps its key and a block that did shows up +//! as removed+added rather than silently "modified" into something unrelated. +//! +//! Scope is deliberately block-level, matching the spec's own list: added/removed blocks, plus +//! for a block present in both, whether its `type`, `captures` (implicit input ports, §3.3) or +//! `interface` (`Satisfies`/`DeclaredAs` targets, for a `TypeDecl`) changed. Wire-level and +//! `Ref`-level diffing is out of scope here — the spec's own wording is `type, interface, +//! captures`, not "every relation". + +use crate::key::BlockKey; +use crate::model::{BlockGraph, BlockKind, Port, RelationKind}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CaptureChange { + pub added: Vec, + pub removed: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InterfaceChange { + /// `Satisfies`/`DeclaredAs` targets (interfaces) gained or lost — the block's declared + /// contract, not its full relation list. + pub added: Vec, + pub removed: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlockDiff { + pub key: BlockKey, + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option<(BlockKind, BlockKind)>, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<(Option, Option)>, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option<(Option, Option)>, + #[serde(skip_serializing_if = "Option::is_none")] + pub captures: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub interface: Option, +} + +impl BlockDiff { + fn is_empty(&self) -> bool { + self.kind.is_none() + && self.name.is_none() + && self.r#type.is_none() + && self.captures.is_none() + && self.interface.is_none() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct GraphDiff { + pub added: Vec, + pub removed: Vec, + pub modified: Vec, +} + +/// Pairs `old` and `new` by [`BlockKey`] and reports what changed (spec §12 étape 6). Both +/// graphs are expected to come from the same builder (`typr_graph::build`) run on two versions +/// of the same source — nothing here assumes that, but a `BlockKey` computed by a different +/// scheme would just never match and everything would show up as removed+added. +pub fn diff(old: &BlockGraph, new: &BlockGraph) -> GraphDiff { + let mut out = GraphDiff::default(); + + for key in old.blocks.keys() { + if !new.blocks.contains_key(key) { + out.removed.push(key.clone()); + } + } + for key in new.blocks.keys() { + if !old.blocks.contains_key(key) { + out.added.push(key.clone()); + } + } + + for (key, old_block) in &old.blocks { + let Some(new_block) = new.blocks.get(key) else { + continue; + }; + + let mut d = BlockDiff { + key: key.clone(), + kind: None, + name: None, + r#type: None, + captures: None, + interface: None, + }; + + if old_block.kind != new_block.kind { + d.kind = Some((old_block.kind, new_block.kind)); + } + if old_block.name != new_block.name { + d.name = Some((old_block.name.clone(), new_block.name.clone())); + } + if old_block.r#type != new_block.r#type { + d.r#type = Some((old_block.r#type.clone(), new_block.r#type.clone())); + } + + let old_captures: Vec<&Port> = old_block.inputs.iter().filter(|p| p.implicit).collect(); + let new_captures: Vec<&Port> = new_block.inputs.iter().filter(|p| p.implicit).collect(); + let captures_added: Vec = new_captures + .iter() + .filter(|p| !old_captures.contains(p)) + .map(|p| (*p).clone()) + .collect(); + let captures_removed: Vec = old_captures + .iter() + .filter(|p| !new_captures.contains(p)) + .map(|p| (*p).clone()) + .collect(); + if !captures_added.is_empty() || !captures_removed.is_empty() { + d.captures = Some(CaptureChange { + added: captures_added, + removed: captures_removed, + }); + } + + let old_interface = interface_targets(old, key); + let new_interface = interface_targets(new, key); + let interface_added: Vec = new_interface + .difference(&old_interface) + .cloned() + .collect(); + let interface_removed: Vec = old_interface + .difference(&new_interface) + .cloned() + .collect(); + if !interface_added.is_empty() || !interface_removed.is_empty() { + d.interface = Some(InterfaceChange { + added: interface_added, + removed: interface_removed, + }); + } + + if !d.is_empty() { + out.modified.push(d); + } + } + + out.added.sort(); + out.removed.sort(); + out.modified.sort_by(|a, b| a.key.cmp(&b.key)); + out +} + +/// The `Satisfies`/`DeclaredAs` targets of `key` in `graph` — its declared type contract. +fn interface_targets(graph: &BlockGraph, key: &BlockKey) -> BTreeSet { + graph + .relations + .iter() + .filter(|r| { + r.from == *key && matches!(r.kind, RelationKind::Satisfies | RelationKind::DeclaredAs) + }) + .map(|r| r.to.clone()) + .collect() +} + +/// A compact human-readable rendering of a [`GraphDiff`], one line per block +/// (`+`/`-`/`~` prefix, like a source diff's file-level summary) with indented sub-lines for +/// what changed on a modified block. `--format json` is the machine contract (spec §9-shaped); +/// this is the CLI's `--format text` and the playground diff view's textual fallback. +pub fn to_text(diff: &GraphDiff) -> String { + let mut out = String::new(); + for key in &diff.added { + out.push_str(&format!("+ {}\n", key.as_str())); + } + for key in &diff.removed { + out.push_str(&format!("- {}\n", key.as_str())); + } + for m in &diff.modified { + out.push_str(&format!("~ {}\n", m.key.as_str())); + if let Some((before, after)) = &m.kind { + out.push_str(&format!(" kind: {:?} -> {:?}\n", before, after)); + } + if let Some((before, after)) = &m.name { + out.push_str(&format!( + " name: {} -> {}\n", + before.as_deref().unwrap_or("_"), + after.as_deref().unwrap_or("_") + )); + } + if let Some((before, after)) = &m.r#type { + out.push_str(&format!( + " type: {} -> {}\n", + before.as_deref().unwrap_or("_"), + after.as_deref().unwrap_or("_") + )); + } + if let Some(c) = &m.captures { + out.push_str(&format!( + " captures: {}\n", + format_port_delta(&c.added, &c.removed) + )); + } + if let Some(i) = &m.interface { + out.push_str(&format!( + " interface: {}\n", + format_key_delta(&i.added, &i.removed) + )); + } + } + out +} + +fn format_port_delta(added: &[Port], removed: &[Port]) -> String { + let plus = added.iter().map(|p| format!("+{}", p.name)); + let minus = removed.iter().map(|p| format!("-{}", p.name)); + plus.chain(minus).collect::>().join(" ") +} + +fn format_key_delta(added: &[BlockKey], removed: &[BlockKey]) -> String { + let plus = added.iter().map(|k| format!("+{}", k.as_str())); + let minus = removed.iter().map(|k| format!("-{}", k.as_str())); + plus.chain(minus).collect::>().join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Block, Body, Origin}; + + fn block(key: &str, kind: BlockKind) -> Block { + Block { + key: BlockKey::from_raw(key), + kind, + name: None, + span: None, + r#type: None, + inputs: Vec::new(), + outputs: Vec::new(), + origin: Origin::User, + body: None, + } + } + + #[test] + fn detects_added_and_removed_blocks() { + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + old.insert(block("val:@program", BlockKind::Program)); + old.insert(block("val:a", BlockKind::Function)); + + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + new.insert(block("val:@program", BlockKind::Program)); + new.insert(block("val:b", BlockKind::Function)); + + let d = diff(&old, &new); + assert_eq!(d.added, vec![BlockKey::from_raw("val:b")]); + assert_eq!(d.removed, vec![BlockKey::from_raw("val:a")]); + assert!(d.modified.is_empty()); + } + + #[test] + fn detects_type_change() { + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a = block("val:a", BlockKind::Function); + a.r#type = Some("int".to_string()); + old.insert(a); + + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a2 = block("val:a", BlockKind::Function); + a2.r#type = Some("char".to_string()); + new.insert(a2); + + let d = diff(&old, &new); + assert_eq!(d.modified.len(), 1); + assert_eq!( + d.modified[0].r#type, + Some((Some("int".to_string()), Some("char".to_string()))) + ); + assert!(d.modified[0].captures.is_none()); + assert!(d.modified[0].interface.is_none()); + } + + #[test] + fn detects_capture_change() { + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a = block("val:a", BlockKind::Function); + a.inputs = vec![Port::implicit("sq", None)]; + old.insert(a); + + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a2 = block("val:a", BlockKind::Function); + a2.inputs = vec![Port::implicit("norm", None)]; + new.insert(a2); + + let d = diff(&old, &new); + assert_eq!(d.modified.len(), 1); + let captures = d.modified[0].captures.as_ref().unwrap(); + assert_eq!(captures.added, vec![Port::implicit("norm", None)]); + assert_eq!(captures.removed, vec![Port::implicit("sq", None)]); + } + + #[test] + fn ignores_explicit_input_changes_as_captures() { + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a = block("val:a", BlockKind::Function); + a.inputs = vec![Port::explicit("n", Some("int".to_string()))]; + old.insert(a); + + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a2 = block("val:a", BlockKind::Function); + a2.inputs = vec![Port::explicit("m", Some("int".to_string()))]; + new.insert(a2); + + let d = diff(&old, &new); + assert!(d.modified.is_empty(), "explicit params aren't captures"); + } + + #[test] + fn detects_interface_change() { + use crate::model::Relation; + + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + old.insert(block("type:Point", BlockKind::TypeDecl)); + old.insert(block("type:Printable", BlockKind::Interface)); + old.relations.push(Relation::satisfies( + BlockKey::from_raw("type:Point"), + BlockKey::from_raw("type:Printable"), + Vec::new(), + )); + + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + new.insert(block("type:Point", BlockKind::TypeDecl)); + new.insert(block("type:Printable", BlockKind::Interface)); + // Printable satisfaction dropped in the new version. + + let d = diff(&old, &new); + let point_diff = d + .modified + .iter() + .find(|m| m.key == BlockKey::from_raw("type:Point")) + .expect("Point should be reported modified"); + let interface = point_diff.interface.as_ref().unwrap(); + assert_eq!(interface.removed, vec![BlockKey::from_raw("type:Printable")]); + assert!(interface.added.is_empty()); + } + + #[test] + fn unchanged_block_is_not_reported() { + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + old.insert(block("val:a", BlockKind::Function)); + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + new.insert(block("val:a", BlockKind::Function)); + + let d = diff(&old, &new); + assert!(d.added.is_empty()); + assert!(d.removed.is_empty()); + assert!(d.modified.is_empty()); + } + + #[test] + fn text_rendering_is_stable_and_readable() { + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + old.insert(block("val:a", BlockKind::Function)); + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + new.insert(block("val:b", BlockKind::Function)); + + let d = diff(&old, &new); + let text = to_text(&d); + assert_eq!(text, "+ val:b\n- val:a\n"); + } + + #[test] + fn body_only_changes_are_not_flagged_at_this_key() { + // A body's children/wires changing (e.g. a new statement inside a Scope) isn't one of + // the spec's tracked attributes (type/interface/captures) — it shows up as the *inner* + // blocks being added/removed instead, not as a change on the outer block itself. + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a = block("val:a", BlockKind::Scope); + a.body = Some(Body { + children: vec![BlockKey::from_raw("val:a/#0")], + wires: Vec::new(), + }); + old.insert(a); + + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a2 = block("val:a", BlockKind::Scope); + a2.body = Some(Body { + children: vec![ + BlockKey::from_raw("val:a/#0"), + BlockKey::from_raw("val:a/#1"), + ], + wires: Vec::new(), + }); + new.insert(a2); + + let d = diff(&old, &new); + assert!(d.modified.is_empty()); + } +} diff --git a/crates/typr-graph/src/lib.rs b/crates/typr-graph/src/lib.rs index df9a64b..8e3d958 100644 --- a/crates/typr-graph/src/lib.rs +++ b/crates/typr-graph/src/lib.rs @@ -5,6 +5,7 @@ //! contract (spec §9). Pure logic, no I/O, WASM-safe. mod build; +pub mod diff; pub mod export; pub mod key; pub mod model; From 4f15e7c74dcf45930401c21bd64ae049bdae5d88 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 26 Sep 2026 01:28:12 +0200 Subject: [PATCH 31/52] =?UTF-8?q?typr-wasm:=20add=20semanticGraphDiff=20ex?= =?UTF-8?q?port=20(=C3=A9tape=206,=20WASM=20bridge)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps typr_graph::diff over two independently-built sources for the playground's upcoming diff view: both sides are type-checked so callers see every error, not just the first, and old/new graph JSON ride along with the diff JSON since the diff itself only carries keys. Co-Authored-By: Claude Sonnet 5 --- crates/typr-wasm/src/lib.rs | 97 +++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/crates/typr-wasm/src/lib.rs b/crates/typr-wasm/src/lib.rs index 0ee52b3..8804a18 100644 --- a/crates/typr-wasm/src/lib.rs +++ b/crates/typr-wasm/src/lib.rs @@ -304,6 +304,103 @@ pub fn semantic_graph(source: &str) -> Result { }) } +/// Build the block-graph views of two versions of TypR source and diff them +/// (`visualization_graph_v2.md` §12 étape 6), for the playground's diff view. +/// +/// Each side is built independently through the same pipeline as `semanticGraph`: if either +/// side has type errors, the graph on that side is not built and the diff is not computed +/// (`has_errors`/`errors` describe whichever side(s) failed — both sides are still type-checked +/// so a caller sees every error, not just the first). Both `old_graph_json` and `new_graph_json` +/// are included alongside `diff_json` because the diff itself only carries keys, not full block +/// data — the playground renders the diff overlaid on the *new* graph's layout (matching `typr +/// graph diff`'s framing of "how did the new version change relative to the old one"), but still +/// needs the *old* graph to show what a removed block actually was (kind, name) since a removed +/// key has no entry in the new graph to look it up in. +#[wasm_bindgen(js_name = semanticGraphDiff)] +pub fn semantic_graph_diff(old_source: &str, new_source: &str) -> Result { + let build_one = |source: &str| -> Result, JsValue> { + let mut sources = InMemorySourceProvider::new(); + sources.add_source("main.ty", source); + let compiler = Compiler::new_wasm(sources); + let ast = compiler + .parse("main.ty") + .map_err(|e| JsValue::from_str(&format!("{}", e)))?; + + let (result, table) = with_recording(|| compiler.type_check(&ast)); + if result.has_errors() { + let errors = result + .get_errors() + .iter() + .map(|e| e.clone().display()) + .collect::>() + .join("\n\n"); + return Ok(Err(errors)); + } + + Ok(Ok(typr_graph::build(&ast, &result.type_context.context, &table))) + }; + + let old_result = build_one(old_source)?; + let new_result = build_one(new_source)?; + + let mut errors = Vec::new(); + if let Err(e) = &old_result { + errors.push(format!("(old) {}", e)); + } + if let Err(e) = &new_result { + errors.push(format!("(new) {}", e)); + } + if !errors.is_empty() { + return Ok(GraphDiffResult { + diff_json: String::new(), + old_graph_json: String::new(), + new_graph_json: String::new(), + has_errors: true, + errors: errors.join("\n\n"), + }); + } + + let old_graph = old_result.expect("checked Ok above"); + let new_graph = new_result.expect("checked Ok above"); + let diff = typr_graph::diff::diff(&old_graph, &new_graph); + + let diff_json = serde_json::to_string_pretty(&diff).map_err(|e| JsValue::from_str(&e.to_string()))?; + let old_graph_json = + typr_graph::export::json::to_string_pretty(&old_graph).map_err(|e| JsValue::from_str(&e.to_string()))?; + let new_graph_json = + typr_graph::export::json::to_string_pretty(&new_graph).map_err(|e| JsValue::from_str(&e.to_string()))?; + + Ok(GraphDiffResult { + diff_json, + old_graph_json, + new_graph_json, + has_errors: false, + errors: String::new(), + }) +} + +/// Result of building and diffing the block-graph views of two source versions +#[wasm_bindgen] +pub struct GraphDiffResult { + /// Pretty-printed JSON of the `GraphDiff` (added/removed/modified block keys), empty when + /// `has_errors` is set + #[wasm_bindgen(getter_with_clone)] + pub diff_json: String, + /// Pretty-printed JSON of the *old* version's `BlockGraph` — only its `blocks` map is + /// actually needed (to look up a removed key's kind/name), not its layout + #[wasm_bindgen(getter_with_clone)] + pub old_graph_json: String, + /// Pretty-printed JSON of the *new* version's `BlockGraph`, so the playground can render the + /// diff overlaid on a real layout without a second round-trip + #[wasm_bindgen(getter_with_clone)] + pub new_graph_json: String, + /// Whether type errors on either side prevented building the diff + pub has_errors: bool, + /// Formatted type error messages, prefixed with `(old)`/`(new)` per side (empty if none) + #[wasm_bindgen(getter_with_clone)] + pub errors: String, +} + /// Result of building the block-graph view #[wasm_bindgen] pub struct GraphResult { From 4c9d7a223a5520961ed06e03305d4ad7470f3f38 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 26 Sep 2026 01:30:19 +0200 Subject: [PATCH 32/52] =?UTF-8?q?docs:=20record=20=C3=A9tape=206=20complet?= =?UTF-8?q?ion=20(diff,=20CLI,=20WASM,=20playground)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- visualization_graph_v2.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/visualization_graph_v2.md b/visualization_graph_v2.md index 93c51a3..ebb9965 100644 --- a/visualization_graph_v2.md +++ b/visualization_graph_v2.md @@ -577,11 +577,42 @@ Notes d'implémentation (blocs restants) : `while_loop`/`for_loop`/`loop_loop`). Contourné dans les tests de `typr-graph` avec la syntaxe qui fonctionne ; à signaler si quelqu'un écrit un jour un `.typr` avec ces formes naturelles. -**Étape 6 — Revue** +**Étape 6 — Revue** — fait (2026-09-26) - `diff(G_old, G_new)` apparié par `BlockKey` : blocs ajoutés, supprimés, modifiés (type, interface, captures) ; `typr graph diff ` ; vue diff dans le playground (deux extraits de code). +Notes d'implémentation : +- `crates/typr-graph/src/diff.rs` : portée délibérément au niveau bloc, alignée sur la lettre du + spec (« type, interface, captures », pas « chaque relation ») — le diff des fils et des `Ref` + reste hors périmètre. Les captures comparées sont les ports d'entrée *implicites* seulement + (`Port.implicit`) ; un paramètre explicite renommé n'est pas un changement de capture. L' + « interface » d'un bloc, c'est ses cibles `Satisfies`/`DeclaredAs` (le contrat déclaré), pas sa + liste de relations complète. Un bloc dont seul le `body` change (nouvelle instruction dans un + `Scope`) n'est **pas** rapporté modifié à sa propre clé : ça remonte comme des blocs enfants + ajoutés/supprimés à la place — cohérent avec l'identité par `BlockKey` (§6), qui appairent déjà + ces enfants indépendamment. +- CLI : `typr graph diff [--format text|json]`, sous-commande de `typr graph` + (`GraphCommands::Diff` dans `crates/typr-cli/src/cli.rs`) — `typr graph ` reste la forme + historique (pas de sous-commande). Chaque fichier est construit indépendamment par le même + pipeline que `typr graph`, sans lien de parenté git requis. +- WASM (`crates/typr-wasm/src/lib.rs::semantic_graph_diff`, export `semanticGraphDiff`) : les + deux côtés sont type-checkés même si un seul échoue, pour que l'appelant voie toutes les + erreurs (préfixées `(old)`/`(new)`) en un coup, pas juste la première. `old_graph_json` et + `new_graph_json` accompagnent `diff_json` — le diff ne porte que des clés, pas les blocs + complets, et le playground a besoin des deux graphes : le nouveau pour le rendu (mise en page), + l'ancien pour afficher ce qu'était un bloc supprimé (kind/name), absent du nouveau. +- Playground (`typr-playground.github.io`, branche `graph-fence-block`) : nouvel onglet « Diff », + état local (`baseline`) délibérément **hors** du contrat d'URL partagé (`useGraphNav`) — une + référence est un fichier source entier, pas une clé de bloc, donc ça n'a pas sa place dans un + lien partageable comme `view`/`focus`. Première visite de l'onglet : le code courant devient la + référence (`baseline ?? code`) ; seul un clic explicite sur « Marquer le code actuel comme + référence » la change ensuite — rouvrir l'onglet ne déplace jamais silencieusement la + référence. Rendu : le diff est superposé à la mise en page du **nouveau** graphe + (`GraphView` reçoit `diff`, colore les nœuds ajoutés/modifiés) ; les blocs supprimés n'ont pas + de nœud où s'accrocher (absents du nouveau graphe) donc listés à part dans `DiffSummary`. Le + panneau de détails affiche type/captures/interface avant/après pour un bloc modifié sélectionné. + **Étape 7 — Ensuite** - Navigation clavier, dépliage sur place, `TypePosition` pour les autres positions et le retour, génériques, métriques et cycles, et au besoin l'enregistrement des cibles d'appel exactes From c90a95b9de5d4d69d98bea792b9177dbeb7400da Mon Sep 17 00:00:00 2001 From: Fabrice Date: Sat, 26 Sep 2026 17:54:33 +0200 Subject: [PATCH 33/52] docs: spec relation edges (capture/type) in the block graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds §11 "Relations comme arêtes" — Ref-capture, TypePosition{0}, and HasType drawn as colored, directed, independently toggleable edges on top of the existing wire rendering, plus the implementation notes for the playground-side complement to étape 4. Co-Authored-By: Claude Sonnet 5 --- visualization_graph_v2.md | 84 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/visualization_graph_v2.md b/visualization_graph_v2.md index ebb9965..8e1bdf8 100644 --- a/visualization_graph_v2.md +++ b/visualization_graph_v2.md @@ -391,6 +391,33 @@ ELK.js pour la mise en page (`elk.portConstraints`), un niveau rendu à la fois. | Précédent/Suivant | historique du navigateur | | clic dans Monaco | synchronisation inverse : le graphe se place sur le bloc le plus interne qui contient le curseur | +**Relations comme arêtes** : par défaut, seuls les **fils** (flux de données à l'intérieur d'un +niveau) sont dessinés — c'est le rendu déjà décrit ci-dessus. Trois catégories de **relations** +(§5) peuvent en plus être affichées comme arêtes colorées et dirigées, chacune activable +indépendamment (légende à cases à cocher sur le canevas) : + +| Catégorie | Relation | Sens de la flèche | +|---|---|---| +| Capture implicite | `Ref` dont le port d'origine est **implicite** (`Port.implicit`, §3.3) | du bloc qui capture vers la définition capturée | +| 1ᵉʳ paramètre lié à un type | `TypePosition { index: 0 }` | de la fonction vers le `TypeDecl` | +| Expression liée à un type | `HasType` | de l'expression vers son `TypeDecl`/`TypeExpr` | + +Un `Ref` explicite (`callee` d'un `Apply`, renommage `let x <- y`, …) n'est **pas** une capture et +n'entre dans aucune de ces trois catégories : la distinction se lit sur le port d'origine, pas sur +le seul `kind` de la relation. Convention de sens et de couleur reprises telles quelles (§5.1) ; +seul l'affichage est nouveau, aucun champ n'est ajouté au format JSON (§9). + +Contraintes : +- une relation n'est dessinée que si **ses deux extrémités sont des blocs visibles au niveau + courant** de la vue « un niveau à la fois » — même règle que pour les fils qui sortent de la vue + (étape 2/4) ; une relation dont l'autre bout est ailleurs (ex. `HasType` vers un type déclaré au + niveau `Program` alors qu'on est descendu dans un `Function`) n'a nulle part où s'accrocher à ce + niveau et reste seulement listée dans le panneau de détails ; +- activer/désactiver une catégorie **réinjecte le filtre dans la mise en page ELK** (pas un simple + masquage CSS), pour que les nœuds se réarrangent en fonction des relations rendues visibles — + c'est le but recherché (« arranger le graphe en fonction de ces relations ») ; +- toutes désactivées par défaut : le comportement par défaut du canevas ne change pas. + **État dans l'URL** : `?view=graph&focus=` (encodé). Chaque vue a son lien profond partageable ; Précédent et Suivant marchent sans code supplémentaire. Ce paramètre s'ajoute au contrat `INTEGRATION.md` (à mettre à jour **dans les deux dépôts à la fois**). @@ -511,6 +538,63 @@ Notes d'implémentation (à connaître pour la suite) : - `typr-wasm::semantic_graph`, onglet Graph, navigation et gestes du §11, URL `view`/`focus`, synchronisation Monaco dans les deux sens, `INTEGRATION.md` mis à jour. +Notes d'implémentation (complément « Relations comme arêtes » du §11, `typr-playground.github.io` +uniquement, 2026-09-26) : +- Aucun changement de format JSON (§9) ni de `typr-graph` : tout ce qu'il faut pour distinguer les + trois catégories est déjà dans le graphe exporté. La règle « capture ⇔ port implicite » a été + vérifiée directement dans le builder (`crates/typr-graph/src/build/mod.rs::take_captures` ajoute + toujours `Port::implicit(name, ty)` **et** `Relation::r#ref(boundary, &name, target, …)` avec le + même nom de port) ; à l'inverse, un `Ref` sur `callee` (`build_apply`/`build_ufcs_apply`) ou sur + `value` (renommage `let x <- y`, `build_expr`) vient toujours d'un port explicite ou d'aucun port + du tout — jamais implicite. D'où `relationCategory` (`src/lib/graph.ts`) : `HasType` → catégorie + directe ; `TypePosition{index:0}` → catégorie directe (`index != 0` exclu, réservé à l'étape 7) ; + `Ref` → capture seulement si `blocks[from].inputs` contient `port` avec `implicit: true`. +- `relationEdgesInView` (`src/lib/graph.ts`) ne garde que les relations dont **les deux extrémités** + sont des clés de `view.blocks` — une `oneLevel()` peut contenir une relation dont un seul bout est + dans le focus courant (c'est déjà le cas aujourd'hui pour le panneau de détails, qui lit le graphe + complet), mais dessiner une arête exige un nœud aux deux bouts. +- `useElkLayout` (`src/components/graph/useElkLayout.ts`) prend un `activeCategories: Set<...>` en + plus de la vue, et ajoute les relations actives à la liste d'`edges` envoyée à ELK, à côté des + fils — en référençant directement l'id du **bloc** (`sources: ['val:norm2']`), pas un id de port, + puisque la cible d'une `HasType`/`TypePosition` (souvent un `TypeDecl` sans port d'entrée du tout, + §4) n'a pas de port à viser. Vérifié à la main avec `elkjs` (`elk.portConstraints: FIXED_POS` + coexiste sans erreur avec une arête nœud-à-nœud, et la déplace bien dans la mise en page — c'est + ce qui permet à l'activation d'une catégorie de réarranger le graphe, pas seulement de colorer une + ligne fixe). La clé de mémoïsation de l'effet (`viewKey`) inclut désormais les catégories actives, + triées, pour redéclencher la mise en page à chaque bascule. +- `BlockNode` (`src/components/graph/BlockNode.tsx`) gagne une paire de handles génériques + `__rel-in`/`__rel-out` (`Position.Top`, invisibles par défaut comme `.handle-shadow`) : les + relations n'étant pas des ports du modèle (§3.2), elles ne peuvent pas s'accrocher à un handle de + port existant, qui peut d'ailleurs ne pas exister côté cible. React Flow dessine toujours la ligne + lui-même à partir de ces handles (comme pour les fils) ; seules les positions des nœuds viennent + d'ELK, jamais le tracé de l'arête (déjà vrai avant ce complément). +- Couleur portée par `style.stroke` (variable CSS themée clair/dark, `--edge-capture`/ + `--edge-type-position`/`--edge-has-type` dans `theme.css`) ; pointe de flèche (`markerEnd`, + `MarkerType.ArrowClosed`) en teinte fixe par catégorie plutôt que themée — un `fill` de marqueur + React Flow est posé en attribut SVG direct, pas via `style`, et une teinte moyenne (violet/bleu + ciel/magenta) reste lisible sur fond clair et sombre sans en dépendre. Capture en trait pointillé + (`strokeDasharray`) en plus de sa couleur, pour rester repérable même en daltonisme. +- Légende à cases à cocher : nouveau composant `RelationLegend.tsx`, état local dans `GraphView` + (`activeRelationKinds`, toutes désactivées par défaut) — délibérément **hors** de l'état d'URL + partagé (`view`/`focus`), même choix que la référence du Diff (étape 6) : c'est une préférence + d'affichage de session, pas une coordonnée du graphe qu'un lien partagé doit reproduire. +- Vérifié : `tsc -b`, `npm run lint`, et un script Node ad hoc (esbuild bundle de `src/lib/graph.ts`, + sans mock) rejouant `relationCategory`/`relationEdgesInView` sur le vrai JSON produit par + `typr graph --format json` pour l'exemple fil rouge (§14). Au niveau `Program` : `capture` ne + retient que `norm2 → sq` (le `Ref` sur le port implicite `sq`), pas les deux `Ref` sur `callee` + ni celui de `val:d → type:Printable` (port explicite, validateur à la compilation) — la + distinction capture/référence explicite fonctionne. `typePosition0` retient `show → Point` + **et** `norm2 → Point` (son propre premier paramètre est aussi de type `Point`, indépendamment + de savoir si `norm2` sert de méthode — cohérent avec la définition structurelle du §5.1, pas une + liste des seules méthodes découvertes). `hasType` retient `p → Point` et `d → Point` — ce dernier + diverge du texte du §14 (qui attendait `d → Printable`) : la sortie réelle du compilateur pour + ce `HasType` a changé depuis la rédaction du §14 et le paragraphe n'a pas suivi ; à corriger + séparément, hors périmètre de ce complément (le rendu n'est pas en cause, il reflète fidèlement + la relation telle qu'exportée). Redescendre dans `norm2` (vue à un niveau qui ne contient plus + `type:Point`) fait disparaître les trois catégories, conformément à la contrainte du §11. + `npm run dev` + un rendu manuel dans Chrome a confirmé la partie purement visuelle (couleurs, + flèche, bascule de la légende, réarrangement ELK à l'activation). + **Étape 5 — Documentation et blocs restants** — fait (2026-09-26) - ` ```typr graph ` sur typr.github.io (dépôts mis à jour ensemble). — fait. - `Loop` (ports d'état), `Match`, `Module` (sorties `@pub`), `RCode`. — fait. From f0b460503ddf5e16ed58d6236265b24641ba2895 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Tue, 29 Sep 2026 23:34:55 +0200 Subject: [PATCH 34/52] update --- .../case.toml | 7 + .../expect.md | 16 + .../expect.toml | 11 + .../observed.txt | 61 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 21 + .../case.toml | 7 + .../expect.md | 26 + .../expect.toml | 19 + .../observed.txt | 11 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 26 + .../case.toml | 7 + .../expect.md | 14 + .../expect.toml | 11 + .../observed.txt | 12 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 7 + .../case.toml | 7 + .../expect.md | 13 + .../expect.toml | 7 + .../observed.txt | 12 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 3 + .../0072-union-subtype-always-true/case.toml | 7 + .../0072-union-subtype-always-true/expect.md | 16 + .../expect.toml | 20 + .../observed.txt | 11 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 15 + .../repro/std.ty | 204 +++ cases/0072-union-subtype-always-true/std.ty | 204 +++ crates/typr-core/src/components/type/mod.rs | 3 - .../src/processes/parsing/elements.rs | 29 +- .../src/processes/parsing/indexation.rs | 10 +- .../typr-core/src/processes/parsing/types.rs | 14 +- .../src/processes/type_checking/mod.rs | 39 +- crates/typr-graph/src/build/blocks.rs | 67 +- crates/typr-graph/src/build/mod.rs | 33 +- crates/typr-graph/src/build/type_relations.rs | 155 ++- crates/typr-graph/src/model.rs | 17 + ...ough_example_builds_without_panicking.snap | 152 ++- refined_types.md | 1144 +++++++++++++++++ refined_types_plan.md | 251 ++++ visualization_graph.md | 698 ---------- visualization_graph_v2.md | 765 ----------- 56 files changed, 2685 insertions(+), 1497 deletions(-) create mode 100644 cases/0068-generic-record-binding-nondeterministic/case.toml create mode 100644 cases/0068-generic-record-binding-nondeterministic/expect.md create mode 100644 cases/0068-generic-record-binding-nondeterministic/expect.toml create mode 100644 cases/0068-generic-record-binding-nondeterministic/observed.txt create mode 100644 cases/0068-generic-record-binding-nondeterministic/repro/DESCRIPTION create mode 100644 cases/0068-generic-record-binding-nondeterministic/repro/NAMESPACE create mode 100644 cases/0068-generic-record-binding-nondeterministic/repro/R/.gitkeep create mode 100644 cases/0068-generic-record-binding-nondeterministic/repro/TypR/main.ty create mode 100644 cases/0069-record-arg-not-first-unchecked/case.toml create mode 100644 cases/0069-record-arg-not-first-unchecked/expect.md create mode 100644 cases/0069-record-arg-not-first-unchecked/expect.toml create mode 100644 cases/0069-record-arg-not-first-unchecked/observed.txt create mode 100644 cases/0069-record-arg-not-first-unchecked/repro/DESCRIPTION create mode 100644 cases/0069-record-arg-not-first-unchecked/repro/NAMESPACE create mode 100644 cases/0069-record-arg-not-first-unchecked/repro/R/.gitkeep create mode 100644 cases/0069-record-arg-not-first-unchecked/repro/TypR/main.ty create mode 100644 cases/0070-integer-literal-overflow-panics/case.toml create mode 100644 cases/0070-integer-literal-overflow-panics/expect.md create mode 100644 cases/0070-integer-literal-overflow-panics/expect.toml create mode 100644 cases/0070-integer-literal-overflow-panics/observed.txt create mode 100644 cases/0070-integer-literal-overflow-panics/repro/DESCRIPTION create mode 100644 cases/0070-integer-literal-overflow-panics/repro/NAMESPACE create mode 100644 cases/0070-integer-literal-overflow-panics/repro/R/.gitkeep create mode 100644 cases/0070-integer-literal-overflow-panics/repro/TypR/main.ty create mode 100644 cases/0071-type-level-integer-overflow/case.toml create mode 100644 cases/0071-type-level-integer-overflow/expect.md create mode 100644 cases/0071-type-level-integer-overflow/expect.toml create mode 100644 cases/0071-type-level-integer-overflow/observed.txt create mode 100644 cases/0071-type-level-integer-overflow/repro/DESCRIPTION create mode 100644 cases/0071-type-level-integer-overflow/repro/NAMESPACE create mode 100644 cases/0071-type-level-integer-overflow/repro/R/.gitkeep create mode 100644 cases/0071-type-level-integer-overflow/repro/TypR/main.ty create mode 100644 cases/0072-union-subtype-always-true/case.toml create mode 100644 cases/0072-union-subtype-always-true/expect.md create mode 100644 cases/0072-union-subtype-always-true/expect.toml create mode 100644 cases/0072-union-subtype-always-true/observed.txt create mode 100644 cases/0072-union-subtype-always-true/repro/DESCRIPTION create mode 100644 cases/0072-union-subtype-always-true/repro/NAMESPACE create mode 100644 cases/0072-union-subtype-always-true/repro/R/.gitkeep create mode 100644 cases/0072-union-subtype-always-true/repro/TypR/main.ty create mode 100644 cases/0072-union-subtype-always-true/repro/std.ty create mode 100644 cases/0072-union-subtype-always-true/std.ty create mode 100644 refined_types.md create mode 100644 refined_types_plan.md delete mode 100644 visualization_graph.md delete mode 100644 visualization_graph_v2.md diff --git a/cases/0068-generic-record-binding-nondeterministic/case.toml b/cases/0068-generic-record-binding-nondeterministic/case.toml new file mode 100644 index 0000000..c68ee3f --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/case.toml @@ -0,0 +1,7 @@ +title = "generic record parameter binds T to a random field type (HashSet iteration order)" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-28" +origin = "perso" +checked = false diff --git a/cases/0068-generic-record-binding-nondeterministic/expect.md b/cases/0068-generic-record-binding-nondeterministic/expect.md new file mode 100644 index 0000000..14bec81 --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/expect.md @@ -0,0 +1,16 @@ +# generic-record-binding-nondeterministic + +## Ce qui devrait se passer + +`first(p: list { a: T, b: U, c: V }): T` appelé avec `:{ a = 1, b = "s", c = true }` doit +lier `T` au type du champ `a` (donc `int`) à chaque exécution : `let y: int <- first(r)` passe +toujours. + +## Anomalies + +`observed.txt` montre `type int doesn't match type true` / `... "t"` : `T` a été lié au type +d'un autre champ (`c` ou `b`). Le résultat change d'une exécution à l'autre (un appel isolé +échoue environ 2 fois sur 3) : l'arm `Record/Record` de `get_gen_type` +(`type_checking/mod.rs`) fait un `zip` sur deux `HashSet` et apparie donc les +champs dans un ordre arbitraire. Le repro contient 8 appels indépendants pour que la +probabilité qu'ils passent tous par hasard soit négligeable tant que le bug existe. diff --git a/cases/0068-generic-record-binding-nondeterministic/expect.toml b/cases/0068-generic-record-binding-nondeterministic/expect.toml new file mode 100644 index 0000000..aedb1f8 --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/expect.toml @@ -0,0 +1,11 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +[[rule]] +file = "@run" +must_not_contain = "doesn't match type" diff --git a/cases/0068-generic-record-binding-nondeterministic/observed.txt b/cases/0068-generic-record-binding-nondeterministic/observed.txt new file mode 100644 index 0000000..c5bddad --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/observed.txt @@ -0,0 +1,61 @@ +# exit=1 + +## stdout/stderr + + Parsing... done (9 ms) + Type checking... failed +Type errors found: + × Type error: type int doesn't match type true + ╭─[TypR/main.ty:7:9] + 6 │ let r1 <- :{ a = 1, b = "s", c = true }; + 7 │ let y1: int <- first(r1); + · ▲ ┬ + · │ ╰── Received true + · ╰── Expected int + 8 │ let r2 <- :{ a = 2, b = "t", c = false }; + ╰──── + + × Type error: type int doesn't match type "t" + ╭─[TypR/main.ty:9:9] + 8 │ let r2 <- :{ a = 2, b = "t", c = false }; + 9 │ let y2: int <- first(r2); + · ▲ ┬ + · │ ╰── Received "t" + · ╰── Expected int + 10 │ let r3 <- :{ a = 3, b = "u", c = true }; + ╰──── + + × Type error: type int doesn't match type true + ╭─[TypR/main.ty:11:9] + 10 │ let r3 <- :{ a = 3, b = "u", c = true }; + 11 │ let y3: int <- first(r3); + · ▲ ┬ + · │ ╰── Received true + · ╰── Expected int + 12 │ let r4 <- :{ a = 4, b = "v", c = false }; + ╰──── + + × Type error: type int doesn't match type false + ╭─[TypR/main.ty:13:9] + 12 │ let r4 <- :{ a = 4, b = "v", c = false }; + 13 │ let y4: int <- first(r4); + · ▲ ┬ + · │ ╰── Received false + · ╰── Expected int + 14 │ let r5 <- :{ a = 5, b = "w", c = true }; + ╰──── + + × Type error: type int doesn't match type false + ╭─[TypR/main.ty:17:9] + 16 │ let r6 <- :{ a = 6, b = "x", c = false }; + 17 │ let y6: int <- first(r6); + · ▲ ┬ + · │ ╰── Received false + · ╰── Expected int + 18 │ let r7 <- :{ a = 7, b = "y", c = true }; + ╰──── + + + +## R généré + diff --git a/cases/0068-generic-record-binding-nondeterministic/repro/DESCRIPTION b/cases/0068-generic-record-binding-nondeterministic/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0068-generic-record-binding-nondeterministic/repro/NAMESPACE b/cases/0068-generic-record-binding-nondeterministic/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0068-generic-record-binding-nondeterministic/repro/R/.gitkeep b/cases/0068-generic-record-binding-nondeterministic/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0068-generic-record-binding-nondeterministic/repro/TypR/main.ty b/cases/0068-generic-record-binding-nondeterministic/repro/TypR/main.ty new file mode 100644 index 0000000..beeecef --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/repro/TypR/main.ty @@ -0,0 +1,21 @@ +# A generic record parameter with several fields of different types: the +# checker must bind T to the type of field `a` every time, regardless of +# the internal iteration order of the record's field set. +let first <- fn(p: list { a: T, b: U, c: V }): T { p.a }; + +let r1 <- :{ a = 1, b = "s", c = true }; +let y1: int <- first(r1); +let r2 <- :{ a = 2, b = "t", c = false }; +let y2: int <- first(r2); +let r3 <- :{ a = 3, b = "u", c = true }; +let y3: int <- first(r3); +let r4 <- :{ a = 4, b = "v", c = false }; +let y4: int <- first(r4); +let r5 <- :{ a = 5, b = "w", c = true }; +let y5: int <- first(r5); +let r6 <- :{ a = 6, b = "x", c = false }; +let y6: int <- first(r6); +let r7 <- :{ a = 7, b = "y", c = true }; +let y7: int <- first(r7); +let r8 <- :{ a = 8, b = "z", c = false }; +let y8: int <- first(r8); diff --git a/cases/0069-record-arg-not-first-unchecked/case.toml b/cases/0069-record-arg-not-first-unchecked/case.toml new file mode 100644 index 0000000..19586ec --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/case.toml @@ -0,0 +1,7 @@ +title = "a record-typed argument that is not the first is never checked against its parameter" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-28" +origin = "perso" +checked = false diff --git a/cases/0069-record-arg-not-first-unchecked/expect.md b/cases/0069-record-arg-not-first-unchecked/expect.md new file mode 100644 index 0000000..1683c33 --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/expect.md @@ -0,0 +1,26 @@ +# record-arg-not-first-unchecked + +## Ce qui devrait se passer + +Un argument de type record en position 2 ou plus doit être vérifié contre le type du +paramètre, comme l'est le premier argument (`take(q)` avec `q: Qq` pour `p: Pt` est rejeté) +et comme le sont les arguments scalaires. Les trois appels du repro sont invalides : +un record d'un autre type (`Qq` pour `Pt`), un record dont le champ `x` a le mauvais type +(`Rr { x: char }` pour `Pt { x: int }`), et le même en 3ᵉ position. + +## Anomalies + +`observed.txt` : le checker termine sans aucune erreur. Cause : `get_gen_type` +(`type_checking/mod.rs`, arm `Record/Record`) renvoie toujours `Some(..)` — c'est un +collecteur de liaisons de génériques utilisé comme porte d'entrée par +`Context::get_unification_map`, qui ne rejette donc jamais deux records. Pour le premier +argument, `filter_by_first_param` fait un vrai contrôle de sous-typage à part, ce qui masque +le trou. (Les arguments de type *fonction* ne sont en revanche pas concernés : un premier +diagnostic les disait non vérifiés, mais c'était un artefact du nom `apply`, qui collisionne +avec l'`apply` de la std.) + +Deuxième cause, visible sur les records anonymes (`:{ z = 1 }` passé pour un `Pt`) : le dernier +arm de `get_gen_type` teste `is_subtype(.., &Context::empty())`. Avec un contexte vide, l'alias +`Pt` ne se résout pas et se réduit à `Any` (cf. S4 de `audit_type_checking.md`), dont tout type +est sous-type. Le correctif renvoie `None` pour un alias transparent en paramètre, ce qui laisse +`match_types_to_generic` retenter sur les types réduits, avec le vrai contexte. diff --git a/cases/0069-record-arg-not-first-unchecked/expect.toml b/cases/0069-record-arg-not-first-unchecked/expect.toml new file mode 100644 index 0000000..7a60167 --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/expect.toml @@ -0,0 +1,19 @@ +[[rule]] +file = "@run" +must_contain = "No signature of function 'take_other' matches this call" + +[[rule]] +file = "@run" +must_contain = "No signature of function 'take_field' matches this call" + +[[rule]] +file = "@run" +must_contain = "No signature of function 'take_third' matches this call" + +[[rule]] +file = "@run" +must_contain = "No signature of function 'take_anon' matches this call" + +[[rule]] +file = "@run" +must_contain = "No signature of function 'take_anon_ty' matches this call" diff --git a/cases/0069-record-arg-not-first-unchecked/observed.txt b/cases/0069-record-arg-not-first-unchecked/observed.txt new file mode 100644 index 0000000..e122488 --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/observed.txt @@ -0,0 +1,11 @@ +# exit=0 + +## stdout/stderr +Code verification successful! + + Parsing... done (8 ms) + Type checking... done (29 ms) + + +## R généré + diff --git a/cases/0069-record-arg-not-first-unchecked/repro/DESCRIPTION b/cases/0069-record-arg-not-first-unchecked/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0069-record-arg-not-first-unchecked/repro/NAMESPACE b/cases/0069-record-arg-not-first-unchecked/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0069-record-arg-not-first-unchecked/repro/R/.gitkeep b/cases/0069-record-arg-not-first-unchecked/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0069-record-arg-not-first-unchecked/repro/TypR/main.ty b/cases/0069-record-arg-not-first-unchecked/repro/TypR/main.ty new file mode 100644 index 0000000..c1a1940 --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/repro/TypR/main.ty @@ -0,0 +1,26 @@ +# A record-typed argument that is not the first must be checked against the +# parameter type, like the first one and scalar ones are. Each call below is +# invalid and must be rejected. +type Pt <- list { x: int }; +type Qq <- list { z: int }; +type Rr <- list { x: char }; + +let take_other <- fn(a: int, p: Pt): int { a }; +let other <- Qq:{ z = 1 }; +let bad_other <- take_other(1, other); + +let take_field <- fn(a: int, p: Pt): int { a }; +let wrong_field <- Rr:{ x = "s" }; +let bad_field <- take_field(1, wrong_field); + +let take_third <- fn(a: int, b: int, p: Pt): int { a }; +let bad_third <- take_third(1, 2, other); + +# Anonymous records (no constructor) must be checked as well. +let take_anon <- fn(a: int, p: Pt): int { a }; +let anon_missing <- :{ z = 1 }; +let bad_anon_missing <- take_anon(1, anon_missing); + +let take_anon_ty <- fn(a: int, p: Pt): int { a }; +let anon_wrong <- :{ x = "s" }; +let bad_anon_wrong <- take_anon_ty(1, anon_wrong); diff --git a/cases/0070-integer-literal-overflow-panics/case.toml b/cases/0070-integer-literal-overflow-panics/case.toml new file mode 100644 index 0000000..72d742b --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/case.toml @@ -0,0 +1,7 @@ +title = "an integer literal outside the i32 range panics the parser instead of typing as num" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-29" +origin = "perso" +checked = false diff --git a/cases/0070-integer-literal-overflow-panics/expect.md b/cases/0070-integer-literal-overflow-panics/expect.md new file mode 100644 index 0000000..418cde8 --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/expect.md @@ -0,0 +1,14 @@ +# integer-literal-overflow-panics + +## Ce qui devrait se passer + +Un littéral entier hors de la plage i32 (`2147483648`, `-2147483649`, `99999999999`) est un +double en R : il doit se typer `num`, pas faire tomber le compilateur. `99999999999.5` (déjà un +`num`) et `2147483647` (le plus grand `int`) doivent continuer à passer. + +## Anomalies + +`observed.txt` : `exit=101`, panic `ParseIntError { kind: PosOverflow }` à +`parsing/elements.rs:117` (`symbol.parse::().unwrap()` dans `integer_impl`). Même +`99999999999.5` panique, car `integer` est tenté avant `number` sur certains chemins du parseur. +Le panic emporte aussi le LSP. diff --git a/cases/0070-integer-literal-overflow-panics/expect.toml b/cases/0070-integer-literal-overflow-panics/expect.toml new file mode 100644 index 0000000..7a9b546 --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/expect.toml @@ -0,0 +1,11 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "panicked" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" diff --git a/cases/0070-integer-literal-overflow-panics/observed.txt b/cases/0070-integer-literal-overflow-panics/observed.txt new file mode 100644 index 0000000..d0dd330 --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/observed.txt @@ -0,0 +1,12 @@ +# exit=101 + +## stdout/stderr + + Parsing... +thread 'main' (107795) panicked at crates/typr-core/src/processes/parsing/elements.rs:117:50: +called `Result::unwrap()` on an `Err` value: ParseIntError { kind: PosOverflow } +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +## R généré + diff --git a/cases/0070-integer-literal-overflow-panics/repro/DESCRIPTION b/cases/0070-integer-literal-overflow-panics/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0070-integer-literal-overflow-panics/repro/NAMESPACE b/cases/0070-integer-literal-overflow-panics/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0070-integer-literal-overflow-panics/repro/R/.gitkeep b/cases/0070-integer-literal-overflow-panics/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0070-integer-literal-overflow-panics/repro/TypR/main.ty b/cases/0070-integer-literal-overflow-panics/repro/TypR/main.ty new file mode 100644 index 0000000..30fe9ea --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/repro/TypR/main.ty @@ -0,0 +1,7 @@ +# An integer literal outside the 32-bit range is a double in R, so it must +# type as `num` instead of aborting the compiler. +let above_max: num <- 2147483648; +let below_min: num <- -2147483649; +let large: num <- 99999999999; +let large_fraction: num <- 99999999999.5; +let still_int: int <- 2147483647; diff --git a/cases/0071-type-level-integer-overflow/case.toml b/cases/0071-type-level-integer-overflow/case.toml new file mode 100644 index 0000000..4fc3e17 --- /dev/null +++ b/cases/0071-type-level-integer-overflow/case.toml @@ -0,0 +1,7 @@ +title = "a type-level integer outside the i32 range panics (or silently becomes 0)" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-29" +origin = "perso" +checked = false diff --git a/cases/0071-type-level-integer-overflow/expect.md b/cases/0071-type-level-integer-overflow/expect.md new file mode 100644 index 0000000..d24f667 --- /dev/null +++ b/cases/0071-type-level-integer-overflow/expect.md @@ -0,0 +1,13 @@ +# type-level-integer-overflow + +## Ce qui devrait se passer + +Une dimension ou un entier singleton de type hors i32 (`[99999999999, int]`) est signalé comme +erreur de syntaxe. Il ne doit ni faire paniquer le compilateur ni être réinterprété en silence. + +## Anomalies + +`observed.txt` : `exit=101`, panic `ParseIntError` à `parsing/types.rs:159` +(`simple_index`). Le chemin voisin `integer_literal` (`types.rs:~557`) est pire : il fait +`parse().unwrap_or(0)`, donc `type Big <- 99999999999` devient en silence le singleton `0`. +`indexation.rs:42/50` (`parse_integer`, `parse_positive_integer`) ont le même `unwrap()`. diff --git a/cases/0071-type-level-integer-overflow/expect.toml b/cases/0071-type-level-integer-overflow/expect.toml new file mode 100644 index 0000000..c296f9e --- /dev/null +++ b/cases/0071-type-level-integer-overflow/expect.toml @@ -0,0 +1,7 @@ +[[rule]] +file = "@run" +must_not_contain = "panicked" + +[[rule]] +file = "@run" +must_contain = "Syntax error" diff --git a/cases/0071-type-level-integer-overflow/observed.txt b/cases/0071-type-level-integer-overflow/observed.txt new file mode 100644 index 0000000..584e1c5 --- /dev/null +++ b/cases/0071-type-level-integer-overflow/observed.txt @@ -0,0 +1,12 @@ +# exit=101 + +## stdout/stderr + + Parsing... +thread 'main' (107798) panicked at crates/typr-core/src/processes/parsing/types.rs:159:63: +called `Result::unwrap()` on an `Err` value: ParseIntError { kind: PosOverflow } +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +## R généré + diff --git a/cases/0071-type-level-integer-overflow/repro/DESCRIPTION b/cases/0071-type-level-integer-overflow/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0071-type-level-integer-overflow/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0071-type-level-integer-overflow/repro/NAMESPACE b/cases/0071-type-level-integer-overflow/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0071-type-level-integer-overflow/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0071-type-level-integer-overflow/repro/R/.gitkeep b/cases/0071-type-level-integer-overflow/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0071-type-level-integer-overflow/repro/TypR/main.ty b/cases/0071-type-level-integer-overflow/repro/TypR/main.ty new file mode 100644 index 0000000..155c7c6 --- /dev/null +++ b/cases/0071-type-level-integer-overflow/repro/TypR/main.ty @@ -0,0 +1,3 @@ +# A type-level integer (array dimension, singleton type) outside the 32-bit +# range must be reported, never abort the compiler nor silently become 0. +let big_dim: [99999999999, int] <- [1]; diff --git a/cases/0072-union-subtype-always-true/case.toml b/cases/0072-union-subtype-always-true/case.toml new file mode 100644 index 0000000..06981c8 --- /dev/null +++ b/cases/0072-union-subtype-always-true/case.toml @@ -0,0 +1,7 @@ +title = "any union is accepted as a subtype of any other union" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-29" +origin = "perso" +checked = false diff --git a/cases/0072-union-subtype-always-true/expect.md b/cases/0072-union-subtype-always-true/expect.md new file mode 100644 index 0000000..f599155 --- /dev/null +++ b/cases/0072-union-subtype-always-true/expect.md @@ -0,0 +1,16 @@ +# union-subtype-always-true + +## Ce qui devrait se passer + +`U1 <: U2` n'est vrai que si chaque membre de `U1` tient dans un membre de `U2` (même tag, charge +utile sous-type). Sur le repro, `Shape -> Unrelated` (tags disjoints), `Shape -> WrongPayload` +(mêmes tags, charge `char` au lieu de `int`) et `Shape -> Partial` (un tag de `Shape` absent) sont +rejetés ; l'élargissement `Shape -> Wider` et l'identité `Shape -> Shape` restent acceptés. + +## Anomalies + +`observed.txt` : aucune erreur. `is_subtype_raw` (`components/type/mod.rs`) avait un arm +`(Union, Union) => true //TODO: Fix this` placé avant les deux arms génériques (« tous les membres +gauches sous-types de la droite », « sous-type d'au moins un membre droit »), qui masquait la bonne +règle. Un `Shape` était donc utilisable là où un union sans aucun tag commun était attendu, et le +`match` du receveur pouvait alors rencontrer un tag qu'il ne connaît pas. diff --git a/cases/0072-union-subtype-always-true/expect.toml b/cases/0072-union-subtype-always-true/expect.toml new file mode 100644 index 0000000..3666d38 --- /dev/null +++ b/cases/0072-union-subtype-always-true/expect.toml @@ -0,0 +1,20 @@ +[[rule]] +file = "@run" +must_contain = "Expected: Unrelated" + +[[rule]] +file = "@run" +must_contain = "Expected: WrongPayload" + +[[rule]] +file = "@run" +must_contain = "Expected: Partial" + +# Valid conversions must keep passing. +[[rule]] +file = "@run" +must_not_contain = "Expected: Wider" + +[[rule]] +file = "@run" +must_not_contain = "Expected: Shape" diff --git a/cases/0072-union-subtype-always-true/observed.txt b/cases/0072-union-subtype-always-true/observed.txt new file mode 100644 index 0000000..21907ff --- /dev/null +++ b/cases/0072-union-subtype-always-true/observed.txt @@ -0,0 +1,11 @@ +# exit=0 + +## stdout/stderr +Code verification successful! + + Parsing... done (14 ms) + Type checking... done (50 ms) + + +## R généré + diff --git a/cases/0072-union-subtype-always-true/repro/DESCRIPTION b/cases/0072-union-subtype-always-true/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0072-union-subtype-always-true/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0072-union-subtype-always-true/repro/NAMESPACE b/cases/0072-union-subtype-always-true/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0072-union-subtype-always-true/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0072-union-subtype-always-true/repro/R/.gitkeep b/cases/0072-union-subtype-always-true/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0072-union-subtype-always-true/repro/TypR/main.ty b/cases/0072-union-subtype-always-true/repro/TypR/main.ty new file mode 100644 index 0000000..e84898b --- /dev/null +++ b/cases/0072-union-subtype-always-true/repro/TypR/main.ty @@ -0,0 +1,15 @@ +# A union is a subtype of another union only when every one of its members +# fits in one of the other's. Each `bad_*` conversion below is invalid. +type Shape <- .Circle(int) | .Square(int); +type Unrelated <- .Triangle(char) | .Hexagon(bool); +type WrongPayload <- .Circle(char) | .Square(char); +type Partial <- .Circle(int) | .Triangle(int); + +let bad_unrelated <- fn(s: Shape): Unrelated { s }; +let bad_payload <- fn(s: Shape): WrongPayload { s }; +let bad_partial <- fn(s: Shape): Partial { s }; + +# Valid widening: must keep type-checking. +type Wider <- .Circle(int) | .Square(int) | .Triangle(int); +let ok_wider <- fn(s: Shape): Wider { s }; +let ok_same <- fn(s: Shape): Shape { s }; diff --git a/cases/0072-union-subtype-always-true/repro/std.ty b/cases/0072-union-subtype-always-true/repro/std.ty new file mode 100644 index 0000000..02e434c --- /dev/null +++ b/cases/0072-union-subtype-always-true/repro/std.ty @@ -0,0 +1,204 @@ +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer sum +#! example: 1L + 2L # -> 3L +@`+`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric sum +#! example: 1.5 + 2.5 # -> 4 +@`+`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer difference +#! example: 5L - 3L # -> 2L +@`-`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric difference +#! example: 5.0 - 3.0 # -> 2 +@`-`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: integer quotient +#! example: 10L / 3L # -> 3L +@`/`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: numeric quotient +#! example: 10.0 / 3.0 # -> 3.333... +@`/`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer product +#! example: 3L * 4L # -> 12L +@`*`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric product +#! example: 3.0 * 4.0 # -> 12 +@`*`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: integer modulo +#! note: %% is the modulo operator (not remainder) +#! example: 7L %% 3L # -> 1L +@`%%`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: numeric modulo +#! example: 7.0 %% 3.0 # -> 1 +@`%%`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: logical AND +#! example: TRUE && FALSE # -> FALSE +@`&&`: (bool, bool) -> bool; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: logical OR +#! example: TRUE || FALSE # -> TRUE +@`||`: (bool, bool) -> bool; +#! pkg: base +#! tier: T1 +#! param a: left vector +#! param b: right vector +#! ret: element-wise sum of two vectors +#! coercion: vectors must be same length (recycling applies in R) +#! example: c(1,2) + c(3,4) # -> c(4,6) +@`+`: (Vec[#M, T], Vec[#M, T]) -> Vec[#M, T]; +#! pkg: base +#! tier: T1 +#! param a: vector to reduce +#! param f: binary combining function +#! ret: single value after left-fold +#! note: equivalent to base R Reduce() +#! example: reduce(c(1,2,3,4), add) # -> 10 +@reduce: ([#N, T], (T, T) -> T) -> T; +#! pkg: base +#! tier: T1 +#! param a: vector to fold +#! param init: initial accumulator value +#! param f: combining function (accumulator, element) -> accumulator +#! ret: final accumulator +#! note: equivalent to base R Reduce() with init +#! example: fold(c(1,2,3), 0, add) # -> 6 +@fold: ([#N, T], U, (U, T) -> U) -> U; +#! pkg: base +#! tier: T1 +#! param a: vector to extend +#! param b: element to append +#! ret: new vector with b appended +#! example: extend(c(1,2), 3) # -> c(1,2,3) +@extend: ([#N, T], T) -> [#N, T]; +#! pkg: base +#! tier: T3 +#! param desc: test description +#! param expr: test expression +#! ret: invisibly NULL +#! note: testing utility — not for production code +@test_that: (char, Any) -> Empty; +#! pkg: base +#! tier: T3 +#! param condition: expected condition +#! ret: invisibly NULL +#! note: testing utility +@expect_true: (bool) -> Empty; +#! pkg: base +#! tier: T3 +#! param actual: actual value +#! param expected: expected value +#! ret: invisibly NULL +#! note: testing utility +@expect_false: (T, T) -> Empty; +#! pkg: base +#! tier: T3 +#! param actual: actual value +#! param expected: expected value +#! ret: invisibly NULL +#! note: testing utility +@expect_equal: (T, T) -> Empty; +#! pkg: base +#! tier: T2 +#! param values: values to concatenate and print +#! ret: invisibly NULL +#! note: variadic, side-effectful; R's paste(..., sep="") + cat() +#! example: cat("hello", "world") +@cat: (...values: Any) -> Empty; +#! pkg: base +#! tier: T1 +#! param a: TypR value +#! ret: same value as native R +#! note: pass-through for interop boundaries +@to_native: (T) -> T; +#! pkg: base +#! tier: T2 +#! param a: native R value +#! param type: target TypR type name +#! ret: coerced value +#! note: runtime cast — type must match or error +@from_native: (Any, char) -> Any; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: integer representation +#! coercion: numeric/logical -> integer +#! example: from_int(3.7) # -> 3L +@from_int: (Any) -> int; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: numeric representation +#! coercion: integer/logical -> numeric +#! example: # noplayground: from_num(3L) # -> 3.0 +@from_num: (Any) -> num; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: character representation +#! coercion: any atomic -> character +#! example: from_char(42) # -> "42" +@from_char: (Any) -> char; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: logical representation +#! coercion: numeric -> logical (0 = FALSE, non-zero = TRUE) +#! example: from_bool(1) # -> TRUE +@from_bool: (Any) -> bool; +#! pkg: base +#! tier: T1 +#! param x: numeric vector +#! ret: sum over all elements +#! example: sum(c(1, 2, 3)) # -> 6 +@sum: ([#N, T]) -> T; +#! pkg: base +#! tier: T1 +#! param x: value to display +#! ret: prints to the console, returns nothing +#! example: print(sum(c(1, 2, 3))) +@print: (Any) -> Empty; diff --git a/cases/0072-union-subtype-always-true/std.ty b/cases/0072-union-subtype-always-true/std.ty new file mode 100644 index 0000000..02e434c --- /dev/null +++ b/cases/0072-union-subtype-always-true/std.ty @@ -0,0 +1,204 @@ +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer sum +#! example: 1L + 2L # -> 3L +@`+`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric sum +#! example: 1.5 + 2.5 # -> 4 +@`+`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer difference +#! example: 5L - 3L # -> 2L +@`-`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric difference +#! example: 5.0 - 3.0 # -> 2 +@`-`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: integer quotient +#! example: 10L / 3L # -> 3L +@`/`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: numeric quotient +#! example: 10.0 / 3.0 # -> 3.333... +@`/`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer product +#! example: 3L * 4L # -> 12L +@`*`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric product +#! example: 3.0 * 4.0 # -> 12 +@`*`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: integer modulo +#! note: %% is the modulo operator (not remainder) +#! example: 7L %% 3L # -> 1L +@`%%`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: numeric modulo +#! example: 7.0 %% 3.0 # -> 1 +@`%%`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: logical AND +#! example: TRUE && FALSE # -> FALSE +@`&&`: (bool, bool) -> bool; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: logical OR +#! example: TRUE || FALSE # -> TRUE +@`||`: (bool, bool) -> bool; +#! pkg: base +#! tier: T1 +#! param a: left vector +#! param b: right vector +#! ret: element-wise sum of two vectors +#! coercion: vectors must be same length (recycling applies in R) +#! example: c(1,2) + c(3,4) # -> c(4,6) +@`+`: (Vec[#M, T], Vec[#M, T]) -> Vec[#M, T]; +#! pkg: base +#! tier: T1 +#! param a: vector to reduce +#! param f: binary combining function +#! ret: single value after left-fold +#! note: equivalent to base R Reduce() +#! example: reduce(c(1,2,3,4), add) # -> 10 +@reduce: ([#N, T], (T, T) -> T) -> T; +#! pkg: base +#! tier: T1 +#! param a: vector to fold +#! param init: initial accumulator value +#! param f: combining function (accumulator, element) -> accumulator +#! ret: final accumulator +#! note: equivalent to base R Reduce() with init +#! example: fold(c(1,2,3), 0, add) # -> 6 +@fold: ([#N, T], U, (U, T) -> U) -> U; +#! pkg: base +#! tier: T1 +#! param a: vector to extend +#! param b: element to append +#! ret: new vector with b appended +#! example: extend(c(1,2), 3) # -> c(1,2,3) +@extend: ([#N, T], T) -> [#N, T]; +#! pkg: base +#! tier: T3 +#! param desc: test description +#! param expr: test expression +#! ret: invisibly NULL +#! note: testing utility — not for production code +@test_that: (char, Any) -> Empty; +#! pkg: base +#! tier: T3 +#! param condition: expected condition +#! ret: invisibly NULL +#! note: testing utility +@expect_true: (bool) -> Empty; +#! pkg: base +#! tier: T3 +#! param actual: actual value +#! param expected: expected value +#! ret: invisibly NULL +#! note: testing utility +@expect_false: (T, T) -> Empty; +#! pkg: base +#! tier: T3 +#! param actual: actual value +#! param expected: expected value +#! ret: invisibly NULL +#! note: testing utility +@expect_equal: (T, T) -> Empty; +#! pkg: base +#! tier: T2 +#! param values: values to concatenate and print +#! ret: invisibly NULL +#! note: variadic, side-effectful; R's paste(..., sep="") + cat() +#! example: cat("hello", "world") +@cat: (...values: Any) -> Empty; +#! pkg: base +#! tier: T1 +#! param a: TypR value +#! ret: same value as native R +#! note: pass-through for interop boundaries +@to_native: (T) -> T; +#! pkg: base +#! tier: T2 +#! param a: native R value +#! param type: target TypR type name +#! ret: coerced value +#! note: runtime cast — type must match or error +@from_native: (Any, char) -> Any; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: integer representation +#! coercion: numeric/logical -> integer +#! example: from_int(3.7) # -> 3L +@from_int: (Any) -> int; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: numeric representation +#! coercion: integer/logical -> numeric +#! example: # noplayground: from_num(3L) # -> 3.0 +@from_num: (Any) -> num; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: character representation +#! coercion: any atomic -> character +#! example: from_char(42) # -> "42" +@from_char: (Any) -> char; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: logical representation +#! coercion: numeric -> logical (0 = FALSE, non-zero = TRUE) +#! example: from_bool(1) # -> TRUE +@from_bool: (Any) -> bool; +#! pkg: base +#! tier: T1 +#! param x: numeric vector +#! ret: sum over all elements +#! example: sum(c(1, 2, 3)) # -> 6 +@sum: ([#N, T]) -> T; +#! pkg: base +#! tier: T1 +#! param x: value to display +#! ret: prints to the console, returns nothing +#! example: print(sum(c(1, 2, 3))) +@print: (Any) -> Empty; diff --git a/crates/typr-core/src/components/type/mod.rs b/crates/typr-core/src/components/type/mod.rs index f232b4c..9b377b9 100644 --- a/crates/typr-core/src/components/type/mod.rs +++ b/crates/typr-core/src/components/type/mod.rs @@ -267,9 +267,6 @@ impl TypeSystem for Type { p1.len() == p2.len() && p1.iter().zip(p2.iter()).all(|(t1, t2)| t1.is_subtype_raw(t2, context)) } (Type::RClass(set1, _), Type::RClass(set2, _)) => set1.is_subset(set2), - (Type::Operator(TypeOperator::Union, _t1, _t2, _), Type::Operator(TypeOperator::Union, _tp1, _tp2, _)) => { - true - } //TODO: Fix this // A union is a subtype of `T` when *every* member is. Without this // rule an `if`/`else` over literal-typed branches — which unions // their types — could never satisfy the base annotation: diff --git a/crates/typr-core/src/processes/parsing/elements.rs b/crates/typr-core/src/processes/parsing/elements.rs index 2e7b6e5..0b84f25 100644 --- a/crates/typr-core/src/processes/parsing/elements.rs +++ b/crates/typr-core/src/processes/parsing/elements.rs @@ -111,13 +111,28 @@ fn integer_impl(s: Span) -> IResult { } .to_string() + d.as_ref(); - Ok(( - s, - Lang::Integer { - value: symbol.parse::().unwrap(), - help_data: d.into(), - }, - )) + match symbol.parse::() { + Ok(value) => Ok(( + s, + Lang::Integer { + value, + help_data: d.into(), + }, + )), + // Outside the 32-bit range. `99999999999.5` must be left to + // `number`; a bare `99999999999` is a double in R, so it types + // as `num` rather than aborting the compiler. + Err(_) if s.starts_with('.') && s.chars().nth(1).is_some_and(|c| c.is_ascii_digit()) => { + Err(nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Digit))) + } + Err(_) => Ok(( + s, + Lang::Number { + value: symbol.parse::().unwrap_or(f64::INFINITY), + help_data: d.into(), + }, + )), + } } Err(r) => Err(r), } diff --git a/crates/typr-core/src/processes/parsing/indexation.rs b/crates/typr-core/src/processes/parsing/indexation.rs index 357b043..a94becd 100644 --- a/crates/typr-core/src/processes/parsing/indexation.rs +++ b/crates/typr-core/src/processes/parsing/indexation.rs @@ -39,7 +39,10 @@ fn parse_integer(s: &str) -> IResult<&str, i32> { let res = recognize(pair(opt(char('-')), digit1)).parse(s); match res { - Ok((s, int)) => Ok((s, int.parse::().unwrap())), + Ok((s, int)) => int + .parse::() + .map(|n| (s, n)) + .map_err(|_| nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Digit))), Err(r) => Err(r), } } @@ -47,7 +50,10 @@ fn parse_integer(s: &str) -> IResult<&str, i32> { /// Parse un nombre positif fn parse_positive_integer(s: &str) -> IResult<&str, i32> { match digit1.parse(s) { - Ok((s, int)) => Ok((s, int.parse::().unwrap())), + Ok((s, int)) => int + .parse::() + .map(|n| (s, n)) + .map_err(|_| nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Digit))), Err(r) => Err(r), } } diff --git a/crates/typr-core/src/processes/parsing/types.rs b/crates/typr-core/src/processes/parsing/types.rs index 10a0147..5433143 100644 --- a/crates/typr-core/src/processes/parsing/types.rs +++ b/crates/typr-core/src/processes/parsing/types.rs @@ -156,7 +156,11 @@ fn generic(s: Span) -> IResult { fn simple_index(s: Span) -> IResult { let res = terminated(digit1, multispace0).parse(s); match res { - Ok((s, fl)) => Ok((s, Type::Integer(fl.parse::().unwrap().into(), fl.into()))), + Ok((s, fl)) => match fl.parse::() { + Ok(value) => Ok((s, Type::Integer(value.into(), fl.into()))), + // A dimension beyond i32 is a syntax error, not a panic. + Err(_) => Err(nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Digit))), + }, Err(r) => Err(r), } } @@ -551,8 +555,12 @@ fn integer_literal(s: Span) -> IResult { let res = terminated(digit1, multispace0).parse(s); match res { Ok((s, span)) => { - let val: i32 = (*span).parse().unwrap_or(0); - Ok((s, Type::Integer(Tint::Val(val), span.into()))) + // Out of range is a parse failure: `unwrap_or(0)` used to turn + // `type Big <- 99999999999` into the singleton type `0`. + match (*span).parse::() { + Ok(val) => Ok((s, Type::Integer(Tint::Val(val), span.into()))), + Err(_) => Err(nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Digit))), + } } Err(r) => Err(r), } diff --git a/crates/typr-core/src/processes/type_checking/mod.rs b/crates/typr-core/src/processes/type_checking/mod.rs index fc3d38d..ab9b822 100644 --- a/crates/typr-core/src/processes/type_checking/mod.rs +++ b/crates/typr-core/src/processes/type_checking/mod.rs @@ -746,17 +746,29 @@ fn get_gen_type(type1: &Type, type2: &Type) -> Option> { } } (Type::Record(v1, _), Type::Record(v2, _)) => { - let res = v1 - .iter() - .zip(v2.iter()) - .flat_map(|(argt1, argt2)| { - let gen1 = get_gen_type(&argt1.get_argument(), &argt2.get_argument()).unwrap_or(vec![]); - let gen2 = get_gen_type(&argt1.get_type(), &argt2.get_type()).unwrap_or(vec![]); - gen1.iter().chain(gen2.iter()).cloned().collect::>() - }) - .collect::>() - .into_iter() - .collect::>(); + // Fields live in a `HashSet`: pair them by name, never by + // iteration order (a positional `zip` bound `T` to a random + // field's type, so the same program type-checked or not + // depending on the hasher's seed). + let mut res: HashSet<(Type, Type)> = HashSet::new(); + for argt2 in v2.iter() { + match v1.iter().find(|argt1| argt1.get_argument_str() == argt2.get_argument_str()) { + Some(argt1) => { + let gen1 = get_gen_type(&argt1.get_argument(), &argt2.get_argument()).unwrap_or(vec![]); + // A shared field whose types cannot be matched makes + // the whole record a mismatch — it used to be + // silently ignored, so any record was accepted for + // any other record parameter. + let gen2 = get_gen_type(&argt1.get_type(), &argt2.get_type())?; + res.extend(gen1); + res.extend(gen2); + } + // The parameter requires a field the argument lacks. + None if matches!(argt2.get_argument(), Type::Char(_, _)) => return None, + None => {} + } + } + let res = res.into_iter().collect::>(); Some(res) } (Type::Tag(name1, inner1, _), Type::Tag(name2, inner2, _)) if name1 == name2 => { @@ -792,6 +804,11 @@ fn get_gen_type(type1: &Type, type2: &Type) -> Option> { _ => None, } } + // A transparent alias can't be judged without its definition: against + // an empty context it reduces to `Any`, which every type is a subtype + // of, so any argument would match any alias parameter. Let the caller + // retry on the reduced types (`match_types_to_generic`). + (_, Type::Alias(_, _, false, _)) => None, (t1, t2) if t1.is_subtype(t2, &Context::empty()).0 => Some(vec![]), _ => None, } diff --git a/crates/typr-graph/src/build/blocks.rs b/crates/typr-graph/src/build/blocks.rs index fd3697b..9b56fb0 100644 --- a/crates/typr-graph/src/build/blocks.rs +++ b/crates/typr-graph/src/build/blocks.rs @@ -105,6 +105,7 @@ impl<'a> Builder<'a> { self.build_operand(value, key.role("value"), &key, "value", &mut children, &mut wires); let inputs = vec![Port::explicit("value", self.pretty_type(value))]; let out_ty = self.pretty_type(lang); + let name = name.or_else(|| Some("!".to_string())); self.finish_block( lang, key, @@ -169,6 +170,13 @@ impl<'a> Builder<'a> { self.finish_block(lang, key, BlockKind::RCode, name, Vec::new(), vec![Port::explicit("out", ty)], Origin::User, None) } + // A `# …` line: its own kind, and the node's name is the comment text. No value, so + // no ports. + Lang::Comment { value, .. } => { + let text = value.trim().to_string(); + self.finish_block(lang, key, BlockKind::Comment, Some(text), Vec::new(), Vec::new(), Origin::User, None) + } + // Everything else — genuinely unhandled, or not worth a dedicated shape (`Assign` // outside a body list, raw `Tag`/`Sequence`, …): falls back to a total, panic-free // Opaque. @@ -187,7 +195,7 @@ impl<'a> Builder<'a> { self.build_ufcs_apply(lhs, identifier, arguments, lang, key, name) } Lang::Variable { .. } => self.build_access(lhs, rhs, lang, key, name), - _ => self.build_binary_operator(lhs, rhs, lang, key, name), + _ => self.build_binary_operator(op, lhs, rhs, lang, key, name), }, Op::Pipe(_) => match rhs { Lang::FunctionApp { identifier, arguments, .. } | Lang::VecFunctionApp { identifier, arguments, .. } => { @@ -195,17 +203,19 @@ impl<'a> Builder<'a> { } other => self.build_ufcs_apply(lhs, other, &[], lang, key, name), }, - _ => self.build_binary_operator(lhs, rhs, lang, key, name), + _ => self.build_binary_operator(op, lhs, rhs, lang, key, name), } } - fn build_binary_operator(&mut self, lhs: &Lang, rhs: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_binary_operator(&mut self, op: &Op, lhs: &Lang, rhs: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { let mut children = Vec::new(); let mut wires = Vec::new(); self.build_operand(lhs, key.role("lhs"), &key, "lhs", &mut children, &mut wires); self.build_operand(rhs, key.role("rhs"), &key, "rhs", &mut children, &mut wires); let inputs = vec![Port::explicit("lhs", self.pretty_type(lhs)), Port::explicit("rhs", self.pretty_type(rhs))]; let out_ty = self.pretty_type(lang); + // An anonymous operator is titled by its symbol (`+`, `&&`…) rather than by its key. + let name = name.or_else(|| Some(op.to_string())); self.finish_block( lang, key, @@ -248,6 +258,9 @@ impl<'a> Builder<'a> { Lang::Variable { name: callee_name, .. } => { let arg0_ty = self.recorded_type(receiver); if let Some((target, confidence)) = self.resolve_definition(callee_name, arg0_ty.as_ref()) { + if self.callee_is_generic(callee_name, arg0_ty.as_ref()) && !target.as_str().starts_with("std:") { + self.graph.relations.push(Relation::instantiates(key.clone(), target.clone(), confidence.clone())); + } self.graph.relations.push(Relation::r#ref(key.clone(), "callee", target, confidence)); } } @@ -280,6 +293,9 @@ impl<'a> Builder<'a> { Lang::Variable { name: callee_name, .. } => { let arg0_ty = arguments.first().and_then(|a| self.recorded_type(a)); if let Some((target, confidence)) = self.resolve_definition(callee_name, arg0_ty.as_ref()) { + if self.callee_is_generic(callee_name, arg0_ty.as_ref()) && !target.as_str().starts_with("std:") { + self.graph.relations.push(Relation::instantiates(key.clone(), target.clone(), confidence.clone())); + } self.graph.relations.push(Relation::r#ref(key.clone(), "callee", target, confidence)); } } @@ -303,6 +319,13 @@ impl<'a> Builder<'a> { ) } + /// Whether `name` resolves (top-level bindings only — a local function isn't in the + /// `Context`) to a function type with type parameters. + fn callee_is_generic(&self, name: &str, arg0: Option<&Type>) -> bool { + matches!(super::refs::resolve_by_name(self.context, name, arg0), + Some((ty @ Type::Function(..), _)) if ty.has_generic()) + } + fn build_function(&mut self, parameters: &[ArgumentType], return_type: &Type, body: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { self.scope.push_boundary(key.clone()); let mut inputs = Vec::new(); @@ -498,6 +521,7 @@ impl<'a> Builder<'a> { outputs.push(Port::explicit(member_name.clone(), self.pretty_type(expr))); } self.build_expr(expr, member_key.clone(), Some(&member_name)); + self.note_declared_type(&member_key, item); children.push(member_key); } None => { @@ -702,7 +726,7 @@ fn body_statements_from_vec(body: &[Lang]) -> Vec<&Lang> { #[cfg(test)] mod tests { use crate::key::Namespace; - use crate::model::{BlockGraph, BlockKind}; + use crate::model::{BlockGraph, BlockKind, RelationKind}; use crate::BlockKey; use typr_core::components::context::Context; use typr_core::processes::parsing::parse_from_string; @@ -716,6 +740,25 @@ mod tests { crate::build(&lang, &result.type_context.context, &table) } + #[test] + fn calling_a_generic_function_emits_instantiates_but_a_concrete_one_does_not() { + let graph = build_graph( + r#" +let id <- fn(x: T): T { x }; +let inc <- fn(x: int): int { x + 1 }; +let a <- id(1); +let b <- inc(1); +"#, + ); + let id = BlockKey::top_level(Namespace::Val, "id"); + let inc = BlockKey::top_level(Namespace::Val, "inc"); + let inst = |to: &BlockKey| { + graph.relations.iter().any(|r| r.kind == RelationKind::Instantiates && r.to == *to) + }; + assert!(inst(&id), "{:#?}", graph.relations); + assert!(!inst(&inc)); + } + // `while`'s own condition parser (`single_element`) can't take a bare comparison — this is a // pre-existing `typr-core` parser quirk, unrelated to this crate, that double parens work // around; not this crate's bug to fix. @@ -816,4 +859,20 @@ module Reporter { assert!(block.inputs.is_empty()); assert!(block.body.is_none()); } + + #[test] + fn comment_is_its_own_block_named_after_its_text() { + let graph = build_graph("# hello world\nlet a <- 1;\n# second\nlet b <- a;\n"); + let comments: Vec<_> = graph.blocks.values().filter(|b| b.kind == BlockKind::Comment).collect(); + let names: Vec<_> = comments.iter().filter_map(|b| b.name.as_deref()).collect(); + assert!(names.contains(&"hello world"), "{names:?}"); + assert!(names.contains(&"second"), "{names:?}"); + assert!(comments.iter().all(|b| b.outputs.is_empty() && b.inputs.is_empty())); + assert!(comments.iter().all(|b| b.r#type.is_none()), "a comment has no type"); + assert!( + !graph.relations.iter().any(|r| r.kind == RelationKind::HasType && comments.iter().any(|b| b.key == r.from)), + "a comment must not be linked to `char`" + ); + assert!(!graph.blocks.values().any(|b| b.kind == BlockKind::Opaque && b.name.as_deref() == Some("hello world"))); + } } diff --git a/crates/typr-graph/src/build/mod.rs b/crates/typr-graph/src/build/mod.rs index 1ff3bb2..504d2fc 100644 --- a/crates/typr-graph/src/build/mod.rs +++ b/crates/typr-graph/src/build/mod.rs @@ -24,6 +24,7 @@ pub fn build(lang: &Lang, context: &Context, types: &TypeTable) -> BlockGraph { scope: ScopeStack::new(), pending_captures: HashMap::new(), reference_blocks: HashMap::new(), + type_notes: Vec::new(), }; b.scope.push_boundary(root_key.clone()); @@ -44,6 +45,8 @@ pub fn build(lang: &Lang, context: &Context, types: &TypeTable) -> BlockGraph { } type_relations::build(&mut b.graph, b.context, &items); + let primitives = type_relations::has_type_links(&mut b.graph, &b.type_notes); + children.extend(primitives); b.scope.pop(); let (inputs, refs) = b.take_captures(&root_key); @@ -118,6 +121,10 @@ struct Builder<'a> { /// Memoizes synthesized leaf blocks for names resolved outside the program (stdlib, R /// packages) so the same name doesn't get a fresh block every time it's referenced. reference_blocks: HashMap, + /// Type texts a block is tied to that its own `type` field doesn't show: the annotation of a + /// `let` (the recorded type is the value's, e.g. the literal `"hi"`, not the declared `char`) + /// and the primitive behind a literal. Scanned by `type_relations::has_type_links`. + type_notes: Vec<(BlockKey, String)>, } impl<'a> Builder<'a> { @@ -125,6 +132,15 @@ impl<'a> Builder<'a> { self.types.get(&SpanKey::from(&lang.get_help_data())).cloned() } + /// Remembers a `let`'s written annotation (`let m: char <- …`) so the block is tied to it. + fn note_declared_type(&mut self, key: &BlockKey, item: &Lang) { + if let Lang::Let { r#type, .. } = item { + if !matches!(r#type, Type::Empty(_)) { + self.type_notes.push((key.clone(), r#type.pretty())); + } + } + } + fn pretty_type(&self, lang: &Lang) -> Option { self.recorded_type(lang).map(|t| t.pretty()) } @@ -151,6 +167,7 @@ impl<'a> Builder<'a> { _ => item, }; self.build_expr(expr, key.clone(), Some(&name)); + self.note_declared_type(&key, item); children.push(key); } None => { @@ -172,6 +189,7 @@ impl<'a> Builder<'a> { Some(name) => { let key = owner.named(&name); let port = self.build_expr(expression, key.clone(), Some(&name)); + self.note_declared_type(&key, stmt); self.scope.bind(&name, port); children.push(key); } @@ -367,7 +385,20 @@ impl<'a> Builder<'a> { origin: Origin, body: Option, ) -> PortRef { - let ty = self.recorded_type(lang); + // A comment has no type: the parser models it as a `char` value, which must neither be + // printed on the block nor linked (`HasType`) to the `char` primitive. + let ty = if kind == BlockKind::Comment { None } else { self.recorded_type(lang) }; + // A literal's recorded type is the value (`"hi"`, `3`), which names no type block. + let primitive = match &ty { + Some(Type::Char(..)) => Some("char"), + Some(Type::Integer(..)) => Some("int"), + Some(Type::Number(..)) => Some("num"), + Some(Type::Boolean(..)) => Some("bool"), + _ => None, + }; + if let Some(p) = primitive { + self.type_notes.push((key.clone(), p.to_string())); + } if let Some(Type::Alias(alias_name, _, _, _)) = &ty { self.graph .relations diff --git a/crates/typr-graph/src/build/type_relations.rs b/crates/typr-graph/src/build/type_relations.rs index 036eeaf..f3d07ae 100644 --- a/crates/typr-graph/src/build/type_relations.rs +++ b/crates/typr-graph/src/build/type_relations.rs @@ -8,8 +8,8 @@ use super::blocks::{alias_name, discover_methods, safe_argument_name}; use super::variable_name; use crate::key::{BlockKey, Namespace}; -use crate::model::{BlockGraph, Evidence, Relation}; -use std::collections::HashSet; +use crate::model::{Block, BlockGraph, BlockKind, Evidence, Origin, Port, Relation, RelationKind}; +use std::collections::{BTreeSet, HashSet}; use typr_core::components::context::Context; use typr_core::components::language::Lang; use typr_core::components::r#type::argument_type::ArgumentType; @@ -46,6 +46,123 @@ pub(super) fn build(graph: &mut BlockGraph, context: &Context, items: &[&Lang]) } } +/// The primitive types, which have no `TypeDecl` but are types all the same: a block typed `char` +/// gets a synthesized `type:char` block to point at. +const PRIMITIVES: [&str; 4] = ["int", "num", "char", "bool"]; + +/// `HasType` for every block, whether its type was written or inferred, and however deep the +/// named type sits. `finish_block` only links a block whose recorded type *is* an alias +/// (`p: Point`), which misses an inferred `fn(v: Point) -> Point`, `[Point]`, `Option`, and +/// every primitive (`char` has no declaration to point at). Runs after every `TypeDecl`/`Interface` +/// block exists and scans each block's printed type — plus `notes`, the type texts the printed type +/// doesn't carry (a `let`'s annotation, the primitive behind a literal) — for type names. +/// Printed rather than walked: `Type::pretty` already renders every variant, so this stays correct +/// as variants are added. +/// +/// Returns the keys of the primitive blocks it synthesized (only the ones used), for the caller to +/// list among the program's children so they appear in the top-level view. +pub(super) fn has_type_links(graph: &mut BlockGraph, notes: &[(BlockKey, String)]) -> Vec { + let mut type_keys: Vec<(String, BlockKey)> = graph + .blocks + .values() + .filter(|b| matches!(b.kind, BlockKind::TypeDecl | BlockKind::Interface) && b.key.is_top_level()) + .filter_map(|b| Some((b.key.as_str().strip_prefix("type:")?.to_string(), b.key.clone()))) + .collect(); + // A user `type int <- …` would shadow the primitive; the declared block wins. + for p in PRIMITIVES { + if !type_keys.iter().any(|(n, _)| n == p) { + type_keys.push((p.to_string(), BlockKey::top_level(Namespace::Type, p))); + } + } + + let mut known: HashSet<(BlockKey, BlockKey)> = graph + .relations + .iter() + .filter(|r| r.kind == RelationKind::HasType) + .map(|r| (r.from.clone(), r.to.clone())) + .collect(); + + let mut texts: Vec<(BlockKey, String)> = Vec::new(); + for block in graph.blocks.values() { + // A declaration's own type is its definition, not something it "has". + if matches!(block.kind, BlockKind::TypeDecl | BlockKind::Interface) { + continue; + } + if let Some(ty) = &block.r#type { + texts.push((block.key.clone(), ty.clone())); + } + } + texts.extend(notes.iter().cloned()); + + let mut added = Vec::new(); + let mut used_primitives = BTreeSet::new(); + for (from, text) in &texts { + let words = type_identifiers(text); + for (name, key) in &type_keys { + if !words.contains(name.as_str()) || !known.insert((from.clone(), key.clone())) { + continue; + } + if !graph.blocks.contains_key(key) { + used_primitives.insert(name.clone()); + } + added.push(Relation::has_type(from.clone(), key.clone())); + } + } + graph.relations.extend(added); + + used_primitives + .into_iter() + .map(|name| { + let key = BlockKey::top_level(Namespace::Type, &name); + graph.insert(Block { + key: key.clone(), + kind: BlockKind::TypeExpr, + name: Some(name.clone()), + span: None, + r#type: None, + inputs: Vec::new(), + outputs: vec![Port::explicit("out", Some(name))], + origin: Origin::Std, + body: None, + }); + key + }) + .collect() +} + +/// Identifiers in a printed type that name a type: outside string literals, and not a field or +/// parameter name (`x` in `list{x: int}`, `v` in `fn(v: Point)`) — those are followed by `:`. +fn type_identifiers(ty: &str) -> BTreeSet<&str> { + let mut words = BTreeSet::new(); + let bytes = ty.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if c == b'"' { + i += 1; + while i < bytes.len() && bytes[i] != b'"' { + i += 1; + } + i += 1; + } else if c.is_ascii_alphabetic() || c == b'_' { + let start = i; + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' || bytes[i] == b'.') { + i += 1; + } + let mut j = i; + while j < bytes.len() && bytes[j] == b' ' { + j += 1; + } + if bytes.get(j) != Some(&b':') { + words.insert(&ty[start..i]); + } + } else { + i += 1; + } + } + words +} + fn collect_type_aliases(context: &Context, items: &[&Lang]) -> Vec { items .iter() @@ -134,6 +251,40 @@ mod tests { crate::build(&lang, &result.type_context.context, &table) } + #[test] + fn has_type_reaches_a_named_type_nested_in_an_inferred_function_type() { + let graph = build_graph( + r#" +type Point <- list { x: int, y: int }; +let m <- fn(v: Point): Point { v }; +let n <- list(name = "Point"); +"#, + ); + let point = BlockKey::top_level(Namespace::Type, "Point"); + let has_type = |from: &str| { + graph.relations.iter().any(|r| { + r.kind == RelationKind::HasType && r.from == BlockKey::top_level(Namespace::Val, from) && r.to == point + }) + }; + assert!(has_type("m"), "{:#?}", graph.relations); + assert!(!has_type("n"), "a string literal isn't a type reference"); + } + + #[test] + fn a_primitive_type_gets_a_block_and_a_let_annotation_is_linked() { + let graph = build_graph("let message: char <- \"Hello, TypR!\";\n\nmessage\n"); + let char_key = BlockKey::top_level(Namespace::Type, "char"); + assert!(graph.blocks.contains_key(&char_key), "{:#?}", graph.blocks.keys()); + assert!(graph.blocks[&graph.root].body.as_ref().unwrap().children.contains(&char_key)); + let linked = |from: &str| { + graph.relations.iter().any(|r| { + r.kind == RelationKind::HasType && r.from == BlockKey::from_raw(from) && r.to == char_key + }) + }; + assert!(linked("val:message"), "{:#?}", graph.relations); + assert!(linked("val:#1")); + } + #[test] fn satisfies_links_a_type_to_the_interface_its_method_satisfies() { let graph = build_graph( diff --git a/crates/typr-graph/src/model.rs b/crates/typr-graph/src/model.rs index b9a635f..9f0e187 100644 --- a/crates/typr-graph/src/model.rs +++ b/crates/typr-graph/src/model.rs @@ -38,6 +38,8 @@ pub enum BlockKind { Match, Module, RCode, + /// A `# …` line kept as its own node; the node's `name` is the comment text. + Comment, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -242,6 +244,21 @@ impl Relation { evidence: None, } } + + /// `Apply` → generic `Function` (spec §5.1): the call site instantiates the generic + /// definition it resolves to. Complements the `Ref` on the `callee` port, which says *what* + /// is called; this says the call binds the callee's type parameters. + pub fn instantiates(from: BlockKey, to: BlockKey, confidence: Confidence) -> Self { + Relation { + kind: RelationKind::Instantiates, + from, + to, + port: Some("callee".to_string()), + index: None, + confidence: Some(confidence), + evidence: None, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap b/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap index a2fdb38..d01e298 100644 --- a/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap +++ b/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap @@ -116,6 +116,36 @@ expression: json ], "origin": "User" }, + "type:char": { + "key": "type:char", + "kind": "TypeExpr", + "name": "char", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "char", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "Std" + }, + "type:int": { + "key": "type:int", + "kind": "TypeExpr", + "name": "int", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "Std" + }, "val:@program": { "key": "val:@program", "kind": "Program", @@ -131,7 +161,9 @@ expression: json "val:show", "val:p", "val:d", - "val:total" + "val:total", + "type:char", + "type:int" ], "wires": [] } @@ -227,6 +259,7 @@ expression: json "val:norm2/#1": { "key": "val:norm2/#1", "kind": "Operator", + "name": "+", "span": { "file": "fil_rouge", "start": 193, @@ -672,6 +705,7 @@ expression: json "val:sq/#0": { "key": "val:sq/#0", "kind": "Operator", + "name": "*", "span": { "file": "fil_rouge", "start": 124, @@ -792,6 +826,7 @@ expression: json "val:total/lhs": { "key": "val:total/lhs", "kind": "Operator", + "name": "+", "span": { "file": "fil_rouge", "start": 323, @@ -1016,6 +1051,121 @@ expression: json "provided_by": "val:show" } ] + }, + { + "kind": "HasType", + "from": "type:Point/x", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "type:Point/y", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "type:Printable/show", + "to": "type:char" + }, + { + "kind": "HasType", + "from": "val:norm2", + "to": "type:Point" + }, + { + "kind": "HasType", + "from": "val:norm2", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:norm2/#1", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:norm2/#1/rhs", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:norm2/#1/rhs/arg0", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:norm2/a", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:norm2/a/arg0", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:show", + "to": "type:Point" + }, + { + "kind": "HasType", + "from": "val:show", + "to": "type:char" + }, + { + "kind": "HasType", + "from": "val:sq", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:sq/#0", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:total", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:total/lhs", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:total/lhs/lhs", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "type:Printable", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:show/#0", + "to": "type:char" + }, + { + "kind": "HasType", + "from": "val:p/x", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:p/y", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:total/lhs/rhs", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:total/rhs", + "to": "type:int" } ] } diff --git a/refined_types.md b/refined_types.md new file mode 100644 index 0000000..6c19c04 --- /dev/null +++ b/refined_types.md @@ -0,0 +1,1144 @@ +# Types raffinés — Spécification d’implémentation + +## 1. Statut + +Cette spécification définit l'implémentation des **types raffinés** dans TypR. Elle a pour but de remplacer les types dépendants dans le future. + +L'objectif est de permettre l'expression de contraintes supplémentaires sur des types existants sans créer un nouveau type structurel pour chaque combinaison de contraintes. + +Exemples : + +```typr +int +int & (> 0) + +[int] +[int] & length(5) +``` + +Le système doit également permettre, lorsque cela est possible, de transformer automatiquement les raffinements en **validations runtime dans le code R généré**. + +--- + +# 2. Modèle conceptuel + +Un type raffiné est une **intersection de type** entre un type de base et une ou plusieurs propriétés. + +```text +RefinedType(A, P₁, ..., Pₙ) +``` + +est représenté conceptuellement par : + +```text +A & P₁ & ... & Pₙ +``` + +où : + +* `A` est un type TypR ; +* `Pᵢ` est une propriété vérifiable ; +* `&` est l'opérateur général d'intersection de types. + +Exemple : + +```typr +[int] & length(5) +``` + +signifie : + +> une valeur de type `[int]` qui satisfait également la propriété `length(5)`. + +Le mécanisme de raffinement est donc **générique** et ne doit pas être implémenté comme une famille de types spéciaux (`VectorOfLength`, `PositiveInt`, etc.). + +--- + +# 3. Syntaxe + +## 3.1 Intersection + +La syntaxe générale est : + +```text +TypeExpr & TypeExpr +``` + +`&` est associatif et commutatif au niveau sémantique. + +Ainsi : + +```typr +A & B +B & A +``` + +désignent le même ensemble de valeurs. + +Une expression peut contenir plusieurs intersections : + +```typr +A & B & C +``` + +--- + +## 3.2 Raffinements + +Un raffinement est une expression reconnue comme une propriété applicable à un type. + +Exemples initiaux : + +```typr +int & (> 0) +int & (< 10) + +[int] & length(5) +``` + +La grammaire exacte des propriétés n'est pas imposée par cette spécification ; celles-ci sont définies par le registre interne des raffinements du compilateur. + +--- + +# 4. Sucre syntaxique des vecteurs + +La notation : + +```typr +[5, int] +``` + +doit être acceptée comme sucre syntaxique pour : + +```typr +[int] & length(5) +``` + +Le parser ou une phase de désucrage doit normaliser les deux formes vers la même représentation interne. + +Le type : + +```typr +[5, int] +``` + +ne doit donc pas créer un nouveau constructeur de type distinct. + +La forme canonique interne est : + +```text +Intersection( + Vector(Int), + Length(5) +) +``` + +--- + +# 5. Représentation de type + +L'AST/type representation doit représenter explicitement les intersections. + +Une représentation minimale est : + +```rust +enum Type { + ... + Intersection(Vec), + ... +} +``` + +Cependant, une représentation normalisée séparant type de base et propriétés est recommandée : + +```rust +struct RefinedType { + base: Type, + refinements: Vec, +} +``` + +avec : + +```rust +enum Refinement { + ... +} +``` + +Cette représentation est une optimisation sémantique et ne doit pas changer le comportement du langage. + +Le compilateur doit être capable de retrouver l'équivalent général : + +```text +A & P & Q +``` + +à partir de cette structure. + +--- + +# 6. Normalisation + +Toute intersection doit être normalisée avant les principales opérations du type checker. + +La normalisation doit : + +1. aplatir les intersections imbriquées ; +2. supprimer les doublons ; +3. rendre l'ordre des composants insignifiant ; +4. séparer les contraintes du type de base lorsque cela est possible ; +5. détecter les contradictions évidentes. + +Exemple : + +```typr +int & (> 0) & int +``` + +devient : + +```typr +int & (> 0) +``` + +Et : + +```typr +A & (B & C) +``` + +devient : + +```typr +A & B & C +``` + +--- + +# 7. Types et propriétés + +Une propriété ne constitue pas nécessairement un type indépendant utilisable partout. + +Elle représente une contrainte applicable à un type compatible. + +Exemple : + +```typr +[int] & length(5) +``` + +est valide car `[int]` possède la capacité nécessaire à l'évaluation de `length`. + +En revanche, une expression telle que : + +```typr +int & length(5) +``` + +doit être rejetée si `int` ne possède pas cette capacité. + +Le type checker doit donc vérifier la compatibilité : + +```text +Base type + ↓ +supports refinement? + ↓ +yes / no +``` + +--- + +# 8. Capacités internes + +Les propriétés doivent être associées à des **capacités internes** du compilateur. + +Exemples : + +```text +Lengthable +Comparable +Ordered +``` + +Ces capacités sont des concepts d'implémentation et ne constituent pas, dans cette première version, un mécanisme extensible par les utilisateurs. + +Exemples : + +```text +length(5) → nécessite Lengthable +(> 0) → nécessite Comparable / Ordered +(< 10) → nécessite Comparable / Ordered +``` + +Le système doit permettre au type checker de déterminer qu'un type possède la capacité requise. + +--- + +# 9. Sous-typage + +Un type raffiné est un sous-type du type qu'il raffine. + +```text +A & P <: A +``` + +Exemples : + +```text +int & (> 0) <: int + +[int] & length(5) <: [int] +``` + +De même : + +```text +A & P & Q <: A & P +``` + +Cette règle est fondamentale. + +Une fonction demandant : + +```typr +let f(x: [int]) = ... +``` + +doit accepter une valeur de type : + +```typr +[int] & length(5) +``` + +sans conversion. + +--- + +# 10. Relations entre raffinements + +Le type checker doit exposer au minimum trois opérations conceptuelles sur les raffinements : + +```text +compatible(P, Q) +implies(P, Q) +contradicts(P, Q) +``` + +Exemples : + +```text +length(5) compatible length(5) → true +length(5) contradicts length(10) → true +(> 10) contradicts (< 5) → true +(> 5) implies (> 0) → true +``` + +L'analyse exacte peut être limitée dans une première version. + +Lorsqu'une relation ne peut pas être déterminée statiquement, le compilateur doit conserver la contrainte plutôt que supposer qu'elle est vraie ou fausse. + +--- + +# 11. Contradictions + +Le type checker doit détecter les contradictions manifestes. + +Exemples : + +```typr +int & (> 10) & (< 5) +``` + +```typr +[int] & length(5) & length(10) +``` + +Ces types doivent être considérés comme inhabités. + +Le compilateur peut produire une erreur au moment de leur construction ou de leur utilisation. + +Une analyse complète des domaines numériques n'est pas requise dans la première implémentation. + +--- + +# 12. Raffinements sur les paramètres + +Les raffinements sont autorisés dans les types de paramètres. + +```typr +let f(x: int & (> 0)) = ... +``` + +Le corps de `f` peut considérer `x` comme satisfaisant le raffinement. + +Le compilateur ne doit pas générer une vérification supplémentaire à chaque utilisation de `x`. + +La vérification éventuelle est associée à l'entrée dans le contexte où le contrat est requis. + +--- + +# 13. Raffinements sur les valeurs de retour + +Les raffinements sont également autorisés sur les types de retour. + +```typr +let f(): [int] & length(5) = ... +``` + +Le compilateur doit vérifier que la valeur produite par le corps satisfait le type de retour. + +Lorsque cette propriété ne peut pas être démontrée statiquement, une validation runtime doit être générée. + +--- + +# 14. Inférence + +Le type checker doit conserver les raffinements connus statiquement. + +Exemple : + +```typr +let x: [int] & length(5) = ... +``` + +`x` possède ce type dans l'environnement de typage. + +La version initiale doit également permettre l'inférence de raffinements structurels lorsqu'ils sont déterminables sans analyse complexe. + +Exemple : + +```typr +let x = [1, 2, 3, 4, 5] +``` + +peut être typé : + +```text +[int] & length(5) +``` + +Cette capacité doit être conçue comme une optimisation de l'information disponible et ne doit pas être nécessaire à la correction du système. + +--- + +# 15. Préservation des raffinements + +Les opérations doivent déclarer leur effet sur les raffinements. + +Une opération peut : + +```text +preserve +produce +weaken +invalidate +``` + +une propriété. + +Exemple : + +```typr +let x: [int] & length(5) +let y = x +``` + +Le type de `y` conserve : + +```text +[int] & length(5) +``` + +À l'inverse : + +```typr +let z = x[1] +``` + +produit : + +```text +int +``` + +et ne conserve pas `length(5)`. + +--- + +# 16. Opérations produisant de nouveaux raffinements + +Lorsqu'une opération permet de déduire statiquement une nouvelle propriété, le type checker doit pouvoir l'ajouter au type résultat. + +Exemple : + +```typr +let x: [int] & length(5) +let y = x[1:3] +``` + +peut être typé : + +```text +[int] & length(3) +``` + +si la sémantique de l'opération garantit cette longueur. + +Cette fonctionnalité doit être implémentée de façon déclarative autant que possible. + +--- + +# 17. Invalidations + +Une opération dont le résultat ne permet plus de garantir un raffinement doit supprimer celui-ci du type statique. + +Exemple conceptuel : + +```text +x : [int] & length(5) + +operation(x) + +result : [int] +``` + +si `operation` ne garantit aucune longueur. + +Il est interdit au type checker de conserver automatiquement un raffinement qu'une opération peut rendre faux. + +--- + +# 18. Vérification statique + +Lorsqu'un raffinement peut être prouvé au compile-time, aucune validation runtime correspondante ne doit être générée. + +Exemple : + +```typr +let x: [int] & length(5) = [1,2,3,4,5] +``` + +Le compilateur peut déduire : + +```text +length(x) = 5 +``` + +Aucun test supplémentaire n'est requis dans le code R généré. + +--- + +# 19. Vérification runtime + +Lorsqu'un raffinement est requis mais ne peut pas être prouvé statiquement, le compilateur doit générer une validation runtime. + +Exemple : + +```typr +let f(x: [int] & length(5)) = ... +``` + +Si `x` provient d'une source dynamique, le code généré doit effectuer une vérification équivalente à : + +```r +if (length(x) != 5) { + stop(...) +} +``` + +La forme exacte du code R généré reste une décision de codegen. + +--- + +# 20. Principe "check at boundary" + +Une propriété doit être vérifiée au point où elle devient nécessaire, plutôt qu'à chaque utilisation. + +Exemple : + +```text +source dynamique + ↓ +validation length(5) + ↓ +x : [int] & length(5) + ↓ +plusieurs utilisations sans nouvelle validation +``` + +Une fois la vérification effectuée dans un contexte donné, le type checker peut considérer la propriété comme établie dans ce contexte. + +Le compilateur doit éviter les validations redondantes lorsqu'il dispose déjà d'une preuve ou d'une vérification antérieure encore valide. + +--- + +# 21. Génération des validations + +Chaque `Refinement` doit fournir au codegen les informations nécessaires pour générer sa validation. + +Conceptuellement : + +```rust +trait RefinementCodegen { + fn check( + &self, + value: ValueRef, + context: &CodegenContext, + ) -> Expr; +} +``` + +Exemple : + +```text +Length(5) + ↓ +length(value) == 5 +``` + +et : + +```text +GreaterThan(0) + ↓ +value > 0 +``` + +Le code généré doit être idiomatique R autant que possible. + +--- + +# 22. Origine des validations + +Une validation runtime ne doit être générée que pour une contrainte effectivement imposée par le programme. + +Le compilateur ne doit pas instrumenter arbitrairement toutes les valeurs avec toutes les propriétés connues. + +Exemple : + +```typr +let x: [int] = ... +``` + +ne doit pas déclencher de vérification de longueur. + +En revanche : + +```typr +let y: [int] & length(5) = x +``` + +doit déclencher une validation si aucune preuve statique ne permet de garantir `length(x) == 5`. + +--- + +# 23. Raffinements et coercions + +Une valeur ne doit pas automatiquement être considérée comme raffinée uniquement parce qu'elle possède le type de base. + +Ainsi : + +```text +[int] +``` + +n'implique pas : + +```text +[int] & length(5) +``` + +Une conversion vers un type raffiné doit donc constituer un **site de preuve ou de validation**. + +Conceptuellement : + +```text +[int] + ↓ +check + ↓ +[int] & length(5) +``` + +--- + +# 24. Narrowing / raffinement par condition + +La conception doit permettre ultérieurement le raffinement d'un type à la suite d'une condition. + +Exemple : + +```typr +if length(x) == 5 { + ... +} +``` + +À l'intérieur du bloc, le compilateur pourrait établir : + +```text +x : [int] & length(5) +``` + +Cette fonctionnalité n'est pas obligatoire pour la première version, mais la représentation des raffinements doit être conçue pour la supporter. + +--- + +# 25. Génériques + +Les raffinements doivent être compatibles avec les types génériques. + +Exemple cible : + +```typr +let first(x: [T] & length(> 0)): T = ... +``` + +La validation de ce type nécessite que le système puisse déterminer les capacités dont dépend le raffinement. + +La version initiale peut limiter les raffinements génériques à ceux dont les capacités sont connues statiquement. + +--- + +# 26. Types composites + +Les raffinements ne doivent pas être limités aux vecteurs. + +Le mécanisme doit être applicable à tout type pour lequel la propriété possède une sémantique. + +Exemples futurs : + +```typr +string & length(10) + +int & (> 0) + +Record & some_property(...) +``` + +Les raffinements spécifiques à un domaine ne doivent donc pas être codés directement dans le système général des vecteurs. + +--- + +# 27. Interaction avec les fonctions R + +Pour chaque fonction ou opérateur connu du compilateur, les informations de typage doivent pouvoir préciser : + +```text +arguments consommés +type résultat +raffinements produits +raffinements préservés +raffinements invalidés +``` + +Cela est particulièrement important pour les opérations vectorielles et le mécanisme de lifting de TypR. + +Exemple conceptuel : + +```text +length + [T] → int + +slice + [T] & length(N), range(a,b) + → [T] & length(...) +``` + +Les fonctions externes dont les effets ne sont pas connus doivent être traitées de manière conservatrice. + +--- + +# 28. Interaction avec `c()` + +`c()` et les autres opérations de combinaison de vecteurs doivent être considérées comme potentiellement génératrices de nouveaux raffinements. + +Par exemple : + +```typr +let x = c([1,2], [3,4,5]) +``` + +peut éventuellement être inféré comme : + +```text +[int] & length(5) +``` + +si les longueurs sont connues. + +La conservation des raffinements doit cependant être dépendante de la sémantique réelle de l'opération et non d'une règle générale supposée. + +--- + +# 29. Diagnostic statique + +Les erreurs de typage doivent afficher les raffinements sous une forme lisible. + +Exemple : + +```text +Type mismatch + +Expected: + [int] & length(5) + +Found: + [int] & length(3) +``` + +Pour une contradiction : + +```text +Unsatisfiable type refinement: + +[int] & length(5) & length(10) +``` + +Pour une propriété incompatible : + +```text +Invalid refinement: + +length(5) cannot be applied to int +``` + +--- + +# 30. Diagnostic runtime + +Les validations générées doivent produire des messages permettant d'identifier : + +1. la propriété attendue ; +2. la valeur observée lorsque cela est pertinent ; +3. le point du programme où le contrat a été violé. + +Exemple : + +```text +Type refinement violation: +expected vector length 5, got 3 +``` + +Le format exact du message peut être défini indépendamment du type checker. + +--- + +# 31. Erreurs de vérification + +Lorsqu'une propriété est impossible à établir statiquement, le compilateur ne doit jamais transformer silencieusement l'incertitude en vérité. + +Il existe trois états conceptuels : + +```text +Proven true +Proven false +Unknown +``` + +`Unknown` déclenche une validation runtime lorsqu'une garantie est nécessaire. + +`False` produit une erreur statique lorsque la contradiction est certaine. + +--- + +# 32. Phases d'implémentation + +L'implémentation dans `typr-core` doit suivre au minimum les étapes suivantes : + +```text +Parser + ↓ +AST + ↓ +Desugaring + ↓ +Type normalization + ↓ +Refinement validation + ↓ +Subtype checking + ↓ +Refinement propagation + ↓ +Static proof / constraint checking + ↓ +Runtime-check insertion + ↓ +R code generation +``` + +Le codegen ne doit pas avoir à comprendre la logique complète des raffinements : les décisions de typage et les points de validation doivent être déterminés auparavant. + +--- + +# 33. Invariants du type checker + +Le type checker doit garantir les invariants suivants. + +### Invariant 1 + +Une intersection est indépendante de l'ordre de ses composants. + +```text +A & B = B & A +``` + +### Invariant 2 + +Une intersection redondante est normalisée. + +```text +A & A = A +``` + +### Invariant 3 + +Un raffinement est toujours associé à un type compatible. + +### Invariant 4 + +Un type raffiné est toujours un sous-type de son type de base. + +### Invariant 5 + +Une propriété statiquement prouvée ne nécessite pas de validation runtime. + +### Invariant 6 + +Une propriété inconnue requise au runtime doit provoquer une validation. + +### Invariant 7 + +Une propriété invalidée par une opération ne peut pas être conservée dans le type résultant. + +--- + +# 34. Tests obligatoires + +L'implémentation doit inclure des tests pour au minimum : + +```text +int & (> 0) + +[int] & length(5) + +[5, int] + +A & B == B & A + +A & A == A + +(A & B) & C == A & B & C + +[int] & length(5) <: [int] + +[int] & length(5) & length(10) → contradiction + +[int] → [int] & length(5) → runtime check + +statically provable refinement → no runtime check + +refinement invalidation + +refinement propagation + +parameter refinement + +return refinement +``` + +Des tests de codegen doivent vérifier à la fois : + +```text +correctness of generated R +absence of unnecessary checks +presence of required checks +``` + +--- + +# 35. Première implémentation recommandée + +La première version du système doit rester volontairement limitée. + +Elle doit fournir : + +```text +& +intersection de types + +length(N) +longueur exacte + +> +< +comparaisons numériques simples + +normalisation +déduplication +contradictions évidentes + +subtyping +A & P <: A + +runtime validation +génération automatique des checks R +``` + +La première version ne doit pas chercher à construire un solveur SMT ou un système de preuve général. + +--- + +# 36. Extensions futures + +Le système doit être conçu pour pouvoir accueillir ultérieurement : + +```text +length(> 0) +length(< 100) + +>= +<= + +membership +x ∈ Set + +regex / string constraints + +numeric ranges + +nullability constraints + +data-frame shape constraints + +column constraints + +refinements dépendant de champs + +flow-sensitive refinement + +generic dependent refinements +``` + +Ces extensions ne doivent pas nécessiter de modifier le principe fondamental : + +```text +base type & property +``` + +--- + +# 37. Exemple complet + +Code TypR : + +```typr +type Coordinates = [float] & length(2) + +let distance( + a: Coordinates, + b: Coordinates +): float = ... +``` + +Une valeur déjà connue : + +```typr +let a: Coordinates = [1.0, 2.0] +``` + +peut être prouvée statiquement. + +Une valeur dynamique : + +```typr +let a: [float] = read_coordinates() +let d = distance(a, b) +``` + +nécessite une validation avant l'appel : + +```text +a : [float] + +distance expects: + [float] & length(2) + +therefore: + insert runtime check +``` + +Le R généré pourra conceptuellement contenir : + +```r +if (length(a) != 2) { + stop("Type refinement violation: expected length 2") +} + +distance(a, b) +``` + +Le corps de `distance` peut ensuite manipuler `a` en supposant : + +```text +a : [float] & length(2) +``` + +sans réintroduire la vérification à chaque accès. + +--- + +# 38. Principe architectural final + +Les types raffinés de TypR doivent être implémentés comme une **extension du système d'intersection existant**, et non comme un système de types parallèle. + +Le cœur du modèle est : + +```text +Type + = +Base Type + & +Properties +``` + +Par conséquent : + +```typr +[int] +``` + +reste un type ordinaire, + +```typr +[int] & length(5) +``` + +est une intersection, + +et : + +```typr +[5, int] +``` + +est uniquement une notation abrégée. + +Le compilateur a ensuite la responsabilité de déterminer, pour chaque propriété : + +```text +Est-elle compatible avec le type ? + ↓ +Est-elle déjà prouvée ? + ↓ +Peut-elle être déduite ? + ↓ +A-t-elle été invalidée ? + ↓ +Faut-il générer une validation R ? +``` + +Cette architecture permet à TypR d'utiliser progressivement les raffinements comme **contrats statiques et runtime**, tout en conservant un système de types simple, composable et compatible avec la nature incrémentale de TypR. diff --git a/refined_types_plan.md b/refined_types_plan.md new file mode 100644 index 0000000..4f976ae --- /dev/null +++ b/refined_types_plan.md @@ -0,0 +1,251 @@ +# Types raffinés — plan d'implémentation + +Plan pour `refined_types.md`, écrit après lecture de `typr-core` (état au 2026-09-29, branche `develop`). + +--- + +## 0. Ce qui existe déjà (et ce qui change la donne) + +L'analyse du code montre que plusieurs pièces de la spec existent déjà, sous une autre forme. + +| Élément de la spec | État actuel dans le code | +|---|---| +| `&` dans les types | Parsé (`parsing/types.rs:1062`) en `Type::Operator(TypeOperator::Intersection, ..)`, mais **limité aux Records/Interfaces** (`type_arithmetic.rs::accepts_record_kind`, `norm_intersection`). `int & int` échoue. | +| Aplatissement, commutativité, déduplication | `IntersectionType` (`components/type/intersection_type.rs`) aplatit l'arbre binaire dans un `HashSet`. L'égalité `A & B == B & A` est déjà en place (`type/mod.rs:1229`). | +| `length(N)` sur les vecteurs | **La longueur est déjà dans le type** : `Type::Vec(VecType, index, elem, h)`. `[5, int]` donne `Vec(S3, Integer(5), int)` et `[int]` donne `Vec(S3, Any, int)`. | +| `[5,int] <: [int]` | Fonctionne déjà (`Integer(5) <: Any`, `type/mod.rs:221`). | +| Contradiction `length(5)` / `length(3)` | Déjà une erreur statique : `let x: [5, int] <- [1,2,3]` est rejeté. | +| Inférence `[1,2,3,4,5] : [5, int]` (§14) | Déjà en place. | +| `c()` produit une longueur (§28) | Déjà en place : `c([1,2],[3,4,5])` a le type `Vec[5, int]`, `c(x, x)` a le type `Vec[10, int]`. | +| Préservation par opération vectorisée | `x + 1` garde `[5, int]` (lifting, `Type::lift`). | +| Validations runtime | Seulement en mode test `--checked` (`transpiling/checked_assertions.rs`, `typr_assert_type`). Elles contrôlent la classe, pas la longueur. | + +Les sondes faites contre le binaire actuel ont aussi révélé des défauts à corriger en chemin : + +- **`int & (> 0)` fait paniquer le parseur** (`parsing/types.rs:974`, `compute_operators` → `_ => panic!()`), parce que `>` est lu comme un `Op` dans `index_algebra`. +- `[int] & length(5)` est parsé en `[any, int] & length` : `length` devient un `Type::Variable` et le `(5)` est perdu. +- `x[1:3]` et `x[x > 2]` sont typés **`int`** alors que ce sont des vecteurs. C'est un bug existant, et c'est précisément la propagation dont parlent les §15-16. +- `[int]` passé là où `[3, int]` est attendu est **une erreur statique** aujourd'hui. La spec (§22-23) en fait un site de validation runtime : **c'est un changement de comportement**, voir D3. + +--- + +## 1. Décisions de conception + +### D1. Représentation : un variant `Refined` plus un variant transitoire `Property` + +```rust +// components/type/mod.rs — à ajouter À LA FIN de l'enum (voir §Risques : sérialisation .bin) +Refined(Box, RefinementSet, HelpData), // base & propriétés, forme normalisée +Property(Refinement, HelpData), // sortie du parseur uniquement ; ne doit jamais survivre à reduce_type +``` + +C'est la forme « `RefinedType { base, refinements }` » que recommande la spec au §5. On n'étend pas `Operator(Intersection)` à la place, pour deux raisons : + +- dans le code actuel, `&` sur des Records veut dire *fusion structurelle*, pas prédicat ; +- un arbre binaire ne donne ni l'ordre canonique ni la détection des contradictions sans renormaliser à chaque fois. + +Le parseur continue de produire `Operator(Intersection, base, Property(p))`. C'est `norm_intersection` qui les replie en `Refined`. + +### D2. `RefinementSet` : raisonner par *mesures* et *intervalles* + +Plutôt qu'une liste de propriétés à comparer deux à deux, on normalise chaque propriété sur une **mesure** munie d'un **intervalle** : + +```rust +// components/type/refinement.rs (nouveau) +pub enum Measure { Value, Length } // extensible : Nchar, Nrow, Ncol... +pub struct Interval { lo: Bound, hi: Bound } // Bound = Open(c) | Closed(c) | Unbounded +pub struct RefinementSet(BTreeMap); // ordonné → Hash/Eq déterministes + +pub enum Refinement { // forme de surface (parse / affichage) + Length(i32), // length(5) → Length ∈ [5,5] + Gt(Lit), Lt(Lit), // (> 0) → Value ∈ (0, +∞) +} +``` + +Avec cette forme, les exigences de la spec tombent d'elles-mêmes : + +| Exigence | Traduction | +|---|---| +| dédup, commutativité, associativité (§6, invariants 1-2) | intersection d'intervalles par mesure | +| `contradicts(P,Q)` (§10-11) | intervalle vide (`(>10)&(<5)`, `length(5)&length(10)`) | +| `implies(P,Q)` | inclusion d'intervalles (`(>5)` ⊂ `(>0)`) | +| `compatible(P,Q)` | intersection non vide | +| extensions §36 (`length(> 0)`, `>=`, plages) | autres bornes sur le même modèle, sans changer le principe | +| narrowing §24 | on intersecte l'intervalle de la condition | + +Bonus : sur `int`, on peut repérer `int & (> 0) & (< 1)` comme vide, en arrondissant les bornes aux entiers. + +Il faut une représentation hachable des constantes numériques (`Type: Hash + Eq`). On réutilise celle de `Tnum` plutôt qu'un `f64` brut. + +### D3. Trois états, et `is_subtype` reste strict + +`is_subtype` est un booléen mis en cache (`SUBTYPE_CACHE`) et utilisé partout : dispatch, unification, sélection de surcharge. **Il ne doit répondre `true` que pour `Proven`.** L'état `Unknown` passe par une fonction séparée, appelée uniquement aux frontières : + +```rust +// processes/type_checking/refinement_check.rs (nouveau) +pub enum Proof { Proven, Refuted, Unknown } +pub enum Coercion { Static, Runtime(RefinementSet /* résidu non prouvé */), Reject } +pub fn coerce_to(found: &Type, expected: &Type, ctx: &Context) -> Coercion +``` + +La règle : `coerce_to` renvoie `Runtime(résidu)` si `base(found) <: base(expected)` et que le résidu est `Unknown`. Il renvoie `Reject` si le résidu est `Refuted`. + +Si on laissait `is_subtype` devenir tolérant, un `[int]` sélectionnerait une surcharge `[5, int]`, et le code deviendrait insoundable sans que rien ne le signale. + +### D4. La longueur des vecteurs reste dans l'index de `Type::Vec`, pour l'instant + +La spec (§4) veut la forme canonique `Intersection(Vector(Int), Length(5))`. On fait **l'inverse en v1** : `[int] & length(5)` se normalise en `Vec(S3, Integer(5), int)`. Une vue `refinements_of(&Type) -> RefinementSet` relit la longueur depuis l'index. + +Pourquoi : +- `Type::Vec(` apparaît dans **135 motifs** répartis sur 20 fichiers ; +- le lifting vectoriel (`Type::lift`) et les génériques d'index `#N` (`[#N, T]`, arithmétique `#N+1`) dépendent tous de ce slot ; +- une migration en une fois casserait les 868 tests pour un gain nul en v1. + +Le §5 l'autorise explicitement (« optimisation sémantique, ne doit pas changer le comportement »), à condition de retrouver `A & P` à partir de la structure : c'est le rôle de `refinements_of`. + +Les intervalles de longueur non ponctuels (`length(> 0)`, futur) iraient dans le `RefinementSet` d'un `Refined(Vec(S3, Any, T), …)`. La migration complète vers `Length` est la phase 9, optionnelle. + +### D5. Les obligations runtime sont décidées au typage, pas au codegen (§32) + +Quand `coerce_to` renvoie `Runtime(résidu)`, le type checker enregistre une **obligation** dans le `Context` : une table latérale indexée par la position (`HelpData`) de l'expression, sur le modèle de `type_recorder.rs`. Le transpileur se contente de la consommer et ne raisonne jamais sur les raffinements. + +Ensuite, la variable entre dans l'environnement avec le type raffiné. C'est le « check at boundary » du §20 : aucune nouvelle vérification aux usages suivants. + +### D6. Forme du R généré + +Le public cible est constitué de développeurs R qui lisent le code généré. Il faut donc que ce soit **court, lisible et inconditionnel** (hors du mode `--checked`). Je recommande de petits helpers du prélude qui renvoient la valeur, pour qu'ils fonctionnent aussi en position d'expression (arguments, `return`) : + +```r +a <- typr_refine_length(read_coordinates(), 2L, "main.ty:12") +distance(typr_refine_length(a, 2L, "main.ty:14"), b) +``` + +Le message produit est `Type refinement violation at main.ty:14: expected length 2, got 3` (§30). + +Pour les comparaisons, le test est `isTRUE(all(x > 0))`, qui est sûr face aux `NA` et aux vecteurs. Une variante « `if (...) stop(...)` inline » reste possible pour les `let`. Le choix est à trancher, voir Q4. + +--- + +## 2. Phases + +Chaque phase laisse `cargo test --workspace` et `typr case run` au vert. + +### Phase 0 — Préparation +- Commiter d'abord le travail en cours sur les angles morts (cas 0068-0072). Il touche `type/mod.rs`, `parsing/types.rs` et `type_checking/mod.rs`, exactement les fichiers de la phase 1, et mélanger les deux diffs serait pénible. +- Trancher les questions ouvertes (§3 ci-dessous), surtout Q1 à Q3. + +### Phase 1 — Modèle (`typr-core/components/type`) +- Créer `refinement.rs` avec `Measure`, `Interval`, `RefinementSet`, `Refinement`, et les opérations `meet`, `is_empty`, `implies`, `compatible`, `contradicts`. +- Ajouter `Type::Refined` et `Type::Property` à la fin de l'enum. Compléter les `match` exhaustifs dans `typr-core`, `typr-graph`, `typr-lsp` et `typr-wasm` : le compilateur les listera. +- Ajouter `refinements_of(&Type)`. Elle lit l'index `Vec` → `Length`, les littéraux `Integer(Val(3))`/`Number` → `Value ∈ [3,3]`, et `Refined` → son ensemble. +- Mettre à jour `type_category.rs` (catégorie `Refined`) et `type_printer.rs` : affichage `[int] & length(5)` et `int & (> 0)`. +- **Tests unitaires** : l'algèbre des intervalles, plus les lignes « A&B==B&A », « A&A==A », « (A&B)&C » et « contradiction » du §34. + +### Phase 2 — Parser et syntaxe +- Dans `parsing/types.rs`, ajouter un parseur `refinement_property`, essayé **avant** `parenthese_value` et `type_variable` dans `single_type` : + - `(` `>`|`<` littéral `)` → `Type::Property(Gt/Lt)` + - `length(` entier `)` → `Type::Property(Length)` +- Corriger la panique de `compute_operators` (erreur de parse au lieu de `panic!`) et ajouter un cas `cases/` pour `int & (> 0)`. +- `[5, int]` ne change pas : c'est déjà la forme de stockage d'après D4. +- **Manifeste de syntaxe** (`components/syntax/mod.rs`) : déclarer `length` comme mot de type et `>`/`<` en position de type. Les deux tests d'invariant l'exigent. Ensuite, lancer `typr syntax --write` pour régénérer la grammaire tmLanguage. +- ⚠️ Côté `typr.github.io`, `scripts/syntax-glossary.mjs` doit recevoir la glose des nouveaux lexèmes, sinon le build de la doc casse. Le changement doit être fait **en même temps** dans les deux dépôts. + +### Phase 3 — Normalisation et validation (`type_arithmetic.rs`) +Étendre `norm_intersection` : +- `(X, Property p)` → `Refined(X, {p})` après le contrôle de capacité. Même chose en symétrique. +- `(Refined(A,ps), Property q)` → `Refined(A, ps ⊓ q)`. +- `(Refined(A,ps), Refined(B,qs))` → `Refined(norm(A&B), ps ⊓ qs)`. +- `(Vec(_, Any, T), Length n)` → `Vec(_, n, T)` (D4). Si `(Vec(_, m, T), Length n)` avec `m ≠ n`, c'est une contradiction. +- Littéral + propriété : `3 & (> 0)` → `3` si prouvé, contradiction si réfuté. +- Un ensemble vide donne l'erreur **`UnsatisfiableRefinement`** (§11, §29). +- Un `Property` seul qui survit à la réduction donne une erreur « une propriété n'est pas un type ». + +Les **capacités** (§8) passent par une simple fonction `capabilities(base) -> {Lengthable, Ordered}` : +- `Vec` → Lengthable ; +- `Integer`/`Number` → Ordered ; +- générique non contraint → rien en v1. + +Si la capacité manque, on lève **`InvalidRefinement("length(5)", int)`**. + +Le cas `&` sur des Records reste inchangé. + +Ajouter les nouveaux variants dans `error_message/type_error.rs` avec leurs codes `T0xx` (utilisés par le MCP) et leurs messages au format du §29. + +`type Coordinates = [num] & length(2)` fonctionne sans travail spécifique, par la réduction des alias. + +### Phase 4 — Sous-typage (`type/mod.rs::is_subtype_raw`) +- `Refined(A, ps) <: B` si `A <: B` (invariant 4, §9). +- `A <: Refined(B, qs)` si `A <: B` et `decide(refinements_of(A), qs) == Proven`. +- `Refined(A, ps) <: Refined(B, qs)` si `A <: B` et `ps implies qs`. +- Placer ces règles **avant** les règles génériques `Operator(Intersection, ..)` existantes, qui feraient sinon `t1 <: typ || t2 <: typ` sur un `Property`. +- Vérifier l'unification (`unification.rs`) : un générique `T` lié à un argument raffiné prend le type raffiné complet (paramétricité, donc sûr). En revanche, les opérateurs arithmétiques et les fonctions non annotées renvoient le **type de base**. L'invariant 7 est ainsi garanti par construction. + +### Phase 5 — Frontières et obligations (§12, 13, 19-23) +- Créer `refinement_check.rs` avec `coerce_to` (D3) et la table d'obligations dans le `Context` (D5). +- Brancher `coerce_to` là où un type attendu rencontre un type trouvé : + - `let_expression.rs` : annotation de `let` ; + - `function_application.rs` : arguments, côté appelant (§37) ; + - `function.rs` : type de retour ; + - les constructeurs de record, dans un second temps. +- Paramètres : à l'intérieur du corps, `x` a le type raffiné sans aucune vérification (§12). +- **Changement de comportement** : `[int]` → `[3, int]` passe d'une erreur statique (T001/T002) à une vérification runtime. Il faut repérer les tests et `cases/` qui attendaient l'erreur (`typr case run` les signalera en REGRESS) et les réécrire comme témoins de l'absence ou de la présence de vérification. + +### Phase 6 — Codegen (`transpiling/`) +- Écrire le module `transpiling/refinement_checks.rs`. Il consomme les obligations et génère les appels du prélude (D6). Chaque `Measure` sait produire son test (§21) : `Length` → `length(x) == n`, `Value` → `x > c` / `x < c`. +- Ajouter les helpers `typr_refine_*` au prélude R, là où vit `typr_assert_type`. +- Aucune vérification n'est émise quand l'obligation est absente (invariants 5 et 6, §18, §22). +- Plus tard, en v1.1 (Q3) : un prologue de vérification pour les fonctions **exportées** d'un package, puisque l'appelant y est du R non typé. + +### Phase 7 — Propagation et invalidation (§15-17, §27-28) +- Corriger au passage `x[1:3]` et `x[x > 2]`, aujourd'hui typés `int`. Ils doivent donner respectivement `[3, int]` (si les bornes sont littérales, sinon `[int]`) et `[int]`. +- `length(x)` avec `x : [5, int]` doit donner le type littéral `5`, ce qui prépare le narrowing. +- Règle par défaut, conservatrice : une fonction dont l'effet n'est pas déclaré renvoie son type de retour déclaré, **sans** raffinement. +- Les effets déclaratifs (preserve/produce/invalidate) sont d'abord codés en dur pour une poignée de primitives (`[`, `:`, `c`, `length`, `rev`, `head`). Une syntaxe d'annotation dans `configs/std/*.ty` pourra venir plus tard. + +### Phase 8 — Tests, cas, documentation +- Les **tests unitaires** couvrent les 14 lignes du §34. +- Côté **`cases/`**, prévoir un cas par règle de codegen. Le format `expect.toml` (des règles sur des sous-chaînes) convient bien à « présence de check » et à « absence de check inutile » : lire `cases/README.md` pour la règle d'absence. Au minimum : + - un littéral prouvé ne produit pas de check ; + - `[int]` → `[5,int]` produit un check ; + - raffinement de paramètre ; + - raffinement de retour ; + - contradiction ; + - capacité invalide ; + - `int & (> 0)` (la panique actuelle). +- Mettre à jour `syntaxe.md`, dans ses **deux** copies, et la page de référence des types dans `typr.github.io` (Diátaxis : reference, plus une explication dans `concepts/`). Les exemples doivent passer `typr check`, car la CI `check:examples` les vérifie. +- Lancer `typr std` si les signatures du std changent (effets de la phase 7). + +### Phase 9 — Plus tard (hors v1) +- Narrowing par condition (§24) : `if (length(x) == 5)` intersecte l'intervalle dans la branche. Le modèle par intervalles le permet directement. +- Génériques `[T] & length(> 0)` (§25) et bornes d'intervalle sur `Length`. +- Migration de l'index de `Type::Vec` vers `Measure::Length`, et remplacement des types dépendants `#N` : c'est l'objectif final annoncé par la spec au §1. + +--- + +## 3. Questions ouvertes sur la spec + +1. **`string & length(10)`** (§26) : en R, `length("abc")` vaut `1`. Parle-t-on de `nchar` ? Si oui, il faut une mesure distincte (`Nchar`), sans quoi le raffinement serait trompeur pour un dev R. +2. **`(> 0)` sur un vecteur** : `[int] & (> 0)` veut-il dire « tous les éléments » ? D'ailleurs un `int` TypR est un vecteur R de longueur 1. Je propose le sens « tous », vérifié par `isTRUE(all(x > 0))`, ce qui règle aussi le cas `NA`. +3. **Où placer les vérifications de paramètres** : côté appelant (§37) ou dans le prologue de l'appelé ? Je recommande l'appelant pour le code TypR (on évite le check quand c'est prouvé, invariant 5), plus un prologue pour les fonctions exportées (appels depuis du R non typé). +4. **Le narrowing implicite avec vérification runtime** (`[int]` → `[5,int]` accepté puis vérifié) remplace une erreur statique actuelle. Veut-on un avertissement, ou une option pour exiger une conversion explicite ? +5. **Forme canonique du §4** : la v1 stocke la longueur dans `Type::Vec` (D4). Il faut confirmer que c'est acceptable tant que `refinements_of` redonne `A & length(N)`. +6. **Bornes `num` vs `int`** : `int & (> 0.5)` doit-il être accepté (équivalent à `>= 1`) ou rejeté ? + +--- + +## 4. Risques + +- **Les `.bin` du std sont sérialisés** (`serde`) : ajouter les variants **à la fin** de `enum Type` pour garder les indices, puis lancer `typr std`. +- **Le cache de sous-typage** : `Refined` doit avoir un `Hash`/`Eq` canonique (d'où le `BTreeMap`), sinon `A&B` et `B&A` occupent deux entrées différentes. +- **Récursion** : la normalisation passe par `reduce_type`. Garder `RUST_MIN_STACK=8388608` en tête si un test déborde. +- **Soundness** : ne jamais laisser `is_subtype` répondre `true` sur `Unknown` (D3). C'est l'invariant qui empêche de « transformer silencieusement l'incertitude en vérité » (§31). + +## 5. Ordre de livraison + +| PR | Contenu | Impact utilisateur | +|---|---|---| +| 1 | Phase 0 + Phase 1 + correction de la panique `int & (> 0)` | aucun (modèle interne) | +| 2 | Phases 2-4 : syntaxe, normalisation, sous-typage, erreurs statiques | `int & (> 0)` et `[int] & length(5)` acceptés ; contradictions et capacités invalides détectées | +| 3 | Phases 5-6 : obligations et codegen | vérifications runtime (changement de comportement Q4) | +| 4 | Phase 7 : propagation, avec la correction de `x[1:3]` | types plus précis | +| 5 | Phase 8, docs (dans les deux dépôts) | — | diff --git a/visualization_graph.md b/visualization_graph.md deleted file mode 100644 index b1df2e8..0000000 --- a/visualization_graph.md +++ /dev/null @@ -1,698 +0,0 @@ -Formalisation du système comme une Semantic Graph IR de TypR, située après résolution des noms et suffisamment proche du système de types pour pouvoir représenter tes relations entre expressions, types, interfaces et dépendances. - -Voici une première spécification assez précise, mais encore indépendante de l'implémentation Rust. - -TypR Semantic Graph IR - -1. Objectif - -La Semantic Graph IR (SGIR) est une représentation intermédiaire du programme TypR sous forme de graphe orienté typé. - -Elle représente les entités sémantiques du programme et les relations entre ces entités. - -Elle a trois objectifs : - -1. fournir une représentation exploitable par le compilateur ; -2. permettre des analyses structurelles du code ; -3. fournir une représentation commune aux outils de développement. - -La SGIR n'est pas une représentation syntaxique du programme. Elle représente les relations sémantiques découvertes par le compilateur. - ---- - -2. Modèle mathématique - -Un programme TypR compilé est associé à un graphe : - -[ -G = (V,E,\tau_V,\tau_E,A) -] - -où : - -- V est l'ensemble des nœuds ; -- E \subseteq V \times V est l'ensemble des arêtes orientées ; -- \tau_V : V \rightarrow NodeKind associe un genre à chaque nœud ; -- \tau_E : E \rightarrow EdgeKind associe un genre à chaque arête ; -- A est l'ensemble des attributs associés aux nœuds et aux arêtes. - -Une arête : - -[ -e = (u,v) -] - -signifie que l'entité u entretient la relation \tau_E(e) avec l'entité v. - -Le graphe est orienté. - -Il peut contenir des cycles. - ---- - -3. Identité des nœuds - -Chaque nœud possède un identifiant stable pendant la durée de vie du graphe : - -NodeId - -L'identifiant n'encode pas la nature du nœud. - -Le type du nœud est obtenu par : - -NodeKind - -Exemple : - -NodeId(42) - kind = Function - -Les identifiants peuvent être implémentés avec "petgraph::stable_graph::NodeIndex" ou un mécanisme d'identifiants stable équivalent. - ---- - -4. Nœuds - -Le type fondamental est : - -enum NodeKind { - Module, - Function, - Parameter, - Variable, - Expression, - - Type, - Interface, - - Field, - Constructor, - - Literal, -} - -Cette liste est extensible. - -Un nœud représente une entité sémantique identifiable par le compilateur. - ---- - -4.1 Module - -Un module représente une unité de compilation ou un module TypR. - -Module - -Exemple : - -module math - ---- - -4.2 Function - -Une fonction représente une fonction déclarée ou une fonction générée par le compilateur. - -Function - -Une fonction peut être reliée : - -Function ──HasType────► FunctionType -Function ──Contains───► Expression -Function ──Uses───────► Variable -Function ──Calls──────► Function - ---- - -4.3 Variable - -Une variable représente une définition ou référence résolue. - -Une référence à une variable doit pointer vers le même nœud sémantique que sa définition lorsque celle-ci est connue. - -Exemple : - -let x = 10 -let y = x - -produit notamment : - -x ──UsedBy──► y - -ou, selon la convention retenue : - -y ──Uses──► x - -La seconde convention est recommandée. - ---- - -5. Expressions - -Une expression peut être représentée comme un nœud lorsque son identité est utile à l'analyse. - -Expression - -Exemple : - -x + y - -peut produire : - -Expression(Add) - ├── Uses ──► x - └── Uses ──► y - -Les expressions purement syntaxiques qui n'apportent aucune information utile à l'analyse peuvent être omises. - -La SGIR n'a donc pas obligation de représenter chaque élément de l'AST. - ---- - -6. Types - -Les types sont des entités du graphe. - -Type - -Exemples : - -Int -String -Vector -Person -(Self) -> Empty - -Les relations entre types sont représentées par des arêtes. - -Exemple : - -Vector - │ - └── ElementType ──► Int - ---- - -7. Interfaces - -Une interface TypR est représentée comme un nœud : - -Interface - -Exemple : - -type Printable <- interface { - print: (Self) -> Empty -} - -produit conceptuellement : - -Printable - │ - └── Requires ──► print - │ - └── HasType ──► (Self) -> Empty - -Une implémentation produit : - -Person ──Implements──► Printable - ---- - -8. Relations - -Les relations sont représentées par : - -enum EdgeKind { - Contains, - - Defines, - Uses, - - Calls, - - HasType, - - ElementType, - FieldType, - - Implements, - Requires, - - Refines, - - Returns, - ParameterType, - - Instantiates, -} - -Cette liste est volontairement limitée. - -Une relation doit être ajoutée uniquement lorsqu'elle possède une signification sémantique exploitable. - ---- - -9. Direction des relations - -La direction doit être définie par la sémantique de la relation. - -La convention générale est : - -«L'arête part de l'entité qui dépend ou possède une relation vers l'entité dont elle dépend.» - -Ainsi : - -Function ──Uses──► Variable -Function ──Calls──► Function -Person ──Implements──► Printable -Function ──HasType──► FunctionType -Vector ──ElementType──► Int - -Cette convention permet notamment d'effectuer facilement une analyse des dépendances sortantes. - ---- - -10. Exemple complet - -Pour : - -type Printable <- interface { - print: (Self) -> Empty -} - -type Person = { - name: String -} - -fn print_person(person: Person) -> Empty { - print(person.name) -} - -la SGIR peut contenir : - - ┌──────────────┐ - │ Printable │ - └──────┬───────┘ - ▲ - Implements - │ - ┌──────┴───────┐ - │ Person │ - └──────┬───────┘ - │ - FieldType - │ - ▼ - String - - -┌─────────────────┐ -│ print_person │ -└───────┬─────────┘ - │ - HasType - │ - ▼ - (Person) -> Empty - │ - Uses - ▼ - Person - ---- - -11. Projections - -Le graphe sémantique constitue la représentation fondamentale. - -Les graphes spécialisés sont des projections du graphe. - -Soit : - -[ -G=(V,E) -] - -Une projection est définie par un prédicat : - -[ -P : E \rightarrow {true,false} -] - -et produit : - -[ -G_P=(V,{e\in E\mid P(e)}) -] - ---- - -11.1 Dependency Graph - -Le dependency graph sélectionne les relations représentant une dépendance. - -Uses -Calls -Implements -Requires -HasType - -Exemple : - -Function A ──Uses──► Type B -Function A ──Calls──► Function C - ---- - -11.2 Type Graph - -Le type graph sélectionne principalement : - -HasType -ElementType -FieldType -Implements -Requires -Refines -Instantiates - -Il permet d'explorer la structure du système de types. - ---- - -11.3 Coupling Graph - -Le coupling graph est une analyse dérivée. - -Il ne correspond pas nécessairement à une catégorie d'arêtes unique. - -Le couplage peut être calculé à partir des relations : - -Uses -Calls -HasType -Implements -Requires -Refines - -et de leur évolution dans le temps. - -Ainsi : - -[ -Coupling(A,B) -] - -est une propriété calculée du sous-graphe reliant A et B. - ---- - -12. Abstraction - -L'abstraction n'est pas nécessairement une arête. - -Elle décrit quelles propriétés d'un nœud ou d'une interface sont exposées ou cachées. - -Par exemple : - -Implementation - │ - ▼ - Interface - -L'interface constitue une frontière abstraite. - -Une analyse peut donc déterminer : - -Concrete node - │ - │ Implements - ▼ -Abstract node - -et identifier quelles propriétés peuvent varier sans modifier les consommateurs. - ---- - -13. Construction du graphe - -La SGIR est construite progressivement pendant la compilation. - -Pipeline recommandé : - -Source - │ - ▼ -AST - │ - ▼ -Name Resolution - │ - ▼ -HIR - │ - ├─────────────► Type Checking - │ - ▼ -Semantic Graph Builder - │ - ▼ -SGIR - -Le graphe doit être construit à partir du HIR et des informations produites par la résolution des noms et le type checking. - -Il ne doit pas être construit directement depuis le texte source. - ---- - -14. Propriété importante : référence aux entités du compilateur - -Les nœuds du graphe doivent pouvoir référencer les entités internes existantes. - -Exemple : - -enum NodeKind { - Function(FunctionId), - Variable(VariableId), - Type(TypeId), - Interface(InterfaceId), - Expression(ExprId), -} - -Ainsi le graphe ne duplique pas les informations du compilateur. - -Il fournit une couche relationnelle au-dessus de celles-ci. - ---- - -15. API minimale - -Une API minimale peut être : - -struct SemanticGraph { - graph: StableDiGraph, -} - -avec : - -impl SemanticGraph { - fn add_node(&mut self, node: Node) -> NodeId; - - fn add_edge( - &mut self, - from: NodeId, - to: NodeId, - kind: EdgeKind, - ) -> EdgeId; - - fn node(&self, id: NodeId) -> &Node; - - fn edges_from(&self, id: NodeId) -> impl Iterator; - - fn edges_to(&self, id: NodeId) -> impl Iterator; -} - -Une couche de requête peut ensuite être ajoutée : - -struct GraphQuery<'a> { - graph: &'a SemanticGraph, -} - -avec par exemple : - -fn dependencies_of(node: NodeId) -> Vec; - -fn dependents_of(node: NodeId) -> Vec; - -fn implementations_of(interface: NodeId) -> Vec; - -fn users_of(node: NodeId) -> Vec; - -fn callers_of(function: NodeId) -> Vec; - ---- - -16. Sérialisation - -La SGIR peut être exportée sous une forme indépendante de "petgraph". - -Format conceptuel : - -{ - "nodes": [ - { - "id": 1, - "kind": "Function", - "name": "print_person" - }, - { - "id": 2, - "kind": "Interface", - "name": "Printable" - } - ], - "edges": [ - { - "from": 1, - "to": 2, - "kind": "Uses" - } - ] -} - -L'export doit être considéré comme une représentation externe de la SGIR et non comme son implémentation interne. - ---- - -17. Visualisation - -Le compilateur doit pouvoir produire une représentation Graphviz/DOT : - -typr graph -typr graph --types -typr graph --dependencies -typr graph --coupling - -Exemple : - -typr graph --types --format dot - -La visualisation interactive pourra être implémentée ultérieurement sans modifier le modèle sémantique. - ---- - -18. Évolution future - -La SGIR est conçue pour permettre des analyses temporelles. - -Pour deux versions : - -[ -G_t -] - -et : - -[ -G_{t+1} -] - -on peut définir : - -[ -\Delta G = G_{t+1} - G_t -] - -permettant d'observer : - -- ajout de nœuds ; -- suppression de nœuds ; -- ajout de relations ; -- suppression de relations ; -- modification des types ; -- modification des interfaces ; -- changement de dépendances. - -Cela permet ultérieurement de calculer des propriétés telles que : - -coupling evolution -interface stability -dependency volatility -change propagation -architectural drift - -sans modifier la représentation fondamentale du programme. - ---- - -19. Principe architectural - -La règle fondamentale de la SGIR est : - -«Le graphe représente les relations ; les entités du compilateur restent la source de vérité.» - -Ainsi : - -AST - │ - ├── syntaxe - │ - ▼ -HIR - │ - ├── identité - ├── résolution - ├── expressions - │ - ▼ -Type System - │ - ├── types - ├── interfaces - ├── contraintes - │ - ▼ -Semantic Graph - │ - ├── relations - ├── dépendances - ├── structure - └── historique futur - -La SGIR devient donc une couche relationnelle du compilateur, et non un deuxième AST. - ---- - -20. Implémentation de référence - -Implémentation initiale recommandée : - -[dependencies] -petgraph = "..." -slotmap = "..." -serde = { version = "...", features = ["derive"] } -serde_json = "..." - -Structure : - -typr-core/ -└── src/ - ├── ast/ - ├── hir/ - ├── resolve/ - ├── types/ - └── graph/ - ├── mod.rs - ├── node.rs - ├── edge.rs - ├── graph.rs - ├── builder.rs - ├── query.rs - └── projection.rs - -Le graphe interne utilise "StableDiGraph". - -Les projections et analyses ne manipulent pas directement les détails de "petgraph". - -Cette séparation permet de remplacer ultérieurement "petgraph" sans modifier le modèle conceptuel de TypR.Le point que je trouve particulièrement intéressant pour TypR est que la SGIR pourrait devenir une véritable IR d'analyse, au même titre que ton HIR est une IR de compilation. Ensuite, le couplage, les interfaces, les types, les dépendances et même l'évolution Git ne seraient plus des systèmes séparés : ce seraient des analyses différentes d'une même structure. diff --git a/visualization_graph_v2.md b/visualization_graph_v2.md deleted file mode 100644 index 8e1bdf8..0000000 --- a/visualization_graph_v2.md +++ /dev/null @@ -1,765 +0,0 @@ -# TypR Block Graph — spécification v2 - -Visualiseur interactif du code TypR sous forme de **graphe de blocs hiérarchique**, pensé à la -fois comme outil pédagogique (comprendre un programme et les concepts du langage) et comme outil -de revue de code. Disponible en CLI (`typr graph`) et dans le playground. - -Cette version remplace `visualization_graph.md` (v1). Elle garde de la v1 l'idée d'un graphe -sémantique typé et de projections, mais l'adapte à l'architecture réelle de `typr-core` et au -modèle « tout est un bloc » décidé en discussion. - -> Pas de RFC nécessaire : l'outil ne change pas le sens du langage (voir `rfcs/README.md`). - ---- - -## 0. Décisions prises - -| # | Sujet | Décision | -|---|---|---| -| A | Priorité | Graphe d'architecture/revue d'abord ; chaque relation peut porter une **justification** (`evidence`) pour servir la pédagogie ensuite. | -| B | Granularité | **Les expressions sont des blocs.** `7`, `12 + 3`, une fonction, un module, un type : tout est un bloc. Les déclarations (`let`, `type`, `module`, paramètres) ne créent pas de bloc : elles **nomment** un bloc. | -| C | Précision | Résolution des noms **par nom** (comme le LSP) pour commencer, avec un niveau de confiance explicite sur chaque référence. Un **enregistreur de types** minimal dans `typing()` fournit le type de chaque bloc. Une vraie passe de résolution de noms est un chantier séparé. | -| D | Spans | `HelpData` gagne un **offset de fin** (prérequis, profite aussi au LSP). | -| E | Rendu | **React Flow (xyflow) + ELK.js** dans le playground. Navigation par « entrée » dans les blocs, fil d'Ariane, historique dans l'URL. Navigation clavier prévue. | -| G | Docs | Un bloc ` ```typr graph ` de la documentation ouvre le playground sur la vue graphe. | -| — | Crate | Nouvelle crate `crates/typr-graph`, WASM-safe, dépendant de `typr-core`. | -| Q1 | Frontière | Toute dépendance qui franchit la frontière d'un bloc apparaît comme **port d'entrée implicite** (capture). Le bloc expose honnêtement tout ce dont il dépend : localité du raisonnement et composabilité rendues visibles. | -| Q2 | Appel ≠ contenance | Une fonction est son propre bloc, défini une seule fois ; un appel ne la contient pas, il la **référence** par une relation. Deux gestes distincts : *entrer* et *aller à la définition*. | -| Q3 | Mutation/boucles | Les boucles sont des blocs à **ports d'état** (entrée/sortie par variable mutée). | -| Q4 | Types | Un type est un bloc ; son interface ce sont ses **méthodes** (fonctions qui le prennent en 1ʳᵉ position). D'autres positions (n-ième paramètre, retour) pourront s'ajouter. | - -Les points non tranchés sont listés en §13 et seront discutés **quand le besoin se présente**, pas avant. - ---- - -## 1. Objectifs et non-objectifs - -**Objectifs** - -1. Représenter un programme TypR comme une hiérarchie de blocs explorable de l'intérieur. -2. Rendre visibles les frontières : ce qu'un bloc consomme (entrées), expose (sorties), cache. -3. Rendre visibles les relations du système de types : type d'un bloc, méthodes d'un type, - satisfaction d'interface avec sa justification, sous-typage. -4. Servir la revue de code : dépendances d'un bloc, puis (plus tard) différence entre deux versions. - -**Non-objectifs (pour l'instant)** - -- Éditer le programme depuis le graphe (le graphe est une vue, pas un éditeur). -- Remplacer l'AST ou introduire un HIR : le graphe est dérivé de l'AST + du contexte. -- Analyse historique git, métriques de couplage : reportées (§12, étape 7+). - ---- - -## 2. Écart avec la v1 et contraintes de la base de code - -| Hypothèse v1 | Réalité de `typr-core` | Conséquence | -|---|---|---| -| Pipeline AST → résolution → HIR → typage | `Lang` est l'AST, typé directement ; pas de HIR | Le builder part de `Lang` + `Context` final + enregistreur de types | -| Noms résolus vers des IDs | Résolution par chaîne dans `VarType` (masquage par ordre, surcharge par type du 1ᵉʳ argument) | Références résolues par nom, avec `confidence` (§5.3) | -| Types par expression disponibles | `TypeChecker` ne garde qu'un type par instruction de haut niveau ; `typing()` (`processes/type_checking/mod.rs`) est l'unique point d'entrée récursif | Enregistreur dans `typing()` (§7.2) | -| `Person implements Printable` déclaré | Interfaces **structurelles**, sans `impl` (`interface_satisfaction.rs`) | `Satisfies` est **calculé**, avec justification | -| Types = entités | `Type` est une valeur structurelle portant un `HelpData` | Canonisation (position retirée) pour l'identité | -| Identité = `NodeIndex` | Instable d'une compilation à l'autre | Identité = **clé sémantique** (§6) ; les index restent internes | -| — | La stdlib est préchargée dans le contexte (milliers d'entrées) | Seules les entrées **référencées** apparaissent, comme blocs externes repliés | -| — | Hiérarchie de sous-typage déjà calculée : `Context.subtypes: Graph` (`context/graph.rs`) | Réutilisée pour la relation `Subtype` | -| — | `HelpData` = `offset` + `file_name` seulement | Ajout d'un offset de fin (décision D) | - -Note : le parseur ne désucre ni `|>` ni `.` (ils restent des `Lang::Operator`), donc le graphe -peut refléter la forme du source. - ---- - -## 3. Modèle - -### 3.1 Vocabulaire - -- **Bloc** : unité sémantique délimitée (une expression, une fonction, un type, un module…). - Il a une **interface** (ports) et éventuellement un **intérieur** (un sous-graphe de blocs). -- **Port** : point d'entrée ou de sortie d'un bloc, nommé et (si connu) typé. -- **Fil** (*wire*) : flot de données d'un port de sortie vers un port d'entrée, **à l'intérieur - d'un même bloc parent**. Les fils ne traversent jamais une frontière : ce qui entre dans un bloc - passe par un de ses ports d'entrée. -- **Relation** : arête sémantique entre deux blocs quelconques (référence vers une définition, - typage, satisfaction d'interface…). Contrairement aux fils, les relations peuvent relier des - blocs à des niveaux différents. -- **Nom** : une déclaration (`let`, `type`, `module`, paramètre, liaison de motif) attache un nom - à un bloc. Un bloc anonyme reste un bloc. - -### 3.2 Anatomie d'un bloc - -``` -Block -├── key : BlockKey clé sémantique stable (§6) -├── kind : BlockKind (§4) -├── name : Option donné par la déclaration qui le nomme -├── span : Option début..fin dans le source (None pour stdlib/externe) -├── type : Option type du bloc (enregistreur, §7.2) -├── inputs : Vec explicites (paramètres, opérandes, arguments) -│ + implicites (captures, §3.3) -├── outputs : Vec en général un seul (la valeur) ; -│ plusieurs pour Record, Module, TypeDecl (§4) -├── origin : User | Std | RPackage(name) -└── body : Option intérieur explorable - ├── children : Vec - └── wires : Vec (from: PortRef, to: PortRef) - -Port { name, type: Option, implicit: bool, visibility: Public | Private } -PortRef { block: BlockKey, port: String } // block = le parent lui-même pour ses propres ports -``` - -### 3.3 Règle de frontière (Q1) - -Pour tout bloc `B` possédant un intérieur : - -> **Chaque nom utilisé dans l'intérieur de `B` et défini hors de `B` devient un port d'entrée -> implicite de `B`.** - -Dans l'intérieur de `B`, ce port est la source des fils vers ses utilisateurs. La définition -elle-même est reliée par une relation `Ref` (§5), jamais par un fil. - -Conséquences : - -- l'interface d'un bloc liste **toutes** ses dépendances : on peut raisonner sur un bloc sans - regarder ailleurs, ce qui est la base de la revue locale et de l'enseignement de la composition ; -- les dépendances remontent naturellement : si `norm2` capture `sq`, et que `norm2` est dans un - module `M` où `sq` est aussi défini, `sq` n'est **pas** une entrée de `M` (la capture est - résolue à l'intérieur de `M`) ; -- au niveau du bloc racine `Program`, les entrées implicites sont exactement les dépendances - externes (stdlib, paquets R) du programme. - -Les opérateurs natifs (`+`, `*`, …) ne sont pas des captures : ils font partie du bloc `Operator`. -(Si un opérateur se résout vers une fonction utilisateur, voir §13.) - -Le rendu **peut** replier ces fils (ex. afficher le nom de l'appelé en étiquette du bloc `Apply` -plutôt qu'un fil depuis le port `sq`) : c'est une option d'affichage, le modèle garde le fil. - ---- - -## 4. Catalogue des blocs - -Colonne **Étape** : quand le bloc est implémenté (voir §12). Tout variant de `Lang` non encore -couvert produit un bloc **`Opaque`** (texte source, type si connu, pas d'intérieur) : le builder -est total dès la première étape et ne panique jamais. - -| Kind | Source (`Lang`) | Entrées | Sorties | Intérieur | Étape | -|---|---|---|---|---|---| -| `Program` | racine `Lines` | dépendances externes | — | instructions de haut niveau | 1 | -| `Literal` | `Number`, `Integer`, `Bool`, `Char`, `Null`, `NA`, `Empty` | — | valeur | — | 1 | -| `Operator(op)` | `Operator` (hors `.`/`\|>`), `Not` | opérandes (`lhs`, `rhs`) | valeur | les opérandes | 1 | -| `Apply` | `FunctionApp`, `VecFunctionApp`, `.`/`\|>` (UFCS) | `callee` + `arg0..n` (ou noms) | valeur | les arguments | 1 | -| `Scope` | `Scope`, corps de fonction | captures | valeur de la dernière expression | blocs nommés par `let` + expressions | 1 | -| `Function` | `Function`, `Lambda` | paramètres + captures | retour | le corps (paramètres comme sources) | 1 | -| `Record` | `List` (champs nommés), `ConstructorCall` | un port par champ (+ spreads) | valeur **et** un port par champ | les valeurs des champs | 1 | -| `Access` | accès champ (`$`, `.x`) | la valeur | le champ | — | 1 | -| `TypeDecl` | `Alias` (record, union, opaque) | — | ses **méthodes** (Q4) | les champs / membres (blocs `TypeExpr`) | 1 | -| `TypeExpr` | type en position d'annotation | — | — | sous-types (`int \| char`, `list{…}`) | 1 | -| `Interface` | `Alias` vers `interface { … }` | — | — | signatures requises | 1 | -| `If` | `If` | `cond` + captures | valeur | `cond`, `then`, `else` (chacun explorable) | 1 | -| `Tuple` | `Tuple` | un port par position | valeur + un par position | éléments | 1 | -| `Array` | `Array`, `Vector` | éléments | valeur | éléments | 1 | -| `Opaque` | tout le reste | captures si connues | valeur | — | 1 | -| `Loop` | `ForLoop`, `WhileLoop`, `Loop` | itérable/condition + **état entrant** | **état sortant** | le corps | 5 | -| `Match` | `Match` | le sujet + captures | valeur | un sous-bloc par bras (§13) | 5 | -| `Module` | `Module` | captures | **membres `@pub` seulement** | tous les membres, privés compris | 5 | -| `RCode` | `RBlock`, `ExternBlock`, `RFunction` | — | valeur | — (frontière opaque vers R) | 5 | - -### 4.1 Précisions - -- **Let** ne crée pas de bloc : `let a <- sq(p$x)` nomme le bloc `Apply` ; toute utilisation - ultérieure de `a` dans le même `Scope` est un fil depuis sa sortie. -- **Assign hors boucle** (`x <- x + 1`) : les utilisations suivantes de `x` sont câblées depuis - le nouveau bloc. Aucune étiquette de version n'est affichée : le câblage suffit. -- **Loop (Q3)** : chaque variable définie hors de la boucle et réaffectée dedans devient une - paire de ports `état entrant` / `état sortant` du bloc `Loop`. Le reste du `Scope` englobant - est câblé depuis l'état sortant. -- **Record et Module ont plusieurs sorties** : un record expose un port par champ ; un module - n'expose que ses membres `@pub`. Entrer dans un module montre aussi les membres privés, - marqués comme tels : **le secret se voit de l'intérieur, jamais sur l'interface.** -- **Access** : `p$x` est un bloc `Access(x)` câblé depuis `p`. Quand la source est un bloc - `Record` visible au même niveau, le rendu peut câbler directement depuis le port `x` du record. -- **Types** : un bloc d'expression est relié à son type par `HasType` (§5). Un `TypeDecl` - expose comme sorties ses méthodes, c'est-à-dire les fonctions reliées à lui par - `TypePosition { index: 0 }`. - ---- - -## 5. Relations - -### 5.1 Catalogue - -| Kind | De → vers | Sens | Étape | -|---|---|---|---| -| `Ref` | utilisation (`Apply.callee`, port implicite) → bloc défini | « dépend de la définition » | 1 | -| `HasType` | bloc d'expression → `TypeDecl`/`TypeExpr` | typage | 1 | -| `TypePosition { index }` | `Function` → `TypeDecl` | le type apparaît en paramètre `index` (`0` = méthode, Q4) ; `Return` plus tard | 1 (index 0), 7 (autres) | -| `Satisfies` | `TypeDecl` → `Interface` | satisfaction structurelle, **calculée** | 3 | -| `DeclaredAs` | `TypeDecl` → `Interface` | intersection `Record & Interface` vérifiée à la déclaration | 3 | -| `Subtype` | type → supertype | depuis `Context.subtypes` | 3 | -| `Instantiates` | `Apply` → `Function` générique | instanciation d'un générique | §13 | - -Convention de sens (reprise de la v1) : l'arête part de ce qui **dépend** vers ce dont il dépend. - -### 5.2 Justification (`evidence`) - -Toute relation calculée peut porter une justification lisible, affichée au clic : - -```json -{ "kind": "Satisfies", "from": "type:Point", "to": "type:Printable", - "evidence": [ { "requires": "show: (Self) -> char", "provided_by": "val:show(Point)" } ] } -``` - -C'est le support principal de l'usage pédagogique : le graphe ne dit pas seulement *que* -`Point` satisfait `Printable`, mais *grâce à quelle fonction*. - -### 5.3 Confiance des références (approche C-i) - -La résolution se fait par nom. Chaque `Ref` indique : - -| `confidence` | Signification | -|---|---| -| `exact` | un seul candidat visible | -| `by_name` | plusieurs définitions du nom, choisie par la règle de masquage (la plus récente) | -| `ambiguous` | surcharge : plusieurs candidats selon le type du 1ᵉʳ argument ; `candidates: [...]` listés | - -Le rendu montre visuellement les références non exactes (pointillés). Ce champ disparaîtra, ou -sera toujours `exact`, quand une vraie passe de résolution existera. - ---- - -## 6. Identité : clés sémantiques - -L'identité d'un bloc est une **clé stable** d'une compilation à l'autre, ce que la revue (diff -entre versions) exige. Les index internes du graphe ne sortent jamais de la crate. - -``` -BlockKey := namespace ":" path -namespace := "val" | "type" | "std" | "r" # TypR sépare les espaces de noms valeurs / alias -path := segment ("/" segment)* -segment := nom # bloc nommé - | nom "(" type_1er_param ")" # surcharge : show(Point) - | "#" index # bloc anonyme, position parmi ses frères - | rôle # then, else, cond, arg0, lhs, rhs, body… -``` - -Les modules qualifient avec `::` : `val:Geo::area`. Exemples : -`val:norm2`, `val:norm2/a` (le bloc nommé `a`), `val:norm2/#1` (l'expression finale), -`val:norm2/#1/rhs/arg0`, `type:Point`, `std:print`. - -Les segments nommés sont stables ; les segments positionnels (`#n`) le sont moins, et le diff les -apparie en second (étape 6). - ---- - -## 7. Construction - -### 7.1 Entrées - -``` -source ─parse─► Lang ─typing()─► Context final - │ │ - │ TypeRecorder (span → Type) - ▼ ▼ - typr-graph::build(&Lang, &Context, &TypeTable) ─► BlockGraph -``` - -1. Parcours de `Lang` → arbre de blocs + fils locaux (§3, §4). -2. Résolution par nom des captures, avec portée lexicale simulée pendant le parcours, et - `Context` final pour les noms de haut niveau et la stdlib (§5.3). -3. Types des blocs depuis la `TypeTable` (§7.2) ; types des paramètres depuis leurs annotations. -4. Relations de typage (`HasType`, `TypePosition`) ; plus tard `Satisfies` et `Subtype` (étape 3). -5. Élagage de la stdlib : seules les entrées référencées sont matérialisées (`origin: Std`). - -### 7.2 Enregistreur de types (prérequis dans `typr-core`) - -`typing(context, expr)` est l'unique point d'entrée récursif du typage. On l'enveloppe : - -```rust -// processes/type_checking/type_recorder.rs -thread_local! { static RECORDER: RefCell> = RefCell::new(None); } - -pub fn with_recording(f: impl FnOnce() -> R) -> (R, TypeTable); -// dans typing() : si RECORDER est actif, table.insert(span(expr), result.value.clone()) -``` - -- Désactivé : coût d'une lecture de `thread_local` par appel, aucune allocation. -- Clé : `(file, start, end)`. D'où le prérequis D : sans offset de fin, `a` et `a + sq(…)` - partagent la même clé. -- `typing()` peut être rappelée sur une même expression (essais de dispatch, unification). La - politique retenue au départ est « dernière valeur non-`Failed` ». À valider sur les cas réels à - l'étape 0, en journalisant les conflits. -- Le WASM est mono-thread : le `thread_local` y est sans risque. - -### 7.3 Offset de fin dans `HelpData` (prérequis D) - -`HelpData { offset, end, file_name }`, rempli par le parseur (`nom_locate` donne la position -après chaque combinateur). Les `HelpData` synthétiques gardent `end = offset`. Bénéficiaires -annexes : surlignage exact des diagnostics et des plages LSP. - ---- - -## 8. Projections - -Le graphe plat de la v1 est une **projection** de la hiérarchie : on aplatit l'arbre des blocs, -on remplace chaque chaîne capture → `Ref` par une arête directe, puis on filtre par prédicat sur -les kinds de relation (v1 §11). - -| Projection | Contenu | Étape | -|---|---|---| -| Dépendances | blocs nommés de haut niveau + `Ref` aplaties | 2 (CLI) | -| Types | `TypeDecl`, `Interface`, `HasType`, `TypePosition`, `Satisfies`, `Subtype` | 3 | -| Couplage | dérivée (fan-in/fan-out, cycles), v1 §11.3 | 7 | - ---- - -## 9. Format d'échange (JSON) - -Carte à plat des blocs (plus simple à naviguer côté client qu'un arbre imbriqué), versionnée : - -```json -{ - "format": "typr-block-graph", - "version": 1, - "root": "val:", - "blocks": { - "val:norm2": { - "kind": "Function", "name": "norm2", "origin": "User", - "span": { "file": "main.ty", "start": 98, "end": 170 }, - "type": "(Point) -> int", - "inputs": [ { "name": "p", "type": "Point", "implicit": false }, - { "name": "sq", "type": "(int) -> int", "implicit": true } ], - "outputs": [ { "name": "out", "type": "int" } ], - "body": { - "children": [ "val:norm2/a", "val:norm2/#1" ], - "wires": [ { "from": { "block": "val:norm2", "port": "p" }, - "to": { "block": "val:norm2/a", "port": "arg0" } } ] - } - } - }, - "relations": [ - { "kind": "Ref", "from": "val:norm2", "port": "sq", "to": "val:sq", "confidence": "exact" }, - { "kind": "TypePosition", "from": "val:show(Point)", "to": "type:Point", "index": 0 } - ] -} -``` - -Ce JSON est un **contrat externe** (CLI, WASM, playground, docs) : tout changement incompatible -incrémente `version`. Les types sont transmis sous leur forme imprimée (`Type::pretty()`) ; une -forme structurée pourra suivre si le front en a besoin. - ---- - -## 10. Architecture des crates - -``` -crates/typr-core + HelpData.end (D) + type_recorder.rs (§7.2) -crates/typr-graph NOUVELLE — pure logique, WASM-safe (pas d'E/S, pas de tokio) - src/model.rs Block, Port, Wire, Relation, BlockKey - src/build/ parcours de Lang, portée lexicale, captures, résolution par nom - src/typing.rs HasType, TypePosition, (étape 3) Satisfies, Subtype - src/project.rs projections - src/export/ json.rs (serde), dot.rs (un niveau à la fois) - src/diff.rs (étape 6) -crates/typr-cli commande `typr graph` -crates/typr-wasm fn semantic_graph(source) -> JSON (+ multi-fichiers plus tard) -``` - -Dépendances : `serde`/`serde_json` (déjà dans le workspace). `petgraph` seulement si un -algorithme le justifie (cycles, étape 7) ; il reste caché derrière `model.rs`. - ---- - -## 11. Interface utilisateur (playground) - -**Pile** : React Flow (xyflow) pour les nœuds à ports (chaque bloc est un composant React), -ELK.js pour la mise en page (`elk.portConstraints`), un niveau rendu à la fois. - -``` - Programme › norm2 › #1 [←] [→] -┌──────────────────────────────────────────────────────┐ -│ p: Point ●───► Access(y) ──► sq(·) ──┐ │ -│ ▼ │ -│ a: int ●────────────────────────► ( + ) ──► ● int │ -└──────────────────────────────────────────────────────┘ -``` - -| Geste | Effet | -|---|---| -| clic | sélectionne le bloc, surligne sa plage dans Monaco, panneau de détails (type, ports, relations, justifications) | -| double-clic | **entre** dans le bloc (s'il a un intérieur) avec une transition de zoom animée | -| Alt+clic / « aller à la définition » | navigue vers le bloc défini (`Ref`), par exemple d'un `Apply` vers la `Function` | -| fil d'Ariane | remonte à n'importe quel ancêtre | -| Précédent/Suivant | historique du navigateur | -| clic dans Monaco | synchronisation inverse : le graphe se place sur le bloc le plus interne qui contient le curseur | - -**Relations comme arêtes** : par défaut, seuls les **fils** (flux de données à l'intérieur d'un -niveau) sont dessinés — c'est le rendu déjà décrit ci-dessus. Trois catégories de **relations** -(§5) peuvent en plus être affichées comme arêtes colorées et dirigées, chacune activable -indépendamment (légende à cases à cocher sur le canevas) : - -| Catégorie | Relation | Sens de la flèche | -|---|---|---| -| Capture implicite | `Ref` dont le port d'origine est **implicite** (`Port.implicit`, §3.3) | du bloc qui capture vers la définition capturée | -| 1ᵉʳ paramètre lié à un type | `TypePosition { index: 0 }` | de la fonction vers le `TypeDecl` | -| Expression liée à un type | `HasType` | de l'expression vers son `TypeDecl`/`TypeExpr` | - -Un `Ref` explicite (`callee` d'un `Apply`, renommage `let x <- y`, …) n'est **pas** une capture et -n'entre dans aucune de ces trois catégories : la distinction se lit sur le port d'origine, pas sur -le seul `kind` de la relation. Convention de sens et de couleur reprises telles quelles (§5.1) ; -seul l'affichage est nouveau, aucun champ n'est ajouté au format JSON (§9). - -Contraintes : -- une relation n'est dessinée que si **ses deux extrémités sont des blocs visibles au niveau - courant** de la vue « un niveau à la fois » — même règle que pour les fils qui sortent de la vue - (étape 2/4) ; une relation dont l'autre bout est ailleurs (ex. `HasType` vers un type déclaré au - niveau `Program` alors qu'on est descendu dans un `Function`) n'a nulle part où s'accrocher à ce - niveau et reste seulement listée dans le panneau de détails ; -- activer/désactiver une catégorie **réinjecte le filtre dans la mise en page ELK** (pas un simple - masquage CSS), pour que les nœuds se réarrangent en fonction des relations rendues visibles — - c'est le but recherché (« arranger le graphe en fonction de ces relations ») ; -- toutes désactivées par défaut : le comportement par défaut du canevas ne change pas. - -**État dans l'URL** : `?view=graph&focus=` (encodé). Chaque vue a son lien profond -partageable ; Précédent et Suivant marchent sans code supplémentaire. Ce paramètre s'ajoute au -contrat `INTEGRATION.md` (à mettre à jour **dans les deux dépôts à la fois**). - -**Clavier (prévu, étape 7)** — à réserver dès maintenant dans la conception des composants -(nœuds focalisables, ordre de tabulation) : - -| Touche | Effet | -|---|---| -| ←/→/↑/↓ | bloc voisin, en suivant les fils (amont/aval) puis les frères | -| Entrée | entrer | -| Échap / Retour arrière | remonter d'un niveau | -| `g` | aller à la définition | -| `/` | recherche d'un bloc par nom | - -**Documentation (G)** : ` ```typr graph ` sur typr.github.io ajoute un bouton « voir le graphe » -qui ouvre le playground avec `?view=graph`. La variante ` ```typr graph focus=norm2 ` ouvre -directement dans un bloc. - ---- - -## 12. Étapes - -Chaque étape est livrable seule et a des critères d'acceptation vérifiables. - -**Étape 0 — Prérequis dans `typr-core`** — fait (2026-09-25) -- `HelpData.end` rempli par le parseur. `cargo test --workspace` et `typr case run` restent verts. -- `type_recorder` : `with_recording` renvoie une table non vide sur l'exemple fil rouge (§14) ; - les conflits de réécriture sont journalisés et analysés. -- Aucune régression de temps mesurable sur `typr check` quand l'enregistreur est désactivé. - -Notes d'implémentation (à connaître pour la suite) : -- Chaque fonction de parsing candidate (`fn foo(s: Span) -> IResult` ou - `IResult>` dans `elements.rs`/`mod.rs`, ~97 sites) est scindée en `foo` (fine - couche : appelle `foo_impl`, met `end` via `Lang::set_help_data_end`) + `foo_impl` (corps - original inchangé). Un nouveau parseur suivra ce même patron. -- `Lang::set_help_data_end` (dans `components/language/mod.rs`) fait pendant à `get_help_data` : - toute nouvelle variante de `Lang` doit être ajoutée aux deux matches. -- `type_recorder.rs` traite `Type::Failed` **et** `Type::UnknownFunction` comme des - placeholders (ni conflit, ni écrasement d'une valeur ferme) — découvert en step 0 sur - l'exemple fil rouge : le corps `Lines` d'une fonction est d'abord typé `UnknownFunction` - pendant la résolution de sa propre signature, avant d'être retypé pour de bon. -- Les `.bin` de la stdlib (`crates/typr-core/configs/bin/*.bin`) sont sérialisés en bincode - (positionnel, pas de champ nommé) : toute évolution de `HelpData`/`Type`/`Lang` exige de les - régénérer avec `typr std` puis un rebuild (`include_bytes!` les fige à la compilation), - sinon `VarType::load_r()...unwrap()` panique avec une erreur de désérialisation. -- 4 régressions `typr case run` (`0004/0005/0006/0008-was-declared-as-a-public-function`) et un - test flaky (`registry_validate::tests::formals_diff_flags_a_missing_export_and_an_arity_mismatch`, - fail seulement en parallèle) préexistent sur `develop` — vérifié en stashant ce travail ; - aucun lien avec ce chantier. - -**Étape 1 — Crate `typr-graph`, modèle et builder** — fait (2026-09-25) -- Blocs de l'étape 1 du §4, fils locaux, captures (§3.3), `Ref` avec `confidence`, `HasType`, - `TypePosition{0}`. -- Totalité : tout programme qui passe `typr check` produit un graphe sans panique (test sur tous - les `cases/` et exemples de la doc) ; variants non couverts → `Opaque`. -- Tests par instantané sur des programmes courts, dont l'exemple fil rouge. - -Notes d'implémentation (à connaître pour la suite) : -- `Lang::Operator{lhs, rhs}` a ses champs inversés par rapport à l'ordre syntaxique dans tout - `typr-core` (`Op::combine`, `to_module_helper`, les bras `dollar_access`/`dot_pipe_access` de - `processes/type_checking/mod.rs`) : `rhs` porte l'opérande *gauche*, `lhs` l'opérande *droite*. - `typr-graph` compense une fois, à l'unique endroit où `Lang::Operator` est déstructuré - (`crates/typr-graph/src/build/blocks.rs`). -- `typing()` sur `Lines`/`Scope` ne renvoie dans `TypeContext.lang` que la dernière instruction - réécrite (comme la valeur d'un bloc), pas le programme entier : le builder part donc du `Lang` - original (`parse_from_string`), pas de `result.type_context.lang`. -- `BlockGraph.blocks` est un `BTreeMap` (pas `HashMap`) pour un JSON déterministe d'un run à - l'autre — nécessaire pour que les instantanés `insta` soient reproductibles. -- Simplifications assumées, à revoir si un cas réel l'exige : une capture ne remonte que jusqu'à - la frontière la *plus proche* (pas de bubbling complet à travers des fonctions imbriquées, - §3.3) ; les fils (`wires`) ne sont enregistrés qu'entre un bloc composé et ses enfants directs, - pas au travers de plusieurs niveaux ; `Access` reçoit un corps (même vide) pour rester - cohérent avec les autres blocs à opérandes, contrairement à la case « Intérieur : — » du §4. - -**Étape 2 — CLI** -- `typr graph [--format json|dot] [--focus ] [--projection deps|types]`. -- DOT : un niveau (le bloc `--focus`) avec ses ports. - -**Étape 3 — Relations de types** — fait (2026-09-25) -- `Satisfies` avec `evidence`, `DeclaredAs`, `Subtype` depuis `Context.subtypes`. -- Nouveau module `crates/typr-graph/src/build/type_relations.rs`, appelé en post-passe (après le - parcours principal, avant `b.scope.pop()`) sur les `Lang::Alias` de haut niveau uniquement — - ces relations comparent des déclarations entre elles, pas un nœud et ses enfants, donc ça ne - rentre pas dans la récursion par nœud de `build_expr`. -- `Satisfies` : calculée avec le même primitif que le vérificateur de types - (`interface_satisfaction::check_interface_satisfaction`, déjà `pub` dans `typr-core`), pas - réimplémentée. L'`evidence` associe chaque méthode requise à son bloc fournisseur — trouvé via - `discover_methods` (nouvel helper `pub(super)`, extrait de `build_type_decl` pour être partagé), - ou, si la méthode est satisfaite structurellement (ex. un champ de record lu comme accesseur - trivial) sans fonction libre correspondante, l'`evidence` pointe vers le `TypeDecl` lui-même. -- `DeclaredAs` : détectée directement sur le `target_type` **tel qu'écrit** (pas réduit) d'un - alias — si c'est un `Type::Operator(Intersection, a, b)` dont un membre est une référence - nommée (`Type::Alias`) qui réduit vers `Type::Interface`, ça donne `DeclaredAs` vers cette - interface. Ne se déclenche que pour une interface *nommée* (`Point & Printable`), pas pour un - `interface { ... }` écrit en ligne dans l'intersection (rare, non couvert). -- `Subtype` : **simplification volontaire par rapport à la lettre du spec**. Le spec dit - « depuis `Context.subtypes` », mais `Context.subtypes` (`components/context/graph.rs`) est un - arbre construit par petits bouts à chaque `push_var_type`/`push_types`/`hoist_aliases` sur des - types **réduits et anonymes** (des `Type::Record`, pas des `Type::Alias` nommés) — remonter - jusqu'à un nom d'alias depuis là demanderait une correspondance inverse peu fiable, et les - associations `union member → alias` passent en réalité par un mécanisme séparé - (`subtype_cache`/`cache_subtype`, jamais vu par `get_supertypes`/`get_ordered_supertypes`) donc - un vrai parcours de l'arbre les aurait de toute façon manquées. À la place : pour chaque paire - de types déclarés dans le fichier (hors interfaces, déjà couvertes par `Satisfies`), - `target_type.is_subtype(&other.target_type, context)` — la même primitive publique - (`TypeSystem::is_subtype`) que le vérificateur de types utilise partout ailleurs, appliquée - directement aux types tels qu'écrits. Toujours « depuis » la même machinerie de sous-typage, - mais en interrogeant des paires de blocs réels du graphe plutôt qu'en essayant de parcourir - l'arbre interne et de faire correspondre le résultat après coup. Vérifié sur un cas structurel - simple (`list { name: char }` / `list { name: char, age: int }` → sous-typage par largeur). -- Tests unitaires dans `type_relations.rs` (un par relation) + le fil rouge (`walkthrough.rs`, - instantané mis à jour) exerce `Satisfies` de bout en bout, CLI comprise - (`typr graph --projection types`). `cargo test --workspace` et `typr case run` toujours verts - (mêmes régressions préexistantes). - -**Étape 4 — Playground** -- `typr-wasm::semantic_graph`, onglet Graph, navigation et gestes du §11, URL `view`/`focus`, - synchronisation Monaco dans les deux sens, `INTEGRATION.md` mis à jour. - -Notes d'implémentation (complément « Relations comme arêtes » du §11, `typr-playground.github.io` -uniquement, 2026-09-26) : -- Aucun changement de format JSON (§9) ni de `typr-graph` : tout ce qu'il faut pour distinguer les - trois catégories est déjà dans le graphe exporté. La règle « capture ⇔ port implicite » a été - vérifiée directement dans le builder (`crates/typr-graph/src/build/mod.rs::take_captures` ajoute - toujours `Port::implicit(name, ty)` **et** `Relation::r#ref(boundary, &name, target, …)` avec le - même nom de port) ; à l'inverse, un `Ref` sur `callee` (`build_apply`/`build_ufcs_apply`) ou sur - `value` (renommage `let x <- y`, `build_expr`) vient toujours d'un port explicite ou d'aucun port - du tout — jamais implicite. D'où `relationCategory` (`src/lib/graph.ts`) : `HasType` → catégorie - directe ; `TypePosition{index:0}` → catégorie directe (`index != 0` exclu, réservé à l'étape 7) ; - `Ref` → capture seulement si `blocks[from].inputs` contient `port` avec `implicit: true`. -- `relationEdgesInView` (`src/lib/graph.ts`) ne garde que les relations dont **les deux extrémités** - sont des clés de `view.blocks` — une `oneLevel()` peut contenir une relation dont un seul bout est - dans le focus courant (c'est déjà le cas aujourd'hui pour le panneau de détails, qui lit le graphe - complet), mais dessiner une arête exige un nœud aux deux bouts. -- `useElkLayout` (`src/components/graph/useElkLayout.ts`) prend un `activeCategories: Set<...>` en - plus de la vue, et ajoute les relations actives à la liste d'`edges` envoyée à ELK, à côté des - fils — en référençant directement l'id du **bloc** (`sources: ['val:norm2']`), pas un id de port, - puisque la cible d'une `HasType`/`TypePosition` (souvent un `TypeDecl` sans port d'entrée du tout, - §4) n'a pas de port à viser. Vérifié à la main avec `elkjs` (`elk.portConstraints: FIXED_POS` - coexiste sans erreur avec une arête nœud-à-nœud, et la déplace bien dans la mise en page — c'est - ce qui permet à l'activation d'une catégorie de réarranger le graphe, pas seulement de colorer une - ligne fixe). La clé de mémoïsation de l'effet (`viewKey`) inclut désormais les catégories actives, - triées, pour redéclencher la mise en page à chaque bascule. -- `BlockNode` (`src/components/graph/BlockNode.tsx`) gagne une paire de handles génériques - `__rel-in`/`__rel-out` (`Position.Top`, invisibles par défaut comme `.handle-shadow`) : les - relations n'étant pas des ports du modèle (§3.2), elles ne peuvent pas s'accrocher à un handle de - port existant, qui peut d'ailleurs ne pas exister côté cible. React Flow dessine toujours la ligne - lui-même à partir de ces handles (comme pour les fils) ; seules les positions des nœuds viennent - d'ELK, jamais le tracé de l'arête (déjà vrai avant ce complément). -- Couleur portée par `style.stroke` (variable CSS themée clair/dark, `--edge-capture`/ - `--edge-type-position`/`--edge-has-type` dans `theme.css`) ; pointe de flèche (`markerEnd`, - `MarkerType.ArrowClosed`) en teinte fixe par catégorie plutôt que themée — un `fill` de marqueur - React Flow est posé en attribut SVG direct, pas via `style`, et une teinte moyenne (violet/bleu - ciel/magenta) reste lisible sur fond clair et sombre sans en dépendre. Capture en trait pointillé - (`strokeDasharray`) en plus de sa couleur, pour rester repérable même en daltonisme. -- Légende à cases à cocher : nouveau composant `RelationLegend.tsx`, état local dans `GraphView` - (`activeRelationKinds`, toutes désactivées par défaut) — délibérément **hors** de l'état d'URL - partagé (`view`/`focus`), même choix que la référence du Diff (étape 6) : c'est une préférence - d'affichage de session, pas une coordonnée du graphe qu'un lien partagé doit reproduire. -- Vérifié : `tsc -b`, `npm run lint`, et un script Node ad hoc (esbuild bundle de `src/lib/graph.ts`, - sans mock) rejouant `relationCategory`/`relationEdgesInView` sur le vrai JSON produit par - `typr graph --format json` pour l'exemple fil rouge (§14). Au niveau `Program` : `capture` ne - retient que `norm2 → sq` (le `Ref` sur le port implicite `sq`), pas les deux `Ref` sur `callee` - ni celui de `val:d → type:Printable` (port explicite, validateur à la compilation) — la - distinction capture/référence explicite fonctionne. `typePosition0` retient `show → Point` - **et** `norm2 → Point` (son propre premier paramètre est aussi de type `Point`, indépendamment - de savoir si `norm2` sert de méthode — cohérent avec la définition structurelle du §5.1, pas une - liste des seules méthodes découvertes). `hasType` retient `p → Point` et `d → Point` — ce dernier - diverge du texte du §14 (qui attendait `d → Printable`) : la sortie réelle du compilateur pour - ce `HasType` a changé depuis la rédaction du §14 et le paragraphe n'a pas suivi ; à corriger - séparément, hors périmètre de ce complément (le rendu n'est pas en cause, il reflète fidèlement - la relation telle qu'exportée). Redescendre dans `norm2` (vue à un niveau qui ne contient plus - `type:Point`) fait disparaître les trois catégories, conformément à la contrainte du §11. - `npm run dev` + un rendu manuel dans Chrome a confirmé la partie purement visuelle (couleurs, - flèche, bascule de la légende, réarrangement ELK à l'activation). - -**Étape 5 — Documentation et blocs restants** — fait (2026-09-26) -- ` ```typr graph ` sur typr.github.io (dépôts mis à jour ensemble). — fait. -- `Loop` (ports d'état), `Match`, `Module` (sorties `@pub`), `RCode`. — fait. - -Notes d'implémentation (partie doc, branche `graph-fence-block` dans les deux dépôts, pas encore -mergée) : -- `typr.github.io` : `src/playground/meta.tsx` lit `graph`/`focus=` dans la metastring de la - fence (même mécanisme que `autorun`/`noplayground`/`compile_fail`, § déjà en place à l'étape 4) ; - `src/playground/url.ts` ajoute `view`/`focus` à `buildPlaygroundUrl` ; `PlaygroundButton` change - d'icône et d'infobulle en mode `graph` et ignore `autorun` (même traitement que pour - `compile_fail`). `focus=` sans `:` est sous-entendu `val:` — évite d'épeler le - namespace dans le cas courant, `focus=type:Point` reste possible pour un type. Exemple réel - ajouté dans `docs/reference/modules.md` (juste après le passage qui explique `@pub`, l'endroit - où la frontière module se voit le mieux visuellement). -- `typr-playground.github.io` : aucun changement de code — `view=graph`/`focus=` existaient déjà - depuis l'étape 4, seul `INTEGRATION.md` documente qu'ils sont maintenant aussi produits par un - mot-clé de fence, pas seulement par la navigation interne du playground. -- Vérifié : `tsc` (typecheck), `docusaurus build` complet, `check-typr-blocks.mjs` (le nouveau bloc - passe `typr check` comme les autres), et le DOM réellement rendu de la page modules (Chrome - headless, pas seulement le build) pour confirmer que les deux boutons (normal et graph) portent - le bon `href`/`title`. - -Notes d'implémentation (blocs restants) : -- `Loop` (`ForLoop`/`WhileLoop`/`Loop`) : le port d'entrée d'un `ForLoop` porte le nom de sa - propre variable de boucle (même convention qu'un paramètre de `Function` — le port *est* la - liaison locale) ; `WhileLoop` a un port `cond` (même convention que `If`) ; `Loop` (boucle - infinie) n'a ni l'un ni l'autre. Les ports d'état (Q3) sont détectés par un balayage superficiel - des instructions *directement* dans le corps de la boucle à la recherche d'`Assign` dont la - cible est déjà liée à l'extérieur — même simplification assumée que les captures (pas de - traversée dans un `if` imbriqué dans la boucle, § scope.rs). Un fil `Wire` peut désormais pointer - vers un port de **sortie** du bloc propriétaire (`{name}_out`), ce qui n'avait pas de précédent - explicite avant cette étape mais est cohérent avec le renderer du playground (qui résout déjà le - côté WEST/EAST d'une extrémité par lookup, pas par position, cf. notes étape 4). -- Prérequis découvert en cours de route : les ports d'état dépendaient d'un mécanisme qui - n'existait pas encore — un `Assign` nu (`x <- expr;`, hors boucle aussi bien que dedans) ne - rebindait pas `x` dans le scope du builder (`build_nested_body`/`build_top_level_item` ne - traitaient que `Let`). Corrigé pour les deux (spec §4.1 « Assign hors boucle », qui n'avait pas - d'étape propre mais dont Loop dépend directement) : un `Assign` est désormais keyé anonymement - (le nom est déjà pris par la déclaration d'origine) mais rebind le nom, comme un `Let`. -- `Match` : question ouverte du §13 tranchée — un sous-bloc par bras (`BlockKind::Scope`, pas de - nouveau `BlockKind` dédié), dont les noms liés par le motif (mêmes formes que - `match_expression::build_match_branch_context` côté vérificateur de types : `Tag`, `TypePattern`, - `Tuple`, `List`, variable catch-all) deviennent ses propres ports d'entrée (implicites, sans fil - tracé depuis la cible — le motif lui-même n'est pas rendu comme bloc). -- `Module` : `module Name { ... }` n'était même pas nommable dans le graphe avant cette étape - (`top_level_name` ne connaissait que `Let`/`Alias`) — corrigé. Frontière de capture comme - `Function`, pré-passe à deux temps comme la racine du programme (§7.1 étape 2) pour que les - membres se référencent mutuellement. Seuls les membres `@pub` ont un port de sortie ; tous les - membres (publics et privés) restent des enfants du corps — la confidentialité tient uniquement à - l'absence de port, jamais à l'absence du bloc. -- `RCode` (`RBlock`/`RFunction`/`ExternBlock`) : implémentation triviale, identique à l'ancien - fallback `Opaque` à l'étiquette de `kind` près — aucune entrée dérivée, le texte R n'est jamais - reparsé (frontière opaque, cohérent avec le spec §4). -- Bug pré-existant découvert (hors scope, non corrigé) : une fonction `let`-liée **imbriquée** dans - le corps d'une autre fonction (ou d'un module) perd son nom de fichier dans son `HelpData` - (`span.file` vide) — reproductible sans aucun rapport avec ce chantier - (`let outer <- fn(a: int): int { let inner <- fn(b: int): int { b + 1 }; inner(a) };`). -- Bug pré-existant découvert (hors scope, non corrigé) : le parseur de `while (cond) { ... }` ne - digère pas une condition qui est elle-même un opérateur binaire nu (`while (i < n) { ... }` - échoue avec « Unknown element `{` », alors que `while ((i < n)) { ... }` avec une parenthèse - surnuméraire fonctionne) ; de même `for i in expr { ... }` sans les parenthèses autour de - `i in expr` échoue entièrement (la forme correcte est `for (i in expr) { ... }`, jamais - documentée nulle part ni testée dans `typr-core` — aucun test de parsing n'existe pour - `while_loop`/`for_loop`/`loop_loop`). Contourné dans les tests de `typr-graph` avec la syntaxe - qui fonctionne ; à signaler si quelqu'un écrit un jour un `.typr` avec ces formes naturelles. - -**Étape 6 — Revue** — fait (2026-09-26) -- `diff(G_old, G_new)` apparié par `BlockKey` : blocs ajoutés, supprimés, modifiés (type, - interface, captures) ; `typr graph diff ` ; vue diff dans le playground (deux - extraits de code). - -Notes d'implémentation : -- `crates/typr-graph/src/diff.rs` : portée délibérément au niveau bloc, alignée sur la lettre du - spec (« type, interface, captures », pas « chaque relation ») — le diff des fils et des `Ref` - reste hors périmètre. Les captures comparées sont les ports d'entrée *implicites* seulement - (`Port.implicit`) ; un paramètre explicite renommé n'est pas un changement de capture. L' - « interface » d'un bloc, c'est ses cibles `Satisfies`/`DeclaredAs` (le contrat déclaré), pas sa - liste de relations complète. Un bloc dont seul le `body` change (nouvelle instruction dans un - `Scope`) n'est **pas** rapporté modifié à sa propre clé : ça remonte comme des blocs enfants - ajoutés/supprimés à la place — cohérent avec l'identité par `BlockKey` (§6), qui appairent déjà - ces enfants indépendamment. -- CLI : `typr graph diff [--format text|json]`, sous-commande de `typr graph` - (`GraphCommands::Diff` dans `crates/typr-cli/src/cli.rs`) — `typr graph ` reste la forme - historique (pas de sous-commande). Chaque fichier est construit indépendamment par le même - pipeline que `typr graph`, sans lien de parenté git requis. -- WASM (`crates/typr-wasm/src/lib.rs::semantic_graph_diff`, export `semanticGraphDiff`) : les - deux côtés sont type-checkés même si un seul échoue, pour que l'appelant voie toutes les - erreurs (préfixées `(old)`/`(new)`) en un coup, pas juste la première. `old_graph_json` et - `new_graph_json` accompagnent `diff_json` — le diff ne porte que des clés, pas les blocs - complets, et le playground a besoin des deux graphes : le nouveau pour le rendu (mise en page), - l'ancien pour afficher ce qu'était un bloc supprimé (kind/name), absent du nouveau. -- Playground (`typr-playground.github.io`, branche `graph-fence-block`) : nouvel onglet « Diff », - état local (`baseline`) délibérément **hors** du contrat d'URL partagé (`useGraphNav`) — une - référence est un fichier source entier, pas une clé de bloc, donc ça n'a pas sa place dans un - lien partageable comme `view`/`focus`. Première visite de l'onglet : le code courant devient la - référence (`baseline ?? code`) ; seul un clic explicite sur « Marquer le code actuel comme - référence » la change ensuite — rouvrir l'onglet ne déplace jamais silencieusement la - référence. Rendu : le diff est superposé à la mise en page du **nouveau** graphe - (`GraphView` reçoit `diff`, colore les nœuds ajoutés/modifiés) ; les blocs supprimés n'ont pas - de nœud où s'accrocher (absents du nouveau graphe) donc listés à part dans `DiffSummary`. Le - panneau de détails affiche type/captures/interface avant/après pour un bloc modifié sélectionné. - -**Étape 7 — Ensuite** -- Navigation clavier, dépliage sur place, `TypePosition` pour les autres positions et le retour, - génériques, métriques et cycles, et au besoin l'enregistrement des cibles d'appel exactes - (approche C-ii). - ---- - -## 13. Questions ouvertes (à traiter quand le besoin se présente) - -- **F — Plusieurs fichiers** : proposition par défaut, le playground reste mono-fichier et la CLI - gère `module`/`use`/`import` multi-fichiers. À confirmer à l'étape 5. -- **Match** : un sous-bloc par bras, dont les liaisons du motif sont les entrées ? Comment - représenter le motif lui-même ? -- **Génériques** : un bloc `Function` générique et ses instanciations (`Instantiates`) — une - instance est-elle un bloc à part ou une annotation de l'`Apply` ? -- **Opérateurs résolus vers une fonction utilisateur** : `Operator` avec une `Ref`, ou `Apply` ? -- **Spreads de records** (`...x`, `..x`) : ports d'entrée spéciaux ? -- **Embedding** (`embed field: Type`) : méthodes héritées affichées comme sorties du `TypeDecl` - avec une justification « transmise par `field` » ? -- **Tests** (`Test`, `TestBlock`) : dans le graphe principal ou dans une vue séparée ? -- **Types en tant que blocs** : un `TypeExpr` anonyme partagé (`int`) est-il un seul bloc - canonique ou un bloc par occurrence ? - ---- - -## 14. Exemple fil rouge - -Vérifié avec `typr check` : - -```typr -type Printable <- interface { show: (Self) -> char }; -type Point <- list { x: int, y: int }; - -let sq <- fn(n: int): int { n * n }; - -let norm2 <- fn(p: Point): int { - let a <- sq(p$x); - a + sq(p$y) -}; - -let show <- fn(p: Point): char { "Point" }; - -let p <- Point:{ x = 3, y = 4 }; -let d <- Printable(p); -let total <- norm2(p) + 12 + 3; -``` - -Graphe attendu, dans les grandes lignes : - -``` -Program -├── type:Printable Interface intérieur : show: (Self) -> char -├── type:Point TypeDecl intérieur : x: int, y: int -│ sorties (méthodes) : show ← TypePosition{0} depuis val:show(Point) -│ ──Satisfies──► type:Printable evidence: show ← val:show(Point) (étape 3) -├── val:sq Function entrées : n: int sortie : int -│ intérieur : Operator(*) ← n, n -├── val:norm2 Function entrées : p: Point, sq (implicite) sortie : int -│ intérieur : a = Apply(callee←sq, arg0←Access(x)←p) -│ #1 = Operator(+)(lhs←a, rhs←Apply(sq, Access(y)←p)) -│ ──Ref(exact)──► val:sq -├── val:show(Point) Function entrées : p: Point sortie : char -├── val:p Record entrées : x←3, y←4 sorties : valeur, x, y HasType → type:Point -├── val:d Apply callee : Printable (validateur à la compilation) HasType → type:Printable -└── val:total Operator(+) lhs ← Operator(+)(Apply(norm2, p), 12), rhs ← 3 -``` From 8bf3c666979292d42495f8e3c3745b9ee657aa7e Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 10:40:57 +0200 Subject: [PATCH 35/52] refined type implementation --- .../case.toml | 7 + .../expect.md | 20 + .../expect.toml | 7 + .../observed.txt | 12 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 3 + .../case.toml | 7 + .../expect.md | 8 + .../expect.toml | 16 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 8 + .../case.toml | 7 + .../expect.md | 6 + .../expect.toml | 15 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 8 + .../case.toml | 7 + .../expect.md | 8 + .../expect.toml | 29 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 12 + .../case.toml | 7 + .../expect.md | 10 + .../expect.toml | 20 + .../golden/R/main.R | 25 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/TypR/main.ty | 6 + .../case.toml | 7 + .../expect.md | 8 + .../expect.toml | 21 + .../golden/R/main.R | 39 ++ .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/TypR/main.ty | 13 + cases/0079-refined-static-errors/case.toml | 7 + cases/0079-refined-static-errors/expect.md | 12 + cases/0079-refined-static-errors/expect.toml | 22 + cases/0079-refined-static-errors/observed.txt | 1 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 5 + .../case.toml | 7 + .../expect.md | 11 + .../expect.toml | 26 + .../observed.txt | 1 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 11 + .../case.toml | 7 + .../expect.md | 7 + .../expect.toml | 8 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 6 + .../case.toml | 7 + .../expect.md | 6 + .../expect.toml | 25 + .../golden/R/main.R | 81 +++ .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 13 + .../0083-narrowing-stays-in-branch/case.toml | 7 + .../0083-narrowing-stays-in-branch/expect.md | 4 + .../expect.toml | 9 + .../golden/R/main.R | 40 ++ .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 8 + cases/0084-refined-length-range/case.toml | 7 + cases/0084-refined-length-range/expect.md | 11 + cases/0084-refined-length-range/expect.toml | 17 + .../0084-refined-length-range/golden/R/main.R | 62 ++ .../repro/DESCRIPTION | 5 + .../0084-refined-length-range/repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 9 + .../case.toml | 7 + .../expect.md | 9 + .../expect.toml | 11 + .../golden/R/main.R | 44 ++ .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/TypR/main.ty | 7 + crates/typr-cli/configs/src/std.R | 35 ++ .../typr-core/src/components/context/mod.rs | 66 ++ .../components/error_message/type_error.rs | 28 + crates/typr-core/src/components/syntax/mod.rs | 10 + crates/typr-core/src/components/type/mod.rs | 91 +++ .../src/components/type/refinement.rs | 570 ++++++++++++++++++ .../src/components/type/type_printer.rs | 2 + .../typr-core/src/processes/parsing/types.rs | 152 +++-- .../src/processes/transpiling/mod.rs | 30 +- .../transpiling/refinement_checks.rs | 98 +++ .../src/processes/type_checking/function.rs | 4 +- .../type_checking/function_application.rs | 180 +++++- .../processes/type_checking/let_expression.rs | 30 +- .../src/processes/type_checking/mod.rs | 145 ++++- .../src/processes/type_checking/narrowing.rs | 198 ++++++ .../type_checking/refinement_check.rs | 211 +++++++ .../type_checking/type_arithmetic.rs | 273 +++++++++ .../type_checking/type_comparison.rs | 15 +- .../processes/type_checking/type_context.rs | 7 +- .../processes/type_checking/unification.rs | 9 + crates/typr-mcp/data/std.R | 35 ++ editors/vim/syntax/typr.vim | 2 + editors/vscode/syntaxes/typr.tmLanguage.json | 12 + 120 files changed, 3058 insertions(+), 74 deletions(-) create mode 100644 cases/0073-refined-type-property-panics/case.toml create mode 100644 cases/0073-refined-type-property-panics/expect.md create mode 100644 cases/0073-refined-type-property-panics/expect.toml create mode 100644 cases/0073-refined-type-property-panics/observed.txt create mode 100644 cases/0073-refined-type-property-panics/repro/DESCRIPTION create mode 100644 cases/0073-refined-type-property-panics/repro/NAMESPACE create mode 100644 cases/0073-refined-type-property-panics/repro/R/.gitkeep create mode 100644 cases/0073-refined-type-property-panics/repro/TypR/main.ty create mode 100644 cases/0074-refined-let-runtime-obligation/case.toml create mode 100644 cases/0074-refined-let-runtime-obligation/expect.md create mode 100644 cases/0074-refined-let-runtime-obligation/expect.toml create mode 100644 cases/0074-refined-let-runtime-obligation/repro/DESCRIPTION create mode 100644 cases/0074-refined-let-runtime-obligation/repro/NAMESPACE create mode 100644 cases/0074-refined-let-runtime-obligation/repro/R/.gitkeep create mode 100644 cases/0074-refined-let-runtime-obligation/repro/TypR/main.ty create mode 100644 cases/0075-refined-argument-runtime-obligation/case.toml create mode 100644 cases/0075-refined-argument-runtime-obligation/expect.md create mode 100644 cases/0075-refined-argument-runtime-obligation/expect.toml create mode 100644 cases/0075-refined-argument-runtime-obligation/repro/DESCRIPTION create mode 100644 cases/0075-refined-argument-runtime-obligation/repro/NAMESPACE create mode 100644 cases/0075-refined-argument-runtime-obligation/repro/R/.gitkeep create mode 100644 cases/0075-refined-argument-runtime-obligation/repro/TypR/main.ty create mode 100644 cases/0076-refined-return-runtime-obligation/case.toml create mode 100644 cases/0076-refined-return-runtime-obligation/expect.md create mode 100644 cases/0076-refined-return-runtime-obligation/expect.toml create mode 100644 cases/0076-refined-return-runtime-obligation/repro/DESCRIPTION create mode 100644 cases/0076-refined-return-runtime-obligation/repro/NAMESPACE create mode 100644 cases/0076-refined-return-runtime-obligation/repro/R/.gitkeep create mode 100644 cases/0076-refined-return-runtime-obligation/repro/TypR/main.ty create mode 100644 cases/0077-refined-propagation-vector-index/case.toml create mode 100644 cases/0077-refined-propagation-vector-index/expect.md create mode 100644 cases/0077-refined-propagation-vector-index/expect.toml create mode 100644 cases/0077-refined-propagation-vector-index/golden/R/main.R create mode 100644 cases/0077-refined-propagation-vector-index/repro/DESCRIPTION create mode 100644 cases/0077-refined-propagation-vector-index/repro/NAMESPACE create mode 100644 cases/0077-refined-propagation-vector-index/repro/TypR/main.ty create mode 100644 cases/0078-refined-propagation-primitive-effects/case.toml create mode 100644 cases/0078-refined-propagation-primitive-effects/expect.md create mode 100644 cases/0078-refined-propagation-primitive-effects/expect.toml create mode 100644 cases/0078-refined-propagation-primitive-effects/golden/R/main.R create mode 100644 cases/0078-refined-propagation-primitive-effects/repro/DESCRIPTION create mode 100644 cases/0078-refined-propagation-primitive-effects/repro/NAMESPACE create mode 100644 cases/0078-refined-propagation-primitive-effects/repro/TypR/main.ty create mode 100644 cases/0079-refined-static-errors/case.toml create mode 100644 cases/0079-refined-static-errors/expect.md create mode 100644 cases/0079-refined-static-errors/expect.toml create mode 100644 cases/0079-refined-static-errors/observed.txt create mode 100644 cases/0079-refined-static-errors/repro/DESCRIPTION create mode 100644 cases/0079-refined-static-errors/repro/NAMESPACE create mode 100644 cases/0079-refined-static-errors/repro/R/.gitkeep create mode 100644 cases/0079-refined-static-errors/repro/TypR/main.ty create mode 100644 cases/0080-refined-record-literal-fields/case.toml create mode 100644 cases/0080-refined-record-literal-fields/expect.md create mode 100644 cases/0080-refined-record-literal-fields/expect.toml create mode 100644 cases/0080-refined-record-literal-fields/observed.txt create mode 100644 cases/0080-refined-record-literal-fields/repro/DESCRIPTION create mode 100644 cases/0080-refined-record-literal-fields/repro/NAMESPACE create mode 100644 cases/0080-refined-record-literal-fields/repro/R/.gitkeep create mode 100644 cases/0080-refined-record-literal-fields/repro/TypR/main.ty create mode 100644 cases/0081-fixed-length-argument-mismatch/case.toml create mode 100644 cases/0081-fixed-length-argument-mismatch/expect.md create mode 100644 cases/0081-fixed-length-argument-mismatch/expect.toml create mode 100644 cases/0081-fixed-length-argument-mismatch/repro/DESCRIPTION create mode 100644 cases/0081-fixed-length-argument-mismatch/repro/NAMESPACE create mode 100644 cases/0081-fixed-length-argument-mismatch/repro/R/.gitkeep create mode 100644 cases/0081-fixed-length-argument-mismatch/repro/TypR/main.ty create mode 100644 cases/0082-narrowing-drops-runtime-check/case.toml create mode 100644 cases/0082-narrowing-drops-runtime-check/expect.md create mode 100644 cases/0082-narrowing-drops-runtime-check/expect.toml create mode 100644 cases/0082-narrowing-drops-runtime-check/golden/R/main.R create mode 100644 cases/0082-narrowing-drops-runtime-check/repro/DESCRIPTION create mode 100644 cases/0082-narrowing-drops-runtime-check/repro/NAMESPACE create mode 100644 cases/0082-narrowing-drops-runtime-check/repro/R/.gitkeep create mode 100644 cases/0082-narrowing-drops-runtime-check/repro/TypR/main.ty create mode 100644 cases/0083-narrowing-stays-in-branch/case.toml create mode 100644 cases/0083-narrowing-stays-in-branch/expect.md create mode 100644 cases/0083-narrowing-stays-in-branch/expect.toml create mode 100644 cases/0083-narrowing-stays-in-branch/golden/R/main.R create mode 100644 cases/0083-narrowing-stays-in-branch/repro/DESCRIPTION create mode 100644 cases/0083-narrowing-stays-in-branch/repro/NAMESPACE create mode 100644 cases/0083-narrowing-stays-in-branch/repro/R/.gitkeep create mode 100644 cases/0083-narrowing-stays-in-branch/repro/TypR/main.ty create mode 100644 cases/0084-refined-length-range/case.toml create mode 100644 cases/0084-refined-length-range/expect.md create mode 100644 cases/0084-refined-length-range/expect.toml create mode 100644 cases/0084-refined-length-range/golden/R/main.R create mode 100644 cases/0084-refined-length-range/repro/DESCRIPTION create mode 100644 cases/0084-refined-length-range/repro/NAMESPACE create mode 100644 cases/0084-refined-length-range/repro/R/.gitkeep create mode 100644 cases/0084-refined-length-range/repro/TypR/main.ty create mode 100644 cases/0085-refined-generic-length-range/case.toml create mode 100644 cases/0085-refined-generic-length-range/expect.md create mode 100644 cases/0085-refined-generic-length-range/expect.toml create mode 100644 cases/0085-refined-generic-length-range/golden/R/main.R create mode 100644 cases/0085-refined-generic-length-range/repro/DESCRIPTION create mode 100644 cases/0085-refined-generic-length-range/repro/NAMESPACE create mode 100644 cases/0085-refined-generic-length-range/repro/TypR/main.ty create mode 100644 crates/typr-core/src/components/type/refinement.rs create mode 100644 crates/typr-core/src/processes/transpiling/refinement_checks.rs create mode 100644 crates/typr-core/src/processes/type_checking/narrowing.rs create mode 100644 crates/typr-core/src/processes/type_checking/refinement_check.rs diff --git a/cases/0073-refined-type-property-panics/case.toml b/cases/0073-refined-type-property-panics/case.toml new file mode 100644 index 0000000..2ac66d8 --- /dev/null +++ b/cases/0073-refined-type-property-panics/case.toml @@ -0,0 +1,7 @@ +title = "`int & (> 0)` (a refined type) aborts the parser instead of type-checking" +cmd = "check" +layer = "parse" +status = "fixed" +created = "2026-09-29" +origin = "perso" +checked = false diff --git a/cases/0073-refined-type-property-panics/expect.md b/cases/0073-refined-type-property-panics/expect.md new file mode 100644 index 0000000..fceb797 --- /dev/null +++ b/cases/0073-refined-type-property-panics/expect.md @@ -0,0 +1,20 @@ +# refined-type-property-panics + +## Ce qui devrait se passer + +`int & (> 0)` est un type raffiné (`refined_types.md`, `refined_types_plan.md` Phase 2) : la base +`int` intersectée avec la propriété de valeur `(> 0)`. `let x: int & (> 0) <- 3;` doit passer +`typr check`, sans panic. + +## Anomalies + +`observed.txt` : `exit=101`. Deux causes empilées dans le parseur de types : + +1. `>` est lu comme un `Op` par `index_algebra`, et `compute_operators` finissait sur + `_ => panic!()` (`parsing/types.rs`). Corrigé : la fonction est maintenant faillible et + `index_chain` échoue proprement, ce qui laisse `alt` essayer autre chose. +2. Rien ne sait encore lire `(> 0)` comme propriété : `ltype` obtient une suite de jetons + invalide et `PriorityTokens::run_helper` lance `panic_any(TypeError::WrongExpression)` + (`operation_priority.rs`). C'est le canal général des erreurs de syntaxe de type + (`let x: int & ;` panique de la même façon) ; le cas se ferme quand la Phase 2 ajoute le + parseur `refinement_property`. diff --git a/cases/0073-refined-type-property-panics/expect.toml b/cases/0073-refined-type-property-panics/expect.toml new file mode 100644 index 0000000..618f488 --- /dev/null +++ b/cases/0073-refined-type-property-panics/expect.toml @@ -0,0 +1,7 @@ +[[rule]] +file = "@run" +must_not_contain = "panicked" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" diff --git a/cases/0073-refined-type-property-panics/observed.txt b/cases/0073-refined-type-property-panics/observed.txt new file mode 100644 index 0000000..c0b2839 --- /dev/null +++ b/cases/0073-refined-type-property-panics/observed.txt @@ -0,0 +1,12 @@ +# exit=101 + +## stdout/stderr + + Parsing... +thread 'main' (268307) panicked at crates/typr-core/src/processes/parsing/types.rs:974:14: +explicit panic +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +## R généré + diff --git a/cases/0073-refined-type-property-panics/repro/DESCRIPTION b/cases/0073-refined-type-property-panics/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0073-refined-type-property-panics/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0073-refined-type-property-panics/repro/NAMESPACE b/cases/0073-refined-type-property-panics/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0073-refined-type-property-panics/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0073-refined-type-property-panics/repro/R/.gitkeep b/cases/0073-refined-type-property-panics/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0073-refined-type-property-panics/repro/TypR/main.ty b/cases/0073-refined-type-property-panics/repro/TypR/main.ty new file mode 100644 index 0000000..4848d3b --- /dev/null +++ b/cases/0073-refined-type-property-panics/repro/TypR/main.ty @@ -0,0 +1,3 @@ +# `int & (> 0)`: a base type refined by a value property (refined_types.md). +# Positive integers must type-check; the compiler must never abort on this. +let x: int & (> 0) <- 3; diff --git a/cases/0074-refined-let-runtime-obligation/case.toml b/cases/0074-refined-let-runtime-obligation/case.toml new file mode 100644 index 0000000..beb075f --- /dev/null +++ b/cases/0074-refined-let-runtime-obligation/case.toml @@ -0,0 +1,7 @@ +title = "Refined `let` annotation: unproven refinement becomes a runtime check, proven one emits none" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0074-refined-let-runtime-obligation/expect.md b/cases/0074-refined-let-runtime-obligation/expect.md new file mode 100644 index 0000000..0475ab4 --- /dev/null +++ b/cases/0074-refined-let-runtime-obligation/expect.md @@ -0,0 +1,8 @@ +# 0074 — refined `let`: boundary check (refined_types_plan.md, Phase 5/6) + +`let a: [3, int] <- v` with `v: [int]` used to be a static type error. `is_subtype` still says +`false` (it only answers `true` for a *proven* refinement), but `coerce_to` +(`type_checking/refinement_check.rs`) sees that the base types agree and the refinements are +compatible, so `let_expression` records an obligation in `Context::refinement_obligations` and +the transpiler wraps the initialiser in `typr_refine_length(...)` / `typr_refine_value(...)` +(prelude `std.R`). A literal or an already-refined value that proves the property gets no check. diff --git a/cases/0074-refined-let-runtime-obligation/expect.toml b/cases/0074-refined-let-runtime-obligation/expect.toml new file mode 100644 index 0000000..6c1dd38 --- /dev/null +++ b/cases/0074-refined-let-runtime-obligation/expect.toml @@ -0,0 +1,16 @@ +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(v, 3L" + +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_value(n, 0, TRUE, Inf, FALSE" + +# Proven by the type: a check here would be noise for the R reader. +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_value(3" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(c(" diff --git a/cases/0074-refined-let-runtime-obligation/repro/DESCRIPTION b/cases/0074-refined-let-runtime-obligation/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0074-refined-let-runtime-obligation/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0074-refined-let-runtime-obligation/repro/NAMESPACE b/cases/0074-refined-let-runtime-obligation/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0074-refined-let-runtime-obligation/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0074-refined-let-runtime-obligation/repro/R/.gitkeep b/cases/0074-refined-let-runtime-obligation/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0074-refined-let-runtime-obligation/repro/TypR/main.ty b/cases/0074-refined-let-runtime-obligation/repro/TypR/main.ty new file mode 100644 index 0000000..5d73d0e --- /dev/null +++ b/cases/0074-refined-let-runtime-obligation/repro/TypR/main.ty @@ -0,0 +1,8 @@ +# `let x: T <- e` where `e`'s refinement cannot be proven: accepted, and checked at run time. +let v: [int] <- [1, 2, 3]; +let a: [3, int] <- v; +let n: int <- 5; +let p: int & (> 0) <- n; +# Proven statically: no check may be emitted for these. +let b: [3, int] <- [4, 5, 6]; +let q: int & (> 0) <- 3; diff --git a/cases/0075-refined-argument-runtime-obligation/case.toml b/cases/0075-refined-argument-runtime-obligation/case.toml new file mode 100644 index 0000000..6422581 --- /dev/null +++ b/cases/0075-refined-argument-runtime-obligation/case.toml @@ -0,0 +1,7 @@ +title = "Refined parameter: unproven argument checked at the call site, proven one is not" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0075-refined-argument-runtime-obligation/expect.md b/cases/0075-refined-argument-runtime-obligation/expect.md new file mode 100644 index 0000000..4eaada1 --- /dev/null +++ b/cases/0075-refined-argument-runtime-obligation/expect.md @@ -0,0 +1,6 @@ +# 0075 — refined argument: caller-side check (refined_types_plan.md, Phase 5, §37) + +No signature accepts `[num]` for `[2, num]` strictly, so `apply_from_variable_inner` retries with +`relax_refined_arguments`: an argument that only fails to *prove* a refinement stands in as the +parameter type, and the residual is recorded as an obligation on the argument expression. The +transpiler wraps that argument. Arguments proven by their type (literals) get no check. diff --git a/cases/0075-refined-argument-runtime-obligation/expect.toml b/cases/0075-refined-argument-runtime-obligation/expect.toml new file mode 100644 index 0000000..167a53e --- /dev/null +++ b/cases/0075-refined-argument-runtime-obligation/expect.toml @@ -0,0 +1,15 @@ +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(u, 2L" + +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_value(n, 0, TRUE, Inf, FALSE" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_value(3" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(c(" diff --git a/cases/0075-refined-argument-runtime-obligation/repro/DESCRIPTION b/cases/0075-refined-argument-runtime-obligation/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0075-refined-argument-runtime-obligation/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0075-refined-argument-runtime-obligation/repro/NAMESPACE b/cases/0075-refined-argument-runtime-obligation/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0075-refined-argument-runtime-obligation/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0075-refined-argument-runtime-obligation/repro/R/.gitkeep b/cases/0075-refined-argument-runtime-obligation/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0075-refined-argument-runtime-obligation/repro/TypR/main.ty b/cases/0075-refined-argument-runtime-obligation/repro/TypR/main.ty new file mode 100644 index 0000000..3da20a2 --- /dev/null +++ b/cases/0075-refined-argument-runtime-obligation/repro/TypR/main.ty @@ -0,0 +1,8 @@ +# Call arguments: an unproven refinement is checked by the caller, a proven one is not. +let dist <- fn(p: [2, num], q: [2, num]): num { 0.0 }; +let pos <- fn(x: int & (> 0)): int { x }; +let n: int <- 5; +let u: [num] <- [1.0, 2.0]; +dist(u, [3.0, 4.0]); +pos(n); +pos(3); diff --git a/cases/0076-refined-return-runtime-obligation/case.toml b/cases/0076-refined-return-runtime-obligation/case.toml new file mode 100644 index 0000000..9248e24 --- /dev/null +++ b/cases/0076-refined-return-runtime-obligation/case.toml @@ -0,0 +1,7 @@ +title = "Refined return type: unproven returned values checked, proven ones not" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0076-refined-return-runtime-obligation/expect.md b/cases/0076-refined-return-runtime-obligation/expect.md new file mode 100644 index 0000000..2a609af --- /dev/null +++ b/cases/0076-refined-return-runtime-obligation/expect.md @@ -0,0 +1,8 @@ +# 0076 — refined return type (refined_types_plan.md, Phase 5) + +`Context::return_position` marks the expressions whose value is the function's result: the +trailing expression, both `if` branches, the argument of `return`. `typing()` checks such a value +against `expected_return_type` with `coerce_to`; an unproven-but-compatible value records an +obligation on that expression, so an early `return` or one branch is checked on its own and a +proven branch is not. Obligations survive the sub-contexts of `if` and `function()` through +`Context::absorb_obligations`. diff --git a/cases/0076-refined-return-runtime-obligation/expect.toml b/cases/0076-refined-return-runtime-obligation/expect.toml new file mode 100644 index 0000000..87f0678 --- /dev/null +++ b/cases/0076-refined-return-runtime-obligation/expect.toml @@ -0,0 +1,29 @@ +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(v, 3L, \"TypR/main.ty:2\")" + +# early `return(v)` is a return position too +[[rule]] +file = "R/main.R" +must_contain = "return(typr_refine_length(v, 3L" + +# only the branch that is not proven is wrapped +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(v, 3L, \"TypR/main.ty:9\")" + +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_value(n, 0, TRUE, Inf, FALSE" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(c(" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(w" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_value(4" diff --git a/cases/0076-refined-return-runtime-obligation/repro/DESCRIPTION b/cases/0076-refined-return-runtime-obligation/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0076-refined-return-runtime-obligation/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0076-refined-return-runtime-obligation/repro/NAMESPACE b/cases/0076-refined-return-runtime-obligation/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0076-refined-return-runtime-obligation/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0076-refined-return-runtime-obligation/repro/R/.gitkeep b/cases/0076-refined-return-runtime-obligation/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0076-refined-return-runtime-obligation/repro/TypR/main.ty b/cases/0076-refined-return-runtime-obligation/repro/TypR/main.ty new file mode 100644 index 0000000..414d29e --- /dev/null +++ b/cases/0076-refined-return-runtime-obligation/repro/TypR/main.ty @@ -0,0 +1,12 @@ +# Return type: every value in return position (trailing expr, early return, if branch) is checked unless proven. +let mk <- fn(v: [int]): [3, int] { v }; +let pick <- fn(v: [int], c: bool): [3, int] { + if (c) { return v; }; + let w: [3, int] <- [1, 2, 3]; + w +}; +let branch <- fn(v: [int], c: bool): [3, int] { + if (c) { v } else { [7, 8, 9] } +}; +let sq <- fn(n: int): int & (> 0) { n }; +let ok <- fn(): int & (> 0) { 4 }; diff --git a/cases/0077-refined-propagation-vector-index/case.toml b/cases/0077-refined-propagation-vector-index/case.toml new file mode 100644 index 0000000..b15f174 --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/case.toml @@ -0,0 +1,7 @@ +title = "Vector index, comparison and length propagate refined lengths" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0077-refined-propagation-vector-index/expect.md b/cases/0077-refined-propagation-vector-index/expect.md new file mode 100644 index 0000000..255ace2 --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/expect.md @@ -0,0 +1,10 @@ +# 0077 — propagation of lengths (refined_types_plan.md, Phase 7) + +Before: `a[1:3]` was rejected as a wrong index (a vector index on a vector) and, when accepted, +typed `int`; `a > 2` was rejected outright for a vector `a`; `length(a)` was `any`. + +Now `a:b` on a one-dimensional vector gives `[b-a+1, T]` when both bounds are literals, a +selection by mask gives `[T]` (length unknown), a comparison is element-wise (`[n, bool]`) and +`length(x)` with `x : [5, T]` is the literal type `5`. The witness is the generated R: the +proven `[3, int]` selections carry no `typr_refine_length`, the mask selection (`[int]` → +`[3, int]`) carries one. diff --git a/cases/0077-refined-propagation-vector-index/expect.toml b/cases/0077-refined-propagation-vector-index/expect.toml new file mode 100644 index 0000000..714bd85 --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/expect.toml @@ -0,0 +1,20 @@ +[[rule]] +file = "R/main.R" +must_contain = "a[a > 2L" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(a[[seq" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(length" + +# single bracket: `a[[1:3]]` would be a recursive index in R +[[rule]] +file = "R/main.R" +must_contain = "a[seq(2L" + +[[rule]] +file = "R/main.R" +must_not_contain = "a[[seq" diff --git a/cases/0077-refined-propagation-vector-index/golden/R/main.R b/cases/0077-refined-propagation-vector-index/golden/R/main.R new file mode 100644 index 0000000..fd95538 --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/golden/R/main.R @@ -0,0 +1,25 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +# Index/comparison/length results carry their length: a proven `[3, int]` needs no check. +`a` <- c( + 1L |> as.Integer(), + 2L |> as.Integer(), + 3L |> as.Integer(), + 4L |> as.Integer(), + 5L |> as.Integer() +) |> + identity() |> + identity() + +`head3` <- a[seq(1L |> as.Integer(), 3L |> as.Integer(), 1L |> as.Integer())] |> + identity() + +`mid` <- a[seq(2L |> as.Integer(), 4L |> as.Integer(), 1L |> as.Integer())] |> + identity() + +`big` <- typr_refine_length(a[a > 2L |> as.Integer()], 3L, "TypR/main.ty:5") |> + identity() + +`n` <- length(a) |> as.Integer() diff --git a/cases/0077-refined-propagation-vector-index/repro/DESCRIPTION b/cases/0077-refined-propagation-vector-index/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0077-refined-propagation-vector-index/repro/NAMESPACE b/cases/0077-refined-propagation-vector-index/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0077-refined-propagation-vector-index/repro/TypR/main.ty b/cases/0077-refined-propagation-vector-index/repro/TypR/main.ty new file mode 100644 index 0000000..f458e0e --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/repro/TypR/main.ty @@ -0,0 +1,6 @@ +# Index/comparison/length results carry their length: a proven `[3, int]` needs no check. +let a: [5, int] <- [1, 2, 3, 4, 5]; +let head3: [3, int] <- a[1:3]; +let mid: [3, int] <- a[2:4]; +let big: [3, int] <- a[a > 2]; +let n: 5 <- length(a); diff --git a/cases/0078-refined-propagation-primitive-effects/case.toml b/cases/0078-refined-propagation-primitive-effects/case.toml new file mode 100644 index 0000000..f5f1593 --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/case.toml @@ -0,0 +1,7 @@ +title = "rev, head and tail carry the refined length of their argument" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0078-refined-propagation-primitive-effects/expect.md b/cases/0078-refined-propagation-primitive-effects/expect.md new file mode 100644 index 0000000..ecf2e6f --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/expect.md @@ -0,0 +1,8 @@ +# 0078 — length effects of `rev`, `head`, `tail` (refined_types_plan.md, Phase 7) + +`rev`, `head` and `tail` come from `base.ty` (doc-only), so the compiler saw them as `any` and +every refined binding downstream needed a runtime check. Their effect on the length is now +hard-coded in `known_effect_call`: `rev(x)` keeps `x`'s type, `head/tail(x, k)` with a literal +`k` give `[min(N, k), T]` (`[max(N + k, 0), T]` for negative `k`). The witness is the generated +R: no `typr_refine_length` around them, while the call to an undeclared function `idf` still +carries one (default rule: no declared effect, no refinement). diff --git a/cases/0078-refined-propagation-primitive-effects/expect.toml b/cases/0078-refined-propagation-primitive-effects/expect.toml new file mode 100644 index 0000000..6a77959 --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/expect.toml @@ -0,0 +1,21 @@ +# proven by the declared effects: no runtime check +[[rule]] +file = "R/main.R" +must_contain = "head(a, 2L" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(rev" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(head" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(tail" + +# an undeclared function keeps its declared type: the boundary check stays +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(idf(a), 5L" diff --git a/cases/0078-refined-propagation-primitive-effects/golden/R/main.R b/cases/0078-refined-propagation-primitive-effects/golden/R/main.R new file mode 100644 index 0000000..3b6b502 --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/golden/R/main.R @@ -0,0 +1,39 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +# rev/head/tail: declared length effects. `head(a, 9)` clamps to 5; a call to an +# unrelated function gets no refinement, so its result is checked at the boundary. + +`a` <- c( + 1L |> as.Integer(), + 2L |> as.Integer(), + 3L |> as.Integer(), + 4L |> as.Integer(), + 5L |> as.Integer() +) |> + identity() |> + identity() + +`r` <- rev(a) |> identity() + +`h` <- head(a, 2L |> as.Integer()) |> identity() + +`t` <- tail(a, 3L |> as.Integer()) |> identity() + +`d` <- head(a, -2L |> as.Integer()) |> identity() + +`all` <- head(a, 9L |> as.Integer()) |> identity() + +`n` <- length(rev(a)) |> as.Integer() + +#' @method idf integer +`idf.integer` <- (function(x) { + { + x + } |> + identity() +}) |> + as.Generic() + +`k` <- typr_refine_length(idf(a), 5L, "TypR/main.ty:13") |> identity() diff --git a/cases/0078-refined-propagation-primitive-effects/repro/DESCRIPTION b/cases/0078-refined-propagation-primitive-effects/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0078-refined-propagation-primitive-effects/repro/NAMESPACE b/cases/0078-refined-propagation-primitive-effects/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0078-refined-propagation-primitive-effects/repro/TypR/main.ty b/cases/0078-refined-propagation-primitive-effects/repro/TypR/main.ty new file mode 100644 index 0000000..40dfc85 --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/repro/TypR/main.ty @@ -0,0 +1,13 @@ +# rev/head/tail: declared length effects. `head(a, 9)` clamps to 5; a call to an +# unrelated function gets no refinement, so its result is checked at the boundary. +@head: (x: Any, n: int) -> Any; +@tail: (x: Any, n: int) -> Any; +let a: [5, int] <- [1, 2, 3, 4, 5]; +let r: [5, int] <- rev(a); +let h: [2, int] <- head(a, 2); +let t: [3, int] <- tail(a, 3); +let d: [3, int] <- head(a, -2); +let all: [5, int] <- head(a, 9); +let n: 5 <- length(rev(a)); +let idf <- fn(x: [int]): [int] { x }; +let k: [5, int] <- idf(a); diff --git a/cases/0079-refined-static-errors/case.toml b/cases/0079-refined-static-errors/case.toml new file mode 100644 index 0000000..d54bdfa --- /dev/null +++ b/cases/0079-refined-static-errors/case.toml @@ -0,0 +1,7 @@ +title = "contradictory or inapplicable refinements are rejected statically" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0079-refined-static-errors/expect.md b/cases/0079-refined-static-errors/expect.md new file mode 100644 index 0000000..e19b4d1 --- /dev/null +++ b/cases/0079-refined-static-errors/expect.md @@ -0,0 +1,12 @@ +# 0079 — refinement errors are static (refined_types_plan.md, Phase 3/8) + +Two families of errors, both raised while the `let` annotation is reduced +(`norm_refinement` / `apply_refinements` in `type_checking/type_arithmetic.rs`): + +- **`UnsatisfiableRefinement` (T046)**: the properties leave an empty interval + (`(> 10) & (< 5)`), or a second `length(n)` disagrees with the length already stored in the + `Vec` index (D4: `[int] & length(5)` is `[5, int]`). +- **`InvalidRefinement` (T045)**: the base lacks the capability — `length` on `int`, + `(> c)` on `chr`. + +Neither may panic, and neither may reach the transpiler. diff --git a/cases/0079-refined-static-errors/expect.toml b/cases/0079-refined-static-errors/expect.toml new file mode 100644 index 0000000..16457f9 --- /dev/null +++ b/cases/0079-refined-static-errors/expect.toml @@ -0,0 +1,22 @@ +# contradiction between two value bounds +[[rule]] +file = "@run" +must_contain = "`int & (> 10) & (< 5)` has no" + +# capability check: `length` needs a vector, `(> c)` needs int/num +[[rule]] +file = "@run" +must_contain = "`int` cannot be refined by `length`" + +[[rule]] +file = "@run" +must_contain = "`chr` cannot be refined by `value`" + +# `[int] & length(5)` is stored as `[5, int]`, so a second length contradicts it +[[rule]] +file = "@run" +must_contain = "contradicts" + +[[rule]] +file = "@run" +must_not_contain = "panicked" diff --git a/cases/0079-refined-static-errors/observed.txt b/cases/0079-refined-static-errors/observed.txt new file mode 100644 index 0000000..07b8db9 --- /dev/null +++ b/cases/0079-refined-static-errors/observed.txt @@ -0,0 +1 @@ +exit=0 (before Phase 3, these annotations were accepted or panicked) diff --git a/cases/0079-refined-static-errors/repro/DESCRIPTION b/cases/0079-refined-static-errors/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0079-refined-static-errors/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0079-refined-static-errors/repro/NAMESPACE b/cases/0079-refined-static-errors/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0079-refined-static-errors/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0079-refined-static-errors/repro/R/.gitkeep b/cases/0079-refined-static-errors/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0079-refined-static-errors/repro/TypR/main.ty b/cases/0079-refined-static-errors/repro/TypR/main.ty new file mode 100644 index 0000000..8dddd41 --- /dev/null +++ b/cases/0079-refined-static-errors/repro/TypR/main.ty @@ -0,0 +1,5 @@ +# Each of these refinements is rejected at compile time. +let a: int & (> 10) & (< 5) <- 7; +let b: int & length(5) <- 3; +let c: [int] & length(5) & length(10) <- [1, 2, 3, 4, 5]; +let d: chr & (> 0) <- "a"; diff --git a/cases/0080-refined-record-literal-fields/case.toml b/cases/0080-refined-record-literal-fields/case.toml new file mode 100644 index 0000000..f571072 --- /dev/null +++ b/cases/0080-refined-record-literal-fields/case.toml @@ -0,0 +1,7 @@ +title = "record literal with refined fields: per-field runtime checks (let, argument, return)" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0080-refined-record-literal-fields/expect.md b/cases/0080-refined-record-literal-fields/expect.md new file mode 100644 index 0000000..bdcd3d7 --- /dev/null +++ b/cases/0080-refined-record-literal-fields/expect.md @@ -0,0 +1,11 @@ +# 0080 — refined record fields (refined_types_plan.md, Phase 5, record constructors) + +`let a: Account <- list { id: n, tags: v }` used to be a static type error: the literal's type +`list{id: int, tags: [int]}` is not a subtype of `Account` (whose fields are refined), and +`coerce_to` on the whole record only knows about refinements of the value itself. + +`field_obligations` (`type_checking/refinement_check.rs`) handles a spread-free record literal +against a record type: each field is a boundary of its own, so the obligation is recorded on the +*field expression* and the transpiler wraps just that value. Wired into `let_expression`, the +return position (`typing`) and `relax_refined_arguments`. A field that is provably wrong +(`id: -3`) is still a static error. diff --git a/cases/0080-refined-record-literal-fields/expect.toml b/cases/0080-refined-record-literal-fields/expect.toml new file mode 100644 index 0000000..63f0f36 --- /dev/null +++ b/cases/0080-refined-record-literal-fields/expect.toml @@ -0,0 +1,26 @@ +[[rule]] +file = "R/main.R" +must_contain = "id = typr_refine_value(n, 0, TRUE, Inf, FALSE, \"TypR/main.ty:6\")" + +[[rule]] +file = "R/main.R" +must_contain = "tags = typr_refine_length(v, 2L, \"TypR/main.ty:6\")" + +# argument position +[[rule]] +file = "R/main.R" +must_contain = "id = typr_refine_value(n, 0, TRUE, Inf, FALSE, \"TypR/main.ty:10\")" + +# return position +[[rule]] +file = "R/main.R" +must_contain = "id = typr_refine_value(k, 0, TRUE, Inf, FALSE, \"TypR/main.ty:11\")" + +# proven by literals: no noise +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_value(3" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(c(" diff --git a/cases/0080-refined-record-literal-fields/observed.txt b/cases/0080-refined-record-literal-fields/observed.txt new file mode 100644 index 0000000..209c2e0 --- /dev/null +++ b/cases/0080-refined-record-literal-fields/observed.txt @@ -0,0 +1 @@ +type Account doesn't match type list{id: ...} (before per-field obligations) diff --git a/cases/0080-refined-record-literal-fields/repro/DESCRIPTION b/cases/0080-refined-record-literal-fields/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0080-refined-record-literal-fields/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0080-refined-record-literal-fields/repro/NAMESPACE b/cases/0080-refined-record-literal-fields/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0080-refined-record-literal-fields/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0080-refined-record-literal-fields/repro/R/.gitkeep b/cases/0080-refined-record-literal-fields/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0080-refined-record-literal-fields/repro/TypR/main.ty b/cases/0080-refined-record-literal-fields/repro/TypR/main.ty new file mode 100644 index 0000000..b0817c1 --- /dev/null +++ b/cases/0080-refined-record-literal-fields/repro/TypR/main.ty @@ -0,0 +1,11 @@ +# A record literal flowing into a record type with refined fields: the check goes on each +# field expression that needs one, at each boundary (let, argument, return). +type Account <- list { id: int & (> 0), tags: [int] & length(2) }; +let n: int <- 5; +let v: [int] <- [1, 2]; +let a: Account <- list { id: n, tags: v }; +# Proven by the literals: no check. +let b: Account <- list { id: 3, tags: [1, 2] }; +let describe <- fn(x: Account): int { 1 }; +let d <- describe(list { id: n, tags: [3, 4] }); +let make <- fn(k: int): Account { list { id: k, tags: [1, 2] } }; diff --git a/cases/0081-fixed-length-argument-mismatch/case.toml b/cases/0081-fixed-length-argument-mismatch/case.toml new file mode 100644 index 0000000..2e00a71 --- /dev/null +++ b/cases/0081-fixed-length-argument-mismatch/case.toml @@ -0,0 +1,7 @@ +title = "a fixed-length parameter is not lifted to a longer argument" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0081-fixed-length-argument-mismatch/expect.md b/cases/0081-fixed-length-argument-mismatch/expect.md new file mode 100644 index 0000000..5c74759 --- /dev/null +++ b/cases/0081-fixed-length-argument-mismatch/expect.md @@ -0,0 +1,7 @@ +# 0081 — a fixed-length parameter is not lifted to the argument's length + +`Type::lift` (vectorised calls) used to rewrite the length of a `[2, num]` parameter to `3` whenever an +argument had 3 elements, so `dist(a)` with `a: [3, num]` type-checked. A parameter that already fixes +its length now keeps it (only scalars and unsized/generic-length vectors are lifted), so the call is +rejected statically like the equivalent `let` annotation. Found while working on refined types +(`refined_types_plan.md`, Phase 9 prerequisite). diff --git a/cases/0081-fixed-length-argument-mismatch/expect.toml b/cases/0081-fixed-length-argument-mismatch/expect.toml new file mode 100644 index 0000000..6e62ca4 --- /dev/null +++ b/cases/0081-fixed-length-argument-mismatch/expect.toml @@ -0,0 +1,8 @@ +# both 3-element calls are rejected; the 2-element one is not +[[rule]] +file = "@run" +must_contain = "No signature of function 'dist' matches this call." + +[[rule]] +file = "@run" +must_not_contain = "panicked" diff --git a/cases/0081-fixed-length-argument-mismatch/repro/DESCRIPTION b/cases/0081-fixed-length-argument-mismatch/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0081-fixed-length-argument-mismatch/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0081-fixed-length-argument-mismatch/repro/NAMESPACE b/cases/0081-fixed-length-argument-mismatch/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0081-fixed-length-argument-mismatch/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0081-fixed-length-argument-mismatch/repro/R/.gitkeep b/cases/0081-fixed-length-argument-mismatch/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0081-fixed-length-argument-mismatch/repro/TypR/main.ty b/cases/0081-fixed-length-argument-mismatch/repro/TypR/main.ty new file mode 100644 index 0000000..7faacd7 --- /dev/null +++ b/cases/0081-fixed-length-argument-mismatch/repro/TypR/main.ty @@ -0,0 +1,6 @@ +# A parameter with a fixed length must not be widened to fit a longer argument. +let dist <- fn(p: [2, num]): num { 0.0 }; +let a: [3, num] <- [1.0, 2.0, 3.0]; +dist(a); +dist([1.0, 2.0, 3.0]); +dist([1.0, 2.0]); diff --git a/cases/0082-narrowing-drops-runtime-check/case.toml b/cases/0082-narrowing-drops-runtime-check/case.toml new file mode 100644 index 0000000..7d547a8 --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/case.toml @@ -0,0 +1,7 @@ +title = "branch conditions narrow refined variables" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0082-narrowing-drops-runtime-check/expect.md b/cases/0082-narrowing-drops-runtime-check/expect.md new file mode 100644 index 0000000..6ad0a6e --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/expect.md @@ -0,0 +1,6 @@ +# 0082 — a branch condition narrows the variable (refined_types_plan.md, Phase 9, §24) + +`if (length(v) == 2) { dist(v) }` needs no `typr_refine_length`: the condition is the check. The +facts read from the condition (`length(x) n`, `x c` on scalars, `&&`, `||` in the else +branch, `!`) are intersected with the variable's type inside the matching branch only +(`type_checking/narrowing.rs`). `!(n < 1)` is `n >= 1`, and `[1, +inf)` lies inside `(0, +inf)`, so that call is proven too. diff --git a/cases/0082-narrowing-drops-runtime-check/expect.toml b/cases/0082-narrowing-drops-runtime-check/expect.toml new file mode 100644 index 0000000..3812126 --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/expect.toml @@ -0,0 +1,25 @@ +# lines 5-11 are proven by their condition: no check there +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:5" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:6" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:7" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:8" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:9" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:11" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:10" +[[rule]] +file = "R/main.R" +must_contain = "dist(v)" diff --git a/cases/0082-narrowing-drops-runtime-check/golden/R/main.R b/cases/0082-narrowing-drops-runtime-check/golden/R/main.R new file mode 100644 index 0000000..a378351 --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/golden/R/main.R @@ -0,0 +1,81 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +# Condition narrowing: inside the branch the condition proves the refinement. +#' @method dist numeric +`dist.numeric` <- (function(p) { + { + 0 |> as.Number() + } |> + as.Number() +}) |> + as.Generic() + +#' @method pos default +`pos.default` <- (function(x) { + { + x + } |> + as.Integer() +}) |> + as.Generic() + +#' @method f numeric +`f.numeric` <- (function(v, n) { + { + if (length(v) == 2L |> as.Integer()) { + dist(v) + } else { + 0 |> as.Number() + } + if (2L |> as.Integer() == length(v)) { + dist(v) + } else { + 0 |> as.Number() + } + if ( + { + length(v) > 1L |> as.Integer() + } && + { + length(v) < 3L |> as.Integer() + } + ) { + dist(v) + } else { + 0 |> as.Number() + } + if (n > 0L |> as.Integer()) { + pos(n) + } else { + 0L |> as.Integer() + } + if (n <= 0L |> as.Integer()) { + 0L |> as.Integer() + } else { + pos(n) + } + if ( + !{ + n < 1L |> as.Integer() + } + ) { + pos(n) + } else { + 0L |> as.Integer() + } + if (length(v) == 2L |> as.Integer()) { + if (n > 0L |> as.Integer()) { + dist(v) + } else { + 0 |> as.Number() + } + } else { + 0 |> as.Number() + } + 0 |> as.Number() + } |> + as.Number() +}) |> + as.Generic() diff --git a/cases/0082-narrowing-drops-runtime-check/repro/DESCRIPTION b/cases/0082-narrowing-drops-runtime-check/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0082-narrowing-drops-runtime-check/repro/NAMESPACE b/cases/0082-narrowing-drops-runtime-check/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0082-narrowing-drops-runtime-check/repro/R/.gitkeep b/cases/0082-narrowing-drops-runtime-check/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0082-narrowing-drops-runtime-check/repro/TypR/main.ty b/cases/0082-narrowing-drops-runtime-check/repro/TypR/main.ty new file mode 100644 index 0000000..3919904 --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/repro/TypR/main.ty @@ -0,0 +1,13 @@ +# Condition narrowing: inside the branch the condition proves the refinement. +let dist <- fn(p: [2, num]): num { 0.0 }; +let pos <- fn(x: int & (> 0)): int { x }; +let f <- fn(v: [num], n: int): num { + if (length(v) == 2) { dist(v) } else { 0.0 }; + if (2 == length(v)) { dist(v) } else { 0.0 }; + if ((length(v) > 1) && (length(v) < 3)) { dist(v) } else { 0.0 }; + if (n > 0) { pos(n) } else { 0 }; + if (n <= 0) { 0 } else { pos(n) }; + if (!(n < 1)) { pos(n) } else { 0 }; + if (length(v) == 2) { if (n > 0) { dist(v) } else { 0.0 } } else { 0.0 }; + 0.0 +}; diff --git a/cases/0083-narrowing-stays-in-branch/case.toml b/cases/0083-narrowing-stays-in-branch/case.toml new file mode 100644 index 0000000..b180846 --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/case.toml @@ -0,0 +1,7 @@ +title = "narrowing does not leak outside its branch" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0083-narrowing-stays-in-branch/expect.md b/cases/0083-narrowing-stays-in-branch/expect.md new file mode 100644 index 0000000..58927bf --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/expect.md @@ -0,0 +1,4 @@ +# 0083 — narrowing is scoped to the branch it proves + +The else-branch of `length(v) == 2`, the code after the `if`, and the then-branch of a `||` are not +narrowed: each `dist(v)` keeps its runtime length check. diff --git a/cases/0083-narrowing-stays-in-branch/expect.toml b/cases/0083-narrowing-stays-in-branch/expect.toml new file mode 100644 index 0000000..ff7f1a7 --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/expect.toml @@ -0,0 +1,9 @@ +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(v, 2L, \"TypR/main.ty:4\")" +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(v, 2L, \"TypR/main.ty:5\")" +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(v, 2L, \"TypR/main.ty:6\")" diff --git a/cases/0083-narrowing-stays-in-branch/golden/R/main.R b/cases/0083-narrowing-stays-in-branch/golden/R/main.R new file mode 100644 index 0000000..7dd8c3c --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/golden/R/main.R @@ -0,0 +1,40 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +# Narrowing must not leak: the else-branch, code after the `if`, and `||` (then) prove nothing. +#' @method dist numeric +`dist.numeric` <- (function(p) { + { + 0 |> as.Number() + } |> + as.Number() +}) |> + as.Generic() + +#' @method f numeric +`f.numeric` <- (function(v) { + { + if (length(v) == 2L |> as.Integer()) { + 1 |> as.Number() + } else { + dist(typr_refine_length(v, 2L, "TypR/main.ty:4")) + } + dist(typr_refine_length(v, 2L, "TypR/main.ty:5")) + if ( + { + length(v) == 2L |> as.Integer() + } || + { + length(v) == 3L |> as.Integer() + } + ) { + dist(typr_refine_length(v, 2L, "TypR/main.ty:6")) + } else { + 0 |> as.Number() + } + 0 |> as.Number() + } |> + as.Number() +}) |> + as.Generic() diff --git a/cases/0083-narrowing-stays-in-branch/repro/DESCRIPTION b/cases/0083-narrowing-stays-in-branch/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0083-narrowing-stays-in-branch/repro/NAMESPACE b/cases/0083-narrowing-stays-in-branch/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0083-narrowing-stays-in-branch/repro/R/.gitkeep b/cases/0083-narrowing-stays-in-branch/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0083-narrowing-stays-in-branch/repro/TypR/main.ty b/cases/0083-narrowing-stays-in-branch/repro/TypR/main.ty new file mode 100644 index 0000000..c4ad938 --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/repro/TypR/main.ty @@ -0,0 +1,8 @@ +# Narrowing must not leak: the else-branch, code after the `if`, and `||` (then) prove nothing. +let dist <- fn(p: [2, num]): num { 0.0 }; +let f <- fn(v: [num]): num { + if (length(v) == 2) { 1.0 } else { dist(v) }; + dist(v); + if ((length(v) == 2) || (length(v) == 3)) { dist(v) } else { 0.0 }; + 0.0 +}; diff --git a/cases/0084-refined-length-range/case.toml b/cases/0084-refined-length-range/case.toml new file mode 100644 index 0000000..af16ae6 --- /dev/null +++ b/cases/0084-refined-length-range/case.toml @@ -0,0 +1,7 @@ +title = "range bounds on lengths and values: length(> 0), (>= 0)" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0084-refined-length-range/expect.md b/cases/0084-refined-length-range/expect.md new file mode 100644 index 0000000..ae4e13a --- /dev/null +++ b/cases/0084-refined-length-range/expect.md @@ -0,0 +1,11 @@ +`[T] & length(> 0)` and `num & (>= 0)` parse, type check and are enforced like the point forms +(`length(5)`, `(> 0)`): the interval model already covered ranges, only the surface syntax and +`Vec` indexing through a `Refined` were missing. + +- `[int]` passed where `length(> 0)` is expected: runtime check (`typr_refine_length_range`). +- inside `if (length(v) > 0)`: proven by the condition, no check. +- `[3, int]`: implies the range, no check. `[0, int]` is rejected statically. +- `v[1]` on a `[int] & length(> 0)` parameter type-checks like on `[int]`. + +Code: `parsing/types.rs::refinement_property`, `type_arithmetic.rs::apply_refinements`, +`type_checking/mod.rs` (`Lang::ArrayIndexing`). diff --git a/cases/0084-refined-length-range/expect.toml b/cases/0084-refined-length-range/expect.toml new file mode 100644 index 0000000..a40a735 --- /dev/null +++ b/cases/0084-refined-length-range/expect.toml @@ -0,0 +1,17 @@ +# unproven boundaries carry a range check +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length_range(v, 0, TRUE, Inf, FALSE, \"TypR/main.ty:7\")" +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_value(n, 0, FALSE, Inf, FALSE, \"TypR/main.ty:9\")" +# the branch condition proves it (line 5), and [3, int] implies it (line 8) +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:5" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:8" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:3\")" diff --git a/cases/0084-refined-length-range/golden/R/main.R b/cases/0084-refined-length-range/golden/R/main.R new file mode 100644 index 0000000..8661e3c --- /dev/null +++ b/cases/0084-refined-length-range/golden/R/main.R @@ -0,0 +1,62 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +# Range bounds: `length(> 0)`, `(>= 0)`. Checked where unproven, absent where proven. +#' @method first Array0 +`first.Array0` <- (function(v) { + { + v[[1L |> as.Integer()]] + } |> + as.Integer() +}) |> + as.Generic() + +#' @method nonneg numeric0 +`nonneg.numeric0` <- (function(x) { + { + x + } |> + as.Number() +}) |> + as.Generic() + +#' @method narrowed integer +`narrowed.integer` <- (function(v) { + { + if (length(v) > 0L |> as.Integer()) { + first(v) + } else { + 0L |> as.Integer() + } + } |> + as.Integer() +}) |> + as.Generic() + +#' @method unchecked integer +`unchecked.integer` <- (function(v) { + { + first(typr_refine_length_range(v, 0, TRUE, Inf, FALSE, "TypR/main.ty:7")) + } |> + as.Integer() +}) |> + as.Generic() + +#' @method sized integer +`sized.integer` <- (function(v) { + { + first(v) + } |> + as.Integer() +}) |> + as.Generic() + +#' @method scalar numeric +`scalar.numeric` <- (function(n) { + { + nonneg(typr_refine_value(n, 0, FALSE, Inf, FALSE, "TypR/main.ty:9")) + } |> + as.Number() +}) |> + as.Generic() diff --git a/cases/0084-refined-length-range/repro/DESCRIPTION b/cases/0084-refined-length-range/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0084-refined-length-range/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0084-refined-length-range/repro/NAMESPACE b/cases/0084-refined-length-range/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0084-refined-length-range/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0084-refined-length-range/repro/R/.gitkeep b/cases/0084-refined-length-range/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0084-refined-length-range/repro/TypR/main.ty b/cases/0084-refined-length-range/repro/TypR/main.ty new file mode 100644 index 0000000..0d7d48e --- /dev/null +++ b/cases/0084-refined-length-range/repro/TypR/main.ty @@ -0,0 +1,9 @@ +# Range bounds: `length(> 0)`, `(>= 0)`. Checked where unproven, absent where proven. +let first <- fn(v: [int] & length(> 0)): int { v[1] }; +let nonneg <- fn(x: num & (>= 0)): num { x }; +let narrowed <- fn(v: [int]): int { + if (length(v) > 0) { first(v) } else { 0 } +}; +let unchecked <- fn(v: [int]): int { first(v) }; +let sized <- fn(v: [3, int]): int { first(v) }; +let scalar <- fn(n: num): num { nonneg(n) }; diff --git a/cases/0085-refined-generic-length-range/case.toml b/cases/0085-refined-generic-length-range/case.toml new file mode 100644 index 0000000..42aacd9 --- /dev/null +++ b/cases/0085-refined-generic-length-range/case.toml @@ -0,0 +1,7 @@ +title = "refinements of a generic base are checked at the call: [#N, T] & length(> 0)" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0085-refined-generic-length-range/expect.md b/cases/0085-refined-generic-length-range/expect.md new file mode 100644 index 0000000..5af9426 --- /dev/null +++ b/cases/0085-refined-generic-length-range/expect.md @@ -0,0 +1,9 @@ +A generic base keeps its refinements at a call: `[#N, T] & length(> 0)` unifies `T` like `[#N, T]`, +and the refinement is then decided against the argument, since unification alone never looks at it. + +- `[int]` passed in: runtime check (`typr_refine_length_range`) on the argument. +- inside `if (length(v) > 0)`, or `[3, char]`: proven, no check. +- `[0, char]`: rejected statically (no signature matches). + +Code: `type_checking/mod.rs::get_gen_type` and `unification.rs` (`Refined` arms), +`function_application.rs::generic_refinement_obligations`, `type_arithmetic.rs::declared_refinements`. diff --git a/cases/0085-refined-generic-length-range/expect.toml b/cases/0085-refined-generic-length-range/expect.toml new file mode 100644 index 0000000..228e8b7 --- /dev/null +++ b/cases/0085-refined-generic-length-range/expect.toml @@ -0,0 +1,11 @@ +# the unproven boundary carries the range check on the argument +[[rule]] +file = "R/main.R" +must_contain = "first(typr_refine_length_range(v, 0, TRUE, Inf, FALSE, \"TypR/main.ty:6\"))" +# proven by the branch condition (line 4) or by the length 3 (line 7): no check +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:4" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:7" diff --git a/cases/0085-refined-generic-length-range/golden/R/main.R b/cases/0085-refined-generic-length-range/golden/R/main.R new file mode 100644 index 0000000..62668f3 --- /dev/null +++ b/cases/0085-refined-generic-length-range/golden/R/main.R @@ -0,0 +1,44 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +# A generic base keeps its refinements: `[#N, T] & length(> 0)` is checked like `[int] & length(> 0)`. +#' @method first Array0 +`first.Array0` <- (function(v) { + { + v[[1L |> as.Integer()]] + } |> + as.Generic() +}) |> + as.Generic() + +#' @method narrowed integer +`narrowed.integer` <- (function(v) { + { + if (length(v) > 0L |> as.Integer()) { + first(v) + } else { + 0L |> as.Integer() + } + } |> + as.Integer() +}) |> + as.Generic() + +#' @method unchecked integer +`unchecked.integer` <- (function(v) { + { + first(typr_refine_length_range(v, 0, TRUE, Inf, FALSE, "TypR/main.ty:6")) + } |> + as.Integer() +}) |> + as.Generic() + +#' @method sized character +`sized.character` <- (function(v) { + { + first(v) + } |> + as.Character() +}) |> + as.Generic() diff --git a/cases/0085-refined-generic-length-range/repro/DESCRIPTION b/cases/0085-refined-generic-length-range/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0085-refined-generic-length-range/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0085-refined-generic-length-range/repro/NAMESPACE b/cases/0085-refined-generic-length-range/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0085-refined-generic-length-range/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0085-refined-generic-length-range/repro/TypR/main.ty b/cases/0085-refined-generic-length-range/repro/TypR/main.ty new file mode 100644 index 0000000..4920864 --- /dev/null +++ b/cases/0085-refined-generic-length-range/repro/TypR/main.ty @@ -0,0 +1,7 @@ +# A generic base keeps its refinements: `[#N, T] & length(> 0)` is checked like `[int] & length(> 0)`. +let first <- fn(v: [#N, T] & length(> 0)): T { v[1] }; +let narrowed <- fn(v: [int]): int { + if (length(v) > 0) { first(v) } else { 0 } +}; +let unchecked <- fn(v: [int]): int { first(v) }; +let sized <- fn(v: [3, char]): char { first(v) }; diff --git a/crates/typr-cli/configs/src/std.R b/crates/typr-cli/configs/src/std.R index 45f30ab..8cac50a 100644 --- a/crates/typr-cli/configs/src/std.R +++ b/crates/typr-cli/configs/src/std.R @@ -81,6 +81,41 @@ typr_assert_type <- function(x, expected, loc, what) { x } +#' @title Vérifications des types raffinés (`[int] & length(3)`, `int & (> 0)`) +#' Émises par le compilateur seulement là où il n'a pas pu prouver le +#' raffinement. Elles renvoient `x` pour s'insérer dans n'importe quelle +#' expression ; `all()`/`isTRUE()` les rendent sûres face aux vecteurs et aux NA. +typr_refine_length <- function(x, n, loc) { + if (!isTRUE(length(x) == n)) { + stop(sprintf("Type refinement violation at %s: expected length %d, got %d", + loc, n, length(x)), call. = FALSE) + } + x +} + +typr_refine_length_range <- function(x, lo, lo_open, hi, hi_open, loc) { + n <- length(x) + ok <- (if (lo_open) n > lo else n >= lo) && (if (hi_open) n < hi else n <= hi) + if (!isTRUE(ok)) { + stop(sprintf("Type refinement violation at %s: length %d is out of bounds %s%s, %s%s", + loc, n, if (lo_open || is.infinite(lo)) "(" else "[", format(lo), format(hi), + if (hi_open || is.infinite(hi)) ")" else "]"), call. = FALSE) + } + x +} + +typr_refine_value <- function(x, lo, lo_open, hi, hi_open, loc) { + above <- if (lo_open) x > lo else x >= lo + below <- if (hi_open) x < hi else x <= hi + if (!isTRUE(all(above & below))) { + bad <- x[!(above & below)][1L] + stop(sprintf("Type refinement violation at %s: value %s is out of bounds %s%s, %s%s", + loc, format(bad), if (lo_open || is.infinite(lo)) "(" else "[", format(lo), format(hi), + if (hi_open || is.infinite(hi)) ")" else "]"), call. = FALSE) + } + x +} + # --- Génériques S3 --- get <- function(a, ...) UseMethod("get") diff --git a/crates/typr-core/src/components/context/mod.rs b/crates/typr-core/src/components/context/mod.rs index 2b98a41..3c4a5b9 100644 --- a/crates/typr-core/src/components/context/mod.rs +++ b/crates/typr-core/src/components/context/mod.rs @@ -14,6 +14,7 @@ use crate::components::language::var::Var; use crate::components::language::var_function::VarFunction; use crate::components::language::Lang; use crate::components::r#type::argument_type::ArgumentType; +use crate::components::r#type::refinement::{RefinementObligation, RefinementSet}; use crate::components::r#type::kind::Kind; use crate::components::r#type::type_system::TypeSystem; use crate::components::r#type::vector_type::ConstructorCategory; @@ -89,6 +90,12 @@ pub struct Context { /// checked against in `function()`. `None` outside a function body. #[serde(skip)] pub expected_return_type: Option, + /// True while typing an expression whose value is the function's result + /// (trailing expression, `if` branches, `return` argument). Lets `typing()` + /// check that value against `expected_return_type` (refined types). Not + /// serialised and never part of a context's identity. + #[serde(skip)] + pub return_position: bool, /// Inner typing contexts computed while type-checking each `module M { ... }` /// body, keyed by module name. Populated during type-checking; consumed /// during transpilation to avoid re-running `typing()` on every module body. @@ -134,6 +141,10 @@ pub struct Context { /// apart by name alone). #[serde(default)] pub vectorizable_fns: Vec<(String, bool)>, + /// Refinement checks the type checker requires at a boundary the + /// transpiler reaches later (`refinement_check::coerce_to`). + #[serde(default)] + pub refinement_obligations: Vec, config: Config, } @@ -183,10 +194,12 @@ impl Default for Context { test_preamble: Vec::new(), self_type: None, expected_return_type: None, + return_position: false, extern_fns: Vec::new(), import_from_fns: Vec::new(), signature_fns: Vec::new(), vectorizable_fns: Vec::new(), + refinement_obligations: Vec::new(), module_inner_contexts: HashMap::new(), processed_modules: HashMap::new(), modules_in_progress: HashSet::new(), @@ -228,10 +241,12 @@ impl Context { test_preamble: Vec::new(), self_type: None, expected_return_type: None, + return_position: false, extern_fns: Vec::new(), import_from_fns: Vec::new(), signature_fns: Vec::new(), vectorizable_fns: Vec::new(), + refinement_obligations: Vec::new(), module_inner_contexts: HashMap::new(), processed_modules: HashMap::new(), modules_in_progress: HashSet::new(), @@ -277,6 +292,28 @@ impl Context { self } + /// Record that the expression at `h` must satisfy `set` at run time. + pub fn add_refinement_obligation(mut self, h: &HelpData, set: RefinementSet) -> Self { + let (file, start, end) = (h.get_file_name(), h.get_offset(), h.get_end()); + match self + .refinement_obligations + .iter_mut() + .find(|o| o.file == file && o.start == start && o.end == end) + { + Some(o) => o.set = set, + None => self.refinement_obligations.push(RefinementObligation { file, start, end, set }), + } + self + } + + pub fn refinement_obligation_at(&self, h: &HelpData) -> Option<&RefinementSet> { + let (file, start, end) = (h.get_file_name(), h.get_offset(), h.get_end()); + self.refinement_obligations + .iter() + .find(|o| o.file == file && o.start == start && o.end == end) + .map(|o| &o.set) + } + pub fn is_vectorizable_fn(&self, name: &str) -> bool { self.vectorizable_fns.iter().any(|(n, v)| n == name && *v) } @@ -336,6 +373,26 @@ impl Context { } } + pub fn set_return_position(mut self, on: bool) -> Context { + self.return_position = on; + self + } + + pub fn is_return_position(&self) -> bool { + self.return_position + } + + /// Keep the refinement obligations `inner` recorded (a sub-context built + /// for a nested scope is otherwise dropped by its parent). + pub fn absorb_obligations(mut self, inner: &Context) -> Context { + for entry in &inner.refinement_obligations { + if !self.refinement_obligations.contains(entry) { + self.refinement_obligations.push(entry.clone()); + } + } + self + } + pub fn get_expected_return_type(&self) -> Option { self.expected_return_type.clone() } @@ -685,6 +742,7 @@ impl Context { subtypes: new_subtypes, ..self } + .absorb_obligations(inner) } pub fn get_type_from_existing_variable(&self, var: Var) -> Type { @@ -1384,6 +1442,12 @@ impl Add for Context { signature_fns.push(name); } } + let mut refinement_obligations = self.refinement_obligations; + for entry in other.refinement_obligations { + if !refinement_obligations.contains(&entry) { + refinement_obligations.push(entry); + } + } let mut vectorizable_fns = self.vectorizable_fns; for (name, is_vec) in other.vectorizable_fns { match vectorizable_fns.iter_mut().find(|(n, _)| n == &name) { @@ -1408,10 +1472,12 @@ impl Add for Context { test_preamble, self_type: None, expected_return_type: None, + return_position: false, extern_fns, import_from_fns, signature_fns, vectorizable_fns, + refinement_obligations, config: self.config, module_inner_contexts, processed_modules, diff --git a/crates/typr-core/src/components/error_message/type_error.rs b/crates/typr-core/src/components/error_message/type_error.rs index 9ff7703..b7dd730 100644 --- a/crates/typr-core/src/components/error_message/type_error.rs +++ b/crates/typr-core/src/components/error_message/type_error.rs @@ -159,6 +159,11 @@ pub enum TypeError { /// the internal `UnknownFunction` placeholder to someone who wrote plain /// R — `(function_name, expected_arity, got_arity, position)`. UntypedFunctionArity(String, usize, usize, HelpData), + /// `T & p` where `T` cannot carry the property `p` (`int & length(3)`). + /// `(message, position)` — the message comes from `apply_refinements`. + InvalidRefinement(String, HelpData), + /// A refined type no value can inhabit (`int & (> 5) & (< 3)`). + UnsatisfiableRefinement(String, HelpData), } impl TypeError { @@ -209,6 +214,8 @@ impl TypeError { TypeError::NoMatchingSignature(_, _, _, h) => Some(h.clone()), TypeError::NoDispatchImplementation(_, _, _, h) => Some(h.clone()), TypeError::UntypedFunctionArity(_, _, _, h) => Some(h.clone()), + TypeError::InvalidRefinement(_, h) => Some(h.clone()), + TypeError::UnsatisfiableRefinement(_, h) => Some(h.clone()), } } @@ -261,6 +268,8 @@ impl TypeError { TypeError::NoMatchingSignature(..) => "T042", TypeError::NoDispatchImplementation(..) => "T043", TypeError::UntypedFunctionArity(..) => "T044", + TypeError::InvalidRefinement(..) => "T045", + TypeError::UnsatisfiableRefinement(..) => "T046", } } @@ -490,6 +499,7 @@ impl TypeError { name, expected, got ) } + TypeError::InvalidRefinement(msg, _) | TypeError::UnsatisfiableRefinement(msg, _) => msg.clone(), } } } @@ -1092,6 +1102,24 @@ impl ErrorMsg for TypeError { .help("Its body is not type-checked; only the number of arguments is.") .build() } + TypeError::InvalidRefinement(msg, help_data) => { + let (file_data, pos) = safe_file_pos(&help_data, 1); + SingleBuilder::new(file_data.0, file_data.1) + .pos(pos) + .text(msg) + .pos_text("This refinement does not apply to that type") + .help("`length(n)` refines vectors (`[T] & length(n)`); `(> c)` and `(< c)` refine `int` and `num`.") + .build() + } + TypeError::UnsatisfiableRefinement(msg, help_data) => { + let (file_data, pos) = safe_file_pos(&help_data, 1); + SingleBuilder::new(file_data.0, file_data.1) + .pos(pos) + .text(msg) + .pos_text("No value can have this type") + .help("Its properties contradict each other; relax one of them.") + .build() + } TypeError::NoDispatchImplementation(name, forced_type, available, help_data) => { let (file_data, pos) = safe_file_pos(&help_data, name.len()); let available_pretty = available.iter().map(|t| t.pretty()).collect::>().join(", "); diff --git a/crates/typr-core/src/components/syntax/mod.rs b/crates/typr-core/src/components/syntax/mod.rs index 9415ec6..131020d 100644 --- a/crates/typr-core/src/components/syntax/mod.rs +++ b/crates/typr-core/src/components/syntax/mod.rs @@ -429,6 +429,16 @@ impl SyntaxManifest { names usable bare in a type position; the ones that only exist in front of \ a delimiter are in `types.constructor` and `keywords.block`.", ), + Rule::new( + "types.refinement", + "support.type.builtin.typr", + words_before(&["length"], "\\s*\\(\\s*[\\d<>]"), + ) + .with_note( + "`[int] & length(5)`, `[int] & length(> 0)` (`parsing/types.rs::refinement_property`). \ + `length` is also R's most common function, so it only counts glued to \ + `(` or `(`: `length(x)` stays a plain call.", + ), Rule::new( "types.builtin-indexed", "support.type.builtin.typr", diff --git a/crates/typr-core/src/components/type/mod.rs b/crates/typr-core/src/components/type/mod.rs index 9b377b9..113b367 100644 --- a/crates/typr-core/src/components/type/mod.rs +++ b/crates/typr-core/src/components/type/mod.rs @@ -8,6 +8,7 @@ pub mod index; pub mod intersection_type; pub mod kind; pub mod module_type; +pub mod refinement; pub mod tbool; pub mod tchar; pub mod tint; @@ -29,6 +30,7 @@ use crate::components::r#type::function_type::FunctionType; use crate::components::r#type::intersection_type::IntersectionType; use crate::components::r#type::kind::Kind; use crate::components::r#type::module_type::ModuleType; +use crate::components::r#type::refinement::{Interval, Measure, Refinement, RefinementSet}; use crate::components::r#type::tbool::Tbool; use crate::components::r#type::tchar::Tchar; use crate::components::r#type::tint::Tint; @@ -132,6 +134,14 @@ pub enum Type { Null(HelpData), NA(HelpData), KindedGen(Kind, String, HelpData), + // Refinement types. Appended last: the std `.bin` files serialise `Type` + // by variant index, so existing indices must not move. + /// `base & properties`, in normal form (see `refinement.rs`). + Refined(Box, RefinementSet, HelpData), + /// A bare property (`length(5)`, `(> 0)`). Parser output only: it must + /// be folded into a `Refined` by `norm_intersection` and never survive + /// `reduce_type`. + Property(Refinement, HelpData), } /// Structural fallback for interface-method-set comparison: for every @@ -214,6 +224,18 @@ impl TypeSystem for Type { match (self, other) { (Type::Empty(_), _) => true, (typ1, typ2) if typ1 == typ2 => true, + // Refined types (plan §Phase 4). Only `Proven` answers `true`: an + // unproven refinement is never assumed, the boundary check of + // Phase 5 is what turns it into a runtime obligation. These arms + // come before the generic `Intersection` ones, which would + // otherwise ask `t1 <: typ || t2 <: typ` of a bare `Property`. + (Type::Refined(a, ps, _), Type::Refined(b, qs, _)) => { + a.is_subtype_raw(b, context) && a.refinements_of().meet(ps).implies(qs) + } + (Type::Refined(a, _, _), typ) => a.is_subtype_raw(typ, context), + (typ, Type::Refined(b, qs, _)) => { + typ.is_subtype_raw(b, context) && typ.refinements_of().implies(qs) + } (Type::Operator(TypeOperator::Intersection, t1, t2, _), typ) => { t1.is_subtype_raw(typ, context) || t2.is_subtype_raw(typ, context) } @@ -346,6 +368,10 @@ impl Type { pub fn lift(self, max_index: &(VecType, i32)) -> Type { match self.clone() { Type::Vec(_, i, _, _) if i.equal(max_index.1) => self, + // A parameter that already fixes a length keeps it: lifting only + // widens scalars and unsized vectors, it must not turn `[2, num]` + // into `[3, num]` to make a `[3, num]` argument fit. + Type::Vec(_, i, _, _) if matches!(*i, Type::Integer(..)) => self, Type::Vec(v, _, t, h) => Type::Vec(v, Box::new(builder::integer_type(max_index.1)), t.clone(), h.clone()), t => Type::Vec( max_index.0.clone(), @@ -768,8 +794,35 @@ impl Type { } } + /// The refinements this type carries, read back from its structure: + /// the length index of a `Vec` (`[5, int]` is `[int] & length(5)`, D4), + /// the value of a literal, or the set of an explicit `Refined`. + pub fn refinements_of(&self) -> RefinementSet { + match self { + Type::Vec(_, index, _, _) => match &**index { + Type::Integer(Tint::Val(n), _) => RefinementSet::single(&Refinement::Length(*n)), + _ => RefinementSet::empty(), + }, + Type::Integer(Tint::Val(n), _) => RefinementSet::empty().with(Measure::Value, Interval::point(*n as f64)), + Type::Number(Tnum::Val(v), _) => RefinementSet::empty().with(Measure::Value, Interval::point(*v)), + Type::Refined(base, refs, _) => base.refinements_of().meet(refs), + _ => RefinementSet::empty(), + } + } + + /// `self` without its explicit refinements (`int & (> 0)` gives `int`). + /// The length index of a `Vec` is part of its structure and is kept. + pub fn unrefined(&self) -> &Type { + match self { + Type::Refined(base, _, _) => base.unrefined(), + t => t, + } + } + pub fn to_category(&self) -> TypeCategory { match self { + // A refined type dispatches like its base (`Refined <: base`). + Type::Refined(base, _, _) => base.to_category(), Type::Vec(_, _, _, _) => TypeCategory::Array, Type::Function(_, _, _) => TypeCategory::Function, Type::Record(_, _) => TypeCategory::Record, @@ -865,6 +918,8 @@ impl Type { Type::Null(h) => h.clone(), Type::NA(h) => h.clone(), Type::KindedGen(_, _, h) => h.clone(), + Type::Refined(_, _, h) => h.clone(), + Type::Property(_, h) => h.clone(), } } @@ -901,6 +956,8 @@ impl Type { Type::Null(_) => Type::Null(h2), Type::NA(_) => Type::NA(h2), Type::KindedGen(k, a, _) => Type::KindedGen(k, a, h2), + Type::Refined(b, r, _) => Type::Refined(b, r, h2), + Type::Property(p, _) => Type::Property(p, h2), } } @@ -1231,6 +1288,8 @@ impl PartialEq for Type { ) => IntersectionType::try_from(self.clone()).ok() == IntersectionType::try_from(other.clone()).ok(), (Type::Operator(op1, a1, b1, _), Type::Operator(op2, a2, b2, _)) => op1 == op2 && a1 == a2 && b1 == b2, (Type::Module(a1, _, _), Type::Module(a2, _, _)) => a1 == a2, + (Type::Refined(b1, r1, _), Type::Refined(b2, r2, _)) => b1 == b2 && r1 == r2, + (Type::Property(p1, _), Type::Property(p2, _)) => p1 == p2, _ => false, } } @@ -1372,6 +1431,15 @@ impl Hash for Type { 42.hash(state); k.hash(state); } + Type::Refined(base, refs, _) => { + 43.hash(state); + base.hash(state); + refs.hash(state); + } + Type::Property(p, _) => { + 44.hash(state); + p.hash(state); + } } } } @@ -1696,4 +1764,27 @@ mod tests { let ac = builder::union_type(&[a, c]); assert_ne!(ab, ac); } + + fn refined_int(set: RefinementSet) -> Type { + Type::Refined(Box::new(Type::Integer(Tint::Unknown, HelpData::default())), set, HelpData::default()) + } + fn gt(c: f64) -> RefinementSet { + RefinementSet::single(&Refinement::Gt(refinement::Num::new(c))) + } + + /// Phase 4: only a *proven* refinement makes a subtype. + #[test] + fn refined_subtyping_answers_true_only_when_proven() { + let ctx = Context::default(); + let int = Type::Integer(Tint::Unknown, HelpData::default()); + let lit = |n| Type::Integer(Tint::Val(n), HelpData::default()); + let (pos, big) = (refined_int(gt(0.0)), refined_int(gt(5.0))); + + assert!(lit(3).is_subtype(&pos, &ctx).0, "3 <: int & (> 0)"); + assert!(!lit(-3).is_subtype(&pos, &ctx).0, "-3 is refuted"); + assert!(!int.is_subtype(&pos, &ctx).0, "int is unknown, so not a subtype"); + assert!(pos.is_subtype(&int, &ctx).0, "Refined <: base"); + assert!(big.is_subtype(&pos, &ctx).0, "(> 5) implies (> 0)"); + assert!(!pos.is_subtype(&big, &ctx).0, "(> 0) does not imply (> 5)"); + } } diff --git a/crates/typr-core/src/components/type/refinement.rs b/crates/typr-core/src/components/type/refinement.rs new file mode 100644 index 0000000..66c733b --- /dev/null +++ b/crates/typr-core/src/components/type/refinement.rs @@ -0,0 +1,570 @@ +//! Refinement algebra: properties normalised as *intervals over measures*. +//! +//! A refinement such as `length(5)` or `(> 0)` is not kept as a bare +//! predicate. It is folded into an [`Interval`] on a [`Measure`] (the length +//! of a vector, the value of a scalar), and a [`RefinementSet`] holds at most +//! one interval per measure. That gives, by construction: +//! +//! * commutativity / associativity / idempotence of `&` (interval meet), +//! * `contradicts` = empty interval, `implies` = interval inclusion, +//! * a canonical, deterministic `Hash`/`Eq` (the set is kept sorted), which +//! the subtype cache relies on. +//! +//! Nothing here knows about `Type`; see `type_arithmetic` for the wiring. + +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::fmt; +use std::hash::{Hash, Hasher}; + +/// A finite `f64` with a total order and a canonical `Hash`/`Eq` +/// (so `-0.0 == 0.0`). Mirrors what `Tnum` does for its payload. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct Num(f64); + +impl Num { + pub fn new(v: f64) -> Self { + // Normalise -0.0 so that equality and hashing agree. + Num(if v == 0.0 { 0.0 } else { v }) + } + + pub fn get(&self) -> f64 { + self.0 + } +} + +impl PartialEq for Num { + fn eq(&self, other: &Self) -> bool { + self.0.total_cmp(&other.0) == Ordering::Equal + } +} +impl Eq for Num {} + +impl PartialOrd for Num { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Num { + fn cmp(&self, other: &Self) -> Ordering { + self.0.total_cmp(&other.0) + } +} +impl Hash for Num { + fn hash(&self, state: &mut H) { + self.0.to_bits().hash(state); + } +} + +impl fmt::Display for Num { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// What a property talks about. Extensible (`Nchar`, `Nrow`, `Ncol`, ...). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Measure { + /// The value itself (`(> 0)` on `int`/`num`; every element for a vector). + Value, + /// `length(x)`. + Length, +} + +impl fmt::Display for Measure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Measure::Value => write!(f, "value"), + Measure::Length => write!(f, "length"), + } + } +} + +/// One end of an interval. On the lower end `Open(c)` means `> c`; on the +/// upper end it means `< c`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Bound { + Unbounded, + Open(Num), + Closed(Num), +} + +impl Bound { + fn value(&self) -> Option { + match self { + Bound::Unbounded => None, + Bound::Open(n) | Bound::Closed(n) => Some(*n), + } + } + + /// The tighter of two *lower* bounds. + fn max_lower(self, other: Bound) -> Bound { + match (self, other) { + (Bound::Unbounded, b) | (b, Bound::Unbounded) => b, + (a, b) => match a.value().cmp(&b.value()) { + Ordering::Greater => a, + Ordering::Less => b, + // Same constant: the open one excludes more. + Ordering::Equal => match a { + Bound::Open(_) => a, + _ => b, + }, + }, + } + } + + /// The tighter of two *upper* bounds. + fn min_upper(self, other: Bound) -> Bound { + match (self, other) { + (Bound::Unbounded, b) | (b, Bound::Unbounded) => b, + (a, b) => match a.value().cmp(&b.value()) { + Ordering::Less => a, + Ordering::Greater => b, + Ordering::Equal => match a { + Bound::Open(_) => a, + _ => b, + }, + }, + } + } +} + +/// A (possibly unbounded) interval of reals. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Interval { + lo: Bound, + hi: Bound, +} + +impl Interval { + pub fn new(lo: Bound, hi: Bound) -> Self { + Interval { lo, hi } + } + + pub fn full() -> Self { + Interval::new(Bound::Unbounded, Bound::Unbounded) + } + + /// `[c, c]` + pub fn point(c: f64) -> Self { + let n = Num::new(c); + Interval::new(Bound::Closed(n), Bound::Closed(n)) + } + + /// `(c, +inf)` + pub fn greater_than(c: f64) -> Self { + Interval::new(Bound::Open(Num::new(c)), Bound::Unbounded) + } + + /// `[c, +inf)` + pub fn at_least(c: f64) -> Self { + Interval::new(Bound::Closed(Num::new(c)), Bound::Unbounded) + } + + /// `(-inf, c)` + pub fn less_than(c: f64) -> Self { + Interval::new(Bound::Unbounded, Bound::Open(Num::new(c))) + } + + /// `(-inf, c]` + pub fn at_most(c: f64) -> Self { + Interval::new(Bound::Unbounded, Bound::Closed(Num::new(c))) + } + + pub fn lo(&self) -> Bound { + self.lo + } + + pub fn hi(&self) -> Bound { + self.hi + } + + pub fn is_full(&self) -> bool { + *self == Interval::full() + } + + /// The single value of a degenerate `[c, c]` interval. + pub fn as_point(&self) -> Option { + match (self.lo, self.hi) { + (Bound::Closed(a), Bound::Closed(b)) if a == b => Some(a.get()), + _ => None, + } + } + + pub fn is_empty(&self) -> bool { + match (self.lo.value(), self.hi.value()) { + (Some(l), Some(h)) => match l.cmp(&h) { + Ordering::Greater => true, + Ordering::Equal => { + !(matches!(self.lo, Bound::Closed(_)) && matches!(self.hi, Bound::Closed(_))) + } + Ordering::Less => false, + }, + _ => false, + } + } + + /// Intersection (`&`). + pub fn meet(&self, other: &Interval) -> Interval { + Interval::new(self.lo.max_lower(other.lo), self.hi.min_upper(other.hi)) + } + + /// `self ⊆ other`: every value allowed by `self` is allowed by `other`. + /// An empty `self` implies everything. + pub fn implies(&self, other: &Interval) -> bool { + self.is_empty() || self.meet(other) == *self + } + + /// Some value satisfies both. + pub fn compatible(&self, other: &Interval) -> bool { + !self.meet(other).is_empty() + } + + /// No value satisfies both. + pub fn contradicts(&self, other: &Interval) -> bool { + !self.compatible(other) + } + + /// Tighten the bounds to integers: `(> 0)` becomes `[1, +inf)`, + /// `(< 1)` becomes `(-inf, 0]`. Used when the base type is `int`, so that + /// `int & (> 0) & (< 1)` is detected as empty. + pub fn to_integral(&self) -> Interval { + let lo = match self.lo { + Bound::Unbounded => Bound::Unbounded, + Bound::Closed(n) => Bound::Closed(Num::new(n.get().ceil())), + Bound::Open(n) => Bound::Closed(Num::new(n.get().floor() + 1.0)), + }; + let hi = match self.hi { + Bound::Unbounded => Bound::Unbounded, + Bound::Closed(n) => Bound::Closed(Num::new(n.get().floor())), + Bound::Open(n) => Bound::Closed(Num::new(n.get().ceil() - 1.0)), + }; + Interval::new(lo, hi) + } +} + +impl Measure { + /// Renders an interval on this measure in surface syntax, one property + /// per bound: `length(5)`, `(> 0) & (< 10)`. + pub fn display(&self, iv: &Interval) -> String { + if let Some(p) = iv.as_point() { + return match self { + Measure::Length => format!("length({})", p), + Measure::Value => format!("(== {})", Num::new(p)), + }; + } + let mut parts = Vec::new(); + let (lo_op, hi_op) = match self { + Measure::Value => (("(> ", "(>= "), ("(< ", "(<= ")), + Measure::Length => (("length(> ", "length(>= "), ("length(< ", "length(<= ")), + }; + match iv.lo { + Bound::Open(n) => parts.push(format!("{}{})", lo_op.0, n)), + Bound::Closed(n) => parts.push(format!("{}{})", lo_op.1, n)), + Bound::Unbounded => {} + } + match iv.hi { + Bound::Open(n) => parts.push(format!("{}{})", hi_op.0, n)), + Bound::Closed(n) => parts.push(format!("{}{})", hi_op.1, n)), + Bound::Unbounded => {} + } + parts.join(" & ") + } +} + +/// Surface form of a single property, as the parser produces it. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Refinement { + /// `length(5)` + Length(i32), + /// `(> c)` + Gt(Num), + /// `(< c)` + Lt(Num), + /// Every other bound: `(>= c)`, `(<= c)`, `length(> n)`, `length(<= n)`... + Range(Measure, Interval), +} + +impl Refinement { + pub fn measure(&self) -> Measure { + match self { + Refinement::Length(_) => Measure::Length, + Refinement::Gt(_) | Refinement::Lt(_) => Measure::Value, + Refinement::Range(m, _) => *m, + } + } + + pub fn interval(&self) -> Interval { + match self { + Refinement::Length(n) => Interval::point(*n as f64), + Refinement::Gt(c) => Interval::greater_than(c.get()), + Refinement::Lt(c) => Interval::less_than(c.get()), + Refinement::Range(_, iv) => *iv, + } + } +} + +impl fmt::Display for Refinement { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Refinement::Length(n) => write!(f, "length({})", n), + Refinement::Gt(c) => write!(f, "(> {})", c), + Refinement::Lt(c) => write!(f, "(< {})", c), + Refinement::Range(m, iv) => write!(f, "{}", m.display(iv)), + } + } +} + +/// A conjunction of properties: at most one non-full interval per measure, +/// kept sorted by measure so that `Hash`/`Eq` are canonical. (A `Vec` rather +/// than a `BTreeMap` so it round-trips through non-string-keyed formats.) +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub struct RefinementSet(Vec<(Measure, Interval)>); + +impl RefinementSet { + pub fn empty() -> Self { + RefinementSet(Vec::new()) + } + + pub fn single(r: &Refinement) -> Self { + RefinementSet::empty().with(r.measure(), r.interval()) + } + + pub fn is_trivial(&self) -> bool { + self.0.is_empty() + } + + pub fn get(&self, m: Measure) -> Option<&Interval> { + self.0.iter().find(|(k, _)| *k == m).map(|(_, iv)| iv) + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } + + /// Add a constraint on `m`, intersecting with what is already there. + pub fn with(mut self, m: Measure, iv: Interval) -> Self { + match self.0.iter_mut().find(|(k, _)| *k == m) { + Some((_, cur)) => *cur = cur.meet(&iv), + None => { + if !iv.is_full() { + self.0.push((m, iv)); + self.0.sort_by_key(|(k, _)| *k); + } + } + } + self + } + + /// Intersection (`&`) of two sets. + pub fn meet(&self, other: &RefinementSet) -> RefinementSet { + other + .0 + .iter() + .fold(self.clone(), |acc, (m, iv)| acc.with(*m, *iv)) + } + + /// Some measure has an empty interval: no value can satisfy the set. + /// `integral` rounds bounds to integers first (base type `int`, and + /// always for `Length`). + pub fn is_empty(&self, integral: bool) -> bool { + self.0.iter().any(|(m, iv)| { + let iv = if integral || *m == Measure::Length { iv.to_integral() } else { *iv }; + iv.is_empty() + }) + } + + /// `self ⊆ other`: every measure constrained by `other` is constrained at + /// least as tightly by `self`. + pub fn implies(&self, other: &RefinementSet) -> bool { + other.0.iter().all(|(m, want)| match self.get(*m) { + Some(have) => have.implies(want), + None => want.is_full(), + }) + } + + /// Some value satisfies both sets. + pub fn compatible(&self, integral: bool) -> bool { + !self.is_empty(integral) + } + + /// No value satisfies both `self` and `other`. + pub fn contradicts(&self, other: &RefinementSet, integral: bool) -> bool { + self.meet(other).is_empty(integral) + } +} + +impl fmt::Display for RefinementSet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let parts: Vec = self.0.iter().map(|(m, iv)| m.display(iv)).collect(); + write!(f, "{}", parts.join(" & ")) + } +} + +/// A runtime check the type checker decided is needed at one boundary +/// (Phase 5, D5). Keyed by the source span of the checked expression; the +/// transpiler only reads the table and never reasons about refinements. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RefinementObligation { + pub file: String, + pub start: usize, + pub end: usize, + pub set: RefinementSet, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gt(c: f64) -> RefinementSet { + RefinementSet::single(&Refinement::Gt(Num::new(c))) + } + fn lt(c: f64) -> RefinementSet { + RefinementSet::single(&Refinement::Lt(Num::new(c))) + } + fn len(n: i32) -> RefinementSet { + RefinementSet::single(&Refinement::Length(n)) + } + + #[test] + fn meet_is_commutative_associative_idempotent() { + let (a, b, c) = (gt(0.0), lt(10.0), gt(3.0)); + assert_eq!(a.meet(&b), b.meet(&a)); + assert_eq!(a.meet(&b).meet(&c), a.meet(&b.meet(&c))); + assert_eq!(a.meet(&a), a); + assert_eq!(len(5).meet(&len(5)), len(5)); + } + + #[test] + fn contradictions() { + assert!(gt(10.0).contradicts(<(5.0), false)); + assert!(len(5).contradicts(&len(10), false)); + assert!(!gt(0.0).contradicts(<(10.0), false)); + // (> 5) & (< 5) is empty even over the reals + assert!(gt(5.0).contradicts(<(5.0), false)); + } + + #[test] + fn integer_rounding_detects_gaps() { + // (> 0) & (< 1): fine over num, empty over int + let s = gt(0.0).meet(<(1.0)); + assert!(!s.is_empty(false)); + assert!(s.is_empty(true)); + } + + #[test] + fn implication_is_interval_inclusion() { + assert!(gt(5.0).implies(>(0.0))); + assert!(!gt(0.0).implies(>(5.0))); + assert!(gt(0.0).implies(&RefinementSet::empty())); + assert!(!RefinementSet::empty().implies(>(0.0))); + assert!(len(5).implies(&RefinementSet::empty())); + // (> 0) & (< 10) implies (< 20) + assert!(gt(0.0).meet(<(10.0)).implies(<(20.0))); + } + + #[test] + fn open_closed_at_same_constant() { + let open = Interval::greater_than(3.0); + let closed = Interval::at_least(3.0); + assert!(open.implies(&closed)); + assert!(!closed.implies(&open)); + assert_eq!(open.meet(&closed), open); + } + + #[test] + fn range_properties_share_the_interval_model() { + let len_pos = RefinementSet::single(&Refinement::Range(Measure::Length, Interval::greater_than(0.0))); + // a point length implies a range that contains it, and only that + assert!(len(5).implies(&len_pos)); + assert!(!len(0).implies(&len_pos)); + assert!(len(0).contradicts(&len_pos, true)); + assert!(!len_pos.implies(&len(5))); + // `length(> 0)` and `length(< 1)` leave no integer length + let none = len_pos.meet(&RefinementSet::single(&Refinement::Range(Measure::Length, Interval::less_than(1.0)))); + assert!(none.is_empty(true)); + assert_eq!(len_pos.to_string(), "length(> 0)"); + let ge = RefinementSet::single(&Refinement::Range(Measure::Value, Interval::at_least(0.0))); + assert_eq!(ge.to_string(), "(>= 0)"); + assert!(gt(0.0).implies(&ge) && !ge.implies(>(0.0))); + } + + #[test] + fn negative_zero_is_canonical() { + assert_eq!(Num::new(-0.0), Num::new(0.0)); + assert_eq!(gt(-0.0), gt(0.0)); + } + + #[test] + fn display_uses_surface_syntax() { + assert_eq!(len(5).to_string(), "length(5)"); + assert_eq!(gt(0.0).to_string(), "(> 0)"); + assert_eq!(gt(0.0).meet(<(10.0)).to_string(), "(> 0) & (< 10)"); + } +} + +#[cfg(test)] +mod type_tests { + use super::*; + use crate::components::error_message::help_data::HelpData; + use crate::components::r#type::tint::Tint; + use crate::components::r#type::type_system::TypeSystem; + use crate::components::r#type::vector_type::VecType; + use crate::components::r#type::Type; + use crate::utils::builder; + use std::collections::HashSet; + + fn refined(base: Type, rs: RefinementSet) -> Type { + Type::Refined(Box::new(base), rs, HelpData::default()) + } + fn int_lit(n: i32) -> Type { + Type::Integer(Tint::Val(n), HelpData::default()) + } + fn vec_of_len(n: Option) -> Type { + let index = match n { + Some(n) => int_lit(n), + None => builder::integer_type_default(), + }; + Type::Vec(VecType::S3, Box::new(index), Box::new(builder::integer_type_default()), HelpData::default()) + } + + #[test] + fn refined_equality_and_hash_ignore_construction_order() { + let a = refined( + builder::integer_type_default(), + RefinementSet::single(&Refinement::Gt(Num::new(0.0))).meet(&RefinementSet::single(&Refinement::Lt(Num::new(9.0)))), + ); + let b = refined( + builder::integer_type_default(), + RefinementSet::single(&Refinement::Lt(Num::new(9.0))).meet(&RefinementSet::single(&Refinement::Gt(Num::new(0.0)))), + ); + assert_eq!(a, b); + let set: HashSet = [a, b].into_iter().collect(); + assert_eq!(set.len(), 1); + } + + #[test] + fn refinements_are_read_back_from_structure() { + assert_eq!(vec_of_len(Some(5)).refinements_of(), RefinementSet::single(&Refinement::Length(5))); + assert!(vec_of_len(None).refinements_of().is_trivial()); + assert_eq!( + int_lit(3).refinements_of().get(Measure::Value), + Some(&Interval::point(3.0)) + ); + let r = refined(vec_of_len(Some(2)), RefinementSet::single(&Refinement::Gt(Num::new(0.0)))); + let got = r.refinements_of(); + assert_eq!(got.get(Measure::Length), Some(&Interval::point(2.0))); + assert_eq!(got.get(Measure::Value), Some(&Interval::greater_than(0.0))); + } + + #[test] + fn refined_prints_and_dispatches_like_its_base() { + let r = refined( + builder::integer_type_default(), + RefinementSet::single(&Refinement::Gt(Num::new(0.0))), + ); + assert_eq!(r.pretty(), "int & (> 0)"); + assert_eq!(r.to_category(), builder::integer_type_default().to_category()); + assert_eq!(r.unrefined(), &builder::integer_type_default()); + } +} diff --git a/crates/typr-core/src/components/type/type_printer.rs b/crates/typr-core/src/components/type/type_printer.rs index 2cb8fa3..606d40c 100644 --- a/crates/typr-core/src/components/type/type_printer.rs +++ b/crates/typr-core/src/components/type/type_printer.rs @@ -149,6 +149,8 @@ pub fn format(ty: &Type) -> String { format!("({} {} {})", left.pretty(), op, right.pretty()) } Type::Variable(name, _) => name.to_string(), + Type::Refined(base, refs, _) => format!("{} & {}", format(base), refs), + Type::Property(p, _) => p.to_string(), t => format!("{:?}", t), } } diff --git a/crates/typr-core/src/processes/parsing/types.rs b/crates/typr-core/src/processes/parsing/types.rs index 5433143..c24e402 100644 --- a/crates/typr-core/src/processes/parsing/types.rs +++ b/crates/typr-core/src/processes/parsing/types.rs @@ -4,6 +4,7 @@ use crate::components::language::operators::{op, Op}; use crate::components::language::Lang; use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::kind::Kind; +use crate::components::r#type::refinement::{Interval, Measure, Num, Refinement}; use crate::components::r#type::tbool::Tbool; use crate::components::r#type::tchar::Tchar; use crate::components::r#type::tint::Tint; @@ -648,6 +649,67 @@ pub fn single_letter_type_alias(s: Span) -> IResult { } } +/// A refinement property, only meaningful right of `&`: `length(5)`, `(> 0)`, +/// `(< 2.5)`. It becomes a `Type::Property`, which `norm_intersection` folds +/// into a `Refined` type. Tried before `parenthese_value` and `type_variable`, +/// which would otherwise read `(> 0)` / `length` as a group / a variable. +fn refinement_property(s: Span) -> IResult { + /// `>`, `>=`, `<`, `<=` + fn comparison(s: Span) -> IResult { + let (s, op) = one_of("<>").parse(s)?; + let (s, eq) = opt(tag("=")).parse(s)?; + let (s, _) = multispace0.parse(s)?; + Ok((s, (op, eq.is_some()))) + } + fn number(s: Span) -> IResult { + terminated(recognize((opt(tag("-")), digit1, opt((tag("."), digit1)))), multispace0).parse(s) + } + /// `length(5)` or `length(> 0)` + fn length_prop(s: Span) -> IResult, Span)> { + let (s, kw) = terminated(tag("length"), multispace0).parse(s)?; + let (s, _) = terminated(tag("("), multispace0).parse(s)?; + let (s, cmp) = opt(comparison).parse(s)?; + let (s, n) = terminated(digit1, multispace0).parse(s)?; + let (s, _) = tag(")").parse(s)?; + Ok((s, (kw, cmp, n))) + } + fn compare_prop(s: Span) -> IResult { + let (s, _) = terminated(tag("("), multispace0).parse(s)?; + let (s, cmp) = comparison(s)?; + let (s, n) = number(s)?; + let (s, _) = tag(")").parse(s)?; + Ok((s, (cmp, n))) + } + let help: HelpData = s.clone().into(); + if let Ok((rest, (kw, cmp, n))) = length_prop(s.clone()) { + let Ok(n) = (*n).parse::() else { + return Err(nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Digit))); + }; + let prop = match cmp { + None => Refinement::Length(n), + Some(cmp) => Refinement::Range(Measure::Length, bound_interval(cmp, n as f64)), + }; + return Ok((rest, Type::Property(prop, kw.into()))); + } + let (rest, (cmp, num)) = compare_prop(s)?; + let c = (*num).parse().unwrap_or(0.0); + let prop = match cmp { + ('>', false) => Refinement::Gt(Num::new(c)), + ('<', false) => Refinement::Lt(Num::new(c)), + _ => Refinement::Range(Measure::Value, bound_interval(cmp, c)), + }; + Ok((rest, Type::Property(prop, help))) +} + +fn bound_interval((op, eq): (char, bool), c: f64) -> Interval { + match (op, eq) { + ('>', false) => Interval::greater_than(c), + ('>', true) => Interval::at_least(c), + ('<', false) => Interval::less_than(c), + _ => Interval::at_most(c), + } +} + fn parenthese_value(s: Span) -> IResult { delimited( terminated(tag("("), multispace0), @@ -936,43 +998,21 @@ fn empty(s: Span) -> IResult { } } -fn compute_operators(v: &mut Vec<(Type, Op)>) -> Type { - // (params, op) - let first = v.pop().unwrap(); - match first { - (p, Op::Add(_)) => { - let res = compute_operators(v); - let pp = p; - Type::Operator(TypeOperator::Addition, Box::new(res.clone()), Box::new(pp), res.into()) - } - (p, Op::Minus(_)) => { - let res = compute_operators(v); - let pp = p; - Type::Operator( - TypeOperator::Substraction, - Box::new(res.clone()), - Box::new(pp), - res.into(), - ) - } - (p, Op::Mul(_)) => { - let res = compute_operators(v); - let pp = p; - Type::Operator( - TypeOperator::Multiplication, - Box::new(res.clone()), - Box::new(pp), - res.into(), - ) - } - (p, Op::Div(_)) => { - let res = compute_operators(v); - let pp = p; - Type::Operator(TypeOperator::Division, Box::new(res.clone()), Box::new(pp), res.into()) - } - (p, Op::Empty(_)) => p, - _ => panic!(), - } +fn compute_operators(v: &mut Vec<(Type, Op)>) -> Option { + // (params, op). `None` when the chain holds an operator that has no + // meaning at the type level (`>`, `<`, `==`, ...): the caller turns that + // into a parse failure instead of aborting. + let (p, op) = v.pop()?; + let arith = match op { + Op::Add(_) => TypeOperator::Addition, + Op::Minus(_) => TypeOperator::Substraction, + Op::Mul(_) => TypeOperator::Multiplication, + Op::Div(_) => TypeOperator::Division, + Op::Empty(_) => return Some(p), + _ => return None, + }; + let res = compute_operators(v)?; + Some(Type::Operator(arith, Box::new(res.clone()), Box::new(p), res.into())) } fn index_operator(s: Span) -> IResult { @@ -985,9 +1025,12 @@ fn index_operator(s: Span) -> IResult { } fn index_chain(s: Span) -> IResult { - let res = many1(index_operator).parse(s); + let res = many1(index_operator).parse(s.clone()); match res { - Ok((s, v)) => Ok((s, compute_operators(&mut v.clone()))), + Ok((rest, v)) => match compute_operators(&mut v.clone()) { + Some(t) => Ok((rest, t)), + None => Err(nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Verify))), + }, Err(r) => Err(r), } } @@ -1171,6 +1214,7 @@ pub fn single_type(s: Span) -> IResult { bracket_tuple_record, // Tuple[...] / Record[...] — before type_alias composite_vec_type, list_types, // tuple{}/record{}/list{} brace forms + refinement_property, // length(n), (> c), (< c) parenthese_value, tag_type, any, @@ -1205,6 +1249,36 @@ mod tests { use crate::components::r#type::type_system::TypeSystem; use crate::utils::builder; + #[test] + fn test_refinement_property_parses() { + let ok = |src: &str| refinement_property(src.into()).unwrap().1; + assert!(matches!(ok("length(5)"), Type::Property(Refinement::Length(5), _))); + assert!(matches!(ok("length( 5 )"), Type::Property(Refinement::Length(5), _))); + assert!(matches!(ok("(> 0)"), Type::Property(Refinement::Gt(_), _))); + assert!(matches!(ok("(< -2.5)"), Type::Property(Refinement::Lt(_), _))); + assert!(matches!(ok("(>= 0)"), Type::Property(Refinement::Range(Measure::Value, _), _))); + assert!(matches!(ok("(<= 9.5)"), Type::Property(Refinement::Range(Measure::Value, _), _))); + assert!(matches!(ok("length(> 0)"), Type::Property(Refinement::Range(Measure::Length, _), _))); + assert!(matches!(ok("length(<= 10)"), Type::Property(Refinement::Range(Measure::Length, _), _))); + } + + #[test] + fn test_refinement_property_rejects_lookalikes() { + assert!(refinement_property("length(x)".into()).is_err()); + assert!(refinement_property("length".into()).is_err()); + assert!(refinement_property("(int)".into()).is_err()); + assert!(refinement_property("(== 0)".into()).is_err()); + assert!(refinement_property("length(>)".into()).is_err()); + } + + #[test] + fn test_intersection_with_property_does_not_panic() { + let t = ltype("int & (> 0)".into()).unwrap().1; + assert!(matches!(t, Type::Operator(TypeOperator::Intersection, _, _, _))); + let v = ltype("[int] & length(5)".into()).unwrap().1; + assert!(matches!(v, Type::Operator(TypeOperator::Intersection, _, _, _))); + } + #[test] fn test_fabrice0() { let arr1 = ltype("[1, T]".into()).unwrap().1; diff --git a/crates/typr-core/src/processes/transpiling/mod.rs b/crates/typr-core/src/processes/transpiling/mod.rs index 387cd78..7e06ae1 100644 --- a/crates/typr-core/src/processes/transpiling/mod.rs +++ b/crates/typr-core/src/processes/transpiling/mod.rs @@ -1,4 +1,5 @@ pub mod checked_assertions; +pub mod refinement_checks; pub mod translatable; use crate::components::context::config::Environment; @@ -1236,7 +1237,17 @@ impl RTranslatable<(String, Context)> for Lang { } } else { let (val_str, _) = val.to_simple_r(cont); - format!("{}[[{}]]", exp_str, val_str) + // `v[1:3]` / `v[mask]` select a sub-vector: single bracket + // (`v[[1:3]]` would index recursively in R). + let selects_subvector = matches!(reduce_type(cont, &typing(cont, exp).value), Type::Vec(..)) + && matches!(val.get_members_if_array().as_deref(), Some([m]) + if !matches!(m, Lang::Integer { .. } | Lang::Array { .. } | Lang::Vector { .. }) + && matches!(reduce_type(cont, &typing(cont, m).value), Type::Vec(..))); + if selects_subvector { + format!("{}[{}]", exp_str, val_str) + } else { + format!("{}[[{}]]", exp_str, val_str) + } }; (res, cont.clone()) } @@ -2814,7 +2825,22 @@ impl RTranslatable<(String, Context)> for Lang { result }; - result + // Refinement the type checker could not prove at this boundary (`let` + // initialiser, call argument; `refinement_check::coerce_to`): the + // obligation is keyed by this expression's span. Single insertion + // point, so every boundary kind shares it. + let (r_code, r_cont) = result; + // `Lines`/`Scope`/`If`/`Return` share their span with the expression + // they hand the position to, which does the wrapping. + let r_code = if matches!( + self, + Lang::Lines { .. } | Lang::Scope { .. } | Lang::If { .. } | Lang::Return { .. } + ) { + r_code + } else { + refinement_checks::wrap_obligation(cont, r_code, &self.get_help_data()) + }; + (r_code, r_cont) } } diff --git a/crates/typr-core/src/processes/transpiling/refinement_checks.rs b/crates/typr-core/src/processes/transpiling/refinement_checks.rs new file mode 100644 index 0000000..d82887a --- /dev/null +++ b/crates/typr-core/src/processes/transpiling/refinement_checks.rs @@ -0,0 +1,98 @@ +//! Runtime side of refined types (`refined_types_plan.md`, D5/D6). +//! +//! The type checker decides, at each boundary, which refinements it could not +//! prove and records them as obligations in the `Context` +//! (`refinement_check::coerce_to`). This module only reads that table and +//! wraps the transpiled expression in the prelude helpers of `std.R`: +//! +//! ```r +//! a <- typr_refine_length(read_coordinates(), 2L, "main.ty:12") +//! ``` +//! +//! The helpers return their argument, so the wrap works in any expression +//! position. Nothing is emitted for a boundary without an obligation. + +use crate::components::context::Context; +use crate::components::error_message::help_data::HelpData; +use crate::components::r#type::refinement::{Bound, Interval, Measure, RefinementSet}; +use crate::processes::transpiling::escape_r_string; + +/// Wrap `r_expr`, the transpilation of the expression at `expr_h`, in the +/// checks the type checker required there. `r_expr` is returned unchanged +/// when there is no obligation. +pub fn wrap_obligation(context: &Context, r_expr: String, expr_h: &HelpData) -> String { + match context.refinement_obligation_at(expr_h) { + Some(set) => wrap_set(r_expr, set, &format_loc(expr_h)), + None => r_expr, + } +} + +pub fn wrap_set(r_expr: String, set: &RefinementSet, loc: &str) -> String { + set.iter().fold(r_expr, |acc, (measure, iv)| wrap_measure(acc, *measure, iv, loc)) +} + +fn wrap_measure(x: String, measure: Measure, iv: &Interval, loc: &str) -> String { + let loc = escape_r_string(loc); + match (measure, iv.as_point()) { + (Measure::Length, Some(n)) => format!("typr_refine_length({}, {}L, {})", x, n as i64, loc), + // Only point lengths can be written today; a range would need its own + // helper, and dropping the check silently is not an option. + (Measure::Length, None) => format!("typr_refine_length_range({}, {}, {})", x, bound_args(iv), loc), + (Measure::Value, _) => format!("typr_refine_value({}, {}, {})", x, bound_args(iv), loc), + } +} + +/// `lo, lo_open, hi, hi_open` as R arguments (`-Inf`/`Inf` when unbounded). +fn bound_args(iv: &Interval) -> String { + let (lo, lo_open) = match iv.lo() { + Bound::Unbounded => ("-Inf".to_string(), false), + Bound::Open(c) => (r_number(c.get()), true), + Bound::Closed(c) => (r_number(c.get()), false), + }; + let (hi, hi_open) = match iv.hi() { + Bound::Unbounded => ("Inf".to_string(), false), + Bound::Open(c) => (r_number(c.get()), true), + Bound::Closed(c) => (r_number(c.get()), false), + }; + format!("{}, {}, {}, {}", lo, lo_open.to_string().to_uppercase(), hi, hi_open.to_string().to_uppercase()) +} + +fn r_number(v: f64) -> String { + if v.fract() == 0.0 && v.abs() < 1e9 { + format!("{}", v as i64) + } else { + format!("{}", v) + } +} + +fn format_loc(loc: &HelpData) -> String { + let file = loc.get_file_name(); + if file.is_empty() { + return "".to_string(); + } + let line = loc.get_file_data().and_then(|(_, content)| { + let offset = loc.get_offset().min(content.len()); + content.get(..offset).map(|prefix| prefix.matches('\n').count() + 1) + }); + match line { + Some(line) => format!("{}:{}", file, line), + None => format!("{}:offset {}", file, loc.get_offset()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::r#type::refinement::Refinement; + + #[test] + fn length_and_value_forms() { + let set = RefinementSet::single(&Refinement::Length(3)); + assert_eq!(wrap_set("x".into(), &set, "m.ty:1"), "typr_refine_length(x, 3L, \"m.ty:1\")"); + let set = RefinementSet::single(&Refinement::Gt(crate::components::r#type::refinement::Num::new(0.0))); + assert_eq!( + wrap_set("x".into(), &set, "m.ty:1"), + "typr_refine_value(x, 0, TRUE, Inf, FALSE, \"m.ty:1\")" + ); + } +} diff --git a/crates/typr-core/src/processes/type_checking/function.rs b/crates/typr-core/src/processes/type_checking/function.rs index 2768fea..d94b57e 100644 --- a/crates/typr-core/src/processes/type_checking/function.rs +++ b/crates/typr-core/src/processes/type_checking/function.rs @@ -349,7 +349,9 @@ pub fn function( // C1 (audit_type_checking.md): thread the declared return type through so // every early `return` inside the body — not just the trailing expression // — gets checked against it (see `Lang::Return` in `type_checking/mod.rs`). - sub_context = sub_context.set_expected_return_type(Some(ret_ty.clone())); + sub_context = sub_context + .set_expected_return_type(Some(ret_ty.clone())) + .set_return_position(true); let body_type = body.typing(&sub_context); let mut errors = body_type.errors; diff --git a/crates/typr-core/src/processes/type_checking/function_application.rs b/crates/typr-core/src/processes/type_checking/function_application.rs index 8cffcc6..f2151de 100644 --- a/crates/typr-core/src/processes/type_checking/function_application.rs +++ b/crates/typr-core/src/processes/type_checking/function_application.rs @@ -1,4 +1,7 @@ #![allow(dead_code, unused_variables, unused_imports, unreachable_code, unused_assignments)] +use crate::components::r#type::refinement::RefinementSet; +use crate::processes::type_checking::type_arithmetic::declared_refinements; +use crate::processes::type_checking::refinement_check::{coerce_to, field_obligations, residual, with_obligations, Coercion}; use crate::components::error_message::help_message::ErrorMsg; use crate::components::error_message::typr_error::TypRError; use crate::components::language::set_related_type_if_variable; @@ -1099,6 +1102,52 @@ fn specialize_lambda(lambda_lang: &Lang, lambda_type: &Type, expected_type: &Typ (lambda_lang.clone(), lambda_type.clone()) } +/// Literal `n` of an index type `Integer(Val(n))`, if it is one. +fn literal_index(t: &Type) -> Option { + match t { + Type::Integer(crate::components::r#type::tint::Tint::Val(n), _) => Some(*n as i64), + _ => None, + } +} + +/// Declared effects of a few base-R primitives on a vector's length +/// (refined_types_plan.md, phase 7). These functions live in `base.ty`, which +/// is doc-only, so the compiler sees them as untyped (`Any`); the effect is +/// hard-coded here and applied only to a result that is otherwise `Any`. +/// Every other function keeps the conservative default: its declared return +/// type, with no refinement. +/// +/// - `length(x)`, `x : [N, T]` -> the literal `N` +/// - `rev(x)` -> `x`'s own type (same length, same elements) +/// - `head(x, k)` / `tail(x, k)`, literal `k`, `x : [N, T]` +/// -> `[min(N, k), T]` (`[max(N + k, 0), T]` for `k < 0`) +fn known_effect_call(var: &Var, context: &Context, parameters: &[Lang]) -> Option { + let name = var.get_name(); + let arity = match name.as_str() { + "length" | "rev" => 1, + "head" | "tail" => 2, + _ => return None, + }; + if parameters.len() != arity { + return None; + } + let arg = reduce_type(context, &typing(context, ¶meters[0]).value); + let Type::Vec(vt, n, elem, h) = &arg else { + return None; + }; + match name.as_str() { + "length" => literal_index(n).map(|_| (**n).clone()), + "rev" => Some(arg.clone()), + _ => { + let len = literal_index(n)?; + let k = literal_index(&reduce_type(context, &typing(context, ¶meters[1]).value))?; + let kept = if k >= 0 { len.min(k) } else { (len + k).max(0) }; + let index = Type::Integer(crate::components::r#type::tint::Tint::Val(kept as _), h.clone()); + Some(Type::Vec(vt.clone(), Box::new(index), elem.clone(), h.clone())) + } + } +} + pub fn apply_from_variable(var: Var, context: &Context, parameters: &[Lang], h: &HelpData) -> TypeContext { thread_local! { static DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; @@ -1113,8 +1162,13 @@ pub fn apply_from_variable(var: Var, context: &Context, parameters: &[Lang], h: return TypeContext::new(builder::any_type(), Lang::Empty(h.clone()), context.clone()) .with_errors(vec![TypRError::Type(TypeError::FunctionNotFound(var.clone()))]); } - let result = apply_from_variable_inner(var, context, parameters, h); + let mut result = apply_from_variable_inner(var.clone(), context, parameters, h); DEPTH.with(|d| d.set(d.get() - 1)); + if matches!(result.value, Type::Any(_)) { + if let Some(len) = known_effect_call(&var, context, parameters) { + result.value = len; + } + } result } @@ -1282,22 +1336,49 @@ fn apply_from_variable_inner(var: Var, context: &Context, parameters: &[Lang], h }, ]; - for step in filters { - if let Some(fun_typ) = (step.filter)(&all_signatures, &types, &var, context) { - let (final_params, final_types) = specialize_lambdas(context, &expanded_parameters, &types, &fun_typ); - let final_fun_typ = match step.refine { - Some(refine) => refine(&all_signatures, &final_types, context).unwrap_or(fun_typ), - None => fun_typ, - }; - return build_success( - &var, - &final_fun_typ, - final_params, - &final_types, - param_errors, - context, - h, - ); + // Second attempt (refined types, plan Phase 5): when no signature accepts + // the arguments as typed, retry with each argument that only fails to + // *prove* a refinement (`[int]` given for `[3, int]`) standing in as the + // parameter type. The check that could not be proven statically becomes a + // runtime obligation on that argument, recorded in the context. + for attempt in 0..2 { + let (try_types, obligations): (Vec, Vec<(HelpData, RefinementSet)>) = if attempt == 0 { + (types.clone(), Vec::new()) + } else { + match relax_refined_arguments(&all_signatures, &types, &expanded_parameters, context) { + Some(relaxed) => relaxed, + None => break, + } + }; + let context = &obligations + .into_iter() + .fold(context.clone(), |ctx, (h, set)| ctx.add_refinement_obligation(&h, set)); + for step in filters { + if let Some(fun_typ) = (step.filter)(&all_signatures, &try_types, &var, context) { + let (final_params, final_types) = + specialize_lambdas(context, &expanded_parameters, &try_types, &fun_typ); + let final_fun_typ = match step.refine { + Some(refine) => refine(&all_signatures, &final_types, context).unwrap_or(fun_typ), + None => fun_typ, + }; + // A generic base (`[#N, T] & length(> 0)`) unifies whatever the + // refinements say: decide them here, against the arguments. + let Some(generic_obligations) = + generic_refinement_obligations(&final_fun_typ, &final_types, &expanded_parameters, context) + else { + continue; + }; + let context = &with_obligations(context.clone(), generic_obligations); + return build_success( + &var, + &final_fun_typ, + final_params, + &final_types, + param_errors, + context, + h, + ); + } } } @@ -1369,6 +1450,71 @@ fn apply_from_variable_inner(var: Var, context: &Context, parameters: &[Lang], h TypeContext::new(builder::any_type(), Lang::Empty(h.clone()), context.clone()).with_errors(errors) } +/// The refinements of a matched signature's parameters against the argument +/// types: `None` when one provably fails (the signature does not match), else +/// the runtime checks still owed, one per argument. Parameters whose base is +/// concrete were already decided by the subtype test; what this adds is the +/// generic ones, which unification accepts without looking at refinements. +fn generic_refinement_obligations( + sig: &FunctionType, + types: &[Type], + args: &[Lang], + context: &Context, +) -> Option> { + let mut out = Vec::new(); + for (i, (arg, param)) in types.iter().zip(sig.get_param_types().iter()).enumerate() { + // not reduced: the signature is already substituted (`N ↦ 0`), and reducing + // `[0, char] & length(> 0)` would collapse it to an error + let Some((base, want)) = declared_refinements(param) else { continue }; + let have = arg.reduce(context).refinements_of(); + if have.implies(&want) { + continue; + } + let integral = matches!(base, Type::Vec(..)); + if have.contradicts(&want, integral) { + return None; + } + out.push((args.get(i)?.get_help_data(), residual(&have, &want))); + } + Some(out) +} + +/// For the first signature of the right arity that every argument can flow +/// into (`coerce_to` is never `Reject`) with at least one argument that needs +/// a runtime check: the argument types with those arguments replaced by the +/// parameter type, and the residual to check at each replaced position. +fn relax_refined_arguments( + signatures: &[FunctionType], + types: &[Type], + args: &[Lang], + context: &Context, +) -> Option<(Vec, Vec<(HelpData, RefinementSet)>)> { + signatures.iter().find_map(|sig| { + let params = sig.get_param_types(); + if params.len() != types.len() { + return None; + } + let mut relaxed = Vec::with_capacity(types.len()); + let mut obligations = Vec::new(); + for (i, (arg, param)) in types.iter().zip(params.iter()).enumerate() { + match coerce_to(arg, param, context) { + Coercion::Static => relaxed.push(arg.clone()), + Coercion::Runtime(set) => { + relaxed.push(param.clone()); + obligations.push((args[i].get_help_data(), set)); + } + // a record literal argument: the checks go on its fields + Coercion::Reject => { + let fields = args.get(i).and_then(|a| field_obligations(a, arg, param, context))?; + relaxed.push(param.clone()); + obligations.extend(fields); + } + } + } + (!obligations.is_empty()).then_some((relaxed, obligations)) + }) +} + fn specialize_lambdas( context: &Context, params: &[Lang], diff --git a/crates/typr-core/src/processes/type_checking/let_expression.rs b/crates/typr-core/src/processes/type_checking/let_expression.rs index 371029a..2b43d85 100644 --- a/crates/typr-core/src/processes/type_checking/let_expression.rs +++ b/crates/typr-core/src/processes/type_checking/let_expression.rs @@ -1,7 +1,10 @@ use crate::components::error_message::type_error::TypeError; use crate::components::error_message::typr_error::TypRError; use crate::components::r#type::type_operator::TypeOperator; +use crate::components::r#type::type_system::TypeSystem; use crate::processes::type_checking::r#Type; +use crate::processes::type_checking::refinement_check::{coerce_to, field_obligations, with_obligations, Coercion}; +use crate::processes::type_checking::type_arithmetic::refinement_error; use crate::processes::type_checking::vectorizability::is_vectorizable_function; use crate::processes::type_checking::Context; use crate::processes::type_checking::HelpData; @@ -155,6 +158,9 @@ pub fn let_expression( }; let mut alias_errors = collect_undefined_aliases(context, ty); + if let Some(e) = refinement_error(&ty.reduce(context)) { + alias_errors.push(TypRError::type_error(e)); + } // E-EMBED-003 (named type embedding, `embedding.rs`): an explicit function // can only collide with an inherited embedded method by being declared after @@ -176,8 +182,28 @@ pub fn let_expression( } } - let mut res = exp - .typing(&new_context) + // Refined annotation (plan Phase 5): what `is_subtype` cannot prove but + // does not refute (`let x: [3, int] <- some_vector`) is accepted here and + // becomes a runtime obligation on the initialiser, instead of a static + // error. The variable then carries the annotated type from this point on. + let mut typed = exp.typing(&new_context); + if !ty.is_empty() && !typed.has_errors() { + match coerce_to(&typed.value, ty, &typed.context) { + Coercion::Runtime(set) => { + typed.context = typed.context.add_refinement_obligation(&exp.get_help_data(), set); + typed.value = ty.reduce(&typed.context); + } + // a record literal: the check goes on each field that needs one + Coercion::Reject => { + if let Some(obs) = field_obligations(exp, &typed.value, ty, &typed.context) { + typed.context = with_obligations(typed.context.clone(), obs); + typed.value = ty.reduce(&typed.context); + } + } + Coercion::Static => {} + } + } + let mut res = typed .get_covariant_type(ty) .add_to_context(Var::try_from(name).unwrap()); diff --git a/crates/typr-core/src/processes/type_checking/mod.rs b/crates/typr-core/src/processes/type_checking/mod.rs index ab9b822..9e741c9 100644 --- a/crates/typr-core/src/processes/type_checking/mod.rs +++ b/crates/typr-core/src/processes/type_checking/mod.rs @@ -11,7 +11,9 @@ pub mod interface_satisfaction; pub mod let_expression; pub mod match_expression; pub mod module_cache; +pub mod narrowing; pub mod partial_application; +pub mod refinement_check; pub mod signature_expression; pub mod type_arithmetic; pub mod type_checker; @@ -22,6 +24,7 @@ pub mod unification; pub mod unification_map; pub mod vectorizability; +use crate::processes::type_checking::refinement_check::{coerce_to, field_obligations, with_obligations, Coercion}; use crate::components::context::config::TargetLanguage; use crate::components::context::Context; use crate::components::error_message::help_data::HelpData; @@ -737,6 +740,11 @@ fn get_gen_type(type1: &Type, type2: &Type) -> Option> { } Some(res) } + // `[#N, T] & length(> 0)`: generics live in the base, the refinements + // are decided at the boundary (`coerce_to`). + (Type::Refined(b1, _, _), Type::Refined(b2, _, _)) => get_gen_type(b1, b2), + (Type::Refined(b1, _, _), t2) => get_gen_type(b1, t2), + (t1, Type::Refined(b2, _, _)) => get_gen_type(t1, b2), (Type::Vec(_, ind1, typ1, _), Type::Vec(_, ind2, typ2, _)) => { let gen1 = get_gen_type(ind1, ind2); let gen2 = get_gen_type(typ1, typ2); @@ -1152,7 +1160,40 @@ pub fn validate_forced_dispatch(context: &Context, var: &Var) -> Option TypeContext { - let result = typing_impl(context, expr); + let at_return = context.is_return_position(); + // `Lines`/`Scope`/`If`/`Return` hand the return position on to the + // expressions they contain; anything else is itself the value returned, + // and its own operands are not in return position. + let delegates = matches!( + expr, + Lang::Lines { .. } | Lang::Scope { .. } | Lang::If { .. } | Lang::Return { .. } + ); + let mut result = if at_return && !delegates { + typing_impl(&context.clone().set_return_position(false), expr) + } else { + typing_impl(context, expr) + }; + if at_return && !delegates && !result.has_errors() { + // Refined return type (plan Phase 5): a value whose refinement is not + // proven but not refuted is accepted, and checked at run time. + if let Some(expected) = context.get_expected_return_type() { + match coerce_to(&result.value, &expected, &result.context) { + Coercion::Runtime(set) => { + result.context = result.context.add_refinement_obligation(&expr.get_help_data(), set); + result.value = expected.reduce(&result.context); + } + Coercion::Reject => { + if let Some(obs) = field_obligations(expr, &result.value, &expected, &result.context) { + result.context = with_obligations(result.context.clone(), obs); + result.value = expected.reduce(&result.context); + } + } + Coercion::Static => {} + } + } + } + // The flag describes where `expr` sits, not what its operands did to it. + result.context = result.context.set_return_position(at_return); type_recorder::record(expr, &result.value); result } @@ -1279,6 +1320,12 @@ fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { // (covers e.g. a record alias vs. its structural literal). let reduced1 = reduce_type(context, &tc1.value); let reduced2 = reduce_type(context, &tc2.value); + // R compares element-wise: `v > 2` is a vector of booleans as long + // as `v`, and a vector may be compared to a scalar of its element + // type (refined_types_plan.md, phase 7 — `v[v > 2]`). + if let Some(shape) = vectorized_comparison(context, &reduced1, &reduced2) { + return TypeContext::new(shape, expr.clone(), context.clone()).with_errors(errors); + } let both_numeric = matches!(reduced1, Type::Integer(_, _) | Type::Number(_, _)) && matches!(reduced2, Type::Integer(_, _) | Type::Number(_, _)); let comparable = reduced1 == reduced2 @@ -1368,12 +1415,13 @@ fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { // error it raised (found via audit_type_checking.md Phase 6 // diagnostic-wording snapshots: `.Blue`-variant match errors // came out twice for a two-statement program). - let new_context = exprs2.iter().fold(context2, |ctx, expr| { + let in_return_position = context2.is_return_position(); + let new_context = exprs2.iter().fold(context2.set_return_position(false), |ctx, expr| { let tc = typing(&ctx, expr); all_errors.extend(tc.errors); tc.context }); - let final_tc = typing(&new_context, &exp); + let final_tc = typing(&new_context.set_return_position(in_return_position), &exp); all_errors.extend(final_tc.errors); TypeContext::new(final_tc.value, final_tc.lang, final_tc.context).with_errors(all_errors) } @@ -1402,12 +1450,12 @@ fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { else_block: false_branch, help_data: _h, } => { - let cond_tc = typing(context, cond); + let cond_tc = typing(&context.clone().set_return_position(false), cond); let mut errors = cond_tc.errors; if reduce_type(context, &cond_tc.value).is_boolean() { - let true_tc = typing(context, true_branch); - let false_tc = typing(context, false_branch); + let true_tc = typing(&narrowing::narrow(context, cond, true), true_branch); + let false_tc = typing(&narrowing::narrow(context, cond, false), false_branch); errors.extend(true_tc.errors); errors.extend(false_tc.errors); @@ -1430,7 +1478,11 @@ fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { } else { builder::union_type(&[true_tc.value, false_tc.value]) }; - TypeContext::new(result_type, expr.clone(), context.clone()).with_errors(errors) + let context = context + .clone() + .absorb_obligations(&true_tc.context) + .absorb_obligations(&false_tc.context); + TypeContext::new(result_type, expr.clone(), context).with_errors(errors) } else { errors.push(TypRError::Type(TypeError::WrongExpression(cond.get_help_data()))); TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors) @@ -1605,7 +1657,12 @@ fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { } => { let tc = typing(context, arr_exp); let mut errors = tc.errors; - let typ1 = tc.value; + // `[T] & length(> 0)` is indexed like `[T]`: a refinement only + // constrains the value, it does not change its shape. + let typ1 = match reduce_type(context, &tc.value) { + refined @ Type::Refined(..) => refined.unrefined().clone(), + _ => tc.value, + }; // Scalar indexing on a Tuple: list(a, b, c)[2] → element type at position 2 if let Type::Tuple(types, _) = &typ1 { @@ -1658,6 +1715,30 @@ fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { return TypeContext::new(element_type, expr.clone(), context.clone()).with_errors(errors); } + // `v[1:3]` / `v[mask]` on a one-dimensional vector selects a + // sub-vector, not an element (refined_types_plan.md, phase 7). The + // result keeps the element type; its length is known only when the + // index is a literal range (`a:b`) — `mask` selections give `[int]`. + if let (Type::Vec(vt, _, elem, vh), Some([member])) = (&typ1, index.get_members_if_array().as_deref()) { + let dynamic = !matches!(member, Lang::Integer { .. } | Lang::Array { .. } | Lang::Vector { .. }); + if dynamic && !matches!(**elem, Type::Vec(..)) { + let idx_tc = typing(context, member); + if let Type::Vec(_, n, ie, _) = reduce_type(context, &idx_tc.value) { + let len = match reduce_type(context, &ie) { + Type::Integer(..) => Some(literal_range_len(member).map_or_else(|| (*n).clone(), builder::integer_type)), + Type::Boolean(..) => Some(builder::any_type()), + _ => None, + }; + if let Some(len) = len { + let len = if matches!(len, Type::Integer(Tint::Val(_), _) | Type::Any(_)) { len } else { builder::any_type() }; + errors.extend(idx_tc.errors); + let typ2 = Type::Vec(vt.clone(), Box::new(len), elem.clone(), vh.clone()); + return TypeContext::new(typ2, expr.clone(), context.clone()).with_errors(errors); + } + } + } + } + let args_target = typ1.clone().linearize(); match index.get_members_if_array() { @@ -1925,7 +2006,7 @@ fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { Lang::Signature { .. } => eval(context, expr).with_lang(expr), Lang::TypeConstructor { .. } => eval(context, expr).with_lang(expr), Lang::Return { value: exp, .. } => { - let tc = typing(context, exp); + let tc = typing(&context.clone().set_return_position(true), exp); // C1 (audit_type_checking.md): an early `return` used to be typed // but never unified with the enclosing function's declared return // type — only the trailing expression was checked in `function()`. @@ -4802,7 +4883,13 @@ p"#; // silent degradation: the call site still gets a real `Type::Function` // with the right arity, and the `Any` boundary is the documented, // intentional cost of the escape hatch (see rfcs/0028). - const BASELINE: usize = 21; + // + // 21 -> 23 (refined_types_plan.md, phase 7): vector indexing/comparison + // results (`v[mask]`, `v[a:f(x)]`, `u > w` with lengths that differ) + // carry `Any` in the *length slot* of `Type::Vec` — that is TypR's + // "length unknown" marker (`[int]` is `Vec(_, Any, int)`), not a + // degraded element type. + const BASELINE: usize = 23; let count = production_source().matches("any_type()").count(); assert!( count <= BASELINE, @@ -4856,3 +4943,41 @@ p"#; ); } } + +/// Length of a literal range `a:b` / `a:s:b` (parsed as `seq(a, b, step)`), `None` for anything else. +fn literal_range_len(member: &Lang) -> Option { + let Lang::FunctionApp { identifier, arguments, .. } = member else { return None }; + match (&**identifier, arguments.as_slice()) { + (Lang::Variable { name, .. }, [Lang::Integer { value: a, .. }, Lang::Integer { value: b, .. }, Lang::Integer { value: step, .. }]) + if name == "seq" && *step != 0 && (b - a) / step >= 0 => + { + Some((b - a) / step + 1) + } + _ => None, + } +} + +/// Result type of comparing `t1` with `t2` when at least one side is a +/// one-dimensional vector and the element types are comparable: a vector of +/// booleans whose length is that of the vector side(s). `None` otherwise. +fn vectorized_comparison(context: &Context, t1: &Type, t2: &Type) -> Option { + let split = |t: &Type| match t { + Type::Vec(vt, n, e, h) if !matches!(**e, Type::Vec(..)) => Some((vt.clone(), (**n).clone(), (**e).clone(), h.clone())), + _ => None, + }; + let (s1, s2) = (split(t1), split(t2)); + let (e1, e2) = ( + s1.as_ref().map_or_else(|| t1.clone(), |s| reduce_type(context, &s.2)), + s2.as_ref().map_or_else(|| t2.clone(), |s| reduce_type(context, &s.2)), + ); + let numeric = |t: &Type| matches!(t, Type::Integer(..) | Type::Number(..)); + if s1.is_none() && s2.is_none() || !(e1 == e2 || numeric(&e1) && numeric(&e2) || e1.is_subtype(&e2, context).0 || e2.is_subtype(&e1, context).0) { + return None; + } + let (vt, n, _, h) = match (s1, s2) { + (Some((vt, n1, _, h)), Some((_, n2, _, _))) => (vt, if n1 == n2 { n1 } else { builder::any_type() }, (), h), + (Some((vt, n, _, h)), None) | (None, Some((vt, n, _, h))) => (vt, n, (), h), + (None, None) => return None, + }; + Some(Type::Vec(vt, Box::new(n), Box::new(builder::boolean_type()), h)) +} diff --git a/crates/typr-core/src/processes/type_checking/narrowing.rs b/crates/typr-core/src/processes/type_checking/narrowing.rs new file mode 100644 index 0000000..32052c2 --- /dev/null +++ b/crates/typr-core/src/processes/type_checking/narrowing.rs @@ -0,0 +1,198 @@ +//! Condition narrowing (`refined_types_plan.md`, Phase 9, §24). +//! +//! Inside `if (length(x) == 5) { .. }` the checker knows more about `x` than +//! its declared type says: the branch is entered only when the condition +//! holds, so `x` can be treated as `[5, int]` there without any runtime +//! check (the condition *is* the check). A condition is read as a list of +//! facts `(variable, measure, interval)` and each fact is intersected with +//! what the variable already is, through the same `apply_refinements` as an +//! annotation. Nothing is narrowed when the intersection is empty (dead +//! branch) or the base does not support the measure (`x > 0` on a vector). +//! +//! Recognised, on either side of the comparison: +//! * `length(x) n` -> measure `Length` +//! * `x c` -> measure `Value` (scalars only) +//! * `a && b` (then-branch) and `a || b` (else-branch), `!c` +//! +//! The else-branch uses the negated comparison; `==` says nothing when +//! false, and neither does `!=` when true. + +use crate::components::context::Context; +use crate::components::language::operators::Op; +use crate::components::language::var::Var; +use crate::components::language::Lang; +use crate::components::r#type::refinement::{Interval, Measure, RefinementSet}; +use crate::components::r#type::Type; +use crate::processes::type_checking::type_arithmetic::apply_refinements; +use crate::processes::type_checking::type_comparison::reduce_type; + +type Fact = (String, Measure, Interval); + +#[derive(Clone, Copy)] +enum Cmp { + Eq, + NotEq, + Lt, + Le, + Gt, + Ge, +} + +impl Cmp { + fn of(op: &Op) -> Option { + Some(match op { + Op::Eq(_) => Cmp::Eq, + Op::NotEq(_) => Cmp::NotEq, + Op::LesserThan(_) => Cmp::Lt, + Op::LesserOrEqual(_) => Cmp::Le, + Op::GreaterThan(_) => Cmp::Gt, + Op::GreaterOrEqual(_) => Cmp::Ge, + _ => return None, + }) + } + + /// `c x` read as `x c`. + fn flip(self) -> Cmp { + match self { + Cmp::Lt => Cmp::Gt, + Cmp::Le => Cmp::Ge, + Cmp::Gt => Cmp::Lt, + Cmp::Ge => Cmp::Le, + c => c, + } + } + + fn negate(self) -> Cmp { + match self { + Cmp::Eq => Cmp::NotEq, + Cmp::NotEq => Cmp::Eq, + Cmp::Lt => Cmp::Ge, + Cmp::Le => Cmp::Gt, + Cmp::Gt => Cmp::Le, + Cmp::Ge => Cmp::Lt, + } + } + + /// What `x c` tells about `x`, if anything. + fn interval(self, c: f64) -> Option { + match self { + Cmp::Eq => Some(Interval::point(c)), + Cmp::NotEq => None, + Cmp::Lt => Some(Interval::less_than(c)), + Cmp::Le => Some(Interval::at_most(c)), + Cmp::Gt => Some(Interval::greater_than(c)), + Cmp::Ge => Some(Interval::at_least(c)), + } + } +} + +fn literal(l: &Lang) -> Option { + match l { + Lang::Integer { value, .. } => Some(*value as f64), + Lang::Number { value, .. } => Some(*value), + _ => None, + } +} + +/// `x` or `length(x)` with `x` a plain variable. +fn subject(l: &Lang) -> Option<(String, Measure)> { + match l { + Lang::Variable { name, .. } => Some((name.clone(), Measure::Value)), + Lang::FunctionApp { identifier, arguments, .. } if arguments.len() == 1 => match (&**identifier, &arguments[0]) { + (Lang::Variable { name: f, .. }, Lang::Variable { name, .. }) if f == "length" => { + Some((name.clone(), Measure::Length)) + } + _ => None, + }, + _ => None, + } +} + +/// Facts that hold when `cond` evaluates to `holds`. +fn facts(cond: &Lang, holds: bool, out: &mut Vec) { + match cond { + // `(c)` parses as a one-expression scope. + Lang::Scope { body, .. } if body.len() == 1 => facts(&body[0], holds, out), + Lang::Not { value, .. } => facts(value, !holds, out), + Lang::Operator { operator: Op::And(_) | Op::And2(_), lhs, rhs, .. } if holds => { + facts(lhs, true, out); + facts(rhs, true, out); + } + Lang::Operator { operator: Op::Or(_) | Op::Or2(_), lhs, rhs, .. } if !holds => { + facts(lhs, false, out); + facts(rhs, false, out); + } + // `&&` / `||` reach the checker as a call to the operator's function. + Lang::FunctionApp { identifier, arguments, .. } if arguments.len() == 2 => { + let Lang::Variable { name, .. } = &**identifier else { return }; + let wanted = match name.trim_matches('`') { + "&&" | "&" if holds => true, + "||" | "|" if !holds => false, + _ => return, + }; + facts(&arguments[0], wanted, out); + facts(&arguments[1], wanted, out); + } + // `Lang::Operator` stores the *left* operand in `rhs`. + Lang::Operator { operator, lhs: right, rhs: left, .. } => { + let Some(cmp) = Cmp::of(operator) else { return }; + let (lhs, rhs) = (left, right); + let (subj, c, cmp) = match (subject(lhs), literal(rhs), literal(lhs), subject(rhs)) { + (Some(s), Some(c), _, _) => (s, c, cmp), + (_, _, Some(c), Some(s)) => (s, c, cmp.flip()), + _ => return, + }; + let cmp = if holds { cmp } else { cmp.negate() }; + if let Some(iv) = cmp.interval(c) { + out.push((subj.0, subj.1, iv)); + } + } + _ => {} + } +} + +/// `context` as it is inside the branch entered when `cond` is `holds`. +pub fn narrow(context: &Context, cond: &Lang, holds: bool) -> Context { + let mut found = Vec::new(); + facts(cond, holds, &mut found); + // One narrowing per variable, from the meet of all its facts + // (`length(v) > 1 && length(v) < 3` is a single `length(v) == 2`). + let mut per_variable: Vec<(String, RefinementSet)> = Vec::new(); + for (name, measure, iv) in found { + let fact = RefinementSet::empty().with(measure, iv); + match per_variable.iter_mut().find(|(n, _)| *n == name) { + Some((_, set)) => *set = set.meet(&fact), + None => per_variable.push((name, fact)), + } + } + per_variable + .into_iter() + .fold(context.clone(), |ctx, (name, set)| narrow_variable(ctx, &name, set)) +} + +fn narrow_variable(context: Context, name: &str, facts: RefinementSet) -> Context { + // Only a variable with a single visible binding: with overloads there is + // no telling which one the condition talks about. + let mut entries = context.typing_context.entries_named(name); + if entries.len() != 1 { + return context; + } + let (var, declared) = entries.remove(0); + let reduced = reduce_type(&context, &declared); + if !matches!(reduced.unrefined(), Type::Vec(..) | Type::Integer(..) | Type::Number(..)) { + return context; + } + let mut set = reduced.refinements_of().meet(&facts); + // A length is an integer: `1 < length(x) < 3` means `length(x) == 2`. + if let Some(len) = set.get(Measure::Length).map(|iv| iv.to_integral()) { + set = set.with(Measure::Length, len); + } + let h = reduced.get_help_data(); + match apply_refinements(reduced.unrefined().clone(), set, h) { + Type::Failed(..) => context, + narrowed => { + let snapshot = context.clone(); + context.replace_or_push_var_type(var, narrowed, &snapshot) + } + } +} diff --git a/crates/typr-core/src/processes/type_checking/refinement_check.rs b/crates/typr-core/src/processes/type_checking/refinement_check.rs new file mode 100644 index 0000000..4af016c --- /dev/null +++ b/crates/typr-core/src/processes/type_checking/refinement_check.rs @@ -0,0 +1,211 @@ +//! Boundary check for refined types (`refined_types_plan.md`, Phase 5, D3/D5). +//! +//! `is_subtype` only ever answers `true` for a *proven* refinement. What it +//! cannot prove (`[int]` where `[3, int]` is expected, `int` where +//! `int & (> 0)` is expected) is neither accepted nor rejected there: the +//! boundary (`let` annotation, argument, return) asks [`coerce_to`], which +//! separates the three outcomes and returns, for the unproven case, the +//! residual the runtime has to check. + +use crate::components::context::Context; +use crate::components::error_message::help_data::HelpData; +use crate::components::language::Lang; +use crate::components::r#type::argument_type::ArgumentType; +use crate::components::r#type::tchar::Tchar; +use crate::components::r#type::refinement::RefinementSet; +use crate::components::r#type::type_system::TypeSystem; +use crate::components::r#type::Type; + +/// Outcome of comparing what a boundary found with what it expects. +#[derive(Debug, Clone, PartialEq)] +pub enum Coercion { + /// `found <: expected` is proven: nothing to check. + Static, + /// The base types agree and the refinements are compatible but not + /// proven. The set is the part of `expected` that `found` does not imply. + Runtime(RefinementSet), + /// A plain type mismatch, or a refinement that provably cannot hold. + Reject, +} + +/// `t` with every refinement removed, including the length index of a `Vec` +/// (`[3, int]` gives `[any, int]`): what is left is the type the refinements +/// are properties *of*. +fn base_of(t: &Type) -> Type { + match t { + Type::Refined(base, _, _) => base_of(base), + Type::Vec(kind, _, elem, h) => Type::Vec(kind.clone(), Box::new(Type::Any(h.clone())), elem.clone(), h.clone()), + t => t.clone(), + } +} + +/// Decide how `found` may flow into `expected` (both reduced here). +pub fn coerce_to(found: &Type, expected: &Type, context: &Context) -> Coercion { + let found = found.reduce(context); + let expected = expected.reduce(context); + + if found.is_subtype(&expected, context).0 { + return Coercion::Static; + } + if !base_of(&found).is_subtype(&base_of(&expected), context).0 { + return Coercion::Reject; + } + + let have = found.refinements_of(); + let want = expected.refinements_of(); + // A vector's length is always integral; scalars follow their base type. + let integral = matches!(base_of(&expected).unrefined(), Type::Integer(..)); + if have.contradicts(&want, integral) { + return Coercion::Reject; + } + + let residual = residual(&have, &want); + if residual.is_trivial() { + // The refinements are not what blocked the subtype (an index that is + // not a literal, say): nothing runtime can repair that. + Coercion::Reject + } else { + Coercion::Runtime(residual) + } +} + +/// The name of a record field, when it is a plain literal. +fn field_name(arg: &ArgumentType) -> Option { + match &arg.0 { + Type::Char(Tchar::Val(name), _) => Some(name.to_string()), + _ => None, + } +} + +/// Runtime obligations for a record literal flowing into a record type +/// (`let a: Account <- list { id: n, .. }`), one per *field expression*: the +/// literal is built right there, so each field is a boundary of its own and +/// the check sits on the value that needs it, not on the whole record. +/// +/// `None` when `expr` is not a spread-free record literal, when the expected +/// type is not a record, when a field is missing or provably wrong, or when +/// no field needs a check (the caller then keeps its own verdict). +pub fn field_obligations( + expr: &Lang, + found: &Type, + expected: &Type, + context: &Context, +) -> Option> { + let Lang::List { value: fields, spreads, .. } = expr else { return None }; + if !spreads.is_empty() { + return None; + } + let (Type::Record(have, _), Type::Record(want, _)) = (found.reduce(context), expected.reduce(context)) else { + return None; + }; + let mut out = Vec::new(); + for w in want.iter() { + let name = field_name(w)?; + let value = fields.iter().find(|f| f.get_argument() == name)?.get_value(); + let h = have.iter().find(|a| field_name(a).as_deref() == Some(name.as_str()))?; + match coerce_to(&h.1, &w.1, context) { + Coercion::Static => {} + Coercion::Runtime(set) => out.push((value.get_help_data(), set)), + // a nested literal is a boundary in turn + Coercion::Reject => out.extend(field_obligations(&value, &h.1, &w.1, context)?), + } + } + (!out.is_empty()).then_some(out) +} + +/// `context` with each obligation of `field_obligations` recorded. +pub fn with_obligations(context: Context, obligations: Vec<(HelpData, RefinementSet)>) -> Context { + obligations.into_iter().fold(context, |ctx, (h, set)| ctx.add_refinement_obligation(&h, set)) +} + +/// The measures of `want` that `have` does not already imply. +pub(crate) fn residual(have: &RefinementSet, want: &RefinementSet) -> RefinementSet { + want.iter() + .filter(|(m, iv)| !have.get(*m).map(|h| h.implies(iv)).unwrap_or(false)) + .fold(RefinementSet::empty(), |acc, (m, iv)| acc.with(*m, *iv)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::fluent_parser::FluentParser; + + fn ty(src: &str) -> (Type, Context) { + let ctx = FluentParser::new().push(&format!("type T <- {};", src)).run().context.clone(); + let alias = Type::Alias("T".into(), vec![], false, HelpData::default()); + (alias.reduce(&ctx), ctx) + } + + #[test] + fn unproven_length_is_a_runtime_obligation() { + let (found, ctx) = ty("[int]"); + let (expected, _) = ty("[3, int]"); + match coerce_to(&found, &expected, &ctx) { + Coercion::Runtime(set) => assert_eq!(set.to_string(), "length(3)"), + other => panic!("expected Runtime, got {:?}", other), + } + } + + #[test] + fn proven_length_needs_no_check() { + let (found, ctx) = ty("[3, int]"); + let (expected, _) = ty("[int]"); + assert_eq!(coerce_to(&found, &expected, &ctx), Coercion::Static); + let (same, _) = ty("[3, int]"); + assert_eq!(coerce_to(&found, &same, &ctx), Coercion::Static); + } + + #[test] + fn contradicting_length_is_rejected() { + let (found, ctx) = ty("[3, int]"); + let (expected, _) = ty("[5, int]"); + assert_eq!(coerce_to(&found, &expected, &ctx), Coercion::Reject); + } + + #[test] + fn base_mismatch_is_rejected() { + let (found, ctx) = ty("[char]"); + let (expected, _) = ty("[3, int]"); + assert_eq!(coerce_to(&found, &expected, &ctx), Coercion::Reject); + } + + #[test] + fn scalar_needs_a_check_for_a_value_bound() { + let (found, ctx) = ty("int"); + let (expected, _) = ty("int & (> 0)"); + match coerce_to(&found, &expected, &ctx) { + Coercion::Runtime(set) => assert_eq!(set.to_string(), "(> 0)"), + other => panic!("expected Runtime, got {:?}", other), + } + } + + #[test] + fn refined_implies_weaker_bound() { + let (found, ctx) = ty("int & (> 5)"); + let (expected, _) = ty("int & (> 0)"); + assert_eq!(coerce_to(&found, &expected, &ctx), Coercion::Static); + let (tighter, _) = ty("int & (> 10)"); + assert!(matches!(coerce_to(&found, &tighter, &ctx), Coercion::Runtime(_))); + } + + fn errors_of(src: &str) -> usize { + use crate::processes::parsing::parse2; + use crate::processes::type_checking::typing_with_errors; + { let r = typing_with_errors(&Context::default(), &crate::processes::parsing::parse_from_string(src, "test")); r.get_errors().len() } + } + + #[test] + fn return_position_accepts_unproven_and_rejects_contradiction() { + assert_eq!(errors_of("let f <- fn(v: [int]): [3, int] { v };"), 0); + assert_eq!(errors_of("let f <- fn(v: [int], c: bool): [3, int] { if (c) { return v; }; [1, 2, 3] };"), 0); + assert!(errors_of("let f <- fn(): [3, int] { [1, 2] };") > 0); + assert!(errors_of("let f <- fn(v: [char]): [3, int] { v };") > 0); + } + + #[test] + fn argument_position_accepts_unproven_and_rejects_contradiction() { + let decl = "let g <- fn(p: [2, num]): num { 1.0 };"; + assert_eq!(errors_of(&format!("{} let u: [num] <- [1.0, 2.0]; let r <- g(u);", decl)), 0); + assert!(errors_of(&format!("{} let c: [bool] <- [true, false]; let r <- g(c);", decl)) > 0); + } +} diff --git a/crates/typr-core/src/processes/type_checking/type_arithmetic.rs b/crates/typr-core/src/processes/type_checking/type_arithmetic.rs index fb88e94..4d1e066 100644 --- a/crates/typr-core/src/processes/type_checking/type_arithmetic.rs +++ b/crates/typr-core/src/processes/type_checking/type_arithmetic.rs @@ -10,6 +10,7 @@ use crate::components::error_message::help_data::HelpData; use crate::components::error_message::type_error::TypeError; use crate::components::error_message::typr_error::TypRError; use crate::components::r#type::argument_type::ArgumentType; +use crate::components::r#type::refinement::{Interval, Measure, RefinementSet}; use crate::components::r#type::tint::Tint; use crate::components::r#type::tnumber::Tnum; use crate::components::r#type::type_category::GKind; @@ -131,6 +132,9 @@ pub fn norm_arithmetic(op: TypeOperator, t1: Type, t2: Type, h: HelpData) -> Typ /// nothing to merge structurally) — callers that need to see through it /// project the facet they need (see `facets.rs`). pub fn norm_intersection(t1: Type, t2: Type, h: HelpData) -> Type { + if matches!(t1, Type::Property(..) | Type::Refined(..)) || matches!(t2, Type::Property(..) | Type::Refined(..)) { + return norm_refinement(t1, t2, h); + } match (&t1, &t2) { (Type::Empty(_), _) | (_, Type::Empty(_)) => Type::Empty(h), (Type::Any(_), _) => t2, @@ -154,6 +158,159 @@ pub fn norm_intersection(t1: Type, t2: Type, h: HelpData) -> Type { } } +/// The `TypeError` for a `Failed` produced by `apply_refinements`, or `None` +/// for any other `Failed` (arithmetic, records, ...). +pub fn refinement_error(t: &Type) -> Option { + match t { + Type::Failed(msg, h) if msg.starts_with("invalid refinement") || msg.contains("is a property, not a type") => { + Some(TypeError::InvalidRefinement(msg.clone(), h.clone())) + } + Type::Refined(base, set, h) if matches!(**base, Type::Any(_)) => Some(TypeError::InvalidRefinement( + format!("`{}` is a property, not a type: refine a base type with `T & {}`", set, set), + h.clone(), + )), + Type::Failed(msg, h) if msg.starts_with("unsatisfiable refinement") => { + Some(TypeError::UnsatisfiableRefinement(msg.clone(), h.clone())) + } + _ => None, + } +} + +/// Split an `&` operand into `(base, refinements)`. A bare `Property` has no +/// base (`int & (> 0)` is parsed as `Intersection(int, Property)`). +fn split_refinement(t: Type) -> (Option, RefinementSet) { + match t { + Type::Property(p, _) => (None, RefinementSet::single(&p)), + // `Refined(Any, ..)` is the base-less carrier made below for `p & q`. + Type::Refined(base, set, _) if matches!(*base, Type::Any(_)) => (None, set), + Type::Refined(base, set, _) => (Some(*base), set), + t => (Some(t), RefinementSet::empty()), + } +} + +/// The base and the refinements a declared type carries, read off its shape +/// without reducing it: `[#N, T] & length(> 0)` stays an unreduced +/// `Intersection` in a signature, and reducing it once `N` is substituted +/// (`[0, char] & length(> 0)`) would collapse it into an error. +pub(crate) fn declared_refinements(t: &Type) -> Option<(Type, RefinementSet)> { + fn walk(t: &Type) -> (Option, RefinementSet) { + match t { + Type::Operator(TypeOperator::Intersection, a, b, _) => { + let ((ba, sa), (bb, sb)) = (walk(a), walk(b)); + (ba.or(bb), sa.meet(&sb)) + } + t => split_refinement(t.clone()), + } + } + let (base, set) = walk(t); + base.filter(|_| !set.is_trivial()).map(|b| (b, set)) +} + +/// `&` where at least one operand is a `Property` or a `Refined` type +/// (refined_types_plan.md, Phase 3): merge the bases like any other +/// intersection, intersect the property sets, then check the result. +fn norm_refinement(t1: Type, t2: Type, h: HelpData) -> Type { + let ((b1, s1), (b2, s2)) = (split_refinement(t1), split_refinement(t2)); + let set = s1.meet(&s2); + let base = match (b1, b2) { + (Some(a), Some(b)) => match norm_intersection(a, b, h.clone()) { + failed @ Type::Failed(..) => return failed, + t => t, + }, + (Some(t), None) | (None, Some(t)) => t, + // `p & q` with no base yet (`int & ((> 0) & (< 10))`): keep the merged + // properties until a base shows up. `refinement_error` rejects one + // that stays alone. + (None, None) => return Type::Refined(Box::new(Type::Any(h.clone())), set, h), + }; + apply_refinements(base, set, h) +} + +/// Attach `set` to `base`, or explain why that is impossible: the base does +/// not support the measure (invalid refinement), or no value can satisfy the +/// set (unsatisfiable refinement). `[T] & length(n)` folds into the length +/// index of `Vec` (D4); a literal that satisfies the set stays a literal. +pub(crate) fn apply_refinements(base: Type, set: RefinementSet, h: HelpData) -> Type { + if set.is_trivial() { + return base; + } + let unsatisfiable = |why: &str| { + Type::Failed(format!("unsatisfiable refinement: `{} & {}` {}", base.pretty(), set, why), h.clone()) + }; + let invalid = |m: Measure| { + Type::Failed( + format!("invalid refinement: `{}` cannot be refined by `{}`", base.pretty(), m), + h.clone(), + ) + }; + let full = Interval::full(); + match &base { + Type::Empty(_) => base, + Type::Vec(kind, index, elem, vh) => { + if set.get(Measure::Value).is_some() { + return invalid(Measure::Value); + } + if set.is_empty(true) { + return unsatisfiable("has no value"); + } + let len = set.get(Measure::Length).and_then(|iv| iv.as_point()); + match (len, &**index) { + (Some(n), Type::Any(_)) => Type::Vec( + kind.clone(), + Box::new(Type::Integer(Tint::Val(n as i32), vh.clone())), + elem.clone(), + vh.clone(), + ), + (Some(n), Type::Integer(Tint::Val(m), _)) if n as i32 == *m => base.clone(), + (Some(n), Type::Integer(Tint::Val(m), _)) => { + unsatisfiable(&format!("contradicts its length {} (asked for {})", m, n)) + } + // A range (`length(> 0)`) against a known length: the literal + // either satisfies it (the range adds nothing) or contradicts it. + (None, Type::Integer(Tint::Val(m), _)) => { + let range = set.get(Measure::Length).unwrap_or(&full); + if Interval::point(*m as f64).implies(range) { + base.clone() + } else { + unsatisfiable(&format!("contradicts its length {}", m)) + } + } + // Symbolic length (`#N`) or unknown length with a range: keep the property. + _ => Type::Refined(Box::new(base.clone()), set, h), + } + } + Type::Integer(tint, _) => { + if set.get(Measure::Length).is_some() { + return invalid(Measure::Length); + } + if set.is_empty(true) { + return unsatisfiable("has no integer value"); + } + match tint { + Tint::Val(v) if Interval::point(*v as f64).implies(set.get(Measure::Value).unwrap_or(&full)) => { + base.clone() + } + Tint::Val(_) => unsatisfiable("excludes this literal"), + Tint::Unknown => Type::Refined(Box::new(base.clone()), set, h), + } + } + Type::Number(tnum, _) => { + if set.get(Measure::Length).is_some() { + return invalid(Measure::Length); + } + if set.is_empty(false) { + return unsatisfiable("has no value"); + } + match tnum { + Tnum::Val(v) if Interval::point(*v).implies(set.get(Measure::Value).unwrap_or(&full)) => base.clone(), + Tnum::Val(_) => unsatisfiable("excludes this literal"), + Tnum::Unknown => Type::Refined(Box::new(base.clone()), set, h), + } + } + _ => invalid(set.iter().next().map(|(m, _)| *m).unwrap_or(Measure::Value)), + } +} + fn merge_record_fields(f1: &HashSet, f2: &HashSet, h: &HelpData) -> Type { let names1: HashMap = f1.iter().map(|a| (a.get_argument_str(), a.get_type())).collect(); let names2: HashMap = f2.iter().map(|a| (a.get_argument_str(), a.get_type())).collect(); @@ -257,6 +414,98 @@ fn collect_failed_types(typ: &Type, acc: &mut Vec<(String, HelpData)>) { #[cfg(test)] mod tests { + use crate::components::r#type::refinement::{Num, Refinement}; + + fn prop(r: Refinement) -> Type { + Type::Property(r, HelpData::default()) + } + fn gt(c: f64) -> Type { + prop(Refinement::Gt(Num::new(c))) + } + fn lt(c: f64) -> Type { + prop(Refinement::Lt(Num::new(c))) + } + fn inter(a: Type, b: Type) -> Type { + norm_intersection(a, b, HelpData::default()) + } + + #[test] + fn test_refine_int_with_value_property() { + let res = inter(builder::integer_type_default(), gt(0.0)); + assert!(matches!(res, Type::Refined(..)), "{:?}", res); + } + + #[test] + fn test_refine_is_commutative_and_associative() { + let int = builder::integer_type_default; + let a = inter(inter(int(), gt(0.0)), lt(10.0)); + let c = inter(int(), inter(lt(10.0), gt(0.0))); + let d = inter(lt(10.0), inter(gt(0.0), int())); + assert_eq!(a, c); + assert_eq!(a, d); + } + + #[test] + fn test_refine_is_idempotent() { + let once = inter(builder::integer_type_default(), gt(0.0)); + let twice = inter(once.clone(), gt(0.0)); + assert_eq!(once, twice); + } + + #[test] + fn test_refine_contradiction_is_unsatisfiable() { + let res = inter(inter(builder::integer_type_default(), gt(10.0)), lt(5.0)); + assert!(matches!(refinement_error(&res), Some(TypeError::UnsatisfiableRefinement(..))), "{:?}", res); + } + + #[test] + fn test_refine_integers_round_bounds() { + // (> 0) & (< 1) has real values but no integer. + let res = inter(inter(builder::integer_type_default(), gt(0.0)), lt(1.0)); + assert!(matches!(refinement_error(&res), Some(TypeError::UnsatisfiableRefinement(..)))); + let num = inter(inter(builder::number_type(), gt(0.0)), lt(1.0)); + assert!(matches!(num, Type::Refined(..))); + } + + #[test] + fn test_refine_capability_is_checked() { + let len = || prop(Refinement::Length(3)); + assert!(matches!( + refinement_error(&inter(builder::integer_type_default(), len())), + Some(TypeError::InvalidRefinement(..)) + )); + assert!(matches!( + refinement_error(&inter(builder::character_type_default(), gt(0.0))), + Some(TypeError::InvalidRefinement(..)) + )); + let vec = builder::array_type(builder::any_type(), builder::integer_type_default()); + assert!(matches!(refinement_error(&inter(vec, gt(0.0))), Some(TypeError::InvalidRefinement(..)))); + } + + #[test] + fn test_refine_length_folds_into_vec_index() { + let vec = builder::array_type(builder::any_type(), builder::integer_type_default()); + let res = inter(vec, prop(Refinement::Length(5))); + assert_eq!(res, builder::array_type2(5, builder::integer_type_default())); + // Same length again: no-op. Another length: contradiction. + assert_eq!(inter(res.clone(), prop(Refinement::Length(5))), res); + let clash = inter(res, prop(Refinement::Length(3))); + assert!(matches!(refinement_error(&clash), Some(TypeError::UnsatisfiableRefinement(..)))); + } + + #[test] + fn test_refine_literal_is_proven_or_refuted() { + assert_eq!(inter(builder::integer_type(3), gt(0.0)), builder::integer_type(3)); + let refuted = inter(builder::integer_type(-3), gt(0.0)); + assert!(matches!(refinement_error(&refuted), Some(TypeError::UnsatisfiableRefinement(..)))); + } + + #[test] + fn test_bare_properties_are_not_types() { + let res = inter(gt(0.0), lt(5.0)); + assert!(matches!(refinement_error(&res), Some(TypeError::InvalidRefinement(..))), "{:?}", res); + } + use super::*; use crate::utils::builder; @@ -502,4 +751,28 @@ mod tests { let errors = validate_operator_kinds(&Context::default(), &typ); assert!(errors.is_empty()); } + + #[test] + fn length_range_on_vectors() { + use crate::components::r#type::refinement::{Interval, Measure}; + let range = || prop(Refinement::Range(Measure::Length, Interval::greater_than(0.0))); + let unknown = Type::Vec( + crate::components::r#type::vector_type::VecType::S3, + Box::new(Type::Any(HelpData::default())), + Box::new(builder::integer_type_default()), + HelpData::default(), + ); + // unknown length: the range is kept as a property + assert!(matches!(inter(unknown.clone(), range()), Type::Refined(..))); + // known length: satisfied range is redundant, violated one is a contradiction + let sized = |n| Type::Vec( + crate::components::r#type::vector_type::VecType::S3, + Box::new(builder::integer_type(n)), + Box::new(builder::integer_type_default()), + HelpData::default(), + ); + assert_eq!(inter(sized(3), range()), sized(3)); + let res = inter(sized(0), range()); + assert!(matches!(refinement_error(&res), Some(TypeError::UnsatisfiableRefinement(..))), "{:?}", res); + } } diff --git a/crates/typr-core/src/processes/type_checking/type_comparison.rs b/crates/typr-core/src/processes/type_checking/type_comparison.rs index adda3c5..2cab40f 100644 --- a/crates/typr-core/src/processes/type_checking/type_comparison.rs +++ b/crates/typr-core/src/processes/type_checking/type_comparison.rs @@ -135,9 +135,20 @@ pub fn reduce_type_helper(context: &Context, type_: &Type, memory: Vector 0)`). + Type::Property(p, h) => Type::Failed( + format!("`{}` is a property, not a type: refine a base type with `T & {}`", p, p), + h.clone(), + ), Type::Operator(TypeOperator::Intersection, t1, t2, h) => { - let r1 = reduce_type_helper(context, t1, memory.clone()); - let r2 = reduce_type_helper(context, t2, memory.clone()); + // A `Property` operand is what `norm_intersection` folds into a + // `Refined`; reducing it alone would report it as a bare property. + let reduce_operand = |t: &Type| match t { + Type::Property(..) => t.clone(), + _ => reduce_type_helper(context, t, memory.clone()), + }; + let r1 = reduce_operand(t1); + let r2 = reduce_operand(t2); norm_intersection(r1, r2, h.clone()) } Type::Operator(TypeOperator::Access, t1, t2, h) => { diff --git a/crates/typr-core/src/processes/type_checking/type_context.rs b/crates/typr-core/src/processes/type_checking/type_context.rs index c662315..0d43372 100644 --- a/crates/typr-core/src/processes/type_checking/type_context.rs +++ b/crates/typr-core/src/processes/type_checking/type_context.rs @@ -1,6 +1,7 @@ use crate::components::error_message::type_error::TypeError; use crate::components::error_message::typr_error::TypRError; use crate::components::r#type::type_system::TypeSystem; +use crate::processes::type_checking::type_arithmetic::refinement_error; use crate::processes::type_checking::Context; use crate::processes::type_checking::Lang; use crate::processes::type_checking::Type; @@ -66,7 +67,11 @@ impl TypeContext { // But it also returns `Any` legitimately when the annotation IS `Any` (success), or when // there is no annotation (Empty) and the actual type is `Any`. Guard against both false // positives: only record an error when the annotation is an explicit non-Any, non-Empty type. - let annotation_is_constraining = !typ.is_empty() && !matches!(typ.reduce(&self.context), Type::Any(_)); + let reduced_annotation = typ.reduce(&self.context); + // A malformed refinement is reported on its own (T045/T046) by `let_expression`. + let annotation_is_constraining = !typ.is_empty() + && !matches!(reduced_annotation, Type::Any(_)) + && refinement_error(&reduced_annotation).is_none(); if annotation_is_constraining { if let crate::components::r#type::Type::Any(_) = new_type { errors.push(TypRError::type_error(TypeError::Let(typ.clone(), self.value.clone()))); diff --git a/crates/typr-core/src/processes/type_checking/unification.rs b/crates/typr-core/src/processes/type_checking/unification.rs index fad5f14..884d433 100644 --- a/crates/typr-core/src/processes/type_checking/unification.rs +++ b/crates/typr-core/src/processes/type_checking/unification.rs @@ -166,6 +166,9 @@ pub fn type_substitution(type_: &Type, substitutions: &[(Type, Type)]) -> Type { ), // Tag type substitution + // `[#N, T] & length(> 0)`: the refinements carry no generic, only the base does + Type::Refined(base, set, h) => Type::Refined(Box::new(type_substitution(base, substitutions)), set.clone(), h.clone()), + Type::Tag(name, inner_type, h) => Type::Tag( name.clone(), Box::new(type_substitution(inner_type, substitutions)), @@ -200,6 +203,7 @@ fn type_contains_generic(typ: &Type, name: &str) -> bool { Type::Record(fields, _) => fields.iter().any(|f| type_contains_generic(&f.get_type(), name)), Type::Alias(_, params, _, _) => params.iter().any(|p| type_contains_generic(p, name)), Type::Tag(_, inner, _) => type_contains_generic(inner, name), + Type::Refined(base, _, _) => type_contains_generic(base, name), Type::Interface(fields, _) => fields.iter().any(|f| type_contains_generic(&f.get_type(), name)), Type::Multi(inner, _) => type_contains_generic(inner, name), Type::Tuple(elems, _) => elems.iter().any(|e| type_contains_generic(e, name)), @@ -316,6 +320,11 @@ fn unification_helper(values: &[Type], type1: &Type, type2: &Type) -> Option unification_helper(values, b1, b2), + (Type::Refined(b, _, _), t) | (t, Type::Refined(b, _, _)) => unification_helper(values, b, t), + (Type::Vec(_, size1, elem1, _), Type::Vec(_, size2, elem2, _)) => { let mut combined = unification_helper(values, size1, size2)?; let elem_matches = unification_helper(values, elem1, elem2)?; diff --git a/crates/typr-mcp/data/std.R b/crates/typr-mcp/data/std.R index 45f30ab..8cac50a 100644 --- a/crates/typr-mcp/data/std.R +++ b/crates/typr-mcp/data/std.R @@ -81,6 +81,41 @@ typr_assert_type <- function(x, expected, loc, what) { x } +#' @title Vérifications des types raffinés (`[int] & length(3)`, `int & (> 0)`) +#' Émises par le compilateur seulement là où il n'a pas pu prouver le +#' raffinement. Elles renvoient `x` pour s'insérer dans n'importe quelle +#' expression ; `all()`/`isTRUE()` les rendent sûres face aux vecteurs et aux NA. +typr_refine_length <- function(x, n, loc) { + if (!isTRUE(length(x) == n)) { + stop(sprintf("Type refinement violation at %s: expected length %d, got %d", + loc, n, length(x)), call. = FALSE) + } + x +} + +typr_refine_length_range <- function(x, lo, lo_open, hi, hi_open, loc) { + n <- length(x) + ok <- (if (lo_open) n > lo else n >= lo) && (if (hi_open) n < hi else n <= hi) + if (!isTRUE(ok)) { + stop(sprintf("Type refinement violation at %s: length %d is out of bounds %s%s, %s%s", + loc, n, if (lo_open || is.infinite(lo)) "(" else "[", format(lo), format(hi), + if (hi_open || is.infinite(hi)) ")" else "]"), call. = FALSE) + } + x +} + +typr_refine_value <- function(x, lo, lo_open, hi, hi_open, loc) { + above <- if (lo_open) x > lo else x >= lo + below <- if (hi_open) x < hi else x <= hi + if (!isTRUE(all(above & below))) { + bad <- x[!(above & below)][1L] + stop(sprintf("Type refinement violation at %s: value %s is out of bounds %s%s, %s%s", + loc, format(bad), if (lo_open || is.infinite(lo)) "(" else "[", format(lo), format(hi), + if (hi_open || is.infinite(hi)) ")" else "]"), call. = FALSE) + } + x +} + # --- Génériques S3 --- get <- function(a, ...) UseMethod("get") diff --git a/editors/vim/syntax/typr.vim b/editors/vim/syntax/typr.vim index 2f6f32a..edc89e9 100644 --- a/editors/vim/syntax/typr.vim +++ b/editors/vim/syntax/typr.vim @@ -43,6 +43,7 @@ syn match typrTypesVariant "\v\.[A-Z][A-Za-z0-9_]*>" syn match typrKeywordsBlock "\v<%(Test|JS|R)%(\s*[\[{])@=" syn match typrTypesConstructor "\v<%(library|Class|seq|c)%(\s*[\[(])@=" syn match typrTypesBuiltinIndexed "\v<%(df)%(\s*\[)@=" +syn match typrTypesRefinement "\v<%(length)%(\s*\(\s*[\d<>])@=" syn match typrTypesBuiltin "\v<%(UnknownFunction|data__frame|data\.frame|dataframe|Record|Array|Tuple|tuple|list|Vec)[A-Za-z0-9_]@!" syn keyword typrTypesPrimitive int num char bool logic Any Empty Self syn keyword typrConstants true TRUE false FALSE null NULL na NA @@ -75,6 +76,7 @@ hi def link typrKeywordsOperatorWord Keyword hi def link typrConstants Constant hi def link typrTypesPrimitive Type hi def link typrTypesBuiltin Type +hi def link typrTypesRefinement Type hi def link typrTypesBuiltinIndexed Type hi def link typrTypesConstructor Function hi def link typrKeywordsBlock Keyword diff --git a/editors/vscode/syntaxes/typr.tmLanguage.json b/editors/vscode/syntaxes/typr.tmLanguage.json index 26c5950..0b88223 100644 --- a/editors/vscode/syntaxes/typr.tmLanguage.json +++ b/editors/vscode/syntaxes/typr.tmLanguage.json @@ -54,6 +54,9 @@ { "include": "#types.builtin" }, + { + "include": "#types.refinement" + }, { "include": "#types.builtin-indexed" }, @@ -473,6 +476,15 @@ } ] }, + "types.refinement": { + "comment": "`[int] & length(5)`, `[int] & length(> 0)` (`parsing/types.rs::refinement_property`). `length` is also R's most common function, so it only counts glued to `(` or `(`: `length(x)` stays a plain call.", + "patterns": [ + { + "match": "(?])", + "name": "support.type.builtin.typr" + } + ] + }, "types.sigil-generic": { "comment": "Sigil + generic. A generic name is a *single* uppercase letter or `Self` (`parsing/types.rs::upper_case_generic`), so the rule stops there instead of running on into a PascalCase alias.", "patterns": [ From a5d17c3f1c46770f1cb51d9c67deaaab7679b7c8 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 10:58:41 +0200 Subject: [PATCH 36/52] field preparation for migrating to dependent types to refined types --- .../src/components/context/fingerprint.rs | 4 +- .../src/components/language/operators.rs | 14 +- .../src/components/type/argument_type.rs | 7 +- crates/typr-core/src/components/type/mod.rs | 59 ++- .../src/components/type/refinement.rs | 2 +- .../typr-core/src/processes/parsing/types.rs | 93 ++-- .../src/processes/transpiling/mod.rs | 16 +- .../processes/type_checking/dollar_access.rs | 25 +- .../src/processes/type_checking/function.rs | 12 +- .../type_checking/function_application.rs | 35 +- .../processes/type_checking/let_expression.rs | 4 +- .../src/processes/type_checking/mod.rs | 75 ++- .../type_checking/refinement_check.rs | 2 +- .../type_checking/type_arithmetic.rs | 30 +- .../type_checking/type_comparison.rs | 7 +- .../processes/type_checking/unification.rs | 17 +- crates/typr-core/src/utils/builder.rs | 10 +- crates/typr-core/src/utils/program_gen.rs | 6 +- .../typr-lsp/src/handlers/semantic_tokens.rs | 8 +- install_plan.md | 454 ++++++++++++++++++ 20 files changed, 673 insertions(+), 207 deletions(-) create mode 100644 install_plan.md diff --git a/crates/typr-core/src/components/context/fingerprint.rs b/crates/typr-core/src/components/context/fingerprint.rs index 9e38daf..cd5dce4 100644 --- a/crates/typr-core/src/components/context/fingerprint.rs +++ b/crates/typr-core/src/components/context/fingerprint.rs @@ -64,10 +64,10 @@ pub fn canonical_type_debug(t: &Type) -> String { canonical_type_debug(ret), h ), - Type::Vec(vt, size, inner, h) => format!( + Type::Vec(vt, _, inner, h) => format!( "Vec({:?}, {:?}, {:?}, {:?})", vt, - canonical_type_debug(size), + t.vec_length().map(|size| canonical_type_debug(&size)).unwrap_or_default(), canonical_type_debug(inner), h ), diff --git a/crates/typr-core/src/components/language/operators.rs b/crates/typr-core/src/components/language/operators.rs index bba6297..756103d 100644 --- a/crates/typr-core/src/components/language/operators.rs +++ b/crates/typr-core/src/components/language/operators.rs @@ -73,12 +73,7 @@ fn lang_to_cast_type(lang: &Lang) -> Option { [idx, elem] => (lang_to_cast_type(idx)?, elem), _ => return None, }; - Some(Type::Vec( - VecType::S3, - Box::new(idx), - Box::new(lang_to_cast_type(elem)?), - help_data.clone(), - )) + Some(Type::vec(VecType::S3, idx, lang_to_cast_type(elem)?, help_data.clone())) } Lang::ArrayIndexing { identifier, @@ -98,12 +93,7 @@ fn lang_to_cast_type(lang: &Lang) -> Option { [idx, elem] => (lang_to_cast_type(idx)?, elem), _ => return None, }; - Some(Type::Vec( - VecType::S3, - Box::new(idx), - Box::new(lang_to_cast_type(elem)?), - help_data.clone(), - )) + Some(Type::vec(VecType::S3, idx, lang_to_cast_type(elem)?, help_data.clone())) } _ => None, } diff --git a/crates/typr-core/src/components/type/argument_type.rs b/crates/typr-core/src/components/type/argument_type.rs index 17392c2..b272fdb 100644 --- a/crates/typr-core/src/components/type/argument_type.rs +++ b/crates/typr-core/src/components/type/argument_type.rs @@ -97,12 +97,7 @@ impl ArgumentType { pub fn body_type(&self) -> Type { if self.3 { let h = self.1.get_help_data(); - Type::Vec( - VecType::S3, - Box::new(Type::IndexGen("N".to_string(), h.clone())), - Box::new(self.1.clone()), - h, - ) + Type::vec(VecType::S3, Type::IndexGen("N".to_string(), h.clone()), self.1.clone(), h) } else { self.1.clone() } diff --git a/crates/typr-core/src/components/type/mod.rs b/crates/typr-core/src/components/type/mod.rs index 113b367..221aa26 100644 --- a/crates/typr-core/src/components/type/mod.rs +++ b/crates/typr-core/src/components/type/mod.rs @@ -365,6 +365,51 @@ impl Type { } } + /// Builds a vector type. `length` is the length slot: `Integer(n)` for a + /// known length, `Any` for an unsized vector, or an index generic / + /// index arithmetic (`#N`, `#N+1`). Every construction and every read of + /// that slot goes through `vec` / `vec_length` so that where + /// the length lives (today the `Vec` index, eventually `Measure::Length` + /// in a `Refined`) is decided in one place. + pub fn vec(kind: VecType, length: Type, elem: Type, help: HelpData) -> Type { + Type::Vec(kind, Box::new(length), Box::new(elem), help) + } + + /// The length of a `Vec`, `None` for any other type. Returned by value: + /// once the length lives in a `Refined` it has to be rebuilt on demand, + /// not borrowed. Cheap (an index type is a literal or a tiny tree). + pub fn vec_length(&self) -> Option { + match self { + Type::Vec(_, length, _, _) => Some((**length).clone()), + _ => None, + } + } + + /// The element type of a `Vec`. + pub fn vec_elem(&self) -> Option<&Type> { + match self { + Type::Vec(_, _, elem, _) => Some(elem), + _ => None, + } + } + + /// `self` with its length slot replaced when it is a `Vec`; any other + /// type is returned unchanged. + pub fn with_vec_length(&self, length: Type) -> Type { + match self { + Type::Vec(kind, _, elem, help) => Type::vec(kind.clone(), length, (**elem).clone(), help.clone()), + t => t.clone(), + } + } + + /// `self` with its element type replaced when it is a `Vec`. + pub fn with_vec_elem(&self, elem: Type) -> Type { + match self { + Type::Vec(kind, length, _, help) => Type::vec(kind.clone(), (**length).clone(), elem, help.clone()), + t => t.clone(), + } + } + pub fn lift(self, max_index: &(VecType, i32)) -> Type { match self.clone() { Type::Vec(_, i, _, _) if i.equal(max_index.1) => self, @@ -373,10 +418,10 @@ impl Type { // into `[3, num]` to make a `[3, num]` argument fit. Type::Vec(_, i, _, _) if matches!(*i, Type::Integer(..)) => self, Type::Vec(v, _, t, h) => Type::Vec(v, Box::new(builder::integer_type(max_index.1)), t.clone(), h.clone()), - t => Type::Vec( + t => Type::vec( max_index.0.clone(), - Box::new(builder::integer_type(max_index.1)), - Box::new(t.clone()), + builder::integer_type(max_index.1), + t.clone(), t.get_help_data(), ), } @@ -637,10 +682,10 @@ impl Type { a.index_calculation().mul_index(&b.index_calculation()) } Type::Operator(TypeOperator::Division, a, b, _) => a.index_calculation().div_index(&b.index_calculation()), - Type::Vec(vtype, ind, typ, h) => Type::Vec( + Type::Vec(vtype, ind, typ, h) => Type::vec( vtype.clone(), - Box::new(ind.index_calculation()), - Box::new(typ.index_calculation()), + ind.index_calculation(), + typ.index_calculation(), h.clone(), ), Type::Function(args, ret_typ, h) => { @@ -1118,7 +1163,7 @@ impl Type { match dims_and_base.pop() { None => builder::any_type(), Some(base) => dims_and_base.into_iter().rev().fold(base, |acc, dim| { - Type::Vec(VecType::S3, Box::new(dim), Box::new(acc), HelpData::default()) + Type::vec(VecType::S3, dim, acc, HelpData::default()) }), } } diff --git a/crates/typr-core/src/components/type/refinement.rs b/crates/typr-core/src/components/type/refinement.rs index 66c733b..8dbab59 100644 --- a/crates/typr-core/src/components/type/refinement.rs +++ b/crates/typr-core/src/components/type/refinement.rs @@ -525,7 +525,7 @@ mod type_tests { Some(n) => int_lit(n), None => builder::integer_type_default(), }; - Type::Vec(VecType::S3, Box::new(index), Box::new(builder::integer_type_default()), HelpData::default()) + Type::vec(VecType::S3, index, builder::integer_type_default(), HelpData::default()) } #[test] diff --git a/crates/typr-core/src/processes/parsing/types.rs b/crates/typr-core/src/processes/parsing/types.rs index c24e402..2491fb4 100644 --- a/crates/typr-core/src/processes/parsing/types.rs +++ b/crates/typr-core/src/processes/parsing/types.rs @@ -181,7 +181,7 @@ fn array_type_full(s: Span) -> IResult { .parse(s); match res { - Ok((s, (start, num, _, typ, _))) => Ok((s, Type::Vec(VecType::S3, Box::new(num), Box::new(typ), start.into()))), + Ok((s, (start, num, _, typ, _))) => Ok((s, Type::vec(VecType::S3, num, typ, start.into()))), Err(r) => Err(r), } } @@ -197,12 +197,7 @@ fn array_type_short(s: Span) -> IResult { match res { Ok((s, (start, typ, _))) => Ok(( s, - Type::Vec( - VecType::S3, - Box::new(Type::Any(start.clone().into())), - Box::new(typ), - start.into(), - ), + Type::vec(VecType::S3, Type::Any(start.clone().into()), typ, start.into()), )), Err(r) => Err(r), } @@ -230,7 +225,7 @@ fn named_array_type_full(s: Span) -> IResult { match res { Ok((s, (start, num, _, typ, _))) => { - Ok((s, Type::Vec(VecType::Array, Box::new(num), Box::new(typ), start.into()))) + Ok((s, Type::vec(VecType::Array, num, typ, start.into()))) } Err(r) => Err(r), } @@ -247,12 +242,7 @@ fn named_array_type_short(s: Span) -> IResult { match res { Ok((s, (start, typ, _))) => Ok(( s, - Type::Vec( - VecType::Array, - Box::new(Type::Any(start.clone().into())), - Box::new(typ), - start.into(), - ), + Type::vec(VecType::Array, Type::Any(start.clone().into()), typ, start.into()), )), Err(r) => Err(r), } @@ -275,7 +265,7 @@ fn vector_type_full(s: Span) -> IResult { match res { Ok((s, (start, num, _, typ, _))) => Ok(( s, - Type::Vec(VecType::Vector, Box::new(num), Box::new(typ), start.into()), + Type::vec(VecType::Vector, num, typ, start.into()), )), Err(r) => Err(r), } @@ -292,12 +282,7 @@ fn vector_type_short(s: Span) -> IResult { match res { Ok((s, (start, typ, _))) => Ok(( s, - Type::Vec( - VecType::Vector, - Box::new(Type::Any(start.clone().into())), - Box::new(typ), - start.into(), - ), + Type::vec(VecType::Vector, Type::Any(start.clone().into()), typ, start.into()), )), Err(r) => Err(r), } @@ -321,10 +306,10 @@ fn dataframe_type_full(s: Span) -> IResult { match res { Ok((s, (start, num, _, _, columns, _))) => Ok(( s, - Type::Vec( + Type::vec( VecType::DataFrame, - Box::new(num), - Box::new(Type::Record(columns.iter().cloned().collect(), start.clone().into())), + num, + Type::Record(columns.iter().cloned().collect(), start.clone().into()), start.into(), ), )), @@ -344,10 +329,10 @@ fn dataframe_type_short(s: Span) -> IResult { match res { Ok((s, (start, _, columns, _))) => Ok(( s, - Type::Vec( + Type::vec( VecType::DataFrame, - Box::new(Type::Any(start.clone().into())), - Box::new(Type::Record(columns.iter().cloned().collect(), start.clone().into())), + Type::Any(start.clone().into()), + Type::Record(columns.iter().cloned().collect(), start.clone().into()), start.into(), ), )), @@ -377,10 +362,10 @@ fn named_record_type(s: Span) -> IResult { match res { Ok((s, ((name, h), _, num, _, _, columns, _))) => Ok(( s, - Type::Vec( + Type::vec( VecType::Named(name), - Box::new(num), - Box::new(Type::Record(columns.iter().cloned().collect(), h.clone())), + num, + Type::Record(columns.iter().cloned().collect(), h.clone()), h, ), )), @@ -408,10 +393,10 @@ fn named_record_bad_index(s: Span) -> IResult { push_parse_error(SyntaxError::RecordConstructorIndex(h.clone())); Ok(( s, - Type::Vec( + Type::vec( VecType::Named(name), - Box::new(first), - Box::new(Type::Record(columns.iter().cloned().collect(), h.clone())), + first, + Type::Record(columns.iter().cloned().collect(), h.clone()), h, ), )) @@ -445,10 +430,10 @@ fn recursive_with_record_error(s: Span) -> IResult { push_parse_error(SyntaxError::RecordInRecursiveParams(h.clone())); Ok(( s, - Type::Vec( + Type::vec( VecType::S3, - Box::new(num), - Box::new(Type::Record(columns.iter().cloned().collect(), h.clone())), + num, + Type::Record(columns.iter().cloned().collect(), h.clone()), h, ), )) @@ -1360,8 +1345,8 @@ mod tests { assert!(res.is_ok(), "dataframe type with numeric index should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::DataFrame, idx, body, _) => { - match idx.as_ref() { + Type::Vec(VecType::DataFrame, _, body, _) => { + match typ.vec_length().expect("vec length") { Type::Integer(_, _) => {} other => panic!("Expected Integer index, got {:?}", other), } @@ -1405,7 +1390,7 @@ mod tests { assert!(res.is_ok(), "dataframe with Any index should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::DataFrame, idx, _, _) => match idx.as_ref() { + Type::Vec(VecType::DataFrame, _, _, _) => match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), }, @@ -1419,7 +1404,7 @@ mod tests { assert!(res.is_ok(), "Vec with Any index should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::Vector, idx, _, _) => match idx.as_ref() { + Type::Vec(VecType::Vector, _, _, _) => match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), }, @@ -1433,7 +1418,7 @@ mod tests { assert!(res.is_ok(), "Array with Any index should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::S3, idx, _, _) => match idx.as_ref() { + Type::Vec(VecType::S3, _, _, _) => match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), }, @@ -1447,8 +1432,8 @@ mod tests { assert!(res.is_ok(), "dataframe without index should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::DataFrame, idx, body, _) => { - match idx.as_ref() { + Type::Vec(VecType::DataFrame, _, body, _) => { + match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), } @@ -1467,7 +1452,7 @@ mod tests { assert!(res.is_ok(), "df without index should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::DataFrame, idx, _, _) => match idx.as_ref() { + Type::Vec(VecType::DataFrame, _, _, _) => match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), }, @@ -1481,8 +1466,8 @@ mod tests { assert!(res.is_ok(), "Vec[type] short form should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::Vector, idx, body, _) => { - match idx.as_ref() { + Type::Vec(VecType::Vector, _, body, _) => { + match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), } @@ -1501,8 +1486,8 @@ mod tests { assert!(res.is_ok(), "[type] short form should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::S3, idx, body, _) => { - match idx.as_ref() { + Type::Vec(VecType::S3, _, body, _) => { + match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), } @@ -1521,8 +1506,8 @@ mod tests { assert!(res.is_ok(), "Array[index, type] should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::Array, idx, body, _) => { - match idx.as_ref() { + Type::Vec(VecType::Array, _, body, _) => { + match typ.vec_length().expect("vec length") { Type::Integer(_, _) => {} other => panic!("Expected Integer index, got {:?}", other), } @@ -1541,8 +1526,8 @@ mod tests { assert!(res.is_ok(), "Array[type] short form should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::Array, idx, body, _) => { - match idx.as_ref() { + Type::Vec(VecType::Array, _, body, _) => { + match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), } @@ -1609,9 +1594,9 @@ mod tests { fn test_named_record_constructor_parses() { let typ = ltype("Tibble[3]{ id: int, active: bool }".into()).unwrap().1; match &typ { - Type::Vec(VecType::Named(name), idx, body, _) => { + Type::Vec(VecType::Named(name), _, body, _) => { assert_eq!(name, "Tibble"); - assert!(matches!(idx.as_ref(), Type::Integer(_, _))); + assert!(matches!(typ.vec_length(), Some(Type::Integer(_, _)))); match body.as_ref() { Type::Record(fields, _) => assert_eq!(fields.len(), 2), other => panic!("Expected Record body, got {:?}", other), diff --git a/crates/typr-core/src/processes/transpiling/mod.rs b/crates/typr-core/src/processes/transpiling/mod.rs index 7e06ae1..264596f 100644 --- a/crates/typr-core/src/processes/transpiling/mod.rs +++ b/crates/typr-core/src/processes/transpiling/mod.rs @@ -1771,7 +1771,7 @@ impl RTranslatable<(String, Context)> for Lang { // into a `data.frame` instead of a `list`, the class // chain carries `"data.frame"`, and a concrete size // index (`df[3]{...}`) additionally checks `nrow(x)`. - Type::Vec(VecType::DataFrame, size, fields_type, _) + Type::Vec(VecType::DataFrame, _, fields_type, _) if matches!(fields_type.as_ref(), Type::Record(_, _)) => { use crate::components::r#type::tint::Tint; @@ -1832,7 +1832,7 @@ impl RTranslatable<(String, Context)> for Lang { // Row-count check: only emitted when the size index is // a concrete literal (`df[3]{...}`); a generic (`#N`) // or unconstrained (`df{...}`) size imposes no check. - let size_check = if let Type::Integer(Tint::Val(n), _) = size.as_ref() { + let size_check = if let Some(Type::Integer(Tint::Val(n), _)) = typ_for_dispatch.vec_length() { format!( " if (nrow(x) != {n}) stop(paste0(\"Validation failed for type {name}: expected {n} rows, got \", nrow(x)))\n" ) @@ -1851,7 +1851,7 @@ impl RTranslatable<(String, Context)> for Lang { // constructor delegates straight to the validator — plus // an optional length check when the size index is a // concrete literal. - Type::Vec(VecType::Vector, size, elem_type, _) => { + Type::Vec(VecType::Vector, _, elem_type, _) => { use crate::components::r#type::tint::Tint; let constructor = format!("{name} <- function(x) {{\n validate_{name}(x)\n}}"); let elem_check = record_field_class(elem_type.as_ref(), cont).map(|cls| { @@ -1859,7 +1859,7 @@ impl RTranslatable<(String, Context)> for Lang { " if (!inherits(x, \"{cls}\")) stop(\"Validation failed for type {name}: expected vector of {cls}\")\n" ) }).unwrap_or_default(); - let size_check = if let Type::Integer(Tint::Val(n), _) = size.as_ref() { + let size_check = if let Some(Type::Integer(Tint::Val(n), _)) = typ_for_dispatch.vec_length() { format!( " if (length(x) != {n}) stop(paste0(\"Validation failed for type {name}: expected length {n}, got \", length(x)))\n" ) @@ -1886,7 +1886,7 @@ impl RTranslatable<(String, Context)> for Lang { // still appends the alias class (S3 dispatch for // alias-typed parameters relies on it); `c()`/subsetting // strip it, but TypR re-annotates from the static type. - Type::Vec(VecType::S3, size, elem_type, _) | Type::Vec(VecType::Array, size, elem_type, _) + Type::Vec(VecType::S3, _, elem_type, _) | Type::Vec(VecType::Array, _, elem_type, _) if cont.atomic_array_elem(typ).is_some() => { use crate::components::r#type::tint::Tint; @@ -1909,7 +1909,7 @@ impl RTranslatable<(String, Context)> for Lang { " if (!is.numeric(x)) stop(\"Validation failed for type {name}: expected a numeric vector\")\n" ), }; - let size_check = if let Type::Integer(Tint::Val(n), _) = size.as_ref() { + let size_check = if let Some(Type::Integer(Tint::Val(n), _)) = typ_for_dispatch.vec_length() { format!( " if (length(x) != {n}) stop(paste0(\"Validation failed for type {name}: expected length {n}, got \", length(x)))\n" ) @@ -1919,7 +1919,7 @@ impl RTranslatable<(String, Context)> for Lang { let validator = format!("validate_{name} <- function(x) {{\n{elem_check}{size_check} x\n}}"); (format!("{constructor}\n{annotator}\n{validator}"), cont.clone()) } - Type::Vec(VecType::S3, size, elem_type, _) | Type::Vec(VecType::Array, size, elem_type, _) => { + Type::Vec(VecType::S3, _, elem_type, _) | Type::Vec(VecType::Array, _, elem_type, _) => { use crate::components::r#type::tint::Tint; let constructor = format!( "{name} <- function(x) {{\n if (!inherits(x, \"typed_vec\")) x <- typed_vec(x)\n as.{name}(x)\n}}" @@ -1933,7 +1933,7 @@ impl RTranslatable<(String, Context)> for Lang { " if (!all(vapply(x$data, inherits, logical(1), \"{cls}\"))) stop(\"Validation failed for type {name}: expected elements of class {cls}\")\n" ) }).unwrap_or_default(); - let size_check = if let Type::Integer(Tint::Val(n), _) = size.as_ref() { + let size_check = if let Some(Type::Integer(Tint::Val(n), _)) = typ_for_dispatch.vec_length() { format!( " if (length(x) != {n}) stop(paste0(\"Validation failed for type {name}: expected length {n}, got \", length(x)))\n" ) diff --git a/crates/typr-core/src/processes/type_checking/dollar_access.rs b/crates/typr-core/src/processes/type_checking/dollar_access.rs index 106db30..f67d04f 100644 --- a/crates/typr-core/src/processes/type_checking/dollar_access.rs +++ b/crates/typr-core/src/processes/type_checking/dollar_access.rs @@ -45,7 +45,8 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & let mut errors = tc1.errors; let ty1 = tc1.value; - match (ty1.reduce(context), e2.clone(), &op) { + let reduced1 = ty1.reduce(context); + match (reduced1.clone(), e2.clone(), &op) { (Type::Record(fields, _), Lang::Variable { name, help_data: h, .. }, _) => { field_access::find_field_or_push_error(&fields, &name, &ty1, h, &mut errors) .map(|ty| TypeContext::new(ty, expr.clone(), context.clone()).with_errors(errors.clone())) @@ -167,7 +168,7 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & } // DataFrame $ access: df$col -> [N, col_type] (VecType::Vector) ( - Type::Vec(VecType::DataFrame, n, ref body, h), + Type::Vec(VecType::DataFrame, _, ref body, h), Lang::Variable { name, help_data: ref vh, @@ -183,7 +184,7 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & other => other, }; TypeContext::new( - Type::Vec(VecType::Vector, n.clone(), Box::new(inner_type), h.clone()), + Type::vec(VecType::Vector, reduced1.vec_length().unwrap_or_else(builder::any_type), inner_type, h.clone()), expr.clone(), context.clone(), ) @@ -199,7 +200,7 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & }, // DataFrame $ access with string literal: df$"col" ( - Type::Vec(VecType::DataFrame, n, ref body, h), + Type::Vec(VecType::DataFrame, _, ref body, h), Lang::Char { value: name, help_data: ref vh, @@ -214,7 +215,7 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & other => other, }; TypeContext::new( - Type::Vec(VecType::Vector, n, Box::new(inner_type), h.clone()), + Type::vec(VecType::Vector, reduced1.vec_length().unwrap_or_else(builder::any_type), inner_type, h.clone()), expr.clone(), context.clone(), ) @@ -229,7 +230,7 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & } }, // DataFrame $ update with list: df$:{col: new_type} - (Type::Vec(VecType::DataFrame, n, ref body, h), Lang::List { value: fields2, .. }, _) => match body.as_ref() { + (Type::Vec(VecType::DataFrame, _, ref body, h), Lang::List { value: fields2, .. }, _) => match body.as_ref() { Type::Record(fields1, rh) => { let at = fields2[0].clone(); let fields3 = fields1 @@ -237,10 +238,10 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & .map(replace_fields_type_if_needed(context, at)) .collect::>(); TypeContext::new( - Type::Vec( + Type::vec( VecType::DataFrame, - n, - Box::new(Type::Record(fields3, rh.clone())), + reduced1.vec_length().unwrap_or_else(builder::any_type), + Type::Record(fields3, rh.clone()), h.clone(), ), expr.clone(), @@ -255,7 +256,7 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & }, // DataFrame $ function call: df$fn(args) ( - Type::Vec(VecType::DataFrame, _n, ref body, _h), + Type::Vec(VecType::DataFrame, _, ref body, _h), Lang::FunctionApp { identifier: exp, arguments: params, @@ -293,7 +294,7 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors) } }, - (Type::Vec(vtype, n, _, h), Lang::Variable { .. }, _) => match ArrayLang::try_from(e1.clone()) { + (Type::Vec(vtype, _, _, h), Lang::Variable { .. }, _) => match ArrayLang::try_from(e1.clone()) { Ok(arr_lang) => match arr_lang.get_first_argument() { Some(first_arg) => { let tc = typing( @@ -302,7 +303,7 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & ); errors.extend(tc.errors); TypeContext::new( - Type::Vec(vtype, n, Box::new(tc.value), h.clone()), + Type::vec(vtype, reduced1.vec_length().unwrap_or_else(builder::any_type), tc.value, h.clone()), tc.lang, context.clone(), ) diff --git a/crates/typr-core/src/processes/type_checking/function.rs b/crates/typr-core/src/processes/type_checking/function.rs index d94b57e..4989538 100644 --- a/crates/typr-core/src/processes/type_checking/function.rs +++ b/crates/typr-core/src/processes/type_checking/function.rs @@ -53,8 +53,10 @@ fn collect_generic_kind_occurrences(typ: &Type, acc: &mut Vec<(String, ObservedK .for_each(|a| collect_generic_kind_occurrences(&a.get_type(), acc)); collect_generic_kind_occurrences(ret, acc); } - Type::Vec(_, idx, body, _) => { - collect_generic_kind_occurrences(idx, acc); + Type::Vec(_, _, body, _) => { + if let Some(length) = typ.vec_length() { + collect_generic_kind_occurrences(&length, acc); + } collect_generic_kind_occurrences(body, acc); } Type::Record(fields, _) | Type::Interface(fields, _) => fields @@ -190,8 +192,10 @@ fn collect_interface_leaf_types(context: &Context, typ: &Type, acc: &mut Vec { - collect_interface_leaf_types(context, idx, acc); + Type::Vec(_, _, body, _) => { + if let Some(length) = typ.vec_length() { + collect_interface_leaf_types(context, &length, acc); + } collect_interface_leaf_types(context, body, acc); } Type::Record(fields, _) => fields diff --git a/crates/typr-core/src/processes/type_checking/function_application.rs b/crates/typr-core/src/processes/type_checking/function_application.rs index f2151de..0c053e4 100644 --- a/crates/typr-core/src/processes/type_checking/function_application.rs +++ b/crates/typr-core/src/processes/type_checking/function_application.rs @@ -368,9 +368,11 @@ fn collect_named_generics(concrete: &Type, param: &Type, subs: &mut std::collect Type::IndexGen(name, _) => { subs.entry(name.clone()).or_insert_with(|| concrete.clone()); } - Type::Vec(_, size_param, elem_param, _) => { - if let Type::Vec(_, size_concrete, elem_concrete, _) = concrete { - collect_named_generics(size_concrete, size_param, subs); + Type::Vec(_, _, elem_param, _) => { + if let Type::Vec(_, _, elem_concrete, _) = concrete { + if let (Some(size_concrete), Some(size_param)) = (concrete.vec_length(), param.vec_length()) { + collect_named_generics(&size_concrete, &size_param, subs); + } collect_named_generics(elem_concrete, elem_param, subs); } } @@ -466,10 +468,10 @@ fn apply_named_generics(ty: &Type, subs: &std::collections::HashMap subs.get(name).cloned().unwrap_or_else(|| ty.clone()), - Type::Vec(vt, size, elem, h) => Type::Vec( + Type::Vec(vt, _, elem, h) => Type::vec( vt.clone(), - Box::new(apply_named_generics(size, subs)), - Box::new(apply_named_generics(elem, subs)), + apply_named_generics(&ty.vec_length().unwrap_or_else(builder::any_type), subs), + apply_named_generics(elem, subs), h.clone(), ), Type::Function(params, ret, h) => Type::Function( @@ -515,8 +517,10 @@ fn collect_interface_bindings(concrete: &Type, param: &Type, context: &Context, return; } match (concrete, param) { - (Type::Vec(_, size_c, elem_c, _), Type::Vec(_, size_p, elem_p, _)) => { - collect_interface_bindings(size_c, size_p, context, mapping); + (Type::Vec(_, _, elem_c, _), Type::Vec(_, _, elem_p, _)) => { + if let (Some(size_c), Some(size_p)) = (concrete.vec_length(), param.vec_length()) { + collect_interface_bindings(&size_c, &size_p, context, mapping); + } collect_interface_bindings(elem_c, elem_p, context, mapping); } (Type::Function(params_c, ret_c, _), Type::Function(params_p, ret_p, _)) => { @@ -534,10 +538,10 @@ fn substitute_interface_types(ty: &Type, mapping: &[(Type, Type)]) -> Type { return concrete.clone(); } match ty { - Type::Vec(vt, size, elem, h) => Type::Vec( + Type::Vec(vt, _, elem, h) => Type::vec( vt.clone(), - Box::new(substitute_interface_types(size, mapping)), - Box::new(substitute_interface_types(elem, mapping)), + substitute_interface_types(&ty.vec_length().unwrap_or_else(builder::any_type), mapping), + substitute_interface_types(elem, mapping), h.clone(), ), Type::Function(params, ret, h) => Type::Function( @@ -1132,18 +1136,19 @@ fn known_effect_call(var: &Var, context: &Context, parameters: &[Lang]) -> Optio return None; } let arg = reduce_type(context, &typing(context, ¶meters[0]).value); - let Type::Vec(vt, n, elem, h) = &arg else { + let Type::Vec(vt, _, elem, h) = &arg else { return None; }; + let n = arg.vec_length()?; match name.as_str() { - "length" => literal_index(n).map(|_| (**n).clone()), + "length" => literal_index(&n).map(|_| n.clone()), "rev" => Some(arg.clone()), _ => { - let len = literal_index(n)?; + let len = literal_index(&n)?; let k = literal_index(&reduce_type(context, &typing(context, ¶meters[1]).value))?; let kept = if k >= 0 { len.min(k) } else { (len + k).max(0) }; let index = Type::Integer(crate::components::r#type::tint::Tint::Val(kept as _), h.clone()); - Some(Type::Vec(vt.clone(), Box::new(index), elem.clone(), h.clone())) + Some(Type::vec(vt.clone(), index, (**elem).clone(), h.clone())) } } } diff --git a/crates/typr-core/src/processes/type_checking/let_expression.rs b/crates/typr-core/src/processes/type_checking/let_expression.rs index 2b43d85..940ecdf 100644 --- a/crates/typr-core/src/processes/type_checking/let_expression.rs +++ b/crates/typr-core/src/processes/type_checking/let_expression.rs @@ -106,8 +106,8 @@ pub fn collect_undefined_aliases(context: &Context, ty: &Type) -> Vec errors.extend(collect_undefined_aliases(context, ret)); errors } - Type::Vec(_, ind, inner, _) => { - let mut errors = collect_undefined_aliases(context, ind); + Type::Vec(_, _, inner, _) => { + let mut errors = ty.vec_length().map_or_else(Vec::new, |length| collect_undefined_aliases(context, &length)); errors.extend(collect_undefined_aliases(context, inner)); errors } diff --git a/crates/typr-core/src/processes/type_checking/mod.rs b/crates/typr-core/src/processes/type_checking/mod.rs index 9e741c9..52b6898 100644 --- a/crates/typr-core/src/processes/type_checking/mod.rs +++ b/crates/typr-core/src/processes/type_checking/mod.rs @@ -745,8 +745,8 @@ fn get_gen_type(type1: &Type, type2: &Type) -> Option> { (Type::Refined(b1, _, _), Type::Refined(b2, _, _)) => get_gen_type(b1, b2), (Type::Refined(b1, _, _), t2) => get_gen_type(b1, t2), (t1, Type::Refined(b2, _, _)) => get_gen_type(t1, b2), - (Type::Vec(_, ind1, typ1, _), Type::Vec(_, ind2, typ2, _)) => { - let gen1 = get_gen_type(ind1, ind2); + (Type::Vec(_, _, typ1, _), Type::Vec(_, _, typ2, _)) => { + let gen1 = get_gen_type(&type1.vec_length()?, &type2.vec_length()?); let gen2 = get_gen_type(typ1, typ2); match (gen1, gen2) { (None, _) | (_, None) => None, @@ -965,25 +965,20 @@ fn try_concat_vectors(types: &[Type], h: &HelpData) -> Option { let mut elem: Option = None; for t in types { match t { - Type::Vec(vt, idx, el, _) if vt.is_vector() || vt.is_array() => { + Type::Vec(vt, _, el, _) if vt.is_vector() || vt.is_array() => { let el_gen = (**el).clone().generalize(); match &elem { None => elem = Some(el_gen), Some(e) if *e == el_gen => {} _ => return None, // heterogeneous element types } - indices.push((**idx).clone()); + indices.extend(t.vec_length()); } _ => return None, } } let elem = elem?; - Some(Type::Vec( - VecType::Vector, - Box::new(vec_index_sum(&indices, h)), - Box::new(elem), - h.clone(), - )) + Some(Type::vec(VecType::Vector, vec_index_sum(&indices, h), elem, h.clone())) } /// Rule 2 (records) — list concatenation: every argument is a record. Result is @@ -1022,12 +1017,7 @@ fn try_homogeneous_scalars(types: &[Type], n: usize, h: &HelpData) -> Option Vec[0, Any] - Type::Vec( - VecType::Vector, - Box::new(builder::integer_type(0)), - Box::default(), - h.clone(), - ) + Type::vec(VecType::Vector, builder::integer_type(0), Type::default(), h.clone()) } else if let Some(t) = try_concat_vectors(&types, h) { t } else if let Some(t) = try_concat_records(&types, h) { @@ -1060,10 +1045,10 @@ fn typing_vector(context: &Context, expr: &Lang, exprs: &[Lang], h: &HelpData) - } else { // Rule 4 — no rule applies: incompatible arguments for c(...). errors.push(TypRError::Type(TypeError::WrongExpression(h.clone()))); - Type::Vec( + Type::vec( VecType::Vector, - Box::new(builder::integer_type(exprs.len() as i32)), - Box::default(), + builder::integer_type(exprs.len() as i32), + Type::default(), h.clone(), ) }; @@ -1603,11 +1588,12 @@ fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { for (arg_val, tc) in fields.iter().zip(type_contexts.iter()) { let name = arg_val.get_argument(); match &tc.value { - Type::Vec(_, idx, _, _) => { + col @ Type::Vec(..) => { + let idx = col.vec_length().unwrap_or_else(builder::any_type); if fields.first().map(|f| f.get_argument()) == Some(name.clone()) { - index = (**idx).clone(); + index = idx.clone(); } - if let Type::Integer(Tint::Val(len), _) = idx.as_ref() { + if let Type::Integer(Tint::Val(len), _) = &idx { match &known_length { None => known_length = Some((name.clone(), *len)), Some((first_name, first_len)) if first_len != len => { @@ -1634,10 +1620,10 @@ fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { } TypeContext::new( - Type::Vec( + Type::vec( VecType::DataFrame, - Box::new(index), - Box::new(Type::Record(field_types, h.clone())), + index, + Type::Record(field_types, h.clone()), h.clone(), ), expr.clone(), @@ -1719,20 +1705,21 @@ fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { // sub-vector, not an element (refined_types_plan.md, phase 7). The // result keeps the element type; its length is known only when the // index is a literal range (`a:b`) — `mask` selections give `[int]`. - if let (Type::Vec(vt, _, elem, vh), Some([member])) = (&typ1, index.get_members_if_array().as_deref()) { + if let (Type::Vec(_, _, elem, _), Some([member])) = (&typ1, index.get_members_if_array().as_deref()) { let dynamic = !matches!(member, Lang::Integer { .. } | Lang::Array { .. } | Lang::Vector { .. }); if dynamic && !matches!(**elem, Type::Vec(..)) { let idx_tc = typing(context, member); - if let Type::Vec(_, n, ie, _) = reduce_type(context, &idx_tc.value) { - let len = match reduce_type(context, &ie) { - Type::Integer(..) => Some(literal_range_len(member).map_or_else(|| (*n).clone(), builder::integer_type)), + let idx_type = reduce_type(context, &idx_tc.value); + if let (Type::Vec(_, _, ie, _), Some(n)) = (&idx_type, idx_type.vec_length()) { + let len = match reduce_type(context, ie) { + Type::Integer(..) => Some(literal_range_len(member).map_or_else(|| n.clone(), builder::integer_type)), Type::Boolean(..) => Some(builder::any_type()), _ => None, }; if let Some(len) = len { let len = if matches!(len, Type::Integer(Tint::Val(_), _) | Type::Any(_)) { len } else { builder::any_type() }; errors.extend(idx_tc.errors); - let typ2 = Type::Vec(vt.clone(), Box::new(len), elem.clone(), vh.clone()); + let typ2 = typ1.with_vec_length(len); return TypeContext::new(typ2, expr.clone(), context.clone()).with_errors(errors); } } @@ -2872,10 +2859,10 @@ mod tests { fields.insert(ArgumentType::new("Pulse", &builder::integer_type_default())); let record_type = Type::Record(fields, HelpData::default()); - let df_type = Type::Vec( + let df_type = Type::vec( VecType::DataFrame, - Box::new(builder::integer_type(3)), - Box::new(record_type.clone()), + builder::integer_type(3), + record_type.clone(), HelpData::default(), ); @@ -2897,10 +2884,10 @@ mod tests { fields.insert(ArgumentType::new("Pulse", &builder::integer_type_default())); let record_type = Type::Record(fields, HelpData::default()); - let df_type = Type::Vec( + let df_type = Type::vec( VecType::DataFrame, - Box::new(builder::integer_type(3)), - Box::new(record_type.clone()), + builder::integer_type(3), + record_type.clone(), HelpData::default(), ); @@ -4962,7 +4949,7 @@ fn literal_range_len(member: &Lang) -> Option { /// booleans whose length is that of the vector side(s). `None` otherwise. fn vectorized_comparison(context: &Context, t1: &Type, t2: &Type) -> Option { let split = |t: &Type| match t { - Type::Vec(vt, n, e, h) if !matches!(**e, Type::Vec(..)) => Some((vt.clone(), (**n).clone(), (**e).clone(), h.clone())), + Type::Vec(vt, _, e, h) if !matches!(**e, Type::Vec(..)) => Some((vt.clone(), t.vec_length()?, (**e).clone(), h.clone())), _ => None, }; let (s1, s2) = (split(t1), split(t2)); @@ -4979,5 +4966,5 @@ fn vectorized_comparison(context: &Context, t1: &Type, t2: &Type) -> Option (vt, n, (), h), (None, None) => return None, }; - Some(Type::Vec(vt, Box::new(n), Box::new(builder::boolean_type()), h)) + Some(Type::vec(vt, n, builder::boolean_type(), h)) } diff --git a/crates/typr-core/src/processes/type_checking/refinement_check.rs b/crates/typr-core/src/processes/type_checking/refinement_check.rs index 4af016c..bccc5ff 100644 --- a/crates/typr-core/src/processes/type_checking/refinement_check.rs +++ b/crates/typr-core/src/processes/type_checking/refinement_check.rs @@ -34,7 +34,7 @@ pub enum Coercion { fn base_of(t: &Type) -> Type { match t { Type::Refined(base, _, _) => base_of(base), - Type::Vec(kind, _, elem, h) => Type::Vec(kind.clone(), Box::new(Type::Any(h.clone())), elem.clone(), h.clone()), + Type::Vec(_, _, _, h) => t.with_vec_length(Type::Any(h.clone())), t => t.clone(), } } diff --git a/crates/typr-core/src/processes/type_checking/type_arithmetic.rs b/crates/typr-core/src/processes/type_checking/type_arithmetic.rs index 4d1e066..78f1a9f 100644 --- a/crates/typr-core/src/processes/type_checking/type_arithmetic.rs +++ b/crates/typr-core/src/processes/type_checking/type_arithmetic.rs @@ -246,7 +246,8 @@ pub(crate) fn apply_refinements(base: Type, set: RefinementSet, h: HelpData) -> let full = Interval::full(); match &base { Type::Empty(_) => base, - Type::Vec(kind, index, elem, vh) => { + Type::Vec(_, _, _, vh) => { + let index = base.vec_length().unwrap_or_else(|| Type::Any(vh.clone())); if set.get(Measure::Value).is_some() { return invalid(Measure::Value); } @@ -254,13 +255,8 @@ pub(crate) fn apply_refinements(base: Type, set: RefinementSet, h: HelpData) -> return unsatisfiable("has no value"); } let len = set.get(Measure::Length).and_then(|iv| iv.as_point()); - match (len, &**index) { - (Some(n), Type::Any(_)) => Type::Vec( - kind.clone(), - Box::new(Type::Integer(Tint::Val(n as i32), vh.clone())), - elem.clone(), - vh.clone(), - ), + match (len, &index) { + (Some(n), Type::Any(_)) => base.with_vec_length(Type::Integer(Tint::Val(n as i32), vh.clone())), (Some(n), Type::Integer(Tint::Val(m), _)) if n as i32 == *m => base.clone(), (Some(n), Type::Integer(Tint::Val(m), _)) => { unsatisfiable(&format!("contradicts its length {} (asked for {})", m, n)) @@ -397,8 +393,10 @@ fn collect_failed_types(typ: &Type, acc: &mut Vec<(String, HelpData)>) { Type::Record(fields, _) | Type::Interface(fields, _) => { fields.iter().for_each(|a| collect_failed_types(&a.get_type(), acc)) } - Type::Vec(_, idx, body, _) => { - collect_failed_types(idx, acc); + Type::Vec(_, _, body, _) => { + if let Some(idx) = typ.vec_length() { + collect_failed_types(&idx, acc); + } collect_failed_types(body, acc); } Type::Tag(_, inner, _) | Type::Multi(inner, _) => collect_failed_types(inner, acc), @@ -756,19 +754,19 @@ mod tests { fn length_range_on_vectors() { use crate::components::r#type::refinement::{Interval, Measure}; let range = || prop(Refinement::Range(Measure::Length, Interval::greater_than(0.0))); - let unknown = Type::Vec( + let unknown = Type::vec( crate::components::r#type::vector_type::VecType::S3, - Box::new(Type::Any(HelpData::default())), - Box::new(builder::integer_type_default()), + Type::Any(HelpData::default()), + builder::integer_type_default(), HelpData::default(), ); // unknown length: the range is kept as a property assert!(matches!(inter(unknown.clone(), range()), Type::Refined(..))); // known length: satisfied range is redundant, violated one is a contradiction - let sized = |n| Type::Vec( + let sized = |n| Type::vec( crate::components::r#type::vector_type::VecType::S3, - Box::new(builder::integer_type(n)), - Box::new(builder::integer_type_default()), + builder::integer_type(n), + builder::integer_type_default(), HelpData::default(), ); assert_eq!(inter(sized(3), range()), sized(3)); diff --git a/crates/typr-core/src/processes/type_checking/type_comparison.rs b/crates/typr-core/src/processes/type_checking/type_comparison.rs index 2cab40f..9d853f4 100644 --- a/crates/typr-core/src/processes/type_checking/type_comparison.rs +++ b/crates/typr-core/src/processes/type_checking/type_comparison.rs @@ -98,12 +98,7 @@ pub fn reduce_type_helper(context: &Context, type_: &Type, memory: Vector Type::Vec( - vtype.clone(), - ind.clone(), - Box::new(reduce_type_helper(context, typ, memory.clone())), - h.clone(), - ), + Type::Vec(_, _, elem, _) => type_.with_vec_elem(reduce_type_helper(context, elem, memory.clone())), Type::Operator(TypeOperator::Union, t1, t2, h) => { // G4 (audit_type_checking.md): branches used to be compared // un-reduced, and — when neither subsumed the other — returned diff --git a/crates/typr-core/src/processes/type_checking/unification.rs b/crates/typr-core/src/processes/type_checking/unification.rs index 884d433..ede7383 100644 --- a/crates/typr-core/src/processes/type_checking/unification.rs +++ b/crates/typr-core/src/processes/type_checking/unification.rs @@ -1,3 +1,4 @@ +use crate::utils::builder; use crate::components::context::Context; use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::type_operator::TypeOperator; @@ -117,10 +118,10 @@ pub fn type_substitution(type_: &Type, substitutions: &[(Type, Type)]) -> Type { } // Array type substitution - Type::Vec(vtype, size, element_type, h) => Type::Vec( + Type::Vec(vtype, _, element_type, h) => Type::vec( vtype.clone(), - Box::new(type_substitution(size, substitutions)), - Box::new(type_substitution(element_type, substitutions)), + type_substitution(&type_.vec_length().unwrap_or_else(builder::any_type), substitutions), + type_substitution(element_type, substitutions), h.clone(), ), @@ -199,7 +200,9 @@ fn type_contains_generic(typ: &Type, name: &str) -> bool { Type::Function(params, ret, _) => { params.iter().any(|p| type_contains_generic(&p.get_type(), name)) || type_contains_generic(ret, name) } - Type::Vec(_, size, elem, _) => type_contains_generic(size, name) || type_contains_generic(elem, name), + Type::Vec(_, _, elem, _) => { + typ.vec_length().is_some_and(|size| type_contains_generic(&size, name)) || type_contains_generic(elem, name) + } Type::Record(fields, _) => fields.iter().any(|f| type_contains_generic(&f.get_type(), name)), Type::Alias(_, params, _, _) => params.iter().any(|p| type_contains_generic(p, name)), Type::Tag(_, inner, _) => type_contains_generic(inner, name), @@ -325,8 +328,10 @@ fn unification_helper(values: &[Type], type1: &Type, type2: &Type) -> Option unification_helper(values, b1, b2), (Type::Refined(b, _, _), t) | (t, Type::Refined(b, _, _)) => unification_helper(values, b, t), - (Type::Vec(_, size1, elem1, _), Type::Vec(_, size2, elem2, _)) => { - let mut combined = unification_helper(values, size1, size2)?; + (Type::Vec(_, _, elem1, _), Type::Vec(_, _, elem2, _)) => { + let size1 = type1.vec_length()?; + let size2 = type2.vec_length()?; + let mut combined = unification_helper(values, &size1, &size2)?; let elem_matches = unification_helper(values, elem1, elem2)?; if !merge_substitutions(&mut combined, elem_matches) { return None; diff --git a/crates/typr-core/src/utils/builder.rs b/crates/typr-core/src/utils/builder.rs index e59df7b..3cb9050 100644 --- a/crates/typr-core/src/utils/builder.rs +++ b/crates/typr-core/src/utils/builder.rs @@ -83,12 +83,12 @@ pub fn tuple_type(types: &[Type]) -> Type { } pub fn array_type(i: Type, t: Type) -> Type { - Type::Vec(VecType::S3, Box::new(i), Box::new(t), HelpData::default()) + Type::vec(VecType::S3, i, t, HelpData::default()) } pub fn array_type2(i: i32, t: Type) -> Type { let i2 = integer_type(i); - Type::Vec(VecType::S3, Box::new(i2), Box::new(t), HelpData::default()) + Type::vec(VecType::S3, i2, t, HelpData::default()) } pub fn dataframe_type(i: Type, columns: &[(String, Type)]) -> Type { @@ -96,10 +96,10 @@ pub fn dataframe_type(i: Type, columns: &[(String, Type)]) -> Type { .iter() .map(|param| ArgumentType::from(param.to_owned())) .collect::>(); - Type::Vec( + Type::vec( VecType::DataFrame, - Box::new(i), - Box::new(Type::Record(fields, HelpData::default())), + i, + Type::Record(fields, HelpData::default()), HelpData::default(), ) } diff --git a/crates/typr-core/src/utils/program_gen.rs b/crates/typr-core/src/utils/program_gen.rs index 9c191f9..2f15c6b 100644 --- a/crates/typr-core/src/utils/program_gen.rs +++ b/crates/typr-core/src/utils/program_gen.rs @@ -197,9 +197,9 @@ fn gen_var(env: &GenEnv, goal: &Type) -> Option { /// since v1 never hands a `Vec` goal to anything else — function /// params/returns are primitive-only). Falls back to 2 otherwise. fn vec_len(goal: &Type) -> usize { - if let Type::Vec(_, idx, _, _) = goal { - if let Type::Integer(Tint::Val(n), _) = idx.as_ref() { - return (*n).max(1) as usize; + if let Some(idx) = goal.vec_length() { + if let Type::Integer(Tint::Val(n), _) = idx { + return n.max(1) as usize; } } 2 diff --git a/crates/typr-lsp/src/handlers/semantic_tokens.rs b/crates/typr-lsp/src/handlers/semantic_tokens.rs index e5c5a8e..ce48a10 100644 --- a/crates/typr-lsp/src/handlers/semantic_tokens.rs +++ b/crates/typr-lsp/src/handlers/semantic_tokens.rs @@ -221,9 +221,11 @@ fn collect_type_tokens(typ: &Type, tokens: &mut Vec) { } collect_type_tokens(ret, tokens); } - Type::Vec(_, a, b, _) => { - collect_type_tokens(a, tokens); - collect_type_tokens(b, tokens); + Type::Vec(_, _, elem, _) => { + if let Some(length) = typ.vec_length() { + collect_type_tokens(&length, tokens); + } + collect_type_tokens(elem, tokens); } Type::Record(fields, _) | Type::Interface(fields, _) => { for f in fields { diff --git a/install_plan.md b/install_plan.md new file mode 100644 index 0000000..08e9197 --- /dev/null +++ b/install_plan.md @@ -0,0 +1,454 @@ +# Plan — installation de TypR en une commande + +Objectif : qu'un utilisateur choisisse **un seul canal** parmi quatre, sans +compter sur Cargo, Docker ou un téléchargement manuel depuis la page des +releases. + +Ce plan part de l'existant : les 6 binaires, `checksums.txt` et la CI de release +sont déjà en place. Il ne s'agit donc que d'ajouter la couche de distribution +par-dessus, pas de refaire ce qui marche. + +--- + +## 1. État actuel + +`.github/workflows/release.yml` produit, à partir d'un unique tag `vX.Y.Z` : + +| Cible | Artefact | +|---|---| +| `x86_64-unknown-linux-gnu` | `typr-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz` | +| `aarch64-unknown-linux-gnu` | `typr-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz` | +| `x86_64-pc-windows-msvc` | `typr-vX.Y.Z-x86_64-pc-windows-msvc.zip` | +| `aarch64-pc-windows-msvc` | `typr-vX.Y.Z-aarch64-pc-windows-msvc.zip` | +| `x86_64-apple-darwin` | `typr-vX.Y.Z-x86_64-apple-darwin.tar.gz` | +| `aarch64-apple-darwin` | `typr-vX.Y.Z-aarch64-apple-darwin.tar.gz` | + +Plus `checksums.txt` au format `sha256sum` (`release.yml:157`), et une version +stable (`X.Y.Z`) et une version instable (`X.Y.Z-alpha.N`) qui est marquée +`prerelease` (`release.yml:169`). + +Ce qui manque est uniquement l'agent qui transforme ces fichiers en une +commande. Le reste du plan est de la distribution, pas de la compilation. + +--- + +## 2. Décisions + +### Ce qu'on fait + +| Canal | Commande | OS | +|---|---|---| +| Script Unix | `curl -fsSL …/install.sh \| sh` | macOS, Linux | +| Script PowerShell | `irm …/install.ps1 \| iex` | Windows | +| Homebrew | `brew install we-data-ch/typr/typr` | macOS, Linux | +| WinGet | `winget install we-data-ch.TypR` | Windows | +| Scoop | `scoop install typr` | Windows | + +### Ce qu'on ne fait pas, et pourquoi + +- **`.msi` / `.pkg` (installeur graphique)** — `.pkg` macOS non notarisé produit + « TypR est endommagé » pour tout le monde ; la notarisation exige un compte + Apple Developer payant. Un binaire posé par `curl | sh` **n'a pas** l'attribut + `com.apple.quarantine` (seuls les navigateurs le posent) et passe sans + friction. Un `.msi` non signé se heurte au même mur avec SmartScreen. Le seul + cas qui le justifierait est un déploiement d'entreprise par GPO — à rouvrir + si ce cas se présente. +- **Code signing / notarization des binaires** — même raison, hors de portée + d'un projet open source sans certificat payant. +- **Wrapper npm** (`npm i -g typr`) — la technique esbuild/biome fonctionne + partout, mais impose Node à l'utilisateur. L'audience TypR (R, data) n'a pas + Node acquis. À réexaminer plus tard, pas comme canal principal. + +### Deux décisions à confirmer + +Les questions n'ont pas encore reçu de réponse ; le plan part sur les valeurs +par défaut ci-dessous. + +1. **Emplacement** — `~/.local/bin` (défaut retenu). Zéro `sudo`, aucun + privilège. L'installateur affiche un avertissement si le dossier n'est pas + dans le `PATH` au lieu d'échouer. Homebrew reste le canal « propre » pour qui + veut une installation système. + *Alternative :* `/usr/local/bin` avec `sudo`, plus proche des conventions + mais qui casse les installations en CI et dans les conteneurs. + +2. **Hébergement des scripts** — GitHub Pages sur le dépôt de documentation + existant (`we-data-ch.github.io/typr.github.io/install/install.sh`), ce qui ne + coûte aucune infrastructure. Un vrai domaine (`typr.sh`) est plus joli mais + demande DNS et redirection ; à faire plus tard si le besoin se fait sentir. + +--- + +## 3. Points de vigilance avant de commencer + +### 3.1 Le glibc des binaires Linux peut être trop récent + +C'est le risque principal, et il est invisible jusqu'au premier utilisateur. + +Les binaires GNU sont compilés sur `ubuntu-latest` (actuellement Ubuntu 24.04, +glibc 2.39). Un binaire Rust y lié exigera une glibc **aussi récente** chez +l'utilisateur. Sur une Ubuntu 22.04 (glibc 2.35) ou une Debian 12 (glibc 2.36), +le démarrage échoue sur `GLIBC_2.38 not found` — ce qui donne exactement +l'impression d'un binaire cassé. + +Vérification à faire immédiatement sur une release existante : + +```bash +objdump -T target/release/typr | grep -o 'GLIBC_[0-9.]*' | sort -Vu | tail -3 +``` + +Si le maximum dépasse glibc 2.35, deux options : + +- **recommandée** — compiler `x86_64-unknown-linux-gnu` sur `ubuntu-22.04` dans + la matrice de `release.yml:77`. Ça abaisse le plancher sans changer + grand-chose, au prix d'un toolchain plus vieux. +- **alternative** — ajouter des cibles `*-unknown-linux-musl`, statiquement + liées, qui fonctionnent partout y compris sur Alpine. Résout le problème de + glibc **et** le cas Alpine, pour deux cibles de plus dans la matrice. + +### 3.2 Résoudre « latest » sans dépendance + +Les scripts ne doivent pas supposer `jq` ni `python` sur la machine. + +L'astuce retenue : l'API GitHub demande `jq`, mais l'URL +`https://github.com//releases/latest` **redirige** vers `/tag/vX.Y.Z`. +Il suffit de lire l'en-tête `Location` : + +```bash +curl -fsSI https://github.com/we-data-ch/typr/releases/latest \ + | grep -i '^location:' | sed 's|.*/tag/||' | tr -d '\r\n' +``` + +Deux conséquences utiles : + +- `/releases/latest` **exclut** les prereleases — exactement la sémantique de la + branche stable, alignée sur `prerelease` dans `release.yml:169`. Le canal + beta devra, lui, lister `/releases` et filtrer. +- Aucune dépendance à un outil de parsing. + +### 3.3 Vérifier le SHA-256 + +`checksums.txt` est déjà au format attendu par `sha256sum -c`. Attention : il +contient des noms de fichiers nus, donc la commande doit tourner **depuis le +répertoire** contenant le fichier téléchargé. + +L'option `TYPR_INSTALL_VERIFY=0` permet de sauter la vérification pour un +dépannage réseau, sans jamais être le mode par défaut. + +### 3.4 Récupérer le binaire après un échec + +`install.sh` télécharge dans un fichier temporaire, vérifie le SHA, puis déplace. +En cas d'échec le fichier temporaire est nettoyé : on n'installe jamais un binaire +partiellement téléchargé. + +### 3.5 Le job `coherence` interdit les binaires commités + +Le job de CI échoue si un binaire réapparaît dans l'index (`RELEASING.md:157`). +Les scripts ne doivent installer que dans `$HOME`, jamais dans le dépôt, et le +nouveau dossier `install/` ne doit contenir aucun artefact — uniquement du texte. + +--- + +## 4. Phase 1 — Scripts d'installation + +Socle : les trois canaux suivants ne font que résoudre une URL et lire un SHA. +Tout ce qui est commun à Unix et Windows (résolution de version, choix du +canal, message de fin) vit dans les scripts, pas dans la CI. + +### Livrables + +``` +install/install.sh # POSIX sh, Linux + macOS +install/install.ps1 # PowerShell, Windows +install/README.md # usage et dépannage, destiné aux contributeurs +``` + +### `install/install.sh` + +- `#!/bin/sh`, `set -eu`, aucune dépendance hors `curl`/`tar`/`sha256sum` + (macOS fournit `shasum -a 256`, pas `sha256sum` : les deux doivent être + Tentés). +- Résolution de la cible par `uname -s` et `uname -m`, avec correspondance + explicite `uname -m` → triple Rust. Sous Rosetta, `uname -m` renvoie `x86_64` + sur une machine Apple Silicon, et le binaire Intel y fonctionne — aucun cas + particulier n'est nécessaire. +- Support d'arguments : `--version ` pour épingler, `--channel `, + `--dry-run`, `--help`. +- Détection du dossier cible : `$TYPR_INSTALL_DIR`, sinon `$HOME/.local/bin`. +- Si le dossier cible n'est pas dans le `PATH`, afficher la ligne à ajouter et le + `~/.profile`, sans échouer. +- Message final indiquant `typr --version` et le lien vers la documentation. + +### `install/install.ps1` + +- Cible `%LOCALAPPDATA%\Programs\typr` — convention utilisateur moderne, aucun + droit administrateur. Surchargeable par `$env:TYPR_INSTALL_DIR`. +- `irm … | iex` :PowerShell impose de rester sur une seule ligne dans le + message du README, et d'afficher l'URL avant exécution. +- Extraction via `Expand-Archive`, donc format `.zip` et non `.tar.gz`. +- Ajout au `PATH` utilisateur par registre + (`[Environment]::SetEnvironmentVariable('Path', …, 'User')`) — qui ne prend + effet qu'au prochain démarrage du terminal. Le script doit le dire + explicitement, **et** mettre à jour `$env:Path` de la session courante pour + que `typr --version` fonctionne tout de suite dans le même terminal. + +### Vérification + +Tester au minimum, pour chaque OS et chaque architecture disponible : + +```bash +# Unix +TYPR_INSTALL_DIR=$(mktemp -d) ./install/install.sh --dry-run +TYPR_INSTALL_DIR=$(mktemp -d) ./install/install.sh +$TYPR_INSTALL_DIR/typr --version +``` + +- [ ] Linux x86_64, Linux aarch64 +- [ ] macOS x86_64, macOS aarch64 +- [ ] Windows x86_64 via `irm | iex` +- [ ] `--version ` installe bien l'ancienne version +- [ ] SHA volontairement corrompu → le script refuse d'installer et le dit +- [ ] `PATH` non modifiable → message clair, pas d'erreur opaque +- [ ] Idempotence : deux exécutions successives ne cassent rien + +--- + +## 5. Phase 2 — Homebrew + +Le canal le plus attendu sur macOS, et il couvre Linux au passage. + +### Livrables + +Dépôt `we-data-ch/homebrew-typr` : + +``` +README.md # généré à partir de la formule +Formula/typr.rb # la seule source +``` + +### Contenu de la formule + +```ruby +class Typr < Formula + desc "A typed superset of R — transpiler and type checker" + homepage "https://we-data-ch.github.io/typr.github.io/" + url "https://github.com/we-data-ch/typr/releases/download/vX.Y.Z/typr-vX.Y.Z-x86_64-apple-darwin.tar.gz" + version "X.Y.Z" + sha256 "" + + on_macos do + if Hardware::CPU.arm? + url "…/typr-vX.Y.Z-aarch64-apple-darwin.tar.gz" + sha256 "…" + end + end + + def install + bin.install "typr" + end + + def caveats + "typR is pre-1.0: version 0.x releases can change CLI surface." + end +end +``` + +- Homebrew gère le `PATH`, les mises à jour (`brew upgrade`) et le + désinstallateur : le script Unix n'a plus à s'en préoccuper pour ce canal. +- **Le `sha256` doit être rempli automatiquement**, jamais écrit à la main. + Extrapoler `release.yml` avec un job `brew` qui lit `checksums.txt` — le + fichier contient déjà la ligne correspondant à l'artefact macOS, il n'y a rien + à recalculer — et commite la formule mise à jour. +- Avec `livecheck`, Homebrew suit le tag et propose la mise à jour tout seul. + +### Vérification + +- [ ] `brew install --formula we-data-ch/typr/typr` depuis un Mac clean +- [ ] Fonctionne sur Apple Silicon **et** Intel +- [ ] `brew upgrade typr` passe à la bonne version +- [ ] `brew audit --strict` ne signale rien +- [ ] Le SHA publié correspond à `checksums.txt` de la release + +--- + +## 6. Phase 3 — WinGet et Scoop + +Deux manifests JSON, aucun code. Ils puisent dans les `.zip` déjà publiés. + +### Livrables + +``` +packaging/winget/we-data-ch.TypR.yaml (+ .installer.yaml si signature) +packaging/scoop/typr.json +``` + +### WinGet + +- `PackageIdentifier: we-data-ch.TypR` +- `PackageVersion` sans le `v` initial — WinGet l'exige. +- `InstallerType: zip`, `InstallerSwitches: Silent` et `SilentWithProgress` avec + `/S` pour `7z`… **à ne pas inventer** : le type `zip` d'WinGest ekstré sans + copie dans un dossier. +- Le chemin `InstallLocation` doit être `%LOCALAPPDATA%\Programs\typr`, pour + coïncider avec ce que fait `install.ps1`. +- Le fichier doit être publié dans `microsoft/winget-pkgs` par une pull request + ouverte automatiquement depuis la CI (action + `microsoft/winget-create` ou un script équivalent). + +### Scoop + +Le manifest est plus court que celui de WinGet et ne demande pas de PR +maintenue manuellement. `scoop install typr` après +`scoop bucket add typr https://github.com/we-data-ch/scoop-bucket`. + +Dans les deux cas, la version publiée doit venir de la même source que le reste — +`needs.verify.outputs.version` — et non d'un fichier écrit à la main. + +### Vérification + +- [ ] `winget install we-data-ch.TypR` sur Windows 10 et 11 +- [ ] `scoop install typr` puis `scoop update typr` +- [ ] Les deux installers aboutissent au même dossier et au même binaire que + `install.ps1` +- [ ] Une machine sans rights administrateur réussit l'installation + +--- + +## 7. Phase 4 — Documentation + +### README + +Remplacer le tableau `Install` (README.md:62-69) : + +```markdown +## Install + +| Plateforme | Commande | +|---|---| +| **Toutes** | `curl -fsSL https://we-data-ch.github.io/typr.github.io/install/install.sh \| sh` | +| **Windows** | `irm https://we-data-ch.github.io/typr.github.io/install/install.ps1 \| iex` | +| macOS / Linux (Homebrew) | `brew install we-data-ch/typr/typr` | +| Windows (WinGet) | `winget install we-data-ch.TypR` | +| Windows (Scoop) | `scoop install typr` | +| Cargo | `cargo install typr` | +| Docker | `docker run --rm -it fabricehategekimana/typr:latest` | +| RStudio / Positron | `typr.runner_*.tar.gz` — release | +| VS Code / Positron | extension **TypR** dans le Marketplace | +| Vim / Neovim | `typr-vim-*.tar.gz` — release, ou un plugin manager | +``` + +Faire figurer en tête les deux lignes universelles : ce sont elles que l'on veut +que la personne copie. + +### Page d'installation du site + +Créer `docs/install.md` (ou l'équivalent dans le dépôt `typr.github.io`) avec, +dans cet ordre : la commande unique, le choix du canal, la vérification du +SHA-256, la mise à jour, le dépannage (PATH non modifié, Gatekeeper, +SmartScreen, architecture non supportée). + +Préciser explicitement que l'installation **n'exige pas `sudo`** et +**n'écrit rien hors de `$HOME`** — c'est ce qui lève la principale réticence. + +### Vérification + +- [ ] Les commandes du README sont copiées et exécutées telles quelles +- [ ] `RELEASING.md` mentionne les nouveaux canaux dans le diagramme +- [ ] `publish.nu check`compare bien les quatre canaux (voir ci-dessous) + +--- + +## 8. Phase 5 — `typr upgrade` (optionnelle) + +À ne faire que si les releases sont fréquentes ; sinon le script et Homebrew +suffisent. + +Le binaire serait `typr upgrade` : même logique de résolution de version et de +vérification de SHA que les scripts, mais à l'intérieur du programme — donc testée +par `cargo test` plutôt que par un script shell. + +Le risque est la duplication entre le code Rust et les scripts. Le traiter en +conséquence : soit le script devient le client d'un sous-commande `typr install`, +soit la logique est factorisée dans une petite bibliothèque partagée. + +--- + +## 9. CI + +### Étapes dans `release.yml` + +``` +tag vX.Y.Z + │ + ├─ verify … verify … release … + │ ├─ brew ……… commit de la formule mise à jour + │ ├─ winget … PR vers microsoft/winget-pkgs + │ └─ scoop … commit du manifest + └─ … +``` + +Ces jobs restent **indépendants** des autres canaux, comme le sont déjà +`vscode` et `vim` : un échec de publication Homebrew ne doit pas empêcher les +binaires d'arriver. C'est la règle que `RELEASING.md:108` énonce déjà. + +### Étapes dans `ci.yml` + +- Job `install`, sur une PR : `bash -n install/install.sh`, `sh -n`, et + exécution réelle avec `--dry-run`. +- Analyse avec `shellcheck` pour `install.sh`, `PSScriptAnalyzer` pour + `install.ps1`. +- Pour WinGet et Scoop, valider le YAML/JSON contre leur schéma. +- Vérifier que `install/` ne contient aucun binaire — le job `coherence` + l'interdit déjà pour l'ensemble du dépôt. + +### `publish.nu` + +Étendre `check` pour interroger l'API Homebrew et WinGet et confirmer que la +version publiée est bien celle du tag. Sans cela, un canal en retard devient +invisible : les autres canaux sont mis à jour au tag, lui seul dériverait en +silence. + +--- + +## 10. Ordre d'exécution + +| # | Lot | Dépend de | Effort | +|---|---|---|---| +| 0 | Diagnostic glibc + décision cible musl | — | 1 h | +| 1 | `install.sh` + `install.ps1` + hébergement | 0 | 1 j | +| 2 | Tests CI sur les scripts | 1 | 3 h | +| 3 | Homebrew tap + job CI | 1 | 1 j | +| 4 | WinGet + Scoop + PR automatique | 1 | 1 j | +| 5 | README, page d'installation, `RELEASING.md` | 1-4 | 3 h | +| 6 | `publish.nu check` étendu | 1-4 | 2 h | +| 7 | `typr upgrade` (optionnel) | — | plus tard | + +Le lot 0 conditionne le lot 1 : publier un script d'installation qui installe un +binaire ne démarre pas sur Ubuntu 22.04 transformerait le canal principal en +source de tickets. C'est un diagnostic d'une heure qui évite ce risque. + +Les lots 3 et 4 sont indépendants de 2 et parallélisables. + +--- + +## 11. Risques + +| Risque | Impact | Réponse | +|---|---|---| +| glibc des binaires Linux trop récent | Installation qui échoue sur les distributions anciennes | Lot 0, puis rebuild sur `ubuntu-22.04` ou cibles musl | +| SmartScreen bloque `typr.exe` | Avertissement à l'insertion, pas un blocage | Signatures `install.ps1` volontairement absentes : PowerShellSmartScreen marque le fichier téléchargé, un fichier posé par script ne l'est pas | +| Gatekeeper sur macOS | Rare pour un binaire installé par script | Documenter `xattr -d com.apple.quarantine` pour ceux qui téléchargent d'abord par navigateur | +| Un canal dérive en silence | Le README promet une version absente d'un canal | `publish.nu check` étendu (lot 6) | +| Le script d'installation casse les gens | Mauvaise expérience sur le canal principal | `--dry-run`, `--version` épinglé, idempotence, tests sur 6 cibles en CI | + +--- + +## 12. hors périmètre + +- Signature et notarification (coût, hors budget d'un projet open source). +- Canal de type `winget` : trop tôt, la distribution de Linux est trop + fragmentée pour que le gain justifie l'effort. +- Mise à jour automatique silencieuse, qui crée plus de problèmes qu'elle n'en + résout (mise à jour pendant une session LSP en cours, binaire remplacé sous + les pieds du processus). `typr upgrade` explicite suffit. +- Paquet `apt` / `dnf` : le script Unix couvre ces distributions sans + fragmentation supplémentaire. \ No newline at end of file From 083e474ab97dcb4a6082b3311982bf7454aa70b3 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 11:51:45 +0200 Subject: [PATCH 37/52] fixed refinment indexation --- .../0084-refined-length-range/golden/R/main.R | 4 +- .../typr-core/configs/bin/.std_js_typed.bin | Bin 7506 -> 7518 bytes crates/typr-core/configs/bin/.std_r_typed.bin | Bin 74410 -> 74701 bytes .../src/components/type/array_type.rs | 4 +- crates/typr-core/src/components/type/mod.rs | 84 ++++--- .../src/components/type/refinement.rs | 134 +++++++++++ .../src/components/type/type_printer.rs | 15 +- .../src/processes/type_checking/mod.rs | 4 + .../type_checking/type_arithmetic.rs | 97 ++++++-- install_plan.md | 208 ++++++++++++++---- 10 files changed, 455 insertions(+), 95 deletions(-) diff --git a/cases/0084-refined-length-range/golden/R/main.R b/cases/0084-refined-length-range/golden/R/main.R index 8661e3c..c06e9fe 100644 --- a/cases/0084-refined-length-range/golden/R/main.R +++ b/cases/0084-refined-length-range/golden/R/main.R @@ -3,8 +3,8 @@ #' @include types.R # Range bounds: `length(> 0)`, `(>= 0)`. Checked where unproven, absent where proven. -#' @method first Array0 -`first.Array0` <- (function(v) { +#' @method first integer +`first.integer` <- (function(v) { { v[[1L |> as.Integer()]] } |> diff --git a/crates/typr-core/configs/bin/.std_js_typed.bin b/crates/typr-core/configs/bin/.std_js_typed.bin index 7a5815f388ac11ae7644ec3207fb00f7b316867e..e1dcbabbb71b7c8147f19050f7b55d75bba35ebf 100644 GIT binary patch delta 40 ucmca)b=B$$#R^#7+A^S0y7+-lV}Hyr-G<7TI28~rhAwF$wV0+M5?wzi zGj`G9A(RD;PIMb2BHJ}9{vkTI>NJr3$v)5zv*=L6Hr)grlC`pT&n-|$N6eDFG^YSXF%D1Rq4%y=3JHUo`y(Z5tPtPQkyKkIM~bZp(2UlaDaS@`55&H zO|s|-MaM#IyZXwj@3(;h^?$;RI z13ib5p*8n8csnak##PXm^%cqwve`NXdb68gY4(;`SOE79jA`y z2dr?zuxU#F!?*~RAI=2J^Gd|-E&E+FPksdU!=DwgI+%iHU`F=8(!rt^=Ooh(#CSe-E{>5f?lT;a-1CWItK}sXqW49RC13Ny%Mw+m56OV>|l~^uYZ_^fTQWh zsI=wFB@*SAL~Au&sy|#u%<3!}zF-ir z{lZ-ABM>(0GU1#kdHjTO!!UOssk|6lo<8oY28D5f%J2PG=CM$5&&?5Q?}! zGf|2B4HC0ymDj!KRC-(!hnoalf0xqWgoBUUaE9BrAd%?&V*(K{I)8$a9OXS-p^k2r zC6LAx7M)+dNRJTTL5Zk*&1nQjhhd*_gA)p=MMqNoCFx`h@s(-_UY36O#(oh$$29q7 NoxYu=FLk7o`~$UDrK$h` delta 1863 zcmbtVeNa?Y6!-4F#b?VR>mX=5e42@1T1}QS5it%0PDu=sBCvee?9^hzmpe+XiZ$6k zU;)2mvxb2d$03@@;tp;OL`#Y`f-~x*Gl?P}P8nD%eF{t1z4twcbu^vn&AfNcJ?D4M zJNKTCd*+nX+$B|oLN*zySDsNwDH^nT$IVC*i(-vbof?F*q{MMOM65rXW;SpXH=ym> zPZn^-@s%Ihi&a4~rh;Xm2?=o$p)u%g(`;RQkZbA$?5L?vOFsy|NYE7Z6qmpgRe|TC za%Zh;h1{_uhRF;#zob*pt4m`b|5S&fr5tO3vOdRT28=!&gGIwlLVj@>!!f`9BP}={ zy-@HSy%wY_&sO<^$cP!oqgy279QJ6~`zil$ijvD=D7#YXqit2Stz1h3`ld8=-YViR zyqTW9$|=>yrTJmaNjomD&82HjMi5l3l~9!=(N`0968gA%AE53v88)UIa+%1n4M{2ApZ-aYSl6g`Hd(mlL1ozkj2!t#EO=FFC@3f5F z?;FH=&jBkW_VUWubHFC(-rr+tqLn0bA6_+$UVJeYPYY}PbRrhM>xM%zCO)#y#B``G zN26}!_6_ujW8wy*AKN*G?Y>C?|9eFH_-Rla-XyvPh4~hm*V3h+)Kt*6))0{|IxBG% zc&6P*zx#5VlF9mwcP#z&*-^<0Zd@a(4h2b3_fy>egC{qANX_$O}=DNqU`AXzYSHO1%_f5@+9$x z=P6z**g5eS7zoCdUKxW!yL=ESDk5bD@z%9DdkXOrStuHp(NiVk9d8;s{*J7fGNOBeI-U(CdW?8CqwVHu)#gpSG?{S+ zn@jiW8+qY#j}ns1TWG&yzm7z@CbUE&3ed%FoO*?0x6(4Bi(Ow&YWTOAOkZ4R*M_;) tKS=Tk=1Xyo#!!;ZL#tjH=3vqw7mGQKAdiGOn!`!u9aYO+?3%nl{sD{Vxq|=z diff --git a/crates/typr-core/src/components/type/array_type.rs b/crates/typr-core/src/components/type/array_type.rs index cb68af5..c94d331 100644 --- a/crates/typr-core/src/components/type/array_type.rs +++ b/crates/typr-core/src/components/type/array_type.rs @@ -35,8 +35,8 @@ impl TryFrom for ArrayType { fn try_from(value: Type) -> Result { match value { - Type::Vec(vtyp, t1, t2, h) => Ok(ArrayType { - index: *t1, + Type::Vec(_, length, t2, h) => Ok(ArrayType { + index: length.to_type(h.clone()), type_: *t2, help_data: h, }), diff --git a/crates/typr-core/src/components/type/mod.rs b/crates/typr-core/src/components/type/mod.rs index 221aa26..f79b8d6 100644 --- a/crates/typr-core/src/components/type/mod.rs +++ b/crates/typr-core/src/components/type/mod.rs @@ -30,7 +30,7 @@ use crate::components::r#type::function_type::FunctionType; use crate::components::r#type::intersection_type::IntersectionType; use crate::components::r#type::kind::Kind; use crate::components::r#type::module_type::ModuleType; -use crate::components::r#type::refinement::{Interval, Measure, Refinement, RefinementSet}; +use crate::components::r#type::refinement::{Interval, Length, Measure, Refinement, RefinementSet}; use crate::components::r#type::tbool::Tbool; use crate::components::r#type::tchar::Tchar; use crate::components::r#type::tint::Tint; @@ -111,7 +111,7 @@ pub enum Type { Generic(String, HelpData), IndexGen(String, HelpData), LabelGen(String, HelpData), - Vec(VecType, Box, Box, HelpData), + Vec(VecType, Length, Box, HelpData), Record(HashSet, HelpData), Module(Vec, Vec, HelpData), Alias(String, Vec, bool, HelpData), //for opacity @@ -240,8 +240,17 @@ impl TypeSystem for Type { t1.is_subtype_raw(typ, context) || t2.is_subtype_raw(typ, context) } (_, Type::Any(_)) => true, - (Type::Vec(_, n1, t1, _), Type::Vec(_, n2, t2, _)) => { - n1.is_subtype_raw(n2, context) && t1.is_subtype_raw(t2, context) + (Type::Vec(_, n1, t1, h1), Type::Vec(_, n2, t2, h2)) => { + let length_ok = match (n1, n2) { + // Numeric ranges, or a symbolic length against a range: + // interval inclusion (`[#N + 1, T] <: [T] & length(> 0)`). + (_, Length::Range(_)) => n1.implies(n2), + // A proper range has no index form: it never meets an + // index expression. + (Length::Range(_), Length::Sym(_)) if n1.as_proper_range().is_some() => false, + _ => n1.to_type(h1.clone()).is_subtype_raw(&n2.to_type(h2.clone()), context), + }; + length_ok && t1.is_subtype_raw(t2, context) } // A function type is structural: only the arity, the parameter // *types* and the return type carry meaning. Parameter names are a @@ -372,7 +381,7 @@ impl Type { /// the length lives (today the `Vec` index, eventually `Measure::Length` /// in a `Refined`) is decided in one place. pub fn vec(kind: VecType, length: Type, elem: Type, help: HelpData) -> Type { - Type::Vec(kind, Box::new(length), Box::new(elem), help) + Type::Vec(kind, Length::from_type(&length), Box::new(elem), help) } /// The length of a `Vec`, `None` for any other type. Returned by value: @@ -380,7 +389,7 @@ impl Type { /// not borrowed. Cheap (an index type is a literal or a tiny tree). pub fn vec_length(&self) -> Option { match self { - Type::Vec(_, length, _, _) => Some((**length).clone()), + Type::Vec(_, length, _, help) => Some(length.to_type(help.clone())), _ => None, } } @@ -402,22 +411,36 @@ impl Type { } } + /// A `Vec` whose length is a proper range (`length(> 0)`) reads as + /// unsized; anything else is returned unchanged. For code that reasons + /// on the shape of a vector, which a range does not change. + pub fn without_length_range(self) -> Type { + match &self { + Type::Vec(kind, l, elem, help) if l.as_proper_range().is_some() => { + Type::Vec(kind.clone(), Length::unknown(), elem.clone(), help.clone()) + } + _ => self, + } + } + /// `self` with its element type replaced when it is a `Vec`. pub fn with_vec_elem(&self, elem: Type) -> Type { match self { - Type::Vec(kind, length, _, help) => Type::vec(kind.clone(), (**length).clone(), elem, help.clone()), + Type::Vec(kind, length, _, help) => Type::Vec(kind.clone(), length.clone(), Box::new(elem), help.clone()), t => t.clone(), } } pub fn lift(self, max_index: &(VecType, i32)) -> Type { match self.clone() { - Type::Vec(_, i, _, _) if i.equal(max_index.1) => self, + Type::Vec(_, i, _, _) if i.as_known() == Some(max_index.1) => self, // A parameter that already fixes a length keeps it: lifting only // widens scalars and unsized vectors, it must not turn `[2, num]` // into `[3, num]` to make a `[3, num]` argument fit. - Type::Vec(_, i, _, _) if matches!(*i, Type::Integer(..)) => self, - Type::Vec(v, _, t, h) => Type::Vec(v, Box::new(builder::integer_type(max_index.1)), t.clone(), h.clone()), + Type::Vec(_, i, _, _) if matches!(i, Length::Range(iv) if iv.as_point().is_some()) => self, + // `length(> 0)` and the like constrain the length: not widened. + Type::Vec(_, i, _, _) if i.as_proper_range().is_some() => self, + Type::Vec(v, _, t, h) => Type::Vec(v, Length::known(max_index.1), t.clone(), h.clone()), t => Type::vec( max_index.0.clone(), builder::integer_type(max_index.1), @@ -644,10 +667,10 @@ impl Type { .collect::>() .into_iter() .collect::>(), - Type::Vec(_, ind, typ, _) => typ + Type::Vec(_, ind, typ, h) => typ .extract_generics() .iter() - .chain(ind.extract_generics().iter()) + .chain(ind.to_type(h.clone()).extract_generics().iter()) .collect::>() .into_iter() .cloned() @@ -684,7 +707,7 @@ impl Type { Type::Operator(TypeOperator::Division, a, b, _) => a.index_calculation().div_index(&b.index_calculation()), Type::Vec(vtype, ind, typ, h) => Type::vec( vtype.clone(), - ind.index_calculation(), + ind.to_type(h.clone()).index_calculation(), typ.index_calculation(), h.clone(), ), @@ -736,8 +759,8 @@ impl Type { } pub fn get_shape(&self) -> Option { - if let Type::Vec(_, i, t, _) = self { - match (*i.clone(), t.get_shape()) { + if let Type::Vec(_, i, t, h) = self { + match (i.to_type(h.clone()), t.get_shape()) { (Type::IndexGen(_, _), _) => Some("dim(===)".to_string()), (Type::Integer(j, _), Some(rest)) => Some(format!("{}, {}", j, rest)), (Type::Integer(j, _), None) => Some(format!("{}", j)), @@ -844,10 +867,20 @@ impl Type { /// the value of a literal, or the set of an explicit `Refined`. pub fn refinements_of(&self) -> RefinementSet { match self { - Type::Vec(_, index, _, _) => match &**index { - Type::Integer(Tint::Val(n), _) => RefinementSet::single(&Refinement::Length(*n)), - _ => RefinementSet::empty(), - }, + Type::Vec(_, length, _, _) => { + let iv = length.bounds(); + // A symbolic length only contributes what it provably has + // (`#N + 1` is at least 1); `>= 0` is true of every length. + let informative = match length { + Length::Range(_) => !iv.is_full(), + Length::Sym(_) => iv != Interval::at_least(0.0), + }; + if informative { + RefinementSet::empty().with(Measure::Length, iv) + } else { + RefinementSet::empty() + } + } Type::Integer(Tint::Val(n), _) => RefinementSet::empty().with(Measure::Value, Interval::point(*n as f64)), Type::Number(Tnum::Val(v), _) => RefinementSet::empty().with(Measure::Value, Interval::point(*v)), Type::Refined(base, refs, _) => base.refinements_of().meet(refs), @@ -1020,7 +1053,7 @@ impl Type { pub fn to_array(&self) -> Option { match self { Type::Vec(_, t1, t2, h) => Some(Array { - index: (**t1).clone(), + index: t1.to_type(h.clone()), base_type: (**t2).clone(), help_data: h.clone(), }), @@ -1149,7 +1182,7 @@ impl Type { pub fn linearize(self) -> Vec { match self { - Type::Vec(_, t1, t2, _) => [*t1].iter().chain((*t2).linearize().iter()).cloned().collect(), + Type::Vec(_, t1, t2, h) => [t1.to_type(h)].iter().chain((*t2).linearize().iter()).cloned().collect(), other => vec![other], } } @@ -1192,7 +1225,7 @@ impl Type { /// [3, T] -> rank: 3, vector type: Array, Type T pub fn get_size_type(&self) -> (i32, VecType, Type) { match self { - Type::Vec(v, i, t, _) => (i.get_index().unwrap_or(0) as i32, v.clone(), (**t).clone()), + Type::Vec(v, i, t, h) => (i.to_type(h.clone()).get_index().unwrap_or(0) as i32, v.clone(), (**t).clone()), typ => (1, VecType::Empty, typ.clone()), } } @@ -1212,7 +1245,7 @@ impl Type { if let VecType::Named(name) = vtype { acc.push((name.clone(), h.clone())); } - idx.collect_named_constructors_into(acc); + idx.to_type(h.clone()).collect_named_constructors_into(acc); body.collect_named_constructors_into(acc); } Type::Function(args, ret, _) => { @@ -1364,8 +1397,9 @@ impl PartialOrd for Type { (typ1, typ2) if typ1 == typ2 => Some(Ordering::Equal), // Array subtyping (_, Type::Any(_)) => Some(Ordering::Less), - (Type::Vec(_, n1, t1, _), Type::Vec(_, n2, t2, _)) => { - (n1.partial_cmp(n2).is_some() && t1.partial_cmp(t2).is_some()).then_some(Ordering::Less) + (Type::Vec(_, n1, t1, h1), Type::Vec(_, n2, t2, h2)) => { + (n1.to_type(h1.clone()).partial_cmp(&n2.to_type(h2.clone())).is_some() && t1.partial_cmp(t2).is_some()) + .then_some(Ordering::Less) } (Type::Function(args1, ret_typ1, _), Type::Function(args2, ret_typ2, _)) => { let args1_types: Vec = args1.iter().map(|arg| arg.get_type()).collect(); diff --git a/crates/typr-core/src/components/type/refinement.rs b/crates/typr-core/src/components/type/refinement.rs index 8dbab59..3bfa307 100644 --- a/crates/typr-core/src/components/type/refinement.rs +++ b/crates/typr-core/src/components/type/refinement.rs @@ -12,6 +12,9 @@ //! //! Nothing here knows about `Type`; see `type_arithmetic` for the wiring. +use crate::components::error_message::help_data::HelpData; +use crate::components::r#type::tint::Tint; +use crate::components::r#type::Type; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; use std::fmt; @@ -315,6 +318,137 @@ impl fmt::Display for Refinement { } } +/// The length of a vector, i.e. the [`Measure::Length`] value stored on +/// `Type::Vec`. A numeric length is an [`Interval`] (`[5, T]` is `[5, 5]`, +/// an unsized `[T]` is the full interval); a length that cannot be decided +/// numerically (`#N`, `#N + 1`) keeps its index expression symbolic. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Length { + Range(Interval), + Sym(Box), +} + +impl Length { + /// No known length (`[int]`). + pub fn unknown() -> Self { + Length::Range(Interval::full()) + } + + pub fn known(n: i32) -> Self { + Length::Range(Interval::point(n as f64)) + } + + /// Reads a length index as written in a type: an integer literal, `any` + /// (unsized), or an index expression kept as-is. + pub fn from_type(t: &Type) -> Self { + match t { + Type::Integer(Tint::Val(n), _) => Length::known(*n), + Type::Any(_) => Length::unknown(), + other => Length::Sym(Box::new(other.clone())), + } + } + + /// The length as an index type. A numeric range that is neither a point + /// nor unbounded has no index form and reads as unsized. + pub fn to_type(&self, help: HelpData) -> Type { + match self { + Length::Range(iv) => match iv.as_point() { + Some(n) => Type::Integer(Tint::Val(n as i32), help), + None => Type::Any(help), + }, + Length::Sym(t) => (**t).clone(), + } + } + + /// The length as an interval on [`Measure::Length`]; `None` when symbolic. + pub fn interval(&self) -> Option { + match self { + Length::Range(iv) => Some(*iv), + Length::Sym(_) => None, + } + } + + /// The exact length when it is a known literal. + pub fn as_known(&self) -> Option { + match self { + Length::Range(iv) => iv.as_point().map(|n| n as i32), + Length::Sym(_) => None, + } + } + + /// A numeric range that is neither a point nor unbounded (`length(> 0)`): + /// the only lengths with no index form, printed as a refinement. + pub fn as_proper_range(&self) -> Option { + match self { + Length::Range(iv) if iv.as_point().is_none() && !iv.is_full() => Some(*iv), + _ => None, + } + } + + /// The surface property for [`Length::as_proper_range`] (`length(> 0)`). + pub fn range_property(&self) -> Option { + self.as_proper_range().map(|iv| Measure::Length.display(&iv)) + } + + /// An interval every length denoted by `self` lies in. Exact for a + /// range; for a symbolic length it is a sound lower bound (a length is + /// never negative, `#N + 2` is at least 2) with no upper bound. + pub fn bounds(&self) -> Interval { + match self { + Length::Range(iv) => *iv, + Length::Sym(t) => Interval::at_least(sym_lower_bound(t).max(0.0)), + } + } + + /// `self ⊆ other`, decided on the bounds. A symbolic length only implies + /// another symbolic one when they are the same expression. + pub fn implies(&self, other: &Length) -> bool { + match (self, other) { + (Length::Sym(a), Length::Sym(b)) => a == b, + (_, Length::Sym(_)) => false, + (_, Length::Range(iv)) => self.bounds().implies(iv), + } + } + + /// No length can satisfy both. + pub fn contradicts(&self, other: &Length) -> bool { + match (self, other) { + (Length::Sym(_), Length::Sym(_)) => false, + _ => self.bounds().contradicts(&other.bounds()), + } + } + + /// Intersect with `iv`. `None` when the result has no representation: + /// a symbolic length narrowed by a range it does not already satisfy + /// stays a `Refined` property. `Some(Err)` is a contradiction. + pub fn meet_interval(&self, iv: &Interval) -> Option> { + match self { + Length::Range(cur) => { + // Kept as written (`> 0`, not `>= 1`): the bound is what the + // runtime check and the printer show. Emptiness is judged on + // integers, lengths being whole numbers. + let m = cur.meet(iv); + Some(if m.to_integral().is_empty() { Err(()) } else { Ok(Length::Range(m)) }) + } + Length::Sym(_) if self.bounds().implies(iv) => Some(Ok(self.clone())), + Length::Sym(_) if self.bounds().contradicts(iv) => Some(Err(())), + Length::Sym(_) => None, + } + } +} + +/// Lower bound of an index expression read as a length: literals and sums +/// are exact, an index generic is only known to be a length (>= 0). +fn sym_lower_bound(t: &Type) -> f64 { + use crate::components::r#type::type_operator::TypeOperator; + match t { + Type::Integer(Tint::Val(n), _) => *n as f64, + Type::Operator(TypeOperator::Addition, a, b, _) => sym_lower_bound(a).max(0.0) + sym_lower_bound(b).max(0.0), + Type::Operator(TypeOperator::Multiplication, a, b, _) => sym_lower_bound(a).max(0.0) * sym_lower_bound(b).max(0.0), + _ => 0.0, + } +} + /// A conjunction of properties: at most one non-full interval per measure, /// kept sorted by measure so that `Hash`/`Eq` are canonical. (A `Vec` rather /// than a `BTreeMap` so it round-trips through non-string-keyed formats.) diff --git a/crates/typr-core/src/components/type/type_printer.rs b/crates/typr-core/src/components/type/type_printer.rs index 606d40c..6924674 100644 --- a/crates/typr-core/src/components/type/type_printer.rs +++ b/crates/typr-core/src/components/type/type_printer.rs @@ -57,13 +57,17 @@ pub fn format(ty: &Type) -> String { format!("{}<{}>", name, paras.join(", ")) } } - Type::Vec(vtype, dim, ty, _) => { + Type::Vec(vtype, length, ty, h) => { + let dim = &length.to_type(h.clone()); let inner = if matches!(vtype, VecType::DataFrame) { simplify_for_dataframe(ty) } else { verbose(ty) }; - format!("{}[{}, {}]", vtype, short(dim), inner) + match length.range_property() { + Some(prop) => format!("{}[{}] & {}", vtype, inner, prop), + None => format!("{}[{}, {}]", vtype, short(dim), inner), + } } Type::Function(params, ret_ty, _h) => { let formatted_params = params @@ -184,8 +188,11 @@ pub fn verbose(t: &Type) -> String { } Type::IndexGen(idgen, _) => format!("#{}", idgen), Type::KindedGen(k, name, _) => format!("{}{}", k, name), - Type::Vec(vtype, i, t, _) => { - format!("{}[{}, {}]", vtype, i.pretty(), t.pretty2()) + Type::Vec(vtype, length, t, h) => { + match length.range_property() { + Some(prop) => format!("{}[{}] & {}", vtype, t.pretty2(), prop), + None => format!("{}[{}, {}]", vtype, length.to_type(h.clone()).pretty(), t.pretty2()), + } } val if val.to_category() == TypeCategory::Template => val.pretty(), val => val.pretty(), //val => panic!("{:?} doesn't have a second format", val) diff --git a/crates/typr-core/src/processes/type_checking/mod.rs b/crates/typr-core/src/processes/type_checking/mod.rs index 52b6898..0606ef7 100644 --- a/crates/typr-core/src/processes/type_checking/mod.rs +++ b/crates/typr-core/src/processes/type_checking/mod.rs @@ -1647,6 +1647,10 @@ fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { // constrains the value, it does not change its shape. let typ1 = match reduce_type(context, &tc.value) { refined @ Type::Refined(..) => refined.unrefined().clone(), + // Same for a folded `length(> 0)`: a property, not a shape. + ranged @ Type::Vec(_, _, _, _) if matches!(&ranged, Type::Vec(_, l, _, _) if l.as_proper_range().is_some()) => { + ranged.without_length_range() + } _ => tc.value, }; diff --git a/crates/typr-core/src/processes/type_checking/type_arithmetic.rs b/crates/typr-core/src/processes/type_checking/type_arithmetic.rs index 78f1a9f..b19f2c2 100644 --- a/crates/typr-core/src/processes/type_checking/type_arithmetic.rs +++ b/crates/typr-core/src/processes/type_checking/type_arithmetic.rs @@ -246,33 +246,28 @@ pub(crate) fn apply_refinements(base: Type, set: RefinementSet, h: HelpData) -> let full = Interval::full(); match &base { Type::Empty(_) => base, - Type::Vec(_, _, _, vh) => { - let index = base.vec_length().unwrap_or_else(|| Type::Any(vh.clone())); + Type::Vec(kind, length, elem, vh) => { if set.get(Measure::Value).is_some() { return invalid(Measure::Value); } if set.is_empty(true) { return unsatisfiable("has no value"); } - let len = set.get(Measure::Length).and_then(|iv| iv.as_point()); - match (len, &index) { - (Some(n), Type::Any(_)) => base.with_vec_length(Type::Integer(Tint::Val(n as i32), vh.clone())), - (Some(n), Type::Integer(Tint::Val(m), _)) if n as i32 == *m => base.clone(), - (Some(n), Type::Integer(Tint::Val(m), _)) => { - unsatisfiable(&format!("contradicts its length {} (asked for {})", m, n)) - } - // A range (`length(> 0)`) against a known length: the literal - // either satisfies it (the range adds nothing) or contradicts it. - (None, Type::Integer(Tint::Val(m), _)) => { - let range = set.get(Measure::Length).unwrap_or(&full); - if Interval::point(*m as f64).implies(range) { - base.clone() - } else { - unsatisfiable(&format!("contradicts its length {}", m)) + let wanted = set.get(Measure::Length).copied().unwrap_or(full); + match length.meet_interval(&wanted) { + // The length folds the range in (D4): `[T] & length(5)` is + // `[5, T]`, `[T] & length(> 0)` is a ranged length. + Some(Ok(l)) => Type::Vec(kind.clone(), l, elem.clone(), vh.clone()), + Some(Err(())) => match (length.as_known(), wanted.as_point()) { + (Some(m), Some(n)) => { + unsatisfiable(&format!("contradicts its length {} (asked for {})", m, n)) } - } - // Symbolic length (`#N`) or unknown length with a range: keep the property. - _ => Type::Refined(Box::new(base.clone()), set, h), + (Some(m), None) => unsatisfiable(&format!("contradicts its length {}", m)), + _ => unsatisfiable("contradicts its length"), + }, + // Symbolic length (`#N`) narrowed by a range it does not + // already satisfy: keep the property. + None => Type::Refined(Box::new(base.clone()), set, h), } } Type::Integer(tint, _) => { @@ -760,8 +755,10 @@ mod tests { builder::integer_type_default(), HelpData::default(), ); - // unknown length: the range is kept as a property - assert!(matches!(inter(unknown.clone(), range()), Type::Refined(..))); + // unknown length: the range folds into the length itself + let folded = inter(unknown.clone(), range()); + assert!(matches!(&folded, Type::Vec(_, l, _, _) if l.as_proper_range().is_some()), "{:?}", folded); + assert_eq!(folded.refinements_of().get(Measure::Length), Some(&Interval::greater_than(0.0))); // known length: satisfied range is redundant, violated one is a contradiction let sized = |n| Type::vec( crate::components::r#type::vector_type::VecType::S3, @@ -773,4 +770,60 @@ mod tests { let res = inter(sized(0), range()); assert!(matches!(refinement_error(&res), Some(TypeError::UnsatisfiableRefinement(..))), "{:?}", res); } + + #[test] + fn folded_length_ranges_meet_and_compare_by_interval() { + use crate::components::context::Context; + use crate::components::r#type::refinement::{Interval, Measure}; + use crate::components::r#type::vector_type::VecType; + let ctx = Context::default(); + let vec_of = |len: Type| Type::vec(VecType::S3, len, builder::integer_type_default(), HelpData::default()); + let any = || Type::Any(HelpData::default()); + let range = |iv: Interval| prop(Refinement::Range(Measure::Length, iv)); + let pos = inter(vec_of(any()), range(Interval::greater_than(0.0))); + let small = inter(pos.clone(), range(Interval::at_most(10.0))); + // meeting narrows the same length + assert!(matches!(&small, Type::Vec(..)), "{:?}", small); + // [1,10] <: [>0] <: [T]; the converse does not hold + assert!(small.is_subtype(&pos, &ctx).0); + assert!(pos.is_subtype(&vec_of(any()), &ctx).0); + assert!(!pos.is_subtype(&small, &ctx).0); + assert!(!vec_of(any()).is_subtype(&pos, &ctx).0); + // a literal length is proven against a range it satisfies, refuted otherwise + assert!(vec_of(builder::integer_type(3)).is_subtype(&pos, &ctx).0); + assert!(!vec_of(builder::integer_type(0)).is_subtype(&pos, &ctx).0); + // a contradiction is reported + let none = inter(pos.clone(), range(Interval::less_than(1.0))); + assert!(matches!(refinement_error(&none), Some(TypeError::UnsatisfiableRefinement(..))), "{:?}", none); + // printed as a refinement + assert_eq!(pos.pretty(), "[int] & length(> 0)"); + } + + #[test] + fn symbolic_length_has_provable_bounds() { + use crate::components::context::Context; + use crate::components::r#type::refinement::{Interval, Measure}; + use crate::components::r#type::type_operator::TypeOperator; + use crate::components::r#type::vector_type::VecType; + let ctx = Context::default(); + let n = || Type::IndexGen("N".into(), HelpData::default()); + let plus1 = Type::Operator( + TypeOperator::Addition, + Box::new(n()), + Box::new(builder::integer_type(1)), + HelpData::default(), + ); + let vec_of = |len: Type| Type::vec(VecType::S3, len, builder::integer_type_default(), HelpData::default()); + let range = |iv: Interval| prop(Refinement::Range(Measure::Length, iv)); + // `#N + 1` is at least 1: it proves length(> 0), `#N` alone does not + let pos = inter(vec_of(Type::Any(HelpData::default())), range(Interval::greater_than(0.0))); + assert!(vec_of(plus1.clone()).is_subtype(&pos, &ctx).0); + assert!(!vec_of(n()).is_subtype(&pos, &ctx).0); + // narrowing `#N + 1` by a satisfied range is a no-op; by an unproven one it stays a property + assert_eq!(inter(vec_of(plus1.clone()), range(Interval::greater_than(0.0))), vec_of(plus1)); + assert!(matches!(inter(vec_of(n()), range(Interval::greater_than(0.0))), Type::Refined(..))); + // a symbolic length is never negative + let neg = inter(vec_of(n()), range(Interval::less_than(0.0))); + assert!(matches!(refinement_error(&neg), Some(TypeError::UnsatisfiableRefinement(..))), "{:?}", neg); + } } diff --git a/install_plan.md b/install_plan.md index 08e9197..0369dfe 100644 --- a/install_plan.md +++ b/install_plan.md @@ -80,32 +80,117 @@ par défaut ci-dessous. ## 3. Points de vigilance avant de commencer -### 3.1 Le glibc des binaires Linux peut être trop récent +### 3.1 Le glibc des binaires Linux — défaut confirmé, à corriger avant tout -C'est le risque principal, et il est invisible jusqu'au premier utilisateur. +Le diagnostic a été exécuté sur le binaire **publié** en `v0.5.12`. Le défaut +est réel, et il bloque l'installation sur une large part des machines. -Les binaires GNU sont compilés sur `ubuntu-latest` (actuellement Ubuntu 24.04, -glibc 2.39). Un binaire Rust y lié exigera une glibc **aussi récente** chez -l'utilisateur. Sur une Ubuntu 22.04 (glibc 2.35) ou une Debian 12 (glibc 2.36), -le démarrage échoue sur `GLIBC_2.38 not found` — ce qui donne exactement -l'impression d'un binaire cassé. +`objdump -T` sur `typr-v0.5.12-x86_64-unknown-linux-gnu` : -Vérification à faire immédiatement sur une release existante : +``` +GLIBC_2.39 pidfd_spawnp (faible) +GLIBC_2.39 pidfd_getpid (faible) +``` + +Exécution réelle dans des conteneurs qui n'ont rien de TypR : + +| Système | glibc / libc | Résultat | +|---|---|---| +| Ubuntu 24.04 (le runner) | 2.39 | fonctionne | +| Debian 13 (trixie) | 2.41 | fonctionne — `typr-cli 0.5.12` | +| Ubuntu 22.04 | 2.35 | **`GLIBC_2.39 not found`** | +| Debian 12 (bookworm) | 2.36 | **`GLIBC_2.39 not found`** | +| Rocky Linux 9 | 2.34 | **`GLIBC_2.39 not found`** | +| Alpine 3.20 | musl | non exécutable — aucun build musl | + +Deux enseignements. + +**Les symboles faibles ne sauvent rien.** `pidfd_spawnp` et `pidfd_getpid` +(venus de `std::process::Command`) sont marqués faibles : on pourrait croire que +le chargeur les résoudra à `NULL` et que le repli prendra le relais. Testé : le +chargeur refuse quand même, parce que c'est la **référence versionnée** qui est +absente, pas seulement le symbole. Il n'y a donc aucun contournement à tenter — +seule une chaîne de compilation avec un plancher plus bas règle le problème. + +**Rocky 9 échoue.** C'est le point le plus douloureux : RHEL / Rocky / AlmaLinux +9 est une base très répandue chez les utilisateurs R professionnels, en +particulier en entreprise. Debian 12 et Ubuntu 22.04 ne sont pas rares non plus. + +**Et le plancher va monter tout seul.** `ubuntu-latest` passe à **Ubuntu 26.04 +en novembre 2026** (annonce de `actions/runner-images#14748`). Sans action, la +glibc requise passera de 2.39 à ~2.42 et le terrain cassera davantage, à chaque +release, sans qu'une ligne de code soit modifiée. C'est un défaut qui s'aggrave +silencieusement — la raison de plus pour traiter le fond, pas seulement le +symptôme. + +**Décision recommandée : ajouter les deux cibles musl.** Elles sont liées +stiquement, donc sans contrainte de glibc du tout — elles fonctionnent sur +Rocky 9, Debian 12, Ubuntu 22.04 **et** Alpine. Cela règle le problème de glibc +et le cas Alpine d'un seul geste, pour deux cibles de plus dans la matrice. + +`cross` est déjà utilisé par la matrice pour `aarch64-unknown-linux-gnu` +(`release.yml:110`), et son image musl est disponible : rien de nouveau à +installer, seulement deux entrées à ajouter. + +| Cible | Rôle | Priorité | +|---|---|---| +| `x86_64-unknown-linux-musl` | remplaçante de la version GNU sur x86_64 | 1 | +| `aarch64-unknown-linux-musl` | remplaçante sur ARM | 1 | + +Deux вариpless possibles si l'on veut rester sur des binaires glibc dynamiques, +par ordre de préférence : + +- `cargo-zigbuild` en visant `glibc = "2.28"` — plancher très bas, couvre + RHEL 8. Mais ne couvre pas Alpine, et ajoute une dépendance d'outillage. +- compiler la cible GNU sur `ubuntu-22.04` au lieu de `ubuntu-latest` — plancher + 2.35, couvre Debian 12 et Ubuntu 22.04 mais **pas** Rocky 9 (2.34). Un + correctif partiel. + +Dans tous les cas, sortir la cible GNU de `latest` une fois musl disponible : +le script doit viser musl par défaut et ne retomber sur GNU que si l'utilisateur +le demande explicitement. + +### 3.2 macOS — aucun problème + +`LC_VERSION_MIN_MACOSX` du binaire `v0.5.12-x86_64-apple-darwin` annonce un +minimum de **10.12.0** (Sierra), compilé avec un SDK 26.5. Toutes les versions +de macOS réellement en usage sont couvertes. Rien à faire. + +### 3.3 Windows — le runtime MSVC manque sur les machines minimalistes + +Les DLL importées par `typr.exe` : -```bash -objdump -T target/release/typr | grep -o 'GLIBC_[0-9.]*' | sort -Vu | tail -3 ``` +kernel32.dll, USER32.dll, advapi32.dll, ntdll.dll, bcryptprimitives.dll +VCRUNTIME140.dll +api-ms-win-crt-runtime / -string / -math / -stdio / -locale / -heap +``` + +`VCRUNTIME140.dll` et les `api-ms-win-crt-*` proviennent du **VC++ Redistributable +de Microsoft**. Ils sont présents sur un poste de développement ou une machine +qui a déjà installé beaucoup de choses, mais **absents d'un Windows 10/11 +frais**. Le scénario est exactement celui qu'on veut éviter : l'utilisateur copie +la commande, l'installation se termine sans erreur, puis `typr --version` échoue +avec une `DLL introuvable` — une faute d'orthographe dans un message de +chargeur. -Si le maximum dépasse glibc 2.35, deux options : +**Correction : `-C target-feature=+crt-static`** sur les cibles `*-msvc`, ce qui +lie statiquement le runtime C et supprime la dépendance. rustc accepte le flag +pour `x86_64-pc-windows-msvc` ; **la liaison n'a pas pu être vérifiée localement** +(pas de linker MSVC dans cet environnement). À valider en CI en comparant la +liste des DLL importées du binaire produit. -- **recommandée** — compiler `x86_64-unknown-linux-gnu` sur `ubuntu-22.04` dans - la matrice de `release.yml:77`. Ça abaisse le plancher sans changer - grand-chose, au prix d'un toolchain plus vieux. -- **alternative** — ajouter des cibles `*-unknown-linux-musl`, statiquement - liées, qui fonctionnent partout y compris sur Alpine. Résout le problème de - glibc **et** le cas Alpine, pour deux cibles de plus dans la matrice. +Ce correctif est peu coûteux et supprime une classe entière de tickets, donc il +fait partie du lot 0. -### 3.2 Résoudre « latest » sans dépendance +### 3.4 `aarch64-pc-windows-msvc` — à réévaluer + +Cette cible ne fonctionne que sur Windows sur ARM (Snapdragon X). Elle occupe un +créneau de la matrice pour une audience quasi inexistante. Ce n'est pas un +problème de fonctionnement, seulement de temps de CI. À garder pour la +complétude, à retirer si la matrice devient coûteuse. + +### 3.5 Résoudre « latest » sans dépendance Les scripts ne doivent pas supposer `jq` ni `python` sur la machine. @@ -125,22 +210,45 @@ Deux conséquences utiles : beta devra, lui, lister `/releases` et filtrer. - Aucune dépendance à un outil de parsing. -### 3.3 Vérifier le SHA-256 +### 3.6 Vérifier le SHA-256 — ne pas utiliser `sha256sum -c` tel quel + +Testé sur `v0.5.12` : `checksums.txt` contient bien les 6 lignes attendues, au +format `sha256sum`. Mais `sha256sum -c checksums.txt` **échoue**, parce qu'il +tente de vérifier les 5 archives qui n'ont pas été téléchargées : + +``` +typr-v0.5.12-x86_64-unknown-linux-gnu.tar.gz: OK +typr-v0.5.12-aarch64-apple-darwin.tar.gz: FAILED open or read +… (4 autres) +``` -`checksums.txt` est déjà au format attendu par `sha256sum -c`. Attention : il -contient des noms de fichiers nus, donc la commande doit tourner **depuis le -répertoire** contenant le fichier téléchargé. +Le code de sortie est non nul même quand le fichier nous intéresse est valide. +Le script doit donc **extraire la seule ligne qui correspond**, puis la +vérifier : + +```bash +grep " $ARTIFACT\$" checksums.txt | sha256sum -c - +``` + +Testé : cette variante sort `OK` avec un code de retour nul. Elle reste le seul +endroit où une erreur de formatage passerait inaperçue, donc à couvrir par un +test qui fournit volontairement un SHA falsifié. + +`checksums.txt` ne contient que les 6 archives de binaires — ni le `.vsix`, ni +le tarball RStudio, ni le plugin Vim, ni le WASM, qui sont joints plus tard par +d'autres jobs. C'est cohérent avec l'usage du script d'installation, et le plan +n'a pas à en tenir compte autrement. L'option `TYPR_INSTALL_VERIFY=0` permet de sauter la vérification pour un dépannage réseau, sans jamais être le mode par défaut. -### 3.4 Récupérer le binaire après un échec +### 3.7 Récupérer le binaire après un échec `install.sh` télécharge dans un fichier temporaire, vérifie le SHA, puis déplace. En cas d'échec le fichier temporaire est nettoyé : on n'installe jamais un binaire partiellement téléchargé. -### 3.5 Le job `coherence` interdit les binaires commités +### 3.8 Le job `coherence` interdit les binaires commités Le job de CI échoue si un binaire réapparaît dans l'index (`RELEASING.md:157`). Les scripts ne doivent installer que dans `$HOME`, jamais dans le dépôt, et le @@ -202,11 +310,25 @@ TYPR_INSTALL_DIR=$(mktemp -d) ./install/install.sh $TYPR_INSTALL_DIR/typr --version ``` -- [ ] Linux x86_64, Linux aarch64 -- [ ] macOS x86_64, macOS aarch64 -- [ ] Windows x86_64 via `irm | iex` +Le `typr --version` de la dernière ligne est le test qui compte : c'est +précisément l'étape qui échoue aujourd'hui sur d'anciennes distributions. +Ajouter un contrôle explicite dans la CI, sur des images Docker sans rien +d'autre : + +```bash +# ce test a échoué sur les 3 premières distributions en septembre 2026 +docker run --rm -v "$BIN:/w/typr" rockylinux:9 sh -c '/w/typr --version' +docker run --rm -v "$BIN:/w/typr" debian:12 sh -c '/w/typr --version' +docker run --rm -v "$BIN:/w/typr" ubuntu:22.04 sh -c '/w/typr --version' +docker run --rm -v "$BIN:/w/typr" alpine:3.20 sh -c '/w/typr --version' +``` + +- [ ] Les 4 conteneurs ci-dessus répondent une version +- [ ] Linux x86_64 et aarch64, macOS x86_64 et aarch64 +- [ ] Windows x86_64 via `irm | iex`, sur une image sans VC++ Redistributable - [ ] `--version ` installe bien l'ancienne version -- [ ] SHA volontairement corrompu → le script refuse d'installer et le dit +- [ ] SHA volontairement falsifié → le script refuse d'installer et le dit +- [ ] `checksums.txt` absent ou malformé → erreur explicite, pas un `FAILED open or read` - [ ] `PATH` non modifiable → message clair, pas d'erreur opaque - [ ] Idempotence : deux exécutions successives ne cassent rien @@ -353,7 +475,7 @@ Préciser explicitement que l'installation **n'exige pas `sudo`** et - [ ] Les commandes du README sont copiées et exécutées telles quelles - [ ] `RELEASING.md` mentionne les nouveaux canaux dans le diagramme -- [ ] `publish.nu check`compare bien les quatre canaux (voir ci-dessous) +- [ ] `publish.nu check` compare bien les quatre canaux (voir ci-dessous) --- @@ -413,7 +535,9 @@ silence. | # | Lot | Dépend de | Effort | |---|---|---|---| -| 0 | Diagnostic glibc + décision cible musl | — | 1 h | +| 0a | **Cibles musl** dans la matrice de `release.yml` | — | 2 h | +| 0b | **`+crt-static`** sur les cibles `*-msvc` | — | 30 min | +| 0c | Vérifier que le binaire musl démarre dans Rocky 9, Debian 12, Alpine | 0a | 1 h | | 1 | `install.sh` + `install.ps1` + hébergement | 0 | 1 j | | 2 | Tests CI sur les scripts | 1 | 3 h | | 3 | Homebrew tap + job CI | 1 | 1 j | @@ -422,9 +546,11 @@ silence. | 6 | `publish.nu check` étendu | 1-4 | 2 h | | 7 | `typr upgrade` (optionnel) | — | plus tard | -Le lot 0 conditionne le lot 1 : publier un script d'installation qui installe un -binaire ne démarre pas sur Ubuntu 22.04 transformerait le canal principal en -source de tickets. C'est un diagnostic d'une heure qui évite ce risque. +Le lot 0 est **précisément-identified et non négociable** : le diagnostic a +montré que le binaire actuel ne démarre ni sur Rocky 9, ni sur Debian 12, ni sur +Ubuntu 22.04. Publier un script d'installation qui lance `typr --version` et +échoue dessus convertirait le canal principal en source de tickets — c'est-à-dire +exactement l'expérience que ce plan cherche à supprimer. Les lots 3 et 4 sont indépendants de 2 et parallélisables. @@ -434,9 +560,11 @@ Les lots 3 et 4 sont indépendants de 2 et parallélisables. | Risque | Impact | Réponse | |---|---|---| -| glibc des binaires Linux trop récent | Installation qui échoue sur les distributions anciennes | Lot 0, puis rebuild sur `ubuntu-22.04` ou cibles musl | -| SmartScreen bloque `typr.exe` | Avertissement à l'insertion, pas un blocage | Signatures `install.ps1` volontairement absentes : PowerShellSmartScreen marque le fichier téléchargé, un fichier posé par script ne l'est pas | -| Gatekeeper sur macOS | Rare pour un binaire installé par script | Documenter `xattr -d com.apple.quarantine` pour ceux qui téléchargent d'abord par navigateur | +| glibc des binaires Linux trop récent | **Confirmé** — l'installation échoue sur Rocky 9, Debian 12, Ubuntu 22.04, Alpine | Lot 0a : cibles musl, liées statiquement | +| Le plancher glibc remontera seul en novembre 2026 | Nouvelle rupture, sans changement de code | Lot 0a : musl supprime la dépendance à la glibc du runner | +| `VCRUNTIME140.dll` absent sur Windows minimal | Installation réussie puis `typr --version` échoue | Lot 0b : `+crt-static` | +| SmartScreen bloque `typr.exe` | Avertissement, pas un blocage | Un binaire posé par `install.ps1` ne porte pas de MOTW | +| Gatekeeper sur macOS | Rare — plancher à macOS 10.12, et `curl \| sh` ne pose pas la quarantaine | Documenter `xattr -d com.apple.quarantine` pour qui télécharge d'abord par navigateur | | Un canal dérive en silence | Le README promet une version absente d'un canal | `publish.nu check` étendu (lot 6) | | Le script d'installation casse les gens | Mauvaise expérience sur le canal principal | `--dry-run`, `--version` épinglé, idempotence, tests sur 6 cibles en CI | @@ -445,10 +573,10 @@ Les lots 3 et 4 sont indépendants de 2 et parallélisables. ## 12. hors périmètre - Signature et notarification (coût, hors budget d'un projet open source). -- Canal de type `winget` : trop tôt, la distribution de Linux est trop - fragmentée pour que le gain justifie l'effort. +- Canal `apt` / `dnf` : la distribution Linux est trop fragmentée pour que le + gain justifie l'effort. Le script Unix — et musl — couvrent ces systèmes sans + coût supplémentaire. - Mise à jour automatique silencieuse, qui crée plus de problèmes qu'elle n'en résout (mise à jour pendant une session LSP en cours, binaire remplacé sous les pieds du processus). `typr upgrade` explicite suffit. -- Paquet `apt` / `dnf` : le script Unix couvre ces distributions sans - fragmentation supplémentaire. \ No newline at end of file +- Paquet `apt` / `dnf` : sans objet, la ligne ci-dessus couvre le sujet. \ No newline at end of file From ef31670bbee04a6a8391e774a7f49d279f654fb6 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 13:09:25 +0200 Subject: [PATCH 38/52] fixed REGRESS cases --- .github/workflows/release.yml | 93 +++++++++++++++++++++++++++++++++-- RELEASING.md | 3 +- install_plan.md | 68 +++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c63ea9f..c23b6cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,19 +74,41 @@ jobs: fail-fast: false matrix: include: - - target: x86_64-unknown-linux-gnu + # Linux musl — la cible que vise le script d'installation par défaut. + # Liée statiquement : aucune contrainte de glibc, donc le binaire + # démarre sur Rocky 9, Debian 12, Ubuntu 22.04 *et* Alpine. C'est le + # correctif du défaut mesuré sur v0.5.12 (`GLIBC_2.39 not found`). + - target: x86_64-unknown-linux-musl os: ubuntu-latest archive: tar.gz - - target: aarch64-unknown-linux-gnu + cross: true + - target: aarch64-unknown-linux-musl os: ubuntu-latest archive: tar.gz cross: true + # Linux GNU — repli, construit sur 22.04 et non sur `latest` : `ubuntu-latest` + # passe à Ubuntu 26.04 en novembre 2026 (actions/runner-images#14748) et + # ferait monter le plancher de glibc de 2.35 à ~2.42 sans qu'une ligne du + # dépôt change. Ici le plancher est figé, quoi qu'il arrive au runner. + - target: x86_64-unknown-linux-gnu + os: ubuntu-22.04 + archive: tar.gz + - target: aarch64-unknown-linux-gnu + os: ubuntu-22.04 + archive: tar.gz + cross: true + # `+crt-static` lie le runtime C : VCRUNTIME140.dll et les + # api-ms-win-crt-* du VC++ Redistributable disparaissent des imports. + # Sans ce flag, l'installation aboutit puis `typr --version` échoue + # sur un Windows 10/11 frais (DLL introuvable). - target: x86_64-pc-windows-msvc os: windows-latest archive: zip + rustflags: -C target-feature=+crt-static - target: aarch64-pc-windows-msvc os: windows-latest archive: zip + rustflags: -C target-feature=+crt-static - target: x86_64-apple-darwin os: macos-latest archive: tar.gz @@ -107,16 +129,23 @@ jobs: with: key: ${{ matrix.target }} - - name: Install cross (Linux ARM) + - name: Install cross (Linux non natif) if: matrix.cross run: cargo install cross --git https://github.com/cross-rs/cross - name: Build (cross) if: matrix.cross + env: + # `-C target-feature=+crt-static` sur les cibles `*-msvc` ; vide + # partout ailleurs. Comme `--target` est toujours passé, cargo + # n'applique pas RUSTFLAGS aux build scripts ni aux proc macros. + RUSTFLAGS: ${{ matrix.rustflags }} run: cross build --release --target ${{ matrix.target }} - name: Build (native) if: ${{ !matrix.cross }} + env: + RUSTFLAGS: ${{ matrix.rustflags }} run: cargo build --release --target ${{ matrix.target }} - name: Prepare artifacts (Unix) @@ -132,6 +161,64 @@ jobs: cd target/${{ matrix.target }}/release Compress-Archive -Path ${{ env.BINARY_NAME }}.exe -DestinationPath ../../../${{ env.BINARY_NAME }}-${{ needs.verify.outputs.tag }}-${{ matrix.target }}.zip + # La liaison statique du runtime C ne se constate qu'en inspectant le PE + # produit : rustc accepte le flag, mais rien ne garantit qu'il ait été + # honoré. C'était le point non vérifié de l'analyse, donc on le vérifie ici + # plutôt que de le croire — sinon le défaut revient en silence au premier + # changement de dépendance. + - name: Runtime C statique (Windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + $exe = "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe" + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $vs = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationPath + if (-not $vs) { throw "Outils C++ de Visual Studio introuvables sur le runner" } + $vcvars = Join-Path $vs "VC\Auxiliary\Build\vcvars64.bat" + + # dumpbin n'est pas dans le PATH du runner : il faut l'environnement VC. + $dlls = cmd /c "`"$vcvars`" >nul 2>&1 && dumpbin /nologo /dependents `"$exe`"" + if ($LASTEXITCODE -ne 0) { throw "dumpbin /dependents a échoué sur $exe" } + Write-Host $dlls + + # UCRT et VCRUNTIME viennent tous deux du VC++ Redistributable, absent + # d'un Windows 10/11 frais. S'ils réapparaissent, l'installation + # paraîtra réussie et `typr --version` échouera. + $runtime = $dlls | Select-String -Pattern 'VCRUNTIME140|VCRUNTIME140_1|MSVCP140|api-ms-win-crt-|ucrtbase\.dll' + if ($runtime) { + Write-Host "::error::le binaire importe encore le runtime du VC++ Redistributable :" + Write-Host $runtime + exit 1 + } + Write-Host "Runtime C statique : aucune DLL du VC++ Redistributable importée." + + # C'est cette étape — `typr --version` dans une distro vierge — qui échouait + # en septembre 2026 avec les binaires GNU, sur les 3 premières + # distributions. Un binaire musl qui trahirait un interpréteur + # (`PT_INTERP`) ne fonctionnerait que sur Alpine, et le défaut + # reviendrait sans qu'aucun test ne le voie : d'où les deux garde-fous. + - name: Binaire musl statique et exécutable + if: matrix.target == 'x86_64-unknown-linux-musl' + run: | + set -euo pipefail + BIN="$PWD/target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}" + file "$BIN" + # On lit la table de segments en entier plutôt que de s'arrêter à la + # première correspondance : `grep -q` sous `pipefail` laisse la + # garde-fou dépendre du tampon de pipe, et le décompte sert aussi au + # message d'erreur. + INTERP=$(readelf -lW "$BIN" | grep -cE '\bINTERP\b' || true) + if [ "$INTERP" -ne 0 ]; then + echo "::error::$BIN déclare un interpréteur — il n'est pas statique et ne tournera que sur Alpine" + exit 1 + fi + for IMAGE in rockylinux:9 debian:12 alpine:3.20; do + echo "→ $IMAGE" + docker run --rm -v "$BIN:/w/typr:ro" "$IMAGE" sh -c '/w/typr --version' + done + - uses: actions/upload-artifact@v4 with: name: ${{ env.BINARY_NAME }}-${{ matrix.target }} diff --git a/RELEASING.md b/RELEASING.md index ed9fb4f..263e5a2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -94,7 +94,8 @@ tag vX.Y.Z │ ├─ verify ......... refuse si tag ≠ Cargo.toml ≠ éditeurs │ - ├─ build .......... 6 cibles (Linux/Windows/macOS × x86_64/aarch64) + ├─ build .......... 8 cibles (Linux musl + Linux GNU + Windows + macOS, + │ × x86_64/aarch64) │ └─ release .... GitHub Release + checksums SHA-256 │ ├─ docker ..... image depuis le binaire Linux, tags X.Y.Z + latest │ ├─ rstudio .... tarball R avec les binaires frais embarqués diff --git a/install_plan.md b/install_plan.md index 0369dfe..17b4c45 100644 --- a/install_plan.md +++ b/install_plan.md @@ -10,6 +10,74 @@ par-dessus, pas de refaire ce qui marche. --- +## 0. Avancement + +| Lot | État | +|---|---| +| 0a — cibles musl dans la matrice | **fait** | +| 0b — `+crt-static` sur `*-msvc` | **fait** (liaison vérifiée en CI, pas en local) | +| 0c — binaire musl dans Rocky 9 / Debian 12 / Alpine | **fait** | +| 1 → 7 | à faire | + +### Ce que le lot 0 a changé + +`.github/workflows/release.yml` est passé de 6 à 8 cibles. + +- `x86_64-unknown-linux-musl` et `aarch64-unknown-linux-musl` sont entrés dans + la matrice, construits par `cross` comme `aarch64-unknown-linux-gnu` l'était + déjà. Plus de cible `*-sys` : le binaire est `static-pie linked`, sans + `PT_INTERP`, et `objdump -T | grep -c GLIBC_` renvoie **0**. +- Les deux cibles GNU sont sorties de `ubuntu-latest` pour `ubuntu-22.04`. Le + plancher de glibc de la voie de repli est ainsi figé à 2.35 au lieu de monter + tout seul à ~2.42 en novembre 2026 avec le runner. +- Les deux cibles `*-msvc` reçoivent `-C target-feature=+crt-static` par + `RUSTFLAGS`. Comme `--target` est toujours passé, cargo n'applique pas la + variable aux build scripts ; le crate C embarqué (oniguruma, via `syntect`) + est donc compilé en `/MT` et non en `/MD` — `cc` lit `crt-static` dans + `CARGO_CFG_TARGET_FEATURE` pour choisir. Sans cela, le runtime du + VC++ Redistributable revenait par la porte du code C. + +### Mesures (30 septembre 2026) + +Binaire `x86_64-unknown-linux-musl` construit par `cross`, profil `release` : + +| Système | Avant (GNU) | Après (musl) | +|---|---|---| +| Rocky Linux 9 (glibc 2.34) | `GLIBC_2.39 not found` | `typr-cli 0.5.12` | +| Debian 12 (glibc 2.36) | `GLIBC_2.39 not found` | `typr-cli 0.5.12` | +| Ubuntu 22.04 (glibc 2.35) | `GLIBC_2.39 not found` | `typr-cli 0.5.12` | +| Alpine 3.20 (musl) | non exécutable | `typr-cli 0.5.12` | +| Debian 13, Ubuntu 24.04 | `typr-cli 0.5.12` | `typr-cli 0.5.12` | + +`typr check` produit des diagnostics identiques à ceux du binaire glibc sur le +même fichier : le comportement n'a pas bougé, seul le lien a changé. + +### Deux garde-fous dans la CI + +La propriété qui fait marcher ces binaires — la liaison statique — est +invisible dans le code : rien ne la casse quand elle disparaît, et c'est +précisément le mode de défaillance qu'on cherche à supprimer. Deux étapes +l'interdisent désormais. + +- `Runtime C statique (Windows)` lit la table d'imports PE avec `dumpbin` et + échoue si `VCRUNTIME140`, `MSVCP140` ou un `api-ms-win-crt-*` réapparaît. +- `Binaire musl statique et exécutable` échoue si le binaire musl déclare un + `PT_INTERP` — un binaire musl qui trahirait son interpréteur ne tournerait + que sur Alpine — puis lance `typr --version` dans `rockylinux:9`, + `debian:12` et `alpine:3.20`. + +La seconde remplace la checklist de la section 4 : le test qui compte n'a pas +lieu d'être fait à la main une fois, mais à chaque release. + +### Reste à confirmer en CI + +La liaison `+crt-static` sur `x86_64-pc-windows-msvc` reste **non vérifiée +localement** : ni le linker MSVC ni le SDK ne sont disponibles ici. `rustc` +accepte le flag pour les deux cibles `*-msvc` (vérifié jusqu'à l'émission des +métadonnées), et le garde-fou `dumpbin` tranchera à la prochaine release. + +--- + ## 1. État actuel `.github/workflows/release.yml` produit, à partir d'un unique tag `vX.Y.Z` : From b4d14905ab5d908437c43b0115dd95f66110358a Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 14:33:33 +0200 Subject: [PATCH 39/52] =?UTF-8?q?install=20:=20scripts=20d=20installation,?= =?UTF-8?q?=20h=C3=A9bergement=20et=20CI=20(lot=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute les deux installeurs du lot 1 du plan, avec leurs suites de tests et les jobs de CI qui les gardent honnêtes. install/install.sh est un sh POSIX de bout en bout : ni bash, ni jq, ni python. Sur Linux il installe un binaire musl lié statiquement, qui démarre sur Rocky 9, Debian 12, Ubuntu 22.04 et Alpine alike ; le binaire GNU n est choisi que sur --gnu explicite, parce que le défaut mesuré sur v0.5.12 était GLIBC_2.39 not found sur les trois premières distributions. Rien n est écrit hors de $HOME/.local/bin, et le script n écrit dans aucun fichier de profil : il affiche la ligne PATH à ajouter. install/install.ps1 vise PowerShell 5.1 comme PowerShell 7, en .zip parce que Expand-Archive est le seul extracteur présent sur un Windows sans rien installer. Il pose le binaire dans %LOCALAPPDATA%\Programs\typr et met le PATH à jour par registre, sans privilège administrateur. Les deux résolvent la dernière stable par l en-tête Location de /releases/latest et la dernière prerelease par releases.atom, sans dépendance externe. Ils vérifient le SHA-256 avant d installer, et refusent aussi bien un checksums.txt absent qu un SHA malformé : deux diagnostics distincts, que les scripts nomment de la même façon. Cinq défauts sont sortis des tests, tous invisibles à la lecture : Try-Save-File rendait $null donc falsy sur un téléchargement réussi, et chaque installation Windows échouait ; $input est une variable automatique PowerShell ; le fichier sans BOM UTF-8 était lu en ANSI par PS 5.1, ce qui casse chaque accent sur un Windows français ou allemand ; une ligne checksums.txt malformée était signalée comme absente ; et le harnais perdait les codes de retour, rendant le refus de tag (2) indiscernable du refus d environnement (1). 59 assertions Unix et 49 PowerShell passent, shellcheck est propre sur les deux scripts shell. Trois jobs : install (analyse et suites), install-windows (le script sous Windows PowerShell 5.1 réel) et install-hosting (copie vers typr.github.io/static/install/). Le démarrage d un binaire Windows réel reste non vérifié : le runner n a pas de VC++ Redistributable et la fixture n est pas un exécutable Windows. Le job install-windows valide donc le script, pas le binaire. --- install/README.md | 124 +++++ install/install.ps1 | 505 ++++++++++++++++++++ install/install.sh | 423 ++++++++++++++++ install/tests/Dockerfile | 30 ++ install/tests/PSScriptAnalyzerSettings.psd1 | 63 +++ install/tests/fixture-server.py | 99 ++++ install/tests/run-tests.ps1 | 495 +++++++++++++++++++ install/tests/run-tests.sh | 451 +++++++++++++++++ 8 files changed, 2190 insertions(+) create mode 100644 install/README.md create mode 100644 install/install.ps1 create mode 100755 install/install.sh create mode 100644 install/tests/Dockerfile create mode 100644 install/tests/PSScriptAnalyzerSettings.psd1 create mode 100755 install/tests/fixture-server.py create mode 100755 install/tests/run-tests.ps1 create mode 100755 install/tests/run-tests.sh diff --git a/install/README.md b/install/README.md new file mode 100644 index 0000000..9c920e8 --- /dev/null +++ b/install/README.md @@ -0,0 +1,124 @@ +# Installation + +TypR s'installe en une commande. Le script vérifie l'empreinte SHA-256 de ce +qu'il télécharge avant de l'exécuter, et n'écrit rien en dehors de votre dossier +utilisateur. + +## Linux, macOS + +```sh +curl -fsSL https://we-data-ch.github.io/typr.github.io/install/install.sh | sh +``` + +Le script est un `sh` POSIX : ni bash, ni `jq`, ni `python`. Il s'exécute +n'importe où, y compris sur une Alpine sans glibc. + +Sur Linux, il installe un binaire **musl**, lié statiquement, qui démarre sur +Rocky 9, Debian 12, Ubuntu 22.04 et Alpine alike. Pour un binaire **GNU** +(lié dynamiquement à glibc), ajoutez `--gnu`. + +## Windows + +PowerShell 5.1 est présent sur toute machine Windows 10 ou plus récente : + +```powershell +irm https://we-data-ch.github.io/typr.github.io/install/install.ps1 | iex +``` + +Le script pose le binaire dans `%LOCALAPPDATA%\Programs\typr`, ajoute ce +dossier au PATH utilisateur, et ne demande aucun privilège administrateur. Il +n'est pas signé : PowerShell affichera *« Unrecognized script from +typr-install »*, avec son nom et son origine. Vérifiez que la ligne correspond +à ce dépôt avant de taper `O`. + +Le PATH ne prend effet qu'au prochain terminal ; le script met aussi à jour la +session courante, donc `typr --version` fonctionne immédiatement. + +## Options + +Les deux scripts partagent la même ligne de commande. + +| Option | Effet | +|---|---| +| `--version vX.Y.Z` | installe ce tag précis au lieu du dernier | +| `--channel beta` | installe la dernière prerelease | +| `--dry-run` | affiche ce qui serait fait, ne télécharge rien | +| `--help` | l'aide du script | +| `--gnu` | Unix seulement : cible glibc au lieu de musl | + +Sur PowerShell, les options s'écrivent `-Version`, `-Channel`, `-DryRun`, `-Gnu`. +Sur la ligne `irm … | iex`, elles ne sont pas transmissibles : `iex` n'accepte +aucun argument. Utilisez alors les variables d'environnement, ou téléchargez le +script dans un fichier et lancez-le. + +| Variable | Effet | +|---|---| +| `TYPR_INSTALL_DIR` | dossier d'installation | +| `TYPR_INSTALL_VERIFY=0` | saute la vérification SHA-256 (dépannage réseau) | + +## Ce qui est écrit + +| Système | Emplacement | +|---|---| +| Linux, macOS | `$HOME/.local/bin` | +| Windows | `%LOCALAPPDATA%\Programs\typr`, ajouté au PATH utilisateur par registre | + +Sur Unix, le script **n'écrit dans aucun fichier de configuration** : si +`$HOME/.local/bin` n'est pas déjà dans votre PATH, il affiche la ligne exacte à +ajouter à `~/.profile` ou `~/.zshrc`. Le laisser modifier votre profil à votre +place serait une surprise, et un `.profile` réécrit à tort peut empêcher votre +shell de démarrer. + +Sur Windows en revanche, le PATH est mis à jour dans le registre — c'est le +seul endroit où Windows le lit. Le changement ne s'applique qu'au prochain +terminal, et le script le dit. + +Dans les deux cas, rien d'autre n'est écrit : pas de fichier système, aucun +privilège administrateur, et le dossier temporaire de téléchargement est +supprimé en cas d'échec comme de succès. + +## Vérifier l'empreinte soi-même + +Si vous ne faites confiance ni au script ni à TLS : + +```sh +curl -fsSLO https://github.com/we-data-ch/typr/releases/latest/download/checksums.txt +grep "x86_64-unknown-linux-musl.tar.gz" checksums.txt +``` + +La ligne attendue commence par une empreinte SHA-256 de 64 caractères +hexadécimaux. C'est exactement la ligne que le script cherche, et il refuse de +continuer si elle est absente ou malformée — y compris lorsque le fichier existe +et contient bien le nom de l'archive. Un `checksums.txt` tronqué ou corrompu est +donc un échec, pas une installation sans vérification. + +## Codes de sortie + +| Code | Signification | +|---|---| +| `0` | installation réussie | +| `1` | environnement ou vérification : réseau coupé, release absente, SHA invalide | +| `2` | ligne de commande : option inconnue, argument manquant, tag illisible | + +La distinction 1 / 2 est volontaire : un appelant peut réagir différemment à une +mauvaise commande et à une release cassée. + +## Dépannage + +**`GLIBC_2.39 not found`** — le binaire GNU exige une glibc plus récente que +celle de votre système. C'est ce qui se passait par défaut avant que musl ne +devienne le choix par défaut. Rejouez sans `--gnu`. + +**`typr: command not found`** — le dossier `$HOME/.local/bin` n'est pas dans le +PATH de ce terminal. Ouvrez-en un nouveau, ou lancez `$HOME/.local/bin/typr`. + +**Le téléchargement échoue alors que le réseau fonctionne** + +`TYPR_INSTALL_VERIFY=0` désactive la vérification SHA-256. À n'utiliser que pour +confirmer qu'un problème de réseau est bien la cause : cela retire la seule +protection contre une archive altérée en transit. + +**Alerte de sécurité PowerShell** — le script n'est pas signé. `irm … | iex` +exécute du code téléchargé ; c'est le compromis habituel du one-liner, contre la +signature Authenticode qui exigerait un certificat de signature de code à jour +pour chaque binaire publié. \ No newline at end of file diff --git a/install/install.ps1 b/install/install.ps1 new file mode 100644 index 0000000..77eccf8 --- /dev/null +++ b/install/install.ps1 @@ -0,0 +1,505 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + TypR — installation en une commande (Windows). + +.DESCRIPTION + powershell -c "irm https://we-data-ch.github.io/typr.github.io/install/install.ps1 | iex" + + Le format est .zip et non .tar.gz : Expand-Archive est le seul outil + d'extraction présent sur un Windows sans rien installer, alors qu'il + faudrait tar.exe (livré à partir de Windows 10 1803, absent de Windows 7/8.1 + et de Windows Server 2012). + + Cible %LOCALAPPDATA%\Programs\typr : convention utilisateur moderne, aucun + droit administrateur. Le PATH utilisateur est mis à jour par registre, donc + il ne prend effet qu'au prochain terminal — le script le dit, et met + également à jour $env:Path de la session courante pour que `typr --version` + fonctionne immédiatement. + + Le binaire n'est pas signé. Un fichier posé par Expand-Archive ne porte pas + l'attribut Mark-of-the-Web (seuls les navigateurs et les zip téléchargés en + le posent) : pas de SmartScreen, contrairement à un exécutable téléchargé + puis double-cliqué. + +.PARAMETER Version + Tag à installer (ex. v0.5.12). Par défaut, la dernière version stable. + +.PARAMETER Channel + stable (défaut) ou beta. + +.PARAMETER DryRun + Affiche ce qui serait fait, ne télécharge rien. + +.PARAMETER Gnu + Sans effet sur Windows ; accepté pour que la même ligne de commande + fonctionne sur les deux scripts. + +.NOTES + Rien n'est écrit hors de %LOCALAPPDATA% et aucun privilège administrateur + n'est requis. +#> + +[CmdletBinding()] +param( + [string] $Version, + [ValidateSet('stable', 'beta')] + [string] $Channel = 'stable', + [switch] $DryRun, + [switch] $Gnu +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Configuration +# +# Les variables d'environnement ci-dessous sont surchargées par les tests, qui +# servent une release locale : sans cela, tester le refus d'un SHA falsifié +# supposerait de falsifier une vraie release publiée. +# --------------------------------------------------------------------------- +$Repo = if ($env:TYPR_INSTALL_REPO) { $env:TYPR_INSTALL_REPO } else { 'we-data-ch/typr' } +$Origin = if ($env:TYPR_INSTALL_ORIGIN) { $env:TYPR_INSTALL_ORIGIN } else { 'https://github.com' } +$Docs = if ($env:TYPR_INSTALL_DOCS) { $env:TYPR_INSTALL_DOCS } else { 'https://we-data-ch.github.io/typr.github.io' } + +$DownloadBase = "$Origin/$Repo/releases/download" +$LatestUrl = "$Origin/$Repo/releases/latest" +$ReleasesFeed = "$Origin/$Repo/releases.atom" + +$Verify = if ($env:TYPR_INSTALL_VERIFY) { $env:TYPR_INSTALL_VERIFY } else { '1' } +if ($Verify -notin @('0', '1')) { + Write-Error "TYPR_INSTALL_VERIFY doit valoir 0 ou 1 (reçu : '$Verify')." + exit 2 +} + +$script:TempDir = $null + +# `exit` ne passe pas par le `trap`, qui ne voit que les erreurs : le dossier +# temporaire est donc nettoyé explicitement ici. Sans cela, chaque échec laissait +# une archive à moitié téléchargée dans %TEMP%. +function Stop-Install { + param([string] $Message, [int] $Code = 1) + Remove-TempDir + Write-Host "typr-install : $Message" -ForegroundColor Red + exit $Code +} + +function Step { param([string] $Message) Write-Host "→ $Message" } +function Note { param([string] $Message) Write-Host " $Message" } +function Warn { + param([string] $Message) + Write-Host "typr-install : attention : $Message" -ForegroundColor Yellow +} + +function Remove-TempDir { + if ($script:TempDir -and (Test-Path -LiteralPath $script:TempDir)) { + Remove-Item -LiteralPath $script:TempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# Nettoyage du dossier temporaire en cas d'erreur, avec un code de retour non +# nul : sous `irm | iex`, un `exit 0` ferait croire à une installation réussie. +trap { + Remove-TempDir + Write-Error $_ + exit 1 +} + +# --------------------------------------------------------------------------- +# HTTP +# +# `HttpClient` plutôt que `Invoke-WebRequest` : la classe .NET Framework est +# présente à l'identique dans Windows PowerShell 5.1 et PowerShell 7, alors que +# les paramètres des cmdlets ont divergé entre les deux — `-UseBasicParsing` +# n'a pas le même sens partout, et `-MaximumRedirection 0` se comporte +# différemment, ou n'existe pas. Ici il n'y a aucun drapeau à se tromper. +function New-Client { + param([bool] $AllowRedirect = $true) + $handler = [System.Net.Http.HttpClientHandler]::new() + $handler.AllowAutoRedirect = $AllowRedirect + $client = [System.Net.Http.HttpClient]::new($handler) + $client.DefaultRequestHeaders.UserAgent.ParseAdd('typr-install') + return $client +} + +function Get-RedirectLocation { + param([string] $Url) + + # On veut l'en-tête de redirection, pas la destination : c'est lui qui porte + # le tag, et `/releases/latest` exclut les prereleases — exactement la + # sémantique du canal stable, sans dépendre de jq ni de l'API GitHub. + $client = New-Client -AllowRedirect $false + try { + $request = [System.Net.Http.HttpRequestMessage]::new( + [System.Net.Http.HttpMethod]::Head, $Url) + $response = $client.SendAsync($request).GetAwaiter().GetResult() + if ($null -ne $response.Headers.Location) { + return $response.Headers.Location.ToString() + } + return $null + } + catch { + return $null + } + finally { + $client.Dispose() + } +} + +function Get-String { + param([string] $Url) + + $client = New-Client + try { + $response = $client.GetAsync($Url).GetAwaiter().GetResult() + $response.EnsureSuccessStatusCode() | Out-Null + return $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + } + finally { + $client.Dispose() + } +} + +# Renvoie $false au lieu d'appeler Stop-Install : à chaque appelant de dire ce +# que l'absence du fichier signifie. +function Try-Save-File { + param([string] $Url, [string] $Path) + + # On écrit dans un fichier temporaire puis on renomme : si le téléchargement + # est interrompu, il ne reste pas un fichier à moitié écrit sous le nom + # définitif — l'archive est vérifiée avant d'être installée de toute façon. + $partial = "$Path.part" + $client = New-Client + try { + $response = $client.GetAsync($Url).GetAwaiter().GetResult() + $response.EnsureSuccessStatusCode() | Out-Null + + # `$source` et non `$input` : ce dernier est une variable automatique + # de PowerShell (l'ancien `$input` du pipeline), qui peut être réécrite + # par du code tiers chargé avant nous — ou par nous, ce qui ferait lire + # un $null à CopyTo et masquerait le téléchargement en « échec ». + $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $target = [System.IO.File]::Create($partial) + try { $source.CopyTo($target) } + finally { $target.Dispose(); $source.Dispose() } + + if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Force } + Move-Item -LiteralPath $partial -Destination $Path -Force + + # Explicite, et non implicite : une fonction PowerShell renvoie ce que + # son `try` a émis, or un téléchargement réussi n'émet rien. Sans ce + # `return`, Try-Save-File rendrait $null — donc falsy — et l'appelant + # lirait un succès comme un échec. + return $true + } + catch { + if (Test-Path -LiteralPath $partial) { + Remove-Item -LiteralPath $partial -Force -ErrorAction SilentlyContinue + } + return $false + } + finally { + $client.Dispose() + } +} + +function Save-File { + param([string] $Url, [string] $Path) + + if (-not (Try-Save-File $Url $Path)) { + Stop-Install "téléchargement échoué : $Url`n Vérifier le réseau, ou réessayer avec -Version vX.Y.Z." + } +} + +# --------------------------------------------------------------------------- +# Version +# --------------------------------------------------------------------------- +function Resolve-Version { + if ($Version) { + $tag = $Version -replace '^v', '' + return "v$tag" + } + + if ($Channel -eq 'stable') { + $location = Get-RedirectLocation $LatestUrl + if ($location -and $location -match '/tag/(v[^/?#]+)') { return $Matches[1] } + Stop-Install "impossible de déterminer la dernière version stable.`n URL interrogée : $LatestUrl`n Pour épingler une version : -Version vX.Y.Z" + } + + # Le canal beta doit lister les releases et filtrer. Le flux Atom en liste + # les dix dernières, prereleases comprises, et se parse sans dépendance. + # Les suffixes sont ceux que release.yml marque `prerelease`. + $xml = Get-String $ReleasesFeed + $tags = [regex]::Matches($xml, 'Repository/\d+/(v[^<]+)') | + ForEach-Object { $_.Groups[1].Value } + $prerelease = $tags | Where-Object { $_ -match '-(alpha|beta|rc)[.0-9]' } | Select-Object -First 1 + if (-not $prerelease) { + Stop-Install "aucune prerelease trouvée sur $ReleasesFeed.`n Le flux Atom ne liste que les dix dernières releases : si la dernière`n prerelease est plus ancienne, épinglez-la avec -Channel beta -Version vX.Y.Z" + } + return $prerelease +} + +function Assert-Tag { + param([string] $Tag) + if ($Tag -notmatch '^v[0-9]') { + Stop-Install "tag illisible : '$Tag' (attendu : vX.Y.Z)." 2 + } +} + +function Resolve-Target { + # Surcharge de test. `PROCESSOR_ARCHITECTURE` n'existe que sous Windows, donc + # sans cette variable le chemin download → SHA → extraction → PATH ne peut + # être exercé que sur un runner Windows. install.sh n'a pas le même besoin : + # `uname -m` existe partout où le script Unix tourne. + if ($env:TYPR_INSTALL_TARGET) { return $env:TYPR_INSTALL_TARGET } + + # Sous WOW64 (PowerShell 32 bits sur Windows 64), PROCESSOR_ARCHITECTURE + # vaut x86 et PROCESSOR_ARCHITEW6432 porte le vrai. Sur ARM64, on prend le + # binaire ARM : Windows l'émule de toute façon s'il ne l'exécute pas. + $arch = $env:PROCESSOR_ARCHITECTURE + if ($arch -eq 'x86' -and $env:PROCESSOR_ARCHITEW6432) { $arch = $env:PROCESSOR_ARCHITEW6432 } + + switch ($arch) { + 'AMD64' { return 'x86_64-pc-windows-msvc' } + 'ARM64' { return 'aarch64-pc-windows-msvc' } + 'x86' { + Stop-Install "TypR ne publie pas de binaire 32 bits (x86).`n Un Windows 64 bits a normalement PROCESSOR_ARCHITECTURE=AMD64 : si ce`n n'est pas le cas, le PowerShell ouvert est 32 bits — fermez-le et rouvrez-le." + } + default { + Stop-Install "architecture non supportée : '$arch'.`n TypR publie des binaires x86_64 et aarch64 pour Windows." + } + } +} + +function Get-OsName { + if ($env:OS) { return $env:OS } + # `$env:OS` n'existe que sous Windows ; sous PowerShell 7 sur Linux/macOS + # (où le script est testé), on retombe sur la description .NET. + if ([System.Runtime.InteropServices.RuntimeInformation]) { + return [System.Runtime.InteropServices.RuntimeInformation]::OSDescription + } + return 'Windows' +} + +function Resolve-InstallDir { + if ($env:TYPR_INSTALL_DIR) { return $env:TYPR_INSTALL_DIR } + if ($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA 'Programs\typr') } + Stop-Install "LOCALAPPDATA n'est pas défini : impossible de savoir où installer.`n Définissez TYPR_INSTALL_DIR." +} + +# --------------------------------------------------------------------------- +# SHA-256 +# --------------------------------------------------------------------------- +# `Get-FileHash` est présent depuis PowerShell 4.0 : pas de module à charger, +# contrairement à `certutil -hashfile` qui n'est pas garanti sur une image +# serveur minimale. +# +# On isole la seule ligne de checksums.txt qui concerne notre archive, puis on +# compare les empreintes. `sha256sum -c` échouerait ici — l'équivalent Windows +# essaierait de vérifier les sept autres archives de la release, non +# téléchargées, en sortant un code d'erreur alors que la nôtre est valide. +function Test-Checksum { + param([string] $Path, [string] $Name) + + Step 'Vérification du SHA-256' + + $sumsPath = Join-Path $script:TempDir 'checksums.txt' + $sumsUrl = "$DownloadBase/$script:Tag/checksums.txt" + # `Save-File` dirait « téléchargement échoué », ce qui ne dit pas *ce qui* + # manque. Ici la réponse est toujours la même — checksums.txt est introuvable + # ou le réseau est coupé — et l'utilisateur a besoin de la seconde phrase + # pour distinguer une release mal publiée d'un pare-feu. + if (-not (Try-Save-File $sumsUrl $sumsPath)) { + Stop-Install "checksums.txt introuvable pour $($script:Tag).`n URL : $sumsUrl`n Le fichier liste les huit archives de la release ; son absence signifie`n que la release est incomplète. Vérifier aussi le réseau." + } + + # On filtre sur le NOM, pas sur le couple SHA + nom. Filtrer sur les deux + # confondrait deux diagnostics très différents : « ce fichier ne parle pas + # de notre archive » et « la ligne existe mais son empreinte est illisible ». + # Le second est un fichier corrompu, le premier une release mal publiée. + # install.sh procède dans le même ordre, pour que les deux scripts nomment la + # même cause de la même façon. + $line = Get-Content -LiteralPath $sumsPath | + Where-Object { $_ -match ('\s+\*?' + [regex]::Escape($Name) + '$') } | + Select-Object -First 1 + + if (-not $line) { + $received = (Get-Content -LiteralPath $sumsPath) -join "`n" + Stop-Install "checksums.txt de $($script:Tag) ne contient aucune ligne pour $Name.`n Fichier reçu :`n$received" + } + + $expected = ($line.Trim() -split '\s+')[0] + if ($expected -notmatch '^[0-9a-f]{64}$') { + Stop-Install "ligne malformée dans checksums.txt pour $Name :`n $line`n Un SHA-256 fait 64 caractères hexadécimaux ; trouvé '$expected'." + } + + $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($expected -ne $actual) { + Stop-Install "SHA-256 falsifié pour $Name.`n attendu $expected`n obtenu $actual`n L'archive a été supprimée ; rien n'est installé." + } + + Note "SHA-256 correct ($actual)" +} + +# --------------------------------------------------------------------------- +# PATH +# --------------------------------------------------------------------------- +function Test-OnPath { + param([string] $Dir) + + $full = [System.IO.Path]::GetFullPath($Dir).TrimEnd('\') + $separator = [System.IO.Path]::PathSeparator + foreach ($entry in ($env:Path -split [regex]::Escape($separator))) { + if (-not $entry) { continue } + $normalized = [System.IO.Path]::GetFullPath($entry.Trim('"')).TrimEnd('\') + if ($normalized -ieq $full) { return $true } + } + return $false +} + +# Le PATH utilisateur est écrit dans le registre : il ne sera pris en compte +# qu'au prochain démarrage de terminal, ce qu'il faut dire explicitement. On +# met aussi à jour $env:Path de la session courante pour que `typr --version` +# fonctionne sans attendre. +function Add-ToUserPath { + param([string] $Dir) + + $current = [Environment]::GetEnvironmentVariable('Path', 'User') + if ($null -eq $current) { $current = '' } + $separator = [System.IO.Path]::PathSeparator + + foreach ($entry in ($current -split [regex]::Escape($separator))) { + if (-not $entry) { continue } + if ([System.IO.Path]::GetFullPath($entry.Trim('"')).TrimEnd('\') -ieq $Dir.TrimEnd('\')) { + return $false + } + } + + $new = if ([string]::IsNullOrWhiteSpace($current)) { $Dir } else { "$current$separator$Dir" } + try { + [Environment]::SetEnvironmentVariable('Path', $new, 'User') + } + catch { + # Un PATH verrouillé par une stratégie de groupe refuse l'écriture. Ce n'est pas + # fatal : le binaire est installé, seul son accès automatisé manque. + Warn "le PATH utilisateur n'a pas pu être écrit : $($_.Exception.Message)" + Warn "ajoutez manuellement : `$env:Path += ';' + '$Dir'" + return $false + } + return $true +} + +function Test-BinaryStart { + param([string] $Path) + + Step 'Vérification de l''installation' + $output = & $Path '--version' 2>&1 + if ($LASTEXITCODE -eq 0) { + $output | ForEach-Object { Note "$_" } + return + } + + # Le cas mesuré en septembre 2026 : l'installation se termine sans erreur, + # puis `typr --version` échoue. Le dire ici vaut mieux qu'un ticket. + Write-Host '' + Warn 'le binaire est installé mais ne démarre pas.' + Note "Message du binaire : $output" + Note 'Si celui-ci évoque une DLL introuvable (VCRUNTIME140.dll, ucrtbase.dll),' + Note "l'artefact n'a pas été lié statiquement : lire $Docs/install pour le dépannage." + exit 1 +} + +# --------------------------------------------------------------------------- +# Déroulement +# --------------------------------------------------------------------------- +$script:Tag = Resolve-Version +Assert-Tag $script:Tag +$target = Resolve-Target +$installDir = Resolve-InstallDir + +$artifact = "typr-$($script:Tag)-$target.zip" +$url = "$DownloadBase/$($script:Tag)/$artifact" + +Step "TypR $($script:Tag)" +Note "système : $(Get-OsName) ($env:PROCESSOR_ARCHITECTURE)" +Note "cible : $target" +Note "dossier : $installDir" + +if ($Verify -eq '0') { Warn 'vérification SHA-256 désactivée (TYPR_INSTALL_VERIFY=0).' } + +if ($DryRun) { + Step 'Simulation — rien ne sera téléchargé ni écrit' + Note "artefact : $artifact" + Note "url : $url" + Note "sha256 : $DownloadBase/$($script:Tag)/checksums.txt" + exit 0 +} + +if (-not (Test-Path -LiteralPath $installDir)) { + New-Item -ItemType Directory -Path $installDir -Force | Out-Null +} +if (-not (Test-Path -LiteralPath $installDir)) { + Stop-Install "impossible de créer $installDir." +} + +$script:TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("typr-install-" + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $script:TempDir -Force | Out-Null + +Step 'Téléchargement' +$archivePath = Join-Path $script:TempDir $artifact +Save-File $url $archivePath + +if ($Verify -ne '0') { + Test-Checksum $archivePath $artifact +} +else { + Step 'Vérification du SHA-256' + Note 'ignorée.' +} + +Step 'Extraction' +$extractDir = Join-Path $script:TempDir 'extract' +New-Item -ItemType Directory -Path $extractDir -Force | Out-Null +try { + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractDir -Force +} +catch { + Stop-Install "archive illisible : $artifact`n $($_.Exception.Message)" +} + +$exePath = Join-Path $extractDir 'typr.exe' +if (-not (Test-Path -LiteralPath $exePath)) { + Stop-Install "l'archive $artifact ne contient aucun binaire 'typr.exe'." +} + +$targetExe = Join-Path $installDir 'typr.exe' +# Remove-Item puis Copy-Item plutôt que Move-Item : un binaire en cours +# d'exécution sous Windows ne peut pas être écrasé, et l'échec serait +# confondu avec un problème d'écriture. L'opération reste idempotente. +if (Test-Path -LiteralPath $targetExe) { + Remove-Item -LiteralPath $targetExe -Force +} +Copy-Item -LiteralPath $exePath -Destination $targetExe -Force + +Write-Host '' +Step "TypR $($script:Tag) installé dans $installDir" + +if (Test-OnPath $installDir) { + Test-BinaryStart $targetExe +} +else { + if (Add-ToUserPath $installDir) { + $env:Path = "$installDir$([System.IO.Path]::PathSeparator)$env:Path" + Warn "$installDir a été ajouté au PATH utilisateur." + Note 'Il ne sera pris en compte qu''au prochain démarrage d''un terminal.' + Note 'Celui-ci est à jour : `typr --version` fonctionne dès maintenant.' + } + Write-Host '' + Note "Essayez d'abord : $targetExe --version" +} + +Remove-TempDir + +Write-Host '' +Note "Documentation : $Docs" \ No newline at end of file diff --git a/install/install.sh b/install/install.sh new file mode 100755 index 0000000..e59d5d7 --- /dev/null +++ b/install/install.sh @@ -0,0 +1,423 @@ +#!/bin/sh +# TypR — installation en une commande (Linux, macOS). +# +# curl -fsSL https://we-data-ch.github.io/typr.github.io/install/install.sh | sh +# +# POSIX sh de bout en bout : ni bash, ni zsh, ni jq, ni python. Un script +# d'installation qui exige un interpréteur que la machine n'a pas transforme le +# canal principal en ticket. +# +# Sur Linux le script vise les binaires musl, liés statiquement : aucune +# contrainte de glibc, donc le binaire démarre sur Rocky 9 (2.34), Debian 12 +# (2.36), Ubuntu 22.04 (2.35) *et* Alpine. Les binaires GNU ne sont choisis que +# sur demande explicite (--gnu), jamais par défaut — c'était le défaut mesuré sur +# v0.5.12 (`GLIBC_2.39 not found` sur les trois premières distributions). + +set -eu + +# --------------------------------------------------------------------------- +# Configuration +# +# Les trois variables ci-dessous existent pour être surchargées par les tests +# CI, qui servent une arborescence de release locale : sans cela, vérifier le +# refus d'un SHA falsifié exigerait de falsifier une vraie release. +# --------------------------------------------------------------------------- +REPO="${TYPR_INSTALL_REPO:-we-data-ch/typr}" +ORIGIN="${TYPR_INSTALL_ORIGIN:-https://github.com}" +DOCS="${TYPR_INSTALL_DOCS:-https://we-data-ch.github.io/typr.github.io}" + +DOWNLOAD_BASE="$ORIGIN/$REPO/releases/download" +LATEST_URL="$ORIGIN/$REPO/releases/latest" +RELEASES_FEED="$ORIGIN/$REPO/releases.atom" + +CHANNEL="stable" +VERSION="" +LIBC="musl" +VERIFY="${TYPR_INSTALL_VERIFY:-1}" +DRY_RUN="0" +INSTALL_DIR="" + +TMP="" + +# --------------------------------------------------------------------------- +# Sortie +# --------------------------------------------------------------------------- +# Erreur d'environnement ou de vérification : le monde est en cause (1). +die() { + printf 'typr-install : %s\n' "$1" >&2 + exit 1 +} + +# Erreur de saisie : la ligne de commande est en cause (2). La distinction +# permet à un appelant de réagir différemment — et à un test d'affirmer que le +# refus vient bien du script. +usage_die() { + printf 'typr-install : %s\n' "$1" >&2 + printf '\n' >&2 + usage >&2 + exit 2 +} + +step() { printf '→ %s\n' "$*"; } +note() { printf ' %s\n' "$*"; } +warn() { printf 'typr-install : attention : %s\n' "$*" >&2; } + +cleanup() { + if [ -n "$TMP" ] && [ -d "$TMP" ]; then + rm -rf "$TMP" + fi +} + +usage() { + cat <<'EOF' +TypR — installeur Unix (Linux, macOS) + +Usage : + install.sh [options] + +Options : + --version installe ce tag précis (ex. v0.5.12) au lieu du dernier + --channel stable (défaut) ou beta + --gnu Linux : cible glibc dynamique au lieu de musl + --dry-run affiche ce qui serait fait, ne télécharge rien + --help cette aide + +Environnement : + TYPR_INSTALL_DIR dossier d'installation (défaut : $HOME/.local/bin) + TYPR_INSTALL_VERIFY mettre à 0 pour sauter la vérification SHA-256. + Réservé au dépannage réseau ; jamais le mode par défaut. + +Rien n'est écrit hors de $HOME et aucun privilège administrateur n'est requis. +EOF +} + +# --------------------------------------------------------------------------- +# Arguments +# --------------------------------------------------------------------------- +while [ $# -gt 0 ]; do + case "$1" in + --version) + [ $# -ge 2 ] || usage_die "--version attend un tag (ex. --version v0.5.12)." + VERSION="$2" + shift 2 + ;; + --version=*) + VERSION="${1#--version=}" + shift + ;; + --channel) + [ $# -ge 2 ] || usage_die "--channel attend stable ou beta." + CHANNEL="$2" + shift 2 + ;; + --channel=*) + CHANNEL="${1#--channel=}" + shift + ;; + --gnu) + LIBC="gnu" + shift + ;; + --dry-run) + DRY_RUN="1" + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + usage_die "argument inconnu : $1" + ;; + esac +done + +case "$CHANNEL" in + stable|beta) ;; + *) usage_die "canal inconnu : '$CHANNEL' (stable ou beta)." ;; +esac + +case "$VERIFY" in + 0|1) ;; + *) usage_die "TYPR_INSTALL_VERIFY doit valoir 0 ou 1 (reçu : '$VERIFY')." ;; +esac + +command -v curl >/dev/null 2>&1 || die "curl est requis et n'est pas installé." +command -v tar >/dev/null 2>&1 || die "tar est requis et n'est pas installé." + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# --------------------------------------------------------------------------- +# Cible : uname -s / uname -m vers le triple Rust +# --------------------------------------------------------------------------- +case "$(uname -m)" in + x86_64|amd64) ARCH="x86_64" ;; + arm64|aarch64) ARCH="aarch64" ;; + *) + die "architecture non supportée : $(uname -m). + TypR publie des binaires x86_64 et aarch64." + ;; +esac + +case "$(uname -s)" in + Linux) + if [ "$LIBC" = "gnu" ]; then + TARGET="$ARCH-unknown-linux-gnu" + else + TARGET="$ARCH-unknown-linux-musl" + fi + FORMAT="tar.gz" + ;; + Darwin) + TARGET="$ARCH-apple-darwin" + FORMAT="tar.gz" + ;; + *) + die "système non supporté : $(uname -s). + Windows : powershell -c \"irm https://$DOCS/install/install.ps1 | iex\"" + ;; +esac + +# Sous Rosetta, `uname -m` renvoie x86_64 sur une machine Apple Silicon et le +# binaire Intel y fonctionne : aucun cas particulier n'est nécessaire. + +# --------------------------------------------------------------------------- +# Version +# --------------------------------------------------------------------------- +validate_tag() { + case "$1" in + v[0-9]*) return 0 ;; + *) usage_die "tag illisible : '$1' (attendu : vX.Y.Z)." ;; + esac +} + +resolve_version() { + if [ -n "$VERSION" ]; then + case "$VERSION" in + v*) TAG="$VERSION" ;; + *) TAG="v$VERSION" ;; + esac + validate_tag "$TAG" + return 0 + fi + + if [ "$CHANNEL" = "stable" ]; then + # `/releases/latest` redirige vers `/tag/` et exclut les prereleases — + # exactement la sémantique de la branche stable. Lire l'en-tête Location + # évite d'avoir à supposer `jq` ou `python` sur la machine. + LOC=$(curl -fsSI "$LATEST_URL" \ + | tr -d '\r' \ + | grep -i '^location:' \ + | sed 's|.*/tag/||' \ + | head -1 || true) + [ -n "$LOC" ] || die "impossible de déterminer la dernière version stable. + URL interrogée : $LATEST_URL + Pour épingler une version : --version vX.Y.Z" + TAG="$LOC" + else + # Le canal beta doit lister les releases et filtrer. Le flux Atom en liste + # les dix dernières, prereleases comprises, et se parse avec grep et sed — + # même contrainte qu'au-dessus. Les suffixes sont ceux que release.yml + # marque `prerelease`. + TAG=$(curl -fsSL "$RELEASES_FEED" \ + | grep -oE 'Repository/[0-9]+/v[^<]+' \ + | sed 's|.*/||' \ + | grep -E -- '-(alpha|beta|rc)[.0-9]' \ + | head -1 || true) + [ -n "$TAG" ] || die "aucune prerelease trouvée sur $RELEASES_FEED. + Le flux Atom ne liste que les dix dernières releases : si la dernière + prerelease est plus ancienne, épinglez-la avec --channel beta --version vX.Y.Z" + fi + + validate_tag "$TAG" +} + +# --------------------------------------------------------------------------- +# SHA-256 +# --------------------------------------------------------------------------- +# GNU Coreutils expose `sha256sum` ; macOS livre `shasum -a 256`. Les deux +# doivent être tentés, sinon le canal principal échoue sur un tiers des machines. +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | cut -d' ' -f1 + else + die "ni sha256sum ni shasum n'est disponible : le SHA-256 ne peut pas être + vérifié. Relancer avec TYPR_INSTALL_VERIFY=0 pour installer sans vérifier." + fi +} + +verify_download() { + file="$1" + name="$2" + + step "Vérification du SHA-256" + + if ! curl -fsSL "$DOWNLOAD_BASE/$TAG/checksums.txt" -o "$TMP/checksums.txt"; then + die "checksums.txt introuvable pour $TAG. + URL : $DOWNLOAD_BASE/$TAG/checksums.txt" + fi + + # `sha256sum -c checksums.txt` échouerait ici : le fichier liste les huit + # archives de la release, et l'outil tenterait de vérifier les sept que + # nous n'avons pas téléchargées, en sortant un code non nul même quand la + # nôtre est valide. On isole donc la seule ligne qui la concerne, et on + # compare les empreintes nous-mêmes — sans dépendre du format de `-c`. + # + # Les points du nom de fichier sont échappés : sans cela, le motif matche + # n'importe quel caractère à leur place. + PATTERN=$(printf '%s' "$name" | sed 's/[.[\*^$]/\\&/g') + LINE=$(grep -E "[[:space:]]\*?${PATTERN}\$" "$TMP/checksums.txt" || true) + + [ -n "$LINE" ] || die "checksums.txt de $TAG ne contient aucune ligne pour $name. + Fichier reçu : +$(cat "$TMP/checksums.txt")" + + EXPECTED=$(printf '%s\n' "$LINE" | cut -d' ' -f1) + + # Un SHA-256 fait 64 caractères hexadécimaux. Sans ce contrôle, une ligne + # tronquée ou malformée produirait un « SHA-256 falsifié » qui accuse + # l'archive à tort : le message désignerait le mauvais fichier. + printf '%s\n' "$EXPECTED" | grep -Eq '^[0-9a-f]{64}$' \ + || die "ligne malformée dans checksums.txt pour $name : + $LINE + Un SHA-256 fait 64 caractères hexadécimaux." + + ACTUAL=$(sha256_of "$file") + [ "$EXPECTED" = "$ACTUAL" ] || die "SHA-256 falsifié pour $name. + attendu $EXPECTED + obtenu $ACTUAL + L'archive a été supprimée ; rien n'est installé." + + note "SHA-256 correct ($ACTUAL)" +} + +# --------------------------------------------------------------------------- +# Installation +# --------------------------------------------------------------------------- +resolve_install_dir() { + if [ -n "$INSTALL_DIR" ]; then + return 0 + fi + if [ -n "${TYPR_INSTALL_DIR:-}" ]; then + INSTALL_DIR="$TYPR_INSTALL_DIR" + return 0 + fi + [ -n "${HOME:-}" ] || die "HOME n'est pas défini et TYPR_INSTALL_DIR ne l'est pas + non plus : impossible de savoir où installer." + INSTALL_DIR="$HOME/.local/bin" +} + +path_contains() { + # Les entrées vides de PATH (`:$PATH`) ignorées, et un eventual `/` final + # toléré : `~/.local/bin/` et `~/.local/bin` désignent le même dossier. + RESOLVED=$(cd "$1" 2>/dev/null && pwd -P) || return 1 + OLDIFS="$IFS" + IFS=":" + for entry in $PATH; do + [ -n "$entry" ] || continue + NORMALIZED=$(cd "$entry" 2>/dev/null && pwd -P) || continue + if [ "$NORMALIZED" = "$RESOLVED" ]; then + IFS="$OLDIFS" + return 0 + fi + done + IFS="$OLDIFS" + return 1 +} + +warn_about_path() { + warn "$INSTALL_DIR n'est pas dans votre PATH." + note "Ajoutez ceci à ~/.profile (ou ~/.zshrc) :" + note " export PATH=\"$INSTALL_DIR:\$PATH\"" + note "Puis ouvrez un nouveau terminal." +} + +check_binary_starts() { + step "Vérification de l'installation" + if "$INSTALL_DIR/typr" --version; then + return 0 + fi + + # Le cas mesuré en septembre 2026 : l'installation se termine sans erreur, + # puis `typr --version` échoue. Le dire ici vaut mieux qu'un ticket. + printf '\n' >&2 + warn "le binaire est installé mais ne démarre pas." + note "Si le message ci-dessus évoque une version de glibc manquante, le" + note "binaire a été compilé pour une glibc plus récente que celle de cette" + note "machine. Relancer sans --gnu, ou lire $DOCS/install pour le dépannage." + exit 1 +} + +# --------------------------------------------------------------------------- +# Déroulement +# --------------------------------------------------------------------------- +resolve_version +resolve_install_dir + +ARTIFACT="typr-$TAG-$TARGET.$FORMAT" +URL="$DOWNLOAD_BASE/$TAG/$ARTIFACT" + +step "TypR $TAG" +note "système : $(uname -s) $(uname -m)" +note "cible : $TARGET" +note "dossier : $INSTALL_DIR" + +if [ "$VERIFY" = "0" ]; then + warn "vérification SHA-256 désactivée (TYPR_INSTALL_VERIFY=0)." +fi + +if [ "$DRY_RUN" = "1" ]; then + step "Simulation — rien ne sera téléchargé ni écrit" + note "artefact : $ARTIFACT" + note "url : $URL" + note "sha256 : $DOWNLOAD_BASE/$TAG/checksums.txt" + exit 0 +fi + +mkdir -p "$INSTALL_DIR" || die "impossible de créer $INSTALL_DIR. + Vérifier les droits, ou choisir un autre dossier avec TYPR_INSTALL_DIR." + +TMP=$(mktemp -d "${TMPDIR:-/tmp}/typr-install.XXXXXX") \ + || die "impossible de créer un dossier temporaire." + +step "Téléchargement" +curl -fL --progress-bar "$URL" -o "$TMP/$ARTIFACT" \ + || die "téléchargement échoué : $URL + Vérifier le réseau ou réessayer avec --version vX.Y.Z." + +if [ "$VERIFY" != "0" ]; then + verify_download "$TMP/$ARTIFACT" "$ARTIFACT" +else + step "Vérification du SHA-256" + note "ignorée." +fi + +step "Extraction" +mkdir -p "$TMP/extract" +tar -xzf "$TMP/$ARTIFACT" -C "$TMP/extract" \ + || die "archive illisible : $ARTIFACT" +[ -f "$TMP/extract/typr" ] \ + || die "l'archive $ARTIFACT ne contient aucun binaire 'typr'." + +# mv -f rend l'opération idempotente : relancer l'installeur remplace le +# binaire au lieu d'échouer dessus. +mv -f "$TMP/extract/typr" "$INSTALL_DIR/typr" +chmod 755 "$INSTALL_DIR/typr" + +printf '\n' +step "TypR $TAG installé dans $INSTALL_DIR" + +if path_contains "$INSTALL_DIR"; then + check_binary_starts +else + warn_about_path + printf '\n' + note "Essayez d'abord : $INSTALL_DIR/typr --version" +fi + +printf '\n' +note "Documentation : $DOCS" \ No newline at end of file diff --git a/install/tests/Dockerfile b/install/tests/Dockerfile new file mode 100644 index 0000000..2c09e6f --- /dev/null +++ b/install/tests/Dockerfile @@ -0,0 +1,30 @@ +# Image de test pour install.ps1. +# +# Le dépôt ne fournit pas de runner Windows, et `pwsh` sous Linux ne suffit +# pas : Expand-Archive, le registre utilisateur et les chemins %LOCALAPPDATA% +# sont des chemins de code Windows. On teste donc ce qui est testable ici — la +# logique, les diagnostics, les codes de retour — et la CI conserve une matrice +# Windows réelle pour la validation d bout en bout. +# +# Le tag `7.4-ubuntu-22.04` est épinglé, pas `latest` : une mise à jour de +# base ne doit pas pouvoir faire échouer la CI sur un changement de runtime. +FROM mcr.microsoft.com/powershell:7.4-ubuntu-22.04 + +RUN apt-get update \ + && apt-get install --no-install-recommends -y python3 \ + && rm -rf /var/lib/apt/lists/* + +# PSScriptAnalyzer est le équivalent PowerShell de shellcheck : il signale ce +# que l'analyse syntaxique laisse passer (variables inutilisées, pièges de +# comparaison, cmdlets obsolètes). Installé ici pour que la suite puisse le +# lancer sans téléchargement à chaque exécution. +RUN pwsh -NoProfile -Command \ + 'Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -AllowClobber -Repository PSGallery' + +# shellcheck : le même outil est utilisé par la suite Unix, pour que les deux +# halves de la suite d'installation soient vérifiées pareil. +RUN apt-get update \ + && apt-get install --no-install-recommends -y shellcheck \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /w \ No newline at end of file diff --git a/install/tests/PSScriptAnalyzerSettings.psd1 b/install/tests/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..37ad881 --- /dev/null +++ b/install/tests/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,63 @@ +# Règles PSScriptAnalyzer écartées pour install.ps1, et pourquoi. +# +# Ce fichier n'est pas lu automatiquement par PSScriptAnalyzer — voir la note en +# bas. Il documente les exclusions, et run-tests.ps1 en tire la liste exacte. +# +# Une règle écartée sans raison écrite est pire qu'une règle active : elle crée +# un sentiment de couverture qui n'existe pas. + +@{ + # `Try-Save-File` renvoie un booléen. Le verbe approprié serait `Test-`, + # qui dirait « tester si on peut sauvegarder » sans le faire. Le préfixe + # `Try-` est la convention .NET pour « essayer, sinon renvoyer false ». + # + # $Version et $Channel sont déclarés dans le bloc `param` et lus plus bas, + # dans `Resolve-Version`. PSScriptAnalyzer ne résout pas la portée : depuis + # une fonction, il voit le $Version de la fonction, pas celui du script. + # $Gnu est volontairement sans effet sur Windows — il existe pour que la + # même ligne de commande fonctionne sur les deux scripts. + # + # Ces trois paramètres sont couverts par les tests : `-Version` épinglé et + # `-Channel beta` ont chacun leurs propres scénarios. + # + # Stop-Install, Remove-TempDir et New-Client ne sont pas des cmdlets et + # n'ont pas à implémenter -WhatIf. La règle vise les modules distribués, où + # l'appelant doit pouvoir voir l'effet avant de le subir. Ici + # l'installateur est le programme : « installer sans installer » n'a pas de + # sens, et un -WhatIf trompeur serait pire que son absence. + # + # Write-Host est le bon choix, et le seul qui convienne. L'installateur + # écrit son rapport sur le flux de l'hôte, pas dans le pipeline : sous + # `irm … | iex`, un Write-Output de plus se retrouverait dans la valeur de + # retour de l'utilisateur, dans un `$x =`, ou dans une boucle. Write-Host + # va vers le flux d'information, que `2>&1` capture et que `iex` ne pollue + # pas. L'échec passe par Write-Host puis `exit`, jamais par Write-Error : + # sous `iex`, une erreur non terminante est écrite sans rien arrêter. + # La couleur est voulue : c'est un outil de terminal, pas une bibliothèque. + + Excluded = @( + 'PSUseApprovedVerbs' + 'PSReviewUnusedParameter' + 'PSUseShouldProcessForStateChangingFunctions' + 'PSAvoidUsingWriteHost' + ) + + # Règles volontairement NON écartées, malgré leur pénalité : elles ont + # attrapé de vrais défauts dans ce fichier. + # + # PSUseBOMForUnicodeEncodedFile : a signalé l'absence de BOM, donc que le + # fichier aurait été lu en ANSI par PowerShell 5.1 — chaque accent cassé + # sur un Windows français ou allemand. Le BOM est posé depuis, et un test + # explicite le vérifie. + # PSAvoidAssignmentToAutomaticVariable : a signalé `$input`, qui est une + # variable automatique. Réécrite en `$source`. + # PSUseSingularNouns : a signalé Test-BinaryStarts, renommé + # Test-BinaryStart. + # + # Note d'implémentation : `-Settings` avec un dictionnaire est accepté par + # l'API mais silencieusement ignoré dans PSScriptAnalyzer 1.25 — les + # constatations changent d'un appel à l'autre. `-ExcludeRule` est le + # mécanisme vérifié, et c'est lui que la suite utilise. Ce fichier reste la + # source de vérité pour les raisons ; la liste effective y est lue par + # run-tests.ps1. +} \ No newline at end of file diff --git a/install/tests/fixture-server.py b/install/tests/fixture-server.py new file mode 100755 index 0000000..9552832 --- /dev/null +++ b/install/tests/fixture-server.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Sert une release GitHub factice, pour tester install.sh sans réseau. + +L'installeur ne connaît que deux points d'entrée côté release : l'URL +`/releases/latest`, dont il lit l'en-tête `Location`, et `/releases.atom`, qu'il +parse pour le canal beta. Les deux sont servis ici depuis l'arborescence racine +en argument, avec une seule route réélée : + + //releases/LATEST contient le tag vers lequel rediriger + //releases/latest absent : c'est la redirection + //releases.atom le flux + //releases/download//… les artefacts + +Sans ce serveur, vérifier que l'installeur *refuse* un SHA falsifié +supposerait de falsifier une vraie release publiée. + +Usage : + fixture-server.py + → écrit « PORT » sur stdout, puis sert jusqu'à l'arrêt. +""" + +import sys +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class Handler(BaseHTTPRequestHandler): + root = "." + + def log_message(self, *_args): + pass # le test lit la sortie du script, pas les accès + + def resolve(self): + """Renvoie (chemin, est_une_redirection) ou None si 404.""" + path = self.path.split("?", 1)[0].split("#", 1)[0] + while "//" in path: + path = path.replace("//", "/") + parts = [p for p in path.split("/") if p and p != ".."] + + if parts and parts[-1] == "latest": + tag_file = os.path.join(self.root, *(parts[:-1] + ["LATEST"])) + if os.path.isfile(tag_file): + with open(tag_file, encoding="utf-8") as handle: + tag = handle.read().strip() + return ("/".join(parts + ["tag", tag]), True) + + target = os.path.normpath(os.path.join(self.root, *parts)) + if not os.path.abspath(target).startswith(os.path.abspath(self.root)): + return None + if os.path.isfile(target): + return (target, False) + return None + + def do_HEAD(self): + self.respond(body=False) + + def do_GET(self): + self.respond(body=True) + + def respond(self, body): + found = self.resolve() + if found is None: + self.send_error(404, "Not Found") + return + + target, redirect = found + if redirect: + self.send_response(302) + self.send_header("Location", target) + self.send_header("Content-Length", "0") + self.end_headers() + return + + with open(target, "rb") as handle: + payload = handle.read() + self.send_response(200) + self.send_header("Content-Length", str(len(payload))) + self.send_header("Content-Type", "application/octet-stream") + self.end_headers() + if body: + self.wfile.write(payload) + + +def main(): + if len(sys.argv) != 2: + print(__doc__, file=sys.stderr) + return 2 + Handler.root = os.path.abspath(sys.argv[1]) + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + print(f"PORT {server.server_port}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/install/tests/run-tests.ps1 b/install/tests/run-tests.ps1 new file mode 100755 index 0000000..2e78190 --- /dev/null +++ b/install/tests/run-tests.ps1 @@ -0,0 +1,495 @@ +#!/usr/bin/env pwsh +# Tests de install/install.ps1, sans réseau. +# +# Le script ne parle qu'à une release : redirection de `/releases/latest`, flux +# Atom, archives .zip et `checksums.txt`. Le serveur de fixtures (voir +# fixture-server.py) reproduit ces quatre points, ce qui permet de tester le +# chemin heureux *et* les refus — notamment le SHA falsifié, qu'on ne peut pas +# produire sur une vraie release sans la compromettre. +# +# pwsh install/tests/run-tests.ps1 +# +# Le même script est exécuté par le job `install` de ci.yml. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$Here = Split-Path -Parent $MyInvocation.MyCommand.Path +$AnalyzerSettings = Join-Path $PSScriptRoot 'PSScriptAnalyzerSettings.psd1' +$InstallPs1 = Join-Path (Split-Path -Parent $Here) 'install.ps1' +$RepoPath = 'we-data-ch/typr' +$StableTag = 'v9.9.9' +$BetaTag = 'v9.9.9-beta.1' + +$script:Passed = 0 +$script:Failed = 0 +$script:Root = $null +$script:Server = $null + +# --------------------------------------------------------------------------- + +function Ok { + param([string] $Message) + $script:Passed++ + Write-Host " $([char]0x2713) $Message" -ForegroundColor Green +} + +function Ko { + param([string] $Message, [string] $Detail) + $script:Failed++ + Write-Host " $([char]0x2717) $Message" -ForegroundColor Red + if ($Detail) { Write-Host " $Detail" -ForegroundColor Red } +} + +function Check-Rc { + param([int] $Expected, [int] $Actual, [string] $Message) + if ($Expected -eq $Actual) { Ok $Message } + else { Ko $Message "code de retour $Actual, attendu $Expected" } +} + +function Check-Contains { + param([string] $Output, [string] $Needle, [string] $Message) + if ($Output -and $Output.Contains($Needle)) { Ok $Message } + else { Ko $Message "« $Needle » absent de la sortie : $Output" } +} + +function Check-NotContains { + param([string] $Output, [string] $Needle, [string] $Message) + if ($Output -and $Output.Contains($Needle)) { + Ko $Message "« $Needle » présent alors qu'il ne devait pas l'être" + } + else { Ok $Message } +} + +function Check-Path { + param([string] $Path, [bool] $ShouldExist, [string] $Message) + if ([bool](Test-Path -LiteralPath $Path) -eq $ShouldExist) { Ok $Message } + else { Ko $Message "$Path : existence inattendue" } +} + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +# Un « binaire » de trois lignes : le script l'extrait, le copie et l'exécute +# avec `--version`. Assez pour couvrir le chemin complet sans télécharger 11 Mo +# ni dépendre d'une release existante. +function Get-Targets { + # Une archive par cible conceivable : le script résout sa cible à partir de + # l'architecture de la machine, que les fixtures ne peuvent pas faire varier. + return @( + 'x86_64-pc-windows-msvc', 'aarch64-pc-windows-msvc', + 'x86_64-unknown-linux-musl', 'x86_64-apple-darwin' + ) +} + +function New-StubArchive { + param([string] $Dir, [string] $Tag, [string] $Target) + + $name = "typr-$Tag-$Target.zip" + $stage = Join-Path $Dir 'stage' + if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } + New-Item -ItemType Directory -Path $stage -Force | Out-Null + + # Un script bat nommé typr.exe : c'est le seul format que PowerShell peut + # exécuter sous Linux, où la suite tourne. Le script testé s'en fiche — il + # copie et invoque le fichier sans regarder ce qu'il contient. + Set-Content -LiteralPath (Join-Path $stage 'typr.exe') -Encoding Ascii -Value @( + '@echo off', + 'if not "%1"=="--version" exit /b 2', + 'echo typr-cli 0.0.0-fixture' + ) + + # On zippe à la main : Compress-Archive n'est pas disponible sous Linux, et + # System.IO.CompressionZipFile l'est partout. + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zipPath = Join-Path $Dir $name + if (Test-Path $zipPath) { Remove-Item $zipPath -Force } + [System.IO.Compression.ZipFile]::CreateFromDirectory($stage, $zipPath) + + $sha = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + Add-Content -LiteralPath (Join-Path $Dir 'checksums.txt') -Value "$sha $name" + Remove-Item $stage -Recurse -Force +} + +function Set-FakeChecksum { + param([string] $Target) + $dir = Join-Path $script:Root "$RepoPath/releases/download/$StableTag" + $sums = Join-Path $dir 'checksums.txt' + $out = @() + foreach ($line in (Get-Content -LiteralPath $sums)) { + if ($line -match "\s+typr-$StableTag-$([regex]::Escape($Target))\.zip$") { + $out += "0000000000000000000000000000000000000000000000000000000000000000 $($line -split '\s+' | Select-Object -Last 1)" + } + else { + $out += $line + } + } + Set-Content -LiteralPath $sums -Value $out +} + +function Reset-Checksums { + param([string] $Tag, [string] $Target) + $dir = Join-Path $script:Root "$RepoPath/releases/download/$Tag" + Remove-Item (Join-Path $dir 'checksums.txt') -Force -ErrorAction SilentlyContinue + New-StubArchive $dir $Tag $Target +} + +function New-FixtureTree { + $script:Root = Join-Path ([System.IO.Path]::GetTempPath()) ("typr-ps-tests-" + [Guid]::NewGuid().ToString('N')) + $rel = Join-Path $script:Root "$RepoPath/releases" + New-Item -ItemType Directory -Path $rel -Force | Out-Null + Set-Content -LiteralPath (Join-Path $rel 'LATEST') -Value $StableTag + + foreach ($t in (Get-Targets)) { + New-StubArchive (Join-Path $rel "download/$StableTag") $StableTag $t + New-StubArchive (Join-Path $rel "download/$BetaTag") $BetaTag $t + } + + Set-Content -LiteralPath (Join-Path $script:Root "$RepoPath/releases.atom") -Encoding Ascii -Value @( + '', + '', + ' Release notes from typr', + " tag:github.com,2008:https://github.com/$RepoPath/releases", + " tag:github.com,2008:Repository/1/$StableTag", + " tag:github.com,2008:Repository/1/$BetaTag", + ' tag:github.com,2008:Repository/1/v0.1.0', + '' + ) +} + +function Start-FixtureServer { + param([string] $Python) + + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $Python + $psi.ArgumentList.Add((Join-Path $Here 'fixture-server.py')) + $psi.ArgumentList.Add($script:Root) + $psi.RedirectStandardOutput = $true + $psi.UseShellExecute = $false + return [System.Diagnostics.Process]::Start($psi) +} + +# --------------------------------------------------------------------------- +# Lancement de l'installeur +# +# `pwsh -File` dans un processus séparé, et non `& $InstallPs1` : le script +# appelle `exit`, qui dans un appel in-process ne ferait que terminer le script +# appelé — le code de retour se perdrait, et « refus d'un SHA falsifié » et +# « refus d'un tag illisible » rendraient tous deux 0. +function Invoke-Install { + param([string] $InstallDir, [string] $ExtraEnv = '', [string[]] $Options = @('-DryRun')) + + $envArgs = @( + "`$env:TYPR_INSTALL_DIR='$InstallDir'", + "`$env:TYPR_INSTALL_TARGET='x86_64-pc-windows-msvc'", + "`$env:TYPR_INSTALL_ORIGIN='$($script:Origin)'" + ) + if ($ExtraEnv) { $envArgs += $ExtraEnv } + + # `exit $LASTEXITCODE` explicite : `& script.ps1` ne propage pas le code de + # sortie du script appelé vers le processus — sans cette ligne, un refus + # 2 (usage) serait indiscernable d'un refus 1 (environnement). + $command = ($envArgs -join '; ') + "; & '$InstallPs1' " + ($Options -join ' ') + " 2>&1; exit `$LASTEXITCODE" + + $out = & pwsh -NoProfile -Command $command 2>&1 | Out-String + return @($out, $LASTEXITCODE) +} + +# Le chemin réel de l'utilisateur est `irm … | iex`, où le script arrive comme +# texte et non comme fichier. Ce n'est pas la même exécution que -File : on +# vérifie que le texte survit à ce mode — notamment que son bloc `param` est +# accepté par `iex` et que l'échec s'y propère toujours. +# +# `iex` ne prend pas d'argument : les options ne sont pas transmissibles par +# cette voie, ce qui est aussi ce que verra l'utilisateur. Les variables +# d'environnement, elles, le sont. +function Invoke-InstallViaIex { + param([string] $InstallDir, [string] $Origin) + + if (-not $Origin) { $Origin = $script:Origin } + + $command = @( + "`$env:TYPR_INSTALL_DIR='$InstallDir'", + "`$env:TYPR_INSTALL_TARGET='x86_64-pc-windows-msvc'", + "`$env:TYPR_INSTALL_ORIGIN='$Origin'", + "`$src = Get-Content -Raw -LiteralPath '$InstallPs1'", + 'iex $src 2>&1' + ) -join '; ' + + $out = & pwsh -NoProfile -Command $command 2>&1 | Out-String + return @($out, $LASTEXITCODE) +} + +function Work { + param([string] $Name) + $dir = Join-Path $script:Root "work-$Name" + New-Item -ItemType Directory -Path $dir -Force | Out-Null + return $dir +} + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +function Test-Syntax { + Write-Host 'Analyse statique' + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $InstallPs1, [ref]$null, [ref]$errors) | Out-Null + if ($errors -and $errors.Count -gt 0) { + Ko 'install.ps1 — analyse syntaxique' ($errors | ForEach-Object { $_.Message } | Out-String) + } + else { + Ok 'install.ps1 — analyse syntaxique' + } + + # Le BOM n'est pas une coquetterie : PowerShell 5.1 lit un .ps1 sans BOM + # comme de l'ANSI, avec la page de code de la machine. Sur un Windows + # français ou allemand — les deux cibles principales du projet — chaque + # accent de ce fichier deviendrait un caractère illisible. Le BOM est donc + # testé explicitement, sinon un reformatage l'effacerait sans bruit. + $head = [System.IO.File]::ReadAllBytes($InstallPs1)[0..2] + if ($head[0] -eq 0xEF -and $head[1] -eq 0xBB -and $head[2] -eq 0xBF) { + Ok 'install.ps1 — BOM UTF-8 présent' + } + else { + Ko 'install.ps1 — BOM UTF-8 présent' ( + 'sans BOM, PowerShell 5.1 lit le fichier en ANSI et les accents cassent. ' + + "Octets : $($head -join ' ')") + } + + if (-not (Get-Module -ListAvailable PSScriptAnalyzer)) { + Ko 'PSScriptAnalyzer est disponible' 'module absent de l image de test' + return + } +# `-Settings` avec un dictionnaire est accepté par l'API mais + # silencieusement ignoré dans PSScriptAnalyzer 1.25 : le nombre de + # constatations ne change pas, que la liste soit vide ou complète. + # `-ExcludeRule` est le mécanisme vérifié — un fichier jetable contenant + # `Write-Host` passe de 1 constatation à 0 avec, et reste à 1 sans. + # + # La liste vient du fichier de configuration, pour que les raisons soient + # écrites à un seul endroit. + $excluded = (Import-PowerShellDataFile -Path $AnalyzerSettings).Excluded + $findings = Invoke-ScriptAnalyzer -Path $InstallPs1 -ExcludeRule $excluded + if ($findings) { + Ko 'install.ps1 — PSScriptAnalyzer' ( + ($findings | ForEach-Object { + "ligne $($_.Line) [$($_.RuleName)] $($_.Message)" + }) -join "`n") + } + else { + Ok 'install.ps1 — PSScriptAnalyzer' + } +} + +function Test-Iex { + Write-Host 'Mode irm | iex' + # `irm … | iex` est la forme documentée pour Windows : le texte arrive dans + # la portée de l'appelant, pas comme fichier. Deux différences réelles avec + # `pwsh -File` sont vérifiées ici : + # + # — `iex` ne transmet aucun argument. Les options (-DryRun, -Version…) sont + # inaccessibles par cette voie ; seules les variables d'environnement + # passent. Le test ne peut donc pas être « juste une simulation ». + # — un `exit` y termine le processus appelant. C'est voulu pour un + # échec, mais cela signifie que la session interactive est perdue si le + # script échoue. D'où un test d'échec : il doit bien rendre 1, et non + # laisser croire à un succès. + $dir = Work 'iex' + $r = Invoke-InstallViaIex $dir + Check-Rc 0 $r[1] 'iex installe et sort en 0' + Check-Contains $r[0] 'installé dans' 'iex installe réellement' + Check-Path (Join-Path $dir 'typr.exe') $true 'iex pose le binaire' + + $badDir = Work 'iex-bad' + $r = Invoke-InstallViaIex $badDir "$($script:Origin)/inexistant" + Check-Rc 1 $r[1] 'iex propage un échec en 1' + Check-Path (Join-Path $badDir 'typr.exe') $false 'iex en échec n installe rien' +} + +function Test-DryRun { + Write-Host ' -DryRun' + $dir = Work 'dryrun' + $r = Invoke-Install $dir + Check-Rc 0 $r[1] '-DryRun sort en 0' + Check-Contains $r[0] 'Simulation' '-DryRun annonce la simulation' + Check-Contains $r[0] 'windows-msvc' '-DryRun résout une cible Windows' + Check-Contains $r[0] $StableTag '-DryRun résout la dernière version stable' + Check-NotContains $r[0] 'Extraction' '-DryRun n''extrait rien' + $leftover = Get-ChildItem -LiteralPath $dir -ErrorAction SilentlyContinue + if (-not $leftover) { Ok '-DryRun n''écrit rien' } else { Ko '-DryRun n''écrit rien' ($leftover.Name) } +} + +function Test-HappyPath { + Write-Host 'Installation réelle' + $dir = Work 'default' + $r = Invoke-Install $dir @() @() + Check-Rc 0 $r[1] 'installation par défaut' + Check-Contains $r[0] 'SHA-256 correct' 'le SHA-256 est vérifié' + Check-Contains $r[0] $StableTag 'la version installée est annoncée' + if (Test-Path (Join-Path $dir 'typr.exe')) { Ok 'le binaire est installé' } + else { Ko 'le binaire est installé' $r[0] } + + # Idempotence : relancer ne doit ni échouer ni laisser de doublon. + $r = Invoke-Install $dir @() @() + Check-Rc 0 $r[1] 'seconde installation (idempotence)' + if (Test-Path (Join-Path $dir 'typr.exe')) { Ok 'le binaire survit à la réinstallation' } + else { Ko 'le binaire survit à la réinstallation' } +} + +function Test-PinnedVersion { + Write-Host 'Version épinglée' + $r = Invoke-Install (Work 'pinned') @() @('-DryRun', '-Version', $StableTag) + Check-Rc 0 $r[1] '-Version sort en 0' + Check-Contains $r[0] $StableTag '-Version est respecté' + + # Sans le « v » initial, comme dans le README. + $r = Invoke-Install (Work 'pinned2') @() @('-DryRun', '-Version', $StableTag.TrimStart('v')) + Check-Contains $r[0] $StableTag '-Version sans le v initial est normalisé' + + $r = Invoke-Install (Work 'pinned3') @() @('-DryRun', '-Version', 'pas-un-tag') + Check-Rc 2 $r[1] 'un tag illisible sort en 2' + Check-Contains $r[0] 'tag illisible' 'le tag illisible est nommé' +} + +function Test-BetaChannel { + Write-Host 'Canal beta' + $r = Invoke-Install (Work 'beta') @() @('-DryRun', '-Channel', 'beta') + Check-Rc 0 $r[1] '-Channel beta résout une prerelease' + Check-Contains $r[0] $BetaTag '-Channel beta choisit la prerelease, pas la stable' + + $r = Invoke-Install (Work 'beta2') @() @('-Channel', 'beta') + Check-Rc 0 $r[1] 'installation depuis le canal beta' + if (Test-Path (Join-Path $script:Root 'work-beta2/typr.exe')) { Ok 'le binaire beta est installé' } + else { Ko 'le binaire beta est installé' $r[0] } +} + +function Test-BadChecksum { + Write-Host 'SHA-256 falsifié' + Set-FakeChecksum 'x86_64-pc-windows-msvc' + $dir = Work 'badsha' + $r = Invoke-Install $dir @() @() + Check-Rc 1 $r[1] 'le script refuse un SHA falsifié' + Check-Contains $r[0] 'SHA-256 falsifié' 'le message nomme la cause' + Check-Contains $r[0] "rien n'est installé" 'le message dit que rien n''est installé' + if (-not (Test-Path (Join-Path $dir 'typr.exe'))) { Ok 'aucun binaire n''est laissé sur le disque' } + else { Ko 'aucun binaire n''est laissé sur le disque' } + + Reset-Checksums $StableTag 'x86_64-pc-windows-msvc' +} + +function Test-BadChecksumBypass { + Write-Host "TYPR_INSTALL_VERIFY=0" + Set-FakeChecksum 'x86_64-pc-windows-msvc' + $dir = Work 'nosha' + $r = Invoke-Install $dir "`$env:TYPR_INSTALL_VERIFY='0'" @() + Check-Rc 0 $r[1] "TYPR_INSTALL_VERIFY=0 installe sans vérifier" + Check-Contains $r[0] 'désactivée' "TYPR_INSTALL_VERIFY=0 est annoncé" + Reset-Checksums $StableTag 'x86_64-pc-windows-msvc' +} + +function Test-MissingChecksums { + Write-Host 'checksums.txt absent ou malformé' + $dir = Join-Path $script:Root "$RepoPath/releases/download/$StableTag" + + # Absent + $saved = Get-Content -LiteralPath (Join-Path $dir 'checksums.txt') + Remove-Item (Join-Path $dir 'checksums.txt') -Force + $inst = Work 'nosum' + $r = Invoke-Install $inst @() @() + Check-Rc 1 $r[1] 'checksums.txt absent → échec' + Check-Contains $r[0] 'checksums.txt introuvable' "l'absence est nommée explicitement" + if (-not (Test-Path (Join-Path $inst 'typr.exe'))) { Ok "rien n'est installé sans checksums.txt" } + else { Ko "rien n'est installé sans checksums.txt" } + Set-Content -LiteralPath (Join-Path $dir 'checksums.txt') -Value $saved + + # Présent mais sans la ligne de cet artefact : c'est le cas de + # `sha256sum -c`, qui tente de vérifier les sept autres archives. + $out = @($saved | Where-Object { $_ -notmatch 'windows-msvc\.zip$' }) + $out += "1111111111111111111111111111111111111111111111111111111111111111 typr-$StableTag-x86_64-pc-windows-msvc.zip.exe" + Set-Content -LiteralPath (Join-Path $dir 'checksums.txt') -Value $out + $inst = Work 'nosum2' + $r = Invoke-Install $inst @() @() + Check-Rc 1 $r[1] 'checksums.txt sans la ligne attendue → échec' + Check-Contains $r[0] 'ne contient aucune ligne pour' 'la ligne manquante est nommée' + if (-not (Test-Path (Join-Path $inst 'typr.exe'))) { Ok "rien n'est installé sans ligne correspondante" } + else { Ko "rien n'est installé sans ligne correspondante" } + + # Malformé : la ligne existe, le SHA est illisible. + Set-Content -LiteralPath (Join-Path $dir 'checksums.txt') ` + -Value "pas-un-sha typr-$StableTag-x86_64-pc-windows-msvc.zip" + $inst = Work 'badline' + $r = Invoke-Install $inst @() @() + Check-Rc 1 $r[1] 'checksums.txt malformé → échec' + Check-Contains $r[0] 'malform' 'la malformation est nommée' + if (-not (Test-Path (Join-Path $inst 'typr.exe'))) { Ok "rien n'est installé sur une ligne malformée" } + else { Ko "rien n'est installé sur une ligne malformée" } + + Reset-Checksums $StableTag 'x86_64-pc-windows-msvc' +} + +function Test-MissingRelease { + Write-Host 'Release absente' + $r = Invoke-Install (Work 'ghost') @() @('-Version', 'v0.0.1-does-not-exist') + Check-Rc 1 $r[1] 'un tag inexistant échoue' + Check-Contains $r[0] 'téléchargement échoué' "l'échec de téléchargement est explicite" +} + +function Test-ContainedWrites { + Write-Host 'Écritures contenues' + $dir = Work 'contained' + $r = Invoke-Install $dir @() @() + Check-Rc 0 $r[1] 'installation avec un dossier explicite' + $stray = Get-ChildItem -LiteralPath $dir | Where-Object { $_.Name -ne 'typr.exe' } + if (-not $stray) { Ok "le dossier d'installation ne contient que le binaire" } + else { Ko "le dossier d'installation ne contient que le binaire" ($stray.Name -join ', ') } + + $leftovers = Get-ChildItem -LiteralPath ([System.IO.Path]::GetTempPath()) -Filter 'typr-install-*' -ErrorAction SilentlyContinue + if (-not $leftovers) { Ok 'aucun dossier temporaire laissé derrière' } + else { Ko 'aucun dossier temporaire laissé derrière' ($leftovers.Name -join ', ') } +} + +# --------------------------------------------------------------------------- + +function Main { + Write-Host 'install.ps1 — tests' + Write-Host '' + + $python = (Get-Command python3 -ErrorAction SilentlyContinue) + if (-not $python) { Write-Error 'python3 est requis pour le serveur de fixtures.'; exit 1 } + +Test-Syntax + + # Le port est choisi par le serveur ; on le récupère en lisant sa sortie. + New-FixtureTree + $script:Server = Start-FixtureServer $python.Source + + $portLine = $script:Server.StandardOutput.ReadLine() + if ($portLine -notmatch 'PORT (\d+)') { Write-Error "le serveur n'a pas démarré : $portLine"; exit 1 } + $script:Origin = "http://127.0.0.1:$($Matches[1])" + + try { + Test-Iex + Test-DryRun + Test-HappyPath + Test-PinnedVersion + Test-BetaChannel + Test-BadChecksum + Test-BadChecksumBypass + Test-MissingChecksums + Test-MissingRelease + Test-ContainedWrites + } + finally { + if ($script:Server -and -not $script:Server.HasExited) { $script:Server.Kill() } + Remove-Item -LiteralPath $script:Root -Recurse -Force -ErrorAction SilentlyContinue + } + + Write-Host '' + Write-Host "$($script:Passed) réussis, $($script:Failed) échoués" + if ($script:Failed -gt 0) { exit 1 } +} + +Main \ No newline at end of file diff --git a/install/tests/run-tests.sh b/install/tests/run-tests.sh new file mode 100755 index 0000000..f3ce593 --- /dev/null +++ b/install/tests/run-tests.sh @@ -0,0 +1,451 @@ +#!/usr/bin/env bash +# Tests de install/install.sh, sans réseau. +# +# L'installeur ne parle qu'à une release : URL de redirection `/releases/latest`, +# flux Atom, artefacts et `checksums.txt`. Le serveur de fixtures reproduit ces +# quatre points, ce qui permet de tester le chemin heureux *et* les refus — +# notamment le SHA falsifié, qu'on ne peut pas produire sur une vraie release +# sans la compromettre. +# +# install/tests/run-tests.sh +# +# Le même script est exécuté par le job `install` de ci.yml. + +set -uo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +INSTALL_SH="$HERE/../install.sh" +REPO_PATH="we-data-ch/typr" +STABLE_TAG="v9.9.9" +BETA_TAG="v9.9.9-beta.1" + +PASSED=0 +FAILED=0 +ROOT="" +SERVER_PID="" + +# --------------------------------------------------------------------------- + +cleanup() { + [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null + [ -n "$ROOT" ] && rm -rf "$ROOT" + return 0 +} +trap cleanup EXIT + +ok() { PASSED=$((PASSED + 1)); printf ' \033[32m✓\033[0m %s\n' "$1"; } +ko() { FAILED=$((FAILED + 1)); printf ' \033[31m✗\033[0m %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; return 0; } + +check_rc() { # check_rc + if [ "$1" -eq "$2" ]; then ok "$3"; else ko "$3" "code de retour $2, attendu $1"; fi +} + +check_contains() { # check_contains + case "$1" in + *"$2"*) ok "$3" ;; + *) ko "$3" "« $2 » absent de la sortie : $1" ;; + esac +} + +check_not_contains() { + case "$1" in + *"$2"*) ko "$3" "« $2 » présent alors qu'il ne devait pas l'être" ;; + *) ok "$3" ;; + esac +} + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- +# Un « binaire » de trois lignes : l'installeur l'extrait, le rend exécutable et +# l'exécute avec `--version`. Assez pour couvrir le chemin complet sans +# télécharger 11 Mo ni dépendre d'une release existante. +build_binary() { + cat > "$1" <<'EOF' +#!/bin/sh +[ "${1:-}" = "--version" ] || exit 2 +echo "typr-cli 0.0.0-fixture" +EOF + chmod 755 "$1" +} + +# Construit une archive et *ajoute* sa ligne à checksums.txt sans l'écraser : +# une vraie release liste les huit archives, et c'est précisément ce qui fait +# échouer `sha256sum -c` quand on n'en a téléchargé qu'une. +make_release() { # make_release [bad] + local tag="$1" target="$2" bad="${3:-}" + local dir="$ROOT/$REPO_PATH/releases/download/$tag" + local name="typr-$tag-$target.tar.gz" + mkdir -p "$dir" "$ROOT/stage" + + build_binary "$ROOT/stage/typr" + tar -czf "$dir/$name" -C "$ROOT/stage" typr + + local sha + sha=$(sha256sum "$dir/$name" | cut -d' ' -f1) + [ "$bad" = "bad" ] && sha="0000000000000000000000000000000000000000000000000000000000000000" + printf '%s %s\n' "$sha" "$name" >> "$dir/checksums.txt" +} + +# Réécrit checksums.txt depuis les archives déjà présentes, en falsifiant +# éventuellement l'empreinte d'une seule. Les archives ne bougent pas entre deux +# appels, donc leur SHA est stable. +rewrite_checksums() { # rewrite_checksums [cible-à-falsifier] + local rel="$ROOT/$REPO_PATH/releases/download/$STABLE_TAG" + local bad="${1:-}" name sha + : > "$rel/checksums.txt" + for name in "$rel"/*.tar.gz; do + sha=$(sha256sum "$name" | cut -d' ' -f1) + if [ -n "$bad" ] && [ "${name##*/}" = "typr-$STABLE_TAG-$bad.tar.gz" ]; then + sha="0000000000000000000000000000000000000000000000000000000000000000" + fi + printf '%s %s\n' "$sha" "${name##*/}" >> "$rel/checksums.txt" + done +} + +make_origin() { + ROOT=$(mktemp -d) + local rel="$ROOT/$REPO_PATH/releases" + + mkdir -p "$rel" + printf '%s\n' "$STABLE_TAG" > "$rel/LATEST" + + for target in \ + x86_64-unknown-linux-musl aarch64-unknown-linux-musl \ + x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu \ + x86_64-apple-darwin aarch64-apple-darwin; do + make_release "$STABLE_TAG" "$target" + done + + # Release beta : plus récente que la stable dans le flux, donc celle que + # `--channel beta` doit choisir. + make_release "$BETA_TAG" "x86_64-unknown-linux-musl" + make_release "$BETA_TAG" "x86_64-unknown-linux-gnu" + + cat > "$ROOT/$REPO_PATH/releases.atom" < + + Release notes from typr + tag:github.com,2008:https://github.com/$REPO_PATH/releases + + tag:github.com,2008:Repository/1/$STABLE_TAG + $STABLE_TAG + + + tag:github.com,2008:Repository/1/$BETA_TAG + $BETA_TAG + + + tag:github.com,2008:Repository/1/v0.1.0 + v0.1.0 + + +EOF + + python3 "$HERE/fixture-server.py" "$ROOT" > "$ROOT/.port" & + SERVER_PID=$! + + local port="" + for _ in $(seq 1 50); do + port=$(grep -oE 'PORT [0-9]+' "$ROOT/.port" 2>/dev/null | cut -d' ' -f2) + [ -n "$port" ] && break + sleep 0.1 + done + [ -n "$port" ] || { echo "le serveur de fixtures n'a pas démarré" >&2; exit 1; } + + export TYPR_INSTALL_ORIGIN="http://127.0.0.1:$port" +} + +# --------------------------------------------------------------------------- +# Lancement de l'installeur +# --------------------------------------------------------------------------- +run() { # run + local dir="$1"; shift + TYPR_INSTALL_DIR="$dir" "$INSTALL_SH" "$@" 2>&1 +} + +# Un dossier de travail sous $ROOT, donc nettoyé avec le reste. +work() { + local dir + dir="$ROOT/work-$(printf '%s' "$1" | tr -c 'a-zA-Z0-9' -)" + mkdir -p "$dir" + printf '%s' "$dir" +} + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- +test_static_analysis() { + echo "Analyse statique" + + # `ok` et `ko` sortent toujours en 0, donc le `||` ne se déclenche jamais à + # tort : SC2015 signalerait ici un if-then-else qui n'existe pas. + # shellcheck disable=SC2015 + sh -n "$INSTALL_SH" 2>&1 && ok "sh -n" || ko "sh -n" + # shellcheck disable=SC2015 + bash -n "$INSTALL_SH" 2>&1 && ok "bash -n" || ko "bash -n" + + if command -v dash >/dev/null 2>&1; then + # shellcheck disable=SC2015 + dash -n "$INSTALL_SH" 2>&1 && ok "dash -n (POSIX strict)" || ko "dash -n" + else + printf ' – dash absent, POSIX strict non vérifié\n' + fi + + if command -v shellcheck >/dev/null 2>&1; then + # Aucune exclusion : install.sh ne se sert ni de `local` (non POSIX) ni + # d'aucun attribut bash. Le dialecte `sh` est vérifié, pas seulement la + # syntaxe. + if out=$(shellcheck -s sh "$INSTALL_SH" 2>&1); then + ok "shellcheck" + else + ko "shellcheck" "$out" + fi + else + printf ' – shellcheck absent, non exécuté\n' + fi + + if command -v pwsh >/dev/null 2>&1; then + if out=$(pwsh -NoProfile -Command " + \$e = \$null + [System.Management.Automation.Language.Parser]::ParseFile( + '$HERE/../install.ps1', [ref]\$null, [ref]\$e) + if (\$e.Count -gt 0) { \$e | ForEach-Object { \$_.Message }; exit 1 }"); then + ok "install.ps1 — analyse syntaxique PowerShell" + else + ko "install.ps1 — analyse syntaxique PowerShell" "$out" + fi + else + printf ' – pwsh absent, install.ps1 non analysé\n' + fi +} + +test_help() { + echo " --help et arguments" + local out + out=$(run "$(work help)" --help) + check_rc 0 $? "--help sort en 0" + check_contains "$out" "Usage" "--help affiche l'usage" + check_contains "$out" "--dry-run" "--help documente --dry-run" + + out=$(run "$(work noarg)" --bogus) + check_rc 2 $? "un argument inconnu sort en 2" + check_contains "$out" "argument inconnu" "un argument inconnu est nommé" +} + +test_dry_run() { + echo " --dry-run" + local dir out + dir=$(work dryrun) + out=$(run "$dir" --dry-run) + check_rc 0 $? "--dry-run sort en 0" + check_contains "$out" "Simulation" "--dry-run annonce la simulation" + check_contains "$out" "musl" "--dry-run vise musl par défaut sur Linux" + check_contains "$out" "$STABLE_TAG" "--dry-run résout la dernière version stable" + check_not_contains "$out" "Téléchargement" "--dry-run ne télécharge rien" + if [ -z "$(ls -A "$dir")" ]; then ok "--dry-run n'écrit rien" + else ko "--dry-run n'écrit rien" "contenu : $(ls -A "$dir")"; fi +} + +test_happy_path() { + echo "Installation réelle" + local dir out + dir="$ROOT/inst-default" + out=$(run "$dir") + check_rc 0 $? "installation par défaut" + check_contains "$out" "SHA-256 correct" "le SHA-256 est vérifié" + check_contains "$out" "$STABLE_TAG" "la version installée est annoncée" + check_contains "$out" "n'est pas dans votre PATH" "un dossier hors PATH avertit sans échouer" + if [ -x "$dir/typr" ]; then ok "le binaire est installé et exécutable" + else ko "le binaire est installé et exécutable" "$dir/typr absent ou non exécutable"; fi + + out=$("$dir/typr" --version 2>&1) + check_rc 0 $? "typr --version répond après installation" + + # Idempotence : relancer ne doit ni échouer ni laisser de fichier temporaire. + out=$(run "$dir") + check_rc 0 $? "seconde installation (idempotence)" + if [ -x "$dir/typr" ]; then ok "le binaire survit à la réinstallation" + else ko "le binaire survit à la réinstallation"; fi + + # --gnu : la cible glibc dynamique, que le script ne doit choisir que sur + # demande explicite. + dir="$ROOT/inst-gnu" + out=$(run "$dir" --gnu) + check_rc 0 $? "installation --gnu" + check_contains "$out" "linux-gnu" "--gnu cible bien unknown-linux-gnu" + if [ -x "$dir/typr" ]; then ok "--gnu installe le binaire" + else ko "--gnu installe le binaire"; fi + + # Dossier d'installation imbriqué absent : il doit être créé. + dir="$ROOT/inst-nested/bin" + out=$(run "$dir") + check_rc 0 $? "dossier imbriqué absent créé" + if [ -x "$dir/typr" ]; then ok "le dossier imbriqué contient le binaire" + else ko "le dossier imbriqué contient le binaire"; fi +} + +test_pinned_version() { + echo "Version épinglée" + local dir out + dir="$ROOT/inst-pinned" + out=$(run "$dir" --dry-run --version "$STABLE_TAG") + check_rc 0 $? "--version sort en 0" + check_contains "$out" "$STABLE_TAG" "--version est respecté" + + # Sans le « v », l'installeur doit le remettre : c'est la forme que la + # documentation affiche. + out=$(run "$(work pinned)" --dry-run --version "${STABLE_TAG#v}") + check_contains "$out" "$STABLE_TAG" "--version sans le v initial est normalisé" +} + +test_beta_channel() { + echo "Canal beta" + local dir out + dir="$ROOT/inst-beta" + out=$(run "$dir" --dry-run --channel beta) + check_rc 0 $? "--channel beta résout une prerelease" + check_contains "$out" "$BETA_TAG" "--channel beta choisit la prerelease, pas la stable" + + out=$(run "$dir" --channel beta) + check_rc 0 $? "installation depuis le canal beta" + if [ -x "$dir/typr" ]; then ok "le binaire beta est installé" + else ko "le binaire beta est installé"; fi +} + +test_bad_checksum() { + echo "SHA-256 falsifié" + local dir out target + rewrite_checksums x86_64-unknown-linux-musl + + dir="$ROOT/inst-badsha" + out=$(run "$dir") + check_rc 1 $? "le script refuse un SHA falsifié" + check_contains "$out" "SHA-256 falsifié" "le message nomme la cause" + check_contains "$out" "rien n'est installé" "le message dit que rien n'est installé" + if [ ! -e "$dir/typr" ]; then ok "aucun binaire n'est laissé sur le disque" + else ko "aucun binaire n'est laissé sur le disque" "$dir/typr présent"; fi + + # La variable d'échappement doit exister, sans être le mode par défaut. + out=$(TYPR_INSTALL_VERIFY=0 run "$dir") + check_rc 0 $? "TYPR_INSTALL_VERIFY=0 installe sans vérifier" + check_contains "$out" "désactivée" "TYPR_INSTALL_VERIFY=0 est annoncé" + rm -rf "$dir" + + rewrite_checksums +} + +test_missing_checksums() { + echo "checksums.txt absent ou malformé" + local dir out rel + rel="$ROOT/$REPO_PATH/releases/download" + + # Absent + rm -f "$rel/$STABLE_TAG/checksums.txt" + dir="$ROOT/inst-nosum" + out=$(run "$dir") + check_rc 1 $? "checksums.txt absent → échec" + check_contains "$out" "checksums.txt introuvable" "l'absence est nommée explicitement" + check_not_contains "$out" "FAILED open or read" "le message n'est pas celui de sha256sum -c" + if [ ! -e "$dir/typr" ]; then ok "rien n'est installé sans checksums.txt" + else ko "rien n'est installé sans checksums.txt"; fi + + # Présent mais sans la ligne de cet artefact : c'est le cas de `sha256sum -c`, + # qui tente de vérifier les sept autres archives et sort en erreur alors que + # la nôtre est valide. + cat > "$rel/$STABLE_TAG/checksums.txt" <<'EOF' +1111111111111111111111111111111111111111111111111111111111111111 typr-v9.9.9-aarch64-apple-darwin.tar.gz +2222222222222222222222222222222222222222222222222222222222222222 typr-v9.9.9-x86_64-pc-windows-msvc.zip +EOF + dir="$ROOT/inst-nosum2" + out=$(run "$dir") + check_rc 1 $? "checksums.txt sans la ligne attendue → échec" + check_contains "$out" "ne contient aucune ligne pour" "la ligne manquante est nommée" + if [ ! -e "$dir/typr" ]; then ok "rien n'est installé sans ligne correspondante" + else ko "rien n'est installé sans ligne correspondante"; fi + + # Malformé : la ligne existe mais le SHA est illisible. + printf 'pas-un-sha typr-v9.9.9-x86_64-unknown-linux-musl.tar.gz\n' \ + > "$rel/$STABLE_TAG/checksums.txt" + dir="$ROOT/inst-badline" + out=$(run "$dir") + check_rc 1 $? "checksums.txt malformé → échec" + check_contains "$out" "malform" "la malformation est nommée" + if [ ! -e "$dir/typr" ]; then ok "rien n'est installé sur une ligne malformée" + else ko "rien n'est installé sur une ligne malformée"; fi + + # Restitué pour les tests suivants. + rewrite_checksums +} + +test_missing_release() { + echo "Release absente" + local out + out=$(run "$(work ghost)" --version v0.0.1-does-not-exist) + check_rc 1 $? "un tag inexistant échoue" + check_contains "$out" "téléchargement échoué" "l'echec de téléchargement est explicite" +} + +test_path_present() { + echo "Dossier déjà dans le PATH" + local dir out + dir="$ROOT/inst-onpath" + out=$(PATH="$dir:$PATH" run "$dir") + check_rc 0 $? "installation avec le dossier dans le PATH" + check_not_contains "$out" "n'est pas dans votre PATH" "aucun avertissement PATH inutile" + check_contains "$out" "Vérification de l'installation" "le binaire est exécuté dans la foulée" +} + +# §3.8 du plan : le job `coherence` interdit les binaires commités. Le script +# ne doit écrire que dans TYPR_INSTALL_DIR — jamais dans l'arbre du dépôt, et +# jamais ailleurs que dans le dossier qu'on lui a donné. +test_contained_writes() { + echo "Écritures contenues" + local dir out stray + dir="$(work contained)" + + # HOME redirigé, TYPR_INSTALL_DIR absent : le dossier par défaut doit être + # `$HOME/.local/bin`, et rien d'autre ne doit être écrit. + out=$(env -u TYPR_INSTALL_DIR HOME="$ROOT/fakehome" PATH="$dir:$PATH" "$INSTALL_SH" 2>&1) + check_rc 0 $? "installation avec HOME redirigé et TYPR_INSTALL_DIR absent" + if [ -x "$ROOT/fakehome/.local/bin/typr" ]; then + ok "le dossier par défaut est \$HOME/.local/bin" + else + ko "le dossier par défaut est \$HOME/.local/bin" "$out" + fi + + stray=$(find "$ROOT/fakehome" -type f ! -path "$ROOT/fakehome/.local/bin/typr" 2>/dev/null) + if [ -z "$stray" ]; then ok "aucun fichier écrit hors du dossier d'installation" + else ko "aucun fichier écrit hors du dossier d'installation" "$stray"; fi + + # Le dossier temporaire doit être nettoyé : un binaire partiellement + # téléchargé qui traînerait là n'est pas installé, mais traîne. + local leftovers + leftovers=$(find "${TMPDIR:-/tmp}" -maxdepth 1 -name 'typr-install.*' 2>/dev/null) + if [ -z "$leftovers" ]; then ok "aucun dossier temporaire laissé derrière" + else ko "aucun dossier temporaire laissé derrière" "$leftovers"; fi +} + +# --------------------------------------------------------------------------- + +main() { + printf 'install.sh — tests\n\n' + make_origin + + test_static_analysis + test_help + test_dry_run + test_happy_path + test_pinned_version + test_beta_channel + test_bad_checksum + test_missing_checksums + test_missing_release + test_path_present + test_contained_writes + + printf '\n%d réussis, %d échoués\n' "$PASSED" "$FAILED" + [ "$FAILED" -eq 0 ] +} + +main "$@" \ No newline at end of file From 70b238dfdbc1f925554d8ef0cfd32a6d0138f6b9 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 14:33:56 +0200 Subject: [PATCH 40/52] install : jobs de CI et avancement du plan (lot 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois jobs pour garder les scripts d installation honnêtes. install lance shellcheck sur les deux scripts shell, dash -n et bash -n, puis les deux suites de tests. install-windows exécute install.ps1 sous Windows PowerShell 5.1 — le runtime le plus ancien encore présent sur une machine de bureau, et celui que les tests sous Linux ne l'exercent pas. install-hosting copie les deux scripts dans typr.github.io/static/install/, sur push vers main seulement : une PR ne doit pas laisser une page d installation qui peut mentir. Le job hosting pousse par commit normal plutôt que par contournement d une branche protégée. Mieux vaut le voir échouer bruyamment qu écraser une protection posée par un humain. install_plan.md passe le lot 1 à « fait » et consigne les cinq défauts que les tests ont tirés, avec la raison de chacun. --- .github/workflows/ci.yml | 138 +++++++++++++++++++++++++++++++++++++++ install_plan.md | 50 +++++++++++++- 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d182a7..b94cda0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,3 +208,141 @@ jobs: echo '```' >> "$GITHUB_STEP_SUMMARY" tail -5 case-run.log >> "$GITHUB_STEP_SUMMARY" echo '```' >> "$GITHUB_STEP_SUMMARY" + + install: + name: Install scripts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + # La suite Unix tourne nativement : `sh` et `dash` existent sur le runner, + # et c'est `sh` qui interprète le script chez la plupart des utilisateurs. + # Le conteneur ne servirait qu'à re-vérifier les mêmes interpréteurs avec + # un système de fichiers différent — ce que fait déjà le job `test` pour + # le compilateur. + - name: Installer shellcheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + + - name: install.sh — shellcheck (script) + run: shellcheck -s sh install/install.sh + + - name: install.sh — shellcheck (tests) + run: shellcheck -s bash install/tests/run-tests.sh + + - name: install.sh — analyse syntaxique + run: | + # `sh -n` avec le dash du runner : c'est l'interpréteur le plus + # strict, il refuse les extensions de bash que `sh -n` tolère. + dash -n install/install.sh + bash -n install/install.sh + + - name: install.sh — suite de tests + run: bash install/tests/run-tests.sh + + # install.ps1 est une suite séparée : elle a besoin de PowerShell, de + # PSScriptAnalyzer et d'un serveur de fixtures en Python. Le conteneur + # est construit depuis install/tests/Dockerfile, épinglé sur un tag + # PowerShell précis — `latest` ferait échouer la CI au jour où la base + # change, sans qu'aucune modification du dépôt soit en cause. + - name: install.ps1 — build de l image de test + run: docker build -t typr-install-tests -f install/tests/Dockerfile . + + - name: install.ps1 — suite de tests + run: docker run --rm -v "$PWD:/w" -w /w typr-install-tests \ + pwsh -NoProfile -File install/tests/run-tests.ps1 + + # Les deux suites précédentes tournent sous Linux : ni le registre Windows, + # ni %LOCALAPPDATA%, ni Expand-Archive ne sont réellement exercés. Ce job + # attrape ce que Linux ne peut pas voir. + # + # Il ne remplace pas la preuve qu'un binaire y démarre : le runner n'a pas de + # VC++ Redistributable, et la fixture n'est de toute façon pas un exécutable + # Windows. La validation de bout en bout se fait sur une machine réelle, avec + # une vraie release publiée. + install-windows: + name: Install scripts (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + + - name: Le script s'exécute et distingue ses deux codes d'échec + shell: pwsh + run: | + # Rien n'est installé : -DryRun ne télécharge rien, et le ORIGIN + # explicite évite de dépendre du réseau. On vérifie surtout que le + # script tourne sous Windows PowerShell 5.1 — le runtime le plus + # ancien encore présent sur une machine de bureau — et pas seulement + # sous PowerShell 7. + $env:TYPR_INSTALL_ORIGIN = 'https://we-data-ch.github.io/typr.github.io' + + & ./install/install.ps1 -DryRun + if ($LASTEXITCODE -ne 0) { throw "-DryRun a échoué (code $LASTEXITCODE)" } + + & ./install/install.ps1 -DryRun -Version 'pas-un-tag' + if ($LASTEXITCODE -ne 2) { + throw "un tag illisible doit sortir en 2 (code obtenu : $LASTEXITCODE)" + } + + Write-Host "install.ps1 — PowerShell $($PSVersionTable.PSVersion) sur $env:OS" + + install-hosting: + name: Install hosting + runs-on: ubuntu-latest + needs: install + # Le dépôt de documentation est poussé seulement sur les branches publiées : + # un push sur une PR laisserait une page d installation qui peut mentir. + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + steps: + - uses: actions/checkout@v5 + + - name: Vérifier le jeton de déploiement + env: + DOCS_DEPLOY_TOKEN: ${{ secrets.DOCS_DEPLOY_TOKEN }} + run: | + if [ -z "$DOCS_DEPLOY_TOKEN" ]; then + echo "::error::DOCS_DEPLOY_TOKEN absent : la copie vers typr.github.io ne peut pas avoir lieu." + exit 1 + fi + + # Les scripts d installation sont servis depuis le dépôt de documentation, + # pas depuis celui-ci : c est là que GitHub Pages est branché, et les deux + # URLs doivent rester stables même si le code bouge. + # + # On ne copie que ce que Pages sert. Les tests et le Dockerfile n ont rien + # à faire dans le site, et le publier reviendrait à exposer la mécanique de + # test — y compris les détails du protocole de fixture. + - name: Copier les scripts vers le dépôt de documentation + env: + DOCS_DEPLOY_TOKEN: ${{ secrets.DOCS_DEPLOY_TOKEN }} + run: | + git clone --depth 1 \ + "https://x-access-token:${DOCS_DEPLOY_TOKEN}@github.com/we-data-ch/typr.github.io.git" \ + site + + mkdir -p site/static/install + # Le BOM est nécessaire : le script affiche des accents, et Pages sert + # le fichier tel quel. Sans BOM, un navigateur et PS 5.1 le liraient + # en ANSI. + cp install/install.sh install/install.ps1 site/static/install/ + + # Aucun autre fichier n est attendu sous static/install : un reste + # d une version précédente (un vieux install.bat) continuerait d être + # servi à l adresse où il a déjà été indexé. + git -C site status --porcelain + git -C site add static/install/install.sh static/install/install.ps1 + if git -C site diff --cached --quiet; then + echo "Les scripts publiés sont déjà à jour." + exit 0 + fi + + git -C site -c user.name='github-actions[bot]' \ + -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + commit -m "install : scripts d installation ($(git rev-parse --short HEAD))" + + # Un push direct sur main est refusé si la branche est protégée. Le + # contournement par empty commit + force n est pas utilisé : mieux + # vaut voir le job échouer bruyamment qu écraser une protection + # posée par un humain. + git -C site push diff --git a/install_plan.md b/install_plan.md index 17b4c45..901e2b5 100644 --- a/install_plan.md +++ b/install_plan.md @@ -17,7 +17,8 @@ par-dessus, pas de refaire ce qui marche. | 0a — cibles musl dans la matrice | **fait** | | 0b — `+crt-static` sur `*-msvc` | **fait** (liaison vérifiée en CI, pas en local) | | 0c — binaire musl dans Rocky 9 / Debian 12 / Alpine | **fait** | -| 1 → 7 | à faire | +| 1 — scripts d'installation + hébergement + CI | **fait** (voir ci-dessous) | +| 2 → 7 | à faire | ### Ce que le lot 0 a changé @@ -76,6 +77,53 @@ localement** : ni le linker MSVC ni le SDK ne sont disponibles ici. `rustc` accepte le flag pour les deux cibles `*-msvc` (vérifié jusqu'à l'émission des métadonnées), et le garde-fou `dumpbin` tranchera à la prochaine release. +### Ce que le lot 1 a livré + +Sept fichiers sous `install/`, tous du texte, aucun binaire. + +| Fichier | Contenu | +|---|---| +| `install/install.sh` | `sh` POSIX : musl par défaut, GNU sur `--gnu`, SHA-256, stable/beta | +| `install/install.ps1` | PowerShell 5.1 et 7 : `.zip`, SHA-256, PATH par registre | +| `install/README.md` | usage, options, codes de sortie, dépannage | +| `install/tests/run-tests.sh` | 59 assertions contre un serveur de fixtures | +| `install/tests/run-tests.ps1` | 49 assertions, plus PSScriptAnalyzer | +| `install/tests/fixture-server.py` | `/releases/latest`, `releases.atom`, archives, `checksums.txt` | +| `install/tests/Dockerfile` | image PowerShell 7.4 + PSScriptAnalyzer + shellcheck | + +Trois jobs de CI : `install` (shellcheck, `dash -n`, les deux suites), +`install-windows` (le script sous Windows PowerShell 5.1 réel), et +`install-hosting` (copie vers `typr.github.io/static/install/`). + +**59 + 49 assertions passent** ; shellcheck est propre sur les deux scripts +shell. Le vrai test d'installation a réussi en local : +`TYPR_INSTALL_DIR=$(mktemp -d) ./install/install.sh --gnu --version v0.5.12` +puis `typr-cli 0.5.12`. + +Cinq défauts réels sont sortis des tests, tous invisibles à la lecture : + +- `Try-Save-File` rendait `$null` — donc falsy — sur un téléchargement réussi, + parce qu'une fonction PowerShell renvoie ce que son `try` a émis, et un + succès n'émet rien. Chaque installation Windows échouait. +- `$input` est une variable automatique PowerShell. Y écrire pouvait faire lire + un `$null` à `CopyTo` et rendre le téléchargement silencieusement infructueux. +- Le fichier n'avait pas de BOM UTF-8. PowerShell 5.1 lit un `.ps1` sans BOM + en ANSI avec la page de code de la machine : sur un Windows français ou + allemand, chaque accent de ce fichier devenait illisible. Le BOM est + maintenant testé explicitement, sinon un reformatage l'effacerait en silence. +- Une ligne `checksums.txt` au SHA malformé était signalée « aucune ligne pour + cette archive » au lieu de « ligne malformée ». Deux diagnostics très + différents — release mal publiée contre fichier corrompu — se confondaient. + `install.sh` et `install.ps1` nomment désormais la même cause. +- Le harnais de test perdait les codes de retour : `& script.ps1` ne propage + pas le `exit` du script appelé au processus. Le refus d'un tag illisible (2) + était indiscernable d'un refus d'environnement (1). + +Deux points restent **non vérifiés localement**, et la CI les couvre sans +prétendre le contraire : la liaison `+crt-static` (ci-dessus) et le démarrage +d'un binaire Windows réel — le runner n'a pas de VC++ Redistributable, et la +fixture n'est de toute façon pas un exécutable Windows. + --- ## 1. État actuel From 236263df09ae7a3fa68956121aa8827e442360f7 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 15:24:05 +0200 Subject: [PATCH 41/52] install : CI, release et packaging (lot 2) Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 63 +++ .github/workflows/release.yml | 138 +++++ RELEASING.md | 35 +- install_plan.md | 136 ++++- packaging/homebrew/Formula/typr.rb.in | 48 ++ packaging/homebrew/README.md | 124 +++++ packaging/homebrew/README.md.in | 42 ++ packaging/homebrew/parts/on_linux.rb.in | 18 + .../homebrew/parts/on_linux_absent.rb.in | 5 + packaging/homebrew/render.sh | 361 +++++++++++++ .../fixtures/checksums-v0.5.12-legacy.txt | 11 + .../tests/fixtures/checksums-v9.9.9.txt | 13 + packaging/homebrew/tests/run-tests.sh | 509 ++++++++++++++++++ 13 files changed, 1488 insertions(+), 15 deletions(-) create mode 100644 packaging/homebrew/Formula/typr.rb.in create mode 100644 packaging/homebrew/README.md create mode 100644 packaging/homebrew/README.md.in create mode 100644 packaging/homebrew/parts/on_linux.rb.in create mode 100644 packaging/homebrew/parts/on_linux_absent.rb.in create mode 100755 packaging/homebrew/render.sh create mode 100644 packaging/homebrew/tests/fixtures/checksums-v0.5.12-legacy.txt create mode 100644 packaging/homebrew/tests/fixtures/checksums-v9.9.9.txt create mode 100755 packaging/homebrew/tests/run-tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b94cda0..e5c2017 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -209,6 +209,69 @@ jobs: tail -5 case-run.log >> "$GITHUB_STEP_SUMMARY" echo '```' >> "$GITHUB_STEP_SUMMARY" + packaging: + name: Packaging (générateur Homebrew) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Installer shellcheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + + - name: render.sh — shellcheck + run: shellcheck -s sh packaging/homebrew/render.sh + + - name: render.sh — analyse syntaxique + run: | + # `dash -n` avec le dash du runner : c'est l'interpréteur le plus + # strict, il refuse les extensions de bash que `sh -n` tolère. + dash -n packaging/homebrew/render.sh + bash -n packaging/homebrew/render.sh + bash -n packaging/homebrew/tests/run-tests.sh + + - name: render.sh — suite de tests + run: bash packaging/homebrew/tests/run-tests.sh + + # La validation par Homebrew ne peut pas tourner dans le job précédent : ni + # `brew style` ni `brew audit` n'existent sous Linux, et les deux sont justement + # ce qui décide si `brew install` fonctionnera. Elle a donc lieu ici, sur un + # runner macOS, à chaque PR — sinon une formule que Homebrew refuse ne se + # verrait qu'à la release, quand le tag est déjà poussé et qu'il faut en créer + # un autre pour la corriger. + # + # Le rendu se fait sur la fixture, donc sans réseau et sans release : c'est le + # même code de substitution que la release exercera, avec des empreintes + # factices. La vérification des empreintes *réelles* appartient au job `brew` + # de release.yml, qui peut télécharger les archives publiées. + packaging-audit: + name: Packaging (audit Homebrew) + runs-on: macos-latest + steps: + - uses: actions/checkout@v5 + + - name: Rendre la formule depuis la fixture + run: | + set -euo pipefail + packaging/homebrew/render.sh \ + --tag v9.9.9 \ + --checksums packaging/homebrew/tests/fixtures/checksums-v9.9.9.txt \ + --out tap + cat tap/Formula/typr.rb + + - name: brew style et brew audit + run: | + set -euo pipefail + TAP_DIR="$(brew --repository)/Library/Taps/we-data-ch/homebrew-typr" + mkdir -p "$TAP_DIR/Formula" + cp tap/Formula/typr.rb "$TAP_DIR/Formula/typr.rb" + cp tap/README.md "$TAP_DIR/README.md" + + brew readall "$TAP_DIR" + brew style "$TAP_DIR/Formula/typr.rb" + brew audit --strict --formula="$TAP_DIR/Formula/typr.rb" + install: name: Install scripts runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c23b6cc..f57ed82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -632,3 +632,141 @@ jobs: if: env.DOCS_DISPATCH_TOKEN == '' run: | echo "::warning title=Doc non rafraichie::DOCS_DISPATCH_TOKEN absent ; typr.github.io n'a pas ete redeploye pour cette release." + + # --------------------------------------------------------------------------- + # 8b. Homebrew — le tap reçoit une formule *générée*, jamais écrite à la main. + # + # Un SHA-256 recopié à la main finit toujours par désigner un binaire qui + # n'est plus celui de la release : l'erreur n'apparaît qu'à + # l'installation, chez l'utilisateur. Ici les empreintes sont lues dans le + # `checksums.txt` que la release vient de publier, puis vérifiées contre + # l'archive réellement téléchargée avant d'être commitées. + # + # Indépendant des autres canaux, comme `vscode` et `vim` : un push refusé + # sur le tap ne doit pas empêcher les binaires d'arriver. + # --------------------------------------------------------------------------- + brew: + name: Homebrew tap + needs: [verify, release] + runs-on: macos-latest + # Au niveau du job pour que l'étape de publication puisse tester sa présence + # dans son propre `if` : un `env` de step n'y est pas visible. + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.verify.outputs.tag }} + + - name: Récupérer checksums.txt de la release + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + set -euo pipefail + curl -fsSfL \ + "https://github.com/${{ github.repository }}/releases/download/$TAG/checksums.txt" \ + -o checksums.txt + cat checksums.txt + + - name: Générer la formule et le README du tap + run: | + set -euo pipefail + packaging/homebrew/render.sh \ + --tag "${{ needs.verify.outputs.tag }}" \ + --checksums checksums.txt \ + --out tap + cat tap/Formula/typr.rb + + # La formule reprend `checksums.txt`, donc son empreinte est correcte par + # construction — sauf si le fichier a été réécrit entre le rendu et cet + # instant, ou si la ligne lue était celle d'un autre artefact. On + # télécharge donc une archive pour de vrai et on la compare : c'est le seul + # endroit où une erreur de sélection se verrait avant le commit. + - name: L'empreinte de la formule correspond à l'archive publiée + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + set -euo pipefail + ARTIFACT="typr-$TAG-aarch64-apple-darwin.tar.gz" + curl -fsSfL \ + "https://github.com/${{ github.repository }}/releases/download/$TAG/$ARTIFACT" \ + -o "$ARTIFACT" + ACTUAL=$(shasum -a 256 "$ARTIFACT" | cut -d' ' -f1) + rm -f "$ARTIFACT" + if ! grep -Fq "$ACTUAL" tap/Formula/typr.rb; then + echo "::error::l'empreinte de $ARTIFACT ($ACTUAL) ne figure pas dans la formule rendue" + exit 1 + fi + echo "archive publiée et formule d'accord : $ACTUAL" + + # `brew style` et `brew audit` jugent la formule comme le ferait + # l'utilisateur, et rien ici ne les remplace : ni ruby ni Homebrew ne + # tournent sous Linux. Une formule qu'ils refusent n'est pas committée. + # + # Le fichier est déposé dans un vrai tap (`Library/Taps//`) : + # c'est ce que fait `brew tap`, et c'est ce que rubocop attend pour + # connaître le nom du tap. + - name: Homebrew accepte la formule + run: | + set -euo pipefail + TAP_DIR="$(brew --repository)/Library/Taps/we-data-ch/homebrew-typr" + mkdir -p "$TAP_DIR/Formula" + cp tap/Formula/typr.rb "$TAP_DIR/Formula/typr.rb" + cp tap/README.md "$TAP_DIR/README.md" + + # `brew readall` va plus loin que `brew style` : il charge la formule + # comme le fera `brew install`. Une formule que Homebrew sait lire mais + # pas charger est detectable ici, et nulle part ailleurs. + brew readall "$TAP_DIR" + brew style "$TAP_DIR/Formula/typr.rb" + brew audit --strict --formula="$TAP_DIR/Formula/typr.rb" + + - name: Pousser la formule vers le tap + if: env.HOMEBREW_TAP_TOKEN != '' + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + set -euo pipefail + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + # Clone complet, pas `--depth 1` comme les autres canaux : en cas de + # push refusé il faut pouvoir rebaser, et un clone superficiel refuse + # de rebaser sur un historique qu'il n'a pas. Le tap fait quelques + # dizaines de kilo-octets. + git clone \ + "https://x-access-token:$HOMEBREW_TAP_TOKEN@github.com/we-data-ch/homebrew-typr.git" \ + taprepo + + mkdir -p taprepo/Formula + cp tap/Formula/typr.rb taprepo/Formula/typr.rb + cp tap/README.md taprepo/README.md + cp LICENSE taprepo/LICENSE + + git -C taprepo add Formula/typr.rb README.md LICENSE + if git -C taprepo diff --cached --quiet; then + echo "Le tap est déjà à jour ($VERSION)." + exit 0 + fi + git -C taprepo commit -m "TypR $VERSION" + + # Le tap a ses propres contributeurs : on rebase plutôt que d'écraser. + for attempt in 1 2 3; do + if git -C taprepo push origin HEAD:main; then + echo "tap $VERSION poussé (tentative $attempt)." + exit 0 + fi + echo "push refusé — rebase sur origin/main et nouvelle tentative" + git -C taprepo fetch origin main + git -C taprepo rebase origin/main + done + echo "::error::impossible de pousser la formule vers le tap après 3 tentatives" + exit 1 + + # Sans jeton, le tap reste sur la version précédente : on le dit, on ne + # casse pas toute la release pour un seul canal — la règle que RELEASING.md + # énonce déjà. + - name: HOMEBREW_TAP_TOKEN absent — tap non mis à jour + if: env.HOMEBREW_TAP_TOKEN == '' + run: | + echo "::warning title=Homebrew non mis a jour::HOMEBREW_TAP_TOKEN absent ; le tap garde sa version precedente." diff --git a/RELEASING.md b/RELEASING.md index 263e5a2..b63b73d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -104,7 +104,8 @@ tag vX.Y.Z ├─ crates-io ...... typr-core → typr-lsp → typr-cli → typr ├─ wasm ........... WASM + version.json → dépôt du playground ├─ vscode ......... .vsix → Marketplace + Open VSX + attaché à la release - └─ vim ............ tarball du plugin, attaché à la release + ├─ vim ............ tarball du plugin, attaché à la release + └─ brew ........... formule générée → tap `we-data-ch/homebrew-typr` ``` Les branches sont indépendantes : si le Marketplace échoue, les binaires et @@ -151,6 +152,38 @@ en 0.5.7 et que les releases GitHub annonçaient 0.5.7. | `VSCE_PAT` | `vscode` | Azure DevOps → PAT, scope *Marketplace: Manage* | | `PLAYGROUND_DEPLOY_TOKEN` | `wasm` | PAT GitHub, scope `repo` sur le dépôt du playground | | `DOCS_DISPATCH_TOKEN` | `docs` | PAT GitHub, scope `repo` sur `typr.github.io` | +| `HOMEBREW_TAP_TOKEN` | `brew` | PAT GitHub, scope `repo` sur `homebrew-typr` | + +## Le tap Homebrew + +`we-data-ch/homebrew-typr` ne contient que `Formula/typr.rb`, `README.md` et +`LICENSE` — aucun binaire. Le job `brew` génère la formule à partir du +`checksums.txt` de la release, la fait valider par `brew style` et +`brew audit --strict`, puis la commite. La formule n'est donc jamais écrite à la +main : voir `packaging/homebrew/README.md`. + +Le dépôt a été créé le 30 septembre 2026 et contient déjà la formule de +v0.5.12 ; la CI le tient ensuite à chaque tag. S'il fallait le refaire, le geste +est : + +```bash +gh repo create we-data-ch/homebrew-typr --public --description "Tap Homebrew pour TypR" +``` + +Deux façons de le tenir, à trancher une fois pour toutes : + +- **`main` libre** — la CI pousse directement, comme `vscode` et `wasm` le font + pour leurs dépôts. Le tag fait foi, et le tap ne peut jamais être en retard. +- **`main` protégé, admission via le dépôt `we-data-ch/homebrew-typr-autoupdate`** + — la CI n'écrit plus dans le tap mais dans le dépôt d'admission, qui ouvre une + PR que l'on relit. `homebrew-typr/typr` reste installable entre-temps, par ce que + `brew install` lit est la branche par défaut. Le tap devient alors impossible à + modifier sans relecture humaine, au prix d'un `brew update-autoupdate` à + installer sur les postes des mainteneurs. + +Le choix du lot 3 est le premier : il est cohérent avec le reste de la chaîne, et +une formule générée par la CI depuis le `checksums.txt` n'a pas à être relue — ce +qui la relèverait, c'est son unicité, pas sa rédaction. ## Aucun binaire dans le dépôt diff --git a/install_plan.md b/install_plan.md index 901e2b5..ac30d00 100644 --- a/install_plan.md +++ b/install_plan.md @@ -18,7 +18,9 @@ par-dessus, pas de refaire ce qui marche. | 0b — `+crt-static` sur `*-msvc` | **fait** (liaison vérifiée en CI, pas en local) | | 0c — binaire musl dans Rocky 9 / Debian 12 / Alpine | **fait** | | 1 — scripts d'installation + hébergement + CI | **fait** (voir ci-dessous) | -| 2 → 7 | à faire | +| 2 — tests CI sur ces scripts | **couvert par le lot 1** — les jobs `install`, `install-windows` et `install-hosting` sont ceux-là | +| 3 — Homebrew | **fait** (voir ci-dessous) | +| 4 → 7 | à faire | ### Ce que le lot 0 a changé @@ -120,10 +122,85 @@ Cinq défauts réels sont sortis des tests, tous invisibles à la lecture : était indiscernable d'un refus d'environnement (1). Deux points restent **non vérifiés localement**, et la CI les couvre sans -prétendre le contraire : la liaison `+crt-static` (ci-dessus) et le démarrage +pretendre le contraire : la liaison `+crt-static` (ci-dessus) et le démarrage d'un binaire Windows réel — le runner n'a pas de VC++ Redistributable, et la fixture n'est de toute façon pas un exécutable Windows. +### Ce que le lot 3 a livré + +La formule, ses deux variantes et son générateur — sous `packaging/homebrew/`, +toujours aucun binaire dans le dépôt. + +| Fichier | Contenu | +|---|---| +| `Formula/typr.rb.in` | `desc`, `homepage`, `license`, `livecheck`, `on_macos` | +| `parts/on_linux.rb.in` | le bloc `on_linux` musl, inséré seulement s'il existe | +| `parts/on_linux_absent.rb.in` | le commentaire qui le remplace sinon | +| `README.md.in` | le README du tap ; sa description et son URL sont **relues dans la formule rendue**, pas recopiées | +| `render.sh` | `sh` POSIX : tag + `checksums.txt` → formule + README | +| `tests/run-tests.sh` | 81 assertions, sans réseau ni brew | +| `README.md` | le mode d'emploi du canal | + +`render.sh` ne fait qu'une chose, et c'est le cœur du lot : **lire les +empreintes dans le `checksums.txt` de la release**. La formule est donc +impossible à publier avec une empreinte recopiée — la seule façon de l'obtenir +est de modifier le générateur, qui est relu et testé à chaque PR. + +Le tag est pris avec ou sans `v` initial ; `--checksums` accepte une URL ou un +fichier local ; **rien n'est écrit** dans `--out` si le rendu échoue en cours de +route, pour qu'une formule tronquée ne puisse pas être commitée par erreur. + +Deux jobs de CI. `packaging` tourne sous Linux : shellcheck, `dash -n`, +`bash -n`, la suite. `packaging-audit` tourne sous macOS et fait passer la +formule rendue à `brew style` et `brew audit --strict` — sur une PR, donc, et +non à la release. C'est le seul endroit où le DSL Homebrew de la formule peut +être jugé, aucun runner Linux n'ayant Homebrew. + +Le job `brew` de `release.yml` reprend le même chemin sur le `checksums.txt` +réellement publié, **télécharge une archive et compare son empreinte à celle +inscrite dans la formule** avant de commiter vers `we-data-ch/homebrew-typr` — +là où une erreur de sélection se verrait enfin. Sans `HOMEBREW_TAP_TOKEN`, la +validation a lieu quand même et seule la publication est sautée, avec un +avertissement ; un refus de Homebrew arrête le job. + +**81 assertions passent.** Cinq mutations du générateur ont été essayées pour +vérifier que la suite a vraiment du mordant — c'est elle qui l'affirme, pas la +relecture du gabarit : + +| Mutation | Détectée par | +|---|---| +| les deux `sha256` macOS intervertis | les deux blocs `url`+`sha256`, vérifiés côte à côte | +| garde-fou des marqueurs `@@` supprimé | 18 assertions | +| cible GNU choisie pour Linux | 12 assertions | +| bloc `on_linux` désindentation | l'imbrication `on_linux` / `on_intel` | +| garde-fou du retour à la ligne final supprimé | 2 assertions | + +La troisième a été trouvée en écrivant la suite : la version précédente +vérifiait que les quatre empreintes *apparaissaient* dans le fichier, ce que +satisfait aussi deux cibles interverties. La désindentation, elle, venait de la +normalisation du gabarit — invisible pour tout test qui ne compte pas les +espaces, et rattrapée par `brew style` dix minutes plus tard si l'on n'y prenait +garde. + +**Ce que le lot n'a pas pu faire, et le dit.** Aucune version publiée ne contient +de binaire musl : `v0.5.12`, la dernière, n'a que six artefacts, tous GNU ou +Apple. Le générateur produit donc, pour elle, une formule **macOS seule**, et le +README du tap l'annonce au lieu de promettre Linux. Aucun repli GNU n'a été +ajouté : ce serait réintroduire, par la porte de Homebrew, le défaut +`GLIBC_2.39 not found` que le lot 0 vient de corriger partout ailleurs. Le bloc +`on_linux` réapparaîtra seul, à la première release qui publiera du musl. + +Reste une seule chose à faire, et elle n'est pas dans ce dépôt : +`HOMEBREW_TAP_TOKEN` (PAT GitHub, scope `repo` sur `we-data-ch/homebrew-typr`) +dans les secrets de la CI. Tant qu'il manque, le job `brew` valide la formule et +saute la publication, avec un avertissement. + +Le dépôt `we-data-ch/homebrew-typr` a été créé le 30 septembre 2026, vide puis +amorcé avec la formule v0.5.12 rendue depuis le `checksums.txt` de cette release +— `brew install we-data-ch/typr/typr` fonctionne donc dès aujourd'hui sur macOS, +et la prochaine release remplacera ce fichier par le sien, ce qui exercera le +chemin complet `release.yml` → tap. + --- ## 1. État actuel @@ -253,7 +330,7 @@ installer, seulement deux entrées à ajouter. | `x86_64-unknown-linux-musl` | remplaçante de la version GNU sur x86_64 | 1 | | `aarch64-unknown-linux-musl` | remplaçante sur ARM | 1 | -Deux вариpless possibles si l'on veut rester sur des binaires glibc dynamiques, +Deux variantes possibles si l'on veut rester sur des binaires glibc dynamiques, par ordre de préférence : - `cargo-zigbuild` en visant `glibc = "2.28"` — plancher très bas, couvre @@ -456,34 +533,58 @@ Le canal le plus attendu sur macOS, et il couvre Linux au passage. ### Livrables -Dépôt `we-data-ch/homebrew-typr` : +Dépôt `we-data-ch/homebrew-typr`, créé à blanc puis tenu par la CI : ``` README.md # généré à partir de la formule -Formula/typr.rb # la seule source +Formula/typr.rb # généré depuis checksums.txt +LICENSE # copié depuis ce dépôt ``` +Le générateur et ses tests restent ici, pas dans le tap : le tap ne contient que +ce qu'il faut pour installer, et sa seule source est `release.yml`. + ### Contenu de la formule +Le plan prévoyait un `url` unique plus un `if Hardware::CPU.arm?`. C'est +insuffisant : avec Linux dans le canal, **les deux** systèmes ont deux +architectures, et un seul `url` au niveau racine ne peut pas désigner le bon +binaire. Le gabarit construit utilise donc les deux variantes du DSL moderne : + ```ruby class Typr < Formula desc "A typed superset of R — transpiler and type checker" homepage "https://we-data-ch.github.io/typr.github.io/" - url "https://github.com/we-data-ch/typr/releases/download/vX.Y.Z/typr-vX.Y.Z-x86_64-apple-darwin.tar.gz" + license "Apache-2.0" version "X.Y.Z" - sha256 "" + + livecheck do + url :stable + strategy :github_latest + end on_macos do - if Hardware::CPU.arm? + on_intel do + url "…/typr-vX.Y.Z-x86_64-apple-darwin.tar.gz" + sha256 "" + end + + on_arm do url "…/typr-vX.Y.Z-aarch64-apple-darwin.tar.gz" - sha256 "…" + sha256 "" end end + # on_linux : idem, en musl. Ce bloc n'existe que si la release en publie. + def install bin.install "typr" end + test do + assert_match version.to_s, shell_output("#{bin}/typr --version") + end + def caveats "typR is pre-1.0: version 0.x releases can change CLI surface." end @@ -500,11 +601,18 @@ end ### Vérification -- [ ] `brew install --formula we-data-ch/typr/typr` depuis un Mac clean -- [ ] Fonctionne sur Apple Silicon **et** Intel -- [ ] `brew upgrade typr` passe à la bonne version -- [ ] `brew audit --strict` ne signale rien -- [ ] Le SHA publié correspond à `checksums.txt` de la release +- [ ] `brew install --formula we-data-ch/typr/typr` depuis un Mac clean — humain +- [ ] Fonctionne sur Apple Silicon **et** Intel — humain +- [ ] `brew upgrade typr` passe à la bonne version — humain +- [x] `brew audit --strict` ne signale rien — `packaging-audit` et `brew` +- [x] Le SHA publié correspond à `checksums.txt` de la release — job `brew`, qui + télécharge l'archive et compare +- [ ] `brew install we-data-ch/typr/typr` résout le tap — le dépôt existe + (30 septembre 2026) ; la ligne n'est cochée qu'après un essai sur un Mac + +Ce que la CI ne peut pas faire, et qui reste à faire à la main une fois le tap +créé : ouvrir un Mac vierge et taper `brew install`. `brew audit --strict` juge la +formule, pas l'installation. --- diff --git a/packaging/homebrew/Formula/typr.rb.in b/packaging/homebrew/Formula/typr.rb.in new file mode 100644 index 0000000..a23a955 --- /dev/null +++ b/packaging/homebrew/Formula/typr.rb.in @@ -0,0 +1,48 @@ +# TypR — un sur-ensemble typé de R : transpileur et vérificateur de types. +# +# FICHIER GÉNÉRÉ — ne pas l'éditer à la main. +# +# Gabarit : packaging/homebrew/Formula/typr.rb.in (dépôt we-data-ch/typr) +# Généré par : packaging/homebrew/render.sh --tag vX.Y.Z +# +# La version et les empreintes viennent de `checksums.txt` de la release : elles +# ne peuvent donc pas dériver du binaire réellement publié, ce qui arrive dès +# qu'un SHA est recopié à la main. Pour installer le tap ou corriger ce +# fichier : packaging/homebrew/README.md +class Typr < Formula + desc "A typed superset of R — transpiler and type checker" + homepage "https://we-data-ch.github.io/typr.github.io/" + version "@@VERSION@@" + license "Apache-2.0" + + on_macos do + on_arm do + url "@@URL_AARCH64_APPLE@@" + sha256 "@@SHA256_AARCH64_APPLE@@" + end + + on_intel do + url "@@URL_X86_64_APPLE@@" + sha256 "@@SHA256_X86_64_APPLE@@" + end + end + +@@LINUX@@ + + livecheck do + url :stable + strategy :github_latest + end + + def install + bin.install "typr" + end + + test do + assert_match version.to_s, shell_output("#{bin}/typr --version") + end + + def caveats + "TypR is pre-1.0: 0.x releases can change the CLI surface." + end +end diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md new file mode 100644 index 0000000..2a06851 --- /dev/null +++ b/packaging/homebrew/README.md @@ -0,0 +1,124 @@ +# Tap Homebrew — TypR + +Le canal `brew install we-data-ch/typr/typr` ne publie aucun binaire. Le dépôt +[`we-data-ch/homebrew-typr`](https://github.com/we-data-ch/homebrew-typr) ne +contient qu'une formule : une URL de release et son SHA-256. + +Ces deux valeurs sont **générées**, jamais écrites à la main. Un SHA recopié à la +main finit toujours par désigner un binaire qui n'est plus celui de la release, +et l'erreur n'apparaît qu'à l'installation, chez l'utilisateur. Ici elles sont +lues dans le `checksums.txt` que la release publie, puis comparées à l'archive +réellement téléchargée avant d'être commitées. + +## Les fichiers d'ici + +| Fichier | Rôle | +|---|---| +| `render.sh` | le générateur : tag + `checksums.txt` → `Formula/typr.rb` et `README.md` | +| `Formula/typr.rb.in` | le gabarit de la formule | +| `parts/on_linux.rb.in` | le bloc `on_linux`, inséré seulement si la release publie un binaire musl | +| `parts/on_linux_absent.rb.in` | le commentaire mis à sa place sinon | +| `README.md.in` | le README du tap, dont la description et l'URL sont relues dans la formule | +| `tests/` | la suite de tests, exécutée par le job `packaging` de `ci.yml` | + +## Rendre une formule + +```sh +# depuis le `checksums.txt` d'une release publiée +packaging/homebrew/render.sh --tag v0.5.12 --out /tmp/tap + +# ou depuis une copie locale du fichier +packaging/homebrew/render.sh --tag v9.9.9 --checksums ./checksums.txt --out /tmp/tap +``` + +Deux fichiers sont écrits, rien d'autre : `Formula/typr.rb` et `README.md`. Sans +`--checksums`, l'URL est déduite du tag — +`https://github.com/we-data-ch/typr/releases/download//checksums.txt` — ce +qui est exactement ce que fait le job `brew`. + +| Option | Effet | +|---|---| +| `--tag ` | version à publier ; le `v` initial est optionnel | +| `--checksums ` | `checksums.txt` : URL (défaut) ou fichier local | +| `--out ` | où écrire (défaut : le répertoire courant) | +| `--help` | l'aide | + +## Tester + +Sans Homebrew ni réseau, sous n'importe quel Unix : + +```sh +packaging/homebrew/tests/run-tests.sh +``` + +La suite couvre ce qui compte et ne se voit pas à la relecture : que le +générateur prenne **la** bonne ligne parmi les huit de `checksums.txt` — les +bâtiments GNU et musl ne diffèrent que par un mot — et qu'il refuse de produire +une formule à moitié rendue. + +Sur macOS, ajouter ce que Linux ne peut pas juger : + +```sh +packaging/homebrew/render.sh --tag v9.9.9 \ + --checksums packaging/homebrew/tests/fixtures/checksums-v9.9.9.txt --out /tmp/tap + +TAP_DIR="$(brew --repository)/Library/Taps/we-data-ch/homebrew-typr" +mkdir -p "$TAP_DIR/Formula" +cp /tmp/tap/Formula/typr.rb "$TAP_DIR/Formula/typr.rb" + +brew style "$TAP_DIR/Formula/typr.rb" +brew audit --strict --formula="$TAP_DIR/Formula/typr.rb" + +# installation réelle, sur l'archive publiée +brew install we-data-ch/typr/typr +``` + +## Ce que la CI en fait + +Le job `brew` de `release.yml`, sur `macos-latest` : + +1. récupère le `checksums.txt` de la release ; +2. rend la formule ; +3. télécharge `typr--aarch64-apple-darwin.tar.gz` et vérifie que son + empreinte figure bien dans la formule rendue ; +4. passe la formule à `brew style` et `brew audit --strict` ; +5. ne commite vers le tap qu'après tout cela. + +Sans le secret `HOMEBREW_TAP_TOKEN`, les étapes 1 à 4 ont lieu quand même et +seule la publication est sautée, avec un avertissement ; un refus de Homebrew +arrête le job. Mieux vaut un job rouge qu'un tap que personne ne peut installer. + +Le dépôt `we-data-ch/homebrew-typr` existe et contient déjà la formule de +v0.5.12, rendue depuis le `checksums.txt` de cette release. Il reste à définir +le secret `HOMEBREW_TAP_TOKEN` (PAT GitHub, scope `repo` sur ce dépôt) dans les +secrets de la CI ; la prochaine release exercera alors le chemin complet +`release.yml` → tap. + +## Pourquoi Linux peut manquer + +Le bloc `on_linux` n'est inséré que si la release publie +`typr--{x86_64,aarch64}-unknown-linux-musl.tar.gz`. **Aucune version +publiée ne le fait** : les cibles musl sont entrées dans la matrice après +v0.5.12, et le prochain tag les produira. Le rendu produit alors une formule +macOS seule, et le README du tap l'annonce au lieu de promettre Linux. + +Le repli n'est pas le binaire GNU. Ce serait réintroduire, par la porte du canal +Homebrew, le défaut que le lot 0 du plan vient de corriger partout ailleurs : +`GLIBC_2.39 not found` sur Rocky 9, Debian 12 et Ubuntu 22.04, après une +installation qui a pourtant réussi. Le bloc `on_linux` réapparaîtra seul à la +première release qui en publiera — sans que quiconque touche à ce fichier. + +## Ce que le générateur refuse + +| Cause | Message | +|---|---| +| tag illisible | `tag illisible : …` (code 2) | +| `checksums.txt` absent | `checksums.txt introuvable` | +| ligne absente pour un artefact | `ne contient aucune ligne pour …` | +| deux lignes pour le même artefact | `contient 2 lignes pour …` | +| SHA malformé | `ligne malformée dans checksums.txt` | +| gabarit modifié | `contient encore un marqueur non substitué` | +| rendu sans retour à la ligne final | `ne se termine pas par un retour à la ligne` | + +Dans tous les cas **rien n'est écrit** dans `--out` : une erreur au milieu du +rendu ne doit pas laisser une formule tronquée que la CI pousserait dans le tap. \ No newline at end of file diff --git a/packaging/homebrew/README.md.in b/packaging/homebrew/README.md.in new file mode 100644 index 0000000..db8bef9 --- /dev/null +++ b/packaging/homebrew/README.md.in @@ -0,0 +1,42 @@ +# TypR + +@@DESC@@ + +## Installation + +```bash +brew install we-data-ch/typr/typr +``` + +@@LINUX@@ + +## Utilisation + +```bash +typr check fichier.ty +typr build fichier.ty +typr --version +``` + +Mise à jour : + +```bash +brew update && brew upgrade typr +``` + +Désinstallation : + +```bash +brew uninstall typr +``` + +## Versions + +Ce dépôt ne publie pas de binaire : la formule ne contient que l'URL de la +release et son SHA-256, lus dans le `checksums.txt` de cette release. Un tag +`vX.Y.Z` de [we-data-ch/typr](https://github.com/we-data-ch/typr) produit le +commit suivant ici — voir `packaging/homebrew/README.md` dans le dépôt source. + +Documentation : @@HOMEPAGE@@ + +Licence : Apache-2.0 — voir `LICENSE`. diff --git a/packaging/homebrew/parts/on_linux.rb.in b/packaging/homebrew/parts/on_linux.rb.in new file mode 100644 index 0000000..95a1366 --- /dev/null +++ b/packaging/homebrew/parts/on_linux.rb.in @@ -0,0 +1,18 @@ + # Linux : binaire musl, lié statiquement. C'est le même choix que + # l'installeur `install.sh`, et pour la même raison — le binaire GNU exige une + # glibc plus récente que celle de Rocky 9 (2.34), Debian 12 (2.36) ou Ubuntu + # 22.04 (2.35) : l'installation y réussit, puis `typr --version` échoue sur + # « GLIBC_2.39 not found ». Aucun repli GNU n'est proposé ici, pour la même + # raison : ce serait réintroduire le défaut que le canal principal vient de + # supprimer. + on_linux do + on_intel do + url "@@URL_X86_64_LINUX@@" + sha256 "@@SHA256_X86_64_LINUX@@" + end + + on_arm do + url "@@URL_AARCH64_LINUX@@" + sha256 "@@SHA256_AARCH64_LINUX@@" + end + end diff --git a/packaging/homebrew/parts/on_linux_absent.rb.in b/packaging/homebrew/parts/on_linux_absent.rb.in new file mode 100644 index 0000000..5eb4657 --- /dev/null +++ b/packaging/homebrew/parts/on_linux_absent.rb.in @@ -0,0 +1,5 @@ + # Linux : cette version ne publie pas encore de binaire musl — les cibles + # `*-unknown-linux-musl` sont entrées dans la matrice de release après + # v0.5.12. Le bloc `on_linux` reviendra tout seul à la prochaine release qui en + # publiera, sans que ce fichier soit touché : c'est le job `brew` de + # `release.yml` qui régénère la formule au tag. diff --git a/packaging/homebrew/render.sh b/packaging/homebrew/render.sh new file mode 100755 index 0000000..27f30fe --- /dev/null +++ b/packaging/homebrew/render.sh @@ -0,0 +1,361 @@ +#!/bin/sh +# TypR — génération de la formule Homebrew à partir d'un tag. +# +# packaging/homebrew/render.sh --tag v0.5.12 +# +# Produit les deux fichiers du tap `we-data-ch/homebrew-typr` : +# +# Formula/typr.rb la formule — version et SHA-256 lus dans checksums.txt +# README.md le README du tap, dérivé de la formule +# +# Rien n'est écrit à la main, et surtout pas un SHA-256 : recopié à la main, il +# finit forcément par désigner un binaire qui n'est plus celui de la release. Ici +# les empreintes viennent du fichier que la release a publié, donc la formule ne +# peut pas en diverger. +# +# POSIX sh de bout en bout : ni bash, ni ruby. La validation par Homebrew +# (`brew audit --strict`) ne peut pas tourner ici ; elle est faite par la CI sur +# un runner macOS, avant le commit vers le tap. + +set -eu + +# --------------------------------------------------------------------------- +# Configuration +# +# Les trois variables existent pour être surchargées par les tests, qui servent +# une arborescence de release locale. +# --------------------------------------------------------------------------- +REPO="${TYPR_REPO:-we-data-ch/typr}" +ORIGIN="${TYPR_ORIGIN:-https://github.com}" + +HERE=$(cd "$(dirname "$0")" && pwd) +FORMULA_IN="$HERE/Formula/typr.rb.in" +LINUX_IN="$HERE/parts/on_linux.rb.in" +LINUX_ABSENT_IN="$HERE/parts/on_linux_absent.rb.in" +README_IN="$HERE/README.md.in" + +TAG="" +CHECKSUMS="" +OUT="." + +TMP="" + +# --------------------------------------------------------------------------- +# Sortie +# --------------------------------------------------------------------------- +# Erreur d'environnement ou de vérification : le monde est en cause (1). +die() { + printf 'typr-brew-render : %s\n' "$1" >&2 + exit 1 +} + +# Erreur de saisie : la ligne de commande est en cause (2). Même convention que +# install.sh et install.ps1, pour qu'un appelant puisse réagir pareil. +usage_die() { + printf 'typr-brew-render : %s\n' "$1" >&2 + printf '\n' >&2 + usage >&2 + exit 2 +} + +step() { printf '→ %s\n' "$*"; } +note() { printf ' %s\n' "$*"; } +warn() { printf 'typr-brew-render : attention : %s\n' "$*" >&2; } + +cleanup() { + if [ -n "$TMP" ] && [ -d "$TMP" ]; then + rm -rf "$TMP" + fi +} + +usage() { + cat <<'EOF' +TypR — générateur de la formule Homebrew + +Usage : + render.sh --tag [options] + +Options : + --tag version à publier (ex. v0.5.12 ; le v initial est optionnel) + --checksums checksums.txt de la release : URL (défaut) ou fichier local + --out où écrire Formula/typr.rb et README.md (défaut : .) + --help cette aide + +Écrit Formula/typr.rb et README.md dans le dossier du tap, et rien d'autre. +Les deux fichiers sont entièrement générés : ne jamais les éditer à la main. +EOF +} + +# --------------------------------------------------------------------------- +# Arguments +# --------------------------------------------------------------------------- +while [ $# -gt 0 ]; do + case "$1" in + --tag) + [ $# -ge 2 ] || usage_die "--tag attend une version (ex. --tag v0.5.12)." + TAG="$2" + shift 2 + ;; + --tag=*) + TAG="${1#--tag=}" + shift + ;; + --checksums) + [ $# -ge 2 ] || usage_die "--checksums attend une URL ou un chemin." + CHECKSUMS="$2" + shift 2 + ;; + --checksums=*) + CHECKSUMS="${1#--checksums=}" + shift + ;; + --out) + [ $# -ge 2 ] || usage_die "--out attend un dossier." + OUT="$2" + shift 2 + ;; + --out=*) + OUT="${1#--out=}" + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + usage_die "argument inconnu : $1" + ;; + esac +done + +[ -n "$TAG" ] || usage_die "--tag est obligatoire." + +case "$TAG" in + v*) ;; + *) TAG="v$TAG" ;; +esac + +# La forme exacte est vérifiée, pas seulement le préfixe `v` : le tag entre dans +# une URL et dans une substitution sed, donc un `v../../x` n'a rien à faire +# ici. `-alpha.1` et `-beta.2` sont acceptés — release.yml marque ces versions +# prerelease, et Homebrew les lit sans problème. +printf '%s' "$TAG" \ + | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$' \ + || usage_die "tag illisible : '$TAG' (attendu : vX.Y.Z, ou vX.Y.Z-alpha.N)." + +VERSION="${TAG#v}" + +for required in "$FORMULA_IN" "$LINUX_IN" "$LINUX_ABSENT_IN" "$README_IN"; do + [ -f "$required" ] || die "gabarit manquant : $required" +done + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +TMP=$(mktemp -d "${TMPDIR:-/tmp}/typr-brew-render.XXXXXX") \ + || die "impossible de créer un dossier temporaire." + +# --------------------------------------------------------------------------- +# checksums.txt +# --------------------------------------------------------------------------- +command -v curl >/dev/null 2>&1 \ + || die "curl est requis et n'est pas installé." + +CHECKSUMS_FILE="$TMP/checksums.txt" + +case "$CHECKSUMS" in + ''|*://*) + URL="$CHECKSUMS" + [ -n "$URL" ] \ + || URL="$ORIGIN/$REPO/releases/download/$TAG/checksums.txt" + step "Téléchargement de $URL" + curl -fsSL "$URL" -o "$CHECKSUMS_FILE" \ + || die "checksums.txt introuvable pour $TAG. + URL : $URL" + ;; + *) + [ -f "$CHECKSUMS" ] || die "checksums.txt introuvable : $CHECKSUMS" + cp "$CHECKSUMS" "$CHECKSUMS_FILE" + ;; +esac + +# --------------------------------------------------------------------------- +# SHA-256 +# +# Une ligne par artefact, au format `sha256sum`. On isole la ligne qui concerne +# l'artefact cherché — `sha256sum -c` ne conviendrait pas, il tenterait de +# vérifier les sept autres archives et sortirait en erreur alors que la nôtre est +# valide. Les points du nom sont échappés : sans cela, le motif matche +# n'importe quel caractère à leur place, et le binaire GNU se ferait prendre pour +# le binaire musl. +# --------------------------------------------------------------------------- +lookup_sha() { # lookup_sha — vide si absent, exit 1 si malformé + artifact="$1" + pattern=$(printf '%s' "$artifact" | sed 's/[.[\*^$]/\\&/g') + lines=$(grep -E "[[:space:]]\*?${pattern}\$" "$CHECKSUMS_FILE" || true) + count=$(printf '%s' "$lines" | grep -c . || true) + + [ "$count" -gt 1 ] && die "checksums.txt contient $count lignes pour $artifact : +$lines +Deux lignes pour le même nom : la release est incohérente, et choisir l'une +des deux reviendrait à publier un SHA arbitraire." + + [ "$count" -eq 1 ] || return 0 + + sha=$(printf '%s\n' "$lines" | cut -d' ' -f1) + printf '%s\n' "$sha" | grep -Eq '^[0-9a-f]{64}$' \ + || die "ligne malformée dans checksums.txt pour $artifact : + $lines + Un SHA-256 fait 64 caractères hexadécimaux." + + printf '%s\n' "$sha" +} + +require_sha() { # require_sha — die si absent + sha=$(lookup_sha "$1") || exit 1 + [ -n "$sha" ] \ + || die "checksums.txt de $TAG ne contient aucune ligne pour $1. + Fichier reçu : +$(cat "$CHECKSUMS_FILE")" + printf '%s' "$sha" +} + +# --------------------------------------------------------------------------- +# Rendu +# --------------------------------------------------------------------------- +A_APPLE_ARM="aarch64-apple-darwin" +A_APPLE_X86="x86_64-apple-darwin" +A_LINUX_ARM="aarch64-unknown-linux-musl" +A_LINUX_X86="x86_64-unknown-linux-musl" + +SHA_APPLE_ARM=$(require_sha "typr-$TAG-$A_APPLE_ARM.tar.gz") || exit 1 +SHA_APPLE_X86=$(require_sha "typr-$TAG-$A_APPLE_X86.tar.gz") || exit 1 + +# Linux est facultatif, contrairement à macOS. Les binaires musl sont entrés +# dans la matrice après v0.5.12 : aucune version publiée n'en contient encore, et +# exiger les quatre artefacts rendrait le tap impossible à amorcer. Le GNU n'est +# pas repris à la place — ce serait réintroduire le défaut `GLIBC_2.39 not +# found` que le lot 0 du plan vient de corriger sur le canal principal. Le bloc +# `on_linux` réapparaîtra seul dès la première release qui en publiera. +SHA_LINUX_ARM=$(lookup_sha "typr-$TAG-$A_LINUX_ARM.tar.gz") || exit 1 +SHA_LINUX_X86=$(lookup_sha "typr-$TAG-$A_LINUX_X86.tar.gz") || exit 1 + +if [ -n "$SHA_LINUX_ARM" ] && [ -n "$SHA_LINUX_X86" ]; then + LINUX_PART="$LINUX_IN" + EXPECTED_SHAS=4 +else + LINUX_PART="$LINUX_ABSENT_IN" + EXPECTED_SHAS=2 + for missing in "$A_LINUX_ARM" "$A_LINUX_X86"; do + grep -q "$TAG-$missing" "$CHECKSUMS_FILE" \ + || warn "$TAG ne publie pas de binaire $missing : le bloc on_linux est omis de la formule." + done +fi + +step "TypR $VERSION" +note "tag : $TAG" +note "sortie : $OUT" + +# Les valeurs substituées sont des URL et des empreintes : aucun caractère `&` +# ni `|`, qui seraient des métacaractères de sed. La seule exception possible +# viendrait du tag, validé plus haut. +BASE_URL="$ORIGIN/$REPO/releases/download/$TAG" + +sed \ + -e "s|@@VERSION@@|$VERSION|g" \ + -e "s|@@URL_AARCH64_APPLE@@|$BASE_URL/typr-$TAG-$A_APPLE_ARM.tar.gz|g" \ + -e "s|@@SHA256_AARCH64_APPLE@@|$SHA_APPLE_ARM|g" \ + -e "s|@@URL_X86_64_APPLE@@|$BASE_URL/typr-$TAG-$A_APPLE_X86.tar.gz|g" \ + -e "s|@@SHA256_X86_64_APPLE@@|$SHA_APPLE_X86|g" \ + "$FORMULA_IN" > "$TMP/formula-1.rb" + +# `r file` insère le fichier après la ligne trouvée, `d` supprime cette ligne — +# la paire remplace donc le marqueur par son contenu, en portable (BSD sed +# compris, contrairement à un `s` multi-lignes). +sed -e "/^@@LINUX@@\$/{ + r $LINUX_PART + d +}" "$TMP/formula-1.rb" > "$TMP/formula-2.rb" + +if [ "$LINUX_PART" = "$LINUX_IN" ]; then + sed \ + -e "s|@@URL_X86_64_LINUX@@|$BASE_URL/typr-$TAG-$A_LINUX_X86.tar.gz|g" \ + -e "s|@@SHA256_X86_64_LINUX@@|$SHA_LINUX_X86|g" \ + -e "s|@@URL_AARCH64_LINUX@@|$BASE_URL/typr-$TAG-$A_LINUX_ARM.tar.gz|g" \ + -e "s|@@SHA256_AARCH64_LINUX@@|$SHA_LINUX_ARM|g" \ + "$TMP/formula-2.rb" > "$TMP/typr.rb" +else + cp "$TMP/formula-2.rb" "$TMP/typr.rb" +fi + +# Un gabarit dont le nom d'un marqueur a changé produirait une formule servie +# telle quelle, avec `@@…@@` dedans — le défaut le plus discret de cette +# famille d'outils, parce qu'il ne se voit qu'à l'installation. +grep -q '@@' "$TMP/typr.rb" \ + && die "la formule rendue contient encore un marqueur non substitué : +$(grep -n '@@' "$TMP/typr.rb")" + +# Un gabarit écrit sans retour à la ligne final donne une formule que `brew style` +# refuse (Style/FinalNewline), et le retrait se voit seulement à l'installation. +# Aucune des deux anomalies ne se voit dans le diff du tap, d'où le contrôle. +[ -z "$(tail -c1 "$TMP/typr.rb")" ] \ + || die "la formule rendue ne se termine pas par un retour à la ligne." + +shas=$(grep -cE '^[[:space:]]+sha256 "[0-9a-f]{64}"$' "$TMP/typr.rb" || true) +[ "$shas" -eq "$EXPECTED_SHAS" ] \ + || die "la formule rendue déclare $shas empreintes, attendu $EXPECTED_SHAS. +Le gabarit et le rendu ne correspondent plus." + +grep -Fq "version \"$VERSION\"" "$TMP/typr.rb" \ + || die "la formule rendue ne déclare pas version \"$VERSION\"." + +# Le README du tap est dérivé de la formule, pas d'une deuxième source : desc et +# homepage sont relus dans ce qui vient d'être écrit, donc les deux fichiers ne +# peuvent pas diverger. +DESC=$(sed -n 's/^[[:space:]]*desc "\(.*\)"$/\1/p' "$TMP/typr.rb" | head -1) +HOMEPAGE=$(sed -n 's/^[[:space:]]*homepage "\(.*\)"$/\1/p' "$TMP/typr.rb" | head -1) + +[ -n "$DESC" ] || die "aucun desc lisible dans la formule rendue." +[ -n "$HOMEPAGE" ] || die "aucun homepage lisible dans la formule rendue." + +if [ "$LINUX_PART" = "$LINUX_IN" ]; then + LINUX_NOTE='- **Linux** : couvert — binaire musl lié statiquement, x86_64 et aarch64, aucune contrainte de glibc.' +else + LINUX_NOTE="- **Linux** : pas encore pour cette version — aucun binaire musl n'est publié. macOS est couvert. Le bloc Linux réapparaîtra à la première release qui en publiera." +fi + +sed \ + -e "s|@@DESC@@|$DESC|g" \ + -e "s|@@HOMEPAGE@@|$HOMEPAGE|g" \ + -e "s|@@VERSION@@|$VERSION|g" \ + -e "s|@@LINUX@@|$LINUX_NOTE|g" \ + "$README_IN" > "$TMP/README.md" + +grep -q '@@' "$TMP/README.md" \ + && die "le README rendu contient encore un marqueur non substitué : +$(grep -n '@@' "$TMP/README.md")" + +# Rien n'est écrit dans le dossier de sortie tant que le rendu n'est pas +# complet : un échec au milieu ne doit pas laisser une formule tronquée que la +# CI pousserait dans le tap. +mkdir -p "$OUT/Formula" || die "impossible de créer $OUT/Formula." + +cp "$TMP/typr.rb" "$OUT/Formula/typr.rb" +cp "$TMP/README.md" "$OUT/README.md" + +note "sha256 : $SHA_APPLE_X86 (x86_64-apple-darwin)" +note "sha256 : $SHA_APPLE_ARM (aarch64-apple-darwin)" +# Les deux lignes Linux sont conditionnelles : leur absence est normale pour +# toutes les versions publiées à ce jour (cf. plus haut). Un `if` explicite +# plutôt qu'un `&&` — le statut de retour de la liste entière est alors celui de +# `note`, ce qui évite de dépendre du traitement de `set -e` par l'interpréteur. +if [ -n "$SHA_LINUX_X86" ]; then + note "sha256 : $SHA_LINUX_X86 (x86_64-unknown-linux-musl)" +fi +if [ -n "$SHA_LINUX_ARM" ]; then + note "sha256 : $SHA_LINUX_ARM (aarch64-unknown-linux-musl)" +fi + +printf '\n' +step "Écrit : $OUT/Formula/typr.rb et $OUT/README.md" \ No newline at end of file diff --git a/packaging/homebrew/tests/fixtures/checksums-v0.5.12-legacy.txt b/packaging/homebrew/tests/fixtures/checksums-v0.5.12-legacy.txt new file mode 100644 index 0000000..a300fae --- /dev/null +++ b/packaging/homebrew/tests/fixtures/checksums-v0.5.12-legacy.txt @@ -0,0 +1,11 @@ +# checksums.txt factice — la forme exacte de la release v0.5.12, dont +# checksums.txt a été recopié tel quel : six archives, aucune cible musl +# (les cibles `*-unknown-linux-musl` sont entrées dans la matrice après +# cette version). Sert à vérifier que le rendu fonctionne quand Linux +# n'est pas encore disponible — voir packaging/homebrew/tests/run-tests.sh. +d06235ea40a816abafc22a02f42306709e1ca9c23c1c4a01ac1f003e5a5313ce typr-v0.5.12-aarch64-apple-darwin.tar.gz +10187a6f8df2c778b50210bbf8594c5dcb185da39aacaf22fa250e7f011bdc9d typr-v0.5.12-aarch64-pc-windows-msvc.zip +146a2e37a80d9847803da2216947bf66e1cc9cbb962ba8da5f7838fe4e4b5e4b typr-v0.5.12-aarch64-unknown-linux-gnu.tar.gz +57ba2a134ba48be5d9df81300b67b99a1144e085ce013b072580bbcf5f1d0091 typr-v0.5.12-x86_64-apple-darwin.tar.gz +b06c470b6a5642754f6289818928f2cd40aa62a40dd9a6fece340e2517ebf784 typr-v0.5.12-x86_64-pc-windows-msvc.zip +fdf7cff81660575fdaa40a4a7a578a97a18e381d52075eaec4eef78fd0287acb typr-v0.5.12-x86_64-unknown-linux-gnu.tar.gz diff --git a/packaging/homebrew/tests/fixtures/checksums-v9.9.9.txt b/packaging/homebrew/tests/fixtures/checksums-v9.9.9.txt new file mode 100644 index 0000000..7a4f6f9 --- /dev/null +++ b/packaging/homebrew/tests/fixtures/checksums-v9.9.9.txt @@ -0,0 +1,13 @@ +# checksums.txt factice — la même forme que celui produit par le job +# `release` de .github/workflows/release.yml (sha256sum, huit archives), +# pour la release de test v9.9.9. Utilisé par packaging/homebrew/tests +# et par le job `packaging-audit` de ci.yml, qui rend la formule sans +# toucher au réseau. Aucune de ces empreintes ne correspond à un fichier. +8146ea8ef59c8c5570f35961c1218a7d1cffb35366fbd9de2746662bf5d4b564 typr-v9.9.9-x86_64-unknown-linux-musl.tar.gz +58aa5132a479b35cb66b6e8e8ab9574991708c77020da12543d6d0f073984919 typr-v9.9.9-aarch64-unknown-linux-musl.tar.gz +981bde1973356ba60175f3831aa6edeef538bcf014f4df7530f9f9851581269d typr-v9.9.9-x86_64-unknown-linux-gnu.tar.gz +96a6c0fa324a1009b470761cb34cf3549a160e11bab9b696aef9f6451e8d2ac5 typr-v9.9.9-aarch64-unknown-linux-gnu.tar.gz +34f8df27e7d4fe1f1553043a1fbe305215ac44b6be57ad28e3aa43595d540f1e typr-v9.9.9-x86_64-apple-darwin.tar.gz +ee13ff3cf174801164d73935b91e878845bbe16f7e37030adc938ab1fe853c81 typr-v9.9.9-aarch64-apple-darwin.tar.gz +ad27d2ab79bff970f475d29693c0da803f97f35ba86174a5a0914dcb04ac7e1d typr-v9.9.9-x86_64-pc-windows-msvc.zip +53f96c50c5c740366100a369a84cd1009bfd85edf402d7763f61f8240d25bbd2 typr-v9.9.9-aarch64-pc-windows-msvc.zip diff --git a/packaging/homebrew/tests/run-tests.sh b/packaging/homebrew/tests/run-tests.sh new file mode 100755 index 0000000..e2535ea --- /dev/null +++ b/packaging/homebrew/tests/run-tests.sh @@ -0,0 +1,509 @@ +#!/usr/bin/env bash +# Tests de packaging/homebrew/render.sh, sans réseau. +# +# Le générateur ne lit qu'une chose : le `checksums.txt` d'une release. Toute sa +# valeur tient donc à deux propriétés qu'aucune relecture ne montre — +# +# 1. il prend **la** bonne ligne (le fichier liste huit archives, dont les +# deux GNU que le bloc `on_linux` ne doit jamais pointer) ; +# 2. il refuse de produire une formule à moitié rendue. +# +# C'est ce que ces tests couvrent, plus la dérive entre les gabarits et le +# générateur — le défaut qui n'apparaît qu'à la prochaine release, chez +# l'utilisateur. +# +# packaging/homebrew/tests/run-tests.sh +# +# Le même script est exécuté par le job `packaging` de ci.yml. + +set -uo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT_DIR=$(cd "$HERE/.." && pwd) +RENDER="$ROOT_DIR/render.sh" +FIXTURE="$HERE/fixtures/checksums-v9.9.9.txt" + +# La release sans binaire musl, c'est-à-dire l'état réel de toutes les versions +# publiées jusqu'ici : les cibles `*-unknown-linux-musl` sont entrées dans la +# matrice après v0.5.12. +LEGACY_TAG="v0.5.12" +LEGACY_FIXTURE="$HERE/fixtures/checksums-v0.5.12-legacy.txt" + +TAG="v9.9.9" + +PASSED=0 +FAILED=0 +ROOT="" +SERVER_PID="" + +cleanup() { + [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null + [ -n "$ROOT" ] && rm -rf "$ROOT" + return 0 +} +trap cleanup EXIT + +ok() { PASSED=$((PASSED + 1)); printf ' \033[32m✓\033[0m %s\n' "$1"; } +ko() { FAILED=$((FAILED + 1)); printf ' \033[31m✗\033[0m %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; return 0; } + +check_rc() { # check_rc + if [ "$1" -eq "$2" ]; then ok "$3"; else ko "$3" "code de retour $2, attendu $1"; fi +} + +check_contains() { # check_contains + # Un motif vide matcherait n'importe quoi : l'assertion passerait sans rien + # vérifier, ce qui est pire que ne pas tester. Il est donc refusé. + if [ -z "$2" ]; then ko "$3" "motif de recherche vide — l'assertion ne vérifierait rien"; return 0; fi + case "$1" in + *"$2"*) ok "$3" ;; + *) ko "$3" "« $2 » absent de la sortie : $1" ;; + esac +} + +check_not_contains() { + if [ -z "$2" ]; then ko "$3" "motif de recherche vide — l'assertion ne vérifierait rien"; return 0; fi + case "$1" in + *"$2"*) ko "$3" "« $2 » présent alors qu'il ne devait pas l'être" ;; + *) ok "$3" ;; + esac +} + +check_file() { # check_file + if [ -f "$1" ]; then ok "$2"; else ko "$2" "$1 absent"; fi +} + +check_no_file() { + if [ -e "$1" ]; then ko "$2" "$1 présent"; else ok "$2"; fi +} + +# L'empreinte de l'artefact dans la fixture — la valeur attendue, lue depuis la +# source du générateur et non recopiée ici. Le tag fait partie du motif : c'est +# lui qui distingue deux releases, et deux releases ont des empreintes +# différentes. +fixture_sha() { # fixture_sha [fichier] + grep -E "[[:space:]]\*?typr-$1-$2(\.tar\.gz|\.zip)\$" "${3:-$FIXTURE}" | head -1 | cut -d' ' -f1 +} + +work() { # work — dossier de travail sous $ROOT, donc nettoyé avec lui + local dir="$ROOT/$1" + rm -rf "$dir" + mkdir -p "$dir" + printf '%s' "$dir" +} + +render() { # render + local out="$1"; shift + "$RENDER" --tag "$TAG" --checksums "$FIXTURE" --out "$out" "$@" 2>&1 +} + +# Le bloc attendu d'une cible : son URL **et** son sha, côte à côte. Vérifier +# séparément que les quatre empreintes apparaissent dans le fichier ne prouverait +# rien — deux cibles interverties donneraient exactement le même texte. +expect_block() { # expect_block + printf ' url "https://github.com/we-data-ch/typr/releases/download/%s/typr-%s-%s.tar.gz"\n sha256 "%s"' \ + "$TAG" "$TAG" "$1" "$(fixture_sha "$TAG" "$1")" +} + +# --------------------------------------------------------------------------- +# Analyse statique +# --------------------------------------------------------------------------- +test_static_analysis() { + echo "Analyse statique" + + # `ok` et `ko` sortent toujours en 0, donc le `||` ne se déclenche jamais à + # tort : SC2015 signalerait ici un if-then-else qui n'existe pas. + # shellcheck disable=SC2015 + sh -n "$RENDER" 2>&1 && ok "sh -n" || ko "sh -n" + # shellcheck disable=SC2015 + bash -n "$RENDER" 2>&1 && ok "bash -n" || ko "bash -n" + + if command -v dash >/dev/null 2>&1; then + # shellcheck disable=SC2015 + dash -n "$RENDER" 2>&1 && ok "dash -n (POSIX strict)" || ko "dash -n" + else + printf ' – dash absent, POSIX strict non vérifié\n' + fi + + if command -v shellcheck >/dev/null 2>&1; then + # Aucune exclusion : render.sh ne se sert ni de `local` (non POSIX) ni + # d'aucun attribut bash. + if out=$(shellcheck -s sh "$RENDER" 2>&1); then + ok "shellcheck" + else + ko "shellcheck" "$out" + fi + else + printf ' – shellcheck absent, non exécuté\n' + fi +} + +# --------------------------------------------------------------------------- +# Dérive gabarit ↔ générateur +# +# Un gabarit dont un marqueur a été renommé (`@@VERSION@@` → `@@VERSION_X@@`) +# produit une formule qui part en production avec `@@VERSION_X@@` dedans : rien +# ne le signale tant qu'un utilisateur n'installe pas. Le générateur le refuse, +# mais seulement s'il connaît encore le nom du marqueur — ce qui est précisément +# ce qui a pu changer. D'où ce test, qui compare les deux listes. +# --------------------------------------------------------------------------- +test_marker_drift() { + echo "Dérive gabarit / générateur" + + local in_tpl in_gen only_tpl only_gen + in_tpl=$(grep -oh '@@[A-Z0-9_]*@@' \ + "$ROOT_DIR/Formula/typr.rb.in" \ + "$ROOT_DIR/parts/on_linux.rb.in" \ + "$ROOT_DIR/parts/on_linux_absent.rb.in" \ + "$ROOT_DIR/README.md.in" | sort -u) + in_gen=$(grep -oh '@@[A-Z0-9_]*@@' "$RENDER" | sort -u) + + only_tpl=$(comm -23 <(printf '%s\n' "$in_tpl") <(printf '%s\n' "$in_gen") | tr '\n' ' ') + [ -z "$only_tpl" ] \ + && ok "aucun marqueur de gabarit inconnu du générateur" \ + || ko "aucun marqueur de gabarit inconnu du générateur" "$only_tpl" + + only_gen=$(comm -13 <(printf '%s\n' "$in_tpl") <(printf '%s\n' "$in_gen") | tr '\n' ' ') + [ -z "$only_gen" ] \ + && ok "aucun marqueur du générateur absent des gabarits" \ + || ko "aucun marqueur du générateur absent des gabarits" "$only_gen" + + # Un gabarit sans retour à la ligne final donne une formule que `brew style` + # refuse, et le retrait ne se voit qu'en installant. + local tpl + for tpl in "$ROOT_DIR/Formula/typr.rb.in" \ + "$ROOT_DIR/parts/on_linux.rb.in" \ + "$ROOT_DIR/parts/on_linux_absent.rb.in" \ + "$ROOT_DIR/README.md.in"; do + if [ -z "$(tail -c1 "$tpl")" ]; then + ok "$(basename "$tpl") se termine par un retour à la ligne" + else + ko "$(basename "$tpl") se termine par un retour à la ligne" "$tpl" + fi + done +} + +# --------------------------------------------------------------------------- +# Ligne de commande +# --------------------------------------------------------------------------- +test_usage() { + echo " --help et erreurs de saisie" + local out + out=$("$RENDER" --help 2>&1) + check_rc 0 $? "--help sort en 0" + check_contains "$out" "Usage" "--help affiche l'usage" + + out=$("$RENDER" --bogus 2>&1) + check_rc 2 $? "un argument inconnu sort en 2" + check_contains "$out" "argument inconnu" "l'argument est nommé" + + out=$("$RENDER" --out /tmp 2>&1) + check_rc 2 $? "--tag absent sort en 2" + check_contains "$out" "--tag est obligatoire" "l'absence de --tag est nommée" + + out=$("$RENDER" --tag 2>&1) + check_rc 2 $? "--tag sans valeur sort en 2" + + # Le tag entre dans une URL et dans une substitution sed : une forme trop + # libre passerait par la porte de l'injection. + for tag in 'pas-un-tag' 'v1.2' 'v1.2.3/../../etc' 'vX.Y.Z' 'v1.2.3 ../../x'; do + out=$("$RENDER" --tag "$tag" --checksums "$FIXTURE" --out "$(work bad-tag)" 2>&1) + check_rc 2 $? "tag refusé : $tag" + done +} + +# --------------------------------------------------------------------------- +# Chemin heureux +# --------------------------------------------------------------------------- +test_render() { + echo "Rendu complet" + local out dir formula readme + dir=$(work render) + out=$(render "$dir") + check_rc 0 $? "rendu" + check_contains "$out" "TypR 9.9.9" "la version est annoncée" + + formula="$dir/Formula/typr.rb" + readme="$dir/README.md" + check_file "$formula" "Formula/typr.rb est écrit" + check_file "$readme" "README.md est écrit" + [ -f "$formula" ] || return 0 + + check_contains "$(cat "$formula")" "version \"${TAG#v}\"" "la version est déclarée" + check_contains "$(cat "$formula")" "class Typr < Formula" "la classe est Typr" + + local target + for target in x86_64-apple-darwin aarch64-apple-darwin \ + x86_64-unknown-linux-musl aarch64-unknown-linux-musl; do + check_contains "$(cat "$formula")" "$(expect_block "$target")" \ + "url et sha256 corrects pour $target" + done + + # La vraie tentation du contrôleur d'"exactitude" : `typr-v9.9.9-x86_64- + # unknown-linux-musl.tar.gz` et `…-linux-gnu.tar.gz` ne diffèrent que par un + # mot, et un `.` non échappé dans le motif fait matcher n'importe quoi à sa + # place. Le GNU ne doit donc apparaître nulle part. + check_not_contains "$(cat "$formula")" "$(fixture_sha "$TAG" x86_64-unknown-linux-gnu)" \ + "le binaire GNU n'est pas retenu à la place du musl" + check_not_contains "$(cat "$formula")" "unknown-linux-gnu" \ + "aucune cible GNU dans la formule" + + local shas + shas=$(grep -cE '^[[:space:]]+sha256 "[0-9a-f]{64}"$' "$formula") + if [ "$shas" -eq 4 ]; then ok "quatre empreintes déclarées"; else ko "quatre empreintes déclarées" "$shas trouvées"; fi + + # Le bloc `on_linux` est inséré depuis une autre fichier : son indentation + # peut être aplatie sans que rien d'autre ne bouge, et `brew style` ne le + # signalerait qu'à la release. On vérifie donc l'imbrication. + check_contains "$(cat "$formula")" " on_linux do + on_intel do" "le bloc on_linux est imbriqué" + + # Invariant plus large, valable pour tout le corps de la classe : un + # commentaire calé à la colonne 0 y serait encore du Ruby valide, donc + # inoffensif — mais seulement parce qu'il est commentaire. Le jour où la même + # transformation aplatit un `on_linux do`, le fichier resterait parsable et le + # défaut passerait le test de syntaxe. + local col0 + col0=$(sed -n '/^class Typr < Formula/,$p' "$formula" \ + | grep '^[^[:space:]]' \ + | grep -cvE '^(class Typr < Formula|end)$' || true) + if [ "$col0" -eq 0 ]; then + ok "aucune ligne du corps de la classe sans indentation" + else + ko "aucune ligne du corps de la classe sans indentation" \ + "$col0 lignes calées à la colonne 0 : $(sed -n '/^class Typr < Formula/,$p' "$formula" | grep '^[^[:space:]]' | head -2)" + fi + + # Un marqueur non substitué partirait en production tel quel. + check_not_contains "$(cat "$formula")" "@@" "aucun marqueur résiduel dans la formule" + check_not_contains "$(cat "$readme")" "@@" "aucun marqueur résiduel dans le README" + + # Le README est dérivé de la formule, pas d'une deuxième source. + check_contains "$(cat "$readme")" "A typed superset of R — transpiler and type checker" \ + "le README reprend le desc de la formule" + check_contains "$(cat "$readme")" "https://we-data-ch.github.io/typr.github.io/" \ + "le README reprend le homepage de la formule" + check_contains "$(cat "$readme")" "brew install we-data-ch/typr/typr" \ + "le README donne la commande d'installation" + check_contains "$(cat "$readme")" "couvert" "le README annonce la couverture Linux" + + # Rien d'autre dans le dossier du tap : le job `install-hosting` refuse + # lui-même de laisser un reste, et un vieux fichier servirait encore à son + # adresse. + local extra + extra=$(cd "$dir" && find . -type f ! -path './Formula/typr.rb' ! -path './README.md') + if [ -z "$extra" ]; then ok "aucun fichier surnumeraire dans le tap"; else ko "aucun fichier surnumeraire dans le tap" "$extra"; fi +} + +# --------------------------------------------------------------------------- +# Sorties d'outils +# --------------------------------------------------------------------------- +test_pin_and_prerelease() { + echo "Version épinglée et prerelease" + local out dir + + # Sans le « v », comme install.sh qui le remet. + dir=$(work no-v) + out=$("$RENDER" --tag "9.9.9" --checksums "$FIXTURE" --out "$dir" 2>&1) + check_rc 0 $? "un tag sans le v initial est accepté" + check_contains "$(cat "$dir/Formula/typr.rb")" 'version "9.9.9"' "la version est rendue sans le v" + + # release.yml marque `-alpha.N` et `-beta.N` prerelease : la formule doit les + # accepter, sinon le canal Homebrew s'arrêterait à la première d'entre elles. + dir=$(work prerelease) + out=$("$RENDER" --tag v9.9.9-alpha.1 --checksums "$FIXTURE" --out "$dir" 2>&1) + check_rc 1 $? "une prerelease sans artefacts propres est refusée" + check_contains "$out" "ne contient aucune ligne pour" "l'absence est nommée, pas devinée" + + # Dossier de sortie imbriqué absent : il doit être créé. + dir="$ROOT/nested/deep/tap" + rm -rf "$ROOT/nested" + out=$("$RENDER" --tag "$TAG" --checksums "$FIXTURE" --out "$dir" 2>&1) + check_rc 0 $? "dossier de sortie imbriqué créé" + check_file "$dir/Formula/typr.rb" "la formule est écrite dans le dossier imbriqué" +} + +# --------------------------------------------------------------------------- +# Refus +# --------------------------------------------------------------------------- +test_missing_line() { + echo "Ligne absente, doublon, malformation" + local out dir rel + dir=$(work missing) + out=$(render "$dir") + check_rc 0 $? "rendu de référence" + + # Sans la ligne d'un artefact macOS : rien ne peut être rendu. v0.5.12 est la + # seule version publiée, mais c'est aussi le cas banal d'une release dont le + # job de build a échoué sur une cible. + rel=$(work sums-missing) + grep -v 'aarch64-apple-darwin' "$FIXTURE" > "$rel/checksums.txt" + out=$("$RENDER" --tag "$TAG" --checksums "$rel/checksums.txt" --out "$(work out-missing)" 2>&1) + check_rc 1 $? "checksums.txt sans la ligne attendue → échec" + check_contains "$out" "aarch64-apple-darwin" "l'artefact manquant est nommé" + check_no_file "$(work out-missing)/Formula/typr.rb" "aucune formule n'est écrite" + + # Deux lignes pour le même nom : choisir l'une des deux reviendrait à publier + # une empreinte arbitraire. + rel=$(work sums-dup) + { cat "$FIXTURE"; printf '%s typr-%s-x86_64-apple-darwin.tar.gz\n' \ + "$(fixture_sha "$TAG" aarch64-apple-darwin)" "$TAG"; } > "$rel/checksums.txt" + out=$("$RENDER" --tag "$TAG" --checksums "$rel/checksums.txt" --out "$(work out-dup)" 2>&1) + check_rc 1 $? "deux lignes pour le même artefact → échec" + check_contains "$out" "2 lignes" "le nombre de lignes est nommé" + check_no_file "$(work out-dup)/Formula/typr.rb" "aucune formule n'est écrite sur un doublon" + + # Malformée : la ligne existe, le SHA est illisible. Un fichier tronqué ne doit + # pas produire une formule que Homebrew refuserait ensuite — il le dirait par + # le nom du fichier, sans jamais nommer la release comme cause. + for bad in 'pas-un-sha' \ + "$(fixture_sha "$TAG" x86_64-apple-darwin | cut -c1-63)" \ + "$(fixture_sha "$TAG" x86_64-apple-darwin | cut -c1-63)z"; do + rel=$(work sums-bad) + awk -v bad="$bad" -v t="typr-$TAG-x86_64-apple-darwin.tar.gz" \ + '{ if ($NF == t) print bad " " $NF; else print }' "$FIXTURE" > "$rel/checksums.txt" + out=$("$RENDER" --tag "$TAG" --checksums "$rel/checksums.txt" --out "$(work out-bad)" 2>&1) + check_rc 1 $? "SHA malformé refusé : $bad" + check_contains "$out" "malform" "la malformation est nommée" + done + + # checksums.txt absent du tout. + out=$("$RENDER" --tag "$TAG" --checksums "$ROOT/inexistant.txt" --out "$(work out-ghost)" 2>&1) + check_rc 1 $? "checksums.txt absent → échec" + check_contains "$out" "introuvable" "l'absence est nommée" +} + +# --------------------------------------------------------------------------- +# Release sans binaire musl +# +# État réel de toutes les versions publiées : la formule doit rester valide en +# macOS seul plutôt que de refuser de se générer — et ne surtout pas pointer le +# binaire GNU à la place, ce qui réintroduirait le défaut `GLIBC_2.39 not found`. +# --------------------------------------------------------------------------- +test_without_musl() { + echo "Release sans binaire musl" + local out dir + dir=$(work legacy) + out=$("$RENDER" --tag "$LEGACY_TAG" --checksums "$LEGACY_FIXTURE" --out "$dir" 2>&1) + check_rc 0 $? "le rendu aboutit malgré l'absence de musl" + check_contains "$out" "on_linux est omis" "l'omission est annoncée" + + formula="$dir/Formula/typr.rb" + check_file "$formula" "la formule est écrite" + [ -f "$formula" ] || return 0 + + check_not_contains "$(cat "$formula")" "on_linux do" "aucun bloc on_linux" + # Le commentaire qui explique l'absence nomme les cibles attendues — c'est + # voulu. Ce qui ne doit pas exister, c'est une *référence* à un artefact + # Linux : le mot « musl » dans un commentaire ne téléchargerait rien. + check_not_contains "$(cat "$formula")" "download/$LEGACY_TAG/typr-$LEGACY_TAG-x86_64-unknown-linux" \ + "aucun artefact Linux référencé" + check_not_contains "$(cat "$formula")" "$(fixture_sha "$LEGACY_TAG" x86_64-unknown-linux-gnu "$LEGACY_FIXTURE")" \ + "le binaire GNU n'est pas utilisé comme repli" + check_contains "$(cat "$formula")" "ne publie pas encore de binaire musl" \ + "la formule explique pourquoi Linux manque" + check_contains "$(cat "$dir/README.md")" "pas encore" "le README ne promet pas Linux" + + local shas + shas=$(grep -cE '^[[:space:]]+sha256 "[0-9a-f]{64}"$' "$formula") + if [ "$shas" -eq 2 ]; then ok "deux empreintes déclarées (macOS)"; else ko "deux empreintes déclarées (macOS)" "$shas trouvées"; fi +} + +# --------------------------------------------------------------------------- +# Gabarit modifié : le générateur doit refuser plutôt que publier tel quel +# --------------------------------------------------------------------------- +test_template_drift() { + echo "Gabarit modifié" + local out dir sandbox + sandbox=$(work sandbox) + cp "$RENDER" "$sandbox/render.sh" + mkdir -p "$sandbox/Formula" "$sandbox/parts" + cp "$ROOT_DIR/Formula/typr.rb.in" "$sandbox/Formula/" + cp "$ROOT_DIR/parts/on_linux.rb.in" "$ROOT_DIR/parts/on_linux_absent.rb.in" "$sandbox/parts/" + cp "$ROOT_DIR/README.md.in" "$sandbox/" + + # Le marqueur est renommé côté gabarit, pas côté générateur : exactement le + # réagencement de nom qu'un commit peut introduire. + sed -i 's/@@VERSION@@/@@TYPR_VERSION@@/' "$sandbox/Formula/typr.rb.in" + out=$("$sandbox/render.sh" --tag "$TAG" --checksums "$FIXTURE" --out "$(work out-drift)" 2>&1) + check_rc 1 $? "un marqueur renommé est refusé" + check_contains "$out" "marqueur" "le marqueur non substitué est nommé" + check_no_file "$(work out-drift)/Formula/typr.rb" "aucune formule n'est écrite" + + # Retrait du retour à la ligne final : invisible dans une diff, fatal pour + # `brew style`. + cp "$ROOT_DIR/Formula/typr.rb.in" "$sandbox/Formula/" + printf '%s' "$(cat "$ROOT_DIR/Formula/typr.rb.in")" > "$sandbox/Formula/typr.rb.in" + out=$("$sandbox/render.sh" --tag "$TAG" --checksums "$FIXTURE" --out "$(work out-nonl)" 2>&1) + check_rc 1 $? "une formule sans retour à la ligne final est refusée" + check_contains "$out" "retour à la ligne" "la raison est nommée" +} + +# --------------------------------------------------------------------------- +# Lecture par URL — le mode réel, celui du job `brew` +# +# Le serveur de fixtures est celui de install/tests : il sert une arborescence +# réelle, donc un vrai chemin `/releases/download//checksums.txt`. Réécrire +# un deuxième serveur pour le même besoin serait du poids inutile. +# --------------------------------------------------------------------------- +test_url_mode() { + echo "Lecture par URL" + if ! command -v python3 >/dev/null 2>&1; then + printf ' – python3 absent, mode URL non exercé\n' + return 0 + fi + + local server_root port out dir + server_root=$(work origin) + mkdir -p "$server_root/we-data-ch/typr/releases/download/$TAG" + cp "$FIXTURE" "$server_root/we-data-ch/typr/releases/download/$TAG/checksums.txt" + + python3 "$ROOT_DIR/../../install/tests/fixture-server.py" "$server_root" \ + > "$server_root/.port" & + SERVER_PID=$! + + port="" + for _ in $(seq 1 50); do + port=$(grep -oE 'PORT [0-9]+' "$server_root/.port" 2>/dev/null | cut -d' ' -f2) + [ -n "$port" ] && break + sleep 0.1 + done + if [ -z "$port" ]; then + ko "le serveur de fixtures n'a pas démarré" + return 0 + fi + + # L'URL par défaut dérive du tag : c'est elle que le job `brew` utilise, sans + # jamais l'écrire en dur. + dir=$(work url) + out=$(TYPR_ORIGIN="http://127.0.0.1:$port" "$RENDER" --tag "$TAG" --out "$dir" 2>&1) + check_rc 0 $? "l'URL de checksums.txt est déduite du tag" + check_contains "$(cat "$dir/Formula/typr.rb" 2>/dev/null)" "$(fixture_sha "$TAG" x86_64-apple-darwin)" \ + "la formule rendue par URL a le bon sha256" + + # 404 : le message doit nommer l'URL, pas laisser croire à un bug de rendu. + out=$(TYPR_ORIGIN="http://127.0.0.1:$port" "$RENDER" --tag v1.2.3 --out "$(work url-404)" 2>&1) + check_rc 1 $? "une release absente échoue" + check_contains "$out" "introuvable" "l'absence est nommée" +} + +# --------------------------------------------------------------------------- + +main() { + printf 'render.sh — tests\n\n' + ROOT=$(mktemp -d) + + test_static_analysis + test_marker_drift + test_usage + test_render + test_pin_and_prerelease + test_missing_line + test_without_musl + test_template_drift + test_url_mode + + printf '\n%d réussis, %d échoués\n' "$PASSED" "$FAILED" + [ "$FAILED" -eq 0 ] +} + +main "$@" \ No newline at end of file From d99f55104a2d059b240f66c2c7ba929ab2b17150 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 15:24:05 +0200 Subject: [PATCH 42/52] =?UTF-8?q?types=20:=20g=C3=A9n=C3=A9riques=20implic?= =?UTF-8?q?ites=20Interface@Id=20(Type::Bounded)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parsing du suffixe @Id (S018 si mal formé), normalisation de signature, un rigide par id dans le corps (rigides imbriqués inclus), liaison des ids au site d'appel (T049 si un id reçoit deux types), erreurs T047/T048, R généré inchangé. Corrige PartialEq de Bounded (boucle dans hoist_aliases). Co-Authored-By: Claude Sonnet 5.5 --- .../typr-core/src/components/context/mod.rs | 8 + .../src/components/context/vartype.rs | 13 +- .../components/error_message/syntax_error.rs | 18 + .../components/error_message/type_error.rs | 55 ++ crates/typr-core/src/components/type/mod.rs | 28 + .../src/components/type/type_printer.rs | 2 + .../typr-core/src/processes/parsing/types.rs | 104 +++- .../transpiling/checked_assertions.rs | 3 + .../src/processes/transpiling/mod.rs | 24 + .../src/processes/type_checking/function.rs | 180 ++++++- .../type_checking/function_application.rs | 50 +- .../src/processes/type_checking/mod.rs | 1 + .../type_checking/signature_normalization.rs | 489 ++++++++++++++++++ crates/typr-mcp/src/explain.rs | 35 ++ 14 files changed, 992 insertions(+), 18 deletions(-) create mode 100644 crates/typr-core/src/processes/type_checking/signature_normalization.rs diff --git a/crates/typr-core/src/components/context/mod.rs b/crates/typr-core/src/components/context/mod.rs index 3c4a5b9..3757071 100644 --- a/crates/typr-core/src/components/context/mod.rs +++ b/crates/typr-core/src/components/context/mod.rs @@ -795,6 +795,10 @@ impl Context { } pub fn get_class(&self, t: &Type) -> String { + // `Lovable@A` dispatches and casts as its bound; the id is type-checker only. + if let Type::Bounded(_, bound, _) = t { + return self.get_class(bound); + } // For a named alias whose underlying type is a record or array, return the alias // name directly. push_types may have also registered the same underlying type with // an auto-generated "Record0"/"Array0" name; searching aliases by type value would @@ -816,6 +820,10 @@ impl Context { } pub fn get_class_unquoted(&self, t: &Type) -> String { + // `Lovable@A` dispatches and casts as its bound; the id is type-checker only. + if let Type::Bounded(_, bound, _) = t { + return self.get_class_unquoted(bound); + } // Same rationale as get_class: bypass the record/array alias search for named aliases. if let Type::Alias(name, _, false, _) = t { if let Some((_, underlying)) = self.aliases().find(|(v, _)| v.get_name() == *name) { diff --git a/crates/typr-core/src/components/context/vartype.rs b/crates/typr-core/src/components/context/vartype.rs index 740edcf..ae1a2f8 100644 --- a/crates/typr-core/src/components/context/vartype.rs +++ b/crates/typr-core/src/components/context/vartype.rs @@ -390,6 +390,8 @@ impl VarType { pub fn get_class(&self, t: &Type) -> String { let res = match t { + // `Lovable@A` is transpiled as its bound; the id is type-checker only. + Type::Bounded(_, bound, _) => return self.get_class(bound), Type::Integer(_, _) => "integer".to_string(), Type::Char(_, _) => "character".to_string(), Type::Boolean(_, _) => "logical".to_string(), @@ -495,6 +497,8 @@ impl VarType { pub fn get_type_anotation(&self, t: &Type) -> String { let res = match t { + // `Lovable@A` is transpiled as its bound; the id is type-checker only. + Type::Bounded(_, bound, _) => return self.get_type_anotation(bound), Type::Boolean(_, _) => "as.Boolean".to_string(), Type::Integer(_, _) => "as.Integer".to_string(), Type::Number(_, _) => "as.Number".to_string(), @@ -532,7 +536,10 @@ impl VarType { // function that was never defined, an R runtime "could not find // function as.FunctionN" error at the very first typed generic // function declared at project top level. - _ if t.has_generic() => "as.Generic".to_string(), + // An `I@Id` is a generic variable too, but `extract_generics` does not report it. + _ if t.has_generic() || crate::processes::type_checking::signature_normalization::has_bounded(t) => { + "as.Generic".to_string() + } _ => self .aliases .iter() @@ -545,6 +552,8 @@ impl VarType { pub fn get_type_anotation_no_parentheses(&self, t: &Type) -> String { match t { + // `Lovable@A` is transpiled as its bound; the id is type-checker only. + Type::Bounded(_, bound, _) => return self.get_type_anotation_no_parentheses(bound), Type::Boolean(_, _) => "logical".to_string(), Type::Integer(_, _) => "integer".to_string(), Type::Number(_, _) => "number".to_string(), @@ -561,6 +570,8 @@ impl VarType { pub fn get_class_unquoted(&self, t: &Type) -> String { match t { + // `Lovable@A` is transpiled as its bound; the id is type-checker only. + Type::Bounded(_, bound, _) => return self.get_class_unquoted(bound), Type::Integer(_, _) => "integer".to_string(), Type::Char(_, _) => "character".to_string(), Type::Boolean(_, _) => "logical".to_string(), diff --git a/crates/typr-core/src/components/error_message/syntax_error.rs b/crates/typr-core/src/components/error_message/syntax_error.rs index 3cebe23..6955437 100644 --- a/crates/typr-core/src/components/error_message/syntax_error.rs +++ b/crates/typr-core/src/components/error_message/syntax_error.rs @@ -45,6 +45,9 @@ pub enum SyntaxError { found: usize, help_data: HelpData, }, + /// `Lovable @A` / `Lovable@Self`: the `@Id` bound suffix must be one capital + /// letter touching the interface name. + DetachedBoundSuffix(HelpData), WithNode(Box, Box), } @@ -69,6 +72,7 @@ impl SyntaxError { SyntaxError::WrongCommentSyntax(h) => Some(h.clone()), SyntaxError::SingleEqualsComparison(h) => Some(h.clone()), SyntaxError::TupleDestructureArityMismatch { help_data, .. } => Some(help_data.clone()), + SyntaxError::DetachedBoundSuffix(h) => Some(h.clone()), SyntaxError::WithNode(_, inner) => inner.get_help_data(), } } @@ -97,6 +101,7 @@ impl SyntaxError { SyntaxError::WrongCommentSyntax(..) => "S015", SyntaxError::SingleEqualsComparison(..) => "S016", SyntaxError::TupleDestructureArityMismatch { .. } => "S017", + SyntaxError::DetachedBoundSuffix(..) => "S018", SyntaxError::WithNode(_, inner) => inner.code(), } } @@ -162,6 +167,9 @@ impl SyntaxError { SyntaxError::TupleDestructureArityMismatch { expected, found, .. } => { format!("Tuple destructuring expects {expected} element(s), but the source tuple has {found}") } + SyntaxError::DetachedBoundSuffix(_) => { + "The `@Id` suffix must be a single capital letter touching the interface name: write `Lovable@A`, not `Lovable @A` or `Lovable@Self`".to_string() + } SyntaxError::WithNode(_, inner) => inner.simple_message(), } } @@ -369,6 +377,16 @@ impl ErrorMsg for SyntaxError { }) .build() } + SyntaxError::DetachedBoundSuffix(help_data) => { + let (file_name, text) = help_data.get_file_data().unwrap_or_else(default_file_data); + SingleBuilder::new(file_name, text) + .kind("Syntax error") + .pos((help_data.get_offset(), 1)) + .text("Malformed `@Id` suffix") + .pos_text("Expected `@` + one capital letter, right after the name") + .help("Write `Lovable@A` (no space, one-letter id)") + .build() + } SyntaxError::WithNode(_, inner) => return inner.display(), }; msg.map_or_else(|e| format!("{:?}", e), |_| String::new()) diff --git a/crates/typr-core/src/components/error_message/type_error.rs b/crates/typr-core/src/components/error_message/type_error.rs index b7dd730..5599ac0 100644 --- a/crates/typr-core/src/components/error_message/type_error.rs +++ b/crates/typr-core/src/components/error_message/type_error.rs @@ -164,6 +164,13 @@ pub enum TypeError { InvalidRefinement(String, HelpData), /// A refined type no value can inhabit (`int & (> 5) & (< 3)`). UnsatisfiableRefinement(String, HelpData), + /// `Lovable@A` and `Printable@A` in one signature: `(id, first_bound, other_bound, position)`. + ConflictingBound(String, String, String, HelpData), + /// `fn(a: T, b: Lovable@T)`: an `I@Id` id that is also a free generic. `(id, position)`. + BoundCollidesWithGeneric(String, HelpData), + /// A call binds one `I@Id` to two different types: + /// `(id, first_type, first_arg_position, second_type, second_arg_position)`. + IdBoundToTwoTypes(String, String, HelpData, String, HelpData), } impl TypeError { @@ -216,6 +223,9 @@ impl TypeError { TypeError::UntypedFunctionArity(_, _, _, h) => Some(h.clone()), TypeError::InvalidRefinement(_, h) => Some(h.clone()), TypeError::UnsatisfiableRefinement(_, h) => Some(h.clone()), + TypeError::ConflictingBound(_, _, _, h) => Some(h.clone()), + TypeError::BoundCollidesWithGeneric(_, h) => Some(h.clone()), + TypeError::IdBoundToTwoTypes(_, _, _, _, h) => Some(h.clone()), } } @@ -270,6 +280,9 @@ impl TypeError { TypeError::UntypedFunctionArity(..) => "T044", TypeError::InvalidRefinement(..) => "T045", TypeError::UnsatisfiableRefinement(..) => "T046", + TypeError::ConflictingBound(..) => "T047", + TypeError::BoundCollidesWithGeneric(..) => "T048", + TypeError::IdBoundToTwoTypes(..) => "T049", } } @@ -500,6 +513,15 @@ impl TypeError { ) } TypeError::InvalidRefinement(msg, _) | TypeError::UnsatisfiableRefinement(msg, _) => msg.clone(), + TypeError::ConflictingBound(id, b1, b2, _) => { + format!("'{}' is bounded by both '{}' and '{}' in the same signature.", id, b1, b2) + } + TypeError::BoundCollidesWithGeneric(id, _) => { + format!("'{}' is used both as a free generic and as a bound id (`I@{}`).", id, id) + } + TypeError::IdBoundToTwoTypes(id, t1, _, t2, _) => { + format!("'{}' is bound to '{}' and then to '{}' in the same call.", id, t1, t2) + } } } } @@ -1120,6 +1142,39 @@ impl ErrorMsg for TypeError { .help("Its properties contradict each other; relax one of them.") .build() } + TypeError::ConflictingBound(id, b1, b2, help_data) => { + let (file_data, pos) = safe_file_pos(&help_data, 1); + SingleBuilder::new(file_data.0, file_data.1) + .pos(pos) + .text(format!("'{}' is bounded by both '{}' and '{}'", id, b1, b2)) + .pos_text(format!("'{}' already bounded by '{}'", id, b1)) + .help("One id names one type, so it can satisfy only one interface here. Use a different id for the second bound.") + .build() + } + TypeError::IdBoundToTwoTypes(id, t1, h1, t2, h2) => { + let ((file_name1, text1), pos1) = safe_file_pos(&h1, 0); + let ((file_name2, text2), pos2) = safe_file_pos(&h2, 1); + DoubleBuilder::new(file_name1, text1, file_name2, text2) + .pos1(pos1) + .pos2(pos2) + .text(format!("'{}' is bound to '{}' and then to '{}' in the same call", id, t1, t2)) + .pos_text1(format!("'{}' is '{}' here", id, t1)) + .pos_text2(format!("but '{}' here", t2)) + .help(format!( + "Every parameter declared `@{}` must receive the same type. Give the parameters different ids (`@A`, `@B`) if they may differ.", + id + )) + .build() + } + TypeError::BoundCollidesWithGeneric(id, help_data) => { + let (file_data, pos) = safe_file_pos(&help_data, 1); + SingleBuilder::new(file_data.0, file_data.1) + .pos(pos) + .text(format!("'{}' is both a free generic and a bound id", id)) + .pos_text("Bound id clashes with a generic of the same name") + .help("Rename one of them: generics and `I@Id` ids share one namespace within a signature.") + .build() + } TypeError::NoDispatchImplementation(name, forced_type, available, help_data) => { let (file_data, pos) = safe_file_pos(&help_data, name.len()); let available_pretty = available.iter().map(|t| t.pretty()).collect::>().join(", "); diff --git a/crates/typr-core/src/components/type/mod.rs b/crates/typr-core/src/components/type/mod.rs index f79b8d6..2abd0c5 100644 --- a/crates/typr-core/src/components/type/mod.rs +++ b/crates/typr-core/src/components/type/mod.rs @@ -142,6 +142,10 @@ pub enum Type { /// be folded into a `Refined` by `norm_intersection` and never survive /// `reduce_type`. Property(Refinement, HelpData), + /// `Lovable@A`: an implicit generic `A` bounded by an interface (RFC + /// `interface_generic_unification`). Appended last for the `.bin` layout. + /// Only meaningful in signatures; the id `_` is anonymous. + Bounded(String, Box, HelpData), } /// Structural fallback for interface-method-set comparison: for every @@ -223,6 +227,10 @@ impl TypeSystem for Type { fn is_subtype_raw(&self, other: &Type, context: &Context) -> bool { match (self, other) { (Type::Empty(_), _) => true, + // Rigid variables (`Lovable@A` in a body) are distinct unknowns: + // `PartialEq` deems any two generics equal, which must not make + // `__rigid_0` a subtype of `__rigid_1`. + (Type::Generic(a, _), Type::Generic(b, _)) if is_rigid_name(a) && is_rigid_name(b) => a == b, (typ1, typ2) if typ1 == typ2 => true, // Refined types (plan §Phase 4). Only `Proven` answers `true`: an // unproven refinement is never assumed, the boundary check of @@ -914,6 +922,7 @@ impl Type { Type::LabelGen(_, _) => TypeCategory::Generic, Type::IndexGen(_, _) => TypeCategory::GenericKinded(GKind::Number), Type::KindedGen(k, _, _) => TypeCategory::GenericKinded(GKind::from_kind(*k)), + Type::Bounded(_, bound, _) => bound.to_category(), Type::Integer(_, _) => TypeCategory::Integer, Type::Alias(_, _, false, _) => TypeCategory::Alias, Type::Alias(name, _, _, _) => TypeCategory::Opaque(name.clone()), @@ -996,6 +1005,7 @@ impl Type { Type::Null(h) => h.clone(), Type::NA(h) => h.clone(), Type::KindedGen(_, _, h) => h.clone(), + Type::Bounded(_, _, h) => h.clone(), Type::Refined(_, _, h) => h.clone(), Type::Property(_, h) => h.clone(), } @@ -1034,6 +1044,7 @@ impl Type { Type::Null(_) => Type::Null(h2), Type::NA(_) => Type::NA(h2), Type::KindedGen(k, a, _) => Type::KindedGen(k, a, h2), + Type::Bounded(id, b, _) => Type::Bounded(id, b, h2), Type::Refined(b, r, _) => Type::Refined(b, r, h2), Type::Property(p, _) => Type::Property(p, h2), } @@ -1368,6 +1379,7 @@ impl PartialEq for Type { (Type::Module(a1, _, _), Type::Module(a2, _, _)) => a1 == a2, (Type::Refined(b1, r1, _), Type::Refined(b2, r2, _)) => b1 == b2 && r1 == r2, (Type::Property(p1, _), Type::Property(p2, _)) => p1 == p2, + (Type::Bounded(i1, b1, _), Type::Bounded(i2, b2, _)) => i1 == i2 && b1 == b2, _ => false, } } @@ -1481,6 +1493,12 @@ impl Hash for Type { } Type::Char(_, _) => 3.hash(state), Type::Function(_, _, _) => 5.hash(state), + // Rigid names take part in the hash (though `PartialEq` ignores them) so + // the subtype cache keeps `(r0, r1)` apart from `(r0, r0)`. + Type::Generic(name, _) if is_rigid_name(name) => { + 6.hash(state); + name.hash(state); + } Type::Generic(_, _) => 6.hash(state), Type::IndexGen(_, _) => 7.hash(state), Type::LabelGen(_, _) => 8.hash(state), @@ -1519,6 +1537,11 @@ impl Hash for Type { 44.hash(state); p.hash(state); } + Type::Bounded(id, bound, _) => { + 45.hash(state); + id.hash(state); + bound.hash(state); + } } } } @@ -1867,3 +1890,8 @@ mod tests { assert!(!pos.is_subtype(&big, &ctx).0, "(> 0) does not imply (> 5)"); } } + +/// Name prefix given by `Context::fresh_rigid_name`. +pub(crate) fn is_rigid_name(name: &str) -> bool { + name.starts_with("__rigid_") +} diff --git a/crates/typr-core/src/components/type/type_printer.rs b/crates/typr-core/src/components/type/type_printer.rs index 6924674..12cb019 100644 --- a/crates/typr-core/src/components/type/type_printer.rs +++ b/crates/typr-core/src/components/type/type_printer.rs @@ -114,6 +114,7 @@ pub fn format(ty: &Type) -> String { Type::IndexGen(i, _) => format!("#{}", i), Type::LabelGen(l, _) => format!("${}", l), Type::KindedGen(k, name, _) => format!("{}{}", k, name), + Type::Bounded(id, bound, _) => format!("{}@{}", format(bound), id), Type::Tuple(elements, _) => { let body = elements.iter().map(format).collect::>().join(", "); format!("tuple{{{}}}", body) @@ -188,6 +189,7 @@ pub fn verbose(t: &Type) -> String { } Type::IndexGen(idgen, _) => format!("#{}", idgen), Type::KindedGen(k, name, _) => format!("{}{}", k, name), + Type::Bounded(id, bound, _) => format!("{}@{}", format(bound), id), Type::Vec(vtype, length, t, h) => { match length.range_property() { Some(prop) => format!("{}[{}] & {}", vtype, t.pretty2(), prop), diff --git a/crates/typr-core/src/processes/parsing/types.rs b/crates/typr-core/src/processes/parsing/types.rs index 2491fb4..67ee32f 100644 --- a/crates/typr-core/src/processes/parsing/types.rs +++ b/crates/typr-core/src/processes/parsing/types.rs @@ -28,12 +28,14 @@ use nom::character::complete::multispace0; use nom::character::complete::multispace1; use nom::character::complete::none_of; use nom::character::complete::one_of; +use nom::combinator::map; use nom::combinator::not; use nom::combinator::opt; use nom::combinator::recognize; use nom::multi::many0; use nom::multi::many1; use nom::sequence::delimited; +use nom::sequence::preceded; use nom::sequence::terminated; use nom::IResult; use nom::Parser; @@ -593,11 +595,44 @@ pub fn pascal_case_no_space(s: Span) -> IResult { } } +/// The `@Id` suffix of `Lovable@A`: an implicit generic `A` bounded by `Lovable`. +/// It must touch the alias name (no space), and `@_` is the anonymous id. +fn bound_suffix(s: Span) -> IResult { + preceded( + tag("@"), + alt(( + map(tag("_"), |_| "_".to_string()), + // One capital letter, not followed by more identifier characters: + // `@Self` or `@Abc` must fail rather than lose `elf` / `bc`. + map( + terminated(one_of("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), not(one_of( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_", + ))), + |c| c.to_string(), + ), + )), + ) + .parse(s) +} + pub fn type_alias(s: Span) -> IResult { - let res = (pascal_case_no_space, terminated(opt(type_params), multispace0)).parse(s); + let res = (pascal_case_no_space, opt(type_params), opt(bound_suffix), multispace0).parse(s); match res { - Ok((s, ((name, h), Some(v)))) => Ok((s, Type::Alias(name, v.clone(), false, h))), - Ok((s, ((name, h), None))) => Ok((s, Type::Alias(name, vec![], false, h))), + Ok((rest, ((name, h), params, None, ws))) + if (!ws.fragment().is_empty() && bound_suffix(rest.clone()).is_ok()) || rest.fragment().starts_with('@') => + { + // `Lovable @A` or `Lovable@Self`: a malformed suffix. Left alone, the priority + // resolver would silently drop the stray `@...` type. + push_parse_error(SyntaxError::DetachedBoundSuffix(rest.clone().into())); + Ok((rest, Type::Alias(name, params.unwrap_or_default(), false, h))) + } + Ok((s, ((name, h), params, bound, _))) => { + let alias = Type::Alias(name, params.unwrap_or_default(), false, h.clone()); + match bound { + Some(id) => Ok((s, Type::Bounded(id, Box::new(alias), h))), + None => Ok((s, alias)), + } + } Err(r) => Err(r), } } @@ -1234,6 +1269,69 @@ mod tests { use crate::components::r#type::type_system::TypeSystem; use crate::utils::builder; + fn bounded(src: &str) -> (String, String) { + match ltype(src.into()).unwrap().1 { + Type::Bounded(id, bound, _) => (id, bound.pretty()), + other => panic!("expected Bounded, got {:?}", other), + } + } + + #[test] + fn test_bounded_suffix_parses() { + assert_eq!(bounded("Lovable@A"), ("A".to_string(), "Lovable".to_string())); + assert_eq!(bounded("Lovable@_"), ("_".to_string(), "Lovable".to_string())); + assert_eq!(bounded("Eq@T"), ("T".to_string(), "Eq".to_string())); + } + + #[test] + fn test_bounded_suffix_rejects_long_ids() { + // `@Self` / `@Abc` must not parse as `@S` / `@A` with the tail swallowed. + assert!(bound_suffix("@Self".into()).is_err()); + assert!(bound_suffix("@Abc".into()).is_err()); + assert!(bound_suffix("@A".into()).is_ok()); + } + + #[test] + fn test_malformed_bound_suffix_is_an_error() { + for src in ["Lovable@Self", "Lovable@Abc"] { + let _ = crate::processes::parsing::take_parse_errors(); + let _ = ltype(src.into()); + let errors = crate::processes::parsing::take_parse_errors(); + assert!(errors.iter().any(|e| matches!(e, SyntaxError::DetachedBoundSuffix(_))), "{src}"); + } + } + + #[test] + fn test_bounded_suffix_nested() { + let t = ltype("[3, Lovable@A]".into()).unwrap().1; + assert!(matches!(&t, Type::Vec(_, _, elem, _) if matches!(elem.as_ref(), Type::Bounded(id, _, _) if id == "A"))); + let t = ltype("tuple{Lovable@B, Lovable@A}".into()).unwrap().1; + assert!(matches!(&t, Type::Tuple(v, _) if v.len() == 2 && v.iter().all(|e| matches!(e, Type::Bounded(..))))); + let t = ltype("(Lovable@A) -> Lovable@A".into()).unwrap().1; + assert!(matches!(&t, Type::Function(args, ret, _) + if matches!(&args[0].get_type(), Type::Bounded(..)) && matches!(ret.as_ref(), Type::Bounded(..)))); + } + + #[test] + fn test_bounded_suffix_prints_back() { + let t = ltype("Lovable@A".into()).unwrap().1; + assert_eq!(t.pretty(), "Lovable@A"); + } + + #[test] + fn test_bare_alias_and_sigil_unchanged() { + assert!(matches!(ltype("Lovable".into()).unwrap().1, Type::Alias(..))); + assert!(matches!(ltype("@A".into()).unwrap().1, Type::KindedGen(Kind::Interface, ..))); + } + + #[test] + fn test_detached_bound_suffix_is_an_error() { + let _ = crate::processes::parsing::take_parse_errors(); + let _ = ltype("Lovable @A".into()); + let errors = crate::processes::parsing::take_parse_errors(); + assert!(errors.iter().any(|e| matches!(e, SyntaxError::DetachedBoundSuffix(_)))); + } + #[test] fn test_refinement_property_parses() { let ok = |src: &str| refinement_property(src.into()).unwrap().1; diff --git a/crates/typr-core/src/processes/transpiling/checked_assertions.rs b/crates/typr-core/src/processes/transpiling/checked_assertions.rs index 4ef1108..1fda61a 100644 --- a/crates/typr-core/src/processes/transpiling/checked_assertions.rs +++ b/crates/typr-core/src/processes/transpiling/checked_assertions.rs @@ -64,6 +64,9 @@ fn checked_descriptor(context: &Context, typ: &Type) -> Option { Type::Char(_, _) => Some("\"character\"".to_string()), Type::Boolean(_, _) => Some("\"logical\"".to_string()), Type::Function(_, _, _) => Some("\"function\"".to_string()), + // `Lovable@A` asserts like its bound `Lovable`: the id only matters + // to the type checker, never to the runtime value. + Type::Bounded(_, bound, _) => checked_descriptor(context, bound), Type::Any(_) | Type::Generic(_, _) | Type::IndexGen(_, _) diff --git a/crates/typr-core/src/processes/transpiling/mod.rs b/crates/typr-core/src/processes/transpiling/mod.rs index 264596f..700e74d 100644 --- a/crates/typr-core/src/processes/transpiling/mod.rs +++ b/crates/typr-core/src/processes/transpiling/mod.rs @@ -1283,6 +1283,11 @@ impl RTranslatable<(String, Context)> for Lang { .map(|v| v.get_type()) .filter(|t| !matches!(t, Type::Empty(_) | Type::UnknownFunction(_))) .unwrap_or_else(|| typing(cont, expr).value); + // `Lovable@A` dispatches as its bound `Lovable`. + let related_type = match related_type { + Type::Bounded(_, bound, _) => *bound, + other => other, + }; let method = match cont.get_environment() { Environment::Project => format!( "#' @method {}\n", @@ -2909,6 +2914,25 @@ mod tests { ); } + #[test] + fn test_bounded_id_does_not_change_generated_r() { + // `Lovable@A` is a type-checker-only id: the generated R (S3 method + // suffix, `.default` fallback, return cast) must match the bare form. + let header = [ + "type Lovable <- interface { love: (Self) -> int };", + "type Cat <- list { name: char };", + "let love <- fn(c: Cat): int { 1 };", + ]; + let with = |def: &'static str| { + let mut p = header.to_vec(); + p.push(def); + transpile_program(&p) + }; + let bare = with("let idf <- fn(a: Lovable): Lovable { a };"); + let ided = with("let idf <- fn(a: Lovable@A): Lovable@A { a };"); + assert_eq!(bare, ided, "@A must not leak into the generated R"); + } + #[test] fn test_any_first_param_default_name_backticks_wrap_whole_name() { // `Type::Any` is left unsuffixed by `display_type`, so `.default` diff --git a/crates/typr-core/src/processes/type_checking/function.rs b/crates/typr-core/src/processes/type_checking/function.rs index 4989538..ec22d4e 100644 --- a/crates/typr-core/src/processes/type_checking/function.rs +++ b/crates/typr-core/src/processes/type_checking/function.rs @@ -2,9 +2,11 @@ use crate::components::error_message::syntax_error::SyntaxError; use crate::components::error_message::type_error::TypeError; use crate::components::error_message::typr_error::TypRError; use crate::components::language::var::Var; +use crate::components::r#type::is_rigid_name; use crate::components::r#type::kind::Kind; use crate::components::r#type::type_system::TypeSystem; use crate::processes::type_checking::facets; +use crate::processes::type_checking::signature_normalization; use crate::processes::type_checking::type_comparison::reduce_type; use crate::processes::type_checking::ArgumentType; use crate::processes::type_checking::HelpData; @@ -238,6 +240,67 @@ fn is_interface_return_only(params: &[ArgumentType], ret_ty: &Type, context: &Co !anchors.contains(&reduced_ret) } +/// The rigid standing for `id` in the body, created (with `bound` as its +/// interface constraint) the first time the id is met. +fn rigid_for_id( + ctx: &mut Context, + rigid_of_id: &mut std::collections::HashMap, + id: &str, + bound: Type, +) -> String { + if let Some(name) = rigid_of_id.get(id) { + return name.clone(); + } + let (name, new_ctx) = ctx.clone().fresh_rigid_name(); + *ctx = new_ctx.add_interface_constraint(name.clone(), reduce_type(ctx, &bound)); + rigid_of_id.insert(id.to_string(), name.clone()); + name +} + +/// Every `Bounded(id, bound)` in `typ` becomes the rigid of `id`. +fn replace_bounded_with_rigids( + ctx: &mut Context, + rigid_of_id: &mut std::collections::HashMap, + typ: &Type, + h: &HelpData, +) -> Type { + signature_normalization::rewrite(typ, &mut |t| match t { + Type::Bounded(id, bound, _) => { + let name = rigid_for_id(ctx, rigid_of_id, id, (**bound).clone()); + Some(Type::Generic(name, h.clone())) + } + _ => None, + }) +} + +/// Rewrites the rigids of `typ` back to what the user wrote (`Lovable@B`, or +/// plain `Lovable` for a bare interface) so an error never shows `__RIGID_1`. +fn name_rigids( + typ: &Type, + rigid_of_id: &std::collections::HashMap, + params: &[ArgumentType], +) -> Type { + let mut bounds: Vec<(String, Type)> = Vec::new(); + for p in params { + signature_normalization::visit(&p.get_type(), &mut |t| { + if let Type::Bounded(id, bound, _) = t { + bounds.push((id.clone(), (**bound).clone())); + } + }); + } + signature_normalization::rewrite(typ, &mut |t| { + let Type::Generic(name, h) = t else { return None }; + let (id, _) = rigid_of_id.iter().find(|(_, rigid)| *rigid == name)?; + let (_, bound) = bounds.iter().find(|(b, _)| b == id)?; + let bare = matches!(bound, Type::Alias(alias, ..) if alias == id); + Some(if bare { + bound.clone() + } else { + Type::Bounded(id.clone(), Box::new(bound.clone()), h.clone()) + }) + }) +} + /// Check if a type is a constrained rigid generic that satisfies the declared return type. /// This handles the case where the body returns a rigid variable `A` and the declared return /// is the interface `I` that constrains `A`. @@ -246,6 +309,13 @@ fn is_rigid_compatible(body_type: &Type, declared_ret: &Type, context: &Context) Type::Generic(name, _) => name, _ => return false, }; + // A declared rigid (`Lovable@B`) is matched by that very rigid only; the + // bound-based rule below would let any `Lovable` rigid through. + if let Type::Generic(declared, _) = declared_ret { + if is_rigid_name(declared) { + return declared == name; + } + } let Some(interface) = context.get_interface_constraint(name) else { return false; }; @@ -301,6 +371,11 @@ pub fn function( .with_errors(vec![TypRError::Type(TypeError::InterfaceReturnOnly(ret_ty.clone()))]); } + // RFC interface_generic_unification §7.3 — `I@Id` well-formedness. Only the + // errors are consumed for now; the desugared signature is used from phase 3. + let normalized = signature_normalization::normalize_signature(context, params, ret_ty); + let bound_errors = normalized.errors; + // RFC sigils.md §7 — intra-signature kind-consistency pass. let kind_consistency_errors = check_kind_consistency(params, ret_ty); let default_param_errors = check_default_params(params, context); @@ -323,23 +398,55 @@ pub fn function( // as the constraint, so `facets::record_facet` can still recover the // record side of a mixed intersection through the rigid generic. let mut sub_context = context.clone(); - for arg_typ in params { - let param_type = arg_typ.body_type(); + // D4: one rigid per `I@Id` identifier (a bare `I` being `I@I`), so two + // parameters sharing an id share their rigid, and distinct ids stay distinct. + let mut rigid_of_id: std::collections::HashMap = std::collections::HashMap::new(); + for (arg_typ, normalized_param) in params.iter().zip(normalized.params.iter()) { + // `Lovable@A` is typed through its bound; the id only decides sharing. + let param_type = match arg_typ.body_type() { + Type::Bounded(_, bound, _) => *bound, + other => other, + }; let reduced = reduce_type(&sub_context, ¶m_type); if facets::interface_facet(&sub_context, &reduced).is_some() { - let (rigid_name, new_ctx) = sub_context.clone().fresh_rigid_name(); - let rigid_type = Type::Generic(rigid_name.clone(), h.clone()); - sub_context = new_ctx.add_interface_constraint(rigid_name, reduced).push_var_type( + let id = match normalized_param.get_type() { + Type::Bounded(id, _, _) if !arg_typ.is_variadic() => Some(id), + _ => None, + }; + let rigid_name = match id { + Some(id) => rigid_for_id(&mut sub_context, &mut rigid_of_id, &id, reduced), + None => { + let (name, new_ctx) = sub_context.clone().fresh_rigid_name(); + sub_context = new_ctx.add_interface_constraint(name.clone(), reduced); + name + } + }; + let rigid_type = Type::Generic(rigid_name, h.clone()); + sub_context = sub_context.clone().push_var_type( Var::from_name(&arg_typ.get_argument_str()).set_type(rigid_type.clone()), rigid_type, &sub_context, ); + } else if !arg_typ.is_variadic() && signature_normalization::has_bounded(&normalized_param.get_type()) { + // `[#N, Lovable@A]`, `tuple{Lovable@A, T}`: the bound sits below the + // top, so the parameter keeps its shape with a rigid at each `@Id`. + let nested = replace_bounded_with_rigids(&mut sub_context, &mut rigid_of_id, &normalized_param.get_type(), h); + let var = arg_typ.clone().set_type(nested.clone()).to_var(&sub_context); + sub_context = sub_context.clone().push_var_type(var, nested, &sub_context); } else { let var = arg_typ.clone().set_type(param_type.clone()).to_var(&sub_context); sub_context = sub_context.clone().push_var_type(var, param_type.clone(), &sub_context); } } + // A return type `Lovable@B` (or `tuple{Lovable@B, …}`) is held to the rigid + // of `B`, not to any rigid whose bound is `Lovable`. + let checked_ret = if signature_normalization::has_bounded(&normalized.ret) { + replace_bounded_with_rigids(&mut sub_context, &mut rigid_of_id, &normalized.ret, h) + } else { + ret_ty.clone() + }; + // `Self:{ ... }` (generic_constructor.md §4.1): bind `Self` to whatever // type the first parameter ended up with in `sub_context` (the declared // type, or a fresh rigid generic for an interface param), so it's only @@ -354,16 +461,20 @@ pub fn function( // every early `return` inside the body — not just the trailing expression // — gets checked against it (see `Lang::Return` in `type_checking/mod.rs`). sub_context = sub_context - .set_expected_return_type(Some(ret_ty.clone())) + .set_expected_return_type(Some(checked_ret.clone())) .set_return_position(true); let body_type = body.typing(&sub_context); let mut errors = body_type.errors; + errors.extend(bound_errors); errors.extend(kind_consistency_errors); errors.extend(default_param_errors); errors.extend(undefined_alias_errors); - let is_compatible = is_compatible_return_type(&body_type.value, ret_ty, &sub_context); - (!is_compatible).then(|| errors.push(builder::unmatching_return_type(ret_ty, &body_type.value))); + let is_compatible = is_compatible_return_type(&body_type.value, &checked_ret, &sub_context); + (!is_compatible).then(|| { + let found = name_rigids(&body_type.value, &rigid_of_id, &normalized.params); + errors.push(builder::unmatching_return_type(ret_ty, &found)) + }); // Structural types registered on the fly while typing the body (e.g. the // `ArrayN` alias created by an inline `expr as! [T]` cast) must survive // into the outer context: the transpiler resolves them there to emit the @@ -1125,4 +1236,57 @@ mod tests { tc2.get_errors() ); } + + // ===================================================================== + // `I@Id` in the body (interface_generic_unification, phase 3) + // ===================================================================== + + /// Type-checks the interface declarations followed by `src` (one source, as + /// the CLI does); true when it is error-free. + fn body_is_ok(src: &str) -> bool { + use crate::components::context::Context; + use crate::processes::parsing::parse2; + use crate::processes::type_checking::type_checker::TypeChecker; + + let program = format!( + "type Lovable <- interface {{ love: (Self) -> char }};\n\ + type Same <- interface {{ same: (Self, Self) -> bool }};\n\ + type Cat <- list {{ name: char }};\n\ + let love <- fn(c: Cat): char {{ \"meow\" }};\n\ + let same <- fn(a: Cat, b: Cat): bool {{ true }};\n\ + let f <- {src};" + ); + let tc = TypeChecker::new(Context::default()).typing_no_panic(&crate::processes::parsing::parse_from_string(&program, "t.ty")); + !tc.has_errors() + } + + #[test] + fn test_bound_return_follows_its_own_id() { + assert!(body_is_ok("fn(a: Lovable@A, b: Lovable@B): Lovable@B { b }")); + assert!(body_is_ok("fn(a: Lovable@A, b: Lovable@B): Lovable@A { a }")); + } + + #[test] + fn test_bound_return_rejects_another_id() { + assert!(!body_is_ok("fn(a: Lovable@A, b: Lovable@B): Lovable@A { b }")); + assert!(!body_is_ok("fn(a: Lovable@A, b: Lovable@B): Lovable@B { a }")); + } + + #[test] + fn test_bound_methods_are_callable_on_distinct_ids() { + assert!(body_is_ok("fn(a: Lovable@A, b: Lovable@B): bool { a.love() == b.love() }")); + } + + #[test] + fn test_self_self_method_needs_a_shared_id() { + assert!(!body_is_ok("fn(a: Same@A, b: Same@B): bool { a.same(b) }")); + assert!(body_is_ok("fn(a: Same@A, b: Same@A): bool { a.same(b) }")); + // D3-A: two bare occurrences of an interface share one variable. + assert!(body_is_ok("fn(a: Same, b: Same): bool { a.same(b) }")); + } + + #[test] + fn test_bare_interface_return_is_tied_to_the_shared_variable() { + assert!(body_is_ok("fn(a: Lovable, b: Lovable): Lovable { b }")); + } } diff --git a/crates/typr-core/src/processes/type_checking/function_application.rs b/crates/typr-core/src/processes/type_checking/function_application.rs index 0c053e4..c72b463 100644 --- a/crates/typr-core/src/processes/type_checking/function_application.rs +++ b/crates/typr-core/src/processes/type_checking/function_application.rs @@ -9,6 +9,7 @@ use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::function_type::FunctionType; use crate::components::r#type::type_system::TypeSystem; use crate::processes::type_checking::facets; +use crate::processes::type_checking::signature_normalization::{self, CallInstance}; use crate::processes::type_checking::interface_satisfaction; use crate::processes::type_checking::match_types_to_generic; use crate::processes::type_checking::type_comparison::reduce_type; @@ -1296,7 +1297,32 @@ fn apply_from_variable_inner(var: Var, context: &Context, parameters: &[Lang], h let (expanded_parameters, types, param_errors, arg_context) = get_expanded_parameters_with_their_types(context, parameters); let context = &arg_context; - let all_signatures = var.get_functions_from_name(context); + let declared_signatures = var.get_functions_from_name(context); + // `I@Id` variables (and repeated bare interfaces) are bound against the + // arguments up front; the regular filters then see concrete signatures. + let mut id_clashes: Vec = Vec::new(); + let all_signatures: Vec = declared_signatures + .iter() + .filter_map(|sig| { + match signature_normalization::instantiate_at_call( + context, + &sig.get_param_types(), + &sig.get_return_type(), + sig.is_variadic(), + &types, + ) { + CallInstance::NotBounded => Some(sig.clone()), + CallInstance::Rejected => None, + CallInstance::Clash(clash) => { + id_clashes.push(clash); + None + } + CallInstance::Instantiated(params, ret) => { + Some(sig.clone().set_params(params).set_return_type(ret)) + } + } + }) + .collect(); let filters: &[FilterStep] = &[ FilterStep { @@ -1393,7 +1419,7 @@ fn apply_from_variable_inner(var: Var, context: &Context, parameters: &[Lang], h // empty) may mean it's declared in an in-scope module but never `use`d — // same idea as `VariableNotImported` for a bare variable reference, just // reached through function-call resolution instead of `Lang::Variable`. - let not_imported = all_signatures + let not_imported = declared_signatures .is_empty() .then(|| context.find_variable_source_module(&var.get_name())) .flatten(); @@ -1411,21 +1437,33 @@ fn apply_from_variable_inner(var: Var, context: &Context, parameters: &[Lang], h // `Lang::RFunction`'s typing rule) gets its own arity message instead // of `NoMatchingSignature`, which would otherwise print the callee's // own `Any` signature back at the caller as if it were informative. - None if all_signatures.len() == 1 && all_signatures[0].is_r_function() => { + None if declared_signatures.len() == 1 && declared_signatures[0].is_r_function() => { errors.push(TypRError::Type(TypeError::UntypedFunctionArity( var.get_name(), - all_signatures[0].get_param_types().len(), + declared_signatures[0].get_param_types().len(), types.len(), h.clone(), ))); } - None if !all_signatures.is_empty() => { + // Every signature was turned down for the same reason: one `@Id` + // bound to two argument types. Say which, instead of listing signatures. + None if !id_clashes.is_empty() && id_clashes.len() == declared_signatures.len() => { + let c = &id_clashes[0]; + errors.push(TypRError::Type(TypeError::IdBoundToTwoTypes( + c.id.clone(), + c.first_type.pretty(), + expanded_parameters[c.first_arg].get_help_data(), + c.second_type.pretty(), + expanded_parameters[c.second_arg].get_help_data(), + ))); + } + None if !declared_signatures.is_empty() => { // The name IS bound to function signature(s) — the call just // doesn't match any of them (wrong arity or argument types). // Reporting `FunctionNotFound` here reads as "the variable // doesn't exist", which is wrong and misleading (e.g. `f()` on // a 1-parameter lambda looked like lambda-lets were unsupported). - let signatures = all_signatures + let signatures = declared_signatures .iter() .map(|sig| { format!( diff --git a/crates/typr-core/src/processes/type_checking/mod.rs b/crates/typr-core/src/processes/type_checking/mod.rs index 0606ef7..73fd841 100644 --- a/crates/typr-core/src/processes/type_checking/mod.rs +++ b/crates/typr-core/src/processes/type_checking/mod.rs @@ -15,6 +15,7 @@ pub mod narrowing; pub mod partial_application; pub mod refinement_check; pub mod signature_expression; +pub mod signature_normalization; pub mod type_arithmetic; pub mod type_checker; pub mod type_comparison; diff --git a/crates/typr-core/src/processes/type_checking/signature_normalization.rs b/crates/typr-core/src/processes/type_checking/signature_normalization.rs new file mode 100644 index 0000000..00ec43a --- /dev/null +++ b/crates/typr-core/src/processes/type_checking/signature_normalization.rs @@ -0,0 +1,489 @@ +//! Signature normalisation for implicit interface generics (`Lovable@A`). +//! +//! RFC `interface_generic_unification`, plan phase 2. One pass over a function +//! signature (`params` + return type) that: +//! +//! - gives every anonymous `I@_` a fresh id (`_0`, `_1`, …); +//! - desugars a bare interface alias `I` into `I@I` (decision D3-A: one +//! variable shared by every bare occurrence of the same interface); +//! - reports the three ill-formed signatures of RFC §7.3: two different bounds +//! for one id, an id colliding with a free generic of the same name, an id +//! that only appears in the return type. +//! +//! The desugared signature is *returned* but not yet consumed: the body +//! (phase 3) and the call site (phase 4) still read the raw signature, so +//! only the errors are wired into `function()` for now. + +use crate::components::error_message::typr_error::TypRError; +use crate::components::error_message::type_error::TypeError; +use crate::components::r#type::argument_type::ArgumentType; +use crate::components::r#type::type_system::TypeSystem; +use crate::processes::type_checking::facets; +use crate::processes::type_checking::type_comparison::reduce_type; +use crate::Context; +use crate::Type; + +/// Id of an anonymous `I@_` before it is renamed. +const ANONYMOUS_ID: &str = "_"; + +pub struct NormalizedSignature { + pub params: Vec, + pub ret: Type, + pub errors: Vec, +} + +/// Rebuilds `typ` top-down: `f` may replace a node (and then its children are +/// not visited) or return `None` to descend into it. Mirrors the recursion +/// shape of `collect_generic_kind_occurrences` in `function.rs`. +pub(crate) fn rewrite(typ: &Type, f: &mut dyn FnMut(&Type) -> Option) -> Type { + if let Some(replacement) = f(typ) { + return replacement; + } + match typ { + Type::Function(args, ret, h) => Type::Function( + args.iter() + .map(|a| a.clone().set_type(rewrite(&a.get_type(), f))) + .collect(), + Box::new(rewrite(ret, f)), + h.clone(), + ), + Type::Vec(vt, len, body, h) => Type::Vec(vt.clone(), len.clone(), Box::new(rewrite(body, f)), h.clone()), + Type::Record(fields, h) => Type::Record( + fields + .iter() + .map(|a| a.clone().set_type(rewrite(&a.get_type(), f))) + .collect(), + h.clone(), + ), + Type::Tag(name, inner, h) => Type::Tag(name.clone(), Box::new(rewrite(inner, f)), h.clone()), + Type::Multi(inner, h) => Type::Multi(Box::new(rewrite(inner, f)), h.clone()), + Type::Operator(op, a, b, h) => { + Type::Operator(op.clone(), Box::new(rewrite(a, f)), Box::new(rewrite(b, f)), h.clone()) + } + Type::Params(ts, h) => Type::Params(ts.iter().map(|t| rewrite(t, f)).collect(), h.clone()), + Type::Tuple(ts, h) => Type::Tuple(ts.iter().map(|t| rewrite(t, f)).collect(), h.clone()), + Type::Alias(name, params, opaque, h) => Type::Alias( + name.clone(), + params.iter().map(|t| rewrite(t, f)).collect(), + *opaque, + h.clone(), + ), + other => other.clone(), + } +} + +/// Read-only walk with the same traversal as `rewrite`. +pub(crate) fn visit(typ: &Type, f: &mut dyn FnMut(&Type)) { + rewrite(typ, &mut |t| { + f(t); + None + }); +} + +/// Whether an `I@Id` variable occurs anywhere in `typ`. +pub(crate) fn has_bounded(typ: &Type) -> bool { + let mut found = false; + visit(typ, &mut |t| found |= matches!(t, Type::Bounded(..))); + found +} + +/// `(id, bound, position)` for every `Bounded` reachable in `typ`, in order. +fn bounded_occurrences(typ: &Type) -> Vec<(String, Type, crate::components::error_message::help_data::HelpData)> { + let mut acc = Vec::new(); + visit(typ, &mut |t| { + if let Type::Bounded(id, bound, h) = t { + acc.push((id.clone(), (**bound).clone(), h.clone())); + } + }); + acc +} + +/// Names of the free generics (`T`, and index generics `#N`) reachable in `typ`. +/// `@A` (`KindedGen`) is left out on purpose: RFC D1 lets `@A` and `I@A` name +/// the same variable. +fn free_generic_names(typ: &Type) -> Vec { + let mut acc = Vec::new(); + visit(typ, &mut |t| match t { + Type::Generic(name, _) | Type::IndexGen(name, _) => acc.push(name.clone()), + _ => {} + }); + acc +} + +fn rename_anonymous(params: &[ArgumentType], ret: &Type) -> (Vec, Type) { + let mut counter = 0usize; + let mut fresh = |t: &Type| match t { + Type::Bounded(id, bound, h) if id == ANONYMOUS_ID => { + let name = format!("_{}", counter); + counter += 1; + Some(Type::Bounded(name, bound.clone(), h.clone())) + } + _ => None, + }; + let params = params + .iter() + .map(|p| p.clone().set_type(rewrite(&p.get_type(), &mut fresh))) + .collect(); + let ret = rewrite(ret, &mut fresh); + (params, ret) +} + +/// `I` → `I@I` for every bare alias that reduces to an interface. Inline +/// `interface { … }` types have no name to share and are left alone, as is +/// anything already `Bounded`. +fn desugar_bare_interfaces(context: &Context, typ: &Type) -> Type { + rewrite(typ, &mut |t| match t { + Type::Bounded(..) => Some(t.clone()), + Type::Alias(name, args, _, h) if args.is_empty() => facets::interface_facet(context, t) + .is_some() + .then(|| Type::Bounded(name.clone(), Box::new(t.clone()), h.clone())), + _ => None, + }) +} + +pub fn normalize_signature(context: &Context, params: &[ArgumentType], ret: &Type) -> NormalizedSignature { + let (params, ret) = rename_anonymous(params, ret); + let mut errors = Vec::new(); + + let param_occurrences: Vec<_> = params.iter().flat_map(|p| bounded_occurrences(&p.get_type())).collect(); + let ret_occurrences = bounded_occurrences(&ret); + + // Two different bounds for one id. Aliases are compared by name (after + // reduction, so `type L2 <- Lovable` isn't a spurious conflict). + let all: Vec<_> = param_occurrences.iter().chain(ret_occurrences.iter()).collect(); + let mut reported: Vec<&String> = Vec::new(); + for (i, (id, bound, _)) in all.iter().enumerate() { + if reported.contains(&&*id) { + continue; + } + let reduced = reduce_type(context, bound); + let clash = all[i + 1..] + .iter() + .find(|(other_id, other, _)| other_id == id && reduce_type(context, other) != reduced); + if let Some((_, other, h)) = clash { + reported.push(&*id); + errors.push(TypRError::Type(TypeError::ConflictingBound( + id.clone(), + bound.pretty(), + other.pretty(), + h.clone(), + ))); + } + } + + // An id sharing its name with a free generic (`fn(a: T, b: Lovable@T)`). + let mut free: Vec = params.iter().flat_map(|p| free_generic_names(&p.get_type())).collect(); + free.extend(free_generic_names(&ret)); + let mut collided: Vec<&String> = Vec::new(); + for (id, _, h) in &all { + if free.contains(id) && !collided.contains(&id) { + collided.push(id); + errors.push(TypRError::Type(TypeError::BoundCollidesWithGeneric( + id.clone(), + h.clone(), + ))); + } + } + + // An id that only appears in the return type has nothing to be inferred + // from: same existential problem as a bare interface returned alone. + let mut orphaned: Vec<&String> = Vec::new(); + for (id, bound, h) in &ret_occurrences { + let anchored = param_occurrences.iter().any(|(pid, _, _)| pid == id); + if !anchored && !orphaned.contains(&id) { + orphaned.push(id); + errors.push(TypRError::Type(TypeError::InterfaceReturnOnly(Type::Bounded( + id.clone(), + Box::new(bound.clone()), + h.clone(), + )))); + } + } + + let params = params + .iter() + .map(|p| p.clone().set_type(desugar_bare_interfaces(context, &p.get_type()))) + .collect(); + let ret = desugar_bare_interfaces(context, &ret); + NormalizedSignature { params, ret, errors } +} + +/// Outcome of matching a call's arguments against a signature that carries +/// `I@Id` variables (declared, or desugared from repeated bare interfaces). +pub enum CallInstance { + /// Nothing to do: the signature has no shared interface variable. + NotBounded, + /// One argument does not satisfy its bound (or the shapes disagree). + Rejected, + /// Two arguments disagree on an id. + Clash(IdClash), + /// The signature with every `Bounded(id, _)` replaced by the type `id` + /// was bound to. Generics left (`#N`, `T`) are for the usual filters. + Instantiated(Vec, Type), +} + +/// `id` was bound to the type of argument `first_arg`, then met `second_type` +/// at `second_arg` (0-based positions in the call). +pub struct IdClash { + pub id: String, + pub first_arg: usize, + pub first_type: Type, + pub second_arg: usize, + pub second_type: Type, +} + +/// Two arguments sharing an id must have the same type (RFC §7.2): mutual +/// subtyping, since `Type`'s `PartialEq` is too loose for generics. +fn same_type(a: &Type, b: &Type, context: &Context) -> bool { + a.is_subtype_raw(b, context) && b.is_subtype_raw(a, context) +} + +/// Binds every `Bounded` in `param` against the matching part of `concrete`. +/// Shapes that do not line up are left to the regular filters. +fn bind_ids( + concrete: &Type, + param: &Type, + arg: usize, + context: &Context, + binds: &mut Vec<(String, Type, usize)>, + clash: &mut Option, +) -> bool { + match param { + Type::Bounded(id, bound, _) => { + if !concrete.is_subtype_raw(&reduce_type(context, bound), context) { + return false; + } + match binds.iter().find(|(k, _, _)| k == id) { + Some((_, previous, first_arg)) => { + let same = same_type(previous, concrete, context); + if !same && clash.is_none() { + *clash = Some(IdClash { + id: id.clone(), + first_arg: *first_arg, + first_type: previous.clone(), + second_arg: arg, + second_type: concrete.clone(), + }); + } + same + } + None => { + binds.push((id.clone(), concrete.clone(), arg)); + true + } + } + } + Type::Vec(_, _, elem_p, _) => match reduce_type(context, concrete) { + Type::Vec(_, _, elem_c, _) => bind_ids(&elem_c, elem_p, arg, context, binds, clash), + _ => true, + }, + Type::Tuple(ps, _) => match reduce_type(context, concrete) { + Type::Tuple(cs, _) if cs.len() == ps.len() => { + cs.iter().zip(ps.iter()).all(|(c, p)| bind_ids(c, p, arg, context, binds, clash)) + } + _ => true, + }, + Type::Function(ps, rp, _) => match reduce_type(context, concrete) { + Type::Function(cs, rc, _) if cs.len() == ps.len() => { + cs.iter() + .zip(ps.iter()) + .all(|(c, p)| bind_ids(&c.get_type(), &p.get_type(), arg, context, binds, clash)) + && bind_ids(&rc, rp, arg, context, binds, clash) + } + _ => true, + }, + _ => true, + } +} + +/// Plan phase 4 (D5). Normalises the signature, binds its ids against the +/// argument types, and substitutes them back. Variadic signatures are left to +/// the regular filters. +pub fn instantiate_at_call( + context: &Context, + params: &[Type], + ret: &Type, + variadic: bool, + arg_types: &[Type], +) -> CallInstance { + if variadic || params.len() != arg_types.len() { + return CallInstance::NotBounded; + } + // Cheap exit: a bound or a bare interface is always an alias/`Bounded` node. + let mut candidate = false; + for p in params { + visit(p, &mut |t| candidate |= matches!(t, Type::Alias(..) | Type::Bounded(..))); + } + if !candidate { + return CallInstance::NotBounded; + } + let args: Vec = params.iter().map(|t| ArgumentType::new("_", t)).collect(); + let normalized = normalize_signature(context, &args, ret); + let norm_params: Vec = normalized.params.iter().map(|p| p.get_type()).collect(); + let declared = params.iter().any(|p| !bounded_occurrences(p).is_empty()); + let occurrences: Vec = norm_params + .iter() + .flat_map(|p| bounded_occurrences(p)) + .map(|(id, _, _)| id) + .collect(); + let shared = occurrences + .iter() + .any(|id| occurrences.iter().filter(|other| *other == id).count() > 1); + if !declared && !shared { + return CallInstance::NotBounded; + } + + let mut binds = Vec::new(); + let mut clash = None; + if !arg_types + .iter() + .zip(norm_params.iter()) + .enumerate() + .all(|(i, (arg, param))| bind_ids(arg, param, i, context, &mut binds, &mut clash)) + { + return match clash { + Some(c) => CallInstance::Clash(c), + None => CallInstance::Rejected, + }; + } + let mut subst = |t: &Type| match t { + Type::Bounded(id, bound, _) => Some( + binds + .iter() + .find(|(k, _, _)| k == id) + .map(|(_, concrete, _)| concrete.clone()) + .unwrap_or_else(|| (**bound).clone()), + ), + _ => None, + }; + let new_params = norm_params.iter().map(|p| rewrite(p, &mut subst)).collect(); + let new_ret = rewrite(&normalized.ret, &mut subst); + CallInstance::Instantiated(new_params, new_ret) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::processes::parsing::parse2; + use crate::processes::type_checking::type_checker::TypeChecker; + + const DECLS: &str = "type Lovable <- interface { love: (Self) -> char };\n\ + type Printable <- interface { show: (Self) -> char };"; + + /// Type-checks the declarations then `fn_src`, and returns the error codes. + fn codes(fn_src: &str) -> Vec<&'static str> { + let tc1 = TypeChecker::new(Context::empty()).typing_no_panic(&parse2(DECLS.into()).unwrap()); + let tc2 = tc1.typing_no_panic(&parse2(fn_src.into()).unwrap()); + tc2.get_errors().iter().map(|e| e.code()).collect() + } + + #[test] + fn well_formed_signatures_are_accepted() { + assert!(codes("fn(a: Lovable@A, b: Lovable@B): bool { true }").is_empty()); + // A shared id is well-formed; typing its body is phase 3. + assert!(codes("fn(a: Lovable@A, b: Lovable@A): bool { true }").is_empty()); + } + + #[test] + fn conflicting_bounds_are_rejected() { + assert!(codes("fn(a: Lovable@A, b: Printable@A): bool { true }").contains(&"T047")); + } + + #[test] + fn id_colliding_with_a_free_generic_is_rejected() { + assert!(codes("fn(a: T, b: Lovable@T): bool { true }").contains(&"T048")); + } + + #[test] + fn id_only_in_return_position_is_rejected() { + assert!(codes("fn(a: Lovable@A): Lovable@B { a }").contains(&"T017")); + } + + #[test] + fn anonymous_ids_are_fresh_and_never_conflict() { + assert!(codes("fn(a: Lovable@_, b: Printable@_): bool { true }").is_empty()); + } + + #[test] + fn bare_interfaces_desugar_to_a_shared_id() { + let tc = TypeChecker::new(Context::empty()).typing_no_panic(&parse2(DECLS.into()).unwrap()); + let ctx = tc.get_context(); + let h = crate::components::error_message::help_data::HelpData::default(); + let bare = ArgumentType::new("a", &Type::Alias("Lovable".into(), vec![], false, h.clone())); + let out = normalize_signature(&ctx, &[bare.clone(), bare], &Type::Any(h)); + assert!(out + .params + .iter() + .all(|p| matches!(p.get_type(), Type::Bounded(ref id, _, _) if id == "Lovable"))); + } + + const WORLD: &str = "type Cat <- list { name: char };\n\ + type Dog <- list { age: int };\n\ + let love <- fn(c: Cat): char { c.name };\n\ + let love <- fn(d: Dog): char { \"dog\" };\n\ + let cat <- Cat:{ name = \"tom\" };\n\ + let dog <- Dog:{ age = 3 };\n"; + + /// Error codes of the declarations, `fn_src` and `call_src` typed as one + /// program (`parse2` silently drops what the CLI rejects). + fn call_codes(fn_src: &str, call_src: &str) -> Vec<&'static str> { + let program = format!("{}\n{}\n{}\n{}", DECLS, WORLD, fn_src, call_src); + let tc = TypeChecker::new(Context::default()) + .typing_no_panic(&crate::processes::parsing::parse_from_string(&program, "t.ty")); + tc.get_errors().iter().map(|e| e.code()).collect() + } + + const TWO_IDS: &str = "let f <- fn(a: Lovable@A, b: Lovable@B): Lovable@B { b };"; + const ONE_ID: &str = "let g <- fn(a: Lovable@X, b: Lovable@X): char { a.love() };"; + const BARE: &str = "let h <- fn(a: Lovable, b: Lovable): char { a.love() };"; + + #[test] + fn distinct_ids_accept_different_types_and_return_the_right_one() { + assert!(call_codes(TWO_IDS, "let r: Dog <- f(cat, dog);").is_empty()); + assert!(!call_codes(TWO_IDS, "let r: Cat <- f(cat, dog);").is_empty()); + } + + #[test] + fn shared_id_rejects_different_types() { + assert!(call_codes(ONE_ID, "g(cat, cat);").is_empty()); + assert!(!call_codes(ONE_ID, "g(cat, dog);").is_empty()); + } + + #[test] + fn bare_interface_repeated_behaves_as_a_shared_id() { + assert!(call_codes(BARE, "h(cat, cat);").is_empty()); + assert!(!call_codes(BARE, "h(cat, dog);").is_empty()); + } + + #[test] + fn bounded_identity_chains() { + let id = "let id <- fn(a: Lovable@A): Lovable@A { a };"; + assert!(call_codes(id, "let r: Cat <- id(id(cat));").is_empty()); + } + + #[test] + fn id_clash_at_a_call_names_the_id_and_both_types() { + assert_eq!(call_codes(ONE_ID, "g(cat, dog);"), vec!["T049"]); + } + + #[test] + fn swap_returns_the_ids_in_swapped_positions() { + let swap = "let swap <- fn(a: Lovable@A, b: Lovable@B): tuple{Lovable@B, Lovable@A} { :{b, a} };"; + assert!(call_codes(swap, "let r: tuple{Dog, Cat} <- swap(cat, dog);").is_empty()); + assert!(!call_codes(swap, "let r: tuple{Cat, Dog} <- swap(cat, dog);").is_empty()); + } + + #[test] + fn nested_bound_in_an_array_is_typed_through_its_rigid() { + let first = "let first <- fn(xs: [#N, Lovable@A]): Lovable@A { xs[1] };\nlet cs <- [cat, cat];"; + assert!(call_codes(first, "let r: Cat <- first(cs);").is_empty()); + assert!(!call_codes(first, "let r: Dog <- first(cs);").is_empty()); + } + + #[test] + fn bounded_is_equal_to_itself() { + let h = crate::components::error_message::help_data::HelpData::default(); + let b = || Type::Bounded("A".into(), Box::new(Type::Alias("L".into(), vec![], false, h.clone())), h.clone()); + assert_eq!(b(), b()); + } +} diff --git a/crates/typr-mcp/src/explain.rs b/crates/typr-mcp/src/explain.rs index 8a4519e..a128f8d 100644 --- a/crates/typr-mcp/src/explain.rs +++ b/crates/typr-mcp/src/explain.rs @@ -185,6 +185,16 @@ pub const ENTRIES: &[ExplainEntry] = &[ bad: "let :{a, b} <- :{1, 2, 3};", good: "let :{a, b, c} <- :{1, 2, 3};", }, + ExplainEntry { + code: "S018", + title: "Malformed `@Id` bound suffix", + explanation: "In `Lovable@A`, the `@A` suffix names an implicit generic bounded by the \ + interface `Lovable`. It must be one capital letter touching the interface name: with a \ + space, or a longer id like `@Self`, the tail would be read as a separate type and \ + silently dropped.", + bad: "let f <- fn(a: Lovable @A): bool { true };", + good: "let f <- fn(a: Lovable@A): bool { true };", + }, ExplainEntry { code: "T001", title: "Type mismatch on a 'let' binding", @@ -571,6 +581,31 @@ pub const ENTRIES: &[ExplainEntry] = &[ bad: "type Personne <- list{ name: char };\ntype Voiture <- list{ marque: char };\nlet greet <- fn(x: Personne, y: Personne): Personne { x };\nlet p1 <- Personne:{ name = \"a\" };\nlet p2 <- Personne:{ name = \"b\" };\ngreet(p1, p2);", good: "type Personne <- list{ name: char };\ntype Voiture <- list{ marque: char };\nlet greet <- fn(x: Personne, y: Personne): Personne { x };\nlet p1 <- Personne:{ name = \"a\" };\nlet p2 <- Personne:{ name = \"b\" };\ngreet(p1, p2);", }, + ExplainEntry { + code: "T047", + title: "Two different bounds for one `@Id`", + explanation: "An `@Id` names one type, so `Lovable@A` and `Printable@A` in the same \ + signature would ask that type to be both interfaces at once. Give the second bound \ + its own id.", + bad: "type Lovable <- interface { love: (Self) -> char };\ntype Printable <- interface { show: (Self) -> char };\nlet f <- fn(a: Lovable@A, b: Printable@A): bool { true };", + good: "type Lovable <- interface { love: (Self) -> char };\ntype Printable <- interface { show: (Self) -> char };\nlet f <- fn(a: Lovable@A, b: Printable@B): bool { true };", + }, + ExplainEntry { + code: "T048", + title: "Bound id clashes with a free generic", + explanation: "Free generics (`T`) and `I@Id` ids share one namespace inside a signature; \ + using `T` for both would make it unclear whether `T` is unconstrained or bounded.", + bad: "type Lovable <- interface { love: (Self) -> char };\nlet f <- fn(a: T, b: Lovable@T): bool { true };", + good: "type Lovable <- interface { love: (Self) -> char };\nlet f <- fn(a: T, b: Lovable@A): bool { true };", + }, + ExplainEntry { + code: "T049", + title: "One `@Id` bound to two different types in a call", + explanation: "Parameters that share an id (`Lovable@A`, or a bare `Lovable` used twice) must \ + receive the same type. Give them different ids when they may differ.", + bad: "type Lovable <- interface { love: (Self) -> char };\ntype Cat <- list { name: char };\ntype Dog <- list { age: int };\nlet love <- fn(c: Cat): char { c.name };\nlet love <- fn(d: Dog): char { \"dog\" };\nlet cat <- Cat:{ name = \"tom\" };\nlet dog <- Dog:{ age = 3 };\nlet f <- fn(a: Lovable@X, b: Lovable@X): char { a.love() };\nf(cat, dog);", + good: "type Lovable <- interface { love: (Self) -> char };\ntype Cat <- list { name: char };\ntype Dog <- list { age: int };\nlet love <- fn(c: Cat): char { c.name };\nlet love <- fn(d: Dog): char { \"dog\" };\nlet cat <- Cat:{ name = \"tom\" };\nlet dog <- Dog:{ age = 3 };\nlet f <- fn(a: Lovable@X, b: Lovable@Y): char { a.love() };\nf(cat, dog);", + }, ]; pub fn find(code: &str) -> Option<&'static ExplainEntry> { From 69ba63d7da2fe97523c39e804c785f7f449289bf Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 15:24:05 +0200 Subject: [PATCH 43/52] docs : RFC, plan et cas 0086-0088 pour Interface@Id Co-Authored-By: Claude Sonnet 5.5 --- .../case.toml | 7 + .../expect.md | 5 + .../expect.toml | 3 + .../observed.txt | 11 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 9 + .../case.toml | 7 + .../expect.md | 5 + .../expect.toml | 3 + .../observed.txt | 11 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 9 + .../case.toml | 7 + .../expect.md | 5 + .../expect.toml | 3 + .../observed.txt | 11 + .../repro/DESCRIPTION | 5 + .../repro/NAMESPACE | 1 + .../repro/R/.gitkeep | 0 .../repro/TypR/main.ty | 10 + interface_generic_unification2.md | 337 ++++++++++++++++ interface_generic_unification_plan.md | 370 ++++++++++++++++++ rfcs/0000-interface-bound-generics.md | 117 ++++++ 27 files changed, 948 insertions(+) create mode 100644 cases/0086-interface-return-bound-to-first-arg/case.toml create mode 100644 cases/0086-interface-return-bound-to-first-arg/expect.md create mode 100644 cases/0086-interface-return-bound-to-first-arg/expect.toml create mode 100644 cases/0086-interface-return-bound-to-first-arg/observed.txt create mode 100644 cases/0086-interface-return-bound-to-first-arg/repro/DESCRIPTION create mode 100644 cases/0086-interface-return-bound-to-first-arg/repro/NAMESPACE create mode 100644 cases/0086-interface-return-bound-to-first-arg/repro/R/.gitkeep create mode 100644 cases/0086-interface-return-bound-to-first-arg/repro/TypR/main.ty create mode 100644 cases/0087-interface-shared-id-different-types/case.toml create mode 100644 cases/0087-interface-shared-id-different-types/expect.md create mode 100644 cases/0087-interface-shared-id-different-types/expect.toml create mode 100644 cases/0087-interface-shared-id-different-types/observed.txt create mode 100644 cases/0087-interface-shared-id-different-types/repro/DESCRIPTION create mode 100644 cases/0087-interface-shared-id-different-types/repro/NAMESPACE create mode 100644 cases/0087-interface-shared-id-different-types/repro/R/.gitkeep create mode 100644 cases/0087-interface-shared-id-different-types/repro/TypR/main.ty create mode 100644 cases/0088-interface-self-self-distinct-ids/case.toml create mode 100644 cases/0088-interface-self-self-distinct-ids/expect.md create mode 100644 cases/0088-interface-self-self-distinct-ids/expect.toml create mode 100644 cases/0088-interface-self-self-distinct-ids/observed.txt create mode 100644 cases/0088-interface-self-self-distinct-ids/repro/DESCRIPTION create mode 100644 cases/0088-interface-self-self-distinct-ids/repro/NAMESPACE create mode 100644 cases/0088-interface-self-self-distinct-ids/repro/R/.gitkeep create mode 100644 cases/0088-interface-self-self-distinct-ids/repro/TypR/main.ty create mode 100644 interface_generic_unification2.md create mode 100644 interface_generic_unification_plan.md create mode 100644 rfcs/0000-interface-bound-generics.md diff --git a/cases/0086-interface-return-bound-to-first-arg/case.toml b/cases/0086-interface-return-bound-to-first-arg/case.toml new file mode 100644 index 0000000..2e16558 --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/case.toml @@ -0,0 +1,7 @@ +title = "a return typed Lovable@B is bound to the first argument, not to B" +cmd = "check" +layer = "type" +status = "open" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0086-interface-return-bound-to-first-arg/expect.md b/cases/0086-interface-return-bound-to-first-arg/expect.md new file mode 100644 index 0000000..122be36 --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/expect.md @@ -0,0 +1,5 @@ +# a return typed Lovable@B is bound to the first argument, not to B + +Avec `fn(a: Lovable@A, b: Lovable@B): Lovable@B`, `let s: Cat <- second(cat, dog)` doit être rejeté (le retour est un `Dog`). Aujourd'hui `@A`/`@B` sont avalés et le retour est lié au premier argument : le programme passe. + +Référence : `interface_generic_unification_plan.md` §0 (défaut 1), phases 3-4. diff --git a/cases/0086-interface-return-bound-to-first-arg/expect.toml b/cases/0086-interface-return-bound-to-first-arg/expect.toml new file mode 100644 index 0000000..36b8585 --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/expect.toml @@ -0,0 +1,3 @@ +[[rule]] +file = "@run" +must_contain = "Type errors found" diff --git a/cases/0086-interface-return-bound-to-first-arg/observed.txt b/cases/0086-interface-return-bound-to-first-arg/observed.txt new file mode 100644 index 0000000..627e23b --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/observed.txt @@ -0,0 +1,11 @@ +# exit=0 + +## stdout/stderr +Code verification successful! + + Parsing... done (7 ms) + Type checking... done (28 ms) + + +## R généré + diff --git a/cases/0086-interface-return-bound-to-first-arg/repro/DESCRIPTION b/cases/0086-interface-return-bound-to-first-arg/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0086-interface-return-bound-to-first-arg/repro/NAMESPACE b/cases/0086-interface-return-bound-to-first-arg/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0086-interface-return-bound-to-first-arg/repro/R/.gitkeep b/cases/0086-interface-return-bound-to-first-arg/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0086-interface-return-bound-to-first-arg/repro/TypR/main.ty b/cases/0086-interface-return-bound-to-first-arg/repro/TypR/main.ty new file mode 100644 index 0000000..f7d288e --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/repro/TypR/main.ty @@ -0,0 +1,9 @@ +type Lovable <- interface { love: (Self) -> int }; +type Cat <- list { name: char }; +type Dog <- list { age: int }; +let love <- fn(c: Cat): int { 1 }; +let love <- fn(d: Dog): int { 2 }; +let cat <- Cat:{ name = "tom" }; +let dog <- Dog:{ age = 3 }; +let second <- fn(a: Lovable@A, b: Lovable@B): Lovable@B { b }; +let s: Cat <- second(cat, dog); diff --git a/cases/0087-interface-shared-id-different-types/case.toml b/cases/0087-interface-shared-id-different-types/case.toml new file mode 100644 index 0000000..8f56e5d --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/case.toml @@ -0,0 +1,7 @@ +title = "two parameters sharing the identifier X must receive the same type" +cmd = "check" +layer = "type" +status = "open" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0087-interface-shared-id-different-types/expect.md b/cases/0087-interface-shared-id-different-types/expect.md new file mode 100644 index 0000000..a7f505a --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/expect.md @@ -0,0 +1,5 @@ +# two parameters sharing the identifier X must receive the same type + +Avec `fn(a: Lovable@X, b: Lovable@X)`, `same_kind(cat, dog)` doit être rejeté (X lié à Cat puis Dog). Aujourd'hui `@X` est avalé silencieusement et l'appel passe. + +Référence : `interface_generic_unification_plan.md` §0 (défaut 2), phases 3-4. diff --git a/cases/0087-interface-shared-id-different-types/expect.toml b/cases/0087-interface-shared-id-different-types/expect.toml new file mode 100644 index 0000000..36b8585 --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/expect.toml @@ -0,0 +1,3 @@ +[[rule]] +file = "@run" +must_contain = "Type errors found" diff --git a/cases/0087-interface-shared-id-different-types/observed.txt b/cases/0087-interface-shared-id-different-types/observed.txt new file mode 100644 index 0000000..9a8e772 --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/observed.txt @@ -0,0 +1,11 @@ +# exit=0 + +## stdout/stderr +Code verification successful! + + Parsing... done (6 ms) + Type checking... done (26 ms) + + +## R généré + diff --git a/cases/0087-interface-shared-id-different-types/repro/DESCRIPTION b/cases/0087-interface-shared-id-different-types/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0087-interface-shared-id-different-types/repro/NAMESPACE b/cases/0087-interface-shared-id-different-types/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0087-interface-shared-id-different-types/repro/R/.gitkeep b/cases/0087-interface-shared-id-different-types/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0087-interface-shared-id-different-types/repro/TypR/main.ty b/cases/0087-interface-shared-id-different-types/repro/TypR/main.ty new file mode 100644 index 0000000..9f7a133 --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/repro/TypR/main.ty @@ -0,0 +1,9 @@ +type Lovable <- interface { love: (Self) -> int }; +type Cat <- list { name: char }; +type Dog <- list { age: int }; +let love <- fn(c: Cat): int { 1 }; +let love <- fn(d: Dog): int { 2 }; +let cat <- Cat:{ name = "tom" }; +let dog <- Dog:{ age = 3 }; +let same_kind <- fn(a: Lovable@X, b: Lovable@X): int { a.love() }; +same_kind(cat, dog); diff --git a/cases/0088-interface-self-self-distinct-ids/case.toml b/cases/0088-interface-self-self-distinct-ids/case.toml new file mode 100644 index 0000000..8e27012 --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/case.toml @@ -0,0 +1,7 @@ +title = "a (Self, Self) method on two distinct rigid ids is rejected" +cmd = "check" +layer = "type" +status = "open" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0088-interface-self-self-distinct-ids/expect.md b/cases/0088-interface-self-self-distinct-ids/expect.md new file mode 100644 index 0000000..6d85372 --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/expect.md @@ -0,0 +1,5 @@ +# a (Self, Self) method on two distinct rigid ids is rejected + +Avec `fn(a: Same@A, b: Same@B): bool { a.same(b) }`, `b : B` ne satisfait pas le paramètre `A` : erreur attendue dans le corps. Aujourd'hui accepté (`@A`/`@B` avalés, et rigides distincts jugés compatibles). + +Référence : `interface_generic_unification_plan.md` §0 (défaut 3), phases 3-4. diff --git a/cases/0088-interface-self-self-distinct-ids/expect.toml b/cases/0088-interface-self-self-distinct-ids/expect.toml new file mode 100644 index 0000000..36b8585 --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/expect.toml @@ -0,0 +1,3 @@ +[[rule]] +file = "@run" +must_contain = "Type errors found" diff --git a/cases/0088-interface-self-self-distinct-ids/observed.txt b/cases/0088-interface-self-self-distinct-ids/observed.txt new file mode 100644 index 0000000..5a33353 --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/observed.txt @@ -0,0 +1,11 @@ +# exit=0 + +## stdout/stderr +Code verification successful! + + Parsing... done (8 ms) + Type checking... done (27 ms) + + +## R généré + diff --git a/cases/0088-interface-self-self-distinct-ids/repro/DESCRIPTION b/cases/0088-interface-self-self-distinct-ids/repro/DESCRIPTION new file mode 100644 index 0000000..b5d35be --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0088-interface-self-self-distinct-ids/repro/NAMESPACE b/cases/0088-interface-self-self-distinct-ids/repro/NAMESPACE new file mode 100644 index 0000000..e651b94 --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0088-interface-self-self-distinct-ids/repro/R/.gitkeep b/cases/0088-interface-self-self-distinct-ids/repro/R/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cases/0088-interface-self-self-distinct-ids/repro/TypR/main.ty b/cases/0088-interface-self-self-distinct-ids/repro/TypR/main.ty new file mode 100644 index 0000000..fc5b6cc --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/repro/TypR/main.ty @@ -0,0 +1,10 @@ +type Lovable <- interface { love: (Self) -> int }; +type Cat <- list { name: char }; +type Dog <- list { name: char, age: int }; +let love <- fn(c: Cat): int { 1 }; +let love <- fn(d: Dog): int { 2 }; +let cat <- Cat:{ name = "tom" }; +let dog <- Dog:{ name = "rex", age = 3 }; +type Same <- interface { same: (Self, Self) -> bool }; +let same <- fn(a: Cat, b: Cat): bool { true }; +let f <- fn(a: Same@A, b: Same@B): bool { a.same(b) }; diff --git a/interface_generic_unification2.md b/interface_generic_unification2.md new file mode 100644 index 0000000..1e03480 --- /dev/null +++ b/interface_generic_unification2.md @@ -0,0 +1,337 @@ +# RFC TypR – Génériques implicites basés sur les interfaces + +> **Objet :** permettre plusieurs paramètres génériques distincts partageant la même interface, +> sans introduire de `T`, `U` explicites, ni casser le modèle “interface = type”. + +--- + +## 1. Contexte et objectifs + +### 1.1. Contexte + +Dans TypR : + +- **Les interfaces sont des types à part entière**, définis via des alias : + ```typr + type Printable <- interface { + print: (Self) -> char + }; + ``` +- Une interface sert **à la fois** : + - de **type structurel** (annotation classique), + - de **contrainte générique** (on peut écrire `fn(xs: Iterable): Iterable`). + +Ce modèle est volontairement simple pour des développeurs venant de R : +pas de `T`, `U`, pas de syntaxe de type paramétrique explicite. + +### 1.2. Problème + +Avec ce modèle, une fonction : + +```typr +let compare <- fn(a: Lovable, b: Lovable): bool { + ... +}; +``` + +ne peut pas distinguer deux **types concrets différents** pour `a` et `b` : + +- `Lovable` est un **type structurel unique**, +- donc `a` et `b` sont du **même type structurel**. + +On ne peut pas exprimer : +“`a` est un type qui satisfait `Lovable`, `b` est un autre type (potentiellement différent) qui satisfait aussi `Lovable`”. + +### 1.3. Objectif + +Introduire un mécanisme de **génériques implicites** qui : + +- **sépare les paramètres** (`a` et `b` peuvent être de types différents), +- **reste structurel** (pas de nominalisme), +- **reste simple** pour des devs R (pas de `T`, `U`), +- **respecte** le modèle mental : *interface = type = contrainte*. + +--- + +## 2. Design proposé – paramètres implicites `@Id` + +### 2.1. Idée centrale + +On ajoute une **annotation légère** sur les paramètres : + +```typr +let compare <- fn(a: Lovable@A, b: Lovable@B): bool { + ... +}; +``` + +- `Lovable` : interface/type structurel. +- `@A`, `@B` : **identifiants de paramètre générique**, *non typés*. +- Chaque identifiant (`A`, `B`) représente un **paramètre générique distinct**. + +### 2.2. Règle intuitive + +- Deux annotations `Lovable@A` et `Lovable@B` : + - partagent la **même interface** (`Lovable`), + - mais sont **deux paramètres génériques différents**. +- L’inférence peut donc déduire : + - `A = Cat`, + - `B = Dog`, + - tant que `Cat` et `Dog` satisfont `Lovable`. + +--- + +## 3. Syntaxe formelle + +### 3.1. Définition d’interface + +Syntaxe inchangée : + +```typr +type Lovable <- interface { + love: (Self) -> char +}; +``` + +### 3.2. Annotation de paramètre + +#### 3.2.1. Forme générale + +Pour un paramètre : + +```typr +name: Interface@Id +``` + +- **`name`** : identifiant du paramètre (variable). +- **`Interface`** : alias de type pointant vers une interface. +- **`Id`** : identifiant de paramètre générique (nom court, style `A`, `B`, `Item`, etc.). + +#### 3.2.2. Forme minimale + +Si aucun `@Id` n’est fourni : + +```typr +name: Interface +``` + +- Comportement **actuel** : + tous les paramètres annotés avec `Interface` **partagent le même type structurel**. +- Ce mode reste valide et est le **mode par défaut**. + +#### 3.2.3. Exemple + +```typr +let pair <- fn(a: Lovable@Left, b: Lovable@Right): (Lovable@Left, Lovable@Right) { + (a, b) +}; +``` + +--- + +## 4. Sémantique – modèle de types + +### 4.1. Interfaces comme types structurels + +On conserve : + +- Une interface `I` est un **type structurel**. +- Un alias : + ```typr + type I <- interface { ... }; + ``` + introduit un **type nommé** `I` avec une **structure requise**. + +### 4.2. Paramètres génériques implicites + +Pour chaque identifiant `Id` utilisé dans `Interface@Id` : + +- Le compilateur introduit un **paramètre de type implicite** \(\alpha_{Id}\). +- La contrainte associée est : + \[ + \alpha_{Id} : Interface + \] + c’est‑à‑dire : + “\(\alpha_{Id}\) doit satisfaire la structure de `Interface`”. + +### 4.3. Distinction des paramètres + +Deux annotations : + +```typr +a: Lovable@A +b: Lovable@B +``` + +induisent : + +- deux paramètres de type distincts \(\alpha_A\) et \(\alpha_B\), +- chacun contraint par `Lovable`. + +Il n’y a **aucune relation** imposée entre \(\alpha_A\) et \(\alpha_B\) +(sauf si le corps de la fonction impose des contraintes supplémentaires). + +--- + +## 5. Règles d’inférence + +### 5.1. Collecte des paramètres + +Pour une fonction : + +```typr +let f <- fn(a: Lovable@A, b: Lovable@B): bool { + ... +}; +``` + +Le compilateur : + +1. **Collecte** les identifiants `A`, `B`. +2. Crée un ensemble de paramètres de type : + \[ + \{\alpha_A, \alpha_B\} + \] +3. Associe les contraintes : + \[ + \alpha_A : Lovable,\quad \alpha_B : Lovable + \] + +### 5.2. Résolution à l’appel + +À l’appel : + +```typr +f(cat, dog); +``` + +Le compilateur : + +- infère le type de `cat` : `Cat`, +- infère le type de `dog` : `Dog`, +- vérifie : + - `Cat` satisfait `Lovable`, + - `Dog` satisfait `Lovable`, +- unifie : + - \(\alpha_A = Cat\), + - \(\alpha_B = Dog\). + +### 5.3. Cas sans `@Id` + +Pour : + +```typr +let f <- fn(a: Lovable, b: Lovable): bool { + ... +}; +``` + +Le compilateur : + +- introduit **un seul** paramètre \(\alpha\), +- avec contrainte : + \[ + \alpha : Lovable + \] +- à l’appel, `a` et `b` doivent être du **même type** \(\alpha\). + +--- + +## 6. Exemples d’usage + +### 6.1. Comparaison de deux Lovable distincts + +```typr +type Lovable <- interface { + love: (Self) -> char +}; + +let compare <- fn(a: Lovable@A, b: Lovable@B): bool { + a.love() == b.love() +}; +``` + +- `A` et `B` peuvent être deux types différents. +- On compare leur “score d’amour”. + +### 6.2. Fonction symétrique + +```typr +let swap <- fn(a: Lovable@A, b: Lovable@B): (Lovable@B, Lovable@A) { + (b, a) +}; +``` + +- Le type de retour encode la permutation des paramètres génériques. + +### 6.3. Interface générique + paramètres implicites + +```typr +type Box <- interface { + value: T +}; + +let merge <- fn(a: Box@A, b: Box@B): (Box@A, Box@B) { + (a, b) +}; +``` + +- `Box@A` et `Box@B` peuvent encapsuler des types différents. +- L’inférence doit gérer : + - le paramètre implicite `A`, + - le paramètre explicite `T` de `Box`. + +--- + +## 7. Points ouverts et variantes + +### 7.1. Identifiants optionnels + +On peut autoriser : + +- `Lovable@A` (nom explicite), +- `Lovable@_` (paramètre anonyme), +- `Lovable#` (syntaxe alternative plus légère). + +À discuter selon la **lisibilité** et la **culture R**. + +### 7.2. Réutilisation d’un identifiant + +Si on écrit : + +```typr +let pair <- fn(a: Lovable@X, b: Lovable@X): (Lovable@X, Lovable@X) { + (a, b) +}; +``` + +- `a` et `b` partagent le **même paramètre générique** \(\alpha_X\), +- donc doivent être du **même type**. + +C’est l’équivalent de `T` réutilisé dans les langages classiques, +mais sans introduire `T` comme type nominal. + +### 7.3. Erreurs de typage + +Cas à définir clairement : + +- utilisation de `Lovable@A` dans le retour sans paramètre correspondant, +- identifiant `@A` utilisé mais jamais contraint, +- conflit entre deux interfaces incompatibles sur le même `@A`. + +--- + +## 8. Résumé conceptuel + +- **Interfaces** restent : + - des **types structurels**, + - des **contrats**, + - des **espaces de fonctions**. +- Les **génériques implicites** sont : + - des **identifiants de contrainte** (`@A`, `@B`), + - non typés, + - résolus par **inférence**. + +> On ne demande jamais au développeur R de “penser en `T`, `U`”. +> On lui dit juste : +> “si tu veux que deux paramètres soient indépendants, donne‑leur chacun un petit tag `@A`, `@B`”. diff --git a/interface_generic_unification_plan.md b/interface_generic_unification_plan.md new file mode 100644 index 0000000..bf35bd1 --- /dev/null +++ b/interface_generic_unification_plan.md @@ -0,0 +1,370 @@ +# Génériques implicites `Interface@Id` — plan d'implémentation + +Plan pour `interface_generic_unification2.md` (RFC), écrit après lecture de `typr-core` et sondes +contre le binaire (`target/debug/typr`, branche `develop`, état au 2026-09-30). + +--- + +## 0. Ce qui existe déjà (et ce que les sondes révèlent) + +| Élément du RFC | État actuel dans le code | +|---|---| +| Interface en paramètre → variable rigide contrainte | **Déjà en place dans le corps** : `type_checking/function.rs:326-340` crée un `Type::Generic("__rigid_N")` + `add_interface_constraint` **par paramètre**. Même mécanique dans `specialize_lambda` (`function_application.rs:1061`). | +| Élimination `e.m() : T[Self ↦ A]` | En place : `try_constrained_variable_match` (`function_application.rs:29`, FILTERING 0). | +| Instanciation au site d'appel | `try_interface_subtype_match` (`function_application.rs:624`) : table `interface_to_concrete` **indexée par l'interface réduite**, et c'est le premier argument qui gagne. | +| Retour interface sans paramètre (§7.3a) | Déjà une erreur : `TypeError::InterfaceReturnOnly` (`function.rs`, `is_interface_return_only`). | +| Sigil `@` en position de type | **Déjà pris** en préfixe : `@A` → `Type::KindedGen(Kind::Interface, "A")` (`parsing/types.rs:917`, RFC `sigils.md`). | +| Génériques explicites `T`, `U` | Fonctionnent correctement : `fn(a: T, b: U): U` appelé avec `(cat, dog)` rend `Dog`. | +| Interfaces paramétrées `interface` (RFC §6.3) | **N'existent pas** (aucune occurrence dans le parseur). | + +Les sondes (fichiers `*.ty` avec `Lovable`, `Cat`, `Dog`) montrent que **le corps et le site d'appel +appliquent deux sémantiques différentes** pour `fn(a: Lovable, b: Lovable)` : + +- **Dans le corps, les variables sont distinctes** : `a : __rigid_0`, `b : __rigid_1`. +- **Au site d'appel, une seule variable est partagée**, et c'est le premier argument qui la fixe. + +Défauts constatés, à capturer en `cases/` avant toute modification : + +1. **Défaut de soundness** : `let second <- fn(a: Lovable, b: Lovable): Lovable { b }` puis + `let r: Cat <- second(cat, dog)` **passe**, et `let r: Dog <- second(cat, dog)` est **rejeté** + (« Received Cat »). Le retour est lié au premier argument, et `b: Dog` n'est jamais confronté + à `a: Cat`. +2. **`Lovable@A` est silencieusement avalé** : `fn(a: Lovable@A, b: Lovable@B)` passe `typr check`, + la signature s'affiche `fn(a: Lovable, b: Lovable) -> bool` et le R généré ne garde aucune trace + de `@A`. Il faudra trouver quel parseur consomme `@A` sans le reporter. +3. **Méthode `(Self, Self)` sur deux rigides distincts acceptée** : + `type Same <- interface { same: (Self, Self) -> bool }; fn(a: Same, b: Same): bool { a.same(b) }` + passe, alors qu'avec des rigides distincts, `b : B` ne devrait pas satisfaire le paramètre `A`. + À expliquer en phase 0 : soit `is_subtype_raw` entre deux rigides est trop permissif, soit un + filtre de repli accepte l'appel. +4. `is_rigid_compatible` (`function.rs:244`) accepte **n'importe quel** rigide dont la borne est + `Lovable` comme retour `Lovable`. C'est pour cela que `{ b }` et `{ a }` passent tous les deux. + +--- + +## 1. Décisions de conception + +### D1. Syntaxe : `I@Id` en suffixe, cohérent avec le sigil préfixe existant + +- `Lovable@A` : variable générique `A`, bornée par `Lovable`. Le `@` suffixe ne se lit qu'**immédiatement + après un nom d'alias**, sans espace. Il ne peut donc pas être confondu avec `@export`, `@pub` ou + `@name: …;`, qui sont au niveau des déclarations. +- `@A` reste ce qu'il est (générique de *kind* interface, sans borne précise). Lecture unifiée : + `@A` ≈ `interface{}@A`. Un même `A` dans `@A` et `Lovable@A` désigne **la même variable**, dont la + borne est `Lovable`. +- `Lovable@_` (RFC §7.1) : identifiant anonyme, donc variable fraîche à chaque occurrence. C'est peu coûteux + à ajouter, à inclure. +- `Lovable#` (variante §7.1) : **rejeté**, parce que `#` est déjà le sigil d'`IndexGen`. +- Les identifiants partagent **l'espace de noms des génériques de la signature**. `fn(a: T, b: Lovable@T)` est + une erreur (`T` est à la fois libre et borné). + +### D2. Représentation : un nouveau variant `Bounded` + +```rust +// components/type/mod.rs — À LA FIN de l'enum (sérialisation .bin, cf. Risques) +Bounded(String, Box, HelpData), // Lovable@A → Bounded("A", Alias("Lovable"), h) +``` + +On le préfère à une extension de `KindedGen`, dont la clé est un `Kind` (4 valeurs) et pas un type. +On le préfère aussi à une table de bornes à côté de la signature : la borne doit voyager avec le type +à travers `Function`, `Vec`, `Tuple` et la substitution. + +`Bounded` n'existe **que dans les signatures**. Dans le corps, il devient un rigide (D4). Au site +d'appel, il est lié comme un générique (D5). + +### D3. Sémantique d'un `I` nu : **option A retenue (2026-09-30) — changement de comportement assumé** + +> **Décision :** un `I` nu ≡ `I@I` (une variable partagée par interface, RFC §5.3). L'option B ci-dessous est écartée ; elle est conservée pour mémoire. + +| Option | `fn(a: Lovable, b: Lovable)` | `cmp(cat, dog)` | Retour `Lovable` | +|---|---|---|---| +| **A (RFC §5.3)** : `I` nu ≡ `I@I`, une variable partagée par interface | `a` et `b` du **même** type | **rejeté** (aujourd'hui accepté) | non ambigu | +| **B** : `I` nu ≡ `I@_`, variable fraîche par occurrence | types indépendants | accepté | **ambigu** s'il y a ≥ 2 paramètres `I` : erreur « ajoutez `@Id` » | + +**Choix : A**, pour trois raisons : + +- c'est le RFC ; +- c'est la seule option sûre pour les interfaces `(Self, Self)` (`Eq`, `Ord`, `Same`), où `a.eq(b)` + n'a de sens que si `a` et `b` ont le même type ; +- elle aligne le corps sur ce que le site d'appel fait déjà à moitié. + +Coût de A : du code qui compile aujourd'hui (`cmp(cat, dog)` avec des `Lovable` nus) sera rejeté, avec +un message qui propose `Lovable@A, Lovable@B`. Le stdlib n'est pas touché (`unique` et `sort` +n'ont qu'un paramètre interface). Il reste à mesurer l'impact sur les tests et la doc en phase 0. + +### D4. Corps : un rigide **par identifiant**, pas par paramètre + +La boucle de `function.rs:326` passe d'un rigide par paramètre à une table `id → rigide`. Deux +paramètres `Lovable@X` partagent `__rigid_k`. Avec D3-A, deux `Lovable` nus partagent aussi le leur. +`is_rigid_compatible` compare le rigide du corps **au rigide de l'identifiant déclaré en retour**, +et non plus seulement à la borne. + +### D5. Site d'appel : `Bounded` passe par l'unification générale + +On ajoute un bras à `unification_helper` (`unification.rs:239`), sur le modèle du bras `KindedGen` +déjà présent : +`(concrete, Bounded(id, bound))` lie `Generic(id) ↦ concrete` **si** `concrete ⊨ bound` +(`interface_satisfaction::check_interface_satisfaction`), et échoue sinon. Deux occurrences du +même `id` passent alors par `UnificationMap::try_new`, qui rejette déjà les liaisons contradictoires. +C'est exactement la règle « même identifiant ⇒ même type » (RFC §7.2). + +`try_interface_subtype_match` ne sert plus qu'aux interfaces nues **après désucrage**. Avec D3-A, +elles deviennent des `Bounded` et la fonction peut disparaître. Égalité exigée pour un même `id` : +on reprend le comportement actuel d'un `T` répété (`fn(a: T, b: T)`), pour ne pas créer deux règles. + +### D6. Hors périmètre : interfaces paramétrées `interface` (RFC §6.3) + +Elles ne sont pas parsées aujourd'hui. `Box@A` avec `Box <- interface` relève d'un RFC distinct. +`Bounded` est conçu pour les accueillir (borne = `Alias("Box", [T])`) sans changement de représentation. + +--- + +## 2. Phases + +### Phase 0 — Filet et mesure (aucun changement de sémantique) + +- `typr case add` pour les défauts 1, 2 et 3 du §0 (`expect.toml` : substrings d'erreur attendues). +- Expliquer le défaut 3 (`a.same(b)` accepté) : tracer `is_subtype_raw(Generic(r1), Generic(r0))`. +- Mesurer D3-A : forcer temporairement un rigide partagé par interface dans `function.rs`, puis lancer + `cargo test --workspace`, `typr case run` et, côté doc, `npm run check:examples`. Lister les échecs. + C'est cette liste qui chiffre le changement de comportement. + +**Statut : FAITE (2026-09-30).** + +- Cas ouverts (échouent tant que le plan n'est pas implémenté ; oracle : `Type errors found`) : + `0086-interface-return-bound-to-first-arg` (défaut 1), `0087-interface-shared-id-different-types` + (défaut 2 / D5), `0088-interface-self-self-distinct-ids` (défaut 3). `typr case run` : ils sont `OPEN`. + Les 4 `REGRESS` restants (0004, 0005, 0006, 0008 : diff golden de `R/main.R`) préexistent et n'ont + aucun rapport. +- **Cause du défaut 3** : `impl PartialEq for Type` (`components/type/mod.rs:1334`) déclare + `(Generic(_), Generic(_)) => true` — deux génériques quelconques sont « égaux ». Or `is_subtype_raw` + commence par `(typ1, typ2) if typ1 == typ2 => true` (`mod.rs:226`) : deux rigides distincts + `__rigid_0` / `__rigid_1` passent donc pour sous-types l'un de l'autre. Ce n'est pas un filtre de + repli. **Conséquence pour la phase 3** : ne pas toucher au `PartialEq` global (il sert à l'unification + et à `UnificationMap`) ; ajouter dans `is_subtype_raw`, avant la garde d'égalité, un bras + `(Generic(a), Generic(b))` qui compare les **noms** quand les deux sont des rigides (`__rigid_`). + Même cause probable pour le défaut 4 (`{ a }` et `{ b }` passent tous deux). +- **Mesure D3-A** (rigide partagé par interface dans le corps, `function.rs`, expérience temporaire + revertie) : `cargo test --workspace` **entièrement vert** (711 + 161 + … tests), `typr case run` + inchangé (mêmes 4 REGRESS préexistants). Un scan de `typr.github.io/docs`, `blog`, `lab/`, + `cases/*/repro` et du stdlib ne trouve **aucune** fonction à deux paramètres de même alias + d'interface. Limite : l'expérience ne touche que le corps ; le rejet de `cmp(cat, dog)` au site + d'appel (D5) n'est pas mesuré, mais le scan statique suggère un impact nul sur le corpus existant. + `npm run check:examples` non lancé (aucun exemple concerné d'après le scan). + +### Phase 1 — Parsing et affichage + +- `parsing/types.rs` : suffixe `opt(preceded(tag("@"), alt((generic_name, tag("_")))))` après un alias + (y compris imbriqué : `[#N, Eq@A]`, `tuple{Lovable@A}`, `(Lovable@A) -> …`). Produit `Type::Bounded`. +- Trouver et supprimer le chemin qui avale `@A` aujourd'hui (défaut 2). Un `@` suffixe mal formé doit + donner une erreur de syntaxe, jamais être ignoré. +- `Type::Bounded` : `Hash`/`Eq`, `type_printer` (`Lovable@A`), `type_category`, `fingerprint`, et tous les + `match` exhaustifs (le compilateur les listera). +- Manifeste de syntaxe `components/syntax/mod.rs` : déclarer le `@` suffixe de type. Puis + `typr syntax --write` et `--check`, et les deux tests `components::syntax::tests`. + +**Statut : FAITE (2026-09-30, non commitée).** + +- `Type::Bounded(id, bound, h)` ajouté en **fin** d'enum ; `Hash` (tag 45), `to_category` (celle de la + borne), `get_help_data`/`set_help_data`, `type_printer` (`format` et `verbose` : `Lovable@A`). + Les autres `match` ont des jokers : le variant ne traverse pas encore `reduce_type`, l'unification + ni `checked_assertions` (phases 2-5). +- Parseur (`type_alias`, `bound_suffix`) : `@` collé à l'alias, suivi de **une seule majuscule** ou de `_`. + `Self` n'est pas un id valable (un générique est une lettre ; `Self` est réservé aux interfaces). +- **Défaut 2 (`@A` avalé)** : la cause est le résolveur de priorité des types, qui écarte un type + résiduel. Un `@` suffixe mal formé (`Lovable @A`, `Lovable@Self`, `Lovable@Abc`) pousse + maintenant `SyntaxError::DetachedBoundSuffix` (**S018**, + entrée `typr-mcp/src/explain.rs`). + `Lovable@` et `Lovable@a` échouaient déjà (point-virgule manquant). +- Manifeste de syntaxe : **rien à changer**, `@` y est déjà (`live("@", "Interface", …)`) ; + `typr syntax --check` passe. +- Tests : 5 nouveaux dans `parsing/types.rs`. `cargo test --workspace` vert ; `typr case run` : mêmes 4 + REGRESS préexistants. +- Limite : `Bounded` est parsé mais **pas encore interprété**. `fn(a: Lovable@A, b: Lovable@B)` passe + `typr check` avec la sémantique d'avant. Le défaut 2 n'est donc corrigé qu'au niveau syntaxe ; le cas + 0087 reste OPEN jusqu'à la phase 4. + +### Phase 2 — Normalisation de la signature + +Un seul passage, appelé depuis `function()` et pour les signatures `@name: …;` : + +- collecter `id → borne` sur les paramètres et le retour ; +- appliquer le désucrage D3 aux `I` nus : `I` devient `Bounded(I, I)` (option A) ; + `@_` donne un id frais ; +- erreurs (RFC §7.3), une variante `TypeError` chacune, codes d'erreur stables : + - un `id` avec deux bornes différentes (`Lovable@A`, `Printable@A`), sauf si l'une est `@A` sans borne ; + - un `id` présent seulement dans le retour : on généralise `InterfaceReturnOnly` ; + - un `id` qui entre en collision avec un générique libre du même nom ; + +**Statut : FAITE (2026-09-30, non commitée).** + +- Nouveau module `type_checking/signature_normalization.rs` : `normalize_signature(context, params, ret)` + renvoie `{ params, ret, errors }`. Il (1) renomme chaque `I@_` en `_0`, `_1`…, (2) désucre un alias + d'interface nu `I` en `Bounded("I", I)` (D3-A ; les `interface { … }` en ligne sont laissés tels quels), + (3) produit les erreurs du §7.3. +- Erreurs : `ConflictingBound` **T047** (deux bornes pour un id ; comparées après `reduce_type`), + `BoundCollidesWithGeneric` **T048** (id égal à un générique libre `T`/`#N` ; `@A` n'est pas compté, D1), + et `InterfaceReturnOnly` **T017** réutilisée pour un id présent seulement au retour. Entrées `explain` + T047/T048 dans `typr-mcp`. +- **Seules les erreurs sont branchées** dans `function()`. La signature désucrée est calculée mais pas + consommée : corps et site d'appel lisent encore la signature brute, donc le comportement des programmes + valides est inchangé (les I nus ne deviennent pas `Bounded` avant les phases 3-4). +- Limite connue : `fn(a: Lovable@A): Lovable@A { a }` est encore rejeté (`Bounded` n'est pas interprété + dans le corps) — c'est l'objet de la phase 3. +- Tests : 6 nouveaux. `RUST_MIN_STACK=8388608 cargo test --workspace` vert, sauf + `typr-graph --test walkthrough` (ordre `Point/x`/`Point/y` d'un `HashSet`, flaky, sans lien). + +### Phase 3 — Typage du corps + +- `function.rs:326` : table `id → rigide` (D4). `add_interface_constraint(rigide, borne)` une seule fois + par id. +- `specialize_lambda` (`function_application.rs:1061`) : même traitement, et `rigid_to_concrete` indexé + par id. +- `is_rigid_compatible` : le retour `Lovable@B` n'accepte que le rigide de `B`. +- Défaut 3 : entre deux rigides distincts, `is_subtype_raw` doit valoir `false`. Donc `a.same(b)` sera + rejeté avec `Same@A, Same@B`, et accepté avec `Same@A, Same@A` ou avec `Same` nu (D3). + +**Statut : FAITE pour `function()` (2026-09-30, non commitée).** + +- `function.rs` : table `id → rigide` (un rigide par id, D3-A pour les `I` nus via la signature + normalisée). Un paramètre `Lovable@A` est typé par sa borne ; la table sert aussi au retour : un retour + `Lovable@B` est vérifié contre le **rigide de `B`** (`checked_ret`, aussi passé à + `set_expected_return_type` pour les `return` anticipés). `is_rigid_compatible` : si le retour déclaré est + un rigide, seul ce rigide passe (le défaut 4 venait de `interface <: Generic(_)`). +- Défaut 3 : bras `(Generic, Generic)` rigides dans `is_subtype_raw` (comparaison par nom). Piège : + `Hash` ignorait le nom d'un `Generic`, donc le cache de sous-typage confondait `(r0, r1)` et `(r0, r0)` ; + les noms de rigides entrent maintenant dans le hash (`is_rigid_name`, `type/mod.rs`). +- Tests : 5 dans `function.rs` (`test_bound_*`, `test_self_self_*`, `test_bare_interface_*`). Piège de test : + `parse2` ne se comporte pas comme le CLI (les rejets passaient) ; le helper utilise `parse_from_string`. +- `typr case run` : 0088 passe à READY (rejeté dans le corps). 0086/0087 restent en attente de la phase 4 + (READY pour de mauvaises raisons : l'appel d'une fonction `Lovable@A` échoue tant que `Bounded` n'est pas + unifié). `cargo test --workspace` vert ; mêmes 4 REGRESS préexistants. +- **Non fait** : `specialize_lambda` (les paramètres de lambda n'ont pas de `Bounded` déclaré ; à traiter en + phase 4 avec `rigid_to_concrete` par id). Rigides imbriqués (`[#N, Lovable@A]`) : toujours sans rigide. +- À corriger : le message de retour affiche `Found: __RIGID_1` ; il faudrait nommer l'id (`B`). + +### Phase 4 — Site d'appel + +- `unification.rs` : bras `Bounded` (D5), avec un occurs check comme pour `Generic`. +- `apply_unification_to_return_type` : la substitution de `Generic(id)` couvre `Bounded(id, _)` dans le + retour, y compris sous `Tuple`, `Vec` et `Function` (le `swap` du RFC §6.2 rend `tuple{Dog, Cat}`). +- `try_named_generic_match` / `collect_interface_bindings` : les signatures mixtes + (`fn(f: (T) -> U, x: Lovable@A)`) et les éléments de tableau (`[#N, Eq@A]`). +- Supprimer `try_interface_subtype_match`, ou le réduire au strict nécessaire (D5). +- Messages d'erreur au site d'appel : pour « `A` lié à `Cat` puis à `Dog` », nommer l'identifiant et les + deux arguments. + +**Statut : FAITE, avec écarts au plan (2026-09-30, non commitée).** + +- **Écart D5** : pas de bras `Bounded` dans `unification_helper`. `signature_normalization::instantiate_at_call` + normalise la signature, lie chaque id à l'argument (`bind_ids` : la borne doit être satisfaite, un même id + exige le même type par sous-typage mutuel, car le `PartialEq` de `Type` est trop lâche pour les génériques), + puis **substitue** `Bounded(id)` par le type lié dans les paramètres et le retour. Les filtres existants + voient ensuite une signature concrète (les `#N`, `T` restants sont à eux). Plus simple qu'un bras + d'unification, et le retour est substitué sous `Vec`/`Tuple`/`Function` par le même `rewrite`. +- Branché au début de `apply_from_variable_inner` : signature rejetée → retirée des candidats ; une signature + est concernée seulement si elle a un `Bounded` déclaré ou un même id ≥ 2 fois après désucrage (un `I` nu + unique garde le chemin historique). Le message d'erreur liste les signatures *déclarées*. +- **`try_interface_subtype_match` conservé** (il sert encore au `I` nu unique, `unique`/`sort`). À réduire plus tard. +- Cas de test : `cmp(cat, dog)` avec `Lovable@A, Lovable@B` OK ; `Lovable@X` répété avec `(cat, dog)` rejeté ; + `Lovable` nu répété avec `(cat, dog)` rejeté (D3-A) ; `id(id(cat))` OK ; retour `Lovable@B` = `Dog`, pas `Cat`. + 4 tests dans `signature_normalization.rs`. Cas 0086/0087/0088 : READY. `cargo test --workspace` vert + (hors `typr-graph walkthrough`, flaky connu) ; mêmes 4 REGRESS préexistants. +- Piège : le cas 0086 utilisait `Dog` ⊃ `Cat` (largeur de record), donc `Dog <: Cat` et le retour `Dog` passait + pour `Cat`. `Dog` n'a plus que `age`. +- **Non fait** : `swap` avec `tuple{…}` (syntaxe littérale non vérifiée), éléments `[#N, Eq@A]` (code prévu via + `Vec` mais sans test), `specialize_lambda` par id, message dédié « `A` lié à `Cat` puis `Dog` » (aujourd'hui : + `NoMatchingSignature`). + +### Phase 5 — Transpilation, `--checked`, LSP + +- **Le R généré ne doit pas changer** (propriété à vérifier, pas une option) : la dispatch S3 + `cmp.Lovable` / `cmp.default` utilise le nom de la borne, pas l'id. +- `transpiling/checked_assertions.rs` : un `Bounded` s'asserte comme sa borne. +- LSP : le survol affiche `Lovable@A` dans la signature et `Cat` au site d'appel. + +**Statut : FAITE (2026-09-30, non commitée). LSP vérifié par lecture seulement.** + +- **La propriété « R inchangé » était fausse** : `fn(a: Lovable@A): Lovable@A` produisait `idf.default` avec + un cast `as.Generic()`, au lieu de `idf.Lovable` / `as.Lovable()` + alias `.default`. Cause : `get_class`, + `get_class_unquoted`, `get_type_anotation` et `get_type_anotation_no_parentheses` (`context/vartype.rs`, + `context/mod.rs`) ne connaissaient pas `Bounded`, et la branche `Lang::Let` de `transpiling/mod.rs` lisait + le type du paramètre de dispatch brut (facettes interface/record). Correctif : `Bounded` est déballé en sa + borne à ces cinq endroits. +- `checked_assertions.rs` : `Bounded` s'asserte comme sa borne (`checked_descriptor`). +- Vérifié : R généré **identique** (diff vide) entre `Lovable` et `Lovable@A`, avec et sans `--checked`. + Test : `test_bounded_id_does_not_change_generated_r` (`transpiling/mod.rs`). +- LSP : aucun changement de code. Le survol passe par `type_printer`, qui affiche déjà `Lovable@A` (phase 1). + Non testé dans le crate `typr-lsp`. +- `cargo test -p typr-core` vert ; `typr case run` : mêmes 4 REGRESS préexistants, 0086-0088 READY. Sous + `cargo test --workspace`, `registry_validate::formals_diff_flags_…` a échoué une fois et passe seul (instable + en parallèle, sans lien). + +### Phase 6 — Documentation et RFC + +- Déplacer le RFC dans `rfcs/` (numéro suivant, format `0000-template.md`), avec les décisions D1-D6. +- `typr.github.io` : page de référence sur les interfaces, `syntaxe.md` (**les deux copies**), glose + du `@` suffixe dans `scripts/syntax-glossary.mjs` (sinon `check:syntax` casse), et exemples vérifiés + par `typr check` (un `compile_fail` pour `cmp(cat, dog)` avec des `Lovable` nus ). +- `ai_context/interface_as_constrained_generic.md` §4.2 et §8.4 : à mettre à jour. Cette spec disait + « paramètres distincts ⇒ variables distinctes » et proposait `where A: I`, que `I@A` remplace. + + +**Statut : FAITE (2026-09-30, non commitée).** + +- RFC : `rfcs/0000-interface-bound-generics.md` (numéro à attribuer au merge de la PR). +- `typr.github.io` : section « `I@Id` » dans `docs/reference/interfaces.md` (4 blocs : 1 `compile_fail` + pour `cmp(cat, dog)` avec `Lovable` nus, 3 vérifiés), et `syntaxe.md` §11. Il n'existe **qu'une** copie de + `syntaxe.md` (`typr.github.io`) ; `typR/typr/syntaxe.md` n'existe pas, contrairement à ce que dit le CLAUDE.md. +- Glossaire `syntax-glossary.mjs` : **inchangé**, car le manifeste n'a pas bougé (`@` y est déjà) ; + `npm run check:syntax` passe. +- `ai_context/interface_as_constrained_generic.md` : §4.2, §8.4 et la note §10 réécrits. +- Piège : `check:examples` prend le `typr` du PATH (installé, ancien). Il faut + `TYPR_BIN=…/target/debug/typr`, sinon les blocs `@Id` échouent à tort. Avec lui : 199 blocs + 7 `compile_fail` OK. +### Suivi — points « non fait » des phases 3-4 (2026-09-30, non commité) + +- **Message de retour** : `name_rigids` (`function.rs`) réécrit les rigides en `Lovable@B` (ou `Lovable` nu) : plus de `__RIGID_1`. +- **Site d'appel** : `CallInstance::Clash(IdClash)` + `TypeError::IdBoundToTwoTypes` **T049** (deux spans : « `X` est `Cat` ici / mais `Dog` ici »). + Émis quand toutes les signatures déclarées sont rejetées par un clash d'id ; sinon `NoMatchingSignature`. Entrée `explain` T049. +- **Rigides imbriqués** : `[#N, Lovable@A]`, `tuple{Lovable@B, Lovable@A}` en paramètre *et* en retour passent par `replace_bounded_with_rigids` + (`function.rs`). `swap` (tuple) et `first` (tableau) sont vérifiés, positif et négatif. +- **Bug trouvé** : `PartialEq for Type` n'avait pas de bras `Bounded` (`_ => false`) : un `Bounded` n'était jamais égal à lui-même, le graphe de types + de `hoist_aliases` se dupliquait à chaque appel, et le vérificateur bouclait (deux fonctions `Lovable@A` appelées avec des lambdas sans type). + Corrigé. Effet de bord : `get_type_anotation` trouvait un alias `FunctionN` au lieu de `as.Generic()` ; `has_bounded` compte désormais comme générique + (R généré à nouveau identique à la forme nue). +- **Hors périmètre, préexistant** : une lambda sans annotation passée en argument (`apply(fn(c) { … }, cat)`) échoue déjà avec `T` (« fn not defined »). + Donc `specialize_lambda` par id n'est pas atteignable tant que ce n'est pas résolu ; lambdas *typées* avec `@A`/`@B` : OK. +- **Reste** : `try_interface_subtype_match` (I nu unique), `[#N, Eq@A]` avec une interface `(Self, Self)` non sondé. +- Tests : 4 nouveaux dans `signature_normalization.rs`. `cargo test --workspace` vert ; mêmes 4 REGRESS. + +--- + +## 3. Tests à écrire (au fil des phases) + +| Programme | Attendu | +|---|---| +| `fn(a: Lovable@A, b: Lovable@B): bool { a.love() == b.love() }`, `cmp(cat, dog)` | OK | +| `fn(a: Lovable@A, b: Lovable@B): Lovable@B { b }`, `let r: Dog <- f(cat, dog)` | OK (défaut 1 corrigé) | +| idem, `let r: Cat <- f(cat, dog)` | erreur | +| `fn(a: Lovable@A, b: Lovable@B): Lovable@A { b }` | erreur dans le corps | +| `fn(a: Lovable@X, b: Lovable@X)` appelé avec `(cat, dog)` | erreur « X lié à Cat puis Dog » | +| `swap` : `tuple{Lovable@B, Lovable@A}` | `tuple{Dog, Cat}` | +| `fn(a: Same@A, b: Same@B): bool { a.same(b) }` | erreur (défaut 3) | +| `fn(a: Same, b: Same): bool { a.same(b) }`, appel `(1, 2)` | OK (D3) | +| `fn(a: Lovable, b: Lovable)` appelé avec `(cat, dog)` | erreur + suggestion `@A/@B` (D3) | +| `Lovable@A` et `Printable@A` dans la même signature | erreur de borne conflictuelle | +| `fn(): Lovable@A` | erreur (retour seul) | +| `double(3)`, `double(double(3))`, `unique`/`sort` sur `[#N, Point]` | inchangés (régression) | +| `Lovable @A` (espace), `Lovable@` | erreur de syntaxe, pas de perte silencieuse | +| R généré de `cmp` | identique à avant | + +--- + +## 4. Risques + +- **Changement de comportement (D3)** : c'est le principal. La phase 0 le chiffre avant qu'on + s'engage. +- **Sérialisation `.bin`** : `Bounded` va à la fin de l'enum `Type` (même contrainte que `Refined`). +- **Deux chemins d'appel** (unification générale et `try_interface_subtype_match`) : risque d'incohérence + s'ils coexistent. D5 les fusionne. +- **Couplage inter-dépôts** : le manifeste de syntaxe et le glossaire de `typr.github.io` doivent + changer ensemble, sinon le build de la doc casse. diff --git a/rfcs/0000-interface-bound-generics.md b/rfcs/0000-interface-bound-generics.md new file mode 100644 index 0000000..268f94e --- /dev/null +++ b/rfcs/0000-interface-bound-generics.md @@ -0,0 +1,117 @@ +- **Status:** implemented (branch `develop`, not yet merged) +- **RFC PR:** we-data-ch/typr#0000 +- **Tracking issue:** — +- **Implemented in:** not yet released +- **Start date:** 2026-09-30 + +# Implicit generics on interfaces: `Interface@Id` + +## Summary + +An interface name in a parameter type can carry a suffix `@Id` (`Lovable@A`) that names the hidden +type variable standing for "some type satisfying `Lovable`". Two parameters written `Lovable@A` and +`Lovable@B` are independent; two written `Lovable@A` share one type; the return type can name the +variable it comes from. A bare `Lovable` is sugar for `Lovable@Lovable`: **one variable per +interface**. No `T`/`U` is needed. The emitted R does not change. + +## Motivation + +Two parameters of the same interface used to behave differently in the body and at the call site: + +```typr +let second <- fn(a: Lovable, b: Lovable): Lovable { b }; +let r: Cat <- second(cat, dog); # accepted; `let r: Dog <- ...` was rejected ("Received Cat") +``` + +- In the body, `a` and `b` were two distinct rigid variables. +- At the call site, one variable was shared, and the first argument fixed it. The return type was + bound to the first argument, and `b: Dog` was never checked against `a: Cat`. This is a soundness hole. +- `fn(a: Lovable@A, b: Lovable@B)` was accepted and the `@A` silently dropped. +- `type Same <- interface { same: (Self, Self) -> bool }; fn(a: Same, b: Same) { a.same(b) }` was + accepted with two distinct rigid variables, because `PartialEq for Type` treats any two + `Generic` as equal. + +## Guide-level explanation + +```typr +let cmp <- fn(a: Lovable@A, b: Lovable@B): bool { a.love() == b.love() }; # cmp(cat, dog): ok +let second <- fn(a: Lovable@A, b: Lovable@B): Lovable@B { b }; # second(cat, dog): Dog +let same <- fn(a: Lovable, b: Lovable): bool { ... }; # same(cat, dog): error +``` + +Teaching line for an R user: "if you want two parameters to be independent, give each a small tag, +`@A`, `@B`". Same tag, same type. No tag, one shared tag per interface. + +## Reference-level explanation + +**Syntax.** `@Id` is written immediately after an interface alias, no space. `Id` is one uppercase +letter, or `_` for a fresh variable at each occurrence. It can be nested (`[#N, Eq@A]`, +`(Lovable@A) -> ...`). A malformed suffix (`Lovable @A`, `Lovable@Self`, `Lovable@Abc`) is a syntax +error S018 (`DetachedBoundSuffix`), never silently ignored. The prefix `@A` (a generic of kind +interface) is unchanged; `@` is already in the syntax manifest, so no lexeme is added. + +**Representation.** `Type::Bounded(id, bound, help_data)`, last variant of `Type` (binary +serialization). It exists only in signatures. + +**Typing.** +1. *Normalization* (`type_checking/signature_normalization.rs`): `I@_` gets a fresh id; a bare + interface alias becomes `Bounded(I, I)`. Errors: T047 two different bounds for one id; T048 an id + equal to a free generic of the signature; T017 an id only in the return type. +2. *Body* (`function.rs`): one rigid variable **per id**. A return `Lovable@B` accepts only the + rigid of `B`. Two distinct rigid variables are no longer subtypes of each other + (`is_subtype_raw` compares rigid names; `PartialEq for Type` is untouched, unification depends on it). +3. *Call site* (`instantiate_at_call`): each id is bound to its argument's type (the bound must be + satisfied; a repeated id requires mutual subtyping), then `Bounded(id)` is substituted in the + parameters and the return type, under `Vec`, `Tuple` and `Function`. + +**Emitted R.** Unchanged (same S3 dispatch on the interface name, same `--checked` assertions). Tested. + +**Error messages.** A call whose ids cannot be bound reports `NoMatchingSignature`, listing the +declared signatures. A dedicated "`A` bound to `Cat` then `Dog`" message is not written yet. + +## Gradual typing + +Unaffected: the feature only applies to interface-annotated parameters. Unannotated parameters stay `Any`. + +## Drawbacks + +- **Behavior change.** A function with two bare parameters of the same interface, called with two + different concrete types, used to compile and is now rejected. The measured corpus (tests, cases, + docs, blog, standard library) has none, but user code may. +- One more piece of syntax next to `T`, `#N`, `@I`. + +## Rationale and alternatives + +- **Bare `I` = fresh variable per occurrence** (rejected). It makes the return type ambiguous with + two parameters, and is unsound for `(Self, Self)` interfaces (`Eq`, `Ord`). +- **`where A: I`** (rejected): needs explicit `T`, which is what the design avoids. +- **`Lovable#Id`** (rejected): `#` already prefixes index generics. +- **Extend `KindedGen`** (rejected): its key is a `Kind`, not a type; the bound must travel with the + type through substitution. +- **A unification arm for `Bounded`** (planned, then dropped): substituting per signature before the + existing filters is simpler and reuses them unchanged. + +## Prior art + +Rust `impl Trait` in argument position (independent anonymous types) versus `` (shared); +Haskell class constraints; TypeScript `T extends I`. R itself has no static counterpart (S3/S4 dispatch +is dynamic), so R users have no existing expectation to break. + +## Unresolved questions + +- `swap` with a `tuple{...}` return, `[#N, Eq@A]` elements and lambda parameters have no dedicated test. +- `try_interface_subtype_match` is kept for a single bare interface parameter (`unique`, `sort`); it could be removed. +- The rigid name shows up in a return-type error (`__RIGID_1`) instead of the id. +- Parametrized interfaces (`interface`, `Box@A`) are not parsed today. + +## Future possibilities + +Parametrized interfaces: `Bounded` already accepts a bound like `Box` without a change of representation. + +## Implementation checklist + +- [x] `cases/0086`, `0087`, `0088` +- [x] `typr syntax --check` (no lexeme changed) +- [x] `syntaxe.md` updated (only the `typr.github.io` copy exists) +- [ ] Documentation PR on `we-data-ch/typr.github.io`, landing in the same release +- [ ] `Implemented in:` filled in above From bc1a8f5c5402a76c66eb5e4fc2e3ec2d73fea62a Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 16:35:06 +0200 Subject: [PATCH 44/52] install : packaging scoop/winget, CI et release (lot 3) Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 60 ++ .github/workflows/release.yml | 278 +++++++ RELEASING.md | 50 +- install_plan.md | 141 +++- packaging/README.md | 238 ++++++ packaging/render.sh | 404 ++++++++++ packaging/scoop/README.md.in | 46 ++ packaging/scoop/typr.json.in | 21 + packaging/tests/fixtures/checksums-v9.9.9.txt | 13 + packaging/tests/run-tests.sh | 718 ++++++++++++++++++ packaging/tests/validate-schemas.py | 226 ++++++ .../winget/we-data-ch.TypR.installer.yaml.in | 53 ++ .../we-data-ch.TypR.locale.en-US.yaml.in | 43 ++ packaging/winget/we-data-ch.TypR.yaml.in | 18 + std.ty | 204 +++++ 15 files changed, 2492 insertions(+), 21 deletions(-) create mode 100644 packaging/README.md create mode 100755 packaging/render.sh create mode 100644 packaging/scoop/README.md.in create mode 100644 packaging/scoop/typr.json.in create mode 100644 packaging/tests/fixtures/checksums-v9.9.9.txt create mode 100755 packaging/tests/run-tests.sh create mode 100755 packaging/tests/validate-schemas.py create mode 100644 packaging/winget/we-data-ch.TypR.installer.yaml.in create mode 100644 packaging/winget/we-data-ch.TypR.locale.en-US.yaml.in create mode 100644 packaging/winget/we-data-ch.TypR.yaml.in create mode 100644 std.ty diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5c2017..724ae85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -234,6 +234,66 @@ jobs: - name: render.sh — suite de tests run: bash packaging/homebrew/tests/run-tests.sh + # Les manifestes WinGet et Scoop sont validés ici sur la fixture, donc sans + # réseau et sans release : c'est le même code de substitution que la release + # exercera, avec des empreintes factices. Ce que la fixture ne peut pas + # vérifier — WinGet qui installe vraiment, Scoop qui télécharge et vérifie une + # empreinte — appartient au job `winget` et au job `scoop` de release.yml, qui + # travaillent sur une release publiée. + # + # `packaging/tests/validate-schemas.py` valide contre les JSON Schema officiels + # de winget-pkgs et de Scoop, téléchargés depuis leurs dépôts : winget-pkgs + # n'accepte que le sien, et un schéma recopié ici validerait moins que ce qui + # sera exigé dès la version suivante. + packaging-win: + name: Packaging (WinGet + Scoop) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Installer shellcheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + + - name: Installer les dépendances de validation + run: python3 -m pip install --user --upgrade pyyaml jsonschema + + - name: Cache des schémas officiels + uses: actions/cache@v5 + with: + path: ~/.cache/typr-packaging/schemas + key: winget-scoop-schemas-${{ hashFiles('packaging/**/*.in') }} + + - name: render.sh — shellcheck + run: shellcheck -s sh packaging/render.sh + + - name: render.sh — analyse syntaxique + run: | + # `dash -n` avec le dash du runner : c'est l'interpréteur le plus + # strict, il refuse les extensions de bash que `sh -n` tolère. + dash -n packaging/render.sh + bash -n packaging/render.sh + bash -n packaging/tests/run-tests.sh + python3 -m py_compile packaging/tests/validate-schemas.py + + - name: render.sh — suite de tests + run: bash packaging/tests/run-tests.sh + + # La suite tolère que les schémas soient indisponibles — le réseau peut + # tomber, et une suite qui échoue pour ça n'apprend rien. Ici, hors de la + # suite, la validation est obligatoire : un manifeste que winget-pkgs ou le + # dépôt Scoop refuseraient doit arrêter la PR, pas la release. + - name: Manifestes rendus — schémas officiels + run: | + set -euo pipefail + packaging/render.sh \ + --tag v9.9.9 \ + --checksums packaging/tests/fixtures/checksums-v9.9.9.txt \ + --release-date 2026-01-02 \ + --out rendered + python3 packaging/tests/validate-schemas.py rendered + # La validation par Homebrew ne peut pas tourner dans le job précédent : ni # `brew style` ni `brew audit` n'existent sous Linux, et les deux sont justement # ce qui décide si `brew install` fonctionnera. Elle a donc lieu ici, sur un diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f57ed82..ff07b02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -770,3 +770,281 @@ jobs: if: env.HOMEBREW_TAP_TOKEN == '' run: | echo "::warning title=Homebrew non mis a jour::HOMEBREW_TAP_TOKEN absent ; le tap garde sa version precedente." + + # --------------------------------------------------------------------------- + # WinGet — pull request vers microsoft/winget-pkgs + # + # Ce job dépend d'un jeton GitHub ayant `public_repo` : winget-pkgs est un dépôt + # public, dans lequel il faut pouvoir ouvrir une pull request. Sans jeton, les + # étapes de rendu et de contrôle s'exécutent quand même, et seule la soumission + # est sautée. + # --------------------------------------------------------------------------- + winget: + name: WinGet manifest + needs: [verify, release] + runs-on: windows-latest + # Au niveau du job pour que l'étape de publication puisse tester sa présence + # dans son propre `if` : un `env` de step n'y est pas visible. + env: + WINGET_CREATE_TOKEN: ${{ secrets.WINGET_CREATE_GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.verify.outputs.tag }} + + - name: Récupérer checksums.txt de la release + shell: bash + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + set -euo pipefail + curl -fsSfL \ + "https://github.com/${{ github.repository }}/releases/download/$TAG/checksums.txt" \ + -o checksums.txt + cat checksums.txt + + - name: Générer les manifestes depuis checksums.txt + shell: bash + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + set -euo pipefail + packaging/render.sh --tag "$TAG" --checksums checksums.txt --out manifests + find manifests -type f | sort + + # Les manifestes reprennent `checksums.txt`, donc leur empreinte est + # correcte par construction — sauf si le fichier a été réécrit entre le + # rendu et cet instant, ou si la ligne lue était celle d'un autre artefact. + # On télécharge donc les deux archives pour de vrai et on les compare : + # c'est le seul endroit où une erreur de sélection se verrait avant la + # soumission. `Get-FileHash` plutôt qu'un `sha256sum` de Git Bash, pour + # ne pas dépendre du jeu d'outils embarqué par Git for Windows. + - name: Les empreintes des manifestes correspondent aux archives publiées + shell: pwsh + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + $ErrorActionPreference = 'Stop' + $targets = @('x86_64', 'aarch64') + # Les manifestes sont lus et accolés en une seule chaîne : `Contains` + # sur un tableau teste l'égalité d'un élément, pas la présence d'un + # motif — l'assertion passerait donc pour n'importe quelle empreinte. + $manifests = (Get-ChildItem -Recurse manifests -File | + ForEach-Object { Get-Content -Raw $_.FullName }) -join "`n" + + foreach ($target in $targets) { + $artifact = "typr-$env:TAG-$target-pc-windows-msvc.zip" + $url = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:TAG/$artifact" + Invoke-WebRequest -Uri $url -OutFile $artifact + $actual = (Get-FileHash -Algorithm SHA256 $artifact).Hash.ToLowerInvariant() + Remove-Item $artifact + + if (-not $manifests.Contains($actual)) { + throw "l'empreinte de $artifact ($actual) ne figure dans aucun manifeste rendu" + } + Write-Host "$artifact : $actual, présente dans les manifestes rendus" + } + + # `wingetcreate submit ` est la commande prévue pour une première + # soumission : `update` exigerait que le paquet existe déjà dans + # winget-pkgs. Le chemin de destination (`manifests/w/we-data-ch/typpr/ + # /`) est calculé par l'outil d'après le contenu des manifestes ; + # le dossier rendu n'a donc pas besoin d'être pré-dispositionné. + # + # L'outil valide les fichiers avec le validateur officiel de WinGet avant + # d'ouvrir la pull request : c'est le même contrôle que winget-pkgs + # appliquera, et il échoue ici plutôt qu'après la fusion. + # + # Le jeton passe par la variable d'environnement `WINGET_CREATE_GITHUB_TOKEN` + # et non par `--token` : sur la ligne de commande il apparaîtrait dans la + # liste des processus du runner et dans les journaux. + # + # `wingetcreate` réécrit les manifestes avec son propre sérialiseur : les + # commentaires de nos gabarits ne se retrouvent donc pas dans la pull + # request. Ce qui compte — les champs — est celui que l'outil a validé. + - name: wingetcreate + if: env.WINGET_CREATE_TOKEN != '' + shell: pwsh + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + $ErrorActionPreference = 'Stop' + + Invoke-WebRequest ` + -Uri https://github.com/microsoft/winget-create/releases/latest/download/wingetcreate.exe ` + -OutFile wingetcreate.exe + + $env:WINGET_CREATE_GITHUB_TOKEN = $env:WINGET_CREATE_TOKEN + & .\wingetcreate.exe submit --prtitle "TypR $env:VERSION" --no-open manifests/winget + if ($LASTEXITCODE -ne 0) { + throw "wingetcreate submit a échoué (code $LASTEXITCODE)" + } + + # `wingetcreate` n'a pas de commande `validate` autonome, et `submit` exige + # une authentification même pour valider : sans jeton, la validation + # officielle ne peut pas tourner ici. Elle a alors lieu à chaque PR, dans + # le job `packaging-win` de ci.yml, contre les schémas JSON de winget-pkgs. + - name: WINGET_CREATE_GITHUB_TOKEN absent — manifestes non soumis + if: env.WINGET_CREATE_TOKEN == '' + shell: bash + run: | + echo "::warning title=WinGet non publie::WINGET_CREATE_GITHUB_TOKEN absent ; les manifestes rendus sont valides (job packaging-win de ci.yml) mais aucune pull request n'est ouverte." + + # --------------------------------------------------------------------------- + # Scoop — commit vers we-data-ch/scoop-bucket, après installation réelle + # + # Contrairement à WinGet, un bucket est un dépôt ordinaire : pas de compte à + # créer, pas de validation à passer. Le contrôle utile n'est donc pas la + # validation du manifeste — Scoop n'en fait pas au commit — mais + # l'installation : `scoop install ` télécharge l'archive + # publiée, en vérifie l'empreinte, l'extrait et crée le shim `typr`. C'est le + # seul endroit où toute la chaîne est exercée pour de vrai. + # --------------------------------------------------------------------------- + scoop: + name: Scoop bucket + needs: [verify, release] + runs-on: windows-latest + env: + SCOOP_BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }} + # Le bucket est écrit par un clone HTTPS : le jeton apparaît dans l'URL + # donnée à git, jamais dans un argument de ligne de commande. + BUCKET_URL: https://x-access-token:${{ secrets.SCOOP_BUCKET_TOKEN }}@github.com/we-data-ch/scoop-bucket.git + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.verify.outputs.tag }} + + - name: Récupérer checksums.txt de la release + shell: bash + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + set -euo pipefail + curl -fsSfL \ + "https://github.com/${{ github.repository }}/releases/download/$TAG/checksums.txt" \ + -o checksums.txt + cat checksums.txt + + - name: Générer les manifestes depuis checksums.txt + shell: bash + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + set -euo pipefail + packaging/render.sh --tag "$TAG" --checksums checksums.txt --out manifests + find manifests -type f | sort + + # Installation réelle depuis le manifeste local : Scoop accepte un chemin + # vers un fichier de manifeste, ce qui évite d'avoir à publier le bucket + # avant de pouvoir tester. L'archive est celle de la release, son + # empreinte est celle du manifeste — une erreur de sélection se verrait + # ici, à l'extraction. + # + # `scoop install` échoue si le hash ne correspond pas, mais on le vérifie + # quand même séparément : Scoop passe alors par aria2, dont les messages + # parlent de miroir et pas d'empreinte. + - name: Scoop installe le binaire publié et le lance + shell: pwsh + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + $ErrorActionPreference = 'Stop' + + # Le manifeste porte un tiret cadratin ; lu avec l'encodage de + # console par défaut de Windows PowerShell 5.1, il devient illisible. + # `pwsh` est en UTF-8 par défaut, on le dit quand même. + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $OutputEncoding = [System.Text.Encoding]::UTF8 + + Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression + Write-Host "scoop : $(scoop --version)" + + scoop install "manifests\scoop\typr.json" + if ($LASTEXITCODE -ne 0) { + throw "scoop install a échoué (code $LASTEXITCODE)" + } + + # L'installateur de Scoop met les shims dans le PATH *de l'utilisateur*, + # donc du PATH du processus seulement à la prochaine session : sans + # cette ligne, `typr` serait introuvable alors que l'installation a + # réussi. + $env:PATH = "$env:USERPROFILE\scoop\shims;$env:PATH" + + # `typr --version` répond `typr ` (clap, `#[command(version)]`). + $reported = (& typr --version) -join ' ' + Write-Host "typr --version : $reported" + if ($reported -notmatch [regex]::Escape($env:VERSION)) { + throw "le binaire installé annonce « $reported », pas la version $env:VERSION de la release" + } + + - name: Pousser le bucket + if: env.SCOOP_BUCKET_TOKEN != '' + shell: bash + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + set -euo pipefail + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + # Clone complet, pas `--depth 1` : en cas de push refusé il faut pouvoir + # rebaser, et un clone superficiel refuse de rebaser sur un historique + # qu'il n'a pas. + if ! git clone "$BUCKET_URL" bucket; then + echo "::error::le dépôt we-data-ch/scoop-bucket est inaccessible — il doit exister avant la première release (README.md du dépôt : « Prérequis »)." + exit 1 + fi + + mkdir -p bucket/bucket bucket/bin/typr + cp manifests/scoop/bucket/typr.json bucket/bucket/typr.json + cp "manifests/scoop/bin/typr/$VERSION.json" "bucket/bin/typr/$VERSION.json" + cp manifests/scoop/README.md bucket/README.md + cp LICENSE bucket/LICENSE + + # Un bucket garde l'historique : `typr update` sert à cela. On n'en + # conserve que les dix dernières versions, classées par ordre d'ajout + # dans l'historique — un tri alphabétique des versions serait faux, et + # `0.5.9` se retrouverait devant `0.5.12`. + # + # `git log` sort du plus récent au plus ancien : il suffit donc de + # laisser passer les dix premières lignes pour purger les plus anciennes. + # + # Le tout premier commit tombe dans un dépôt vide, où `git log` n'a rien + # à raconter et sortirait en erreur. + if git -C bucket rev-parse --verify HEAD >/dev/null 2>&1; then + git -C bucket log --diff-filter=A --name-only --format= -- 'bucket/bin/typr/*.json' \ + | grep -v '^$' | awk '!seen[$0]++' | tail -n +11 \ + | while read -r old; do + git -C bucket rm -q "$old" + echo " purgé : $old" + done + else + echo "bucket vide : premier commit, rien à purger." + fi + + git -C bucket add bucket/typr.json "bucket/bin/typr/$VERSION.json" README.md LICENSE + if git -C bucket diff --cached --quiet; then + echo "Le bucket est déjà à jour ($VERSION)." + exit 0 + fi + git -C bucket commit -m "TypR $VERSION" + + # Le bucket a ses propres contributeurs : on rebase plutôt que + # d'écraser. + for attempt in 1 2 3; do + if git -C bucket push origin HEAD:main; then + echo "bucket $VERSION poussé (tentative $attempt)." + exit 0 + done + echo "push refusé — rebase sur origin/main et nouvelle tentative" + git -C bucket fetch origin main + git -C bucket rebase origin/main + done + echo "::error::impossible de pousser le bucket après 3 tentatives" + exit 1 + + - name: SCOOP_BUCKET_TOKEN absent — bucket non mis à jour + if: env.SCOOP_BUCKET_TOKEN == '' + shell: bash + run: | + echo "::warning title=Scoop non publie::SCOOP_BUCKET_TOKEN absent ; le bucket garde sa version precedente (l installation reelle, elle, a ete verifiee)." diff --git a/RELEASING.md b/RELEASING.md index b63b73d..b0cdd13 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -101,11 +101,13 @@ tag vX.Y.Z │ ├─ rstudio .... tarball R avec les binaires frais embarqués │ └─ docs ....... repository_dispatch → reconstruction du site │ - ├─ crates-io ...... typr-core → typr-lsp → typr-cli → typr - ├─ wasm ........... WASM + version.json → dépôt du playground - ├─ vscode ......... .vsix → Marketplace + Open VSX + attaché à la release - ├─ vim ............ tarball du plugin, attaché à la release - └─ brew ........... formule générée → tap `we-data-ch/homebrew-typr` + ├─ crates-io ...... typr-core → typr-lsp → typr-cli → typr + ├─ wasm ........... WASM + version.json → dépôt du playground + ├─ vscode ......... .vsix → Marketplace + Open VSX + attaché à la release + ├─ vim ............ tarball du plugin, attaché à la release + ├─ brew ........... formule générée → tap `we-data-ch/homebrew-typr` + ├─ winget ......... manifestes générés → PR `microsoft/winget-pkgs` + └─ scoop .......... manifeste généré → bucket `we-data-ch/scoop-bucket` ``` Les branches sont indépendantes : si le Marketplace échoue, les binaires et @@ -153,6 +155,8 @@ en 0.5.7 et que les releases GitHub annonçaient 0.5.7. | `PLAYGROUND_DEPLOY_TOKEN` | `wasm` | PAT GitHub, scope `repo` sur le dépôt du playground | | `DOCS_DISPATCH_TOKEN` | `docs` | PAT GitHub, scope `repo` sur `typr.github.io` | | `HOMEBREW_TAP_TOKEN` | `brew` | PAT GitHub, scope `repo` sur `homebrew-typr` | +| `WINGET_CREATE_GITHUB_TOKEN` | `winget` | PAT GitHub, scope `public_repo` (lu par `wingetcreate` dans l'environnement) | +| `SCOOP_BUCKET_TOKEN` | `scoop` | PAT GitHub, écriture sur `scoop-bucket` | ## Le tap Homebrew @@ -185,6 +189,42 @@ Le choix du lot 3 est le premier : il est cohérent avec le reste de la chaîne, une formule générée par la CI depuis le `checksums.txt` n'a pas à être relue — ce qui la relèverait, c'est son unicité, pas sa rédaction. +## Les canaux WinGet et Scoop + +Aucun binaire non plus : `winget-pkgs` ne publie que trois fichiers YAML, et le +bucket Scoop que des URL et leurs SHA-256. Les deux canaux lisent le **même** +`checksums.txt` que la formule Homebrew, et sont rendus par le même script, +`packaging/render.sh` — voir `packaging/README.md`. + +Ce que les deux jobs font de plus que le tap, c'est **installer pour de vrai** +avant de publier : + +- `winget` télécharge les deux archives Windows et compare leurs empreintes à + celles des manifestes rendus, puis passe le dossier à `wingetcreate submit`, + qui le valide avec le validateur officiel avant d'ouvrir la PR ; +- `scoop` lance `scoop install` sur le manifeste local, attend le shim `typr`, et + exige que `typr --version` annonce la version de la release. + +Un canal qui refuse d'installer ne publie donc rien — mieux vaut un job rouge +qu'un manifeste que personne ne peut installer. + +**Le dépôt `we-data-ch/scoop-bucket` doit exister avant le premier tag.** Le job +`clone` échoue bruyamment s'il est absent, parce qu'un `git push` vers une URL +inexistante échouerait lui aussi, mais trois minutes plus tard : + +```bash +gh repo create we-data-ch/scoop-bucket --public --description "Bucket Scoop pour TypR" +``` + +Les secrets manquants ne bloquent pas la release : le rendu et les installations +locales ont lieu, seule la publication est sautée avec un avertissement. C'est +voulu — pour qu'un tag serve à autre chose qu'à produire un job rouge. + +Le bucket garde dix versions, purgées par ordre d'ajout dans l'historique et non +par ordre alphabétique. `typr update` sert à cela : sans purge, un utilisateur qui +n'aurait pas fait `scoop update` pendant un an verrait une liste de manifests +toujours plus longue. + ## Aucun binaire dans le dépôt Les binaires du runner RStudio sont assemblés par la CI au moment de construire diff --git a/install_plan.md b/install_plan.md index ac30d00..b24dcdd 100644 --- a/install_plan.md +++ b/install_plan.md @@ -20,7 +20,8 @@ par-dessus, pas de refaire ce qui marche. | 1 — scripts d'installation + hébergement + CI | **fait** (voir ci-dessous) | | 2 — tests CI sur ces scripts | **couvert par le lot 1** — les jobs `install`, `install-windows` et `install-hosting` sont ceux-là | | 3 — Homebrew | **fait** (voir ci-dessous) | -| 4 → 7 | à faire | +| 4 — WinGet et Scoop | **fait** (voir ci-dessous) | +| 5 → 7 | à faire | ### Ce que le lot 0 a changé @@ -201,6 +202,76 @@ amorcé avec la formule v0.5.12 rendue depuis le `checksums.txt` de cette releas et la prochaine release remplacera ce fichier par le sien, ce qui exercera le chemin complet `release.yml` → tap. +### Ce que le lot 4 a livré + +Les deux canaux sont rendus par **un seul script**, `packaging/render.sh`, au +niveau `packaging/` — et non deux sous `packaging/winget/` et `packaging/scoop/` +comme le plan les posait. La raison est celle qui vaut partout ailleurs dans ce +dépôt : les deux canaux lisent le **même** `checksums.txt` et doivent donc +pouvoir être cassés ensemble. Un générateur par canal laisserait passer le jour +où l'un des deux est régénéré et pas l'autre — et les deux canaux +désinstalleront la même version, avec des empreintes différentes. + +```sh +packaging/render.sh --tag v0.5.12 --out /tmp/manifests # 6 fichiers +``` + +| Fichier | Contenu | +|---|---| +| `packaging/winget/*.yaml.in` | version + installateurs (`x64`, `arm64`) + locale `en-US` | +| `packaging/scoop/typr.json.in` | manifeste Scoop | +| `packaging/scoop/README.md.in` | README du bucket, relu dans le manifeste | +| `packaging/tests/run-tests.sh` | 122 assertions, dont la validation par les schémas officiels | +| `packaging/tests/validate-schemas.py` | schémas WinGet et Scoop, en cache, `--offline` disponible | + +Le job `packaging-win` de `ci.yml` rend depuis une fixture et fait valider par les +schémas officiels. Les jobs `winget` et `scoop` de `release.yml` rendent depuis le +`checksums.txt` de la release, **vérifient les deux archives téléchargées**, puis +publient : PR `winget-pkgs` via `wingetcreate submit` d'un côté, commit dans le +bucket de l'autre. Le job `scoop` va plus loin et **installe** : `scoop install` +sur le manifeste local, puis `typr --version` doit annoncer la version de la +release. + +Trois défauts ont été trouvés en faisant, que rien ne montrait à la relecture. +Les trois sont couverts par la suite, et détaillés dans `packaging/README.md` : + +- **`ReleaseDate` sans guillemets** — lu par un analyseur YAML 1.1, `2026-01-02` + est une *date* ; le schéma WinGet exige une *chaîne*. 23 tests cassent si le + guillemet disparaît. +- **Une clé `_comment_urls` dans le manifeste Scoop** — le schéma de Scoop refuse + toute clé inconnue et n'accorde que `_comment` ; le bucket n'aurait pas + installé. +- **`url` et `hash` réordonnés** — les deux tableaux sont alignés par index chez + Scoop ; c'est un défaut qui ne se voit qu'à l'installation, sur une + architecture ou pas l'autre. + +**Deux affirmations de ce plan étaient fausses.** Elles sont corrigées en §6 : + +- `InstallLocation` n'est pas un dossier de destination, c'est un *argument* + passé à l'installeur — et un zip n'a pas d'installeur. WinGet installe sous + `%LOCALAPPDATA%\Microsoft\WinGet\Packages\…`. La case « même dossier que + `install.ps1` » de la vérification est donc **abandonnée**, pas ratée : + `irm | iex` reste le canal qui promet `%LOCALAPPDATA%`. +- « `.installer.yaml` **si signature** » : ce fichier est obligatoire, signé ou + non — c'est lui qui décrit les installateurs. `SignatureSha256` n'a pas sa + place ici : les archives ne sont pas signées, et WinGet n'en exige pas pour + publier. + +Sur ARM64, WinGet associe `arm64` au binaire natif. Scoop, lui, cherche la +chaîne littérale `arm64` dans le manifeste et ne trouve pas `aarch64` : le +binaire x86_64 y tourne par émulation, ce que Windows 11 sait faire. Le plan +annonçait une sélection native par architecture ; le bucket fournit **les deux** +URL et laisse Scoop choisir son miroir, ce qui est déjà le natif quand il le prend. +Ajouter un bloc `architecture.arm64` décoratif aurait coûté une illusion — et un +risque de régression sur les vraies installations ARM64, ce qui vaut mieux éviter. + +Reste à faire, et ce n'est pas dans ce dépôt : créer +[`we-data-ch/scoop-bucket`](https://github.com/we-data-ch/scoop-bucket) (le job +échoue bruyamment s'il n'existe pas) et définir les secrets +`WINGET_CREATE_GITHUB_TOKEN` (scope `public_repo`) et `SCOOP_BUCKET_TOKEN`. Tant +qu'ils manquent, les deux jobs rendent, valident, téléchargent et vérifient les +archives, puis sautent la publication avec un avertissement. + --- ## 1. État actuel @@ -618,27 +689,45 @@ formule, pas l'installation. ## 6. Phase 3 — WinGet et Scoop -Deux manifests JSON, aucun code. Ils puisent dans les `.zip` déjà publiés. +Deux manifests, aucun binaire. Ils puisent dans les `.zip` déjà publiés. + +> **Ce qui suit a été corrigé après implémentation.** Les deux affirmations +> fausses du plan initial sont signalées en place ; voir « Ce que le lot 4 a +> livré » pour le reste. ### Livrables ``` -packaging/winget/we-data-ch.TypR.yaml (+ .installer.yaml si signature) -packaging/scoop/typr.json +packaging/render.sh le générateur des deux canaux +packaging/winget/we-data-ch.TypR.yaml.in version +packaging/winget/we-data-ch.TypR.installer.yaml.in installateurs x64 + arm64 +packaging/winget/we-data-ch.TypR.locale.en-US.yaml.in locale +packaging/scoop/typr.json.in manifeste Scoop +packaging/scoop/README.md.in README du bucket +packaging/tests/ suite + validation des schémas ``` +Le `.installer.yaml` n'est pas conditionné à une signature : WinGet l'exige pour +décrire les installateurs, signés ou non. + ### WinGet - `PackageIdentifier: we-data-ch.TypR` - `PackageVersion` sans le `v` initial — WinGet l'exige. - `InstallerType: zip`, `InstallerSwitches: Silent` et `SilentWithProgress` avec - `/S` pour `7z`… **à ne pas inventer** : le type `zip` d'WinGest ekstré sans + `/S` pour `7z`… **à ne pas inventer** : le type `zip` de WinGet extrait sans copie dans un dossier. -- Le chemin `InstallLocation` doit être `%LOCALAPPDATA%\Programs\typr`, pour - coïncider avec ce que fait `install.ps1`. -- Le fichier doit être publié dans `microsoft/winget-pkgs` par une pull request - ouverte automatiquement depuis la CI (action - `microsoft/winget-create` ou un script équivalent). +- ~~Le chemin `InstallLocation` doit être `%LOCALAPPDATA%\Programs\typr`, pour + coïncider avec ce que fait `install.ps1`.~~ **Faux.** `InstallLocation` est un + *argument passé à l'installeur*, pas un dossier de destination ; un zip n'a pas + d'installeur, et le champ n'aurait rien fait. WinGet extrait sous + `%LOCALAPPDATA%\Microsoft\WinGet\Packages\…` et y met l'alias `typr` sur le + PATH — ce qui garde l'installation sans droits administrateur, le seul point + qui comptait dans cette case. +- Le fichier est publié dans `microsoft/winget-pkgs` par une pull request + ouverte automatiquement depuis la CI. `wingetcreate` n'a pas de commande + `validate` autonome : c'est `wingetcreate submit` qui valide avec l'outil + officiel, puis calcule lui-même le chemin de destination et ouvre la PR. ### Scoop @@ -646,16 +735,36 @@ Le manifest est plus court que celui de WinGet et ne demande pas de PR maintenue manuellement. `scoop install typr` après `scoop bucket add typr https://github.com/we-data-ch/scoop-bucket`. +`url` et `hash` sont des **tableaux alignés par index** : les deux URL sont +fournies, Scoop les propose en miroirs et vérifie l'empreinte du fichier +réellement téléchargé. Voir la note ARM64 dans « Ce que le lot 4 a livré ». + Dans les deux cas, la version publiée doit venir de la même source que le reste — -`needs.verify.outputs.version` — et non d'un fichier écrit à la main. +`needs.verify.outputs.version` — et non d'un fichier écrit à la main. C'est +fait, et par construction : `render.sh` refuse de produire quoi que ce soit +depuis un `checksums.txt` qui ne contiendrait pas les deux empreintes Windows. ### Vérification -- [ ] `winget install we-data-ch.TypR` sur Windows 10 et 11 -- [ ] `scoop install typr` puis `scoop update typr` -- [ ] Les deux installers aboutissent au même dossier et au même binaire que - `install.ps1` -- [ ] Une machine sans rights administrateur réussit l'installation +- [x] Les manifestes rendus passent les **schémas officiels** WinGet et Scoop — + 122 assertions, dont six mutations vérifiées détectées à la main. +- [ ] `winget install we-data-ch.TypR` sur Windows 10 et 11 — **impossible en + local** : c'est fait par le job `winget` sur `windows-latest`, avec + `wingetcreate submit`. +- [x] L'installation **sans droits administrateur** est acquise : WinGet comme + Scoop écrivent dans le profil utilisateur. C'est vérifié par construction + (aucun `InstallerSwitches`, aucun `RequireExplicitUpgrade`, aucun + installeur MSI), pas par une exécution. +- [ ] `scoop install typr` puis `scoop update typr` — **impossible en local** : + c'est fait par le job `scoop`, qui installe le manifeste rendu avant de + commiter. +- [x] Les deux canaux installent **le même binaire** : la même archive + `typr-vX.Y.Z-x86_64-pc-windows-msvc.zip`, lue dans le même + `checksums.txt`, et le même `typr.exe` à sa racine. La suite le vérifie. +- ~~Les deux installers aboutissent au même dossier et au même binaire que + `install.ps1`~~ **Abandonnée**, voir §6 WinGet : le dossier ne peut pas être + choisi pour un zip portable. Ce qui est vérifié, c'est le binaire — pas le + chemin. --- diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..4efe94e --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,238 @@ +# Manifestes WinGet et Scoop — TypR + +Les canaux `winget install we-data-ch.TypR` et `scoop install typr` ne publient +aucun binaire. Le dépôt [`we-data-ch/scoop-bucket`](https://github.com/we-data-ch/scoop-bucket) +ne contient que des URL de release et leurs SHA-256 ; la pull request ouverte sur +[`microsoft/winget-pkgs`](https://github.com/microsoft/winget-pkgs) n'en contient +pas davantage. + +Ces valeurs sont **générées**, jamais écrites à la main. Un SHA recopié à la main +finit toujours par désigner un binaire qui n'est plus celui de la release, et +l'erreur n'apparaît qu'à l'installation, chez l'utilisateur. Ici elles sont lues +dans le `checksums.txt` que la release publie, puis comparées aux archives +réellement téléchargées avant d'être publiées. + +Les deux canaux lisent le **même** fichier, ce qui les empêche de diverger entre +eux : une release ne peut pas être installé par WinGet et refusée par Scoop sous +prétexte qu'une empreinte a été recopiée. + +## Les fichiers d'ici + +| Fichier | Rôle | +|---|---| +| `render.sh` | le générateur : tag + `checksums.txt` → les six fichiers des deux canaux | +| `winget/*.yaml.in` | les trois manifestes WinGet : version, installation, locale `en-US` | +| `scoop/typr.json.in` | le manifeste Scoop, et le même fichier versé dans l'historique du bucket | +| `scoop/README.md.in` | le README du bucket, dont la description et l'URL sont relues dans le manifeste | +| `tests/` | la suite de tests et le validateur de schémas, exécutés par le job `packaging-win` de `ci.yml` | + +Le générateur est commun aux deux canaux — `render.sh`, à la racine de +`packaging/`, rend tout. Il est POSIX `sh` de bout en bout : ni bash, ni ruby, ni +python. La validation par WinGet et par Scoop ne peut pas tourner ici ; elle est +faite par la suite de tests (schémas officiels) et par la CI (installation +réelle). + +## Rendre les manifestes + +```sh +# depuis le `checksums.txt` d'une release publiée +packaging/render.sh --tag v0.5.12 --out /tmp/manifests + +# ou depuis une copie locale du fichier +packaging/render.sh --tag v9.9.9 --checksums ./checksums.txt --out /tmp/manifests +``` + +Six fichiers sont écrits, rien d'autre : + +``` +winget/we-data-ch.TypR.yaml version → PR winget-pkgs +winget/we-data-ch.TypR.installer.yaml installateurs (x64, arm64) +winget/we-data-ch.TypR.locale.en-US.yaml locale +scoop/bucket/typr.json version courante du bucket +scoop/bin/typr/.json historique +scoop/README.md README du bucket +``` + +Sans `--checksums`, l'URL est déduite du tag — +`https://github.com/we-data-ch/typr/releases/download//checksums.txt` — ce +qui est exactement ce que font les jobs `winget` et `scoop`. + +| Option | Effet | +|---|---| +| `--tag ` | version à publier ; le `v` initial est optionnel, `-alpha.N` et `-beta.N` sont acceptés | +| `--checksums ` | `checksums.txt` : URL (défaut) ou fichier local | +| `--out ` | où écrire `winget/` et `scoop/` (défaut : le répertoire courant) | +| `--release-date ` | `ReleaseDate` WinGet ; par défaut la date du jour, en UTC | +| `--help` | l'aide | + +## Tester + +Sans réseau, sous n'importe quel Unix : + +```sh +packaging/tests/run-tests.sh +``` + +La suite couvre ce qui compte et ne se voit pas à la relecture : + +- que le générateur prenne **la** bonne ligne parmi les huit de `checksums.txt` — + les deux archives Windows ne diffèrent que par un mot, et les six autres + cibles ne doivent jamais apparaître dans un canal Windows ; +- que l'URL d'une architecture ne soit jamais jumelée à l'empreinte de l'autre ; + les deux cibles sont vérifiées comme un bloc, architecture comprise ; +- que `url` et `hash` du manifeste Scoop restent alignés par index, comme Scoop + les lit ; +- que l'alias WinGet (`typr`) et l'exécutable Scoop (`typr.exe`) désignent le + même programme ; +- que rien ne soit écrit quand une vérification échoue. + +Les schémas officiels sont validés en plus, quand le réseau est là : + +```sh +packaging/render.sh --tag v9.9.9 \ + --checksums packaging/tests/fixtures/checksums-v9.9.9.txt --out /tmp/manifests +packaging/tests/validate-schemas.py /tmp/manifests +``` + +Ils sont téléchargés depuis `winget-pkgs` et `Scoop`, pas recopiés dans ce dépôt : +`winget-pkgs` n'accepte que son propre schéma, et une copie locale validerait +moins que ce qui sera exigé dès la version suivante. + +## Ce que la CI en fait + +Le job `packaging-win` de `ci.yml` rend les manifestes depuis une fixture, sans +réseau ni release, et les fait valider par les schémas officiels. C'est ce qui +arrête une pull request. + +Ce que la fixture ne peut pas juger a lieu au moment de la release : + +Le job `winget` de `release.yml`, sur `windows-latest` : + +1. récupère le `checksums.txt` de la release ; +2. rend les manifestes ; +3. télécharge les deux archives Windows et vérifie que leurs empreintes + figurent bien dans les manifestes rendus ; +4. passe le dossier rendu à `wingetcreate submit`, qui le valide avec le + validateur officiel de WinGet avant d'ouvrir la pull request. + +Le job `scoop` de `release.yml`, sur `windows-latest` : + +1. récupère le `checksums.txt` de la release ; +2. rend les manifestes ; +3. installe le binaire **pour de vrai**, depuis le manifeste local : + `scoop install ` télécharge l'archive publiée, en vérifie + l'empreinte, l'extrait, crée le shim `typr` — puis `typr --version` doit + annoncer la version de la release ; +4. ne commite vers le bucket qu'après cela, en purgeant l'historique au-delà des + dix dernières versions. + +Sans les secrets, les étapes 1 à 3 ont lieu quand même et seule la publication est +sautée, avec un avertissement ; un refus de WinGet ou de Scoop arrête le job. + +## Deux points où le plan avait tort + +Le plan de distribution annonçait `InstallLocation` dans le manifeste WinGet, en +prétendant obtenir `%LOCALAPPDATA%\Programs\typr`. Ce champ n'est pas un dossier +de destination : c'est un *argument passé à l'installeur*, et un zip n'a pas +d'installeur. WinGet installe donc dans son propre dossier de paquet, sous +`%LOCALAPPDATA%\Microsoft\WinGet\Packages\…`, et y met `typr` sur le PATH — sans +droits administrateur, ce qui est le point qui compte. Le chemin exact n'est pas +contractuel et changera ; `irm | iex` reste le canal qui promet `%LOCALAPPDATA%`. + +Le plan annonçait aussi un « `.installer.yaml` de signature ». Ce fichier est +nécessaire quel que soit le statut de signature : c'est lui qui décrit les +installateurs. La signature, elle, se déclare par `SignatureSha256`, qui n'a pas +sa place ici : les archives ne sont pas signées, et WinGet n'en exige pas pour +publier. + +## Trois pièges de validation, et comment ils ont été trouvés + +Ce sont des défauts que rien n'affiche à la relecture. Ils sont tous couverts par +la suite de tests. + +**`ReleaseDate` sans guillemets.** Lu par un analyseur YAML 1.1 — PyYAML, et +tout ce qui l'imite — `2026-01-02` est une *date*, pas une chaîne, et le schéma +de `winget-pkgs` exige une chaîne. Le gabarit écrit donc `ReleaseDate: "…"`. +Guillemeté, le fichier est un `string` partout, sans dépendre de la version de la +norme qu'applique l'outil de validation. + +**Une clé de confort dans le manifeste Scoop.** Le schéma de Scoop refuse toute +clé inconnue (`additionalProperties: false`) et n'accorde que `_comment` parmi les +clés préfixées par un tiret bas. Une clé `_comment_urls` expliquant l'ordre des +URLs aurait été rejetée à l'installation : l'explication est dans ce fichier, dans +le gabarit, et dans les tests. + +**`url` et `hash` réordonnés.** Les deux tableaux sont alignés par index, et +Scoop vérifie l'empreinte du fichier qu'il a réellement téléchargé. Un `hash` +réordonné seul ne serait pas faux *par construction* — seulement faux à +l'installation, chez l'utilisateur, sur une architecture ou pas l'autre. La suite +compare donc les deux tableaux au `checksums.txt` de la fixture, index par index. + +## ARM64 + +Les deux archives Windows sont publiées et annoncées : `x86_64-pc-windows-msvc` et +`aarch64-pc-windows-msvc`. WinGet les associe à `x64` et `arm64` respectivement. + +Scoop, lui, choisit son architecture en cherchant la chaîne littérale `arm64` +dans le manifeste — un nom de fichier `aarch64-…` ne suffit pas. Un manifeste qui +ne contient pas ce mot est donc traité comme x64 sur une machine ARM64, et le +binaire x86_64 y tourne par émulation, ce que Windows 11 sait faire. C'est +acceptable, et c'est pourquoi les deux URL sont fournies : Scoop les propose en +miroirs et vérifie l'empreinte du fichier réellement téléchargé. Ajouter un bloc +`architecture.arm64` pour faire croire à une sélection native ne changerait rien +— `arch_specific` retomberait sur le tableau `url` — et coûterait une illusion. + +## Prérequis de publication + +- **Le dépôt [`we-data-ch/scoop-bucket`](https://github.com/we-data-ch/scoop-bucket) doit exister.** + C'est un dépôt ordinaire : il suffit de l'initialiser avec un `README.md` et un + `LICENSE`. Tant qu'il n'existe pas, le job `scoop` échoue bruyamment. +- **`SCOOP_BUCKET_TOKEN`** — PAT GitHub avec permission d'écriture sur ce dépôt. +- **`WINGET_CREATE_GITHUB_TOKEN`** — PAT GitHub avec le scope `public_repo` : + `winget-pkgs` est public, et il faut pouvoir y ouvrir une pull request. + `wingetcreate` le lit dans la variable d'environnement + `WINGET_CREATE_GITHUB_TOKEN`, jamais dans `--token`, qui l'afficherait dans la + liste des processus du runner. + +## La première soumission WinGet + +`wingetcreate submit` sert à la première publication d'un paquet ; `update` +exigerait qu'il existe déjà dans `winget-pkgs`. Le chemin de destination +(`manifests/w/we-data-ch/typpr//`) est calculé par l'outil d'après le +contenu des manifestes : le dossier rendu n'a pas besoin d'être pré-dispositionné. + +WinGet ne propose une version prerelease que sur demande explicite : +`winget install --pre`. Le paquet reste donc installable normalement, et le +rendu des manifestes n'a pas à distinguer les deux cas — sauf que le premier +`winget install` que quelqu'un tentera pour un paquet encore inconnu de +`winget-pkgs` risque de proposer `-alpha.1` à la place de la version stable. +Précéder la première publication d'une version stable est donc la prudence, pas +une règle que le générateur puisse appliquer : il rend fidèlement ce que la +release publie. + +Ce n'est pas au générateur de trancher non plus sur la date : `ReleaseDate` +décrit la date de la version WinGet, pas celle du commit ni celle de la +publication. Par défaut c'est le jour de la génération, ce qui est le jour où la +pull request est ouverte. + +Enfin, `wingetcreate` réécrit les manifestes avec son propre sérialiseur : les +commentaires de nos gabarits ne se retrouvent pas dans la pull request. Ce qui +compte — les champs — est exactement ce que l'outil a validé. + +## Ce que le générateur refuse + +| Cause | Message | +|---|---| +| tag illisible | `tag illisible : …` (code 2) | +| `--release-date` illisible | `release-date illisible : …` (code 2) | +| `checksums.txt` absent | `checksums.txt introuvable` | +| ligne absente pour un artefact | `ne contient aucune ligne pour …` | +| deux lignes pour le même artefact | `contient 2 lignes pour …` | +| SHA malformé | `ligne malformée dans checksums.txt` | +| gabarit modifié | `contient encore un marqueur non substitué` | +| rendu sans retour à la ligne final | `ne se termine pas par un retour à la ligne` | +| gabarit amputé d'une cible | `le manifeste d'installation déclare N empreintes, attendu 2` | + +Dans tous les cas **rien n'est écrit** dans `--out` : une erreur au milieu du +rendu ne doit pas laisser un manifeste tronqué que la CI proposerait à +`winget-pkgs`, ou commiterait dans le bucket. diff --git a/packaging/render.sh b/packaging/render.sh new file mode 100755 index 0000000..e8e4257 --- /dev/null +++ b/packaging/render.sh @@ -0,0 +1,404 @@ +#!/bin/sh +# TypR — génération des manifestes WinGet et Scoop à partir d'un tag. +# +# packaging/render.sh --tag v0.5.12 +# +# Produit les six fichiers des deux canaux Windows, tels que les dépôts de +# destination les attendent : +# +# winget/we-data-ch.TypR.yaml version → PR winget-pkgs +# winget/we-data-ch.TypR.installer.yaml installateurs (x64, arm64) +# winget/we-data-ch.TypR.locale.en-US.yaml locale +# scoop/bucket/typr.json version courante du bucket +# scoop/bin/typr/.json historique +# scoop/README.md README du bucket +# +# Rien n'est écrit à la main, et surtout pas un SHA-256 : recopié à la main, il +# finit forcément par désigner un binaire qui n'est plus celui de la release. Ici +# les empreintes viennent du fichier que la release a publié, donc aucun +# manifeste ne peut en diverger — et les deux canaux lisent le même fichier, ce +# qui les empêche de diverger entre eux. +# +# POSIX sh de bout en bout : ni bash, ni ruby, ni python. La validation par +# WinGet et par Scoop ne peut pas tourner ici ; elle est faite par la suite de +# tests (schémas officiels) et par la CI (installation réelle). +# +# Ce que ce fichier répète de `packaging/homebrew/render.sh` — la lecture du +# `checksums.txt`, l'isolement d'une ligne, la vérification 64 hexadécimaux — y est +# recopié volontairement plutôt que factorisé dans une bibliothèque partagée : +# les deux générateurs sont testés isolément, dans des bacs à sable où aucun +# fichier voisin n'est disponible. Un correctif appliqué à une seule des deux +# copies se verrait ici, par la suite de tests. + +set -eu + +# --------------------------------------------------------------------------- +# Configuration +# +# Les deux variables existent pour être surchargées par les tests, qui servent +# une arborescence de release locale. +# --------------------------------------------------------------------------- +REPO="${TYPR_REPO:-we-data-ch/typr}" +ORIGIN="${TYPR_ORIGIN:-https://github.com}" + +HERE=$(cd "$(dirname "$0")" && pwd) + +WINGET_VERSION_IN="$HERE/winget/we-data-ch.TypR.yaml.in" +WINGET_INSTALLER_IN="$HERE/winget/we-data-ch.TypR.installer.yaml.in" +WINGET_LOCALE_IN="$HERE/winget/we-data-ch.TypR.locale.en-US.yaml.in" +SCOOP_IN="$HERE/scoop/typr.json.in" +SCOOP_README_IN="$HERE/scoop/README.md.in" + +# Les deux cibles Windows, dans l'ordre où elles apparaissent dans les manifestes. +# L'ordre est une convention lisible, pas une contrainte : chaque entrée porte +# son URL et son empreinte, donc les deux sont interchangeables sans que rien ne +# puisse devenir faux. +A_WINDOWS_X86="x86_64-pc-windows-msvc" +A_WINDOWS_ARM="aarch64-pc-windows-msvc" + +# Nom de fichier WinGet : l'identifiant du paquet, puis le type de manifeste. +WINGET_ID="we-data-ch.TypR" + +TAG="" +CHECKSUMS="" +OUT="." +RELEASE_DATE="" + +TMP="" + +# --------------------------------------------------------------------------- +# Sortie +# --------------------------------------------------------------------------- +# Erreur d'environnement ou de vérification : le monde est en cause (1). +die() { + printf 'typr-render : %s\n' "$1" >&2 + exit 1 +} + +# Erreur de saisie : la ligne de commande est en cause (2). Même convention que +# install.sh, install.ps1 et homebrew/render.sh, pour qu'un appelant puisse +# réagir pareil. +usage_die() { + printf 'typr-render : %s\n' "$1" >&2 + printf '\n' >&2 + usage >&2 + exit 2 +} + +step() { printf '→ %s\n' "$*"; } +note() { printf ' %s\n' "$*"; } +warn() { printf 'typr-render : attention : %s\n' "$*" >&2; } + +cleanup() { + if [ -n "$TMP" ] && [ -d "$TMP" ]; then + rm -rf "$TMP" + fi +} + +usage() { + cat <<'EOF' +TypR — générateur des manifestes WinGet et Scoop + +Usage : + render.sh --tag [options] + +Options : + --tag version à publier (ex. v0.5.12 ; le v initial est optionnel) + --checksums checksums.txt de la release : URL (défaut) ou fichier local + --out où écrire winget/ et scoop/ (défaut : .) + --release-date ReleaseDate WinGet, format AAAA-MM-JJ (défaut : aujourd'hui, UTC) + --help cette aide + +Écrit : + winget/we-data-ch.TypR.yaml, .installer.yaml, .locale.en-US.yaml + scoop/bucket/typr.json, scoop/bin/typr/.json, scoop/README.md + +Ces fichiers sont entièrement générés : ne jamais les éditer à la main. Voir +packaging/README.md. +EOF +} + +# --------------------------------------------------------------------------- +# Arguments +# --------------------------------------------------------------------------- +while [ $# -gt 0 ]; do + case "$1" in + --tag) + [ $# -ge 2 ] || usage_die "--tag attend une version (ex. --tag v0.5.12)." + TAG="$2" + shift 2 + ;; + --tag=*) + TAG="${1#--tag=}" + shift + ;; + --checksums) + [ $# -ge 2 ] || usage_die "--checksums attend une URL ou un chemin." + CHECKSUMS="$2" + shift 2 + ;; + --checksums=*) + CHECKSUMS="${1#--checksums=}" + shift + ;; + --out) + [ $# -ge 2 ] || usage_die "--out attend un dossier." + OUT="$2" + shift 2 + ;; + --out=*) + OUT="${1#--out=}" + shift + ;; + --release-date) + [ $# -ge 2 ] || usage_die "--release-date attend une date (AAAA-MM-JJ)." + RELEASE_DATE="$2" + shift 2 + ;; + --release-date=*) + RELEASE_DATE="${1#--release-date=}" + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + usage_die "argument inconnu : $1" + ;; + esac +done + +[ -n "$TAG" ] || usage_die "--tag est obligatoire." + +case "$TAG" in + v*) ;; + *) TAG="v$TAG" ;; +esac + +# La forme exacte est vérifiée, pas seulement le préfixe `v` : le tag entre dans +# une URL, dans un nom de fichier (`scoop/bin/typr/.json`) et dans une +# substitution sed, donc un `v../../x` n'a rien à faire ici. `-alpha.1` et +# `-beta.2` sont acceptés : release.yml marque ces versions prerelease, et les +# deux canaux savent les publier. +printf '%s' "$TAG" \ + | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$' \ + || usage_die "tag illisible : '$TAG' (attendu : vX.Y.Z, ou vX.Y.Z-alpha.N)." + +VERSION="${TAG#v}" + +# WinGet classe les versions par version puis par ReleaseDate. Une date illisible +# ferait échouer la validation du dépôt avec un message qui ne parle que de la +# date — mieux vaut la nommer ici, où le tag est en cause. +if [ -z "$RELEASE_DATE" ]; then + RELEASE_DATE=$(date -u +%Y-%m-%d) \ + || die "date du jour illisible par \`date\` — passez --release-date AAAA-MM-JJ." +fi + +printf '%s' "$RELEASE_DATE" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' \ + || usage_die "release-date illisible : '$RELEASE_DATE' (attendu : AAAA-MM-JJ)." + +for required in "$WINGET_VERSION_IN" "$WINGET_INSTALLER_IN" "$WINGET_LOCALE_IN" \ + "$SCOOP_IN" "$SCOOP_README_IN"; do + [ -f "$required" ] || die "gabarit manquant : $required" +done + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +TMP=$(mktemp -d "${TMPDIR:-/tmp}/typr-render.XXXXXX") \ + || die "impossible de créer un dossier temporaire." + +# --------------------------------------------------------------------------- +# checksums.txt +# --------------------------------------------------------------------------- +command -v curl >/dev/null 2>&1 \ + || die "curl est requis et n'est pas installé." + +CHECKSUMS_FILE="$TMP/checksums.txt" + +case "$CHECKSUMS" in + ''|*://*) + URL="$CHECKSUMS" + [ -n "$URL" ] \ + || URL="$ORIGIN/$REPO/releases/download/$TAG/checksums.txt" + step "Téléchargement de $URL" + curl -fsSL "$URL" -o "$CHECKSUMS_FILE" \ + || die "checksums.txt introuvable pour $TAG. + URL : $URL" + ;; + *) + [ -f "$CHECKSUMS" ] || die "checksums.txt introuvable : $CHECKSUMS" + cp "$CHECKSUMS" "$CHECKSUMS_FILE" + ;; +esac + +# --------------------------------------------------------------------------- +# SHA-256 +# +# Une ligne par artefact, au format `sha256sum`. On isole la ligne qui concerne +# l'artefact cherché — `sha256sum -c` ne conviendrait pas, il tenterait de +# vérifier les sept autres archives et sortirait en erreur alors que la nôtre est +# valide. Les points du nom sont échappés : sans cela, le motif matche +# n'importe quel caractère à leur place, et `x86_64-pc-windows-msvc` se ferait +# prendre pour `x86_64-pc-windows-msvc` suivi de n'importe quoi. +# --------------------------------------------------------------------------- +lookup_sha() { # lookup_sha — vide si absent, exit 1 si malformé + artifact="$1" + pattern=$(printf '%s' "$artifact" | sed 's/[.[\*^$]/\\&/g') + lines=$(grep -E "[[:space:]]\*?${pattern}\$" "$CHECKSUMS_FILE" || true) + count=$(printf '%s' "$lines" | grep -c . || true) + + [ "$count" -gt 1 ] && die "checksums.txt contient $count lignes pour $artifact : +$lines +Deux lignes pour le même nom : la release est incohérente, et choisir l'une +des deux reviendrait à publier une empreinte arbitraire." + + [ "$count" -eq 1 ] || return 0 + + sha=$(printf '%s\n' "$lines" | cut -d' ' -f1) + printf '%s\n' "$sha" | grep -Eq '^[0-9a-f]{64}$' \ + || die "ligne malformée dans checksums.txt pour $artifact : + $lines +Un SHA-256 fait 64 caractères hexadécimaux." + + printf '%s\n' "$sha" +} + +require_sha() { # require_sha — die si absent + sha=$(lookup_sha "$1") || exit 1 + [ -n "$sha" ] \ + || die "checksums.txt de $TAG ne contient aucune ligne pour $1. + Fichier reçu : +$(cat "$CHECKSUMS_FILE")" + printf '%s' "$sha" +} + +# --------------------------------------------------------------------------- +# Rendu +# --------------------------------------------------------------------------- +SHA_WINDOWS_X86=$(require_sha "typr-$TAG-$A_WINDOWS_X86.zip") || exit 1 +SHA_WINDOWS_ARM=$(require_sha "typr-$TAG-$A_WINDOWS_ARM.zip") || exit 1 + +step "TypR $VERSION" +note "tag : $TAG" +note "release date : $RELEASE_DATE" +note "sortie : $OUT" + +# Les valeurs substituées sont des URL et des empreintes : aucun caractère `&` +# ni `|`, qui seraient des métacaractères de sed. La seule exception possible +# viendrait du tag, validé plus haut. +BASE_URL="$ORIGIN/$REPO/releases/download/$TAG" +URL_WINDOWS_X86="$BASE_URL/typr-$TAG-$A_WINDOWS_X86.zip" +URL_WINDOWS_ARM="$BASE_URL/typr-$TAG-$A_WINDOWS_ARM.zip" + +# Les trois manifestes WinGet sont rendus par la même substitution : `sed` ne +# sait pas répéter une substitution par cible, alors que `awk` le ferait — mais +# `awk` n'est pas garanti sur un macOS minimal, et POSIX sh l'est. +for template in version installer locale; do + case "$template" in + version) in_file="$WINGET_VERSION_IN" ;; + installer) in_file="$WINGET_INSTALLER_IN" ;; + locale) in_file="$WINGET_LOCALE_IN" ;; + esac + sed \ + -e "s|@@VERSION@@|$VERSION|g" \ + -e "s|@@RELEASE_DATE@@|$RELEASE_DATE|g" \ + -e "s|@@URL_X86_64_WINDOWS@@|$URL_WINDOWS_X86|g" \ + -e "s|@@SHA256_X86_64_WINDOWS@@|$SHA_WINDOWS_X86|g" \ + -e "s|@@URL_AARCH64_WINDOWS@@|$URL_WINDOWS_ARM|g" \ + -e "s|@@SHA256_AARCH64_WINDOWS@@|$SHA_WINDOWS_ARM|g" \ + "$in_file" > "$TMP/winget-$template.yaml" +done + +sed \ + -e "s|@@VERSION@@|$VERSION|g" \ + -e "s|@@URL_X86_64_WINDOWS@@|$URL_WINDOWS_X86|g" \ + -e "s|@@SHA256_X86_64_WINDOWS@@|$SHA_WINDOWS_X86|g" \ + -e "s|@@URL_AARCH64_WINDOWS@@|$URL_WINDOWS_ARM|g" \ + -e "s|@@SHA256_AARCH64_WINDOWS@@|$SHA_WINDOWS_ARM|g" \ + "$SCOOP_IN" > "$TMP/typr.json" + +# Un gabarit dont le nom d'un marqueur a changé produirait un manifeste servi +# tel quel, avec `@@…@@` dedans — pour WinGet, une pull request qui ne passe +# jamais la validation ; pour Scoop, un bucket cassé. +for rendered in "$TMP/winget-version.yaml" "$TMP/winget-installer.yaml" \ + "$TMP/winget-locale.yaml" "$TMP/typr.json"; do + grep -q '@@' "$rendered" \ + && die "$(basename "$rendered") contient encore un marqueur non substitué : +$(grep -n '@@' "$rendered")" +done + +# Un gabarit écrit sans retour à la ligne final donne un fichier que les +# validateurs refusent, et le retrait ne se voit que dans le diff du dépôt de +# destination. Aucune de ces anomalies ne se voit dans la sortie de ce script, +# d'où le contrôle. +for rendered in "$TMP/winget-version.yaml" "$TMP/winget-installer.yaml" \ + "$TMP/winget-locale.yaml" "$TMP/typr.json"; do + [ -z "$(tail -c1 "$rendered")" ] \ + || die "$(basename "$rendered") ne se termine pas par un retour à la ligne." +done + +# Le compte est une garde, pas une décoration : il vérifie que les deux cibles +# Windows ont chacune produit une URL et une empreinte, sans quoi un gabarit +# amputé d'une moitié partirait sans bruit. +shas=$(grep -cE '^[[:space:]]*InstallerSha256: [0-9a-f]{64}$' "$TMP/winget-installer.yaml" || true) +[ "$shas" -eq 2 ] \ + || die "le manifeste d'installation déclare $shas empreintes, attendu 2. +Le gabarit et le rendu ne correspondent plus." + +grep -Fq "PackageVersion: $VERSION" "$TMP/winget-installer.yaml" \ + || die "le manifeste d'installation ne déclare pas PackageVersion: $VERSION." + +grep -Fq "ReleaseDate: \"$RELEASE_DATE\"" "$TMP/winget-installer.yaml" \ + || die "le manifeste d'installation ne déclare pas ReleaseDate: \"$RELEASE_DATE\"." + +# Le README du bucket est dérivé du manifeste, pas d'une deuxième source : +# description et URL y sont relues dans ce qui vient d'être écrit, donc les deux +# fichiers ne peuvent pas diverger. +DESC=$(sed -n 's/^[[:space:]]*"description": "\(.*\)",\{0,1\}$/\1/p' "$TMP/typr.json" | head -1) +HOMEPAGE=$(sed -n 's/^[[:space:]]*"homepage": "\(.*\)",\{0,1\}$/\1/p' "$TMP/typr.json" | head -1) + +[ -n "$DESC" ] || die "aucune description lisible dans le manifeste rendu." +[ -n "$HOMEPAGE" ] || die "aucune homepage lisible dans le manifeste rendu." + +sed \ + -e "s|@@DESC@@|$DESC|g" \ + -e "s|@@HOMEPAGE@@|$HOMEPAGE|g" \ + -e "s|@@VERSION@@|$VERSION|g" \ + "$SCOOP_README_IN" > "$TMP/README.md" + +# Le README est rendu après le manifeste, parce qu'il en relit la description : +# il a donc besoin de ses propres contrôles. +grep -q '@@' "$TMP/README.md" \ + && die "le README rendu contient encore un marqueur non substitué : +$(grep -n '@@' "$TMP/README.md")" + +[ -z "$(tail -c1 "$TMP/README.md")" ] \ + || die "README.md ne se termine pas par un retour à la ligne." + +# --------------------------------------------------------------------------- +# Écriture +# +# Rien n'est écrit dans le dossier de sortie tant que le rendu n'est pas +# complet : un échec au milieu ne doit pas laisser un manifeste tronqué que la +# CI pousserait vers winget-pkgs ou vers le bucket. +# --------------------------------------------------------------------------- +mkdir -p "$OUT/winget" || die "impossible de créer $OUT/winget." +mkdir -p "$OUT/scoop/bucket" "$OUT/scoop/bin/typr" \ + || die "impossible de créer $OUT/scoop." + +cp "$TMP/winget-version.yaml" "$OUT/winget/$WINGET_ID.yaml" +cp "$TMP/winget-installer.yaml" "$OUT/winget/$WINGET_ID.installer.yaml" +cp "$TMP/winget-locale.yaml" "$OUT/winget/$WINGET_ID.locale.en-US.yaml" + +cp "$TMP/typr.json" "$OUT/scoop/bucket/typr.json" +cp "$TMP/typr.json" "$OUT/scoop/bin/typr/$VERSION.json" +cp "$TMP/README.md" "$OUT/scoop/README.md" + +note "sha256 : $SHA_WINDOWS_X86 ($A_WINDOWS_X86)" +note "sha256 : $SHA_WINDOWS_ARM ($A_WINDOWS_ARM)" + +printf '\n' +step "Écrit : $OUT/winget/ (3 manifestes) et $OUT/scoop/ (bucket, historique, README)" diff --git a/packaging/scoop/README.md.in b/packaging/scoop/README.md.in new file mode 100644 index 0000000..72e248d --- /dev/null +++ b/packaging/scoop/README.md.in @@ -0,0 +1,46 @@ +# TypR — bucket Scoop + +@@DESC@@ + +## Installation + +```powershell +scoop bucket add typr https://github.com/we-data-ch/scoop-bucket +scoop install typr +``` + +`bucket add` n'est nécessaire qu'une fois. Scoop installe sans droits +administrateur et met `typr` sur le PATH. + +## Utilisation + +```powershell +typr check fichier.ty +typr build fichier.ty +typr --version +``` + +Mise à jour : + +```powershell +scoop update typr +``` + +Désinstallation : + +```powershell +scoop uninstall typr +``` + +## Versions + +Ce dépôt ne publie aucun binaire. Le manifeste ne contient que l'URL de la +release et le SHA-256 des deux archives Windows, lus dans le `checksums.txt` de +cette release. Un tag `vX.Y.Z` de [we-data-ch/typr](https://github.com/we-data-ch/typr) +produit le commit suivant ici — voir `packaging/README.md` dans le dépôt source. + +TypR @@VERSION@@ est la version publiée dans ce bucket. + +Documentation : @@HOMEPAGE@@ + +Licence : Apache-2.0 — voir `LICENSE`. diff --git a/packaging/scoop/typr.json.in b/packaging/scoop/typr.json.in new file mode 100644 index 0000000..5ff7c3f --- /dev/null +++ b/packaging/scoop/typr.json.in @@ -0,0 +1,21 @@ +{ + "$schema": "https://raw.githubusercontent.com/ScoopInstaller/Scoop/master/schema.json", + "_comment": "GENERATED FILE — do not edit by hand. Template: packaging/scoop/typr.json.in in we-data-ch/typr. Rendered by packaging/render.sh --tag vX.Y.Z. Note: the Scoop schema allows only _comment among underscore-prefixed keys, and rejects every unknown key outright.", + "version": "@@VERSION@@", + "description": "A typed superset of R — transpiler and type checker", + "homepage": "https://we-data-ch.github.io/typr.github.io/", + "license": "Apache-2.0", + "url": [ + "@@URL_X86_64_WINDOWS@@", + "@@URL_AARCH64_WINDOWS@@" + ], + "hash": [ + "@@SHA256_X86_64_WINDOWS@@", + "@@SHA256_AARCH64_WINDOWS@@" + ], + "bin": "typr.exe", + "notes": [ + "TypR is pre-1.0: 0.x releases can change the CLI surface.", + "Installed by Scoop under %USERPROFILE%\\scoop\\apps\\typr — not under %LOCALAPPDATA%\\Programs\\typr as irm | iex does. Both put `typr` on the PATH for the current user, without administrator rights." + ] +} diff --git a/packaging/tests/fixtures/checksums-v9.9.9.txt b/packaging/tests/fixtures/checksums-v9.9.9.txt new file mode 100644 index 0000000..09f1811 --- /dev/null +++ b/packaging/tests/fixtures/checksums-v9.9.9.txt @@ -0,0 +1,13 @@ +# checksums.txt factice — la même forme que celui produit par le job +# `release` de .github/workflows/release.yml (sha256sum, huit archives), +# pour la release de test v9.9.9. Utilisé par packaging/tests/run-tests.sh +# et par le job `packaging` de ci.yml, qui rend les manifestes sans toucher +# au réseau. Aucune de ces empreintes ne correspond à un fichier. +67bfbab2f377ffb28cc9d394222f7403d71202d1559cba9f179e5728cc8bffc2 typr-v9.9.9-x86_64-unknown-linux-musl.tar.gz +2ceb889725cc3afea080085a48588f191235ff2bf54b3a6596609b692c019394 typr-v9.9.9-aarch64-unknown-linux-musl.tar.gz +9eb96299648b6fb476ad2176cfd326e6683b896d5c982ec0f30f5407b550dd91 typr-v9.9.9-x86_64-unknown-linux-gnu.tar.gz +ebd691b6a05239269b8d4b0a1e5a691505e12d80fc8c9ef6130ddf42688e0490 typr-v9.9.9-aarch64-unknown-linux-gnu.tar.gz +b9fd9fdc0cb0e6b6b07a4cf7a5f80161290ef786b95a2551a80476b5245dc277 typr-v9.9.9-x86_64-apple-darwin.tar.gz +6fe8e339164f7261977dd9ce689f50e075fc31872eda6a47b91299cf52e5a7da typr-v9.9.9-aarch64-apple-darwin.tar.gz +c42898b3daa997df6dc7dff3ab5e704be2059360894d973a2fc364ec8ff8ab14 typr-v9.9.9-x86_64-pc-windows-msvc.zip +8d8d51a553587a69ea63dd3883ba7e7730561f1375c8e62ae7dcb53b425ba81a typr-v9.9.9-aarch64-pc-windows-msvc.zip diff --git a/packaging/tests/run-tests.sh b/packaging/tests/run-tests.sh new file mode 100755 index 0000000..c053df1 --- /dev/null +++ b/packaging/tests/run-tests.sh @@ -0,0 +1,718 @@ +#!/usr/bin/env bash +# Tests de packaging/render.sh (manifestes WinGet et Scoop), sans réseau. +# +# Le générateur ne lit qu'une chose : le `checksums.txt` d'une release. Toute sa +# valeur tient donc à trois propriétés qu'aucune relecture ne montre — +# +# 1. il prend **la** bonne ligne (le fichier liste huit archives, dont six +# Linux/macOS et les deux GNU qu'un canal Windows n'a jamais le droit de +# pointer) ; +# 2. il necroise jamais une URL avec l'empreinte d'une autre cible — les deux +# cibles Windows ne diffèrent que par un mot du nom ; +# 3. il refuse de produire un manifeste à moitié rendu. +# +# C'est ce que ces tests couvrent, plus la dérive entre les gabarits et le +# générateur — le défaut qui n'apparaît qu'à la prochaine release, chez +# l'utilisateur. +# +# packaging/tests/run-tests.sh +# +# Le même script est exécuté par le job `packaging` de ci.yml. + +set -uo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +PACKAGING_DIR=$(cd "$HERE/.." && pwd) +RENDER="$PACKAGING_DIR/render.sh" +FIXTURE="$HERE/fixtures/checksums-v9.9.9.txt" +VALIDATE="$HERE/validate-schemas.py" + +TAG="v9.9.9" +VERSION="9.9.9" +RELEASE_DATE="2026-01-02" + +# Les deux cibles Windows, dans l'ordre où le générateur les déclare. +WIN_X86="x86_64-pc-windows-msvc" +WIN_ARM="aarch64-pc-windows-msvc" + +WINGET_VERSION_FILE="we-data-ch.TypR.yaml" +WINGET_INSTALLER_FILE="we-data-ch.TypR.installer.yaml" +WINGET_LOCALE_FILE="we-data-ch.TypR.locale.en-US.yaml" + +PASSED=0 +FAILED=0 +ROOT="" +SERVER_PID="" + +cleanup() { + [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null + [ -n "$ROOT" ] && rm -rf "$ROOT" + return 0 +} +trap cleanup EXIT + +ok() { PASSED=$((PASSED + 1)); printf ' \033[32m✓\033[0m %s\n' "$1"; } +ko() { FAILED=$((FAILED + 1)); printf ' \033[31m✗\033[0m %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; return 0; } + +check_rc() { # check_rc + if [ "$1" -eq "$2" ]; then ok "$3"; else ko "$3" "code de retour $2, attendu $1"; fi +} + +check_contains() { # check_contains + # Un motif vide matcherait n'importe quoi : l'assertion passerait sans rien + # vérifier, ce qui est pire que ne pas tester. Il est donc refusé. + if [ -z "$2" ]; then ko "$3" "motif de recherche vide — l'assertion ne vérifierait rien"; return 0; fi + case "$1" in + *"$2"*) ok "$3" ;; + *) ko "$3" "« $2 » absent de la sortie : $1" ;; + esac +} + +check_not_contains() { + if [ -z "$2" ]; then ko "$3" "motif de recherche vide — l'assertion ne vérifierait rien"; return 0; fi + case "$1" in + *"$2"*) ko "$3" "« $2 » présent alors qu'il ne devait pas l'être" ;; + *) ok "$3" ;; + esac +} + +check_file() { # check_file + if [ -f "$1" ]; then ok "$2"; else ko "$2" "$1 absent"; fi +} + +check_no_file() { + if [ -e "$1" ]; then ko "$2" "$1 présent"; else ok "$2"; fi +} + +# Un manifeste WinGet porte des commentaires qui expliquent le rendu — dont +# certains nomment précisément ce qui ne doit pas apparaître (pourquoi pas +# d'`InstallLocation`, par exemple). Les contrôles de contenu portent donc sur +# les *champs*, pas sur le texte : un commentaire ne télécharge rien, et une +# assertion qui le prendrait en compte finirait un jour par interdire sa propre +# documentation. +fields() { # fields + grep -v '^[[:space:]]*#' "$1" || true +} + +# L'empreinte de l'artefact dans la fixture — la valeur attendue, lue depuis la +# fixture et non recopiée ici. Le tag fait partie du motif : c'est lui qui +# distingue deux releases, et deux releases ont des empreintes différentes. +fixture_sha() { # fixture_sha [fichier] + grep -E "[[:space:]]\*?typr-$TAG-$1\.zip\$" "${2:-$FIXTURE}" | head -1 | cut -d' ' -f1 +} + +work() { # work — dossier de travail sous $ROOT, donc nettoyé avec lui + local dir="$ROOT/$1" + rm -rf "$dir" + mkdir -p "$dir" + printf '%s' "$dir" +} + +render() { # render [args...] + local out="$1"; shift + "$RENDER" --tag "$TAG" --checksums "$FIXTURE" --release-date "$RELEASE_DATE" --out "$out" 2>&1 +} + +# Le bloc attendu d'une cible WinGet : son architecture, son URL **et** son +# empreinte, côte à côte. Vérifier séparément que les quatre valeurs +# apparaissent dans le fichier ne prouverait rien — deux cibles interverties +# donneraient exactement le même texte. C'est l'erreur que le canal Homebrew a +# déjà laissée passer, une fois. +expect_winget_block() { # expect_winget_block + printf -- '- Architecture: %s\n NestedInstallerFiles:\n - RelativeFilePath: typr.exe\n PortableCommandAlias: typr\n InstallerUrl: https://github.com/we-data-ch/typr/releases/download/%s/typr-%s-%s.zip\n InstallerSha256: %s' \ + "$1" "$TAG" "$TAG" "$2" "$(fixture_sha "$2")" +} + +# Le manifeste Scoop, lu comme Scoop le lit : `url` et `hash` sont alignés par +# index, `bin` désigne l'exécutable, et l'alias WinGet doit désigner le même +# programme. C'est fait en Python parce que le point à vérifier — l'alignement +# index par index — n'est pas exprimable autrement. +check_scoop() { # check_scoop + # La lecture du manifeste Scoop est faite en Python : l'alignement index par + # index de `url` et `hash` ne s'exprime pas autrement. Chaque problème y est + # listé, ce que le shell ne sait pas faire. + local details + if details=$(python3 - "$1" "$2" "$FIXTURE" "$TAG" 2>&1 <<'PY' +import json, os, re, sys + +manifest, installer, fixture, tag = sys.argv[1:5] +problems = [] + +with open(manifest, encoding="utf-8") as handle: + doc = json.load(handle) + +with open(fixture, encoding="utf-8") as handle: + wanted = {} + for line in handle: + line = line.strip() + if line and not line.startswith("#"): + sha, _, name = line.partition(" ") + wanted[name.strip()] = sha + +if doc.get("version") != tag.lstrip("v"): + problems.append(f"version : {doc.get('version')!r}, attendu {tag.lstrip('v')!r}") + +urls, hashes = doc.get("url"), doc.get("hash") +if not isinstance(urls, list) or not isinstance(hashes, list): + problems.append("url et hash doivent être des listes alignées") +else: + if len(urls) != len(hashes): + problems.append(f"{len(urls)} URL pour {len(hashes)} empreintes") + for url, sha in zip(urls, hashes): + name = url.rsplit("/", 1)[-1] + if not re.fullmatch(r"[0-9a-f]{64}", sha): + problems.append(f"empreinte mal formée pour {name} : {sha}") + if wanted.get(name) != sha: + problems.append( + f"{name} → {sha}, alors que la release publie {wanted.get(name)}" + ) + if f"typr-{tag}-" not in name: + problems.append(f"{name} ne vient pas de la release {tag}") + +bin_name = doc.get("bin") +if bin_name != "typr.exe": + problems.append(f"bin : {bin_name!r}, attendu 'typr.exe'") + +# Même programme dans les deux canaux : l'alias WinGet est le nom de l'exécutable +# Scoop, sans son extension. +alias = None +with open(installer, encoding="utf-8") as handle: + for line in handle: + if "PortableCommandAlias:" in line: + alias = line.split(":", 1)[1].strip() +if isinstance(bin_name, str) and alias != os.path.splitext(bin_name)[0]: + problems.append(f"alias WinGet {alias!r} ≠ exécutable Scoop {bin_name!r}") + +for problem in problems: + print(f" {problem}") +sys.exit(1 if problems else 0) +PY + ); then + ok "$3" + else + ko "$3" "$details" + fi +} + +# --------------------------------------------------------------------------- +# Analyse statique +# --------------------------------------------------------------------------- +test_static_analysis() { + echo "Analyse statique" + + # `ok` et `ko` sortent toujours en 0, donc le `||` ne se déclenche jamais à + # tort : SC2015 signalerait ici un if-then-else qui n'existe pas. + # shellcheck disable=SC2015 + sh -n "$RENDER" 2>&1 && ok "sh -n" || ko "sh -n" + # shellcheck disable=SC2015 + bash -n "$RENDER" 2>&1 && ok "bash -n" || ko "bash -n" + # shellcheck disable=SC2015 + bash -n "$HERE/run-tests.sh" 2>&1 && ok "bash -n (suite)" || ko "bash -n (suite)" + # Le cache d'octets est redirigé vers le répertoire de travail de la suite : + # `py_compile` écrit d'ordinaire `__pycache__/` à côté du fichier, et + # `packaging/tests/` n'a rien à faire committer. + # shellcheck disable=SC2015 + PYTHONPYCACHEPREFIX="$(work pycache)" python3 -m py_compile "$VALIDATE" 2>&1 \ + && ok "py_compile" || ko "py_compile" + + if command -v dash >/dev/null 2>&1; then + # shellcheck disable=SC2015 + dash -n "$RENDER" 2>&1 && ok "dash -n (POSIX strict)" || ko "dash -n" + else + printf ' – dash absent, POSIX strict non vérifié\n' + fi + + if command -v shellcheck >/dev/null 2>&1; then + # Aucune exclusion : render.sh ne se sert ni de `local` (non POSIX) ni + # d'aucun attribut bash. + if out=$(shellcheck -s sh "$RENDER" 2>&1); then + ok "shellcheck" + else + ko "shellcheck" "$out" + fi + if out=$(shellcheck "$HERE/run-tests.sh" 2>&1); then + ok "shellcheck (suite)" + else + ko "shellcheck (suite)" "$out" + fi + else + printf ' – shellcheck absent, non exécuté\n' + fi +} + +# --------------------------------------------------------------------------- +# Dérive gabarit ↔ générateur +# +# Un gabarit dont un marqueur a été renommé (`@@VERSION@@` → `@@VERSION_X@@`) +# produit un manifeste qui part en production avec `@@VERSION_X@@` dedans : rien +# ne le signale tant qu'un utilisateur n'installe pas. Le générateur le refuse, +# mais seulement s'il connaît encore le nom du marqueur — ce qui est précisément +# ce qui a pu changer. D'où ce test, qui compare les deux listes. +# --------------------------------------------------------------------------- +test_marker_drift() { + echo "Dérive gabarit / générateur" + + local in_tpl in_gen only_tpl only_gen + in_tpl=$(grep -oh '@@[A-Z0-9_]*@@' \ + "$PACKAGING_DIR/winget/$WINGET_VERSION_FILE.in" \ + "$PACKAGING_DIR/winget/$WINGET_INSTALLER_FILE.in" \ + "$PACKAGING_DIR/winget/$WINGET_LOCALE_FILE.in" \ + "$PACKAGING_DIR/scoop/typr.json.in" \ + "$PACKAGING_DIR/scoop/README.md.in" | sort -u) + in_gen=$(grep -oh '@@[A-Z0-9_]*@@' "$RENDER" | sort -u) + + only_tpl=$(comm -23 <(printf '%s\n' "$in_tpl") <(printf '%s\n' "$in_gen") | tr '\n' ' ') + if [ -z "$only_tpl" ]; then + ok "aucun marqueur de gabarit inconnu du générateur" + else + ko "aucun marqueur de gabarit inconnu du générateur" "$only_tpl" + fi + + only_gen=$(comm -13 <(printf '%s\n' "$in_tpl") <(printf '%s\n' "$in_gen") | tr '\n' ' ') + if [ -z "$only_gen" ]; then + ok "aucun marqueur du générateur absent des gabarits" + else + ko "aucun marqueur du générateur absent des gabarits" "$only_gen" + fi + + # Un gabarit sans retour à la ligne final donne un fichier que les validateurs + # de winget-pkgs et de Scoop refusent, et le retrait ne se voit qu'en + # installant. + local tpl + for tpl in "$PACKAGING_DIR/winget/$WINGET_VERSION_FILE.in" \ + "$PACKAGING_DIR/winget/$WINGET_INSTALLER_FILE.in" \ + "$PACKAGING_DIR/winget/$WINGET_LOCALE_FILE.in" \ + "$PACKAGING_DIR/scoop/typr.json.in" \ + "$PACKAGING_DIR/scoop/README.md.in"; do + if [ -z "$(tail -c1 "$tpl")" ]; then + ok "$(basename "$tpl") se termine par un retour à la ligne" + else + ko "$(basename "$tpl") se termine par un retour à la ligne" "$tpl" + fi + done +} + +# --------------------------------------------------------------------------- +# Ligne de commande +# --------------------------------------------------------------------------- +test_usage() { + echo " --help et erreurs de saisie" + local out + out=$("$RENDER" --help 2>&1) + check_rc 0 $? "--help sort en 0" + check_contains "$out" "Usage" "--help affiche l'usage" + + out=$("$RENDER" --bogus 2>&1) + check_rc 2 $? "un argument inconnu sort en 2" + check_contains "$out" "argument inconnu" "l'argument est nommé" + + out=$("$RENDER" --out /tmp 2>&1) + check_rc 2 $? "--tag absent sort en 2" + check_contains "$out" "--tag est obligatoire" "l'absence de --tag est nommée" + + out=$("$RENDER" --tag 2>&1) + check_rc 2 $? "--tag sans valeur sort en 2" + out=$("$RENDER" --tag "$TAG" --release-date 2>&1) + check_rc 2 $? "--release-date sans valeur sort en 2" + out=$("$RENDER" --tag "$TAG" --out 2>&1) + check_rc 2 $? "--out sans valeur sort en 2" + + # Le tag entre dans une URL, dans un nom de fichier + # (`scoop/bin/typr/.json`) et dans une substitution sed : une forme + # trop libre passerait par la porte de l'injection. + for tag in 'pas-un-tag' 'v1.2' 'v1.2.3/../../etc' 'vX.Y.Z' 'v1.2.3 ../../x' 'v1.2.3;rm'; do + out=$("$RENDER" --tag "$tag" --checksums "$FIXTURE" --out "$(work bad-tag)" 2>&1) + check_rc 2 $? "tag refusé : $tag" + done + + # `ReleaseDate` est lue par WinGet comme une chaîne ; une date illisible ferait + # échouer la validation du dépôt en ne parlant que d'elle. + out=$("$RENDER" --tag "$TAG" --checksums "$FIXTURE" \ + --release-date '02/01/2026' --out "$(work bad-date)" 2>&1) + check_rc 2 $? "release-date illisible refusée" + check_contains "$out" "AAAA-MM-JJ" "le format attendu est nommé" +} + +# --------------------------------------------------------------------------- +# Chemin heureux +# --------------------------------------------------------------------------- +test_render() { + echo "Rendu complet" + local out dir installer locale version_manifest + dir=$(work render) + out=$(render "$dir") + check_rc 0 $? "rendu" + check_contains "$out" "TypR $VERSION" "la version est annoncée" + + version_manifest="$dir/winget/$WINGET_VERSION_FILE" + installer="$dir/winget/$WINGET_INSTALLER_FILE" + locale="$dir/winget/$WINGET_LOCALE_FILE" + + check_file "$version_manifest" "le manifeste de version est écrit" + check_file "$installer" "le manifeste d'installation est écrit" + check_file "$locale" "la locale est écrite" + check_file "$dir/scoop/bucket/typr.json" "le manifeste du bucket est écrit" + check_file "$dir/scoop/bin/typr/$VERSION.json" "le manifeste d'historique est écrit" + check_file "$dir/scoop/README.md" "le README du bucket est écrit" + [ -f "$installer" ] || return 0 + + # Trois manifestes, une seule version : WinGet refuse une pull request dont les + # versions ne concordent pas, et il le dit d'une façon illisible + # (« manifest version mismatch ») quand ça vient d'un copier-coller. + local f + for f in "$version_manifest" "$installer" "$locale" "$dir/scoop/bucket/typr.json"; do + check_contains "$(cat "$f")" "$VERSION" "la version $VERSION est déclarée dans $(basename "$f")" + check_not_contains "$(cat "$f")" "PackageVersion: v$VERSION" "aucun v initial dans le nom de version WinGet" + check_not_contains "$(cat "$f")" "\"version\": \"v$VERSION\"" "aucun v initial dans la version Scoop" + done + + check_contains "$(cat "$version_manifest")" "DefaultLocale: en-US" "la version désigne la locale en-US" + check_contains "$(cat "$locale")" "PackageLocale: en-US" "la locale se déclare en-US" + check_contains "$(cat "$version_manifest")" "ManifestType: version" "le manifeste se déclare version" + check_contains "$(cat "$installer")" "ManifestType: installer" "l'installateur se déclare installer" + check_contains "$(cat "$locale")" "ManifestType: defaultLocale" "la locale se déclare defaultLocale" + + # Chaque cible porte son URL et son empreinte côte à côte, sous son + # architecture. Une cible intervertie donnerait le même nombre d'empreintes. + check_contains "$(cat "$installer")" "$(expect_winget_block x64 "$WIN_X86")" \ + "architecture, URL et SHA corrects pour x64" + check_contains "$(cat "$installer")" "$(expect_winget_block arm64 "$WIN_ARM")" \ + "architecture, URL et SHA corrects pour arm64" + + local shas + shas=$(grep -cE '^[[:space:]]*InstallerSha256: [0-9a-f]{64}$' "$installer") + if [ "$shas" -eq 2 ]; then ok "deux empreintes déclarées"; else ko "deux empreintes déclarées" "$shas trouvées"; fi + + # Les deux archives Windows ne diffèrent que par un mot du nom, et un `.` non + # échappé dans le motif de recherche ferait matcher n'importe quoi à sa place. + # Aucune des six archives Linux/macOS ne doit apparaître, et en particulier + # pas les GNU, que le canal Homebrew a déjà confondus un jour. + check_not_contains "$(fields "$installer")" "tar.gz" "aucune archive Linux/macOS dans l'installateur WinGet" + check_not_contains "$(fields "$installer")" "unknown-linux" "aucune cible Linux dans l'installateur WinGet" + check_not_contains "$(fields "$installer")" "apple-darwin" "aucune cible macOS dans l'installateur WinGet" + local gnu + gnu=$(grep -E '[[:space:]]typr-v9\.9\.9-x86_64-unknown-linux-gnu\.tar\.gz$' "$FIXTURE" | cut -d' ' -f1) + check_not_contains "$(cat "$installer")$(cat "$dir/scoop/bucket/typr.json")" "$gnu" \ + "l'empreinte GNU n'est retenue dans aucun canal" + check_not_contains "$(cat "$dir/scoop/bucket/typr.json")" "tar.gz" "aucune archive Linux/macOS dans le manifeste Scoop" + + # `url` et `hash` alignés par index, `bin` juste, alias concordant. + check_scoop "$dir/scoop/bucket/typr.json" "$installer" \ + "le manifeste Scoop est cohérent (url/hash/bin/alias)" + + # L'historique et le bucket sont le même fichier : deux rendus distincts + # divergeraient au premier changement de gabarit. + if diff -q "$dir/scoop/bucket/typr.json" "$dir/scoop/bin/typr/$VERSION.json" >/dev/null 2>&1; then + ok "le manifeste d'historique est identique au manifeste du bucket" + else + ko "le manifeste d'historique est identique au manifeste du bucket" \ + "$(diff "$dir/scoop/bucket/typr.json" "$dir/scoop/bin/typr/$VERSION.json" | head -4)" + fi + + # Rien d'autre dans la sortie : un vieux fichier y servirait encore à son + # adresse, dans un dépôt où l'historique fait foi. + local extra expected + expected=$(printf '%s\n' \ + "scoop/README.md" "scoop/bucket/typr.json" "scoop/bin/typr/$VERSION.json" \ + "winget/$WINGET_VERSION_FILE" "winget/$WINGET_INSTALLER_FILE" \ + "winget/$WINGET_LOCALE_FILE" | sort) + extra=$(cd "$dir" && find . -type f | sed 's|^\./||' | sort | grep -vxF "$expected" || true) + if [ -z "$extra" ]; then ok "aucun fichier surnumeraire dans la sortie"; else ko "aucun fichier surnumeraire dans la sortie" "$extra"; fi +} + +# --------------------------------------------------------------------------- +# Les pièges WinGet et Scoop, une fois pour toutes +# +# Ce sont des défauts que rien n'affiche à la relecture : ils n'apparaissent que +# dans le dépôt de destination, ou pas du tout. +# --------------------------------------------------------------------------- +test_platform_pitfalls() { + echo "Pièges WinGet et Scoop" + local out dir installer manifest + dir=$(work pitfalls) + out=$(render "$dir") + check_rc 0 $? "rendu de référence" + + installer="$dir/winget/$WINGET_INSTALLER_FILE" + manifest="$dir/scoop/bucket/typr.json" + [ -f "$installer" ] || return 0 + + check_contains "$(cat "$installer")" 'ReleaseDate: "'"$RELEASE_DATE"'"' \ + "ReleaseDate est guillemeté (une date nue devient un objet date en YAML 1.1)" + check_contains "$(cat "$installer")" "InstallerType: zip" "l'installateur est un zip" + check_contains "$(cat "$installer")" "NestedInstallerType: portable" "le zip est traité comme portable" + check_contains "$(cat "$installer")" "RelativeFilePath: typr.exe" "l'exécutable est nommé dans l'archive" + + # `InstallLocation` est un argument d'installeur, pas un dossier de destination + # : un zip n'en accepte pas, et WinGet installe sans élévation de toute façon. + check_not_contains "$(fields "$installer")" "InstallerSwitches" \ + "aucun champ InstallerSwitches sur un zip (dont InstallLocation, qui n'est pas un dossier)" + check_not_contains "$(fields "$installer")" "Scope:" \ + "aucun champ Scope (le zip est déjà en installation utilisateur)" + + # Le schéma de Scoop refuse toute clé inconnue, `_comment` étant la seule + # tolérée : une clé de confort se paie par un manifeste refusé. + check_not_contains "$(cat "$manifest")" '"_comment_"' "aucune clé _comment_* dans le manifeste Scoop" + local unknown + unknown=$(python3 - "$manifest" <<'PY' +import json, sys +allowed = {"$schema", "_comment", "version", "description", "homepage", "license", + "url", "hash", "bin", "notes", "architecture", "checkver", "autoupdate", + "depends", "conflicts", "suggest", "installer", "uninstaller", "env_add_path", + "env_set", "shortcuts", "psmodule", "pre_install", "post_install", "pre_uninstall", + "post_uninstall", "persist", "extract_dir", "extract_to", "encoding", "innosetup"} +extra = sorted(set(json.load(open(sys.argv[1], encoding="utf-8"))) - allowed) +print(" ".join(extra)) +PY +) + if [ -z "$unknown" ]; then ok "aucune clé inconnue dans le manifeste Scoop"; else ko "aucune clé inconnue dans le manifeste Scoop" "$unknown"; fi +} + +# --------------------------------------------------------------------------- +# Version épinglée et prerelease +# --------------------------------------------------------------------------- +test_pin_and_prerelease() { + echo "Version épinglée et prerelease" + local out dir sums + + # Sans le « v », comme install.sh qui le remet. + dir=$(work no-v) + out=$("$RENDER" --tag "$VERSION" --checksums "$FIXTURE" --release-date "$RELEASE_DATE" --out "$dir" 2>&1) + check_rc 0 $? "un tag sans le v initial est accepté" + check_contains "$(cat "$dir/winget/$WINGET_VERSION_FILE" 2>/dev/null)" "PackageVersion: $VERSION" \ + "la version est rendue sans le v" + + # release.yml marque `-alpha.N` et `-beta.N` prerelease : les deux canaux + # doivent les accepter, sinon le canal WinGet s'arrêterait à la première + # d'entre elles. La fixture est réécrite sur un tag de prerelease, parce + # qu'une release prerelease publie bien ses propres archives. + sums=$(work sums-prerelease) + sed 's/v9\.9\.9/v0.5.13-alpha.1/g' "$FIXTURE" > "$sums/checksums.txt" + dir=$(work prerelease) + out=$("$RENDER" --tag v0.5.13-alpha.1 --checksums "$sums/checksums.txt" --release-date "$RELEASE_DATE" --out "$dir" 2>&1) + check_rc 0 $? "une prerelease est rendue" + check_contains "$(cat "$dir/winget/$WINGET_VERSION_FILE" 2>/dev/null)" "PackageVersion: 0.5.13-alpha.1" \ + "la prerelease est déclarée sans v" + check_contains "$(cat "$dir/scoop/bucket/typr.json" 2>/dev/null)" '"version": "0.5.13-alpha.1"' \ + "la prerelease est déclarée dans le manifeste Scoop" + check_file "$dir/scoop/bin/typr/0.5.13-alpha.1.json" "l'historique porte le nom de la prerelease" + + # Sans artefact à son nom, rien ne peut être rendu : une release dont le job de + # build a échoué sur une cible Windows ne doit pas produire un manifeste qui + # pointerait une autre archive. + dir=$(work prerelease-missing) + out=$("$RENDER" --tag v9.9.9-alpha.1 --checksums "$FIXTURE" --release-date "$RELEASE_DATE" --out "$dir" 2>&1) + check_rc 1 $? "une prerelease sans artefacts propres est refusée" + check_contains "$out" "ne contient aucune ligne pour" "l'absence est nommée, pas devinée" + check_no_file "$dir/winget/$WINGET_VERSION_FILE" "aucun manifeste n'est écrit" + + # Dossier de sortie imbriqué absent : il doit être créé. + dir="$ROOT/nested/deep/out" + rm -rf "$ROOT/nested" + out=$(render "$dir") + check_rc 0 $? "dossier de sortie imbriqué créé" + check_file "$dir/scoop/bucket/typr.json" "le manifeste est écrit dans le dossier imbriqué" +} + +# --------------------------------------------------------------------------- +# Refus +# --------------------------------------------------------------------------- +test_missing_line() { + echo "Ligne absente, doublon, malformation" + local out dir rel + dir=$(work missing) + out=$(render "$dir") + check_rc 0 $? "rendu de référence" + + # Sans la ligne d'un artefact : rien ne peut être rendu. + rel=$(work sums-missing) + grep -v "$WIN_ARM" "$FIXTURE" > "$rel/checksums.txt" + out=$("$RENDER" --tag "$TAG" --checksums "$rel/checksums.txt" --release-date "$RELEASE_DATE" --out "$(work out-missing)" 2>&1) + check_rc 1 $? "checksums.txt sans la ligne attendue → échec" + check_contains "$out" "$WIN_ARM" "l'artefact manquant est nommé" + check_no_file "$(work out-missing)/winget/$WINGET_INSTALLER_FILE" "aucun manifeste n'est écrit" + check_no_file "$(work out-missing)/scoop/bucket/typr.json" "aucun manifeste Scoop n'est écrit" + + # Deux lignes pour le même nom : choisir l'une des deux reviendrait à publier + # une empreinte arbitraire. + rel=$(work sums-dup) + { cat "$FIXTURE"; printf '%s typr-%s-%s.zip\n' "$(fixture_sha "$WIN_X86")" "$TAG" "$WIN_X86"; } > "$rel/checksums.txt" + out=$("$RENDER" --tag "$TAG" --checksums "$rel/checksums.txt" --release-date "$RELEASE_DATE" --out "$(work out-dup)" 2>&1) + check_rc 1 $? "deux lignes pour le même artefact → échec" + check_contains "$out" "2 lignes" "le nombre de lignes est nommé" + check_no_file "$(work out-dup)/winget/$WINGET_INSTALLER_FILE" "aucun manifeste n'est écrit sur un doublon" + + # Malformée : la ligne existe, le SHA est illisible. Un fichier tronqué ne doit + # pas produire un manifeste que winget-pkgs refusera ensuite — il le dirait par + # le nom du champ, sans jamais nommer la release comme cause. + # (64 zéros ne sont pas un cas de test : c'est un SHA bien formé, et le + # générateur a raison de l'accepter.) + for bad in 'pas-un-sha' \ + "$(fixture_sha "$WIN_X86" | cut -c1-63)" \ + "$(fixture_sha "$WIN_X86" | cut -c1-63)z" \ + "$(fixture_sha "$WIN_X86")0"; do + rel=$(work sums-bad) + awk -v bad="$bad" -v t="typr-$TAG-$WIN_X86.zip" \ + '{ if ($NF == t) print bad " " $NF; else print }' "$FIXTURE" > "$rel/checksums.txt" + out=$("$RENDER" --tag "$TAG" --checksums "$rel/checksums.txt" --release-date "$RELEASE_DATE" --out "$(work out-bad)" 2>&1) + check_rc 1 $? "SHA malformé refusé : $bad" + check_contains "$out" "malform" "la malformation est nommée" + done + + # checksums.txt absent du tout. + out=$("$RENDER" --tag "$TAG" --checksums "$ROOT/inexistant.txt" --out "$(work out-ghost)" 2>&1) + check_rc 1 $? "checksums.txt absent → échec" + check_contains "$out" "introuvable" "l'absence est nommée" +} + +# --------------------------------------------------------------------------- +# Gabarit modifié : le générateur doit refuser plutôt que publier tel quel +# --------------------------------------------------------------------------- +test_template_drift() { + echo "Gabarit modifié" + local out sandbox + sandbox=$(work sandbox) + cp "$RENDER" "$sandbox/render.sh" + mkdir -p "$sandbox/winget" "$sandbox/scoop" + cp "$PACKAGING_DIR/winget/"*.in "$sandbox/winget/" + cp "$PACKAGING_DIR/scoop/"*.in "$sandbox/scoop/" + + # Le marqueur est renommé côté gabarit, pas côté générateur : exactement le + # réagencement de nom qu'un commit peut introduire. + sed -i 's/@@VERSION@@/@@TYPR_VERSION@@/' "$sandbox/winget/$WINGET_VERSION_FILE.in" + out=$("$sandbox/render.sh" --tag "$TAG" --checksums "$FIXTURE" --release-date "$RELEASE_DATE" --out "$(work out-drift)" 2>&1) + check_rc 1 $? "un marqueur renommé est refusé" + check_contains "$out" "marqueur" "le marqueur non substitué est nommé" + check_no_file "$(work out-drift)/winget/$WINGET_VERSION_FILE" "aucun manifeste n'est écrit" + + # Retrait du retour à la ligne final : invisible dans une diff, fatal pour la + # validation du dépôt de destination. + cp "$PACKAGING_DIR/winget/$WINGET_VERSION_FILE.in" "$sandbox/winget/" + printf '%s' "$(cat "$PACKAGING_DIR/winget/$WINGET_VERSION_FILE.in")" > "$sandbox/winget/$WINGET_VERSION_FILE.in" + out=$("$sandbox/render.sh" --tag "$TAG" --checksums "$FIXTURE" --release-date "$RELEASE_DATE" --out "$(work out-nonl)" 2>&1) + check_rc 1 $? "un manifeste sans retour à la ligne final est refusé" + check_contains "$out" "retour à la ligne" "la raison est nommée" + + # Un gabarit amputé d'une cible : le compte d'empreintes le remarque, au lieu + # de pousser un instalteur qui ne dit plus rien de l'arm64. + cp "$PACKAGING_DIR/winget/$WINGET_VERSION_FILE.in" "$sandbox/winget/" + cp "$PACKAGING_DIR/winget/$WINGET_INSTALLER_FILE.in" "$sandbox/winget/" + sed -i '/^- Architecture: arm64/,/^ InstallerSha256: [0-9a-f]\{64\}$/d' \ + "$sandbox/winget/$WINGET_INSTALLER_FILE.in" + out=$("$sandbox/render.sh" --tag "$TAG" --checksums "$FIXTURE" --release-date "$RELEASE_DATE" --out "$(work out-one-arch)" 2>&1) + check_rc 1 $? "un gabarit amputé d'une cible est refusé" + check_contains "$out" "empreinte" "le compte d'empreintes est nommé" +} + +# --------------------------------------------------------------------------- +# Schémas officiels +# +# WinGet refuse en amont toute pull request dont un manifeste ne passe pas son +# JSON Schema. Le vérifier ici coûte deux secondes ; le faire dire par le dépôt +# coûte une release. +# --------------------------------------------------------------------------- +test_schemas() { + echo "Schémas officiels" + + local out dir + dir=$(work schemas) + out=$(render "$dir") + check_rc 0 $? "rendu de référence" + + out=$(python3 "$VALIDATE" "$dir" 2>&1) + local rc=$? + if [ "$rc" -eq 0 ]; then + ok "les manifestes rendus passent les schémas officiels" + elif [ "$rc" -eq 2 ]; then + printf ' – validation sautée : %s\n' "$(printf '%s' "$out" | tail -1)" + else + ko "les manifestes rendus passent les schémas officiels" "$out" + fi + + # Un manifeste cassé doit être rejeté, sinon ce test ne prouve rien. + local broken + broken=$(work schemas-broken) + cp -r "$dir/." "$broken/" + sed -i 's/^ InstallerSha256: .*/ InstallerSha256: pas-un-sha/' "$broken/winget/$WINGET_INSTALLER_FILE" + out=$(python3 "$VALIDATE" "$broken" 2>&1) + check_rc 1 $? "un manifeste invalide est rejeté" + check_contains "$out" "✗" "le manifeste fautif est nommé" + + # Hors ligne sans cache : ni échec ni faux positif — le contrôle est déclaré + # non fait, pas simulé. + out=$(TYPR_SCHEMA_CACHE="$(work no-schemas)" "$VALIDATE" --offline "$dir" 2>&1) + check_rc 2 $? "hors ligne sans cache, la validation est déclarée sautée" +} + +# --------------------------------------------------------------------------- +# Lecture par URL — le mode réel, celui des jobs `winget` et `scoop` +# +# Le serveur de fixtures est celui de install/tests : il sert une arborescence +# réelle, donc un vrai chemin `/releases/download//checksums.txt`. Réécrire +# un deuxième serveur pour le même besoin serait du poids inutile. +# --------------------------------------------------------------------------- +test_url_mode() { + echo "Lecture par URL" + if ! command -v python3 >/dev/null 2>&1; then + printf ' – python3 absent, mode URL non exercé\n' + return 0 + fi + + local server_root port out dir + server_root=$(work origin) + mkdir -p "$server_root/we-data-ch/typr/releases/download/$TAG" + cp "$FIXTURE" "$server_root/we-data-ch/typr/releases/download/$TAG/checksums.txt" + + python3 "$PACKAGING_DIR/../install/tests/fixture-server.py" "$server_root" \ + > "$server_root/.port" & + SERVER_PID=$! + + port="" + for _ in $(seq 1 50); do + port=$(grep -oE 'PORT [0-9]+' "$server_root/.port" 2>/dev/null | cut -d' ' -f2) + [ -n "$port" ] && break + sleep 0.1 + done + if [ -z "$port" ]; then + ko "le serveur de fixtures n'a pas démarré" + return 0 + fi + + # L'URL par défaut dérive du tag : c'est elle que les jobs de release + # utilisent, sans jamais l'écrire en dur. + dir=$(work url) + out=$(TYPR_ORIGIN="http://127.0.0.1:$port" "$RENDER" --tag "$TAG" --release-date "$RELEASE_DATE" --out "$dir" 2>&1) + check_rc 0 $? "l'URL de checksums.txt est déduite du tag" + check_contains "$(cat "$dir/winget/$WINGET_INSTALLER_FILE" 2>/dev/null)" "$(fixture_sha "$WIN_X86")" \ + "l'installateur WinGet rendu par URL a le bon sha256" + check_contains "$(cat "$dir/scoop/bucket/typr.json" 2>/dev/null)" "$(fixture_sha "$WIN_ARM")" \ + "le manifeste Scoop rendu par URL a le bon sha256" + + # 404 : le message doit nommer l'URL, pas laisser croire à un bug de rendu. + out=$(TYPR_ORIGIN="http://127.0.0.1:$port" "$RENDER" --tag v1.2.3 --release-date "$RELEASE_DATE" --out "$(work url-404)" 2>&1) + check_rc 1 $? "une release absente échoue" + check_contains "$out" "introuvable" "l'absence est nommée" +} + +# --------------------------------------------------------------------------- + +main() { + printf 'render.sh (WinGet + Scoop) — tests\n\n' + ROOT=$(mktemp -d) + + test_static_analysis + test_marker_drift + test_usage + test_render + test_platform_pitfalls + test_pin_and_prerelease + test_missing_line + test_template_drift + test_schemas + test_url_mode + + printf '\n%d réussis, %d échoués\n' "$PASSED" "$FAILED" + [ "$FAILED" -eq 0 ] +} + +main "$@" diff --git a/packaging/tests/validate-schemas.py b/packaging/tests/validate-schemas.py new file mode 100755 index 0000000..2a1e569 --- /dev/null +++ b/packaging/tests/validate-schemas.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""TypR — validation des manifestes WinGet et Scoop contre les schémas officiels. + + packaging/tests/validate-schemas.py + +WinGet refuse en amont toute pull request dont un manifeste ne passe pas son +JSON Schema, et le dépôt `scoop` attire des manifests invalides de toutes +sortes. Valider ici, avant la publication, coûte deux secondes ; se faire refuser +par un dépôt coûte une release. + +Les schémas ne sont pas écrits dans ce dépôt : ce sont ceux de winget-pkgs et de +Scoop, telechargés une fois et mis en cache. `winget-pkgs` n'accepte que les +schemas qu'il publie lui-meme — un schema recopie dans notre dépôt deriverait +dès la version suivante, en validant moins que ce qui sera exige. + +Sorties : + 0 tout est valide + 1 au moins un manifeste est invalide + 2 validation impossible (PyYAML absent, schema indisponible hors ligne) + +Options : + --schemas cache des schemas (defaut : $TYPR_SCHEMA_CACHE) + --offline n'echoue pas si un schema manque, il saute le controle +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +SCOOP_SCHEMA_URL = ( + "https://raw.githubusercontent.com/ScoopInstaller/Scoop/master/schema.json" +) +WINGET_SCHEMA_TMPL = "https://aka.ms/winget-manifest.{kind}.{version}.schema.json" + +# WinGet nomme ses trois manifestes `version`, `installer`, `defaultLocale`. +# `locale` est l'ancien nom, encore accepté par le dépôt : on le tolère aussi, +# pour qu'une version de Winget plus ancienne n'explique pas le refus autrement. +WINGET_KIND_BY_TYPE = { + "version": "version", + "installer": "installer", + "defaultLocale": "defaultLocale", + "locale": "defaultLocale", +} + + +def default_cache() -> Path: + env = os.environ.get("TYPR_SCHEMA_CACHE") + if env: + return Path(env) + base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache") + return Path(base) / "typr-packaging" / "schemas" + + +def fetch(url: str, dest: Path, offline: bool) -> Path | None: + """Ramène le schéma `url` dans `dest`, ou None si impossible et hors ligne.""" + if dest.is_file(): + return dest + if offline: + print(f" (hors ligne) schéma absent du cache : {url}") + return None + dest.parent.mkdir(parents=True, exist_ok=True) + try: + with tempfile.NamedTemporaryFile( + dir=dest.parent, delete=False, suffix=".part" + ) as tmp: + tmp_path = Path(tmp.name) + subprocess.run( + ["curl", "-fsSL", url, "-o", str(tmp_path)], + check=True, + capture_output=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + detail = getattr(exc, "stderr", b"") or b"" + print( + f" téléchargement impossible : {url}\n" + f" {detail.decode(errors='replace').strip() or exc}", + file=sys.stderr, + ) + tmp_path.unlink(missing_ok=True) + return None + tmp_path.replace(dest) + return dest + + +def load_schema(url: str, cache: Path, offline: bool) -> dict | None: + dest = cache / url.rsplit("/", 1)[-1] + path = fetch(url, dest, offline) + if path is None: + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f" schéma illisible : {path} ({exc})", file=sys.stderr) + return None + + +def check(instance, schema: dict, label: str) -> bool: + """Valide une instance ; renvoie True si elle est valide.""" + try: + import jsonschema + except ImportError: + print(" jsonschema absent — pip install jsonschema", file=sys.stderr) + raise + + validator_cls = jsonschema.validators.validator_for(schema) + validator_cls.check_schema(schema) + errors = sorted( + validator_cls(schema).iter_errors(instance), + key=lambda e: list(getattr(e, "absolute_path", [])), + ) + if not errors: + print(f" ✓ {label}") + return True + print(f" ✗ {label}") + for err in errors[:20]: + where = "/".join(str(p) for p in err.absolute_path) or "(racine)" + print(f" {where} : {err.message}") + if len(errors) > 20: + print(f" … et {len(errors) - 20} autres") + return False + + +def validate_winget(path: Path, cache: Path, offline: bool) -> bool | None: + import yaml + + try: + doc = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + print(f" ✗ {path.name} : YAML illisible — {exc}") + return False + if not isinstance(doc, dict): + print(f" ✗ {path.name} : la racine n'est pas un objet") + return False + + kind = WINGET_KIND_BY_TYPE.get(doc.get("ManifestType", "")) + if kind is None: + print( + f" ✗ {path.name} : ManifestType inconnu " + f"({doc.get('ManifestType')!r})" + ) + return False + + version = str(doc.get("ManifestVersion", "")) + url = WINGET_SCHEMA_TMPL.format(kind=kind, version=version) + schema = load_schema(url, cache, offline) + if schema is None: + return None + return check(doc, schema, f"{path.name} ({kind} {version or '?'})") + + +def validate_scoop(path: Path, cache: Path, offline: bool) -> bool | None: + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f" ✗ {path.name} : JSON illisible — {exc}") + return False + schema = load_schema(SCOOP_SCHEMA_URL, cache, offline) + if schema is None: + return None + return check(doc, schema, path.name) + + +def main() -> int: + parser = argparse.ArgumentParser(add_help=True) + parser.add_argument("directory", type=Path) + parser.add_argument("--schemas", type=Path, default=None) + parser.add_argument("--offline", action="store_true") + args = parser.parse_args() + + try: + import yaml # noqa: F401 + except ImportError: + print( + "PyYAML absent : la validation des manifestes WinGet est sautée " + "(pip install pyyaml).", + file=sys.stderr, + ) + return 2 + + root = args.directory + if not root.is_dir(): + print(f"dossier introuvable : {root}", file=sys.stderr) + return 2 + + cache = args.schemas or default_cache() + offline = args.offline or bool(os.environ.get("TYPR_SCHEMA_OFFLINE")) + + winget = sorted((root / "winget").glob("*.yaml")) + scoop = sorted((root / "scoop").rglob("*.json")) + if not winget and not scoop: + print(f"aucun manifeste à valider sous {root}", file=sys.stderr) + return 2 + + results: list[bool | None] = [] + if winget: + print(f"WinGet — {len(winget)} manifeste(s)") + for path in winget: + results.append(validate_winget(path, cache, offline)) + if scoop: + print(f"Scoop — {len(scoop)} manifeste(s)") + for path in scoop: + results.append(validate_scoop(path, cache, offline)) + + failures = [r for r in results if r is False] + skipped = [r for r in results if r is None] + + if failures: + print(f"\n{len(failures)} manifeste(s) invalide(s).") + return 1 + if skipped: + print( + f"\n{len(skipped)} manifeste(s) non contrôlé(s) : schéma indisponible." + ) + return 2 + print(f"\n{len(results)} manifeste(s) valide(s).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/winget/we-data-ch.TypR.installer.yaml.in b/packaging/winget/we-data-ch.TypR.installer.yaml.in new file mode 100644 index 0000000..8deba9d --- /dev/null +++ b/packaging/winget/we-data-ch.TypR.installer.yaml.in @@ -0,0 +1,53 @@ +# TypR — manifeste d'installation WinGet. +# +# FICHIER GÉNÉRÉ — ne pas l'éditer à la main. +# +# Gabarit : packaging/winget/we-data-ch.TypR.installer.yaml.in (dépôt we-data-ch/typr) +# Généré par: packaging/render.sh --tag vX.Y.Z +# +# `zip` + `NestedInstallerType: portable` : l'archive publiée contient +# `typr.exe` à sa racine (release.yml, `Compress-Archive -Path typr.exe`), et +# WinGet en fait un shim `typr` sur le PATH. C'est la forme employée par les +# applications portables distribuées en zip dans winget-pkgs. +# +# Deux points où le plan de distribution se trompaient, et où il est utile que la +# réponse reste lisible ici : +# +# * `InstallerSwitches.InstallLocation` est un *argument d'installeur*, pas un +# dossier de destination. Un zip n'a pas d'installeur : il n'y a donc aucun +# moyen de demander à WinGet d'écrire dans +# `%LOCALAPPDATA%\Programs\typr`, comme le fait install.ps1. WinGet extrait +# dans son propre dossier de paquet et y met `typr` sur le PATH — sans +# droits administrateur, ce qui est le point qui compte. +# * `InstallerSwitches.Silent` n'a pas lieu d'être ici non plus : il n'y a pas +# d'interface à piloter. +# +# Chaque entrée porte son URL et son empreinte côte à côte : c'est la seule +# façon que l'une ne puisse pas désigner l'archive de l'autre. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json + +PackageIdentifier: we-data-ch.TypR +PackageVersion: @@VERSION@@ +InstallerType: zip +NestedInstallerType: portable +# `ReleaseDate` est écrit entre guillemets à dessein : lu par un analyseur YAML +# 1.1 — PyYAML, et tout ce qui l'imite — `2026-01-02` est une date, pas une +# chaîne, et le schéma de winget-pkgs exige une chaîne. Guillemeté, le même +# fichier est un `string` partout, sans dépendre de la version de la norme que +# l'outil de validation applique. +ReleaseDate: "@@RELEASE_DATE@@" +Installers: +- Architecture: x64 + NestedInstallerFiles: + - RelativeFilePath: typr.exe + PortableCommandAlias: typr + InstallerUrl: @@URL_X86_64_WINDOWS@@ + InstallerSha256: @@SHA256_X86_64_WINDOWS@@ +- Architecture: arm64 + NestedInstallerFiles: + - RelativeFilePath: typr.exe + PortableCommandAlias: typr + InstallerUrl: @@URL_AARCH64_WINDOWS@@ + InstallerSha256: @@SHA256_AARCH64_WINDOWS@@ +ManifestType: installer +ManifestVersion: 1.12.0 diff --git a/packaging/winget/we-data-ch.TypR.locale.en-US.yaml.in b/packaging/winget/we-data-ch.TypR.locale.en-US.yaml.in new file mode 100644 index 0000000..c873aad --- /dev/null +++ b/packaging/winget/we-data-ch.TypR.locale.en-US.yaml.in @@ -0,0 +1,43 @@ +# TypR — locale WinGet (en-US, celle que le manifeste de version désigne). +# +# FICHIER GÉNÉRÉ — ne pas l'éditer à la main. +# +# Gabarit : packaging/winget/we-data-ch.TypR.locale.en-US.yaml.in (dépôt we-data-ch/typr) +# Généré par: packaging/render.sh --tag vX.Y.Z +# +# `ShortDescription` est la même phrase que la `desc` de la formule Homebrew et +# que la `description` du manifeste Scoop : c'est ce que l'utilisateur lit dans +# `winget search`, dans `brew info` et dans `scoop info`. Trois fichiers, une +# seule phrase — la suite de tests compare les trois rendus. +# +# Pas de `Tags` à rallonge : ils servent au classement dans le Store, et une +# liste qui ne dit rien de l'outil coûte plus qu'elle ne rapporte. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json + +PackageIdentifier: we-data-ch.TypR +PackageVersion: @@VERSION@@ +PackageLocale: en-US +Publisher: we-data-ch +PublisherUrl: https://github.com/we-data-ch +PublisherSupportUrl: https://github.com/we-data-ch/typr/issues +PackageName: TypR +PackageUrl: https://github.com/we-data-ch/typr +License: Apache-2.0 +LicenseUrl: https://github.com/we-data-ch/typr/blob/main/LICENSE +ShortDescription: A typed superset of R — transpiler and type checker +Description: |- + TypR is a typed superset of R. It compiles to readable, stock R, with static + type checking, modules, sum types and interfaces — so that a wrong argument + type, an undocumented return or a silent coercion is reported before the code + ever runs. + The Windows binaries ship with the C runtime linked statically, so they start + on a freshly installed Windows 10 or 11 with no Visual C++ Redistributable. +Moniker: typr +Tags: +- cli +- r +- static-analysis +- transpiler +- types +ManifestType: defaultLocale +ManifestVersion: 1.12.0 diff --git a/packaging/winget/we-data-ch.TypR.yaml.in b/packaging/winget/we-data-ch.TypR.yaml.in new file mode 100644 index 0000000..50b0bad --- /dev/null +++ b/packaging/winget/we-data-ch.TypR.yaml.in @@ -0,0 +1,18 @@ +# TypR — manifeste de version WinGet. +# +# FICHIER GÉNÉRÉ — ne pas l'éditer à la main. +# +# Gabarit : packaging/winget/we-data-ch.TypR.yaml.in (dépôt we-data-ch/typr) +# Généré par: packaging/render.sh --tag vX.Y.Z +# +# Il ne contient que ce que WinGet sait de la version : ni URL, ni empreinte. +# La version est sans le « v » initial, que WinGet refuse, et vient du tag — le +# même que lit le job `verify` de release.yml, donc le même que tous les autres +# canaux. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json + +PackageIdentifier: we-data-ch.TypR +PackageVersion: @@VERSION@@ +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.12.0 diff --git a/std.ty b/std.ty new file mode 100644 index 0000000..02e434c --- /dev/null +++ b/std.ty @@ -0,0 +1,204 @@ +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer sum +#! example: 1L + 2L # -> 3L +@`+`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric sum +#! example: 1.5 + 2.5 # -> 4 +@`+`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer difference +#! example: 5L - 3L # -> 2L +@`-`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric difference +#! example: 5.0 - 3.0 # -> 2 +@`-`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: integer quotient +#! example: 10L / 3L # -> 3L +@`/`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: numeric quotient +#! example: 10.0 / 3.0 # -> 3.333... +@`/`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer product +#! example: 3L * 4L # -> 12L +@`*`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric product +#! example: 3.0 * 4.0 # -> 12 +@`*`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: integer modulo +#! note: %% is the modulo operator (not remainder) +#! example: 7L %% 3L # -> 1L +@`%%`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: numeric modulo +#! example: 7.0 %% 3.0 # -> 1 +@`%%`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: logical AND +#! example: TRUE && FALSE # -> FALSE +@`&&`: (bool, bool) -> bool; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: logical OR +#! example: TRUE || FALSE # -> TRUE +@`||`: (bool, bool) -> bool; +#! pkg: base +#! tier: T1 +#! param a: left vector +#! param b: right vector +#! ret: element-wise sum of two vectors +#! coercion: vectors must be same length (recycling applies in R) +#! example: c(1,2) + c(3,4) # -> c(4,6) +@`+`: (Vec[#M, T], Vec[#M, T]) -> Vec[#M, T]; +#! pkg: base +#! tier: T1 +#! param a: vector to reduce +#! param f: binary combining function +#! ret: single value after left-fold +#! note: equivalent to base R Reduce() +#! example: reduce(c(1,2,3,4), add) # -> 10 +@reduce: ([#N, T], (T, T) -> T) -> T; +#! pkg: base +#! tier: T1 +#! param a: vector to fold +#! param init: initial accumulator value +#! param f: combining function (accumulator, element) -> accumulator +#! ret: final accumulator +#! note: equivalent to base R Reduce() with init +#! example: fold(c(1,2,3), 0, add) # -> 6 +@fold: ([#N, T], U, (U, T) -> U) -> U; +#! pkg: base +#! tier: T1 +#! param a: vector to extend +#! param b: element to append +#! ret: new vector with b appended +#! example: extend(c(1,2), 3) # -> c(1,2,3) +@extend: ([#N, T], T) -> [#N, T]; +#! pkg: base +#! tier: T3 +#! param desc: test description +#! param expr: test expression +#! ret: invisibly NULL +#! note: testing utility — not for production code +@test_that: (char, Any) -> Empty; +#! pkg: base +#! tier: T3 +#! param condition: expected condition +#! ret: invisibly NULL +#! note: testing utility +@expect_true: (bool) -> Empty; +#! pkg: base +#! tier: T3 +#! param actual: actual value +#! param expected: expected value +#! ret: invisibly NULL +#! note: testing utility +@expect_false: (T, T) -> Empty; +#! pkg: base +#! tier: T3 +#! param actual: actual value +#! param expected: expected value +#! ret: invisibly NULL +#! note: testing utility +@expect_equal: (T, T) -> Empty; +#! pkg: base +#! tier: T2 +#! param values: values to concatenate and print +#! ret: invisibly NULL +#! note: variadic, side-effectful; R's paste(..., sep="") + cat() +#! example: cat("hello", "world") +@cat: (...values: Any) -> Empty; +#! pkg: base +#! tier: T1 +#! param a: TypR value +#! ret: same value as native R +#! note: pass-through for interop boundaries +@to_native: (T) -> T; +#! pkg: base +#! tier: T2 +#! param a: native R value +#! param type: target TypR type name +#! ret: coerced value +#! note: runtime cast — type must match or error +@from_native: (Any, char) -> Any; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: integer representation +#! coercion: numeric/logical -> integer +#! example: from_int(3.7) # -> 3L +@from_int: (Any) -> int; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: numeric representation +#! coercion: integer/logical -> numeric +#! example: # noplayground: from_num(3L) # -> 3.0 +@from_num: (Any) -> num; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: character representation +#! coercion: any atomic -> character +#! example: from_char(42) # -> "42" +@from_char: (Any) -> char; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: logical representation +#! coercion: numeric -> logical (0 = FALSE, non-zero = TRUE) +#! example: from_bool(1) # -> TRUE +@from_bool: (Any) -> bool; +#! pkg: base +#! tier: T1 +#! param x: numeric vector +#! ret: sum over all elements +#! example: sum(c(1, 2, 3)) # -> 6 +@sum: ([#N, T]) -> T; +#! pkg: base +#! tier: T1 +#! param x: value to display +#! ret: prints to the console, returns nothing +#! example: print(sum(c(1, 2, 3))) +@print: (Any) -> Empty; From fe3957fe80ee47919bd7a33759e01ec4cbde4cd5 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 16:40:47 +0200 Subject: [PATCH 45/52] =?UTF-8?q?ci=20:=20cargo=20fmt,=20dry-run=20Windows?= =?UTF-8?q?=20hors=20r=C3=A9seau,=20continuation=20docker,=20SC2015?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 7 +- crates/typr-cli/src/cli.rs | 7 +- .../src/components/context/fingerprint.rs | 4 +- .../typr-core/src/components/context/mod.rs | 6 +- .../components/error_message/type_error.rs | 14 +- .../src/components/type/argument_type.rs | 7 +- crates/typr-core/src/components/type/mod.rs | 29 +- .../src/components/type/refinement.rs | 46 +- .../src/components/type/type_printer.rs | 10 +- .../typr-core/src/processes/parsing/types.rs | 62 +- .../transpiling/refinement_checks.rs | 16 +- .../processes/type_checking/dollar_access.rs | 21 +- .../src/processes/type_checking/function.rs | 16 +- .../type_checking/function_application.rs | 18 +- .../processes/type_checking/let_expression.rs | 4 +- .../src/processes/type_checking/mod.rs | 48 +- .../src/processes/type_checking/narrowing.rs | 38 +- .../type_checking/refinement_check.rs | 37 +- .../type_checking/signature_normalization.rs | 21 +- .../type_checking/type_arithmetic.rs | 104 ++- .../processes/type_checking/unification.rs | 6 +- crates/typr-graph/src/build/blocks.rs | 615 +++++++++++++++--- crates/typr-graph/src/build/mod.rs | 93 ++- crates/typr-graph/src/build/refs.rs | 7 +- crates/typr-graph/src/build/type_relations.rs | 40 +- crates/typr-graph/src/diff.rs | 29 +- crates/typr-graph/tests/totality.rs | 9 +- crates/typr-graph/tests/walkthrough.rs | 14 +- packaging/tests/run-tests.sh | 2 +- 29 files changed, 1036 insertions(+), 294 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 724ae85..c264e70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -373,8 +373,9 @@ jobs: run: docker build -t typr-install-tests -f install/tests/Dockerfile . - name: install.ps1 — suite de tests - run: docker run --rm -v "$PWD:/w" -w /w typr-install-tests \ - pwsh -NoProfile -File install/tests/run-tests.ps1 + run: >- + docker run --rm -v "$PWD:/w" -w /w typr-install-tests + pwsh -NoProfile -File install/tests/run-tests.ps1 # Les deux suites précédentes tournent sous Linux : ni le registre Windows, # ni %LOCALAPPDATA%, ni Expand-Archive ne sont réellement exercés. Ce job @@ -400,7 +401,7 @@ jobs: # sous PowerShell 7. $env:TYPR_INSTALL_ORIGIN = 'https://we-data-ch.github.io/typr.github.io' - & ./install/install.ps1 -DryRun + & ./install/install.ps1 -DryRun -Version 'v0.0.0' if ($LASTEXITCODE -ne 0) { throw "-DryRun a échoué (code $LASTEXITCODE)" } & ./install/install.ps1 -DryRun -Version 'pas-un-tag' diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index bbcb7d7..ac1cd83 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -473,7 +473,12 @@ pub fn start() { _ => check_project(), }, Some(Commands::Graph { - graph_command: Some(GraphCommands::Diff { old_file, new_file, format }), + graph_command: + Some(GraphCommands::Diff { + old_file, + new_file, + format, + }), .. }) => crate::graph::graph_diff(&old_file, &new_file, &format), Some(Commands::Graph { diff --git a/crates/typr-core/src/components/context/fingerprint.rs b/crates/typr-core/src/components/context/fingerprint.rs index cd5dce4..d733e8e 100644 --- a/crates/typr-core/src/components/context/fingerprint.rs +++ b/crates/typr-core/src/components/context/fingerprint.rs @@ -67,7 +67,9 @@ pub fn canonical_type_debug(t: &Type) -> String { Type::Vec(vt, _, inner, h) => format!( "Vec({:?}, {:?}, {:?}, {:?})", vt, - t.vec_length().map(|size| canonical_type_debug(&size)).unwrap_or_default(), + t.vec_length() + .map(|size| canonical_type_debug(&size)) + .unwrap_or_default(), canonical_type_debug(inner), h ), diff --git a/crates/typr-core/src/components/context/mod.rs b/crates/typr-core/src/components/context/mod.rs index 3757071..85e68cd 100644 --- a/crates/typr-core/src/components/context/mod.rs +++ b/crates/typr-core/src/components/context/mod.rs @@ -14,8 +14,8 @@ use crate::components::language::var::Var; use crate::components::language::var_function::VarFunction; use crate::components::language::Lang; use crate::components::r#type::argument_type::ArgumentType; -use crate::components::r#type::refinement::{RefinementObligation, RefinementSet}; use crate::components::r#type::kind::Kind; +use crate::components::r#type::refinement::{RefinementObligation, RefinementSet}; use crate::components::r#type::type_system::TypeSystem; use crate::components::r#type::vector_type::ConstructorCategory; use crate::components::r#type::Type; @@ -301,7 +301,9 @@ impl Context { .find(|o| o.file == file && o.start == start && o.end == end) { Some(o) => o.set = set, - None => self.refinement_obligations.push(RefinementObligation { file, start, end, set }), + None => self + .refinement_obligations + .push(RefinementObligation { file, start, end, set }), } self } diff --git a/crates/typr-core/src/components/error_message/type_error.rs b/crates/typr-core/src/components/error_message/type_error.rs index 5599ac0..ec7ef66 100644 --- a/crates/typr-core/src/components/error_message/type_error.rs +++ b/crates/typr-core/src/components/error_message/type_error.rs @@ -514,10 +514,16 @@ impl TypeError { } TypeError::InvalidRefinement(msg, _) | TypeError::UnsatisfiableRefinement(msg, _) => msg.clone(), TypeError::ConflictingBound(id, b1, b2, _) => { - format!("'{}' is bounded by both '{}' and '{}' in the same signature.", id, b1, b2) + format!( + "'{}' is bounded by both '{}' and '{}' in the same signature.", + id, b1, b2 + ) } TypeError::BoundCollidesWithGeneric(id, _) => { - format!("'{}' is used both as a free generic and as a bound id (`I@{}`).", id, id) + format!( + "'{}' is used both as a free generic and as a bound id (`I@{}`).", + id, id + ) } TypeError::IdBoundToTwoTypes(id, t1, _, t2, _) => { format!("'{}' is bound to '{}' and then to '{}' in the same call.", id, t1, t2) @@ -1130,7 +1136,9 @@ impl ErrorMsg for TypeError { .pos(pos) .text(msg) .pos_text("This refinement does not apply to that type") - .help("`length(n)` refines vectors (`[T] & length(n)`); `(> c)` and `(< c)` refine `int` and `num`.") + .help( + "`length(n)` refines vectors (`[T] & length(n)`); `(> c)` and `(< c)` refine `int` and `num`.", + ) .build() } TypeError::UnsatisfiableRefinement(msg, help_data) => { diff --git a/crates/typr-core/src/components/type/argument_type.rs b/crates/typr-core/src/components/type/argument_type.rs index b272fdb..6de9b44 100644 --- a/crates/typr-core/src/components/type/argument_type.rs +++ b/crates/typr-core/src/components/type/argument_type.rs @@ -97,7 +97,12 @@ impl ArgumentType { pub fn body_type(&self) -> Type { if self.3 { let h = self.1.get_help_data(); - Type::vec(VecType::S3, Type::IndexGen("N".to_string(), h.clone()), self.1.clone(), h) + Type::vec( + VecType::S3, + Type::IndexGen("N".to_string(), h.clone()), + self.1.clone(), + h, + ) } else { self.1.clone() } diff --git a/crates/typr-core/src/components/type/mod.rs b/crates/typr-core/src/components/type/mod.rs index 2abd0c5..8f8d670 100644 --- a/crates/typr-core/src/components/type/mod.rs +++ b/crates/typr-core/src/components/type/mod.rs @@ -241,9 +241,7 @@ impl TypeSystem for Type { a.is_subtype_raw(b, context) && a.refinements_of().meet(ps).implies(qs) } (Type::Refined(a, _, _), typ) => a.is_subtype_raw(typ, context), - (typ, Type::Refined(b, qs, _)) => { - typ.is_subtype_raw(b, context) && typ.refinements_of().implies(qs) - } + (typ, Type::Refined(b, qs, _)) => typ.is_subtype_raw(b, context) && typ.refinements_of().implies(qs), (Type::Operator(TypeOperator::Intersection, t1, t2, _), typ) => { t1.is_subtype_raw(typ, context) || t2.is_subtype_raw(typ, context) } @@ -1193,7 +1191,11 @@ impl Type { pub fn linearize(self) -> Vec { match self { - Type::Vec(_, t1, t2, h) => [t1.to_type(h)].iter().chain((*t2).linearize().iter()).cloned().collect(), + Type::Vec(_, t1, t2, h) => [t1.to_type(h)] + .iter() + .chain((*t2).linearize().iter()) + .cloned() + .collect(), other => vec![other], } } @@ -1206,9 +1208,10 @@ impl Type { pub fn from_linear(mut dims_and_base: Vec) -> Type { match dims_and_base.pop() { None => builder::any_type(), - Some(base) => dims_and_base.into_iter().rev().fold(base, |acc, dim| { - Type::vec(VecType::S3, dim, acc, HelpData::default()) - }), + Some(base) => dims_and_base + .into_iter() + .rev() + .fold(base, |acc, dim| Type::vec(VecType::S3, dim, acc, HelpData::default())), } } @@ -1236,7 +1239,11 @@ impl Type { /// [3, T] -> rank: 3, vector type: Array, Type T pub fn get_size_type(&self) -> (i32, VecType, Type) { match self { - Type::Vec(v, i, t, h) => (i.to_type(h.clone()).get_index().unwrap_or(0) as i32, v.clone(), (**t).clone()), + Type::Vec(v, i, t, h) => ( + i.to_type(h.clone()).get_index().unwrap_or(0) as i32, + v.clone(), + (**t).clone(), + ), typ => (1, VecType::Empty, typ.clone()), } } @@ -1868,7 +1875,11 @@ mod tests { } fn refined_int(set: RefinementSet) -> Type { - Type::Refined(Box::new(Type::Integer(Tint::Unknown, HelpData::default())), set, HelpData::default()) + Type::Refined( + Box::new(Type::Integer(Tint::Unknown, HelpData::default())), + set, + HelpData::default(), + ) } fn gt(c: f64) -> RefinementSet { RefinementSet::single(&Refinement::Gt(refinement::Num::new(c))) diff --git a/crates/typr-core/src/components/type/refinement.rs b/crates/typr-core/src/components/type/refinement.rs index 3bfa307..f87dd01 100644 --- a/crates/typr-core/src/components/type/refinement.rs +++ b/crates/typr-core/src/components/type/refinement.rs @@ -198,9 +198,7 @@ impl Interval { match (self.lo.value(), self.hi.value()) { (Some(l), Some(h)) => match l.cmp(&h) { Ordering::Greater => true, - Ordering::Equal => { - !(matches!(self.lo, Bound::Closed(_)) && matches!(self.hi, Bound::Closed(_))) - } + Ordering::Equal => !(matches!(self.lo, Bound::Closed(_)) && matches!(self.hi, Bound::Closed(_))), Ordering::Less => false, }, _ => false, @@ -428,7 +426,11 @@ impl Length { // runtime check and the printer show. Emptiness is judged on // integers, lengths being whole numbers. let m = cur.meet(iv); - Some(if m.to_integral().is_empty() { Err(()) } else { Ok(Length::Range(m)) }) + Some(if m.to_integral().is_empty() { + Err(()) + } else { + Ok(Length::Range(m)) + }) } Length::Sym(_) if self.bounds().implies(iv) => Some(Ok(self.clone())), Length::Sym(_) if self.bounds().contradicts(iv) => Some(Err(())), @@ -444,7 +446,9 @@ fn sym_lower_bound(t: &Type) -> f64 { match t { Type::Integer(Tint::Val(n), _) => *n as f64, Type::Operator(TypeOperator::Addition, a, b, _) => sym_lower_bound(a).max(0.0) + sym_lower_bound(b).max(0.0), - Type::Operator(TypeOperator::Multiplication, a, b, _) => sym_lower_bound(a).max(0.0) * sym_lower_bound(b).max(0.0), + Type::Operator(TypeOperator::Multiplication, a, b, _) => { + sym_lower_bound(a).max(0.0) * sym_lower_bound(b).max(0.0) + } _ => 0.0, } } @@ -492,10 +496,7 @@ impl RefinementSet { /// Intersection (`&`) of two sets. pub fn meet(&self, other: &RefinementSet) -> RefinementSet { - other - .0 - .iter() - .fold(self.clone(), |acc, (m, iv)| acc.with(*m, *iv)) + other.0.iter().fold(self.clone(), |acc, (m, iv)| acc.with(*m, *iv)) } /// Some measure has an empty interval: no value can satisfy the set. @@ -503,7 +504,11 @@ impl RefinementSet { /// always for `Length`). pub fn is_empty(&self, integral: bool) -> bool { self.0.iter().any(|(m, iv)| { - let iv = if integral || *m == Measure::Length { iv.to_integral() } else { *iv }; + let iv = if integral || *m == Measure::Length { + iv.to_integral() + } else { + *iv + }; iv.is_empty() }) } @@ -615,7 +620,10 @@ mod tests { assert!(len(0).contradicts(&len_pos, true)); assert!(!len_pos.implies(&len(5))); // `length(> 0)` and `length(< 1)` leave no integer length - let none = len_pos.meet(&RefinementSet::single(&Refinement::Range(Measure::Length, Interval::less_than(1.0)))); + let none = len_pos.meet(&RefinementSet::single(&Refinement::Range( + Measure::Length, + Interval::less_than(1.0), + ))); assert!(none.is_empty(true)); assert_eq!(len_pos.to_string(), "length(> 0)"); let ge = RefinementSet::single(&Refinement::Range(Measure::Value, Interval::at_least(0.0))); @@ -666,11 +674,13 @@ mod type_tests { fn refined_equality_and_hash_ignore_construction_order() { let a = refined( builder::integer_type_default(), - RefinementSet::single(&Refinement::Gt(Num::new(0.0))).meet(&RefinementSet::single(&Refinement::Lt(Num::new(9.0)))), + RefinementSet::single(&Refinement::Gt(Num::new(0.0))) + .meet(&RefinementSet::single(&Refinement::Lt(Num::new(9.0)))), ); let b = refined( builder::integer_type_default(), - RefinementSet::single(&Refinement::Lt(Num::new(9.0))).meet(&RefinementSet::single(&Refinement::Gt(Num::new(0.0)))), + RefinementSet::single(&Refinement::Lt(Num::new(9.0))) + .meet(&RefinementSet::single(&Refinement::Gt(Num::new(0.0)))), ); assert_eq!(a, b); let set: HashSet = [a, b].into_iter().collect(); @@ -679,13 +689,19 @@ mod type_tests { #[test] fn refinements_are_read_back_from_structure() { - assert_eq!(vec_of_len(Some(5)).refinements_of(), RefinementSet::single(&Refinement::Length(5))); + assert_eq!( + vec_of_len(Some(5)).refinements_of(), + RefinementSet::single(&Refinement::Length(5)) + ); assert!(vec_of_len(None).refinements_of().is_trivial()); assert_eq!( int_lit(3).refinements_of().get(Measure::Value), Some(&Interval::point(3.0)) ); - let r = refined(vec_of_len(Some(2)), RefinementSet::single(&Refinement::Gt(Num::new(0.0)))); + let r = refined( + vec_of_len(Some(2)), + RefinementSet::single(&Refinement::Gt(Num::new(0.0))), + ); let got = r.refinements_of(); assert_eq!(got.get(Measure::Length), Some(&Interval::point(2.0))); assert_eq!(got.get(Measure::Value), Some(&Interval::greater_than(0.0))); diff --git a/crates/typr-core/src/components/type/type_printer.rs b/crates/typr-core/src/components/type/type_printer.rs index 12cb019..c916955 100644 --- a/crates/typr-core/src/components/type/type_printer.rs +++ b/crates/typr-core/src/components/type/type_printer.rs @@ -190,12 +190,10 @@ pub fn verbose(t: &Type) -> String { Type::IndexGen(idgen, _) => format!("#{}", idgen), Type::KindedGen(k, name, _) => format!("{}{}", k, name), Type::Bounded(id, bound, _) => format!("{}@{}", format(bound), id), - Type::Vec(vtype, length, t, h) => { - match length.range_property() { - Some(prop) => format!("{}[{}] & {}", vtype, t.pretty2(), prop), - None => format!("{}[{}, {}]", vtype, length.to_type(h.clone()).pretty(), t.pretty2()), - } - } + Type::Vec(vtype, length, t, h) => match length.range_property() { + Some(prop) => format!("{}[{}] & {}", vtype, t.pretty2(), prop), + None => format!("{}[{}, {}]", vtype, length.to_type(h.clone()).pretty(), t.pretty2()), + }, val if val.to_category() == TypeCategory::Template => val.pretty(), val => val.pretty(), //val => panic!("{:?} doesn't have a second format", val) } diff --git a/crates/typr-core/src/processes/parsing/types.rs b/crates/typr-core/src/processes/parsing/types.rs index 67ee32f..94cd7d3 100644 --- a/crates/typr-core/src/processes/parsing/types.rs +++ b/crates/typr-core/src/processes/parsing/types.rs @@ -226,9 +226,7 @@ fn named_array_type_full(s: Span) -> IResult { .parse(s); match res { - Ok((s, (start, num, _, typ, _))) => { - Ok((s, Type::vec(VecType::Array, num, typ, start.into()))) - } + Ok((s, (start, num, _, typ, _))) => Ok((s, Type::vec(VecType::Array, num, typ, start.into()))), Err(r) => Err(r), } } @@ -265,10 +263,7 @@ fn vector_type_full(s: Span) -> IResult { .parse(s); match res { - Ok((s, (start, num, _, typ, _))) => Ok(( - s, - Type::vec(VecType::Vector, num, typ, start.into()), - )), + Ok((s, (start, num, _, typ, _))) => Ok((s, Type::vec(VecType::Vector, num, typ, start.into()))), Err(r) => Err(r), } } @@ -605,9 +600,12 @@ fn bound_suffix(s: Span) -> IResult { // One capital letter, not followed by more identifier characters: // `@Self` or `@Abc` must fail rather than lose `elf` / `bc`. map( - terminated(one_of("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), not(one_of( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_", - ))), + terminated( + one_of("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), + not(one_of( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_", + )), + ), |c| c.to_string(), ), )), @@ -619,7 +617,8 @@ pub fn type_alias(s: Span) -> IResult { let res = (pascal_case_no_space, opt(type_params), opt(bound_suffix), multispace0).parse(s); match res { Ok((rest, ((name, h), params, None, ws))) - if (!ws.fragment().is_empty() && bound_suffix(rest.clone()).is_ok()) || rest.fragment().starts_with('@') => + if (!ws.fragment().is_empty() && bound_suffix(rest.clone()).is_ok()) + || rest.fragment().starts_with('@') => { // `Lovable @A` or `Lovable@Self`: a malformed suffix. Left alone, the priority // resolver would silently drop the stray `@...` type. @@ -1049,7 +1048,10 @@ fn index_chain(s: Span) -> IResult { match res { Ok((rest, v)) => match compute_operators(&mut v.clone()) { Some(t) => Ok((rest, t)), - None => Err(nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Verify))), + None => Err(nom::Err::Error(nom::error::Error::new( + s, + nom::error::ErrorKind::Verify, + ))), }, Err(r) => Err(r), } @@ -1233,7 +1235,7 @@ pub fn single_type(s: Span) -> IResult { unknown_function, bracket_tuple_record, // Tuple[...] / Record[...] — before type_alias composite_vec_type, - list_types, // tuple{}/record{}/list{} brace forms + list_types, // tuple{}/record{}/list{} brace forms refinement_property, // length(n), (> c), (< c) parenthese_value, tag_type, @@ -1297,14 +1299,19 @@ mod tests { let _ = crate::processes::parsing::take_parse_errors(); let _ = ltype(src.into()); let errors = crate::processes::parsing::take_parse_errors(); - assert!(errors.iter().any(|e| matches!(e, SyntaxError::DetachedBoundSuffix(_))), "{src}"); + assert!( + errors.iter().any(|e| matches!(e, SyntaxError::DetachedBoundSuffix(_))), + "{src}" + ); } } #[test] fn test_bounded_suffix_nested() { let t = ltype("[3, Lovable@A]".into()).unwrap().1; - assert!(matches!(&t, Type::Vec(_, _, elem, _) if matches!(elem.as_ref(), Type::Bounded(id, _, _) if id == "A"))); + assert!( + matches!(&t, Type::Vec(_, _, elem, _) if matches!(elem.as_ref(), Type::Bounded(id, _, _) if id == "A")) + ); let t = ltype("tuple{Lovable@B, Lovable@A}".into()).unwrap().1; assert!(matches!(&t, Type::Tuple(v, _) if v.len() == 2 && v.iter().all(|e| matches!(e, Type::Bounded(..))))); let t = ltype("(Lovable@A) -> Lovable@A".into()).unwrap().1; @@ -1321,7 +1328,10 @@ mod tests { #[test] fn test_bare_alias_and_sigil_unchanged() { assert!(matches!(ltype("Lovable".into()).unwrap().1, Type::Alias(..))); - assert!(matches!(ltype("@A".into()).unwrap().1, Type::KindedGen(Kind::Interface, ..))); + assert!(matches!( + ltype("@A".into()).unwrap().1, + Type::KindedGen(Kind::Interface, ..) + )); } #[test] @@ -1339,10 +1349,22 @@ mod tests { assert!(matches!(ok("length( 5 )"), Type::Property(Refinement::Length(5), _))); assert!(matches!(ok("(> 0)"), Type::Property(Refinement::Gt(_), _))); assert!(matches!(ok("(< -2.5)"), Type::Property(Refinement::Lt(_), _))); - assert!(matches!(ok("(>= 0)"), Type::Property(Refinement::Range(Measure::Value, _), _))); - assert!(matches!(ok("(<= 9.5)"), Type::Property(Refinement::Range(Measure::Value, _), _))); - assert!(matches!(ok("length(> 0)"), Type::Property(Refinement::Range(Measure::Length, _), _))); - assert!(matches!(ok("length(<= 10)"), Type::Property(Refinement::Range(Measure::Length, _), _))); + assert!(matches!( + ok("(>= 0)"), + Type::Property(Refinement::Range(Measure::Value, _), _) + )); + assert!(matches!( + ok("(<= 9.5)"), + Type::Property(Refinement::Range(Measure::Value, _), _) + )); + assert!(matches!( + ok("length(> 0)"), + Type::Property(Refinement::Range(Measure::Length, _), _) + )); + assert!(matches!( + ok("length(<= 10)"), + Type::Property(Refinement::Range(Measure::Length, _), _) + )); } #[test] diff --git a/crates/typr-core/src/processes/transpiling/refinement_checks.rs b/crates/typr-core/src/processes/transpiling/refinement_checks.rs index d82887a..a71939a 100644 --- a/crates/typr-core/src/processes/transpiling/refinement_checks.rs +++ b/crates/typr-core/src/processes/transpiling/refinement_checks.rs @@ -28,7 +28,8 @@ pub fn wrap_obligation(context: &Context, r_expr: String, expr_h: &HelpData) -> } pub fn wrap_set(r_expr: String, set: &RefinementSet, loc: &str) -> String { - set.iter().fold(r_expr, |acc, (measure, iv)| wrap_measure(acc, *measure, iv, loc)) + set.iter() + .fold(r_expr, |acc, (measure, iv)| wrap_measure(acc, *measure, iv, loc)) } fn wrap_measure(x: String, measure: Measure, iv: &Interval, loc: &str) -> String { @@ -54,7 +55,13 @@ fn bound_args(iv: &Interval) -> String { Bound::Open(c) => (r_number(c.get()), true), Bound::Closed(c) => (r_number(c.get()), false), }; - format!("{}, {}, {}, {}", lo, lo_open.to_string().to_uppercase(), hi, hi_open.to_string().to_uppercase()) + format!( + "{}, {}, {}, {}", + lo, + lo_open.to_string().to_uppercase(), + hi, + hi_open.to_string().to_uppercase() + ) } fn r_number(v: f64) -> String { @@ -88,7 +95,10 @@ mod tests { #[test] fn length_and_value_forms() { let set = RefinementSet::single(&Refinement::Length(3)); - assert_eq!(wrap_set("x".into(), &set, "m.ty:1"), "typr_refine_length(x, 3L, \"m.ty:1\")"); + assert_eq!( + wrap_set("x".into(), &set, "m.ty:1"), + "typr_refine_length(x, 3L, \"m.ty:1\")" + ); let set = RefinementSet::single(&Refinement::Gt(crate::components::r#type::refinement::Num::new(0.0))); assert_eq!( wrap_set("x".into(), &set, "m.ty:1"), diff --git a/crates/typr-core/src/processes/type_checking/dollar_access.rs b/crates/typr-core/src/processes/type_checking/dollar_access.rs index f67d04f..a2b8a41 100644 --- a/crates/typr-core/src/processes/type_checking/dollar_access.rs +++ b/crates/typr-core/src/processes/type_checking/dollar_access.rs @@ -184,7 +184,12 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & other => other, }; TypeContext::new( - Type::vec(VecType::Vector, reduced1.vec_length().unwrap_or_else(builder::any_type), inner_type, h.clone()), + Type::vec( + VecType::Vector, + reduced1.vec_length().unwrap_or_else(builder::any_type), + inner_type, + h.clone(), + ), expr.clone(), context.clone(), ) @@ -215,7 +220,12 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & other => other, }; TypeContext::new( - Type::vec(VecType::Vector, reduced1.vec_length().unwrap_or_else(builder::any_type), inner_type, h.clone()), + Type::vec( + VecType::Vector, + reduced1.vec_length().unwrap_or_else(builder::any_type), + inner_type, + h.clone(), + ), expr.clone(), context.clone(), ) @@ -303,7 +313,12 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & ); errors.extend(tc.errors); TypeContext::new( - Type::vec(vtype, reduced1.vec_length().unwrap_or_else(builder::any_type), tc.value, h.clone()), + Type::vec( + vtype, + reduced1.vec_length().unwrap_or_else(builder::any_type), + tc.value, + h.clone(), + ), tc.lang, context.clone(), ) diff --git a/crates/typr-core/src/processes/type_checking/function.rs b/crates/typr-core/src/processes/type_checking/function.rs index ec22d4e..7dab090 100644 --- a/crates/typr-core/src/processes/type_checking/function.rs +++ b/crates/typr-core/src/processes/type_checking/function.rs @@ -275,11 +275,7 @@ fn replace_bounded_with_rigids( /// Rewrites the rigids of `typ` back to what the user wrote (`Lovable@B`, or /// plain `Lovable` for a bare interface) so an error never shows `__RIGID_1`. -fn name_rigids( - typ: &Type, - rigid_of_id: &std::collections::HashMap, - params: &[ArgumentType], -) -> Type { +fn name_rigids(typ: &Type, rigid_of_id: &std::collections::HashMap, params: &[ArgumentType]) -> Type { let mut bounds: Vec<(String, Type)> = Vec::new(); for p in params { signature_normalization::visit(&p.get_type(), &mut |t| { @@ -430,7 +426,8 @@ pub fn function( } else if !arg_typ.is_variadic() && signature_normalization::has_bounded(&normalized_param.get_type()) { // `[#N, Lovable@A]`, `tuple{Lovable@A, T}`: the bound sits below the // top, so the parameter keeps its shape with a rigid at each `@Id`. - let nested = replace_bounded_with_rigids(&mut sub_context, &mut rigid_of_id, &normalized_param.get_type(), h); + let nested = + replace_bounded_with_rigids(&mut sub_context, &mut rigid_of_id, &normalized_param.get_type(), h); let var = arg_typ.clone().set_type(nested.clone()).to_var(&sub_context); sub_context = sub_context.clone().push_var_type(var, nested, &sub_context); } else { @@ -1256,7 +1253,8 @@ mod tests { let same <- fn(a: Cat, b: Cat): bool {{ true }};\n\ let f <- {src};" ); - let tc = TypeChecker::new(Context::default()).typing_no_panic(&crate::processes::parsing::parse_from_string(&program, "t.ty")); + let tc = TypeChecker::new(Context::default()) + .typing_no_panic(&crate::processes::parsing::parse_from_string(&program, "t.ty")); !tc.has_errors() } @@ -1274,7 +1272,9 @@ mod tests { #[test] fn test_bound_methods_are_callable_on_distinct_ids() { - assert!(body_is_ok("fn(a: Lovable@A, b: Lovable@B): bool { a.love() == b.love() }")); + assert!(body_is_ok( + "fn(a: Lovable@A, b: Lovable@B): bool { a.love() == b.love() }" + )); } #[test] diff --git a/crates/typr-core/src/processes/type_checking/function_application.rs b/crates/typr-core/src/processes/type_checking/function_application.rs index c72b463..58a5010 100644 --- a/crates/typr-core/src/processes/type_checking/function_application.rs +++ b/crates/typr-core/src/processes/type_checking/function_application.rs @@ -1,17 +1,19 @@ #![allow(dead_code, unused_variables, unused_imports, unreachable_code, unused_assignments)] -use crate::components::r#type::refinement::RefinementSet; -use crate::processes::type_checking::type_arithmetic::declared_refinements; -use crate::processes::type_checking::refinement_check::{coerce_to, field_obligations, residual, with_obligations, Coercion}; use crate::components::error_message::help_message::ErrorMsg; use crate::components::error_message::typr_error::TypRError; use crate::components::language::set_related_type_if_variable; use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::function_type::FunctionType; +use crate::components::r#type::refinement::RefinementSet; use crate::components::r#type::type_system::TypeSystem; use crate::processes::type_checking::facets; -use crate::processes::type_checking::signature_normalization::{self, CallInstance}; use crate::processes::type_checking::interface_satisfaction; use crate::processes::type_checking::match_types_to_generic; +use crate::processes::type_checking::refinement_check::{ + coerce_to, field_obligations, residual, with_obligations, Coercion, +}; +use crate::processes::type_checking::signature_normalization::{self, CallInstance}; +use crate::processes::type_checking::type_arithmetic::declared_refinements; use crate::processes::type_checking::type_comparison::reduce_type; use crate::processes::type_checking::typing; use crate::processes::type_checking::Context; @@ -1317,9 +1319,7 @@ fn apply_from_variable_inner(var: Var, context: &Context, parameters: &[Lang], h id_clashes.push(clash); None } - CallInstance::Instantiated(params, ret) => { - Some(sig.clone().set_params(params).set_return_type(ret)) - } + CallInstance::Instantiated(params, ret) => Some(sig.clone().set_params(params).set_return_type(ret)), } }) .collect(); @@ -1508,7 +1508,9 @@ fn generic_refinement_obligations( for (i, (arg, param)) in types.iter().zip(sig.get_param_types().iter()).enumerate() { // not reduced: the signature is already substituted (`N ↦ 0`), and reducing // `[0, char] & length(> 0)` would collapse it to an error - let Some((base, want)) = declared_refinements(param) else { continue }; + let Some((base, want)) = declared_refinements(param) else { + continue; + }; let have = arg.reduce(context).refinements_of(); if have.implies(&want) { continue; diff --git a/crates/typr-core/src/processes/type_checking/let_expression.rs b/crates/typr-core/src/processes/type_checking/let_expression.rs index 940ecdf..0a24cd6 100644 --- a/crates/typr-core/src/processes/type_checking/let_expression.rs +++ b/crates/typr-core/src/processes/type_checking/let_expression.rs @@ -107,7 +107,9 @@ pub fn collect_undefined_aliases(context: &Context, ty: &Type) -> Vec errors } Type::Vec(_, _, inner, _) => { - let mut errors = ty.vec_length().map_or_else(Vec::new, |length| collect_undefined_aliases(context, &length)); + let mut errors = ty + .vec_length() + .map_or_else(Vec::new, |length| collect_undefined_aliases(context, &length)); errors.extend(collect_undefined_aliases(context, inner)); errors } diff --git a/crates/typr-core/src/processes/type_checking/mod.rs b/crates/typr-core/src/processes/type_checking/mod.rs index 73fd841..4bda0cb 100644 --- a/crates/typr-core/src/processes/type_checking/mod.rs +++ b/crates/typr-core/src/processes/type_checking/mod.rs @@ -25,7 +25,6 @@ pub mod unification; pub mod unification_map; pub mod vectorizability; -use crate::processes::type_checking::refinement_check::{coerce_to, field_obligations, with_obligations, Coercion}; use crate::components::context::config::TargetLanguage; use crate::components::context::Context; use crate::components::error_message::help_data::HelpData; @@ -53,6 +52,7 @@ use crate::processes::type_checking::function::is_compatible_return_type; use crate::processes::type_checking::function_application::function_application; use crate::processes::type_checking::let_expression::let_expression; use crate::processes::type_checking::match_expression::match_expression; +use crate::processes::type_checking::refinement_check::{coerce_to, field_obligations, with_obligations, Coercion}; use crate::processes::type_checking::partial_application::partial_application; use crate::processes::type_checking::signature_expression::signature_expression; @@ -761,7 +761,10 @@ fn get_gen_type(type1: &Type, type2: &Type) -> Option> { // depending on the hasher's seed). let mut res: HashSet<(Type, Type)> = HashSet::new(); for argt2 in v2.iter() { - match v1.iter().find(|argt1| argt1.get_argument_str() == argt2.get_argument_str()) { + match v1 + .iter() + .find(|argt1| argt1.get_argument_str() == argt2.get_argument_str()) + { Some(argt1) => { let gen1 = get_gen_type(&argt1.get_argument(), &argt2.get_argument()).unwrap_or(vec![]); // A shared field whose types cannot be matched makes @@ -1018,7 +1021,12 @@ fn try_homogeneous_scalars(types: &[Type], n: usize, h: &HelpData) -> Option TypeContext { let idx_type = reduce_type(context, &idx_tc.value); if let (Type::Vec(_, _, ie, _), Some(n)) = (&idx_type, idx_type.vec_length()) { let len = match reduce_type(context, ie) { - Type::Integer(..) => Some(literal_range_len(member).map_or_else(|| n.clone(), builder::integer_type)), + Type::Integer(..) => { + Some(literal_range_len(member).map_or_else(|| n.clone(), builder::integer_type)) + } Type::Boolean(..) => Some(builder::any_type()), _ => None, }; if let Some(len) = len { - let len = if matches!(len, Type::Integer(Tint::Val(_), _) | Type::Any(_)) { len } else { builder::any_type() }; + let len = if matches!(len, Type::Integer(Tint::Val(_), _) | Type::Any(_)) { + len + } else { + builder::any_type() + }; errors.extend(idx_tc.errors); let typ2 = typ1.with_vec_length(len); return TypeContext::new(typ2, expr.clone(), context.clone()).with_errors(errors); @@ -4938,13 +4952,17 @@ p"#; /// Length of a literal range `a:b` / `a:s:b` (parsed as `seq(a, b, step)`), `None` for anything else. fn literal_range_len(member: &Lang) -> Option { - let Lang::FunctionApp { identifier, arguments, .. } = member else { return None }; + let Lang::FunctionApp { + identifier, arguments, .. + } = member + else { + return None; + }; match (&**identifier, arguments.as_slice()) { - (Lang::Variable { name, .. }, [Lang::Integer { value: a, .. }, Lang::Integer { value: b, .. }, Lang::Integer { value: step, .. }]) - if name == "seq" && *step != 0 && (b - a) / step >= 0 => - { - Some((b - a) / step + 1) - } + ( + Lang::Variable { name, .. }, + [Lang::Integer { value: a, .. }, Lang::Integer { value: b, .. }, Lang::Integer { value: step, .. }], + ) if name == "seq" && *step != 0 && (b - a) / step >= 0 => Some((b - a) / step + 1), _ => None, } } @@ -4954,7 +4972,9 @@ fn literal_range_len(member: &Lang) -> Option { /// booleans whose length is that of the vector side(s). `None` otherwise. fn vectorized_comparison(context: &Context, t1: &Type, t2: &Type) -> Option { let split = |t: &Type| match t { - Type::Vec(vt, _, e, h) if !matches!(**e, Type::Vec(..)) => Some((vt.clone(), t.vec_length()?, (**e).clone(), h.clone())), + Type::Vec(vt, _, e, h) if !matches!(**e, Type::Vec(..)) => { + Some((vt.clone(), t.vec_length()?, (**e).clone(), h.clone())) + } _ => None, }; let (s1, s2) = (split(t1), split(t2)); @@ -4963,7 +4983,9 @@ fn vectorized_comparison(context: &Context, t1: &Type, t2: &Type) -> Option Option { fn subject(l: &Lang) -> Option<(String, Measure)> { match l { Lang::Variable { name, .. } => Some((name.clone(), Measure::Value)), - Lang::FunctionApp { identifier, arguments, .. } if arguments.len() == 1 => match (&**identifier, &arguments[0]) { + Lang::FunctionApp { + identifier, arguments, .. + } if arguments.len() == 1 => match (&**identifier, &arguments[0]) { (Lang::Variable { name: f, .. }, Lang::Variable { name, .. }) if f == "length" => { Some((name.clone(), Measure::Length)) } @@ -114,17 +116,31 @@ fn facts(cond: &Lang, holds: bool, out: &mut Vec) { // `(c)` parses as a one-expression scope. Lang::Scope { body, .. } if body.len() == 1 => facts(&body[0], holds, out), Lang::Not { value, .. } => facts(value, !holds, out), - Lang::Operator { operator: Op::And(_) | Op::And2(_), lhs, rhs, .. } if holds => { + Lang::Operator { + operator: Op::And(_) | Op::And2(_), + lhs, + rhs, + .. + } if holds => { facts(lhs, true, out); facts(rhs, true, out); } - Lang::Operator { operator: Op::Or(_) | Op::Or2(_), lhs, rhs, .. } if !holds => { + Lang::Operator { + operator: Op::Or(_) | Op::Or2(_), + lhs, + rhs, + .. + } if !holds => { facts(lhs, false, out); facts(rhs, false, out); } // `&&` / `||` reach the checker as a call to the operator's function. - Lang::FunctionApp { identifier, arguments, .. } if arguments.len() == 2 => { - let Lang::Variable { name, .. } = &**identifier else { return }; + Lang::FunctionApp { + identifier, arguments, .. + } if arguments.len() == 2 => { + let Lang::Variable { name, .. } = &**identifier else { + return; + }; let wanted = match name.trim_matches('`') { "&&" | "&" if holds => true, "||" | "|" if !holds => false, @@ -134,7 +150,12 @@ fn facts(cond: &Lang, holds: bool, out: &mut Vec) { facts(&arguments[1], wanted, out); } // `Lang::Operator` stores the *left* operand in `rhs`. - Lang::Operator { operator, lhs: right, rhs: left, .. } => { + Lang::Operator { + operator, + lhs: right, + rhs: left, + .. + } => { let Some(cmp) = Cmp::of(operator) else { return }; let (lhs, rhs) = (left, right); let (subj, c, cmp) = match (subject(lhs), literal(rhs), literal(lhs), subject(rhs)) { @@ -179,7 +200,10 @@ fn narrow_variable(context: Context, name: &str, facts: RefinementSet) -> Contex } let (var, declared) = entries.remove(0); let reduced = reduce_type(&context, &declared); - if !matches!(reduced.unrefined(), Type::Vec(..) | Type::Integer(..) | Type::Number(..)) { + if !matches!( + reduced.unrefined(), + Type::Vec(..) | Type::Integer(..) | Type::Number(..) + ) { return context; } let mut set = reduced.refinements_of().meet(&facts); diff --git a/crates/typr-core/src/processes/type_checking/refinement_check.rs b/crates/typr-core/src/processes/type_checking/refinement_check.rs index bccc5ff..1f0fc6d 100644 --- a/crates/typr-core/src/processes/type_checking/refinement_check.rs +++ b/crates/typr-core/src/processes/type_checking/refinement_check.rs @@ -11,8 +11,8 @@ use crate::components::context::Context; use crate::components::error_message::help_data::HelpData; use crate::components::language::Lang; use crate::components::r#type::argument_type::ArgumentType; -use crate::components::r#type::tchar::Tchar; use crate::components::r#type::refinement::RefinementSet; +use crate::components::r#type::tchar::Tchar; use crate::components::r#type::type_system::TypeSystem; use crate::components::r#type::Type; @@ -91,7 +91,12 @@ pub fn field_obligations( expected: &Type, context: &Context, ) -> Option> { - let Lang::List { value: fields, spreads, .. } = expr else { return None }; + let Lang::List { + value: fields, spreads, .. + } = expr + else { + return None; + }; if !spreads.is_empty() { return None; } @@ -115,7 +120,9 @@ pub fn field_obligations( /// `context` with each obligation of `field_obligations` recorded. pub fn with_obligations(context: Context, obligations: Vec<(HelpData, RefinementSet)>) -> Context { - obligations.into_iter().fold(context, |ctx, (h, set)| ctx.add_refinement_obligation(&h, set)) + obligations + .into_iter() + .fold(context, |ctx, (h, set)| ctx.add_refinement_obligation(&h, set)) } /// The measures of `want` that `have` does not already imply. @@ -131,7 +138,11 @@ mod tests { use crate::utils::fluent_parser::FluentParser; fn ty(src: &str) -> (Type, Context) { - let ctx = FluentParser::new().push(&format!("type T <- {};", src)).run().context.clone(); + let ctx = FluentParser::new() + .push(&format!("type T <- {};", src)) + .run() + .context + .clone(); let alias = Type::Alias("T".into(), vec![], false, HelpData::default()); (alias.reduce(&ctx), ctx) } @@ -191,13 +202,22 @@ mod tests { fn errors_of(src: &str) -> usize { use crate::processes::parsing::parse2; use crate::processes::type_checking::typing_with_errors; - { let r = typing_with_errors(&Context::default(), &crate::processes::parsing::parse_from_string(src, "test")); r.get_errors().len() } + { + let r = typing_with_errors( + &Context::default(), + &crate::processes::parsing::parse_from_string(src, "test"), + ); + r.get_errors().len() + } } #[test] fn return_position_accepts_unproven_and_rejects_contradiction() { assert_eq!(errors_of("let f <- fn(v: [int]): [3, int] { v };"), 0); - assert_eq!(errors_of("let f <- fn(v: [int], c: bool): [3, int] { if (c) { return v; }; [1, 2, 3] };"), 0); + assert_eq!( + errors_of("let f <- fn(v: [int], c: bool): [3, int] { if (c) { return v; }; [1, 2, 3] };"), + 0 + ); assert!(errors_of("let f <- fn(): [3, int] { [1, 2] };") > 0); assert!(errors_of("let f <- fn(v: [char]): [3, int] { v };") > 0); } @@ -205,7 +225,10 @@ mod tests { #[test] fn argument_position_accepts_unproven_and_rejects_contradiction() { let decl = "let g <- fn(p: [2, num]): num { 1.0 };"; - assert_eq!(errors_of(&format!("{} let u: [num] <- [1.0, 2.0]; let r <- g(u);", decl)), 0); + assert_eq!( + errors_of(&format!("{} let u: [num] <- [1.0, 2.0]; let r <- g(u);", decl)), + 0 + ); assert!(errors_of(&format!("{} let c: [bool] <- [true, false]; let r <- g(c);", decl)) > 0); } } diff --git a/crates/typr-core/src/processes/type_checking/signature_normalization.rs b/crates/typr-core/src/processes/type_checking/signature_normalization.rs index 00ec43a..31d83f8 100644 --- a/crates/typr-core/src/processes/type_checking/signature_normalization.rs +++ b/crates/typr-core/src/processes/type_checking/signature_normalization.rs @@ -14,8 +14,8 @@ //! (phase 3) and the call site (phase 4) still read the raw signature, so //! only the errors are wired into `function()` for now. -use crate::components::error_message::typr_error::TypRError; use crate::components::error_message::type_error::TypeError; +use crate::components::error_message::typr_error::TypRError; use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::type_system::TypeSystem; use crate::processes::type_checking::facets; @@ -278,9 +278,10 @@ fn bind_ids( _ => true, }, Type::Tuple(ps, _) => match reduce_type(context, concrete) { - Type::Tuple(cs, _) if cs.len() == ps.len() => { - cs.iter().zip(ps.iter()).all(|(c, p)| bind_ids(c, p, arg, context, binds, clash)) - } + Type::Tuple(cs, _) if cs.len() == ps.len() => cs + .iter() + .zip(ps.iter()) + .all(|(c, p)| bind_ids(c, p, arg, context, binds, clash)), _ => true, }, Type::Function(ps, rp, _) => match reduce_type(context, concrete) { @@ -312,7 +313,9 @@ pub fn instantiate_at_call( // Cheap exit: a bound or a bare interface is always an alias/`Bounded` node. let mut candidate = false; for p in params { - visit(p, &mut |t| candidate |= matches!(t, Type::Alias(..) | Type::Bounded(..))); + visit(p, &mut |t| { + candidate |= matches!(t, Type::Alias(..) | Type::Bounded(..)) + }); } if !candidate { return CallInstance::NotBounded; @@ -483,7 +486,13 @@ mod tests { #[test] fn bounded_is_equal_to_itself() { let h = crate::components::error_message::help_data::HelpData::default(); - let b = || Type::Bounded("A".into(), Box::new(Type::Alias("L".into(), vec![], false, h.clone())), h.clone()); + let b = || { + Type::Bounded( + "A".into(), + Box::new(Type::Alias("L".into(), vec![], false, h.clone())), + h.clone(), + ) + }; assert_eq!(b(), b()); } } diff --git a/crates/typr-core/src/processes/type_checking/type_arithmetic.rs b/crates/typr-core/src/processes/type_checking/type_arithmetic.rs index b19f2c2..6fb7a24 100644 --- a/crates/typr-core/src/processes/type_checking/type_arithmetic.rs +++ b/crates/typr-core/src/processes/type_checking/type_arithmetic.rs @@ -166,7 +166,10 @@ pub fn refinement_error(t: &Type) -> Option { Some(TypeError::InvalidRefinement(msg.clone(), h.clone())) } Type::Refined(base, set, h) if matches!(**base, Type::Any(_)) => Some(TypeError::InvalidRefinement( - format!("`{}` is a property, not a type: refine a base type with `T & {}`", set, set), + format!( + "`{}` is a property, not a type: refine a base type with `T & {}`", + set, set + ), h.clone(), )), Type::Failed(msg, h) if msg.starts_with("unsatisfiable refinement") => { @@ -235,7 +238,10 @@ pub(crate) fn apply_refinements(base: Type, set: RefinementSet, h: HelpData) -> return base; } let unsatisfiable = |why: &str| { - Type::Failed(format!("unsatisfiable refinement: `{} & {}` {}", base.pretty(), set, why), h.clone()) + Type::Failed( + format!("unsatisfiable refinement: `{} & {}` {}", base.pretty(), set, why), + h.clone(), + ) }; let invalid = |m: Measure| { Type::Failed( @@ -259,9 +265,7 @@ pub(crate) fn apply_refinements(base: Type, set: RefinementSet, h: HelpData) -> // `[5, T]`, `[T] & length(> 0)` is a ranged length. Some(Ok(l)) => Type::Vec(kind.clone(), l, elem.clone(), vh.clone()), Some(Err(())) => match (length.as_known(), wanted.as_point()) { - (Some(m), Some(n)) => { - unsatisfiable(&format!("contradicts its length {} (asked for {})", m, n)) - } + (Some(m), Some(n)) => unsatisfiable(&format!("contradicts its length {} (asked for {})", m, n)), (Some(m), None) => unsatisfiable(&format!("contradicts its length {}", m)), _ => unsatisfiable("contradicts its length"), }, @@ -448,14 +452,21 @@ mod tests { #[test] fn test_refine_contradiction_is_unsatisfiable() { let res = inter(inter(builder::integer_type_default(), gt(10.0)), lt(5.0)); - assert!(matches!(refinement_error(&res), Some(TypeError::UnsatisfiableRefinement(..))), "{:?}", res); + assert!( + matches!(refinement_error(&res), Some(TypeError::UnsatisfiableRefinement(..))), + "{:?}", + res + ); } #[test] fn test_refine_integers_round_bounds() { // (> 0) & (< 1) has real values but no integer. let res = inter(inter(builder::integer_type_default(), gt(0.0)), lt(1.0)); - assert!(matches!(refinement_error(&res), Some(TypeError::UnsatisfiableRefinement(..)))); + assert!(matches!( + refinement_error(&res), + Some(TypeError::UnsatisfiableRefinement(..)) + )); let num = inter(inter(builder::number_type(), gt(0.0)), lt(1.0)); assert!(matches!(num, Type::Refined(..))); } @@ -472,7 +483,10 @@ mod tests { Some(TypeError::InvalidRefinement(..)) )); let vec = builder::array_type(builder::any_type(), builder::integer_type_default()); - assert!(matches!(refinement_error(&inter(vec, gt(0.0))), Some(TypeError::InvalidRefinement(..)))); + assert!(matches!( + refinement_error(&inter(vec, gt(0.0))), + Some(TypeError::InvalidRefinement(..)) + )); } #[test] @@ -483,20 +497,30 @@ mod tests { // Same length again: no-op. Another length: contradiction. assert_eq!(inter(res.clone(), prop(Refinement::Length(5))), res); let clash = inter(res, prop(Refinement::Length(3))); - assert!(matches!(refinement_error(&clash), Some(TypeError::UnsatisfiableRefinement(..)))); + assert!(matches!( + refinement_error(&clash), + Some(TypeError::UnsatisfiableRefinement(..)) + )); } #[test] fn test_refine_literal_is_proven_or_refuted() { assert_eq!(inter(builder::integer_type(3), gt(0.0)), builder::integer_type(3)); let refuted = inter(builder::integer_type(-3), gt(0.0)); - assert!(matches!(refinement_error(&refuted), Some(TypeError::UnsatisfiableRefinement(..)))); + assert!(matches!( + refinement_error(&refuted), + Some(TypeError::UnsatisfiableRefinement(..)) + )); } #[test] fn test_bare_properties_are_not_types() { let res = inter(gt(0.0), lt(5.0)); - assert!(matches!(refinement_error(&res), Some(TypeError::InvalidRefinement(..))), "{:?}", res); + assert!( + matches!(refinement_error(&res), Some(TypeError::InvalidRefinement(..))), + "{:?}", + res + ); } use super::*; @@ -757,18 +781,31 @@ mod tests { ); // unknown length: the range folds into the length itself let folded = inter(unknown.clone(), range()); - assert!(matches!(&folded, Type::Vec(_, l, _, _) if l.as_proper_range().is_some()), "{:?}", folded); - assert_eq!(folded.refinements_of().get(Measure::Length), Some(&Interval::greater_than(0.0))); - // known length: satisfied range is redundant, violated one is a contradiction - let sized = |n| Type::vec( - crate::components::r#type::vector_type::VecType::S3, - builder::integer_type(n), - builder::integer_type_default(), - HelpData::default(), + assert!( + matches!(&folded, Type::Vec(_, l, _, _) if l.as_proper_range().is_some()), + "{:?}", + folded ); + assert_eq!( + folded.refinements_of().get(Measure::Length), + Some(&Interval::greater_than(0.0)) + ); + // known length: satisfied range is redundant, violated one is a contradiction + let sized = |n| { + Type::vec( + crate::components::r#type::vector_type::VecType::S3, + builder::integer_type(n), + builder::integer_type_default(), + HelpData::default(), + ) + }; assert_eq!(inter(sized(3), range()), sized(3)); let res = inter(sized(0), range()); - assert!(matches!(refinement_error(&res), Some(TypeError::UnsatisfiableRefinement(..))), "{:?}", res); + assert!( + matches!(refinement_error(&res), Some(TypeError::UnsatisfiableRefinement(..))), + "{:?}", + res + ); } #[test] @@ -794,7 +831,11 @@ mod tests { assert!(!vec_of(builder::integer_type(0)).is_subtype(&pos, &ctx).0); // a contradiction is reported let none = inter(pos.clone(), range(Interval::less_than(1.0))); - assert!(matches!(refinement_error(&none), Some(TypeError::UnsatisfiableRefinement(..))), "{:?}", none); + assert!( + matches!(refinement_error(&none), Some(TypeError::UnsatisfiableRefinement(..))), + "{:?}", + none + ); // printed as a refinement assert_eq!(pos.pretty(), "[int] & length(> 0)"); } @@ -816,14 +857,27 @@ mod tests { let vec_of = |len: Type| Type::vec(VecType::S3, len, builder::integer_type_default(), HelpData::default()); let range = |iv: Interval| prop(Refinement::Range(Measure::Length, iv)); // `#N + 1` is at least 1: it proves length(> 0), `#N` alone does not - let pos = inter(vec_of(Type::Any(HelpData::default())), range(Interval::greater_than(0.0))); + let pos = inter( + vec_of(Type::Any(HelpData::default())), + range(Interval::greater_than(0.0)), + ); assert!(vec_of(plus1.clone()).is_subtype(&pos, &ctx).0); assert!(!vec_of(n()).is_subtype(&pos, &ctx).0); // narrowing `#N + 1` by a satisfied range is a no-op; by an unproven one it stays a property - assert_eq!(inter(vec_of(plus1.clone()), range(Interval::greater_than(0.0))), vec_of(plus1)); - assert!(matches!(inter(vec_of(n()), range(Interval::greater_than(0.0))), Type::Refined(..))); + assert_eq!( + inter(vec_of(plus1.clone()), range(Interval::greater_than(0.0))), + vec_of(plus1) + ); + assert!(matches!( + inter(vec_of(n()), range(Interval::greater_than(0.0))), + Type::Refined(..) + )); // a symbolic length is never negative let neg = inter(vec_of(n()), range(Interval::less_than(0.0))); - assert!(matches!(refinement_error(&neg), Some(TypeError::UnsatisfiableRefinement(..))), "{:?}", neg); + assert!( + matches!(refinement_error(&neg), Some(TypeError::UnsatisfiableRefinement(..))), + "{:?}", + neg + ); } } diff --git a/crates/typr-core/src/processes/type_checking/unification.rs b/crates/typr-core/src/processes/type_checking/unification.rs index ede7383..e2ba25a 100644 --- a/crates/typr-core/src/processes/type_checking/unification.rs +++ b/crates/typr-core/src/processes/type_checking/unification.rs @@ -1,9 +1,9 @@ -use crate::utils::builder; use crate::components::context::Context; use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::type_operator::TypeOperator; use crate::components::r#type::Type; use crate::processes::type_checking::type_comparison; +use crate::utils::builder; use std::collections::HashSet; /// Compare two index/label keys by *name*. @@ -168,7 +168,9 @@ pub fn type_substitution(type_: &Type, substitutions: &[(Type, Type)]) -> Type { // Tag type substitution // `[#N, T] & length(> 0)`: the refinements carry no generic, only the base does - Type::Refined(base, set, h) => Type::Refined(Box::new(type_substitution(base, substitutions)), set.clone(), h.clone()), + Type::Refined(base, set, h) => { + Type::Refined(Box::new(type_substitution(base, substitutions)), set.clone(), h.clone()) + } Type::Tag(name, inner_type, h) => Type::Tag( name.clone(), diff --git a/crates/typr-graph/src/build/blocks.rs b/crates/typr-graph/src/build/blocks.rs index 9b56fb0..3b10097 100644 --- a/crates/typr-graph/src/build/blocks.rs +++ b/crates/typr-graph/src/build/blocks.rs @@ -47,7 +47,11 @@ pub(super) fn discover_methods(context: &Context, type_name: &str) -> Vec<(Strin let p0 = params.first()?; if alias_name(&p0.get_type()).as_deref() == Some(type_name) { let method_name = var.get_name(); - Some((method_name.clone(), ty.pretty(), BlockKey::top_level(Namespace::Val, &method_name))) + Some(( + method_name.clone(), + ty.pretty(), + BlockKey::top_level(Namespace::Val, &method_name), + )) } else { None } @@ -76,7 +80,16 @@ impl<'a> Builder<'a> { | Lang::NA(_) | Lang::Empty(_) => { let ty = self.pretty_type(lang); - self.finish_block(lang, key, BlockKind::Literal, name, Vec::new(), vec![Port::explicit("out", ty)], Origin::User, None) + self.finish_block( + lang, + key, + BlockKind::Literal, + name, + Vec::new(), + vec![Port::explicit("out", ty)], + Origin::User, + None, + ) } // Reached only for a *named* position whose RHS is a bare rename (`let x <- y;`) — @@ -84,10 +97,21 @@ impl<'a> Builder<'a> { // Modeled as a reference to the real definition rather than a copy of it. Lang::Variable { name: var_name, .. } => { if let Some((target, confidence)) = self.resolve_definition(var_name, None) { - self.graph.relations.push(Relation::r#ref(key.clone(), "value", target, confidence)); + self.graph + .relations + .push(Relation::r#ref(key.clone(), "value", target, confidence)); } let ty = self.pretty_type(lang); - self.finish_block(lang, key, BlockKind::Opaque, name, Vec::new(), vec![Port::explicit("out", ty)], Origin::User, None) + self.finish_block( + lang, + key, + BlockKind::Opaque, + name, + Vec::new(), + vec![Port::explicit("out", ty)], + Origin::User, + None, + ) } // `Lang::Operator`'s own field names are swapped from what they read as: `Op::combine` @@ -118,24 +142,41 @@ impl<'a> Builder<'a> { ) } - Lang::FunctionApp { identifier, arguments, .. } | Lang::VecFunctionApp { identifier, arguments, .. } => { - self.build_apply(identifier, arguments, lang, key, name) - } - - Lang::Function { parameters, return_type, body, .. } => { - self.build_function(parameters, return_type, body, lang, key, name) + Lang::FunctionApp { + identifier, arguments, .. } + | Lang::VecFunctionApp { + identifier, arguments, .. + } => self.build_apply(identifier, arguments, lang, key, name), + + Lang::Function { + parameters, + return_type, + body, + .. + } => self.build_function(parameters, return_type, body, lang, key, name), Lang::Lambda { parameters, body, .. } => self.build_lambda(parameters, body, lang, key, name), Lang::List { value, .. } => self.build_record(value, lang, key, name), Lang::ConstructorCall { fields, .. } => self.build_record(fields, lang, key, name), - Lang::Alias { identifier, target_type, .. } => self.build_alias(identifier, target_type, lang, key, name), + Lang::Alias { + identifier, + target_type, + .. + } => self.build_alias(identifier, target_type, lang, key, name), - Lang::If { condition, if_block, else_block, .. } => self.build_if(condition, if_block, else_block, lang, key, name), + Lang::If { + condition, + if_block, + else_block, + .. + } => self.build_if(condition, if_block, else_block, lang, key, name), Lang::Tuple { value, .. } => self.build_sequence(BlockKind::Tuple, value, lang, key, name), - Lang::Array { value, .. } | Lang::Vector { value, .. } => self.build_sequence(BlockKind::Array, value, lang, key, name), + Lang::Array { value, .. } | Lang::Vector { value, .. } => { + self.build_sequence(BlockKind::Array, value, lang, key, name) + } Lang::Scope { body, .. } => { self.scope.push_plain(); @@ -151,13 +192,25 @@ impl<'a> Builder<'a> { Vec::new(), vec![Port::explicit("out", out_ty)], Origin::User, - Some(Body { children, wires: Vec::new() }), + Some(Body { + children, + wires: Vec::new(), + }), ) } - Lang::Module { name: module_name, body, .. } => self.build_module(module_name, body, lang, key, name), - - Lang::ForLoop { identifier, expression, body, .. } => self.build_for_loop(identifier, expression, body, lang, key, name), + Lang::Module { + name: module_name, + body, + .. + } => self.build_module(module_name, body, lang, key, name), + + Lang::ForLoop { + identifier, + expression, + body, + .. + } => self.build_for_loop(identifier, expression, body, lang, key, name), Lang::WhileLoop { condition, body, .. } => self.build_while_loop(condition, body, lang, key, name), Lang::Loop { body, .. } => self.build_bare_loop(body, lang, key, name), @@ -167,14 +220,32 @@ impl<'a> Builder<'a> { // TypR, so unlike every other block above, no inputs/wires are derived from it. Lang::RBlock { .. } | Lang::RFunction { .. } | Lang::ExternBlock { .. } => { let ty = self.pretty_type(lang); - self.finish_block(lang, key, BlockKind::RCode, name, Vec::new(), vec![Port::explicit("out", ty)], Origin::User, None) + self.finish_block( + lang, + key, + BlockKind::RCode, + name, + Vec::new(), + vec![Port::explicit("out", ty)], + Origin::User, + None, + ) } // A `# …` line: its own kind, and the node's name is the comment text. No value, so // no ports. Lang::Comment { value, .. } => { let text = value.trim().to_string(); - self.finish_block(lang, key, BlockKind::Comment, Some(text), Vec::new(), Vec::new(), Origin::User, None) + self.finish_block( + lang, + key, + BlockKind::Comment, + Some(text), + Vec::new(), + Vec::new(), + Origin::User, + None, + ) } // Everything else — genuinely unhandled, or not worth a dedicated shape (`Assign` @@ -182,37 +253,71 @@ impl<'a> Builder<'a> { // Opaque. other => { let ty = self.pretty_type(other); - self.finish_block(other, key, BlockKind::Opaque, name, Vec::new(), vec![Port::explicit("out", ty)], Origin::User, None) + self.finish_block( + other, + key, + BlockKind::Opaque, + name, + Vec::new(), + vec![Port::explicit("out", ty)], + Origin::User, + None, + ) } } } - fn build_operator_like(&mut self, op: &Op, lhs: &Lang, rhs: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_operator_like( + &mut self, + op: &Op, + lhs: &Lang, + rhs: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { match op { Op::Dollar(_) => self.build_access(lhs, rhs, lang, key, name), Op::Dot(_) => match rhs { - Lang::FunctionApp { identifier, arguments, .. } | Lang::VecFunctionApp { identifier, arguments, .. } => { - self.build_ufcs_apply(lhs, identifier, arguments, lang, key, name) + Lang::FunctionApp { + identifier, arguments, .. } + | Lang::VecFunctionApp { + identifier, arguments, .. + } => self.build_ufcs_apply(lhs, identifier, arguments, lang, key, name), Lang::Variable { .. } => self.build_access(lhs, rhs, lang, key, name), _ => self.build_binary_operator(op, lhs, rhs, lang, key, name), }, Op::Pipe(_) => match rhs { - Lang::FunctionApp { identifier, arguments, .. } | Lang::VecFunctionApp { identifier, arguments, .. } => { - self.build_ufcs_apply(lhs, identifier, arguments, lang, key, name) + Lang::FunctionApp { + identifier, arguments, .. } + | Lang::VecFunctionApp { + identifier, arguments, .. + } => self.build_ufcs_apply(lhs, identifier, arguments, lang, key, name), other => self.build_ufcs_apply(lhs, other, &[], lang, key, name), }, _ => self.build_binary_operator(op, lhs, rhs, lang, key, name), } } - fn build_binary_operator(&mut self, op: &Op, lhs: &Lang, rhs: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_binary_operator( + &mut self, + op: &Op, + lhs: &Lang, + rhs: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { let mut children = Vec::new(); let mut wires = Vec::new(); self.build_operand(lhs, key.role("lhs"), &key, "lhs", &mut children, &mut wires); self.build_operand(rhs, key.role("rhs"), &key, "rhs", &mut children, &mut wires); - let inputs = vec![Port::explicit("lhs", self.pretty_type(lhs)), Port::explicit("rhs", self.pretty_type(rhs))]; + let inputs = vec![ + Port::explicit("lhs", self.pretty_type(lhs)), + Port::explicit("rhs", self.pretty_type(rhs)), + ]; let out_ty = self.pretty_type(lang); // An anonymous operator is titled by its symbol (`+`, `&&`…) rather than by its key. let name = name.or_else(|| Some(op.to_string())); @@ -250,7 +355,15 @@ impl<'a> Builder<'a> { /// `lhs.f(args)` / `lhs |> f(args)`: UFCS desugars to `f(lhs, args)` at this level already /// (the parser keeps `.`/`|>` as plain operators, spec §2's note), so the graph builds it /// directly as the `Apply` it becomes rather than as a nested `Access` + call. - fn build_ufcs_apply(&mut self, receiver: &Lang, callee: &Lang, rest: &[Lang], lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_ufcs_apply( + &mut self, + receiver: &Lang, + callee: &Lang, + rest: &[Lang], + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { let mut children = Vec::new(); let mut wires = Vec::new(); let mut inputs = vec![Port::explicit("callee", None)]; @@ -259,9 +372,15 @@ impl<'a> Builder<'a> { let arg0_ty = self.recorded_type(receiver); if let Some((target, confidence)) = self.resolve_definition(callee_name, arg0_ty.as_ref()) { if self.callee_is_generic(callee_name, arg0_ty.as_ref()) && !target.as_str().starts_with("std:") { - self.graph.relations.push(Relation::instantiates(key.clone(), target.clone(), confidence.clone())); + self.graph.relations.push(Relation::instantiates( + key.clone(), + target.clone(), + confidence.clone(), + )); } - self.graph.relations.push(Relation::r#ref(key.clone(), "callee", target, confidence)); + self.graph + .relations + .push(Relation::r#ref(key.clone(), "callee", target, confidence)); } } _ => self.build_operand(callee, key.role("callee"), &key, "callee", &mut children, &mut wires), @@ -285,7 +404,14 @@ impl<'a> Builder<'a> { ) } - fn build_apply(&mut self, identifier: &Lang, arguments: &[Lang], lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_apply( + &mut self, + identifier: &Lang, + arguments: &[Lang], + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { let mut children = Vec::new(); let mut wires = Vec::new(); let mut inputs = vec![Port::explicit("callee", None)]; @@ -294,12 +420,25 @@ impl<'a> Builder<'a> { let arg0_ty = arguments.first().and_then(|a| self.recorded_type(a)); if let Some((target, confidence)) = self.resolve_definition(callee_name, arg0_ty.as_ref()) { if self.callee_is_generic(callee_name, arg0_ty.as_ref()) && !target.as_str().starts_with("std:") { - self.graph.relations.push(Relation::instantiates(key.clone(), target.clone(), confidence.clone())); + self.graph.relations.push(Relation::instantiates( + key.clone(), + target.clone(), + confidence.clone(), + )); } - self.graph.relations.push(Relation::r#ref(key.clone(), "callee", target, confidence)); + self.graph + .relations + .push(Relation::r#ref(key.clone(), "callee", target, confidence)); } } - _ => self.build_operand(identifier, key.role("callee"), &key, "callee", &mut children, &mut wires), + _ => self.build_operand( + identifier, + key.role("callee"), + &key, + "callee", + &mut children, + &mut wires, + ), } for (i, arg) in arguments.iter().enumerate() { let port = format!("arg{i}"); @@ -326,12 +465,26 @@ impl<'a> Builder<'a> { Some((ty @ Type::Function(..), _)) if ty.has_generic()) } - fn build_function(&mut self, parameters: &[ArgumentType], return_type: &Type, body: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_function( + &mut self, + parameters: &[ArgumentType], + return_type: &Type, + body: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { self.scope.push_boundary(key.clone()); let mut inputs = Vec::new(); for p in parameters { let pname = safe_argument_name(p); - self.scope.bind(&pname, PortRef { block: key.clone(), port: pname.clone() }); + self.scope.bind( + &pname, + PortRef { + block: key.clone(), + port: pname.clone(), + }, + ); inputs.push(Port::explicit(pname, Some(p.get_type().pretty()))); } let stmts = body_statements(body); @@ -348,16 +501,32 @@ impl<'a> Builder<'a> { inputs, vec![Port::explicit("out", Some(return_type.pretty()))], Origin::User, - Some(Body { children, wires: Vec::new() }), + Some(Body { + children, + wires: Vec::new(), + }), ) } - fn build_lambda(&mut self, parameters: &[Lang], body: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_lambda( + &mut self, + parameters: &[Lang], + body: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { self.scope.push_boundary(key.clone()); let mut inputs = Vec::new(); for (i, p) in parameters.iter().enumerate() { let pname = variable_name(p).unwrap_or_else(|| format!("arg{i}")); - self.scope.bind(&pname, PortRef { block: key.clone(), port: pname.clone() }); + self.scope.bind( + &pname, + PortRef { + block: key.clone(), + port: pname.clone(), + }, + ); inputs.push(Port::explicit(pname, self.pretty_type(p))); } let stmts = body_statements(body); @@ -375,7 +544,10 @@ impl<'a> Builder<'a> { inputs, vec![Port::explicit("out", out_ty)], Origin::User, - Some(Body { children, wires: Vec::new() }), + Some(Body { + children, + wires: Vec::new(), + }), ) } @@ -390,24 +562,65 @@ impl<'a> Builder<'a> { let ty = self.pretty_type(&value); inputs.push(Port::explicit(field_name.clone(), ty.clone())); outputs.push(Port::explicit(field_name.clone(), ty)); - self.build_operand(&value, key.named(&field_name), &key, &field_name, &mut children, &mut wires); + self.build_operand( + &value, + key.named(&field_name), + &key, + &field_name, + &mut children, + &mut wires, + ); } - self.finish_block(lang, key, BlockKind::Record, name, inputs, outputs, Origin::User, Some(Body { children, wires })) + self.finish_block( + lang, + key, + BlockKind::Record, + name, + inputs, + outputs, + Origin::User, + Some(Body { children, wires }), + ) } - fn build_alias(&mut self, _identifier: &Lang, target_type: &Type, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_alias( + &mut self, + _identifier: &Lang, + target_type: &Type, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { match target_type { Type::Interface(methods, _) => self.build_interface(methods, lang, key, name), other => self.build_type_decl(other, lang, key, name), } } - fn build_interface(&mut self, methods: &HashSet, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_interface( + &mut self, + methods: &HashSet, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { let mut children = Vec::new(); for m in methods { children.push(self.insert_type_expr_leaf(&key, m)); } - self.finish_block(lang, key, BlockKind::Interface, name, Vec::new(), Vec::new(), Origin::User, Some(Body { children, wires: Vec::new() })) + self.finish_block( + lang, + key, + BlockKind::Interface, + name, + Vec::new(), + Vec::new(), + Origin::User, + Some(Body { + children, + wires: Vec::new(), + }), + ) } fn build_type_decl(&mut self, target_type: &Type, lang: &Lang, key: BlockKey, name: Option) -> PortRef { @@ -424,10 +637,24 @@ impl<'a> Builder<'a> { let methods = discover_methods(self.context, type_name); for (method_name, pretty, method_key) in methods { outputs.push(Port::explicit(method_name, Some(pretty))); - self.graph.relations.push(Relation::type_position(method_key, key.clone(), 0)); + self.graph + .relations + .push(Relation::type_position(method_key, key.clone(), 0)); } } - self.finish_block(lang, key, BlockKind::TypeDecl, name, Vec::new(), outputs, Origin::User, Some(Body { children, wires: Vec::new() })) + self.finish_block( + lang, + key, + BlockKind::TypeDecl, + name, + Vec::new(), + outputs, + Origin::User, + Some(Body { + children, + wires: Vec::new(), + }), + ) } fn insert_type_expr_leaf(&mut self, parent: &BlockKey, member: &ArgumentType) -> BlockKey { @@ -448,7 +675,15 @@ impl<'a> Builder<'a> { child_key } - fn build_if(&mut self, condition: &Lang, if_block: &Lang, else_block: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_if( + &mut self, + condition: &Lang, + if_block: &Lang, + else_block: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { let mut children = Vec::new(); let mut wires = Vec::new(); self.build_operand(condition, key.role("cond"), &key, "cond", &mut children, &mut wires); @@ -474,7 +709,14 @@ impl<'a> Builder<'a> { ) } - fn build_sequence(&mut self, kind: BlockKind, elements: &[Lang], lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_sequence( + &mut self, + kind: BlockKind, + elements: &[Lang], + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { let mut children = Vec::new(); let mut wires = Vec::new(); let mut inputs = Vec::new(); @@ -488,7 +730,16 @@ impl<'a> Builder<'a> { } self.build_operand(el, key.role(&port), &key, &port, &mut children, &mut wires); } - self.finish_block(lang, key, kind, name, inputs, outputs, Origin::User, Some(Body { children, wires })) + self.finish_block( + lang, + key, + kind, + name, + inputs, + outputs, + Origin::User, + Some(Body { children, wires }), + ) } /// `module Name { ... }` (spec §4, §4.1, étape 5). A capture boundary like `Function`, whose @@ -498,12 +749,25 @@ impl<'a> Builder<'a> { /// se voit de l'intérieur"), but only `@pub` members get an output port: that's the only /// place privacy is actually enforced, since a private member simply isn't reachable from /// outside the module. - fn build_module(&mut self, _module_name: &str, members: &[Lang], lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_module( + &mut self, + _module_name: &str, + members: &[Lang], + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { self.scope.push_boundary(key.clone()); for item in members { if let Some((_, member_name)) = top_level_name(item) { - self.scope.bind(&member_name, PortRef { block: key.named(&member_name), port: "out".to_string() }); + self.scope.bind( + &member_name, + PortRef { + block: key.named(&member_name), + port: "out".to_string(), + }, + ); } } @@ -535,34 +799,93 @@ impl<'a> Builder<'a> { self.scope.pop(); let (captured, refs) = self.take_captures(&key); self.graph.relations.extend(refs); - self.finish_block(lang, key, BlockKind::Module, name, captured, outputs, Origin::User, Some(Body { children, wires: Vec::new() })) + self.finish_block( + lang, + key, + BlockKind::Module, + name, + captured, + outputs, + Origin::User, + Some(Body { + children, + wires: Vec::new(), + }), + ) } /// `for i in xs { ... }`: the iterable feeds a port named after the loop variable itself /// (mirroring a `Function` parameter — the loop variable's port *is* its binding inside the /// body), plus the state-port pair of every outer variable reassigned in the loop (spec /// §4.1 Q3, `build_loop_state_and_body`). - fn build_for_loop(&mut self, identifier: &Var, expression: &Lang, body: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_for_loop( + &mut self, + identifier: &Var, + expression: &Lang, + body: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { let var_name = identifier.get_name(); let mut children = Vec::new(); let mut wires = Vec::new(); let mut inputs = vec![Port::explicit(&var_name, self.pretty_type(expression))]; - self.build_operand(expression, key.role(&var_name), &key, &var_name, &mut children, &mut wires); + self.build_operand( + expression, + key.role(&var_name), + &key, + &var_name, + &mut children, + &mut wires, + ); - let outputs = self.build_loop_state_and_body(body, &key, Some((&var_name, &var_name)), &mut inputs, &mut wires, &mut children); - self.finish_block(lang, key, BlockKind::Loop, name, inputs, outputs, Origin::User, Some(Body { children, wires })) + let outputs = self.build_loop_state_and_body( + body, + &key, + Some((&var_name, &var_name)), + &mut inputs, + &mut wires, + &mut children, + ); + self.finish_block( + lang, + key, + BlockKind::Loop, + name, + inputs, + outputs, + Origin::User, + Some(Body { children, wires }), + ) } /// `while (cond) { ... }`: the condition feeds a `cond` input port (same convention as /// `If`), re-read every iteration — no loop-variable binding, unlike `ForLoop`. - fn build_while_loop(&mut self, condition: &Lang, body: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_while_loop( + &mut self, + condition: &Lang, + body: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { let mut children = Vec::new(); let mut wires = Vec::new(); let mut inputs = vec![Port::explicit("cond", self.pretty_type(condition))]; self.build_operand(condition, key.role("cond"), &key, "cond", &mut children, &mut wires); let outputs = self.build_loop_state_and_body(body, &key, None, &mut inputs, &mut wires, &mut children); - self.finish_block(lang, key, BlockKind::Loop, name, inputs, outputs, Origin::User, Some(Body { children, wires })) + self.finish_block( + lang, + key, + BlockKind::Loop, + name, + inputs, + outputs, + Origin::User, + Some(Body { children, wires }), + ) } /// `loop { ... }`: no condition or iterable at all, just the state-port pairs (the loop is @@ -572,7 +895,16 @@ impl<'a> Builder<'a> { let mut wires = Vec::new(); let mut inputs = Vec::new(); let outputs = self.build_loop_state_and_body(body, &key, None, &mut inputs, &mut wires, &mut children); - self.finish_block(lang, key, BlockKind::Loop, name, inputs, outputs, Origin::User, Some(Body { children, wires })) + self.finish_block( + lang, + key, + BlockKind::Loop, + name, + inputs, + outputs, + Origin::User, + Some(Body { children, wires }), + ) } /// Shared tail of the three loop shapes (spec §4.1 Q3): every outer variable reassigned by a @@ -612,23 +944,50 @@ impl<'a> Builder<'a> { let in_port = format!("{n}_in"); let src = self.resolve_and_wire(n, None); inputs.push(Port::implicit(in_port.clone(), None)); - wires.push(Wire { from: src, to: PortRef { block: key.clone(), port: in_port } }); + wires.push(Wire { + from: src, + to: PortRef { + block: key.clone(), + port: in_port, + }, + }); } self.scope.push_plain(); if let Some((bind_name, port)) = lead_binding { - self.scope.bind(bind_name, PortRef { block: key.clone(), port: port.to_string() }); + self.scope.bind( + bind_name, + PortRef { + block: key.clone(), + port: port.to_string(), + }, + ); } for n in &state_names { - self.scope.bind(n, PortRef { block: key.clone(), port: format!("{n}_in") }); + self.scope.bind( + n, + PortRef { + block: key.clone(), + port: format!("{n}_in"), + }, + ); } children.extend(self.build_nested_body(&stmts, key)); let mut outputs = Vec::with_capacity(state_names.len()); for n in &state_names { - let (final_port, _) = self.scope.resolve(n).expect("a name just bound in this frame resolves in it"); + let (final_port, _) = self + .scope + .resolve(n) + .expect("a name just bound in this frame resolves in it"); outputs.push(Port::implicit(format!("{n}_out"), None)); - wires.push(Wire { from: final_port, to: PortRef { block: key.clone(), port: format!("{n}_out") } }); + wires.push(Wire { + from: final_port, + to: PortRef { + block: key.clone(), + port: format!("{n}_out"), + }, + }); } self.scope.pop(); outputs @@ -640,7 +999,14 @@ impl<'a> Builder<'a> { /// entrées" — resolved that way), with the sub-block's body wired up exactly like a /// function's or scope's. The pattern itself isn't rendered as a block, only the names it /// introduces. - fn build_match(&mut self, target: &Lang, branches: &[(Lang, Box)], lang: &Lang, key: BlockKey, name: Option) -> PortRef { + fn build_match( + &mut self, + target: &Lang, + branches: &[(Lang, Box)], + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { let mut children = Vec::new(); let mut wires = Vec::new(); self.build_operand(target, key.role("target"), &key, "target", &mut children, &mut wires); @@ -653,7 +1019,13 @@ impl<'a> Builder<'a> { self.scope.push_plain(); let mut branch_inputs = Vec::with_capacity(bindings.len()); for n in &bindings { - self.scope.bind(n, PortRef { block: branch_key.clone(), port: n.clone() }); + self.scope.bind( + n, + PortRef { + block: branch_key.clone(), + port: n.clone(), + }, + ); branch_inputs.push(Port::implicit(n.clone(), None)); } let stmts = body_statements(body); @@ -670,13 +1042,25 @@ impl<'a> Builder<'a> { inputs: branch_inputs, outputs: vec![Port::explicit("out", branch_out_ty)], origin: Origin::User, - body: Some(Body { children: branch_children, wires: Vec::new() }), + body: Some(Body { + children: branch_children, + wires: Vec::new(), + }), }); children.push(branch_key); } let out_ty = self.pretty_type(lang); - self.finish_block(lang, key, BlockKind::Match, name, inputs, vec![Port::explicit("out", out_ty)], Origin::User, Some(Body { children, wires })) + self.finish_block( + lang, + key, + BlockKind::Match, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires }), + ) } } @@ -699,7 +1083,9 @@ fn pattern_bindings(pattern: &Lang) -> Vec { Lang::Variable { name, .. } if name != "_" => vec![name.clone()], _ => Vec::new(), }, - Lang::TypePattern { variable_name: name, .. } => vec![name.clone()], + Lang::TypePattern { + variable_name: name, .. + } => vec![name.clone()], Lang::Tuple { value: elements, .. } => elements .iter() .filter_map(|e| match e { @@ -753,7 +1139,10 @@ let b <- inc(1); let id = BlockKey::top_level(Namespace::Val, "id"); let inc = BlockKey::top_level(Namespace::Val, "inc"); let inst = |to: &BlockKey| { - graph.relations.iter().any(|r| r.kind == RelationKind::Instantiates && r.to == *to) + graph + .relations + .iter() + .any(|r| r.kind == RelationKind::Instantiates && r.to == *to) }; assert!(inst(&id), "{:#?}", graph.relations); assert!(!inst(&inc)); @@ -778,7 +1167,10 @@ let total <- fn(seed: int): int { "#, ); let loop_key = BlockKey::top_level(Namespace::Val, "total").role("#2"); - let block = graph.blocks.get(&loop_key).expect("the while loop is built as a top-level function's 3rd body statement"); + let block = graph + .blocks + .get(&loop_key) + .expect("the while loop is built as a top-level function's 3rd body statement"); assert_eq!(block.kind, BlockKind::Loop); let input_names: Vec<_> = block.inputs.iter().map(|p| p.name.as_str()).collect(); assert!(input_names.contains(&"acc_in"), "{input_names:?}"); @@ -801,12 +1193,18 @@ let total <- fn(seed: int): int { "#, ); let loop_key = BlockKey::top_level(Namespace::Val, "total").role("#1"); - let block = graph.blocks.get(&loop_key).expect("the for loop is built as a top-level function's 2nd body statement"); + let block = graph + .blocks + .get(&loop_key) + .expect("the for loop is built as a top-level function's 2nd body statement"); assert_eq!(block.kind, BlockKind::Loop); let input_names: Vec<_> = block.inputs.iter().map(|p| p.name.as_str()).collect(); assert!(input_names.contains(&"i"), "{input_names:?}"); assert!(input_names.contains(&"acc_in"), "{input_names:?}"); - assert_eq!(block.outputs.iter().map(|p| p.name.as_str()).collect::>(), vec!["acc_out"]); + assert_eq!( + block.outputs.iter().map(|p| p.name.as_str()).collect::>(), + vec!["acc_out"] + ); } #[test] @@ -823,14 +1221,32 @@ let area <- fn(s: Shape): int { "#, ); let match_key = BlockKey::top_level(Namespace::Val, "area").role("#0"); - let block = graph.blocks.get(&match_key).expect("the match is the function's sole body statement"); + let block = graph + .blocks + .get(&match_key) + .expect("the match is the function's sole body statement"); assert_eq!(block.kind, BlockKind::Match); - assert_eq!(block.inputs.iter().map(|p| p.name.as_str()).collect::>(), vec!["target"]); + assert_eq!( + block.inputs.iter().map(|p| p.name.as_str()).collect::>(), + vec!["target"] + ); - let branch0 = graph.blocks.get(&match_key.role("branch0")).expect("first branch sub-block"); - assert_eq!(branch0.inputs.iter().map(|p| p.name.as_str()).collect::>(), vec!["r"]); - let branch1 = graph.blocks.get(&match_key.role("branch1")).expect("second branch sub-block"); - assert_eq!(branch1.inputs.iter().map(|p| p.name.as_str()).collect::>(), vec!["side"]); + let branch0 = graph + .blocks + .get(&match_key.role("branch0")) + .expect("first branch sub-block"); + assert_eq!( + branch0.inputs.iter().map(|p| p.name.as_str()).collect::>(), + vec!["r"] + ); + let branch1 = graph + .blocks + .get(&match_key.role("branch1")) + .expect("second branch sub-block"); + assert_eq!( + branch1.inputs.iter().map(|p| p.name.as_str()).collect::>(), + vec!["side"] + ); } #[test] @@ -844,17 +1260,36 @@ module Reporter { "#, ); let module_key = BlockKey::top_level(Namespace::Val, "Reporter"); - let block = graph.blocks.get(&module_key).expect("the module is built at the top level, named after itself"); + let block = graph + .blocks + .get(&module_key) + .expect("the module is built at the top level, named after itself"); assert_eq!(block.kind, BlockKind::Module); - assert_eq!(block.outputs.iter().map(|p| p.name.as_str()).collect::>(), vec!["describe"]); - assert!(block.body.as_ref().unwrap().children.contains(&module_key.named("helper"))); - assert!(block.body.as_ref().unwrap().children.contains(&module_key.named("describe"))); + assert_eq!( + block.outputs.iter().map(|p| p.name.as_str()).collect::>(), + vec!["describe"] + ); + assert!(block + .body + .as_ref() + .unwrap() + .children + .contains(&module_key.named("helper"))); + assert!(block + .body + .as_ref() + .unwrap() + .children + .contains(&module_key.named("describe"))); } #[test] fn r_block_is_a_total_opaque_boundary_with_no_inputs() { let graph = build_graph("let total <- R { sum(c(1, 2, 3)) };\n"); - let block = graph.blocks.get(&BlockKey::top_level(Namespace::Val, "total")).expect("top-level R block"); + let block = graph + .blocks + .get(&BlockKey::top_level(Namespace::Val, "total")) + .expect("top-level R block"); assert_eq!(block.kind, BlockKind::RCode); assert!(block.inputs.is_empty()); assert!(block.body.is_none()); @@ -870,9 +1305,15 @@ module Reporter { assert!(comments.iter().all(|b| b.outputs.is_empty() && b.inputs.is_empty())); assert!(comments.iter().all(|b| b.r#type.is_none()), "a comment has no type"); assert!( - !graph.relations.iter().any(|r| r.kind == RelationKind::HasType && comments.iter().any(|b| b.key == r.from)), + !graph + .relations + .iter() + .any(|r| r.kind == RelationKind::HasType && comments.iter().any(|b| b.key == r.from)), "a comment must not be linked to `char`" ); - assert!(!graph.blocks.values().any(|b| b.kind == BlockKind::Opaque && b.name.as_deref() == Some("hello world"))); + assert!(!graph + .blocks + .values() + .any(|b| b.kind == BlockKind::Opaque && b.name.as_deref() == Some("hello world"))); } } diff --git a/crates/typr-graph/src/build/mod.rs b/crates/typr-graph/src/build/mod.rs index 504d2fc..0c0d88b 100644 --- a/crates/typr-graph/src/build/mod.rs +++ b/crates/typr-graph/src/build/mod.rs @@ -35,7 +35,13 @@ pub fn build(lang: &Lang, context: &Context, types: &TypeTable) -> BlockGraph { for item in &items { if let Some((namespace, name)) = top_level_name(item) { let key = BlockKey::top_level(namespace, &name); - b.scope.bind(&name, PortRef { block: key, port: "out".to_string() }); + b.scope.bind( + &name, + PortRef { + block: key, + port: "out".to_string(), + }, + ); } } @@ -60,7 +66,10 @@ pub fn build(lang: &Lang, context: &Context, types: &TypeTable) -> BlockGraph { inputs, outputs: Vec::new(), origin: Origin::User, - body: Some(Body { children, wires: Vec::new() }), + body: Some(Body { + children, + wires: Vec::new(), + }), }); b.graph } @@ -150,7 +159,10 @@ impl<'a> Builder<'a> { // declaration, so it gets an anonymous positional key like any unnamed statement, but // rebinds `x` going forward — later top-level reads of `x` wire from this block, not // from whatever defined it before. - if let Lang::Assign { identifier, expression, .. } = item { + if let Lang::Assign { + identifier, expression, .. + } = item + { if let Some(reassigned) = variable_name(identifier) { let key = BlockKey::top_level(Namespace::Val, &format!("#{index}")); let port = self.build_expr(expression, key.clone(), Some(&reassigned)); @@ -185,7 +197,9 @@ impl<'a> Builder<'a> { let mut children = Vec::new(); for (index, stmt) in stmts.iter().enumerate() { match stmt { - Lang::Let { variable, expression, .. } => match variable_name(variable) { + Lang::Let { + variable, expression, .. + } => match variable_name(variable) { Some(name) => { let key = owner.named(&name); let port = self.build_expr(expression, key.clone(), Some(&name)); @@ -205,7 +219,9 @@ impl<'a> Builder<'a> { // `Loop`'s state ports, étape 5): rebinds `x` going forward, same as a `Let`, // but keyed anonymously since the name is already claimed by its original // declaration. - Lang::Assign { identifier, expression, .. } => { + Lang::Assign { + identifier, expression, .. + } => { let key = owner.anonymous(index); match variable_name(identifier) { Some(reassigned) => { @@ -251,19 +267,29 @@ impl<'a> Builder<'a> { }; wires.push(Wire { from: src, - to: PortRef { block: owner.clone(), port: port.to_string() }, + to: PortRef { + block: owner.clone(), + port: port.to_string(), + }, }); } fn resolve_name(&mut self, name: &str, arg0: Option<&Type>) -> Option { if let Some((port, is_local)) = self.scope.resolve(name) { let crosses = if is_local { None } else { self.scope.nearest_boundary() }; - return Some(Resolved { target: port, confidence: Confidence::Exact, crosses }); + return Some(Resolved { + target: port, + confidence: Confidence::Exact, + crosses, + }); } let (ty, confidence) = refs::resolve_by_name(self.context, name, arg0)?; let target_key = self.ensure_reference_block(name, &ty); Some(Resolved { - target: PortRef { block: target_key, port: "out".to_string() }, + target: PortRef { + block: target_key, + port: "out".to_string(), + }, confidence, crosses: self.scope.nearest_boundary(), }) @@ -277,7 +303,13 @@ impl<'a> Builder<'a> { fn resolve_definition(&mut self, name: &str, arg0: Option<&Type>) -> Option<(BlockKey, Confidence)> { let resolved = self.resolve_name(name, arg0)?; if let Some(boundary) = resolved.crosses.clone() { - self.ensure_capture_port(boundary, name, resolved.target.clone(), resolved.confidence.clone(), None); + self.ensure_capture_port( + boundary, + name, + resolved.target.clone(), + resolved.confidence.clone(), + None, + ); } Some((resolved.target.block, resolved.confidence)) } @@ -287,14 +319,26 @@ impl<'a> Builder<'a> { /// (spec §3.3). fn resolve_and_wire(&mut self, name: &str, arg0: Option<&Type>) -> PortRef { match self.resolve_name(name, arg0) { - Some(Resolved { target, confidence, crosses: Some(boundary) }) => { + Some(Resolved { + target, + confidence, + crosses: Some(boundary), + }) => { self.ensure_capture_port(boundary.clone(), name, target, confidence, None); - PortRef { block: boundary, port: name.to_string() } + PortRef { + block: boundary, + port: name.to_string(), + } } - Some(Resolved { target, crosses: None, .. }) => target, + Some(Resolved { + target, crosses: None, .. + }) => target, None => { let key = self.ensure_unresolved_block(name); - PortRef { block: key, port: "out".to_string() } + PortRef { + block: key, + port: "out".to_string(), + } } } } @@ -387,7 +431,11 @@ impl<'a> Builder<'a> { ) -> PortRef { // A comment has no type: the parser models it as a `char` value, which must neither be // printed on the block nor linked (`HasType`) to the `char` primitive. - let ty = if kind == BlockKind::Comment { None } else { self.recorded_type(lang) }; + let ty = if kind == BlockKind::Comment { + None + } else { + self.recorded_type(lang) + }; // A literal's recorded type is the value (`"hi"`, `3`), which names no type block. let primitive = match &ty { Some(Type::Char(..)) => Some("char"), @@ -400,11 +448,15 @@ impl<'a> Builder<'a> { self.type_notes.push((key.clone(), p.to_string())); } if let Some(Type::Alias(alias_name, _, _, _)) = &ty { - self.graph - .relations - .push(Relation::has_type(key.clone(), BlockKey::top_level(Namespace::Type, alias_name))); + self.graph.relations.push(Relation::has_type( + key.clone(), + BlockKey::top_level(Namespace::Type, alias_name), + )); } - let default_port = outputs.first().map(|p| p.name.clone()).unwrap_or_else(|| "out".to_string()); + let default_port = outputs + .first() + .map(|p| p.name.clone()) + .unwrap_or_else(|| "out".to_string()); self.graph.insert(Block { key: key.clone(), kind, @@ -416,6 +468,9 @@ impl<'a> Builder<'a> { origin, body, }); - PortRef { block: key, port: default_port } + PortRef { + block: key, + port: default_port, + } } } diff --git a/crates/typr-graph/src/build/refs.rs b/crates/typr-graph/src/build/refs.rs index 38e8231..14c08ba 100644 --- a/crates/typr-graph/src/build/refs.rs +++ b/crates/typr-graph/src/build/refs.rs @@ -47,12 +47,7 @@ pub fn resolve_by_name(context: &Context, name: &str, arg0: Option<&Type>) -> Op match matching.as_slice() { [only] => Some(((*only).clone(), Confidence::Exact)), [] => Some((many[0].clone(), Confidence::ByName)), - _ => Some(( - many[0].clone(), - Confidence::Ambiguous { - candidates: Vec::new(), - }, - )), + _ => Some((many[0].clone(), Confidence::Ambiguous { candidates: Vec::new() })), } } } diff --git a/crates/typr-graph/src/build/type_relations.rs b/crates/typr-graph/src/build/type_relations.rs index f3d07ae..9a47601 100644 --- a/crates/typr-graph/src/build/type_relations.rs +++ b/crates/typr-graph/src/build/type_relations.rs @@ -40,7 +40,9 @@ pub(super) fn build(graph: &mut BlockGraph, context: &Context, items: &[&Lang]) } for other in &type_decls { if other.key != decl.key && decl.target_type.is_subtype(&other.target_type, context).0 { - graph.relations.push(Relation::subtype(decl.key.clone(), other.key.clone())); + graph + .relations + .push(Relation::subtype(decl.key.clone(), other.key.clone())); } } } @@ -167,14 +169,23 @@ fn collect_type_aliases(context: &Context, items: &[&Lang]) -> Vec { items .iter() .filter_map(|item| match item { - Lang::Alias { identifier, target_type, .. } => { + Lang::Alias { + identifier, + target_type, + .. + } => { let name = variable_name(identifier)?; let key = BlockKey::top_level(Namespace::Type, &name); let interface_methods = match target_type.reduce(context) { Type::Interface(methods, _) => Some(methods), _ => None, }; - Some(TypeAlias { name, key, target_type: target_type.clone(), interface_methods }) + Some(TypeAlias { + name, + key, + target_type: target_type.clone(), + interface_methods, + }) } _ => None, }) @@ -227,11 +238,16 @@ fn satisfies(graph: &mut BlockGraph, context: &Context, decl: &TypeAlias, iface: .find(|(method_name, ..)| *method_name == name) .map(|(_, _, key)| key.clone()) .unwrap_or_else(|| decl.key.clone()); - Evidence { requires: format!("{name}: {}", req.get_type().pretty()), provided_by } + Evidence { + requires: format!("{name}: {}", req.get_type().pretty()), + provided_by, + } }) .collect(); evidence.sort_by(|a, b| a.requires.cmp(&b.requires)); - graph.relations.push(Relation::satisfies(decl.key.clone(), iface.key.clone(), evidence)); + graph + .relations + .push(Relation::satisfies(decl.key.clone(), iface.key.clone(), evidence)); } #[cfg(test)] @@ -275,11 +291,17 @@ let n <- list(name = "Point"); let graph = build_graph("let message: char <- \"Hello, TypR!\";\n\nmessage\n"); let char_key = BlockKey::top_level(Namespace::Type, "char"); assert!(graph.blocks.contains_key(&char_key), "{:#?}", graph.blocks.keys()); - assert!(graph.blocks[&graph.root].body.as_ref().unwrap().children.contains(&char_key)); + assert!(graph.blocks[&graph.root] + .body + .as_ref() + .unwrap() + .children + .contains(&char_key)); let linked = |from: &str| { - graph.relations.iter().any(|r| { - r.kind == RelationKind::HasType && r.from == BlockKey::from_raw(from) && r.to == char_key - }) + graph + .relations + .iter() + .any(|r| r.kind == RelationKind::HasType && r.from == BlockKey::from_raw(from) && r.to == char_key) }; assert!(linked("val:message"), "{:#?}", graph.relations); assert!(linked("val:#1")); diff --git a/crates/typr-graph/src/diff.rs b/crates/typr-graph/src/diff.rs index a48f2c2..35ae1b3 100644 --- a/crates/typr-graph/src/diff.rs +++ b/crates/typr-graph/src/diff.rs @@ -123,14 +123,8 @@ pub fn diff(old: &BlockGraph, new: &BlockGraph) -> GraphDiff { let old_interface = interface_targets(old, key); let new_interface = interface_targets(new, key); - let interface_added: Vec = new_interface - .difference(&old_interface) - .cloned() - .collect(); - let interface_removed: Vec = old_interface - .difference(&new_interface) - .cloned() - .collect(); + let interface_added: Vec = new_interface.difference(&old_interface).cloned().collect(); + let interface_removed: Vec = old_interface.difference(&new_interface).cloned().collect(); if !interface_added.is_empty() || !interface_removed.is_empty() { d.interface = Some(InterfaceChange { added: interface_added, @@ -154,9 +148,7 @@ fn interface_targets(graph: &BlockGraph, key: &BlockKey) -> BTreeSet { graph .relations .iter() - .filter(|r| { - r.from == *key && matches!(r.kind, RelationKind::Satisfies | RelationKind::DeclaredAs) - }) + .filter(|r| r.from == *key && matches!(r.kind, RelationKind::Satisfies | RelationKind::DeclaredAs)) .map(|r| r.to.clone()) .collect() } @@ -193,16 +185,10 @@ pub fn to_text(diff: &GraphDiff) -> String { )); } if let Some(c) = &m.captures { - out.push_str(&format!( - " captures: {}\n", - format_port_delta(&c.added, &c.removed) - )); + out.push_str(&format!(" captures: {}\n", format_port_delta(&c.added, &c.removed))); } if let Some(i) = &m.interface { - out.push_str(&format!( - " interface: {}\n", - format_key_delta(&i.added, &i.removed) - )); + out.push_str(&format!(" interface: {}\n", format_key_delta(&i.added, &i.removed))); } } out @@ -382,10 +368,7 @@ mod tests { let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); let mut a2 = block("val:a", BlockKind::Scope); a2.body = Some(Body { - children: vec![ - BlockKey::from_raw("val:a/#0"), - BlockKey::from_raw("val:a/#1"), - ], + children: vec![BlockKey::from_raw("val:a/#0"), BlockKey::from_raw("val:a/#1")], wires: Vec::new(), }); new.insert(a2); diff --git a/crates/typr-graph/tests/totality.rs b/crates/typr-graph/tests/totality.rs index 0cd3d5a..37a6fec 100644 --- a/crates/typr-graph/tests/totality.rs +++ b/crates/typr-graph/tests/totality.rs @@ -43,11 +43,16 @@ fn builds_every_typechecking_case_without_panicking() { // Built from the originally parsed tree, not `result.type_context.lang` — `typing()` on // a `Lines` node returns only its last statement's rewritten form, not the whole program. - let outcome = catch_unwind(AssertUnwindSafe(|| typr_graph::build(&lang, &result.type_context.context, &table))); + let outcome = catch_unwind(AssertUnwindSafe(|| { + typr_graph::build(&lang, &result.type_context.context, &table) + })); assert!(outcome.is_ok(), "typr-graph panicked building {}", main_ty.display()); checked += 1; } - assert!(checked > 0, "expected at least one cases/ repro to typecheck standalone and be built"); + assert!( + checked > 0, + "expected at least one cases/ repro to typecheck standalone and be built" + ); eprintln!("totality: built {checked} case(s) without panicking, skipped {skipped} (parse/typecheck failure or multi-file dependency)"); } diff --git a/crates/typr-graph/tests/walkthrough.rs b/crates/typr-graph/tests/walkthrough.rs index ac7dcdb..aaf06bd 100644 --- a/crates/typr-graph/tests/walkthrough.rs +++ b/crates/typr-graph/tests/walkthrough.rs @@ -29,7 +29,11 @@ let total <- norm2(p) + 12 + 3; fn walkthrough_example_builds_without_panicking() { let lang = parse_from_string(FIL_ROUGE, "fil_rouge"); let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); - assert!(!result.has_errors(), "walkthrough example should typecheck cleanly: {:?}", result.display_errors()); + assert!( + !result.has_errors(), + "walkthrough example should typecheck cleanly: {:?}", + result.display_errors() + ); // `typing()` on a `Lines` node returns the *last statement's* rewritten `Lang` in // `type_context.lang` (mirroring a `Scope`'s "value is its last expression"), not the whole @@ -38,8 +42,12 @@ fn walkthrough_example_builds_without_panicking() { // takes the parsed AST, not `typing()`'s return value). let graph = typr_graph::build(&lang, &result.type_context.context, &table); - assert!(graph.blocks.contains_key(&typr_graph::BlockKey::top_level(typr_graph::Namespace::Val, "norm2"))); - assert!(graph.blocks.contains_key(&typr_graph::BlockKey::top_level(typr_graph::Namespace::Type, "Point"))); + assert!(graph + .blocks + .contains_key(&typr_graph::BlockKey::top_level(typr_graph::Namespace::Val, "norm2"))); + assert!(graph + .blocks + .contains_key(&typr_graph::BlockKey::top_level(typr_graph::Namespace::Type, "Point"))); let json = serde_json::to_string_pretty(&graph).unwrap(); insta::assert_snapshot!(json); diff --git a/packaging/tests/run-tests.sh b/packaging/tests/run-tests.sh index c053df1..b31178a 100755 --- a/packaging/tests/run-tests.sh +++ b/packaging/tests/run-tests.sh @@ -416,7 +416,7 @@ test_render() { "scoop/README.md" "scoop/bucket/typr.json" "scoop/bin/typr/$VERSION.json" \ "winget/$WINGET_VERSION_FILE" "winget/$WINGET_INSTALLER_FILE" \ "winget/$WINGET_LOCALE_FILE" | sort) - extra=$(cd "$dir" && find . -type f | sed 's|^\./||' | sort | grep -vxF "$expected" || true) + extra=$( (cd "$dir" && find . -type f | sed 's|^\./||' | sort) | grep -vxF "$expected" || true) if [ -z "$extra" ]; then ok "aucun fichier surnumeraire dans la sortie"; else ko "aucun fichier surnumeraire dans la sortie" "$extra"; fi } From 56dde5f38d992305904e8fab5c49450724ddd91c Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 16:52:24 +0200 Subject: [PATCH 46/52] ci : tap Homebrew, exit PowerShell ; type_registry : dossier de clone unique Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 11 +++++++---- crates/typr-cli/src/type_registry.rs | 11 ++++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c264e70..2f6538b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -323,14 +323,15 @@ jobs: - name: brew style et brew audit run: | set -euo pipefail - TAP_DIR="$(brew --repository)/Library/Taps/we-data-ch/homebrew-typr" + brew tap-new --no-git we-data-ch/typr + TAP_DIR="$(brew --repository we-data-ch/typr)" mkdir -p "$TAP_DIR/Formula" cp tap/Formula/typr.rb "$TAP_DIR/Formula/typr.rb" cp tap/README.md "$TAP_DIR/README.md" - brew readall "$TAP_DIR" - brew style "$TAP_DIR/Formula/typr.rb" - brew audit --strict --formula="$TAP_DIR/Formula/typr.rb" + brew readall we-data-ch/typr + brew style we-data-ch/typr/typr + brew audit --strict --formula we-data-ch/typr/typr install: name: Install scripts @@ -410,6 +411,8 @@ jobs: } Write-Host "install.ps1 — PowerShell $($PSVersionTable.PSVersion) sur $env:OS" + # Le dernier code natif (2, attendu) ferait sinon échouer l'étape. + exit 0 install-hosting: name: Install hosting diff --git a/crates/typr-cli/src/type_registry.rs b/crates/typr-cli/src/type_registry.rs index 0b5a7f3..1ba920c 100644 --- a/crates/typr-cli/src/type_registry.rs +++ b/crates/typr-cli/src/type_registry.rs @@ -211,7 +211,16 @@ pub(crate) fn git_available() -> bool { /// `Rscript` — rather than adding a git-in-process dependency for a command /// that already needs network access. fn clone_repo(url: &str, rev: Option<&str>) -> Result<(PathBuf, String), String> { - let dir = std::env::temp_dir().join(format!("typr_types_fetch_{}_{}", std::process::id(), now_millis())); + // The counter keeps two clones started in the same millisecond (parallel + // tests, or a batch update) from sharing — and corrupting — one directory. + static CLONE_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = CLONE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "typr_types_fetch_{}_{}_{}", + std::process::id(), + now_millis(), + seq + )); fs::create_dir_all(&dir).map_err(|e| format!("could not create temp dir: {e}"))?; let clone_status = Command::new("git") From 42d59bc72b1084ec5b445503cb489312752ff040 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 16:59:10 +0200 Subject: [PATCH 47/52] homebrew : desc sans article, livecheck avant on_macos (brew audit) Co-Authored-By: Claude Sonnet 5.5 --- packaging/homebrew/Formula/typr.rb.in | 12 ++++++------ packaging/homebrew/tests/run-tests.sh | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packaging/homebrew/Formula/typr.rb.in b/packaging/homebrew/Formula/typr.rb.in index a23a955..524d2f2 100644 --- a/packaging/homebrew/Formula/typr.rb.in +++ b/packaging/homebrew/Formula/typr.rb.in @@ -10,11 +10,16 @@ # qu'un SHA est recopié à la main. Pour installer le tap ou corriger ce # fichier : packaging/homebrew/README.md class Typr < Formula - desc "A typed superset of R — transpiler and type checker" + desc "Typed superset of R — transpiler and type checker" homepage "https://we-data-ch.github.io/typr.github.io/" version "@@VERSION@@" license "Apache-2.0" + livecheck do + url :stable + strategy :github_latest + end + on_macos do on_arm do url "@@URL_AARCH64_APPLE@@" @@ -29,11 +34,6 @@ class Typr < Formula @@LINUX@@ - livecheck do - url :stable - strategy :github_latest - end - def install bin.install "typr" end diff --git a/packaging/homebrew/tests/run-tests.sh b/packaging/homebrew/tests/run-tests.sh index e2535ea..812e2af 100755 --- a/packaging/homebrew/tests/run-tests.sh +++ b/packaging/homebrew/tests/run-tests.sh @@ -278,7 +278,7 @@ test_render() { check_not_contains "$(cat "$readme")" "@@" "aucun marqueur résiduel dans le README" # Le README est dérivé de la formule, pas d'une deuxième source. - check_contains "$(cat "$readme")" "A typed superset of R — transpiler and type checker" \ + check_contains "$(cat "$readme")" "Typed superset of R — transpiler and type checker" \ "le README reprend le desc de la formule" check_contains "$(cat "$readme")" "https://we-data-ch.github.io/typr.github.io/" \ "le README reprend le homepage de la formule" From 4a71c3d6ca153a7294d37cb012af3c710ef3d344 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 17:14:55 +0200 Subject: [PATCH 48/52] types : ne pas mettre en cache les sous-typages de rigides ; homebrew : caveats avant test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le cache SUBTYPE_CACHE hache tous les génériques pareil et PartialEq les juge égaux : __rigid_1 <: __rigid_0 pouvait être répondu par un T <: U antérieur du même thread (test_bound_return_rejects_another_id, ~1,5 %). Co-Authored-By: Claude Sonnet 5.5 --- R/MyModule.R | 7 +++++++ crates/typr-core/src/components/type/mod.rs | 15 +++++++++++++++ packaging/homebrew/Formula/typr.rb.in | 8 ++++---- 3 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 R/MyModule.R diff --git a/R/MyModule.R b/R/MyModule.R new file mode 100644 index 0000000..b876fb2 --- /dev/null +++ b/R/MyModule.R @@ -0,0 +1,7 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R +MyModule <- new.env(parent = emptyenv()) +local({ + +}) \ No newline at end of file diff --git a/crates/typr-core/src/components/type/mod.rs b/crates/typr-core/src/components/type/mod.rs index 8f8d670..5709379 100644 --- a/crates/typr-core/src/components/type/mod.rs +++ b/crates/typr-core/src/components/type/mod.rs @@ -62,6 +62,14 @@ thread_local! { RefCell::new(HashMap::new()); } +fn mentions_rigid(typ: &Type) -> bool { + let mut found = false; + crate::processes::type_checking::signature_normalization::visit(typ, &mut |t| { + found |= matches!(t, Type::Generic(name, _) if is_rigid_name(name)); + }); + found +} + pub fn clear_subtype_cache() { SUBTYPE_CACHE.with(|c| c.borrow_mut().clear()); } @@ -211,6 +219,13 @@ impl TypeSystem for Type { } fn is_subtype(&self, other: &Type, context: &Context) -> (bool, Option) { + // The cache key hashes every generic alike and `PartialEq` deems any + // two generics equal, so `__rigid_1 <: __rigid_0` would be answered by + // an unrelated earlier `T <: U`. Rigids are distinct unknowns: never + // cache a query that mentions one. + if mentions_rigid(self) || mentions_rigid(other) { + return (self.is_subtype_raw(other, context), None); + } let key = (self.clone(), other.clone()); let cached = SUBTYPE_CACHE.with(|c| c.borrow().get(&key).copied()); if let Some(result) = cached { diff --git a/packaging/homebrew/Formula/typr.rb.in b/packaging/homebrew/Formula/typr.rb.in index 524d2f2..7368b13 100644 --- a/packaging/homebrew/Formula/typr.rb.in +++ b/packaging/homebrew/Formula/typr.rb.in @@ -38,11 +38,11 @@ class Typr < Formula bin.install "typr" end - test do - assert_match version.to_s, shell_output("#{bin}/typr --version") - end - def caveats "TypR is pre-1.0: 0.x releases can change the CLI surface." end + + test do + assert_match version.to_s, shell_output("#{bin}/typr --version") + end end From 13713afed493e405dd41ec727f4eee561438f671 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Wed, 30 Sep 2026 17:20:17 +0200 Subject: [PATCH 49/52] =?UTF-8?q?homebrew=20:=20version=20d=C3=A9duite=20d?= =?UTF-8?q?e=20l'URL=20(brew=20audit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- packaging/homebrew/Formula/typr.rb.in | 2 +- packaging/homebrew/render.sh | 6 ++++-- packaging/homebrew/tests/run-tests.sh | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packaging/homebrew/Formula/typr.rb.in b/packaging/homebrew/Formula/typr.rb.in index 7368b13..33df906 100644 --- a/packaging/homebrew/Formula/typr.rb.in +++ b/packaging/homebrew/Formula/typr.rb.in @@ -4,6 +4,7 @@ # # Gabarit : packaging/homebrew/Formula/typr.rb.in (dépôt we-data-ch/typr) # Généré par : packaging/homebrew/render.sh --tag vX.Y.Z +# Version : @@VERSION@@ (déduite de l'URL par Homebrew, sans `version`) # # La version et les empreintes viennent de `checksums.txt` de la release : elles # ne peuvent donc pas dériver du binaire réellement publié, ce qui arrive dès @@ -12,7 +13,6 @@ class Typr < Formula desc "Typed superset of R — transpiler and type checker" homepage "https://we-data-ch.github.io/typr.github.io/" - version "@@VERSION@@" license "Apache-2.0" livecheck do diff --git a/packaging/homebrew/render.sh b/packaging/homebrew/render.sh index 27f30fe..14dd740 100755 --- a/packaging/homebrew/render.sh +++ b/packaging/homebrew/render.sh @@ -307,8 +307,10 @@ shas=$(grep -cE '^[[:space:]]+sha256 "[0-9a-f]{64}"$' "$TMP/typr.rb" || true) || die "la formule rendue déclare $shas empreintes, attendu $EXPECTED_SHAS. Le gabarit et le rendu ne correspondent plus." -grep -Fq "version \"$VERSION\"" "$TMP/typr.rb" \ - || die "la formule rendue ne déclare pas version \"$VERSION\"." +# Pas de `version` explicite : `brew audit` la juge redondante, Homebrew la +# déduit de l'URL. On vérifie donc que l'URL porte bien la version attendue. +grep -Fq "/download/v$VERSION/" "$TMP/typr.rb" \ + || die "la formule rendue ne pointe pas vers la release v$VERSION." # Le README du tap est dérivé de la formule, pas d'une deuxième source : desc et # homepage sont relus dans ce qui vient d'être écrit, donc les deux fichiers ne diff --git a/packaging/homebrew/tests/run-tests.sh b/packaging/homebrew/tests/run-tests.sh index 812e2af..980bebf 100755 --- a/packaging/homebrew/tests/run-tests.sh +++ b/packaging/homebrew/tests/run-tests.sh @@ -228,7 +228,7 @@ test_render() { check_file "$readme" "README.md est écrit" [ -f "$formula" ] || return 0 - check_contains "$(cat "$formula")" "version \"${TAG#v}\"" "la version est déclarée" + check_contains "$(cat "$formula")" "/download/${TAG}/" "la version est portée par l'URL" check_contains "$(cat "$formula")" "class Typr < Formula" "la classe est Typr" local target @@ -305,7 +305,7 @@ test_pin_and_prerelease() { dir=$(work no-v) out=$("$RENDER" --tag "9.9.9" --checksums "$FIXTURE" --out "$dir" 2>&1) check_rc 0 $? "un tag sans le v initial est accepté" - check_contains "$(cat "$dir/Formula/typr.rb")" 'version "9.9.9"' "la version est rendue sans le v" + check_contains "$(cat "$dir/Formula/typr.rb")" '/download/v9.9.9/' "la version est rendue sans le v" # release.yml marque `-alpha.N` et `-beta.N` prerelease : la formule doit les # accepter, sinon le canal Homebrew s'arrêterait à la première d'entre elles. From edabc5e2be771a271b51a3093f42531ac252f8e0 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Thu, 1 Oct 2026 12:01:14 +0200 Subject: [PATCH 50/52] workspace: pin internal crates to 0.6 ahead of the 0.6.0 release Co-Authored-By: Claude Sonnet 5.5 --- Cargo.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e6655cf..12da1a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,12 +65,12 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" -typr-core = { path = "crates/typr-core", version = "0.5" } -typr-cli = { path = "crates/typr-cli", version = "0.5" } -typr-lsp = { path = "crates/typr-lsp", version = "0.5" } -typr-wasm = { path = "crates/typr-wasm", version = "0.5" } -typr-mcp = { path = "crates/typr-mcp", version = "0.5" } -typr-graph = { path = "crates/typr-graph", version = "0.5" } +typr-core = { path = "crates/typr-core", version = "0.6" } +typr-cli = { path = "crates/typr-cli", version = "0.6" } +typr-lsp = { path = "crates/typr-lsp", version = "0.6" } +typr-wasm = { path = "crates/typr-wasm", version = "0.6" } +typr-mcp = { path = "crates/typr-mcp", version = "0.6" } +typr-graph = { path = "crates/typr-graph", version = "0.6" } [profile.release] opt-level = 3 From 8a0457653a3d8dfa7f3ff7268a878c00622070d9 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Thu, 1 Oct 2026 12:01:32 +0200 Subject: [PATCH 51/52] release v0.6.0 --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- editors/rstudio/DESCRIPTION | 2 +- editors/vscode/package-lock.json | 4 ++-- editors/vscode/package.json | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bfc0313..ef666aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1902,7 +1902,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typr" -version = "0.5.12" +version = "0.6.0" dependencies = [ "typr-cli", "typr-core", @@ -1910,7 +1910,7 @@ dependencies = [ [[package]] name = "typr-cli" -version = "0.5.12" +version = "0.6.0" dependencies = [ "anyhow", "bincode", @@ -1938,7 +1938,7 @@ dependencies = [ [[package]] name = "typr-core" -version = "0.5.12" +version = "0.6.0" dependencies = [ "anyhow", "bincode", @@ -1960,7 +1960,7 @@ dependencies = [ [[package]] name = "typr-graph" -version = "0.5.12" +version = "0.6.0" dependencies = [ "insta", "serde", @@ -1970,7 +1970,7 @@ dependencies = [ [[package]] name = "typr-lsp" -version = "0.5.12" +version = "0.6.0" dependencies = [ "anyhow", "bincode", @@ -1996,7 +1996,7 @@ dependencies = [ [[package]] name = "typr-mcp" -version = "0.5.12" +version = "0.6.0" dependencies = [ "anyhow", "rmcp", @@ -2009,7 +2009,7 @@ dependencies = [ [[package]] name = "typr-wasm" -version = "0.5.12" +version = "0.6.0" dependencies = [ "serde", "serde-wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 12da1a1..c37d795 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ typr-cli.workspace = true typr-core.workspace = true [workspace.package] -version = "0.5.12" +version = "0.6.0" edition = "2021" authors = ["Fabrice Hategekimana "] license = "Apache-2.0" diff --git a/editors/rstudio/DESCRIPTION b/editors/rstudio/DESCRIPTION index 0698d6c..257e389 100644 --- a/editors/rstudio/DESCRIPTION +++ b/editors/rstudio/DESCRIPTION @@ -1,6 +1,6 @@ Package: typr.runner Title: Run TypR from RStudio and Positron -Version: 0.5.12 +Version: 0.6.0 Authors@R: person("Fabrice", "Hategekimana", email = "fab.hatege15@gmail.com", role = c("aut", "cre")) Description: RStudio addins to create, check, build, test and run TypR projects without leaving the IDE. Wraps the TypR compiler binary, which is bundled in diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index 44fcdc3..7f61dc3 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "typr-language", - "version": "0.5.12", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "typr-language", - "version": "0.5.12", + "version": "0.6.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^25.2.3", diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 8f02693..53237b8 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "typr-language", "displayName": "TypR Tooling and Language Support", "description": "Language support for typR - A typed version of R with TypeScript-like typing and Rust-like syntax", - "version": "0.5.12", + "version": "0.6.0", "publisher": "wedata-ch", "license": "Apache-2.0", "engines": { From 29bf4fe576383835d3b384354d6c82d5dee8bec4 Mon Sep 17 00:00:00 2001 From: Fabrice Date: Thu, 1 Oct 2026 12:11:37 +0200 Subject: [PATCH 52/52] typr-graph: emit record fields, interface methods and discovered methods in name order They were read from a HashSet / unordered context, so the block order changed between runs and the walkthrough snapshot failed intermittently in CI. The snapshot is re-frozen in the (now stable) alphabetical order. Co-Authored-By: Claude Sonnet 5.5 --- crates/typr-graph/src/build/blocks.rs | 19 +++++++++++++++---- ...ough_example_builds_without_panicking.snap | 14 +++++++------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/typr-graph/src/build/blocks.rs b/crates/typr-graph/src/build/blocks.rs index 3b10097..9e30384 100644 --- a/crates/typr-graph/src/build/blocks.rs +++ b/crates/typr-graph/src/build/blocks.rs @@ -40,7 +40,7 @@ pub(super) fn alias_name(ty: &Type) -> Option { /// declared `impl`. Shared between `build_type_decl` (its `TypeDecl` outputs) and the type /// relations pass (étape 3's `Satisfies` evidence). pub(super) fn discover_methods(context: &Context, type_name: &str) -> Vec<(String, String, BlockKey)> { - context + let mut methods: Vec<_> = context .variables() .filter_map(|(var, ty)| match ty { Type::Function(params, _, _) => { @@ -58,7 +58,18 @@ pub(super) fn discover_methods(context: &Context, type_name: &str) -> Vec<(Strin } _ => None, }) - .collect() + .collect(); + // `Context::variables()` has no stable order; the graph must. + methods.sort_by(|a, b| a.0.cmp(&b.0)); + methods +} + +/// Record fields and interface methods live in a `HashSet`, whose iteration order changes from one +/// run to the next. The graph (and its snapshots) must not: members are emitted by name. +fn sorted_members(members: &HashSet) -> Vec<&ArgumentType> { + let mut sorted: Vec<&ArgumentType> = members.iter().collect(); + sorted.sort_by_key(|m| safe_argument_name(m)); + sorted } fn body_statements(body: &Lang) -> Vec<&Lang> { @@ -605,7 +616,7 @@ impl<'a> Builder<'a> { name: Option, ) -> PortRef { let mut children = Vec::new(); - for m in methods { + for m in sorted_members(methods) { children.push(self.insert_type_expr_leaf(&key, m)); } self.finish_block( @@ -626,7 +637,7 @@ impl<'a> Builder<'a> { fn build_type_decl(&mut self, target_type: &Type, lang: &Lang, key: BlockKey, name: Option) -> PortRef { let mut children = Vec::new(); if let Type::Record(fields, _) = target_type { - for f in fields { + for f in sorted_members(fields) { children.push(self.insert_type_expr_leaf(&key, f)); } } diff --git a/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap b/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap index d01e298..f0b1cec 100644 --- a/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap +++ b/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap @@ -1,6 +1,6 @@ --- source: crates/typr-graph/tests/walkthrough.rs -assertion_line: 45 +assertion_line: 53 expression: json --- { @@ -27,14 +27,14 @@ expression: json "visibility": "Public" }, { - "name": "show", - "type": "fn(p: Point) -> char", + "name": "norm2", + "type": "fn(p: Point) -> int", "implicit": false, "visibility": "Public" }, { - "name": "norm2", - "type": "fn(p: Point) -> int", + "name": "show", + "type": "fn(p: Point) -> char", "implicit": false, "visibility": "Public" } @@ -976,13 +976,13 @@ expression: json "relations": [ { "kind": "TypePosition", - "from": "val:show", + "from": "val:norm2", "to": "type:Point", "index": 0 }, { "kind": "TypePosition", - "from": "val:norm2", + "from": "val:show", "to": "type:Point", "index": 0 },