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