Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cases/0062-untyped-r-function-callable/case.toml
Original file line number Diff line number Diff line change
@@ -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"
28 changes: 28 additions & 0 deletions cases/0062-untyped-r-function-callable/expect.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions cases/0062-untyped-r-function-callable/expect.toml
Original file line number Diff line number Diff line change
@@ -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)"
13 changes: 13 additions & 0 deletions cases/0062-untyped-r-function-callable/repro/TypR/main.ty
Original file line number Diff line number Diff line change
@@ -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);
6 changes: 6 additions & 0 deletions cases/0063-untyped-r-function-arity-error/case.toml
Original file line number Diff line number Diff line change
@@ -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"
37 changes: 37 additions & 0 deletions cases/0063-untyped-r-function-arity-error/expect.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions cases/0063-untyped-r-function-arity-error/expect.toml
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions cases/0064-untyped-preloaded-builtin-variadic/case.toml
Original file line number Diff line number Diff line change
@@ -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"
29 changes: 29 additions & 0 deletions cases/0064-untyped-preloaded-builtin-variadic/expect.md
Original file line number Diff line number Diff line change
@@ -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<Type> 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.
11 changes: 11 additions & 0 deletions cases/0064-untyped-preloaded-builtin-variadic/expect.toml
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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);
27 changes: 27 additions & 0 deletions crates/typr-core/src/components/error_message/type_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type>, 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 {
Expand Down Expand Up @@ -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()),
}
}

Expand Down Expand Up @@ -252,6 +260,7 @@ impl TypeError {
TypeError::UnknownUnionVariant(..) => "T041",
TypeError::NoMatchingSignature(..) => "T042",
TypeError::NoDispatchImplementation(..) => "T043",
TypeError::UntypedFunctionArity(..) => "T044",
}
}

Expand Down Expand Up @@ -475,6 +484,12 @@ impl TypeError {
available.iter().map(|t| t.pretty()).collect::<Vec<_>>().join(", ")
)
}
TypeError::UntypedFunctionArity(name, expected, got, _) => {
format!(
"'{}' is an untyped R function taking {} argument(s), called with {}.",
name, expected, got
)
}
}
}
}
Expand Down Expand Up @@ -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::<Vec<_>>().join(", ");
Expand Down
17 changes: 15 additions & 2 deletions crates/typr-core/src/components/type/function_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type> {
Expand Down Expand Up @@ -284,7 +291,13 @@ impl TryFrom<Type> for FunctionType {
fn try_from(value: Type) -> Result<Self, Self::Error> {
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)),
}
}
Expand Down
7 changes: 5 additions & 2 deletions crates/typr-core/src/components/type/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
28 changes: 25 additions & 3 deletions crates/typr-core/src/processes/type_checking/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentType> = 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,
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 5 additions & 3 deletions rfcs/0028-calling-untyped-r-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down