diff --git a/.gitignore b/.gitignore index 4cac2d82..aab4c32a 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,9 @@ lcov.info AGENTS.md CLAUDE.md /.claude/ +/.agents/ +/LOCAL/ + # Documentation function_type_checking.md diff --git a/README.md b/README.md index 10b78937..023ca508 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ typr test # run testthat suites typr document # generate .Rd documentation typr pkgdown # build a documentation website typr repl # interactive session +typr tutor # learn TypR step by step, like vimtutor typr lsp # language server (used by the editor extensions) typr mcp # MCP server (exposes the compiler to AI agents) ``` @@ -99,6 +100,12 @@ Tools exposed today (see `crates/typr-mcp`): |---|---| | `check` | Type-checks TypR source, returns `{ok, diagnostics[{code, message}]}` with stable `T0xx`/`S0xx` codes | | `build` | Same as `check`, plus the transpiled R code (`r_code`) — produced even when there are type errors | +| `tutor_lessons` / `tutor_lesson` / `tutor_check` / `tutor_hint` | The TypR tutor's lessons, graded by the compiler — the same course as `typr tutor` | + +The server also offers a `tutor` **prompt**: pick it in your client (in Claude Code, +`/mcp__typr__tutor`) and the assistant becomes a tutor that walks you through the lessons, +grades your attempts without writing them for you, and points you to the documentation +pages each lesson opens onto. Point a client at the `typr` binary with the `mcp` argument. After `cargo install typr`, `typr` is on `PATH`, so a client config (e.g. Claude Desktop's/Claude Code's `mcpServers` @@ -118,6 +125,15 @@ block) just needs: Building from a local checkout instead of installing? Point `command` at `target/debug/typr` (or `target/release/typr`) directly. +## Learning TypR: `typr tutor` + +Like `vimtutor`, `typr tutor` teaches by doing: it creates a `typr-tutor/` folder holding +thirteen short lessons, each a TypR file whose comments explain and whose code you complete. +`typr tutor check` grades your answer with the compiler and, once it passes, lists the +documentation pages to read next. `typr tutor hint`, `list`, `reset N` and `solution N` +do what they say. No AI needed — and if you want one, the MCP server's `tutor` prompt runs +the same lessons. Lessons live in `crates/typr-mcp/data/tutor/`. + ## Repository layout ``` diff --git a/cases/0089-inferred-dataframe-vs-annotation/case.toml b/cases/0089-inferred-dataframe-vs-annotation/case.toml new file mode 100644 index 00000000..8cce82bd --- /dev/null +++ b/cases/0089-inferred-dataframe-vs-annotation/case.toml @@ -0,0 +1,7 @@ +title = "data.frame(...) inferred type does not match a dataframe[N]{...} annotation" +cmd = "build" +layer = "type" +status = "open" +created = "2026-10-01" +origin = "perso" +checked = false diff --git a/cases/0089-inferred-dataframe-vs-annotation/expect.md b/cases/0089-inferred-dataframe-vs-annotation/expect.md new file mode 100644 index 00000000..fb7be753 --- /dev/null +++ b/cases/0089-inferred-dataframe-vs-annotation/expect.md @@ -0,0 +1,9 @@ +`data.frame(units = c(...), price = c(...))` is inferred as `DataFrame[3, {price: num, units: num}]`, +which is rejected where `dataframe[3]{ units: num, price: num }` is expected +("No signature of function 'total' matches this call"). Row count and column names agree; the +mismatch comes from the column types: the inferred side stores `Vec[3, num]` per column, the +annotation `num`. The inferred form should be accepted (a `data.frame(...)` call is the natural +way to build one for R users). + +Code: `type_checking/mod.rs`, `Lang::DataFrame` arm. +Found while checking the landing-page examples (typr.github.io, "What R lets through"). diff --git a/cases/0089-inferred-dataframe-vs-annotation/expect.toml b/cases/0089-inferred-dataframe-vs-annotation/expect.toml new file mode 100644 index 00000000..b0c04bf8 --- /dev/null +++ b/cases/0089-inferred-dataframe-vs-annotation/expect.toml @@ -0,0 +1,4 @@ +# the call type-checks, so the R file is produced and the call is emitted +[[rule]] +file = "R/main.R" +must_contain = "total(sales)" diff --git a/cases/0089-inferred-dataframe-vs-annotation/observed.txt b/cases/0089-inferred-dataframe-vs-annotation/observed.txt new file mode 100644 index 00000000..27285603 --- /dev/null +++ b/cases/0089-inferred-dataframe-vs-annotation/observed.txt @@ -0,0 +1,1159 @@ +# exit=0 + +## stdout/stderr + 0 .Rd file(s) written to man/ +R code successfully generated in the R/ folder + + Parsing... done (9 ms) + Type checking... failed + × Type error: No signature of function 'total' matches this call. + ╭─[TypR/main.ty:8:2] + 7 │ let sales <- data.frame(units = c(1.0, 2.0, 3.0), price = c(5.0, 6.0, 7.0)); + 8 │ total(sales); + · ──┬── + · ╰── Called with 1 argument(s): (DataFrame[3, {"price": num, "units": num}]) + ╰──── + help: 'total' exists but none of its signature(s) accepts these arguments: + (DataFrame[3, {"price": num, "units": num}]) -> num + + Transpiling... done (99 ms) + Writing R files... done (58 ms) + Generating .Rd files... done (0 ms) + Generating vignette fragments... done (0 ms) + Compiling vignette templates... done (0 ms) + Updating NAMESPACE... failed +Warning: NAMESPACE update failed (devtools/roxygen2 may not be installed). + +R version 4.6.1 (2026-06-24) -- "Happy Hop" +Copyright (C) 2026 The R Foundation for Statistical Computing +Platform: x86_64-pc-linux-gnu + +R est un logiciel libre livré sans AUCUNE GARANTIE. +Vous pouvez le redistribuer sous certaines conditions. +Tapez 'license()' ou 'licence()' pour plus de détails. + +R est un projet collaboratif avec de nombreux contributeurs. +Tapez 'contributors()' pour plus d'information et +'citation()' pour la façon de le citer dans les publications. + +Tapez 'demo()' pour des démonstrations, 'help()' pour l'aide +en ligne ou 'help.start()' pour obtenir l'aide au format HTML. +Tapez 'q()' pour quitter R. + +> devtools::document('/tmp/typr-case-1226873-1790866339616664983/work', roclets = c('collate', 'namespace')) + +Erreur dans loadNamespace(x) : aucun package nommé ‘devtools’ n'est trouvé +Appels : loadNamespace -> withRestarts -> withOneRestart -> doWithOneRestart +Exécution arrêtée + + + +## R généré +=== R/generic_functions.R === +#' @include std.R +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +annotate_factor <- function(x, ...) UseMethod('annotate_factor', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as_vec <- function(x, ...) UseMethod('as_vec', x) +#' @export +bsystem2 <- function(x, ...) UseMethod('bsystem2', x) +#' @export +contains <- function(x, ...) UseMethod('contains', x) +#' @export +cvec <- function(x, ...) UseMethod('cvec', x) +#' @export +derive <- function(x, ...) UseMethod('derive', x) +#' @export +dir <- function(x, ...) UseMethod('dir', x) +#' @export +dir__create <- function(x, ...) UseMethod('dir__create', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +dot <- function(x, ...) UseMethod('dot', x) +#' @export +endsWith <- function(x, ...) UseMethod('endsWith', x) +#' @export +exec <- function(x, ...) UseMethod('exec', x) +#' @export +expect <- function(x, ...) UseMethod('expect', x) +#' @export +expect_equal <- function(x, ...) UseMethod('expect_equal', x) +#' @export +expect_false <- function(x, ...) UseMethod('expect_false', x) +#' @export +extend <- function(x, ...) UseMethod('extend', x) +#' @export +factor <- function(x, ...) UseMethod('factor', x) +#' @export +file__copy <- function(x, ...) UseMethod('file__copy', x) +#' @export +file__create <- function(x, ...) UseMethod('file__create', x) +#' @export +file__exists <- function(x, ...) UseMethod('file__exists', x) +#' @export +file__remove <- function(x, ...) UseMethod('file__remove', x) +#' @export +file__rename <- function(x, ...) UseMethod('file__rename', x) +#' @export +filter <- function(x, ...) UseMethod('filter', x) +#' @export +fold <- function(x, ...) UseMethod('fold', x) +#' @export +get <- function(x, ...) UseMethod('get', x) +#' @export +getwd <- function(x, ...) UseMethod('getwd', x) +#' @export +is_none <- function(x, ...) UseMethod('is_none', x) +#' @export +is_some <- function(x, ...) UseMethod('is_some', x) +#' @export +join <- function(x, ...) UseMethod('join', x) +#' @export +levels <- function(x, ...) UseMethod('levels', x) +#' @export +list__files <- function(x, ...) UseMethod('list__files', x) +#' @export +lvec <- function(x, ...) UseMethod('lvec', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +nlevels <- function(x, ...) UseMethod('nlevels', x) +#' @export +plot <- function(x, ...) UseMethod('plot', x) +#' @export +reduce <- function(x, ...) UseMethod('reduce', x) +#' @export +seq <- function(x, ...) UseMethod('seq', x) +#' @export +set <- function(x, ...) UseMethod('set', x) +#' @export +set_at <- function(x, ...) UseMethod('set_at', x) +#' @export +setwd <- function(x, ...) UseMethod('setwd', x) +#' @export +sort <- function(x, ...) UseMethod('sort', x) +#' @export +startsWith <- function(x, ...) UseMethod('startsWith', x) +#' @export +state <- function(x, ...) UseMethod('state', x) +#' @export +sum <- function(x, ...) UseMethod('sum', x) +#' @export +system2 <- function(x, ...) UseMethod('system2', x) +#' @export +t <- function(x, ...) UseMethod('t', x) +#' @export +to_native <- function(x, ...) UseMethod('to_native', x) +#' @export +to_nullable <- function(x, ...) UseMethod('to_nullable', x) +#' @export +total <- function(x, ...) UseMethod('total', x) +#' @export +unique <- function(x, ...) UseMethod('unique', x) +#' @export +unlink <- function(x, ...) UseMethod('unlink', x) +#' @export +unwrap <- function(x, ...) UseMethod('unwrap', x) +#' @export +unwrap_or <- function(x, ...) UseMethod('unwrap_or', x) +#' @export +update <- function(x, ...) UseMethod('update', x) +#' @export +version <- function(x, ...) UseMethod('version', x) + + +=== R/main.R === +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +#' @method total Array0 +`total.Array0` <- (function(sales) { + { + sum(sales$units * sales$price) + } |> + as.Number() +}) |> + as.Generic() + +`sales` <- data.frame( + units = c(1 |> as.Number(), 2 |> as.Number(), 3 |> as.Number()), + price = c(5 |> as.Number(), 6 |> as.Number(), 7 |> as.Number()) +) |> + as.Array1() + +NA + + +=== R/std.R === +# --- Fonctions Système --- + +sys.info <- function() Sys.info() +sys.getenv <- function() Sys.getenv() +sys.setenv <- function(var, val) Sys.setenv(var = val) +sys.time <- function() Sys.time() +sys.date <- function() Sys.Date() +sys.sleep <- function(n) Sys.sleep(n) +sys.which <- function(s) Sys.which(s) +sys.timezone <- function() Sys.timezone() +sys.setlocale <- function() Sys.setlocale() + +# --- Utilitaires de Typage --- + +#' @title Ajout d'une structure de classe +struct <- function(x, new_class, typed_dim = NULL) { + if (is.null(x)) return(NULL) + + # Fusion propre des classes sans doublons + class(x) <- unique(c(class(x), new_class)) + + if (!is.null(typed_dim)) { + attr(x, "typed_dim") <- typed_dim + } + + x +} + +#' @title Changement forcé de type +let_type <- function(x, new_class) { + # On retire les classes actuelles puis on applique le nouveau constructeur + unclass(x) |> new_class() +} + +#' @title Fusion shallow pour le spread operator (`{ ...x, a = 1 }`) +spread <- function(base, override) { + out <- base + for (nm in names(override)) { + out[[nm]] <- override[[nm]] + } + out +} + +#' @title Fusion explicite/spread pour les constructeurs record (`TypeName:{ ...x }`) +typr_spread_record <- function(explicit, spread) { + if (is.null(spread)) { + return(explicit) + } + if (!is.list(spread)) { + stop("`.spread` must be a list (record) in TypR constructors.") + } + merged <- spread + merged[names(explicit)] <- explicit + merged +} + +#' @title Contrat de type runtime pour le mode `typr build --checked` +#' (soundness_transpilation.md Phase A). Jamais un mode de production : un +#' oracle sémantique pour lab/ et cases/. `expected` est soit une chaîne +#' `typeof` simple ("integer"/"double"/"character"/"logical"/"list"), soit +#' un vecteur de classes nommées (tête = classe canonique, reste = les +#' variantes acceptées pour un type union). +typr_assert_type <- function(x, expected, loc, what) { + ok <- if (is.character(expected) && length(expected) > 1L) { + any(vapply(expected, function(cls) inherits(x, cls), logical(1))) + } else { + switch(expected, + integer = is.integer(x) || (is.numeric(x) && all(x == trunc(x))), + double = is.double(x), + character = is.character(x), + logical = is.logical(x), + list = is.list(x), + inherits(x, expected)) + } + if (!isTRUE(ok)) { + stop(sprintf( + "[typr --checked] %s at %s: expected %s, got class <%s> (typeof %s)", + what, loc, paste(expected, collapse = "/"), paste(class(x), collapse = ","), + typeof(x)), call. = FALSE) + } + 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") +apply <- function(X, ...) UseMethod("apply") +reduce <- function(vec, ...) UseMethod("reduce") +fold <- function(vec, ...) UseMethod("fold") +as_vec <- function(x, ...) UseMethod("as_vec") +max <- function(a, ...) UseMethod("max") +min <- function(a, ...) UseMethod("min") +replace <- function(a, ...) UseMethod("replace") +append <- function(a, ...) UseMethod("append") +plot <- function(a, ...) UseMethod("plot") +expect_equal <- function(a, ...) UseMethod("expect_equal") +set <- function(a, ...) UseMethod("set") +update <- function(a, ...) UseMethod("update") +state <- function(a, ...) UseMethod("state") +map <- function(a, ...) UseMethod("map") +derive <- function(a, ...) UseMethod("derive") +unwrap <- function(a, ...) UseMethod("unwrap") +unwrap_or <- function(a, ...) UseMethod("unwrap_or") +expect <- function(a, ...) UseMethod("expect") +is_some <- function(a, ...) UseMethod("is_some") +is_none <- function(a, ...) UseMethod("is_none") +factor <- function(a, ...) UseMethod("factor") +annotate_factor <- function(a, ...) UseMethod("annotate_factor") +nlevels <- function(a, ...) UseMethod("nlevels") + +# User validator hook for record types: `as.T` calls validate() after the +# internal validate_T. Default is identity; users extend it with validate.T. +validate <- function(x, ...) UseMethod("validate") +validate.default <- function(x) x + +# --- Méthodes par défaut et types de base --- + +max.default <- function(a, ...) base::max(a, ...) +min.default <- function(a, ...) base::min(a, ...) +replace.default <- function(a, ...) base::replace(a, ...) +append.default <- function(a, ...) base::append(a, ...) +plot.default <- function(a, ...) base::plot(a, ...) +expect_equal.default <- function(a, ...) testthat::expect_equal(a, ...) +# `cat` is a plain (non-S3-generic) base-R function; the `.default` is both the +# UseMethod fallback and where TypR's own boxing is peeled off each argument so +# `cat("x:", x)` prints bare values, not `attr(,"class")` noise. +cat.default <- function(...) { + args <- lapply(list(...), to_native) + do.call(base::cat, args) + invisible(NULL) +} + +# The string/file/system helpers below (default.ty, file.ty, system.ty) are +# plain, non-S3-generic base-R functions (see r_name_lint's Phase C database, +# soundness_transpilation.md). Without a `.default` fallback, the UseMethod +# stub generic_functions.R emits for each of them clobbers the base function +# with zero method to dispatch to — the exact `nlevels` bug shape, caught for +# all of these at once when the Phase C lint was first run against a fresh +# project. Argument order below mirrors each `.ty` signature (see +# configs/std/default.ty / file.ty / system.ty), which already matches base +# R's own argument order in every case. +nchar.default <- function(a, ...) base::nchar(a, ...) +sd.default <- function(a, ...) base::sd(a, ...) +substr.default <- function(a, b, e, ...) base::substr(a, b, e) +sub.default <- function(a, b, c, ...) base::sub(a, b, c, ...) +gsub.default <- function(a, b, c, ...) base::gsub(a, b, c, ...) +strsplit.default <- function(s, d, ...) base::strsplit(s, d, ...)[[1]] +tolower.default <- function(a, ...) base::tolower(a) +toupper.default <- function(a, ...) base::toupper(a) +startsWith.default <- function(a, b, ...) base::startsWith(a, b) +endsWith.default <- function(a, b, ...) base::endsWith(a, b) +grepl.default <- function(a, b, ...) base::grepl(a, b, ...) +setwd.default <- function(a, ...) base::setwd(a) +unlink.default <- function(a, ...) base::unlink(a, ...) == 0L +system2.default <- function(command, args, stdout, stderr, stdin, ...) { + base::system2(command, args = args, stdout = stdout, stderr = stderr, stdin = stdin, ...) +} + +# `version` is only ever meaningfully defined for `State` (see +# `version.State` below) — there is no sensible base-R fallback, so the +# `.default` gives a clear, localized error instead of the generic "no +# applicable method" UseMethod message. +version.default <- function(x, ...) { + stop(sprintf( + "version() is only defined for State values, got class <%s>", + paste(class(x), collapse = ",")), call. = FALSE) +} + +apply.default <- function(X, ...) { + # Si MARGIN est présent, c'est probablement un appel à base::apply + args <- list(...) + if (is.numeric(X) || is.array(X) || is.matrix(X)) { + return(do.call(base::apply, c(list(X), args))) + } + base::apply(X, ...) +} + +get.default <- function(a, name, ...) { + if (is.list(a)) a[[name]] else base::get(name, pos = a, ...) +} + +# Arrays (and atomic vectors) are already iterable: `as_vec` is the identity, +# R iterates them natively in `for (x in ...)`. +as_vec.default <- function(x, ...) x + +set_at.default <- function(a, i, val) replace(a, i, val) + +get.data <- function(a, name) { + a$data[[1]] +} + +get.list <- function(a, name) { + a$data[[1]][[name]] +} + +get.any <- function(a, name) { + a[[name]] +} + +Integer <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Integer", "integer", "Any", "Generic"), typed_dim = dim) +} + +Number <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Number", "numeric", "Any", "Generic"), typed_dim = dim) +} + +Character <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Character", "character", "Any", "Generic"), typed_dim = dim) +} + +Boolean <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Boolean", "logical", "Any", "Generic"), typed_dim = dim) +} + +as.Generic <- function(x) x |> struct(c('Generic', 'Any')) + +# Opérateur de coalescence nulle utilitaire +`%||%` <- function(a, b) if (!is.null(a)) a else b + +# --- Affichage des types de base --- + +print.Integer <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Character <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Boolean <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Number <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } + +# --- Opérateurs --- + +`%==%.default` <- function(x, y) { + unclass(x) == unclass(y) +} + +# --- Classe typed_vec --- + +typed_vec <- function(..., dim = 0) { + x <- list(...) + + if (length(x) == 0) { + return(structure( + list(data = list()), + class = "typed_vec", + typed_dim = dim + )) + } + + # Vérification si tous les éléments sont déjà des typed_vec + all_typed <- vapply(x, inherits, logical(1), what = "typed_vec") + + if (all(all_typed)) { + combined_data <- unlist(lapply(x, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = dim + )) + } + + structure( + list(data = x), + class = "typed_vec", + typed_dim = dim + ) +} + +length.typed_vec <- function(x) { + length(x$data) +} + +# No length.default: `length` is one of R's internal S3-primitive generics +# (.S3PrimitiveGenerics), not a plain closure-based generic like nchar/sd. +# `length` is also blacklisted from the typed UseMethod-stub codegen +# (BLACKLIST in standard_library.rs), so bare `length()` still resolves to +# the real base primitive, which already falls through to its internal C +# implementation for any class with no matching method — no `.default` +# needed. Adding one that forwards via `base::length(x)` would NOT bypass +# dispatch (internal-generic re-dispatch fires again on the still-classed +# `x` regardless of namespace-qualification) and recurses infinitely for +# every classed value, confirmed with a minimal `length.default <- +# function(x, ...) base::length(x); length(structure(1:3, class="foo"))`. + +`[[.typed_vec` <- function(x, i) { + if (is.character(i)) { + lapply(x$data, `[[`, i) + } else { + x$data[[i]] + } +} + +get.typed_vec <- function(a, name) { + a$data[[1]][[name]] +} + +apply.typed_vec <- function(X, FUN, ...) { + # Appliquer la fonction à chaque élément de data et ré-emballer + typed_vec(lapply(X$data, FUN, ...)) +} + +vec_apply <- function(f, ...) { + args <- list(...) + + # Normalisation en typed_vec + args <- lapply(args, function(x) { + if (!inherits(x, "typed_vec")) typed_vec(x) else x + }) + + lengths <- vapply(args, length, integer(1)) + n <- max(lengths) + + if (any(lengths == 0)) { + return(typed_vec(dim = 0)) + } + + if (any(n %% lengths != 0)) { + stop("Incompatible vector lengths for recycling") + } + + # Recyclage des données + recycled <- lapply(args, function(x) { + if (length(x) == n) x$data else rep(x$data, length.out = n) + }) + + results <- vector("list", n) + for (i in seq_len(n)) { + elements <- lapply(recycled, `[[`, i) + results[[i]] <- do.call(f, elements) + } + + # Si tous les résultats sont des typed_vec, on les combine + all_typed <- vapply(results, inherits, logical(1), what = "typed_vec") + + if (all(all_typed) && length(results) > 0) { + combined_data <- unlist(lapply(results, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = n + )) + } + + structure( + list(data = results), + class = "typed_vec", + typed_dim = n + ) +} + +vec_apply_fun <- function(fun_vec, ...) { + if (!inherits(fun_vec, "typed_vec")) fun_vec <- typed_vec(fun_vec) + + args <- lapply(list(...), function(x) { + if (!inherits(x, "typed_vec")) typed_vec(x) else x + }) + + lengths <- c(length(fun_vec), vapply(args, length, integer(1))) + n <- max(lengths) + + if (any(lengths == 0)) return(typed_vec(dim = 0)) + if (any(n %% lengths != 0)) stop("Incompatible vector lengths") + + funs <- if (length(fun_vec) == n) fun_vec$data else rep(fun_vec$data, length.out = n) + recycled_args <- lapply(args, function(x) { + if (length(x) == n) x$data else rep(x$data, length.out = n) + }) + + results <- vector("list", n) + for (i in seq_len(n)) { + f <- funs[[i]] + params <- lapply(recycled_args, `[[`, i) + results[[i]] <- do.call(f, params) + } + + all_typed <- vapply(results, inherits, logical(1), what = "typed_vec") + + if (all(all_typed) && length(results) > 0) { + combined_data <- unlist(lapply(results, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = n + )) + } + + structure( + list(data = results), + class = "typed_vec", + typed_dim = n + ) +} + +reduce.typed_vec <- function(vec, f, init = NULL) { + if (!inherits(vec, "typed_vec")) vec <- typed_vec(vec) + + n <- length(vec) + if (n == 0) { + if (is.null(init)) stop("Cannot reduce empty vector without initial value") + return(init) + } + + # Utilisation de la fonction Reduce de base pour plus de robustesse + res <- if (is.null(init)) { + Reduce(f, vec$data) + } else { + Reduce(f, vec$data, init = init) + } + + # Si le résultat est un typed_vec, on extrait la donnée (comportement original) + if (inherits(res, "typed_vec")) res <- res$data[[1]] + + structure( + list(data = list(res)), + class = "typed_vec", + typed_dim = 1 + ) +} + +sum.default <- function(x, ...) base::sum(x, ...) + +# Étape ③ : reduce sur un vecteur atomique nu — résultat scalaire, conforme à +# la signature `@reduce: ([#N, T], (T, T) -> T) -> T` (contrairement au quirk +# préexistant de reduce.typed_vec qui ré-emballe dans un typed_vec). +reduce.default <- function(vec, f, init = NULL) { + vals <- as.list(unclass(vec)) + if (length(vals) == 0) { + if (is.null(init)) stop("Cannot reduce empty vector without initial value") + return(init) + } + if (is.null(init)) { + Reduce(function(a, b) f(a, b), vals) + } else { + Reduce(function(a, b) f(a, b), vals, init = init) + } +} + +sum.typed_vec <- function(x, ...) { + reduce(x, `+`) +} + +vec_reduce <- function(vec, f, init = NULL) reduce(vec, f, init) + +fold.typed_vec <- function(vec, init, f, ...) { + Reduce(function(acc, x) f(acc, x), vec$data, init) +} + +# Étape ③ : version atomique directe (un scalaire isolé se comporte comme un +# vecteur de longueur 1, comme avant via le wrap typed_vec). +fold.default <- function(vec, init, f, ...) { + Reduce(function(acc, el) f(acc, el), as.list(unclass(vec)), init) +} + +extend <- function(vec, ...) UseMethod("extend") + +extend.typed_vec <- function(vec, x) { + structure( + list(data = c(vec$data, list(x))), + class = "typed_vec", + typed_dim = length(vec) + 1L + ) +} + +# Étape ③ : extension d'un vecteur atomique nu — c() strip le boxing de +# l'élément ajouté, TypR ré-annote depuis le type statique si nécessaire. +extend.default <- function(vec, x) { + c(vec, x) +} + +vec_extend <- function(vec, x) extend(vec, x) + +print.typed_vec <- function(x, ...) { + dims <- attr(x, "typed_dim") + data <- x$data + n <- length(data) + + if (n == 0) { + cat("typed_vec(0)\n") + return(invisible(x)) + } + + # Si pas de dimensions ou 1D, affichage linéaire amélioré + if (is.null(dims) || length(dims) <= 1 || (length(dims) == 1 && dims == 0)) { + cat("typed_vec [", n, "]\n", sep = "") + for (i in seq_len(n)) { + cat("[", i, "] ", sep = "") + el <- data[[i]] + if (is.function(el)) cat("\n") else print(el) + } + return(invisible(x)) + } + + # --- Affichage Multidimensionnel --- + + # Helper pour formater un élément individuel + format_el <- function(el) { + if (is.function(el)) return("") + if (inherits(el, "typed_vec")) return(paste0("")) + s <- format(el) + if (length(s) > 1) paste0(s[1], "..") else s + } + + # Helper pour afficher une tranche 2D (Matrice) + print_slice2d <- function(slice_data, nr, nc, header = "") { + if (nchar(header) > 0) cat(header, "\n") + + # Pré-formatage pour l'alignement + formatted <- vapply(slice_data, format_el, character(1)) + + # Remplissage par ligne (row first) + mat_form <- matrix(formatted, nrow = nr, ncol = nc, byrow = TRUE) + + # On utilise explicitement base::apply pour éviter le conflit S3 + col_widths <- base::apply(mat_form, 2, function(col) max(nchar(col), 4)) + + # En-tête des colonnes + cat(" ") + for (j in 1:nc) { + cat(sprintf("[%*s,%d]", col_widths[j] - 2, "", j), " ") + } + cat("\n") + + # Lignes + for (i in 1:nr) { + cat(sprintf("[%d,] ", i)) + for (j in 1:nc) { + cat(sprintf("%*s", col_widths[j], mat_form[i, j]), " ") + } + cat("\n") + } + } + + if (length(dims) == 2) { + print_slice2d(data, dims[1], dims[2]) + } else { + # Pour 3D et plus : on itère sur les dimensions supérieures + slice_size <- dims[1] * dims[2] + num_slices <- n / slice_size + + high_dims <- dims[3:length(dims)] + + for (s in 1:num_slices) { + # Calcul des indices pour les dimensions > 2 (basé sur le stockage column-major de R) + # On utilise arrayInd pour retrouver les coordonnées multi-D à partir de l'index linéaire + coords <- arrayInd(s, high_dims) + header <- paste0(", , ", paste(coords, collapse = ", ")) + + start_idx <- (s - 1) * slice_size + 1 + end_idx <- s * slice_size + + print_slice2d(data[start_idx:end_idx], dims[1], dims[2], header = header) + if (s < num_slices) cat("\n") + } + } + + invisible(x) +} + +# --- Option --- +# .Some(v)/.None transpile to structure(list('Some', body = v), class = +# c('Some', 'Option', 'Tag', 'list')) / structure(list('None'), class = +# c('None', 'Option', 'Tag', 'list')) — see the union variant constructor +# pipeline. Both variants share the 'Option' class, so a single method +# dispatching on it (rather than separate .Some/.None methods) covers both. + +unwrap.Option <- function(value, ...) { + if (inherits(value, "Some")) value$body else stop("The value is not unwrappable.") +} + +expect.Option <- function(value, msg, ...) { + if (inherits(value, "Some")) value$body else stop(msg) +} + +unwrap_or.Option <- function(value, alternative, ...) { + if (inherits(value, "Some")) value$body else alternative +} + +is_some.Option <- function(value, ...) inherits(value, "Some") + +is_none.Option <- function(value, ...) inherits(value, "None") + +# --- Factor --- + +# Standard constructor: looks up `x` in `levels` and returns the matching +# 1-based index (delegates the bounds check to annotate_factor). +# `factor` is also a base-R name; without `.default`, generic_functions.R's +# auto-generated UseMethod stub would shadow it with no fallback method. +factor.default <- function(x, levels, ...) { + idx <- match(x, levels) + if (is.na(idx)) { + stop(paste0("'", x, "' is not among Factor levels: [", paste(levels, collapse = ", "), "]")) + } + annotate_factor(idx, levels) +} + +# Annotator: wraps an integer index as an R factor with the given levels. +# Validates that 1 <= x <= length(levels). +annotate_factor.default <- function(x, levels, ...) { + if (x < 1L || x > length(levels)) { + stop(paste0("index ", x, " out of bounds for Factor[", paste(levels, collapse = ", "), "]")) + } + structure(x, class = "factor", levels = levels) +} + +# base::nlevels is a plain function (length(levels(x))), not S3-generic, so +# it has no registered nlevels.factor method for generic_functions.R's +# UseMethod stub to dispatch to. .default replicates base's own definition. +nlevels.default <- function(x, ...) length(levels(x)) + +# --- State --- +# Real shared mutable state, backed by an R environment (reference type). +# `version` auto-increments on every set()/update(). The constructor +# captures class(value) as a per-instance runtime fingerprint; set/update +# validate new values against it (defense-in-depth; the TypR static type +# checker is the real guarantee against misuse in well-typed code). +# +# `state` is defined as `state.default` (not a plain function): the project +# build's generated R/generic_functions.R emits `state <- function(x, ...) +# UseMethod('state', x)` for every typed stdlib name, which would otherwise +# shadow a plain `state <- function(value) {...}` definition here. Dispatch +# on .default catches every input class, since the constructor accepts any T. +state.default <- function(value, ...) { + e <- new.env(parent = emptyenv()) + e$value <- value + e$version <- 0L + e$.tag <- class(value) + structure(e, class = "State") +} + +get.State <- function(s, ...) s$value + +set.State <- function(s, value, ...) { + if (!identical(class(value), s$.tag)) { + stop(paste0( + "State type mismatch: expected ", paste(s$.tag, collapse = "/"), + ", got ", paste(class(value), collapse = "/") + )) + } + s$value <- value + s$version <- s$version + 1L + invisible(NULL) +} + +update.State <- function(s, f, ...) { + set.State(s, f(s$value)) +} + +version.State <- function(s, ...) s$version + +# Étape ③ (unification_arrays.md) : les tableaux d'éléments primitifs sont +# des vecteurs atomiques R nus. typr_collect est le collecteur commun de +# map : résultats tous scalaires « strippables » (atomiques, longueur 1, sans +# classe ou avec seulement le boxing transparent TypR) → vecteur atomique +# (unlist strip le boxing par élément), sinon typed_vec (records, valeurs à +# classe porteuse comme factor/alias opaques, éléments vecteurs). Helpers nus +# sans @signature (même statut que typed_vec/vec_apply/struct : jamais de +# stub UseMethod). +typr_scalar_strippable <- function(el) { + if (!is.atomic(el) || length(el) != 1L) return(FALSE) + cls <- attr(el, "class") + is.null(cls) || all(cls %in% c( + "Integer", "Number", "Character", "Boolean", + "integer", "numeric", "character", "logical", "Any", "Generic" + )) +} + +typr_collect <- function(results, dim = NULL) { + if (length(results) > 0L && + all(vapply(results, typr_scalar_strippable, logical(1))) && + length(unique(vapply(results, typeof, character(1)))) == 1L) { + unlist(results, use.names = FALSE) + } else if (is.null(dim)) { + do.call(typed_vec, results) + } else { + structure(list(data = results), class = "typed_vec", typed_dim = dim) + } +} + +# map composite -> primitif : le type statique du résultat est un tableau +# atomique, la valeur doit suivre (étape ③). +map.typed_vec <- function(a, f, ...) { + typr_collect(lapply(a$data, f), dim = attr(a, "typed_dim")) +} + +map.default <- function(a, f, ...) { + typr_collect(lapply(as.list(unclass(a)), f)) +} + +map.State <- function(s, f, ...) state(f(s$value)) + +filter.typed_vec <- function(a, f, ...) { + structure( + list(data = Filter(f, a$data)), + class = "typed_vec", + typed_dim = attr(a, "typed_dim") + ) +} + +filter.default <- function(a, f, ...) { + keep <- vapply(as.list(unclass(a)), function(el) isTRUE(all(unclass(f(el)))), logical(1)) + unclass(a)[keep] +} + +# --- Eq / Ord (ord.ty) --- +# Atomic element arrays dispatch to base R's own (already S3-generic) +# unique()/sort() — real ordering/equality on int/num/char/bool at C speed. +# Composite (typed_vec) arrays call the element type's own eq()/compare(), +# resolved by ordinary S3 dispatch at each call, exactly like unique.Point +# would if it existed as a dedicated method. +# +# Same footgun as `length.default` above, generalized: `unique`/`sort` are +# themselves plain closure-based S3 generics in base R (`function(x, ...) +# UseMethod("unique")`), so calling `base::unique(a, ...)` from inside our +# OWN `unique.default` re-enters `UseMethod("unique")`, which (namespace +# qualification notwithstanding — S3 dispatch resolves by search path, not +# lexical scope) finds our shadowing `unique.default` again and recurses +# forever (confirmed with a minimal repro: "C stack usage ... too close to +# the limit"). Call the base *method* directly instead of the generic. +unique.default <- function(a, ...) base:::unique.default(a, ...) +sort.default <- function(a, ...) base:::sort.default(a, ...) + +#' @title Invoke an implicit typeclass method (`eq`, `compare`, …) by name +#' +#' `unique.typed_vec`/`sort.typed_vec` live in std.R, sourced by +#' `load_module.R` into their own isolated environment (`parent = baseenv()`) +#' — a project file that `@include`s std.R gets a one-way COPY of its +#' bindings into its own separate environment, so a bare `eq(...)` call from +#' inside a std.R-defined function can never see a user's `eq.Point` (defined +#' only in the project's own environment): the copy doesn't create a shared +#' scope, and the function's closure still points at std.R's own (unrelated) +#' environment. +#' +#' Simply *fetching* the `eq` generic stub via `get(..., envir = parent.frame())` +#' and calling it directly isn't enough either: `eq` itself is a `UseMethod` +#' stub (`function(x, ...) UseMethod("eq")`, from generic_functions.R, a +#' *third*, separately-isolated environment) — dispatching to `eq.Point` +#' happens inside *that* call, whose own calling context is then this +#' function's frame, not the original project caller, so the inner dispatch +#' still fails to find `eq.Point`. Evaluating the call expression itself +#' (`eval(as.call(...), envir = ...)`) inside the caller's environment fixes +#' this at every level: any `UseMethod` dispatch that happens *during* that +#' evaluation resolves against that environment's lexical chain, where the +#' project's own `eq.Point` (or `eq.`) is visible — confirmed with a +#' minimal repro mirroring load_module.R's three-environment split (std.R / +#' generic_functions.R / project file). +typr_call_method <- function(name, ...) { + eval(as.call(c(as.name(name), list(...))), envir = parent.frame(2)) +} + +unique.typed_vec <- function(a, ...) { + out <- list() + for (el in a$data) { + is_dup <- FALSE + for (o in out) { + if (isTRUE(typr_call_method("eq", el, o))) { + is_dup <- TRUE + break + } + } + if (!is_dup) out[[length(out) + 1L]] <- el + } + structure(list(data = out), class = "typed_vec", typed_dim = length(out)) +} + +sort.typed_vec <- function(a, ...) { + data <- a$data + n <- length(data) + if (n >= 2) { + for (i in seq(2L, n)) { + key <- data[[i]] + j <- i - 1L + while (j >= 1L && typr_call_method("compare", data[[j]], key) > 0L) { + data[[j + 1L]] <- data[[j]] + j <- j - 1L + } + data[[j + 1L]] <- key + } + } + structure(list(data = data), class = "typed_vec", typed_dim = attr(a, "typed_dim")) +} + +derive.State <- function(s, f, ...) state(f(s$value)) + +# --- Interop (Niveau 0) --- +# to_native: lowering récursif TypR→R plat à la frontière des packages R externes. +# from_native: lifting R natif→TypR piloté par un descripteur de type ("int","num","char","bool","Any"). +# Ces fonctions sont la fondation du plan d'interop; elles ne modifient pas l'état TypR interne. + +to_native <- function(x, ...) UseMethod("to_native") + +to_native.default <- function(x, ...) x +to_native.Integer <- function(x, ...) as.integer(unclass(x)) +to_native.Number <- function(x, ...) as.numeric(unclass(x)) +to_native.Character <- function(x, ...) as.character(unclass(x)) +to_native.Boolean <- function(x, ...) as.logical(unclass(x)) + +to_native.typed_vec <- function(x, ...) { + if (length(x$data) == 0L) return(list()) + # lambda wrapper: keeps the closure chain so UseMethod can find to_native.* methods + # when called via lapply in load_module's isolated environments (parent=baseenv) + elems <- lapply(x$data, function(el) to_native(el)) + # Collapse to atomic vector when all elements share the same base storage type + if (all(vapply(elems, is.atomic, logical(1))) && + length(unique(vapply(elems, typeof, character(1)))) == 1L) { + do.call(c, elems) + } else { + elems + } +} + +# Handles records (class c("T","list")) and Tag variants not matched by a more specific method. +to_native.list <- function(x, ...) { + # lambda wrapper: keeps the closure chain so UseMethod can find to_native.* methods + # when called via lapply in load_module's isolated environments (parent=baseenv) + result <- lapply(unclass(x), function(el) to_native(el)) + class(result) <- NULL + result +} + +to_native.Some <- function(x, ...) to_native(x$body) +to_native.None <- function(x, ...) NULL +to_native.State <- function(x, ...) to_native(get.State(x)) + +from_native <- function(x, ...) UseMethod("from_native") + +# type_desc: "int" | "num" | "char" | "bool" | "Any" (default = identity) +from_native.default <- function(x, type_desc = "Any", ...) { + if (is.null(x)) return(NULL) + switch(type_desc, + "int" = Integer(as.integer(x)), + "num" = Number(as.numeric(x)), + "char" = Character(as.character(x)), + "bool" = Boolean(as.logical(x)), + x + ) +} + +# Typed lifting helpers: convert a plain R value to a specific TypR type. +# These are the idiomatic way to bring native R values into TypR-typed code. +from_int <- function(x, ...) UseMethod("from_int") +from_num <- function(x, ...) UseMethod("from_num") +from_char <- function(x, ...) UseMethod("from_char") +from_bool <- function(x, ...) UseMethod("from_bool") + +from_int.default <- function(x, ...) Integer(as.integer(x)) +from_num.default <- function(x, ...) Number(as.numeric(x)) +from_char.default <- function(x, ...) Character(as.character(x)) +from_bool.default <- function(x, ...) Boolean(as.logical(x)) + +# --- Interop (Niveau 2) --- +# from_nullable / to_nullable: bridge between R NULL and TypR Option. +# Use from_nullable when an @extern function may return NULL to represent absence. +# Use to_nullable when passing an Option to an external function expecting NULL. + +from_nullable <- function(x, ...) UseMethod("from_nullable") +from_nullable.default <- function(x, ...) { + if (is.null(x)) + structure(list("None"), class = c("None", "Option", "Tag", "list")) + else + structure(list("Some", body = x), class = c("Some", "Option", "Tag", "list")) +} + +to_nullable <- function(x, ...) UseMethod("to_nullable") +to_nullable.Option <- function(x, ...) if (inherits(x, "Some")) x$body else NULL +to_nullable.default <- function(x, ...) x + +# Foreign: identity function that serves as the runtime root for opaque Foreign aliases. +# `type DataFrame <- Foreign` compiles to `DataFrame <- Foreign` in R, so calling +# `DataFrame(x)` returns `x` unchanged — native R objects (data.frame, R6, S4, etc.) +# pass through without boxing. to_native.default already handles Foreign values correctly. +Foreign <- function(x, ...) x + + +=== R/types.R === +#' @include generic_functions.R +as.Array1 <- function(x) x |> struct(c('Array1', 'Any')) +as.Function0 <- function(x) x |> struct(c('Function0', 'Any')) +as.Array0 <- function(x) x |> struct(c('Array0', 'Any')) +as.Ord <- function(x) x |> struct(c('Ord', 'Any')) +as.Eq <- function(x) x |> struct(c('Eq', 'Any')) +as.Foreign <- function(x) x |> struct(c('Foreign', 'Any', 'None')) +as.Function58 <- function(x) x |> struct(c('Function58', 'Any')) +as.State0 <- function(x) x |> struct(c('State0', 'Any')) +as.State <- function(x) x |> struct(c('State', 'Any', 'None')) +as.Function53 <- function(x) x |> struct(c('Function53', 'Any')) +as.Function52 <- function(x) x |> struct(c('Function52', 'Any')) +as.Factor0 <- function(x) x |> struct(c('Factor0', 'Any')) +as.Factor <- function(x) x |> struct(c('Factor', 'integer', 'Any')) +as.Tag0 <- function(x) x |> struct(c('Tag0', 'Any')) +as.Array4 <- function(x) x |> struct(c('Array4', 'numeric', 'Any')) +as.Function38 <- function(x) x |> struct(c('Function38', 'Any')) +as.Union0 <- function(x) x |> struct(c('Union0', 'Any')) +as.Function33 <- function(x) x |> struct(c('Function33', 'Any')) +as.Function31 <- function(x) x |> struct(c('Function31', 'Any')) +as.Function30 <- function(x) x |> struct(c('Function30', 'Any')) +as.Function29 <- function(x) x |> struct(c('Function29', 'Any')) +as.Array1 <- function(x) x |> struct(c('Array1', 'integer', 'Any')) +as.Function19 <- function(x) x |> struct(c('Function19', 'Any')) +as.Function17 <- function(x) x |> struct(c('Function17', 'Any')) +as.Function16 <- function(x) x |> struct(c('Function16', 'Any')) +as.Function15 <- function(x) x |> struct(c('Function15', 'Any')) +as.Function14 <- function(x) x |> struct(c('Function14', 'Any')) +as.Function13 <- function(x) x |> struct(c('Function13', 'Any')) +as.Function11 <- function(x) x |> struct(c('Function11', 'Any')) +as.Function9 <- function(x) x |> struct(c('Function9', 'Any')) +as.Function8 <- function(x) x |> struct(c('Function8', 'Any')) +as.Empty0 <- function(x) x |> struct(c('Empty0', 'Array0', 'numeric', 'Function0', 'Array1', 'Any')) +as.Function2 <- function(x) x |> struct(c('Function2', 'Any')) +as.Function1 <- function(x) x |> struct(c('Function1', 'Any')) +as.Function0 <- function(x) x |> struct(c('Function0', 'Any')) +as.Integer <- function(x) x |> struct(c('Integer', 'integer', 'Any')) +as.Character <- function(x) x |> struct(c('Character', 'character', 'Any')) +as.Number <- function(x) x |> struct(c('Number', 'numeric', 'Any')) +as.Boolean <- function(x) x |> struct(c('Boolean', 'logical', 'Any')) diff --git a/cases/0089-inferred-dataframe-vs-annotation/repro/DESCRIPTION b/cases/0089-inferred-dataframe-vs-annotation/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0089-inferred-dataframe-vs-annotation/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/0089-inferred-dataframe-vs-annotation/repro/NAMESPACE b/cases/0089-inferred-dataframe-vs-annotation/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0089-inferred-dataframe-vs-annotation/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0089-inferred-dataframe-vs-annotation/repro/R/.gitkeep b/cases/0089-inferred-dataframe-vs-annotation/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0089-inferred-dataframe-vs-annotation/repro/TypR/main.ty b/cases/0089-inferred-dataframe-vs-annotation/repro/TypR/main.ty new file mode 100644 index 00000000..861df18d --- /dev/null +++ b/cases/0089-inferred-dataframe-vs-annotation/repro/TypR/main.ty @@ -0,0 +1,8 @@ +let total <- fn( + sales: dataframe[3]{ units: num, price: num } +): num { + sum(sales$units * sales$price) +}; + +let sales <- data.frame(units = c(1.0, 2.0, 3.0), price = c(5.0, 6.0, 7.0)); +total(sales); diff --git a/cases/0090-extern-double-underscore-not-rewritten/case.toml b/cases/0090-extern-double-underscore-not-rewritten/case.toml new file mode 100644 index 00000000..9578a421 --- /dev/null +++ b/cases/0090-extern-double-underscore-not-rewritten/case.toml @@ -0,0 +1,7 @@ +title = "@extern utils::read__csv is emitted verbatim instead of utils::read.csv" +cmd = "build" +layer = "transpile" +status = "open" +created = "2026-10-01" +origin = "perso" +checked = false diff --git a/cases/0090-extern-double-underscore-not-rewritten/expect.md b/cases/0090-extern-double-underscore-not-rewritten/expect.md new file mode 100644 index 00000000..2c0f4e06 --- /dev/null +++ b/cases/0090-extern-double-underscore-not-rewritten/expect.md @@ -0,0 +1,6 @@ +Identifiers cannot contain `.`, so `@extern` names use `__` for it (`weighted__mean`). +Calls to a plain function are rewritten (`weighted.mean`), but a namespaced extern +`utils::read__csv` is emitted as `utils$utils::read__csv(...)`: the `__` is kept, so the call +fails at run time. Expected `utils::read.csv(...)`. + +Code: transpiling of `Lang::Extern` / namespaced function calls. diff --git a/cases/0090-extern-double-underscore-not-rewritten/expect.toml b/cases/0090-extern-double-underscore-not-rewritten/expect.toml new file mode 100644 index 00000000..c08faf27 --- /dev/null +++ b/cases/0090-extern-double-underscore-not-rewritten/expect.toml @@ -0,0 +1,6 @@ +[[rule]] +file = "R/main.R" +must_contain = "utils::read.csv(" +[[rule]] +file = "R/main.R" +must_not_contain = "read__csv" diff --git a/cases/0090-extern-double-underscore-not-rewritten/observed.txt b/cases/0090-extern-double-underscore-not-rewritten/observed.txt new file mode 100644 index 00000000..cc86642f --- /dev/null +++ b/cases/0090-extern-double-underscore-not-rewritten/observed.txt @@ -0,0 +1,1151 @@ +# exit=0 + +## stdout/stderr + 1 .Rd file(s) written to man/ +R code successfully generated in the R/ folder + + Parsing... done (3 ms) + Type checking... failed + × Type error: Undefined variable 'utils' + ╭─[TypR/main.ty:3:13] + 2 │ + 3 │ let raw <- utils::read__csv("sales.csv"); + · ▲ + · ╰── Undefined variable 'utils' + ╰──── + help: - Check the orthograph + - if it's a function check if it's defined for the given type Empty + + × Type error: Unknown element `tils::read__csv("sales.csv");` in `TypR/ + │ main.ty` at `4` + ╭─[TypR/main.ty:3:13] + 2 │ + 3 │ let raw <- utils::read__csv("sales.csv"); + · ▲ + · ╰── Error here + ╰──── + + Transpiling... done (5 ms) + Writing R files...Warning: could not format R output with air (ERROR Failed to format stdin: Failed to parse due to syntax errors.); writing unformatted code + done (50 ms) + Generating .Rd files... done (0 ms) + Generating vignette fragments... done (0 ms) + Compiling vignette templates... done (0 ms) + Updating NAMESPACE... failed +Warning: NAMESPACE update failed (devtools/roxygen2 may not be installed). + +R version 4.6.1 (2026-06-24) -- "Happy Hop" +Copyright (C) 2026 The R Foundation for Statistical Computing +Platform: x86_64-pc-linux-gnu + +R est un logiciel libre livré sans AUCUNE GARANTIE. +Vous pouvez le redistribuer sous certaines conditions. +Tapez 'license()' ou 'licence()' pour plus de détails. + +R est un projet collaboratif avec de nombreux contributeurs. +Tapez 'contributors()' pour plus d'information et +'citation()' pour la façon de le citer dans les publications. + +Tapez 'demo()' pour des démonstrations, 'help()' pour l'aide +en ligne ou 'help.start()' pour obtenir l'aide au format HTML. +Tapez 'q()' pour quitter R. + +> devtools::document('/tmp/typr-case-1226915-1790866340231106060/work', roclets = c('collate', 'namespace')) + +Erreur dans loadNamespace(x) : aucun package nommé ‘devtools’ n'est trouvé +Appels : loadNamespace -> withRestarts -> withOneRestart -> doWithOneRestart +Exécution arrêtée + + + +## R généré +=== R/generic_functions.R === +#' @include std.R +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +annotate_factor <- function(x, ...) UseMethod('annotate_factor', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as_vec <- function(x, ...) UseMethod('as_vec', x) +#' @export +bsystem2 <- function(x, ...) UseMethod('bsystem2', x) +#' @export +contains <- function(x, ...) UseMethod('contains', x) +#' @export +cvec <- function(x, ...) UseMethod('cvec', x) +#' @export +derive <- function(x, ...) UseMethod('derive', x) +#' @export +dir <- function(x, ...) UseMethod('dir', x) +#' @export +dir__create <- function(x, ...) UseMethod('dir__create', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +dot <- function(x, ...) UseMethod('dot', x) +#' @export +endsWith <- function(x, ...) UseMethod('endsWith', x) +#' @export +exec <- function(x, ...) UseMethod('exec', x) +#' @export +expect <- function(x, ...) UseMethod('expect', x) +#' @export +expect_equal <- function(x, ...) UseMethod('expect_equal', x) +#' @export +expect_false <- function(x, ...) UseMethod('expect_false', x) +#' @export +extend <- function(x, ...) UseMethod('extend', x) +#' @export +factor <- function(x, ...) UseMethod('factor', x) +#' @export +file__copy <- function(x, ...) UseMethod('file__copy', x) +#' @export +file__create <- function(x, ...) UseMethod('file__create', x) +#' @export +file__exists <- function(x, ...) UseMethod('file__exists', x) +#' @export +file__remove <- function(x, ...) UseMethod('file__remove', x) +#' @export +file__rename <- function(x, ...) UseMethod('file__rename', x) +#' @export +filter <- function(x, ...) UseMethod('filter', x) +#' @export +fold <- function(x, ...) UseMethod('fold', x) +#' @export +get <- function(x, ...) UseMethod('get', x) +#' @export +getwd <- function(x, ...) UseMethod('getwd', x) +#' @export +is_none <- function(x, ...) UseMethod('is_none', x) +#' @export +is_some <- function(x, ...) UseMethod('is_some', x) +#' @export +join <- function(x, ...) UseMethod('join', x) +#' @export +levels <- function(x, ...) UseMethod('levels', x) +#' @export +list__files <- function(x, ...) UseMethod('list__files', x) +#' @export +lvec <- function(x, ...) UseMethod('lvec', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +nlevels <- function(x, ...) UseMethod('nlevels', x) +#' @export +plot <- function(x, ...) UseMethod('plot', x) +#' @export +reduce <- function(x, ...) UseMethod('reduce', x) +#' @export +seq <- function(x, ...) UseMethod('seq', x) +#' @export +set <- function(x, ...) UseMethod('set', x) +#' @export +set_at <- function(x, ...) UseMethod('set_at', x) +#' @export +setwd <- function(x, ...) UseMethod('setwd', x) +#' @export +sort <- function(x, ...) UseMethod('sort', x) +#' @export +startsWith <- function(x, ...) UseMethod('startsWith', x) +#' @export +state <- function(x, ...) UseMethod('state', x) +#' @export +sum <- function(x, ...) UseMethod('sum', x) +#' @export +system2 <- function(x, ...) UseMethod('system2', x) +#' @export +t <- function(x, ...) UseMethod('t', x) +#' @export +to_native <- function(x, ...) UseMethod('to_native', x) +#' @export +to_nullable <- function(x, ...) UseMethod('to_nullable', x) +#' @export +unique <- function(x, ...) UseMethod('unique', x) +#' @export +unlink <- function(x, ...) UseMethod('unlink', x) +#' @export +unwrap <- function(x, ...) UseMethod('unwrap', x) +#' @export +unwrap_or <- function(x, ...) UseMethod('unwrap_or', x) +#' @export +update <- function(x, ...) UseMethod('update', x) +#' @export +version <- function(x, ...) UseMethod('version', x) + + +=== R/main.R === +#' @include std.R +#' @include generic_functions.R +#' @include types.R + + +`raw` <- utils$utils::read__csv(to_native("sales.csv" |> as.Character())) + + +=== R/std.R === +# --- Fonctions Système --- + +sys.info <- function() Sys.info() +sys.getenv <- function() Sys.getenv() +sys.setenv <- function(var, val) Sys.setenv(var = val) +sys.time <- function() Sys.time() +sys.date <- function() Sys.Date() +sys.sleep <- function(n) Sys.sleep(n) +sys.which <- function(s) Sys.which(s) +sys.timezone <- function() Sys.timezone() +sys.setlocale <- function() Sys.setlocale() + +# --- Utilitaires de Typage --- + +#' @title Ajout d'une structure de classe +struct <- function(x, new_class, typed_dim = NULL) { + if (is.null(x)) return(NULL) + + # Fusion propre des classes sans doublons + class(x) <- unique(c(class(x), new_class)) + + if (!is.null(typed_dim)) { + attr(x, "typed_dim") <- typed_dim + } + + x +} + +#' @title Changement forcé de type +let_type <- function(x, new_class) { + # On retire les classes actuelles puis on applique le nouveau constructeur + unclass(x) |> new_class() +} + +#' @title Fusion shallow pour le spread operator (`{ ...x, a = 1 }`) +spread <- function(base, override) { + out <- base + for (nm in names(override)) { + out[[nm]] <- override[[nm]] + } + out +} + +#' @title Fusion explicite/spread pour les constructeurs record (`TypeName:{ ...x }`) +typr_spread_record <- function(explicit, spread) { + if (is.null(spread)) { + return(explicit) + } + if (!is.list(spread)) { + stop("`.spread` must be a list (record) in TypR constructors.") + } + merged <- spread + merged[names(explicit)] <- explicit + merged +} + +#' @title Contrat de type runtime pour le mode `typr build --checked` +#' (soundness_transpilation.md Phase A). Jamais un mode de production : un +#' oracle sémantique pour lab/ et cases/. `expected` est soit une chaîne +#' `typeof` simple ("integer"/"double"/"character"/"logical"/"list"), soit +#' un vecteur de classes nommées (tête = classe canonique, reste = les +#' variantes acceptées pour un type union). +typr_assert_type <- function(x, expected, loc, what) { + ok <- if (is.character(expected) && length(expected) > 1L) { + any(vapply(expected, function(cls) inherits(x, cls), logical(1))) + } else { + switch(expected, + integer = is.integer(x) || (is.numeric(x) && all(x == trunc(x))), + double = is.double(x), + character = is.character(x), + logical = is.logical(x), + list = is.list(x), + inherits(x, expected)) + } + if (!isTRUE(ok)) { + stop(sprintf( + "[typr --checked] %s at %s: expected %s, got class <%s> (typeof %s)", + what, loc, paste(expected, collapse = "/"), paste(class(x), collapse = ","), + typeof(x)), call. = FALSE) + } + 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") +apply <- function(X, ...) UseMethod("apply") +reduce <- function(vec, ...) UseMethod("reduce") +fold <- function(vec, ...) UseMethod("fold") +as_vec <- function(x, ...) UseMethod("as_vec") +max <- function(a, ...) UseMethod("max") +min <- function(a, ...) UseMethod("min") +replace <- function(a, ...) UseMethod("replace") +append <- function(a, ...) UseMethod("append") +plot <- function(a, ...) UseMethod("plot") +expect_equal <- function(a, ...) UseMethod("expect_equal") +set <- function(a, ...) UseMethod("set") +update <- function(a, ...) UseMethod("update") +state <- function(a, ...) UseMethod("state") +map <- function(a, ...) UseMethod("map") +derive <- function(a, ...) UseMethod("derive") +unwrap <- function(a, ...) UseMethod("unwrap") +unwrap_or <- function(a, ...) UseMethod("unwrap_or") +expect <- function(a, ...) UseMethod("expect") +is_some <- function(a, ...) UseMethod("is_some") +is_none <- function(a, ...) UseMethod("is_none") +factor <- function(a, ...) UseMethod("factor") +annotate_factor <- function(a, ...) UseMethod("annotate_factor") +nlevels <- function(a, ...) UseMethod("nlevels") + +# User validator hook for record types: `as.T` calls validate() after the +# internal validate_T. Default is identity; users extend it with validate.T. +validate <- function(x, ...) UseMethod("validate") +validate.default <- function(x) x + +# --- Méthodes par défaut et types de base --- + +max.default <- function(a, ...) base::max(a, ...) +min.default <- function(a, ...) base::min(a, ...) +replace.default <- function(a, ...) base::replace(a, ...) +append.default <- function(a, ...) base::append(a, ...) +plot.default <- function(a, ...) base::plot(a, ...) +expect_equal.default <- function(a, ...) testthat::expect_equal(a, ...) +# `cat` is a plain (non-S3-generic) base-R function; the `.default` is both the +# UseMethod fallback and where TypR's own boxing is peeled off each argument so +# `cat("x:", x)` prints bare values, not `attr(,"class")` noise. +cat.default <- function(...) { + args <- lapply(list(...), to_native) + do.call(base::cat, args) + invisible(NULL) +} + +# The string/file/system helpers below (default.ty, file.ty, system.ty) are +# plain, non-S3-generic base-R functions (see r_name_lint's Phase C database, +# soundness_transpilation.md). Without a `.default` fallback, the UseMethod +# stub generic_functions.R emits for each of them clobbers the base function +# with zero method to dispatch to — the exact `nlevels` bug shape, caught for +# all of these at once when the Phase C lint was first run against a fresh +# project. Argument order below mirrors each `.ty` signature (see +# configs/std/default.ty / file.ty / system.ty), which already matches base +# R's own argument order in every case. +nchar.default <- function(a, ...) base::nchar(a, ...) +sd.default <- function(a, ...) base::sd(a, ...) +substr.default <- function(a, b, e, ...) base::substr(a, b, e) +sub.default <- function(a, b, c, ...) base::sub(a, b, c, ...) +gsub.default <- function(a, b, c, ...) base::gsub(a, b, c, ...) +strsplit.default <- function(s, d, ...) base::strsplit(s, d, ...)[[1]] +tolower.default <- function(a, ...) base::tolower(a) +toupper.default <- function(a, ...) base::toupper(a) +startsWith.default <- function(a, b, ...) base::startsWith(a, b) +endsWith.default <- function(a, b, ...) base::endsWith(a, b) +grepl.default <- function(a, b, ...) base::grepl(a, b, ...) +setwd.default <- function(a, ...) base::setwd(a) +unlink.default <- function(a, ...) base::unlink(a, ...) == 0L +system2.default <- function(command, args, stdout, stderr, stdin, ...) { + base::system2(command, args = args, stdout = stdout, stderr = stderr, stdin = stdin, ...) +} + +# `version` is only ever meaningfully defined for `State` (see +# `version.State` below) — there is no sensible base-R fallback, so the +# `.default` gives a clear, localized error instead of the generic "no +# applicable method" UseMethod message. +version.default <- function(x, ...) { + stop(sprintf( + "version() is only defined for State values, got class <%s>", + paste(class(x), collapse = ",")), call. = FALSE) +} + +apply.default <- function(X, ...) { + # Si MARGIN est présent, c'est probablement un appel à base::apply + args <- list(...) + if (is.numeric(X) || is.array(X) || is.matrix(X)) { + return(do.call(base::apply, c(list(X), args))) + } + base::apply(X, ...) +} + +get.default <- function(a, name, ...) { + if (is.list(a)) a[[name]] else base::get(name, pos = a, ...) +} + +# Arrays (and atomic vectors) are already iterable: `as_vec` is the identity, +# R iterates them natively in `for (x in ...)`. +as_vec.default <- function(x, ...) x + +set_at.default <- function(a, i, val) replace(a, i, val) + +get.data <- function(a, name) { + a$data[[1]] +} + +get.list <- function(a, name) { + a$data[[1]][[name]] +} + +get.any <- function(a, name) { + a[[name]] +} + +Integer <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Integer", "integer", "Any", "Generic"), typed_dim = dim) +} + +Number <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Number", "numeric", "Any", "Generic"), typed_dim = dim) +} + +Character <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Character", "character", "Any", "Generic"), typed_dim = dim) +} + +Boolean <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Boolean", "logical", "Any", "Generic"), typed_dim = dim) +} + +as.Generic <- function(x) x |> struct(c('Generic', 'Any')) + +# Opérateur de coalescence nulle utilitaire +`%||%` <- function(a, b) if (!is.null(a)) a else b + +# --- Affichage des types de base --- + +print.Integer <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Character <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Boolean <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Number <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } + +# --- Opérateurs --- + +`%==%.default` <- function(x, y) { + unclass(x) == unclass(y) +} + +# --- Classe typed_vec --- + +typed_vec <- function(..., dim = 0) { + x <- list(...) + + if (length(x) == 0) { + return(structure( + list(data = list()), + class = "typed_vec", + typed_dim = dim + )) + } + + # Vérification si tous les éléments sont déjà des typed_vec + all_typed <- vapply(x, inherits, logical(1), what = "typed_vec") + + if (all(all_typed)) { + combined_data <- unlist(lapply(x, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = dim + )) + } + + structure( + list(data = x), + class = "typed_vec", + typed_dim = dim + ) +} + +length.typed_vec <- function(x) { + length(x$data) +} + +# No length.default: `length` is one of R's internal S3-primitive generics +# (.S3PrimitiveGenerics), not a plain closure-based generic like nchar/sd. +# `length` is also blacklisted from the typed UseMethod-stub codegen +# (BLACKLIST in standard_library.rs), so bare `length()` still resolves to +# the real base primitive, which already falls through to its internal C +# implementation for any class with no matching method — no `.default` +# needed. Adding one that forwards via `base::length(x)` would NOT bypass +# dispatch (internal-generic re-dispatch fires again on the still-classed +# `x` regardless of namespace-qualification) and recurses infinitely for +# every classed value, confirmed with a minimal `length.default <- +# function(x, ...) base::length(x); length(structure(1:3, class="foo"))`. + +`[[.typed_vec` <- function(x, i) { + if (is.character(i)) { + lapply(x$data, `[[`, i) + } else { + x$data[[i]] + } +} + +get.typed_vec <- function(a, name) { + a$data[[1]][[name]] +} + +apply.typed_vec <- function(X, FUN, ...) { + # Appliquer la fonction à chaque élément de data et ré-emballer + typed_vec(lapply(X$data, FUN, ...)) +} + +vec_apply <- function(f, ...) { + args <- list(...) + + # Normalisation en typed_vec + args <- lapply(args, function(x) { + if (!inherits(x, "typed_vec")) typed_vec(x) else x + }) + + lengths <- vapply(args, length, integer(1)) + n <- max(lengths) + + if (any(lengths == 0)) { + return(typed_vec(dim = 0)) + } + + if (any(n %% lengths != 0)) { + stop("Incompatible vector lengths for recycling") + } + + # Recyclage des données + recycled <- lapply(args, function(x) { + if (length(x) == n) x$data else rep(x$data, length.out = n) + }) + + results <- vector("list", n) + for (i in seq_len(n)) { + elements <- lapply(recycled, `[[`, i) + results[[i]] <- do.call(f, elements) + } + + # Si tous les résultats sont des typed_vec, on les combine + all_typed <- vapply(results, inherits, logical(1), what = "typed_vec") + + if (all(all_typed) && length(results) > 0) { + combined_data <- unlist(lapply(results, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = n + )) + } + + structure( + list(data = results), + class = "typed_vec", + typed_dim = n + ) +} + +vec_apply_fun <- function(fun_vec, ...) { + if (!inherits(fun_vec, "typed_vec")) fun_vec <- typed_vec(fun_vec) + + args <- lapply(list(...), function(x) { + if (!inherits(x, "typed_vec")) typed_vec(x) else x + }) + + lengths <- c(length(fun_vec), vapply(args, length, integer(1))) + n <- max(lengths) + + if (any(lengths == 0)) return(typed_vec(dim = 0)) + if (any(n %% lengths != 0)) stop("Incompatible vector lengths") + + funs <- if (length(fun_vec) == n) fun_vec$data else rep(fun_vec$data, length.out = n) + recycled_args <- lapply(args, function(x) { + if (length(x) == n) x$data else rep(x$data, length.out = n) + }) + + results <- vector("list", n) + for (i in seq_len(n)) { + f <- funs[[i]] + params <- lapply(recycled_args, `[[`, i) + results[[i]] <- do.call(f, params) + } + + all_typed <- vapply(results, inherits, logical(1), what = "typed_vec") + + if (all(all_typed) && length(results) > 0) { + combined_data <- unlist(lapply(results, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = n + )) + } + + structure( + list(data = results), + class = "typed_vec", + typed_dim = n + ) +} + +reduce.typed_vec <- function(vec, f, init = NULL) { + if (!inherits(vec, "typed_vec")) vec <- typed_vec(vec) + + n <- length(vec) + if (n == 0) { + if (is.null(init)) stop("Cannot reduce empty vector without initial value") + return(init) + } + + # Utilisation de la fonction Reduce de base pour plus de robustesse + res <- if (is.null(init)) { + Reduce(f, vec$data) + } else { + Reduce(f, vec$data, init = init) + } + + # Si le résultat est un typed_vec, on extrait la donnée (comportement original) + if (inherits(res, "typed_vec")) res <- res$data[[1]] + + structure( + list(data = list(res)), + class = "typed_vec", + typed_dim = 1 + ) +} + +sum.default <- function(x, ...) base::sum(x, ...) + +# Étape ③ : reduce sur un vecteur atomique nu — résultat scalaire, conforme à +# la signature `@reduce: ([#N, T], (T, T) -> T) -> T` (contrairement au quirk +# préexistant de reduce.typed_vec qui ré-emballe dans un typed_vec). +reduce.default <- function(vec, f, init = NULL) { + vals <- as.list(unclass(vec)) + if (length(vals) == 0) { + if (is.null(init)) stop("Cannot reduce empty vector without initial value") + return(init) + } + if (is.null(init)) { + Reduce(function(a, b) f(a, b), vals) + } else { + Reduce(function(a, b) f(a, b), vals, init = init) + } +} + +sum.typed_vec <- function(x, ...) { + reduce(x, `+`) +} + +vec_reduce <- function(vec, f, init = NULL) reduce(vec, f, init) + +fold.typed_vec <- function(vec, init, f, ...) { + Reduce(function(acc, x) f(acc, x), vec$data, init) +} + +# Étape ③ : version atomique directe (un scalaire isolé se comporte comme un +# vecteur de longueur 1, comme avant via le wrap typed_vec). +fold.default <- function(vec, init, f, ...) { + Reduce(function(acc, el) f(acc, el), as.list(unclass(vec)), init) +} + +extend <- function(vec, ...) UseMethod("extend") + +extend.typed_vec <- function(vec, x) { + structure( + list(data = c(vec$data, list(x))), + class = "typed_vec", + typed_dim = length(vec) + 1L + ) +} + +# Étape ③ : extension d'un vecteur atomique nu — c() strip le boxing de +# l'élément ajouté, TypR ré-annote depuis le type statique si nécessaire. +extend.default <- function(vec, x) { + c(vec, x) +} + +vec_extend <- function(vec, x) extend(vec, x) + +print.typed_vec <- function(x, ...) { + dims <- attr(x, "typed_dim") + data <- x$data + n <- length(data) + + if (n == 0) { + cat("typed_vec(0)\n") + return(invisible(x)) + } + + # Si pas de dimensions ou 1D, affichage linéaire amélioré + if (is.null(dims) || length(dims) <= 1 || (length(dims) == 1 && dims == 0)) { + cat("typed_vec [", n, "]\n", sep = "") + for (i in seq_len(n)) { + cat("[", i, "] ", sep = "") + el <- data[[i]] + if (is.function(el)) cat("\n") else print(el) + } + return(invisible(x)) + } + + # --- Affichage Multidimensionnel --- + + # Helper pour formater un élément individuel + format_el <- function(el) { + if (is.function(el)) return("") + if (inherits(el, "typed_vec")) return(paste0("")) + s <- format(el) + if (length(s) > 1) paste0(s[1], "..") else s + } + + # Helper pour afficher une tranche 2D (Matrice) + print_slice2d <- function(slice_data, nr, nc, header = "") { + if (nchar(header) > 0) cat(header, "\n") + + # Pré-formatage pour l'alignement + formatted <- vapply(slice_data, format_el, character(1)) + + # Remplissage par ligne (row first) + mat_form <- matrix(formatted, nrow = nr, ncol = nc, byrow = TRUE) + + # On utilise explicitement base::apply pour éviter le conflit S3 + col_widths <- base::apply(mat_form, 2, function(col) max(nchar(col), 4)) + + # En-tête des colonnes + cat(" ") + for (j in 1:nc) { + cat(sprintf("[%*s,%d]", col_widths[j] - 2, "", j), " ") + } + cat("\n") + + # Lignes + for (i in 1:nr) { + cat(sprintf("[%d,] ", i)) + for (j in 1:nc) { + cat(sprintf("%*s", col_widths[j], mat_form[i, j]), " ") + } + cat("\n") + } + } + + if (length(dims) == 2) { + print_slice2d(data, dims[1], dims[2]) + } else { + # Pour 3D et plus : on itère sur les dimensions supérieures + slice_size <- dims[1] * dims[2] + num_slices <- n / slice_size + + high_dims <- dims[3:length(dims)] + + for (s in 1:num_slices) { + # Calcul des indices pour les dimensions > 2 (basé sur le stockage column-major de R) + # On utilise arrayInd pour retrouver les coordonnées multi-D à partir de l'index linéaire + coords <- arrayInd(s, high_dims) + header <- paste0(", , ", paste(coords, collapse = ", ")) + + start_idx <- (s - 1) * slice_size + 1 + end_idx <- s * slice_size + + print_slice2d(data[start_idx:end_idx], dims[1], dims[2], header = header) + if (s < num_slices) cat("\n") + } + } + + invisible(x) +} + +# --- Option --- +# .Some(v)/.None transpile to structure(list('Some', body = v), class = +# c('Some', 'Option', 'Tag', 'list')) / structure(list('None'), class = +# c('None', 'Option', 'Tag', 'list')) — see the union variant constructor +# pipeline. Both variants share the 'Option' class, so a single method +# dispatching on it (rather than separate .Some/.None methods) covers both. + +unwrap.Option <- function(value, ...) { + if (inherits(value, "Some")) value$body else stop("The value is not unwrappable.") +} + +expect.Option <- function(value, msg, ...) { + if (inherits(value, "Some")) value$body else stop(msg) +} + +unwrap_or.Option <- function(value, alternative, ...) { + if (inherits(value, "Some")) value$body else alternative +} + +is_some.Option <- function(value, ...) inherits(value, "Some") + +is_none.Option <- function(value, ...) inherits(value, "None") + +# --- Factor --- + +# Standard constructor: looks up `x` in `levels` and returns the matching +# 1-based index (delegates the bounds check to annotate_factor). +# `factor` is also a base-R name; without `.default`, generic_functions.R's +# auto-generated UseMethod stub would shadow it with no fallback method. +factor.default <- function(x, levels, ...) { + idx <- match(x, levels) + if (is.na(idx)) { + stop(paste0("'", x, "' is not among Factor levels: [", paste(levels, collapse = ", "), "]")) + } + annotate_factor(idx, levels) +} + +# Annotator: wraps an integer index as an R factor with the given levels. +# Validates that 1 <= x <= length(levels). +annotate_factor.default <- function(x, levels, ...) { + if (x < 1L || x > length(levels)) { + stop(paste0("index ", x, " out of bounds for Factor[", paste(levels, collapse = ", "), "]")) + } + structure(x, class = "factor", levels = levels) +} + +# base::nlevels is a plain function (length(levels(x))), not S3-generic, so +# it has no registered nlevels.factor method for generic_functions.R's +# UseMethod stub to dispatch to. .default replicates base's own definition. +nlevels.default <- function(x, ...) length(levels(x)) + +# --- State --- +# Real shared mutable state, backed by an R environment (reference type). +# `version` auto-increments on every set()/update(). The constructor +# captures class(value) as a per-instance runtime fingerprint; set/update +# validate new values against it (defense-in-depth; the TypR static type +# checker is the real guarantee against misuse in well-typed code). +# +# `state` is defined as `state.default` (not a plain function): the project +# build's generated R/generic_functions.R emits `state <- function(x, ...) +# UseMethod('state', x)` for every typed stdlib name, which would otherwise +# shadow a plain `state <- function(value) {...}` definition here. Dispatch +# on .default catches every input class, since the constructor accepts any T. +state.default <- function(value, ...) { + e <- new.env(parent = emptyenv()) + e$value <- value + e$version <- 0L + e$.tag <- class(value) + structure(e, class = "State") +} + +get.State <- function(s, ...) s$value + +set.State <- function(s, value, ...) { + if (!identical(class(value), s$.tag)) { + stop(paste0( + "State type mismatch: expected ", paste(s$.tag, collapse = "/"), + ", got ", paste(class(value), collapse = "/") + )) + } + s$value <- value + s$version <- s$version + 1L + invisible(NULL) +} + +update.State <- function(s, f, ...) { + set.State(s, f(s$value)) +} + +version.State <- function(s, ...) s$version + +# Étape ③ (unification_arrays.md) : les tableaux d'éléments primitifs sont +# des vecteurs atomiques R nus. typr_collect est le collecteur commun de +# map : résultats tous scalaires « strippables » (atomiques, longueur 1, sans +# classe ou avec seulement le boxing transparent TypR) → vecteur atomique +# (unlist strip le boxing par élément), sinon typed_vec (records, valeurs à +# classe porteuse comme factor/alias opaques, éléments vecteurs). Helpers nus +# sans @signature (même statut que typed_vec/vec_apply/struct : jamais de +# stub UseMethod). +typr_scalar_strippable <- function(el) { + if (!is.atomic(el) || length(el) != 1L) return(FALSE) + cls <- attr(el, "class") + is.null(cls) || all(cls %in% c( + "Integer", "Number", "Character", "Boolean", + "integer", "numeric", "character", "logical", "Any", "Generic" + )) +} + +typr_collect <- function(results, dim = NULL) { + if (length(results) > 0L && + all(vapply(results, typr_scalar_strippable, logical(1))) && + length(unique(vapply(results, typeof, character(1)))) == 1L) { + unlist(results, use.names = FALSE) + } else if (is.null(dim)) { + do.call(typed_vec, results) + } else { + structure(list(data = results), class = "typed_vec", typed_dim = dim) + } +} + +# map composite -> primitif : le type statique du résultat est un tableau +# atomique, la valeur doit suivre (étape ③). +map.typed_vec <- function(a, f, ...) { + typr_collect(lapply(a$data, f), dim = attr(a, "typed_dim")) +} + +map.default <- function(a, f, ...) { + typr_collect(lapply(as.list(unclass(a)), f)) +} + +map.State <- function(s, f, ...) state(f(s$value)) + +filter.typed_vec <- function(a, f, ...) { + structure( + list(data = Filter(f, a$data)), + class = "typed_vec", + typed_dim = attr(a, "typed_dim") + ) +} + +filter.default <- function(a, f, ...) { + keep <- vapply(as.list(unclass(a)), function(el) isTRUE(all(unclass(f(el)))), logical(1)) + unclass(a)[keep] +} + +# --- Eq / Ord (ord.ty) --- +# Atomic element arrays dispatch to base R's own (already S3-generic) +# unique()/sort() — real ordering/equality on int/num/char/bool at C speed. +# Composite (typed_vec) arrays call the element type's own eq()/compare(), +# resolved by ordinary S3 dispatch at each call, exactly like unique.Point +# would if it existed as a dedicated method. +# +# Same footgun as `length.default` above, generalized: `unique`/`sort` are +# themselves plain closure-based S3 generics in base R (`function(x, ...) +# UseMethod("unique")`), so calling `base::unique(a, ...)` from inside our +# OWN `unique.default` re-enters `UseMethod("unique")`, which (namespace +# qualification notwithstanding — S3 dispatch resolves by search path, not +# lexical scope) finds our shadowing `unique.default` again and recurses +# forever (confirmed with a minimal repro: "C stack usage ... too close to +# the limit"). Call the base *method* directly instead of the generic. +unique.default <- function(a, ...) base:::unique.default(a, ...) +sort.default <- function(a, ...) base:::sort.default(a, ...) + +#' @title Invoke an implicit typeclass method (`eq`, `compare`, …) by name +#' +#' `unique.typed_vec`/`sort.typed_vec` live in std.R, sourced by +#' `load_module.R` into their own isolated environment (`parent = baseenv()`) +#' — a project file that `@include`s std.R gets a one-way COPY of its +#' bindings into its own separate environment, so a bare `eq(...)` call from +#' inside a std.R-defined function can never see a user's `eq.Point` (defined +#' only in the project's own environment): the copy doesn't create a shared +#' scope, and the function's closure still points at std.R's own (unrelated) +#' environment. +#' +#' Simply *fetching* the `eq` generic stub via `get(..., envir = parent.frame())` +#' and calling it directly isn't enough either: `eq` itself is a `UseMethod` +#' stub (`function(x, ...) UseMethod("eq")`, from generic_functions.R, a +#' *third*, separately-isolated environment) — dispatching to `eq.Point` +#' happens inside *that* call, whose own calling context is then this +#' function's frame, not the original project caller, so the inner dispatch +#' still fails to find `eq.Point`. Evaluating the call expression itself +#' (`eval(as.call(...), envir = ...)`) inside the caller's environment fixes +#' this at every level: any `UseMethod` dispatch that happens *during* that +#' evaluation resolves against that environment's lexical chain, where the +#' project's own `eq.Point` (or `eq.`) is visible — confirmed with a +#' minimal repro mirroring load_module.R's three-environment split (std.R / +#' generic_functions.R / project file). +typr_call_method <- function(name, ...) { + eval(as.call(c(as.name(name), list(...))), envir = parent.frame(2)) +} + +unique.typed_vec <- function(a, ...) { + out <- list() + for (el in a$data) { + is_dup <- FALSE + for (o in out) { + if (isTRUE(typr_call_method("eq", el, o))) { + is_dup <- TRUE + break + } + } + if (!is_dup) out[[length(out) + 1L]] <- el + } + structure(list(data = out), class = "typed_vec", typed_dim = length(out)) +} + +sort.typed_vec <- function(a, ...) { + data <- a$data + n <- length(data) + if (n >= 2) { + for (i in seq(2L, n)) { + key <- data[[i]] + j <- i - 1L + while (j >= 1L && typr_call_method("compare", data[[j]], key) > 0L) { + data[[j + 1L]] <- data[[j]] + j <- j - 1L + } + data[[j + 1L]] <- key + } + } + structure(list(data = data), class = "typed_vec", typed_dim = attr(a, "typed_dim")) +} + +derive.State <- function(s, f, ...) state(f(s$value)) + +# --- Interop (Niveau 0) --- +# to_native: lowering récursif TypR→R plat à la frontière des packages R externes. +# from_native: lifting R natif→TypR piloté par un descripteur de type ("int","num","char","bool","Any"). +# Ces fonctions sont la fondation du plan d'interop; elles ne modifient pas l'état TypR interne. + +to_native <- function(x, ...) UseMethod("to_native") + +to_native.default <- function(x, ...) x +to_native.Integer <- function(x, ...) as.integer(unclass(x)) +to_native.Number <- function(x, ...) as.numeric(unclass(x)) +to_native.Character <- function(x, ...) as.character(unclass(x)) +to_native.Boolean <- function(x, ...) as.logical(unclass(x)) + +to_native.typed_vec <- function(x, ...) { + if (length(x$data) == 0L) return(list()) + # lambda wrapper: keeps the closure chain so UseMethod can find to_native.* methods + # when called via lapply in load_module's isolated environments (parent=baseenv) + elems <- lapply(x$data, function(el) to_native(el)) + # Collapse to atomic vector when all elements share the same base storage type + if (all(vapply(elems, is.atomic, logical(1))) && + length(unique(vapply(elems, typeof, character(1)))) == 1L) { + do.call(c, elems) + } else { + elems + } +} + +# Handles records (class c("T","list")) and Tag variants not matched by a more specific method. +to_native.list <- function(x, ...) { + # lambda wrapper: keeps the closure chain so UseMethod can find to_native.* methods + # when called via lapply in load_module's isolated environments (parent=baseenv) + result <- lapply(unclass(x), function(el) to_native(el)) + class(result) <- NULL + result +} + +to_native.Some <- function(x, ...) to_native(x$body) +to_native.None <- function(x, ...) NULL +to_native.State <- function(x, ...) to_native(get.State(x)) + +from_native <- function(x, ...) UseMethod("from_native") + +# type_desc: "int" | "num" | "char" | "bool" | "Any" (default = identity) +from_native.default <- function(x, type_desc = "Any", ...) { + if (is.null(x)) return(NULL) + switch(type_desc, + "int" = Integer(as.integer(x)), + "num" = Number(as.numeric(x)), + "char" = Character(as.character(x)), + "bool" = Boolean(as.logical(x)), + x + ) +} + +# Typed lifting helpers: convert a plain R value to a specific TypR type. +# These are the idiomatic way to bring native R values into TypR-typed code. +from_int <- function(x, ...) UseMethod("from_int") +from_num <- function(x, ...) UseMethod("from_num") +from_char <- function(x, ...) UseMethod("from_char") +from_bool <- function(x, ...) UseMethod("from_bool") + +from_int.default <- function(x, ...) Integer(as.integer(x)) +from_num.default <- function(x, ...) Number(as.numeric(x)) +from_char.default <- function(x, ...) Character(as.character(x)) +from_bool.default <- function(x, ...) Boolean(as.logical(x)) + +# --- Interop (Niveau 2) --- +# from_nullable / to_nullable: bridge between R NULL and TypR Option. +# Use from_nullable when an @extern function may return NULL to represent absence. +# Use to_nullable when passing an Option to an external function expecting NULL. + +from_nullable <- function(x, ...) UseMethod("from_nullable") +from_nullable.default <- function(x, ...) { + if (is.null(x)) + structure(list("None"), class = c("None", "Option", "Tag", "list")) + else + structure(list("Some", body = x), class = c("Some", "Option", "Tag", "list")) +} + +to_nullable <- function(x, ...) UseMethod("to_nullable") +to_nullable.Option <- function(x, ...) if (inherits(x, "Some")) x$body else NULL +to_nullable.default <- function(x, ...) x + +# Foreign: identity function that serves as the runtime root for opaque Foreign aliases. +# `type DataFrame <- Foreign` compiles to `DataFrame <- Foreign` in R, so calling +# `DataFrame(x)` returns `x` unchanged — native R objects (data.frame, R6, S4, etc.) +# pass through without boxing. to_native.default already handles Foreign values correctly. +Foreign <- function(x, ...) x + + +=== R/types.R === +#' @include generic_functions.R +as.Function0 <- function(x) x |> struct(c('Function0', 'Any')) +as.Ord <- function(x) x |> struct(c('Ord', 'Any')) +as.Eq <- function(x) x |> struct(c('Eq', 'Any')) +as.Foreign <- function(x) x |> struct(c('Foreign', 'Any', 'None')) +as.Function58 <- function(x) x |> struct(c('Function58', 'Any')) +as.State0 <- function(x) x |> struct(c('State0', 'Any')) +as.State <- function(x) x |> struct(c('State', 'Any', 'None')) +as.Function53 <- function(x) x |> struct(c('Function53', 'Any')) +as.Function52 <- function(x) x |> struct(c('Function52', 'Any')) +as.Factor0 <- function(x) x |> struct(c('Factor0', 'Any')) +as.Factor <- function(x) x |> struct(c('Factor', 'integer', 'Any')) +as.Tag0 <- function(x) x |> struct(c('Tag0', 'Any')) +as.Array4 <- function(x) x |> struct(c('Array4', 'numeric', 'Any')) +as.Function38 <- function(x) x |> struct(c('Function38', 'Any')) +as.Union0 <- function(x) x |> struct(c('Union0', 'Any')) +as.Function33 <- function(x) x |> struct(c('Function33', 'Function0', 'Any')) +as.Function31 <- function(x) x |> struct(c('Function31', 'Function0', 'Any')) +as.Function30 <- function(x) x |> struct(c('Function30', 'Any')) +as.Function29 <- function(x) x |> struct(c('Function29', 'Any')) +as.Array1 <- function(x) x |> struct(c('Array1', 'integer', 'Any')) +as.Function19 <- function(x) x |> struct(c('Function19', 'Function0', 'Any')) +as.Function17 <- function(x) x |> struct(c('Function17', 'Any')) +as.Function16 <- function(x) x |> struct(c('Function16', 'Any')) +as.Function15 <- function(x) x |> struct(c('Function15', 'Any')) +as.Function14 <- function(x) x |> struct(c('Function14', 'Any')) +as.Function13 <- function(x) x |> struct(c('Function13', 'Any')) +as.Function11 <- function(x) x |> struct(c('Function11', 'Any')) +as.Function9 <- function(x) x |> struct(c('Function9', 'Any')) +as.Function8 <- function(x) x |> struct(c('Function8', 'Any')) +as.Empty0 <- function(x) x |> struct(c('Empty0', 'character', 'Function0', 'Any')) +as.Function2 <- function(x) x |> struct(c('Function2', 'Any')) +as.Function1 <- function(x) x |> struct(c('Function1', 'Any')) +as.Function0 <- function(x) x |> struct(c('Function0', 'Any')) +as.Integer <- function(x) x |> struct(c('Integer', 'integer', 'Any')) +as.Character <- function(x) x |> struct(c('Character', 'character', 'Any')) +as.Number <- function(x) x |> struct(c('Number', 'numeric', 'Any')) +as.Boolean <- function(x) x |> struct(c('Boolean', 'logical', 'Any')) diff --git a/cases/0090-extern-double-underscore-not-rewritten/repro/DESCRIPTION b/cases/0090-extern-double-underscore-not-rewritten/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0090-extern-double-underscore-not-rewritten/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/0090-extern-double-underscore-not-rewritten/repro/NAMESPACE b/cases/0090-extern-double-underscore-not-rewritten/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0090-extern-double-underscore-not-rewritten/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0090-extern-double-underscore-not-rewritten/repro/R/.gitkeep b/cases/0090-extern-double-underscore-not-rewritten/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0090-extern-double-underscore-not-rewritten/repro/TypR/main.ty b/cases/0090-extern-double-underscore-not-rewritten/repro/TypR/main.ty new file mode 100644 index 00000000..4ac07fe1 --- /dev/null +++ b/cases/0090-extern-double-underscore-not-rewritten/repro/TypR/main.ty @@ -0,0 +1,3 @@ +@extern utils::read__csv: (path: char) -> Any; + +let raw <- utils::read__csv("sales.csv"); diff --git a/cases/0091-as-cast-dataframe-skips-class/case.toml b/cases/0091-as-cast-dataframe-skips-class/case.toml new file mode 100644 index 00000000..636e57f2 --- /dev/null +++ b/cases/0091-as-cast-dataframe-skips-class/case.toml @@ -0,0 +1,7 @@ +title = "as! on a dataframe alias calls validate_X, skipping the S3 class (as.X)" +cmd = "build" +layer = "transpile" +status = "open" +created = "2026-10-01" +origin = "perso" +checked = false diff --git a/cases/0091-as-cast-dataframe-skips-class/expect.md b/cases/0091-as-cast-dataframe-skips-class/expect.md new file mode 100644 index 00000000..4030e906 --- /dev/null +++ b/cases/0091-as-cast-dataframe-skips-class/expect.md @@ -0,0 +1,5 @@ +`raw as! Sales` with `Sales` a `dataframe[N]{...}` alias is emitted as `validate_Sales(raw)`. That +checks the columns but returns the data frame without setting the class, so a later +`with_vat(s)` (UFCS, S3 dispatch on `Sales`) finds no method. Other aliases cast through +`as.`, which validates and sets the class. The same happens for an `@extern` returning a +dataframe type. Expected `as.Sales(raw)`. diff --git a/cases/0091-as-cast-dataframe-skips-class/expect.toml b/cases/0091-as-cast-dataframe-skips-class/expect.toml new file mode 100644 index 00000000..7b42e213 --- /dev/null +++ b/cases/0091-as-cast-dataframe-skips-class/expect.toml @@ -0,0 +1,7 @@ +# the cast must go through as.Sales (sets the S3 class) so UFCS dispatch on Sales works +[[rule]] +file = "R/main.R" +must_contain = "as.Sales(raw)" +[[rule]] +file = "R/main.R" +must_not_contain = "validate_Sales(raw)" diff --git a/cases/0091-as-cast-dataframe-skips-class/observed.txt b/cases/0091-as-cast-dataframe-skips-class/observed.txt new file mode 100644 index 00000000..cab3800e --- /dev/null +++ b/cases/0091-as-cast-dataframe-skips-class/observed.txt @@ -0,0 +1,1200 @@ +# exit=0 + +## stdout/stderr + 0 .Rd file(s) written to man/ +R code successfully generated in the R/ folder + + Parsing... done (6 ms) + Type checking... done (18 ms) + Transpiling... done (50 ms) + Writing R files... done (60 ms) + Generating .Rd files... done (0 ms) + Generating vignette fragments... done (0 ms) + Compiling vignette templates... done (0 ms) + Updating NAMESPACE... failed +Warning: NAMESPACE update failed (devtools/roxygen2 may not be installed). + +R version 4.6.1 (2026-06-24) -- "Happy Hop" +Copyright (C) 2026 The R Foundation for Statistical Computing +Platform: x86_64-pc-linux-gnu + +R est un logiciel libre livré sans AUCUNE GARANTIE. +Vous pouvez le redistribuer sous certaines conditions. +Tapez 'license()' ou 'licence()' pour plus de détails. + +R est un projet collaboratif avec de nombreux contributeurs. +Tapez 'contributors()' pour plus d'information et +'citation()' pour la façon de le citer dans les publications. + +Tapez 'demo()' pour des démonstrations, 'help()' pour l'aide +en ligne ou 'help.start()' pour obtenir l'aide au format HTML. +Tapez 'q()' pour quitter R. + +> devtools::document('/tmp/typr-case-1226951-1790866340726477579/work', roclets = c('collate', 'namespace')) + +Erreur dans loadNamespace(x) : aucun package nommé ‘devtools’ n'est trouvé +Appels : loadNamespace -> withRestarts -> withOneRestart -> doWithOneRestart +Exécution arrêtée + + + +## R généré +=== R/generic_functions.R === +#' @include std.R +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +annotate_factor <- function(x, ...) UseMethod('annotate_factor', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as_vec <- function(x, ...) UseMethod('as_vec', x) +#' @export +bsystem2 <- function(x, ...) UseMethod('bsystem2', x) +#' @export +contains <- function(x, ...) UseMethod('contains', x) +#' @export +cvec <- function(x, ...) UseMethod('cvec', x) +#' @export +derive <- function(x, ...) UseMethod('derive', x) +#' @export +dir <- function(x, ...) UseMethod('dir', x) +#' @export +dir__create <- function(x, ...) UseMethod('dir__create', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +dot <- function(x, ...) UseMethod('dot', x) +#' @export +endsWith <- function(x, ...) UseMethod('endsWith', x) +#' @export +exec <- function(x, ...) UseMethod('exec', x) +#' @export +expect <- function(x, ...) UseMethod('expect', x) +#' @export +expect_equal <- function(x, ...) UseMethod('expect_equal', x) +#' @export +expect_false <- function(x, ...) UseMethod('expect_false', x) +#' @export +extend <- function(x, ...) UseMethod('extend', x) +#' @export +factor <- function(x, ...) UseMethod('factor', x) +#' @export +file__copy <- function(x, ...) UseMethod('file__copy', x) +#' @export +file__create <- function(x, ...) UseMethod('file__create', x) +#' @export +file__exists <- function(x, ...) UseMethod('file__exists', x) +#' @export +file__remove <- function(x, ...) UseMethod('file__remove', x) +#' @export +file__rename <- function(x, ...) UseMethod('file__rename', x) +#' @export +filter <- function(x, ...) UseMethod('filter', x) +#' @export +fold <- function(x, ...) UseMethod('fold', x) +#' @export +get <- function(x, ...) UseMethod('get', x) +#' @export +getwd <- function(x, ...) UseMethod('getwd', x) +#' @export +is_none <- function(x, ...) UseMethod('is_none', x) +#' @export +is_some <- function(x, ...) UseMethod('is_some', x) +#' @export +join <- function(x, ...) UseMethod('join', x) +#' @export +levels <- function(x, ...) UseMethod('levels', x) +#' @export +list__files <- function(x, ...) UseMethod('list__files', x) +#' @export +lvec <- function(x, ...) UseMethod('lvec', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +nlevels <- function(x, ...) UseMethod('nlevels', x) +#' @export +plot <- function(x, ...) UseMethod('plot', x) +#' @export +reduce <- function(x, ...) UseMethod('reduce', x) +#' @export +seq <- function(x, ...) UseMethod('seq', x) +#' @export +set <- function(x, ...) UseMethod('set', x) +#' @export +set_at <- function(x, ...) UseMethod('set_at', x) +#' @export +setwd <- function(x, ...) UseMethod('setwd', x) +#' @export +sort <- function(x, ...) UseMethod('sort', x) +#' @export +startsWith <- function(x, ...) UseMethod('startsWith', x) +#' @export +state <- function(x, ...) UseMethod('state', x) +#' @export +sum <- function(x, ...) UseMethod('sum', x) +#' @export +system2 <- function(x, ...) UseMethod('system2', x) +#' @export +t <- function(x, ...) UseMethod('t', x) +#' @export +to_native <- function(x, ...) UseMethod('to_native', x) +#' @export +to_nullable <- function(x, ...) UseMethod('to_nullable', x) +#' @export +unique <- function(x, ...) UseMethod('unique', x) +#' @export +unlink <- function(x, ...) UseMethod('unlink', x) +#' @export +unwrap <- function(x, ...) UseMethod('unwrap', x) +#' @export +unwrap_or <- function(x, ...) UseMethod('unwrap_or', x) +#' @export +update <- function(x, ...) UseMethod('update', x) +#' @export +version <- function(x, ...) UseMethod('version', x) +#' @export +with_vat <- function(x, ...) UseMethod('with_vat', x) + + +=== R/main.R === +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +Sales <- function(price, units, .spread = NULL) { + explicit <- list() + if (!missing(price)) { + explicit[["price"]] <- price + } + if (!missing(units)) { + explicit[["units"]] <- units + } + x <- typr_spread_record(explicit, .spread) + as.Sales(do.call(data.frame, c(x, list(stringsAsFactors = FALSE)))) +} +as.Sales <- function(x) { + if (!inherits(x, "Sales")) { + class(x) <- c("Sales", "data.frame", "list") + } + x <- validate_Sales(x) + x <- validate(x) + x +} +validate_Sales <- function(x) { + if (!is.data.frame(x)) { + stop("Validation failed for type Sales: expected a data.frame") + } + required_fields <- c("price", "units") + missing_fields <- setdiff(required_fields, names(x)) + if (length(missing_fields) > 0) { + stop(paste0( + "Validation failed for type Sales: missing columns: ", + paste(missing_fields, collapse = ", ") + )) + } + if (!inherits(x[["price"]], "numeric")) { + stop( + "Validation failed for type Sales: column 'price' must be of class numeric" + ) + } + if (!inherits(x[["units"]], "numeric")) { + stop( + "Validation failed for type Sales: column 'units' must be of class numeric" + ) + } + if (nrow(x) != 3) { + stop(paste0( + "Validation failed for type Sales: expected 3 rows, got ", + nrow(x) + )) + } + x +} +#' @method with_vat Sales +`with_vat.Sales` <- (function(s) { + { + 1 |> as.Number() + } |> + as.Number() +}) |> + as.Generic() + +`raw` <- data.frame( + units = c(1 |> as.Number(), 2 |> as.Number(), 3 |> as.Number()), + price = c(5 |> as.Number(), 6 |> as.Number(), 7 |> as.Number()) +) |> + as.Array0() + +`s` <- validate_Sales(raw) + +with_vat(s) + + +=== R/std.R === +# --- Fonctions Système --- + +sys.info <- function() Sys.info() +sys.getenv <- function() Sys.getenv() +sys.setenv <- function(var, val) Sys.setenv(var = val) +sys.time <- function() Sys.time() +sys.date <- function() Sys.Date() +sys.sleep <- function(n) Sys.sleep(n) +sys.which <- function(s) Sys.which(s) +sys.timezone <- function() Sys.timezone() +sys.setlocale <- function() Sys.setlocale() + +# --- Utilitaires de Typage --- + +#' @title Ajout d'une structure de classe +struct <- function(x, new_class, typed_dim = NULL) { + if (is.null(x)) return(NULL) + + # Fusion propre des classes sans doublons + class(x) <- unique(c(class(x), new_class)) + + if (!is.null(typed_dim)) { + attr(x, "typed_dim") <- typed_dim + } + + x +} + +#' @title Changement forcé de type +let_type <- function(x, new_class) { + # On retire les classes actuelles puis on applique le nouveau constructeur + unclass(x) |> new_class() +} + +#' @title Fusion shallow pour le spread operator (`{ ...x, a = 1 }`) +spread <- function(base, override) { + out <- base + for (nm in names(override)) { + out[[nm]] <- override[[nm]] + } + out +} + +#' @title Fusion explicite/spread pour les constructeurs record (`TypeName:{ ...x }`) +typr_spread_record <- function(explicit, spread) { + if (is.null(spread)) { + return(explicit) + } + if (!is.list(spread)) { + stop("`.spread` must be a list (record) in TypR constructors.") + } + merged <- spread + merged[names(explicit)] <- explicit + merged +} + +#' @title Contrat de type runtime pour le mode `typr build --checked` +#' (soundness_transpilation.md Phase A). Jamais un mode de production : un +#' oracle sémantique pour lab/ et cases/. `expected` est soit une chaîne +#' `typeof` simple ("integer"/"double"/"character"/"logical"/"list"), soit +#' un vecteur de classes nommées (tête = classe canonique, reste = les +#' variantes acceptées pour un type union). +typr_assert_type <- function(x, expected, loc, what) { + ok <- if (is.character(expected) && length(expected) > 1L) { + any(vapply(expected, function(cls) inherits(x, cls), logical(1))) + } else { + switch(expected, + integer = is.integer(x) || (is.numeric(x) && all(x == trunc(x))), + double = is.double(x), + character = is.character(x), + logical = is.logical(x), + list = is.list(x), + inherits(x, expected)) + } + if (!isTRUE(ok)) { + stop(sprintf( + "[typr --checked] %s at %s: expected %s, got class <%s> (typeof %s)", + what, loc, paste(expected, collapse = "/"), paste(class(x), collapse = ","), + typeof(x)), call. = FALSE) + } + 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") +apply <- function(X, ...) UseMethod("apply") +reduce <- function(vec, ...) UseMethod("reduce") +fold <- function(vec, ...) UseMethod("fold") +as_vec <- function(x, ...) UseMethod("as_vec") +max <- function(a, ...) UseMethod("max") +min <- function(a, ...) UseMethod("min") +replace <- function(a, ...) UseMethod("replace") +append <- function(a, ...) UseMethod("append") +plot <- function(a, ...) UseMethod("plot") +expect_equal <- function(a, ...) UseMethod("expect_equal") +set <- function(a, ...) UseMethod("set") +update <- function(a, ...) UseMethod("update") +state <- function(a, ...) UseMethod("state") +map <- function(a, ...) UseMethod("map") +derive <- function(a, ...) UseMethod("derive") +unwrap <- function(a, ...) UseMethod("unwrap") +unwrap_or <- function(a, ...) UseMethod("unwrap_or") +expect <- function(a, ...) UseMethod("expect") +is_some <- function(a, ...) UseMethod("is_some") +is_none <- function(a, ...) UseMethod("is_none") +factor <- function(a, ...) UseMethod("factor") +annotate_factor <- function(a, ...) UseMethod("annotate_factor") +nlevels <- function(a, ...) UseMethod("nlevels") + +# User validator hook for record types: `as.T` calls validate() after the +# internal validate_T. Default is identity; users extend it with validate.T. +validate <- function(x, ...) UseMethod("validate") +validate.default <- function(x) x + +# --- Méthodes par défaut et types de base --- + +max.default <- function(a, ...) base::max(a, ...) +min.default <- function(a, ...) base::min(a, ...) +replace.default <- function(a, ...) base::replace(a, ...) +append.default <- function(a, ...) base::append(a, ...) +plot.default <- function(a, ...) base::plot(a, ...) +expect_equal.default <- function(a, ...) testthat::expect_equal(a, ...) +# `cat` is a plain (non-S3-generic) base-R function; the `.default` is both the +# UseMethod fallback and where TypR's own boxing is peeled off each argument so +# `cat("x:", x)` prints bare values, not `attr(,"class")` noise. +cat.default <- function(...) { + args <- lapply(list(...), to_native) + do.call(base::cat, args) + invisible(NULL) +} + +# The string/file/system helpers below (default.ty, file.ty, system.ty) are +# plain, non-S3-generic base-R functions (see r_name_lint's Phase C database, +# soundness_transpilation.md). Without a `.default` fallback, the UseMethod +# stub generic_functions.R emits for each of them clobbers the base function +# with zero method to dispatch to — the exact `nlevels` bug shape, caught for +# all of these at once when the Phase C lint was first run against a fresh +# project. Argument order below mirrors each `.ty` signature (see +# configs/std/default.ty / file.ty / system.ty), which already matches base +# R's own argument order in every case. +nchar.default <- function(a, ...) base::nchar(a, ...) +sd.default <- function(a, ...) base::sd(a, ...) +substr.default <- function(a, b, e, ...) base::substr(a, b, e) +sub.default <- function(a, b, c, ...) base::sub(a, b, c, ...) +gsub.default <- function(a, b, c, ...) base::gsub(a, b, c, ...) +strsplit.default <- function(s, d, ...) base::strsplit(s, d, ...)[[1]] +tolower.default <- function(a, ...) base::tolower(a) +toupper.default <- function(a, ...) base::toupper(a) +startsWith.default <- function(a, b, ...) base::startsWith(a, b) +endsWith.default <- function(a, b, ...) base::endsWith(a, b) +grepl.default <- function(a, b, ...) base::grepl(a, b, ...) +setwd.default <- function(a, ...) base::setwd(a) +unlink.default <- function(a, ...) base::unlink(a, ...) == 0L +system2.default <- function(command, args, stdout, stderr, stdin, ...) { + base::system2(command, args = args, stdout = stdout, stderr = stderr, stdin = stdin, ...) +} + +# `version` is only ever meaningfully defined for `State` (see +# `version.State` below) — there is no sensible base-R fallback, so the +# `.default` gives a clear, localized error instead of the generic "no +# applicable method" UseMethod message. +version.default <- function(x, ...) { + stop(sprintf( + "version() is only defined for State values, got class <%s>", + paste(class(x), collapse = ",")), call. = FALSE) +} + +apply.default <- function(X, ...) { + # Si MARGIN est présent, c'est probablement un appel à base::apply + args <- list(...) + if (is.numeric(X) || is.array(X) || is.matrix(X)) { + return(do.call(base::apply, c(list(X), args))) + } + base::apply(X, ...) +} + +get.default <- function(a, name, ...) { + if (is.list(a)) a[[name]] else base::get(name, pos = a, ...) +} + +# Arrays (and atomic vectors) are already iterable: `as_vec` is the identity, +# R iterates them natively in `for (x in ...)`. +as_vec.default <- function(x, ...) x + +set_at.default <- function(a, i, val) replace(a, i, val) + +get.data <- function(a, name) { + a$data[[1]] +} + +get.list <- function(a, name) { + a$data[[1]][[name]] +} + +get.any <- function(a, name) { + a[[name]] +} + +Integer <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Integer", "integer", "Any", "Generic"), typed_dim = dim) +} + +Number <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Number", "numeric", "Any", "Generic"), typed_dim = dim) +} + +Character <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Character", "character", "Any", "Generic"), typed_dim = dim) +} + +Boolean <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Boolean", "logical", "Any", "Generic"), typed_dim = dim) +} + +as.Generic <- function(x) x |> struct(c('Generic', 'Any')) + +# Opérateur de coalescence nulle utilitaire +`%||%` <- function(a, b) if (!is.null(a)) a else b + +# --- Affichage des types de base --- + +print.Integer <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Character <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Boolean <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Number <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } + +# --- Opérateurs --- + +`%==%.default` <- function(x, y) { + unclass(x) == unclass(y) +} + +# --- Classe typed_vec --- + +typed_vec <- function(..., dim = 0) { + x <- list(...) + + if (length(x) == 0) { + return(structure( + list(data = list()), + class = "typed_vec", + typed_dim = dim + )) + } + + # Vérification si tous les éléments sont déjà des typed_vec + all_typed <- vapply(x, inherits, logical(1), what = "typed_vec") + + if (all(all_typed)) { + combined_data <- unlist(lapply(x, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = dim + )) + } + + structure( + list(data = x), + class = "typed_vec", + typed_dim = dim + ) +} + +length.typed_vec <- function(x) { + length(x$data) +} + +# No length.default: `length` is one of R's internal S3-primitive generics +# (.S3PrimitiveGenerics), not a plain closure-based generic like nchar/sd. +# `length` is also blacklisted from the typed UseMethod-stub codegen +# (BLACKLIST in standard_library.rs), so bare `length()` still resolves to +# the real base primitive, which already falls through to its internal C +# implementation for any class with no matching method — no `.default` +# needed. Adding one that forwards via `base::length(x)` would NOT bypass +# dispatch (internal-generic re-dispatch fires again on the still-classed +# `x` regardless of namespace-qualification) and recurses infinitely for +# every classed value, confirmed with a minimal `length.default <- +# function(x, ...) base::length(x); length(structure(1:3, class="foo"))`. + +`[[.typed_vec` <- function(x, i) { + if (is.character(i)) { + lapply(x$data, `[[`, i) + } else { + x$data[[i]] + } +} + +get.typed_vec <- function(a, name) { + a$data[[1]][[name]] +} + +apply.typed_vec <- function(X, FUN, ...) { + # Appliquer la fonction à chaque élément de data et ré-emballer + typed_vec(lapply(X$data, FUN, ...)) +} + +vec_apply <- function(f, ...) { + args <- list(...) + + # Normalisation en typed_vec + args <- lapply(args, function(x) { + if (!inherits(x, "typed_vec")) typed_vec(x) else x + }) + + lengths <- vapply(args, length, integer(1)) + n <- max(lengths) + + if (any(lengths == 0)) { + return(typed_vec(dim = 0)) + } + + if (any(n %% lengths != 0)) { + stop("Incompatible vector lengths for recycling") + } + + # Recyclage des données + recycled <- lapply(args, function(x) { + if (length(x) == n) x$data else rep(x$data, length.out = n) + }) + + results <- vector("list", n) + for (i in seq_len(n)) { + elements <- lapply(recycled, `[[`, i) + results[[i]] <- do.call(f, elements) + } + + # Si tous les résultats sont des typed_vec, on les combine + all_typed <- vapply(results, inherits, logical(1), what = "typed_vec") + + if (all(all_typed) && length(results) > 0) { + combined_data <- unlist(lapply(results, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = n + )) + } + + structure( + list(data = results), + class = "typed_vec", + typed_dim = n + ) +} + +vec_apply_fun <- function(fun_vec, ...) { + if (!inherits(fun_vec, "typed_vec")) fun_vec <- typed_vec(fun_vec) + + args <- lapply(list(...), function(x) { + if (!inherits(x, "typed_vec")) typed_vec(x) else x + }) + + lengths <- c(length(fun_vec), vapply(args, length, integer(1))) + n <- max(lengths) + + if (any(lengths == 0)) return(typed_vec(dim = 0)) + if (any(n %% lengths != 0)) stop("Incompatible vector lengths") + + funs <- if (length(fun_vec) == n) fun_vec$data else rep(fun_vec$data, length.out = n) + recycled_args <- lapply(args, function(x) { + if (length(x) == n) x$data else rep(x$data, length.out = n) + }) + + results <- vector("list", n) + for (i in seq_len(n)) { + f <- funs[[i]] + params <- lapply(recycled_args, `[[`, i) + results[[i]] <- do.call(f, params) + } + + all_typed <- vapply(results, inherits, logical(1), what = "typed_vec") + + if (all(all_typed) && length(results) > 0) { + combined_data <- unlist(lapply(results, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = n + )) + } + + structure( + list(data = results), + class = "typed_vec", + typed_dim = n + ) +} + +reduce.typed_vec <- function(vec, f, init = NULL) { + if (!inherits(vec, "typed_vec")) vec <- typed_vec(vec) + + n <- length(vec) + if (n == 0) { + if (is.null(init)) stop("Cannot reduce empty vector without initial value") + return(init) + } + + # Utilisation de la fonction Reduce de base pour plus de robustesse + res <- if (is.null(init)) { + Reduce(f, vec$data) + } else { + Reduce(f, vec$data, init = init) + } + + # Si le résultat est un typed_vec, on extrait la donnée (comportement original) + if (inherits(res, "typed_vec")) res <- res$data[[1]] + + structure( + list(data = list(res)), + class = "typed_vec", + typed_dim = 1 + ) +} + +sum.default <- function(x, ...) base::sum(x, ...) + +# Étape ③ : reduce sur un vecteur atomique nu — résultat scalaire, conforme à +# la signature `@reduce: ([#N, T], (T, T) -> T) -> T` (contrairement au quirk +# préexistant de reduce.typed_vec qui ré-emballe dans un typed_vec). +reduce.default <- function(vec, f, init = NULL) { + vals <- as.list(unclass(vec)) + if (length(vals) == 0) { + if (is.null(init)) stop("Cannot reduce empty vector without initial value") + return(init) + } + if (is.null(init)) { + Reduce(function(a, b) f(a, b), vals) + } else { + Reduce(function(a, b) f(a, b), vals, init = init) + } +} + +sum.typed_vec <- function(x, ...) { + reduce(x, `+`) +} + +vec_reduce <- function(vec, f, init = NULL) reduce(vec, f, init) + +fold.typed_vec <- function(vec, init, f, ...) { + Reduce(function(acc, x) f(acc, x), vec$data, init) +} + +# Étape ③ : version atomique directe (un scalaire isolé se comporte comme un +# vecteur de longueur 1, comme avant via le wrap typed_vec). +fold.default <- function(vec, init, f, ...) { + Reduce(function(acc, el) f(acc, el), as.list(unclass(vec)), init) +} + +extend <- function(vec, ...) UseMethod("extend") + +extend.typed_vec <- function(vec, x) { + structure( + list(data = c(vec$data, list(x))), + class = "typed_vec", + typed_dim = length(vec) + 1L + ) +} + +# Étape ③ : extension d'un vecteur atomique nu — c() strip le boxing de +# l'élément ajouté, TypR ré-annote depuis le type statique si nécessaire. +extend.default <- function(vec, x) { + c(vec, x) +} + +vec_extend <- function(vec, x) extend(vec, x) + +print.typed_vec <- function(x, ...) { + dims <- attr(x, "typed_dim") + data <- x$data + n <- length(data) + + if (n == 0) { + cat("typed_vec(0)\n") + return(invisible(x)) + } + + # Si pas de dimensions ou 1D, affichage linéaire amélioré + if (is.null(dims) || length(dims) <= 1 || (length(dims) == 1 && dims == 0)) { + cat("typed_vec [", n, "]\n", sep = "") + for (i in seq_len(n)) { + cat("[", i, "] ", sep = "") + el <- data[[i]] + if (is.function(el)) cat("\n") else print(el) + } + return(invisible(x)) + } + + # --- Affichage Multidimensionnel --- + + # Helper pour formater un élément individuel + format_el <- function(el) { + if (is.function(el)) return("") + if (inherits(el, "typed_vec")) return(paste0("")) + s <- format(el) + if (length(s) > 1) paste0(s[1], "..") else s + } + + # Helper pour afficher une tranche 2D (Matrice) + print_slice2d <- function(slice_data, nr, nc, header = "") { + if (nchar(header) > 0) cat(header, "\n") + + # Pré-formatage pour l'alignement + formatted <- vapply(slice_data, format_el, character(1)) + + # Remplissage par ligne (row first) + mat_form <- matrix(formatted, nrow = nr, ncol = nc, byrow = TRUE) + + # On utilise explicitement base::apply pour éviter le conflit S3 + col_widths <- base::apply(mat_form, 2, function(col) max(nchar(col), 4)) + + # En-tête des colonnes + cat(" ") + for (j in 1:nc) { + cat(sprintf("[%*s,%d]", col_widths[j] - 2, "", j), " ") + } + cat("\n") + + # Lignes + for (i in 1:nr) { + cat(sprintf("[%d,] ", i)) + for (j in 1:nc) { + cat(sprintf("%*s", col_widths[j], mat_form[i, j]), " ") + } + cat("\n") + } + } + + if (length(dims) == 2) { + print_slice2d(data, dims[1], dims[2]) + } else { + # Pour 3D et plus : on itère sur les dimensions supérieures + slice_size <- dims[1] * dims[2] + num_slices <- n / slice_size + + high_dims <- dims[3:length(dims)] + + for (s in 1:num_slices) { + # Calcul des indices pour les dimensions > 2 (basé sur le stockage column-major de R) + # On utilise arrayInd pour retrouver les coordonnées multi-D à partir de l'index linéaire + coords <- arrayInd(s, high_dims) + header <- paste0(", , ", paste(coords, collapse = ", ")) + + start_idx <- (s - 1) * slice_size + 1 + end_idx <- s * slice_size + + print_slice2d(data[start_idx:end_idx], dims[1], dims[2], header = header) + if (s < num_slices) cat("\n") + } + } + + invisible(x) +} + +# --- Option --- +# .Some(v)/.None transpile to structure(list('Some', body = v), class = +# c('Some', 'Option', 'Tag', 'list')) / structure(list('None'), class = +# c('None', 'Option', 'Tag', 'list')) — see the union variant constructor +# pipeline. Both variants share the 'Option' class, so a single method +# dispatching on it (rather than separate .Some/.None methods) covers both. + +unwrap.Option <- function(value, ...) { + if (inherits(value, "Some")) value$body else stop("The value is not unwrappable.") +} + +expect.Option <- function(value, msg, ...) { + if (inherits(value, "Some")) value$body else stop(msg) +} + +unwrap_or.Option <- function(value, alternative, ...) { + if (inherits(value, "Some")) value$body else alternative +} + +is_some.Option <- function(value, ...) inherits(value, "Some") + +is_none.Option <- function(value, ...) inherits(value, "None") + +# --- Factor --- + +# Standard constructor: looks up `x` in `levels` and returns the matching +# 1-based index (delegates the bounds check to annotate_factor). +# `factor` is also a base-R name; without `.default`, generic_functions.R's +# auto-generated UseMethod stub would shadow it with no fallback method. +factor.default <- function(x, levels, ...) { + idx <- match(x, levels) + if (is.na(idx)) { + stop(paste0("'", x, "' is not among Factor levels: [", paste(levels, collapse = ", "), "]")) + } + annotate_factor(idx, levels) +} + +# Annotator: wraps an integer index as an R factor with the given levels. +# Validates that 1 <= x <= length(levels). +annotate_factor.default <- function(x, levels, ...) { + if (x < 1L || x > length(levels)) { + stop(paste0("index ", x, " out of bounds for Factor[", paste(levels, collapse = ", "), "]")) + } + structure(x, class = "factor", levels = levels) +} + +# base::nlevels is a plain function (length(levels(x))), not S3-generic, so +# it has no registered nlevels.factor method for generic_functions.R's +# UseMethod stub to dispatch to. .default replicates base's own definition. +nlevels.default <- function(x, ...) length(levels(x)) + +# --- State --- +# Real shared mutable state, backed by an R environment (reference type). +# `version` auto-increments on every set()/update(). The constructor +# captures class(value) as a per-instance runtime fingerprint; set/update +# validate new values against it (defense-in-depth; the TypR static type +# checker is the real guarantee against misuse in well-typed code). +# +# `state` is defined as `state.default` (not a plain function): the project +# build's generated R/generic_functions.R emits `state <- function(x, ...) +# UseMethod('state', x)` for every typed stdlib name, which would otherwise +# shadow a plain `state <- function(value) {...}` definition here. Dispatch +# on .default catches every input class, since the constructor accepts any T. +state.default <- function(value, ...) { + e <- new.env(parent = emptyenv()) + e$value <- value + e$version <- 0L + e$.tag <- class(value) + structure(e, class = "State") +} + +get.State <- function(s, ...) s$value + +set.State <- function(s, value, ...) { + if (!identical(class(value), s$.tag)) { + stop(paste0( + "State type mismatch: expected ", paste(s$.tag, collapse = "/"), + ", got ", paste(class(value), collapse = "/") + )) + } + s$value <- value + s$version <- s$version + 1L + invisible(NULL) +} + +update.State <- function(s, f, ...) { + set.State(s, f(s$value)) +} + +version.State <- function(s, ...) s$version + +# Étape ③ (unification_arrays.md) : les tableaux d'éléments primitifs sont +# des vecteurs atomiques R nus. typr_collect est le collecteur commun de +# map : résultats tous scalaires « strippables » (atomiques, longueur 1, sans +# classe ou avec seulement le boxing transparent TypR) → vecteur atomique +# (unlist strip le boxing par élément), sinon typed_vec (records, valeurs à +# classe porteuse comme factor/alias opaques, éléments vecteurs). Helpers nus +# sans @signature (même statut que typed_vec/vec_apply/struct : jamais de +# stub UseMethod). +typr_scalar_strippable <- function(el) { + if (!is.atomic(el) || length(el) != 1L) return(FALSE) + cls <- attr(el, "class") + is.null(cls) || all(cls %in% c( + "Integer", "Number", "Character", "Boolean", + "integer", "numeric", "character", "logical", "Any", "Generic" + )) +} + +typr_collect <- function(results, dim = NULL) { + if (length(results) > 0L && + all(vapply(results, typr_scalar_strippable, logical(1))) && + length(unique(vapply(results, typeof, character(1)))) == 1L) { + unlist(results, use.names = FALSE) + } else if (is.null(dim)) { + do.call(typed_vec, results) + } else { + structure(list(data = results), class = "typed_vec", typed_dim = dim) + } +} + +# map composite -> primitif : le type statique du résultat est un tableau +# atomique, la valeur doit suivre (étape ③). +map.typed_vec <- function(a, f, ...) { + typr_collect(lapply(a$data, f), dim = attr(a, "typed_dim")) +} + +map.default <- function(a, f, ...) { + typr_collect(lapply(as.list(unclass(a)), f)) +} + +map.State <- function(s, f, ...) state(f(s$value)) + +filter.typed_vec <- function(a, f, ...) { + structure( + list(data = Filter(f, a$data)), + class = "typed_vec", + typed_dim = attr(a, "typed_dim") + ) +} + +filter.default <- function(a, f, ...) { + keep <- vapply(as.list(unclass(a)), function(el) isTRUE(all(unclass(f(el)))), logical(1)) + unclass(a)[keep] +} + +# --- Eq / Ord (ord.ty) --- +# Atomic element arrays dispatch to base R's own (already S3-generic) +# unique()/sort() — real ordering/equality on int/num/char/bool at C speed. +# Composite (typed_vec) arrays call the element type's own eq()/compare(), +# resolved by ordinary S3 dispatch at each call, exactly like unique.Point +# would if it existed as a dedicated method. +# +# Same footgun as `length.default` above, generalized: `unique`/`sort` are +# themselves plain closure-based S3 generics in base R (`function(x, ...) +# UseMethod("unique")`), so calling `base::unique(a, ...)` from inside our +# OWN `unique.default` re-enters `UseMethod("unique")`, which (namespace +# qualification notwithstanding — S3 dispatch resolves by search path, not +# lexical scope) finds our shadowing `unique.default` again and recurses +# forever (confirmed with a minimal repro: "C stack usage ... too close to +# the limit"). Call the base *method* directly instead of the generic. +unique.default <- function(a, ...) base:::unique.default(a, ...) +sort.default <- function(a, ...) base:::sort.default(a, ...) + +#' @title Invoke an implicit typeclass method (`eq`, `compare`, …) by name +#' +#' `unique.typed_vec`/`sort.typed_vec` live in std.R, sourced by +#' `load_module.R` into their own isolated environment (`parent = baseenv()`) +#' — a project file that `@include`s std.R gets a one-way COPY of its +#' bindings into its own separate environment, so a bare `eq(...)` call from +#' inside a std.R-defined function can never see a user's `eq.Point` (defined +#' only in the project's own environment): the copy doesn't create a shared +#' scope, and the function's closure still points at std.R's own (unrelated) +#' environment. +#' +#' Simply *fetching* the `eq` generic stub via `get(..., envir = parent.frame())` +#' and calling it directly isn't enough either: `eq` itself is a `UseMethod` +#' stub (`function(x, ...) UseMethod("eq")`, from generic_functions.R, a +#' *third*, separately-isolated environment) — dispatching to `eq.Point` +#' happens inside *that* call, whose own calling context is then this +#' function's frame, not the original project caller, so the inner dispatch +#' still fails to find `eq.Point`. Evaluating the call expression itself +#' (`eval(as.call(...), envir = ...)`) inside the caller's environment fixes +#' this at every level: any `UseMethod` dispatch that happens *during* that +#' evaluation resolves against that environment's lexical chain, where the +#' project's own `eq.Point` (or `eq.`) is visible — confirmed with a +#' minimal repro mirroring load_module.R's three-environment split (std.R / +#' generic_functions.R / project file). +typr_call_method <- function(name, ...) { + eval(as.call(c(as.name(name), list(...))), envir = parent.frame(2)) +} + +unique.typed_vec <- function(a, ...) { + out <- list() + for (el in a$data) { + is_dup <- FALSE + for (o in out) { + if (isTRUE(typr_call_method("eq", el, o))) { + is_dup <- TRUE + break + } + } + if (!is_dup) out[[length(out) + 1L]] <- el + } + structure(list(data = out), class = "typed_vec", typed_dim = length(out)) +} + +sort.typed_vec <- function(a, ...) { + data <- a$data + n <- length(data) + if (n >= 2) { + for (i in seq(2L, n)) { + key <- data[[i]] + j <- i - 1L + while (j >= 1L && typr_call_method("compare", data[[j]], key) > 0L) { + data[[j + 1L]] <- data[[j]] + j <- j - 1L + } + data[[j + 1L]] <- key + } + } + structure(list(data = data), class = "typed_vec", typed_dim = attr(a, "typed_dim")) +} + +derive.State <- function(s, f, ...) state(f(s$value)) + +# --- Interop (Niveau 0) --- +# to_native: lowering récursif TypR→R plat à la frontière des packages R externes. +# from_native: lifting R natif→TypR piloté par un descripteur de type ("int","num","char","bool","Any"). +# Ces fonctions sont la fondation du plan d'interop; elles ne modifient pas l'état TypR interne. + +to_native <- function(x, ...) UseMethod("to_native") + +to_native.default <- function(x, ...) x +to_native.Integer <- function(x, ...) as.integer(unclass(x)) +to_native.Number <- function(x, ...) as.numeric(unclass(x)) +to_native.Character <- function(x, ...) as.character(unclass(x)) +to_native.Boolean <- function(x, ...) as.logical(unclass(x)) + +to_native.typed_vec <- function(x, ...) { + if (length(x$data) == 0L) return(list()) + # lambda wrapper: keeps the closure chain so UseMethod can find to_native.* methods + # when called via lapply in load_module's isolated environments (parent=baseenv) + elems <- lapply(x$data, function(el) to_native(el)) + # Collapse to atomic vector when all elements share the same base storage type + if (all(vapply(elems, is.atomic, logical(1))) && + length(unique(vapply(elems, typeof, character(1)))) == 1L) { + do.call(c, elems) + } else { + elems + } +} + +# Handles records (class c("T","list")) and Tag variants not matched by a more specific method. +to_native.list <- function(x, ...) { + # lambda wrapper: keeps the closure chain so UseMethod can find to_native.* methods + # when called via lapply in load_module's isolated environments (parent=baseenv) + result <- lapply(unclass(x), function(el) to_native(el)) + class(result) <- NULL + result +} + +to_native.Some <- function(x, ...) to_native(x$body) +to_native.None <- function(x, ...) NULL +to_native.State <- function(x, ...) to_native(get.State(x)) + +from_native <- function(x, ...) UseMethod("from_native") + +# type_desc: "int" | "num" | "char" | "bool" | "Any" (default = identity) +from_native.default <- function(x, type_desc = "Any", ...) { + if (is.null(x)) return(NULL) + switch(type_desc, + "int" = Integer(as.integer(x)), + "num" = Number(as.numeric(x)), + "char" = Character(as.character(x)), + "bool" = Boolean(as.logical(x)), + x + ) +} + +# Typed lifting helpers: convert a plain R value to a specific TypR type. +# These are the idiomatic way to bring native R values into TypR-typed code. +from_int <- function(x, ...) UseMethod("from_int") +from_num <- function(x, ...) UseMethod("from_num") +from_char <- function(x, ...) UseMethod("from_char") +from_bool <- function(x, ...) UseMethod("from_bool") + +from_int.default <- function(x, ...) Integer(as.integer(x)) +from_num.default <- function(x, ...) Number(as.numeric(x)) +from_char.default <- function(x, ...) Character(as.character(x)) +from_bool.default <- function(x, ...) Boolean(as.logical(x)) + +# --- Interop (Niveau 2) --- +# from_nullable / to_nullable: bridge between R NULL and TypR Option. +# Use from_nullable when an @extern function may return NULL to represent absence. +# Use to_nullable when passing an Option to an external function expecting NULL. + +from_nullable <- function(x, ...) UseMethod("from_nullable") +from_nullable.default <- function(x, ...) { + if (is.null(x)) + structure(list("None"), class = c("None", "Option", "Tag", "list")) + else + structure(list("Some", body = x), class = c("Some", "Option", "Tag", "list")) +} + +to_nullable <- function(x, ...) UseMethod("to_nullable") +to_nullable.Option <- function(x, ...) if (inherits(x, "Some")) x$body else NULL +to_nullable.default <- function(x, ...) x + +# Foreign: identity function that serves as the runtime root for opaque Foreign aliases. +# `type DataFrame <- Foreign` compiles to `DataFrame <- Foreign` in R, so calling +# `DataFrame(x)` returns `x` unchanged — native R objects (data.frame, R6, S4, etc.) +# pass through without boxing. to_native.default already handles Foreign values correctly. +Foreign <- function(x, ...) x + + +=== R/types.R === +#' @include generic_functions.R +as.Array0 <- function(x) x |> struct(c('Array0', 'Any')) +as.Function0 <- function(x) x |> struct(c('Function0', 'Any')) +as.Sales <- function(x) x |> struct(c('Sales', 'Any')) +as.Ord <- function(x) x |> struct(c('Ord', 'Any')) +as.Eq <- function(x) x |> struct(c('Eq', 'Any')) +as.Foreign <- function(x) x |> struct(c('Foreign', 'Any', 'None')) +as.Function58 <- function(x) x |> struct(c('Function58', 'Any')) +as.State0 <- function(x) x |> struct(c('State0', 'Any')) +as.State <- function(x) x |> struct(c('State', 'Any', 'None')) +as.Function53 <- function(x) x |> struct(c('Function53', 'Any')) +as.Function52 <- function(x) x |> struct(c('Function52', 'Any')) +as.Factor0 <- function(x) x |> struct(c('Factor0', 'Any')) +as.Factor <- function(x) x |> struct(c('Factor', 'integer', 'Any')) +as.Tag0 <- function(x) x |> struct(c('Tag0', 'Any')) +as.Array4 <- function(x) x |> struct(c('Array4', 'numeric', 'Any')) +as.Function38 <- function(x) x |> struct(c('Function38', 'Any')) +as.Union0 <- function(x) x |> struct(c('Union0', 'Any')) +as.Function33 <- function(x) x |> struct(c('Function33', 'Any')) +as.Function31 <- function(x) x |> struct(c('Function31', 'Any')) +as.Function30 <- function(x) x |> struct(c('Function30', 'Any')) +as.Function29 <- function(x) x |> struct(c('Function29', 'Any')) +as.Array1 <- function(x) x |> struct(c('Array1', 'integer', 'Any')) +as.Function19 <- function(x) x |> struct(c('Function19', 'Any')) +as.Function17 <- function(x) x |> struct(c('Function17', 'Any')) +as.Function16 <- function(x) x |> struct(c('Function16', 'Any')) +as.Function15 <- function(x) x |> struct(c('Function15', 'Any')) +as.Function14 <- function(x) x |> struct(c('Function14', 'Any')) +as.Function13 <- function(x) x |> struct(c('Function13', 'Any')) +as.Function11 <- function(x) x |> struct(c('Function11', 'Any')) +as.Function9 <- function(x) x |> struct(c('Function9', 'Any')) +as.Function8 <- function(x) x |> struct(c('Function8', 'Any')) +as.Empty0 <- function(x) x |> struct(c('Empty0', 'Sales', 'numeric', 'Function0', 'Array0', 'Any')) +as.Function2 <- function(x) x |> struct(c('Function2', 'Any')) +as.Function1 <- function(x) x |> struct(c('Function1', 'Any')) +as.Function0 <- function(x) x |> struct(c('Function0', 'Any')) +as.Integer <- function(x) x |> struct(c('Integer', 'integer', 'Any')) +as.Character <- function(x) x |> struct(c('Character', 'character', 'Any')) +as.Number <- function(x) x |> struct(c('Number', 'numeric', 'Any')) +as.Boolean <- function(x) x |> struct(c('Boolean', 'logical', 'Any')) diff --git a/cases/0091-as-cast-dataframe-skips-class/repro/DESCRIPTION b/cases/0091-as-cast-dataframe-skips-class/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0091-as-cast-dataframe-skips-class/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/0091-as-cast-dataframe-skips-class/repro/NAMESPACE b/cases/0091-as-cast-dataframe-skips-class/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0091-as-cast-dataframe-skips-class/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0091-as-cast-dataframe-skips-class/repro/R/.gitkeep b/cases/0091-as-cast-dataframe-skips-class/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0091-as-cast-dataframe-skips-class/repro/TypR/main.ty b/cases/0091-as-cast-dataframe-skips-class/repro/TypR/main.ty new file mode 100644 index 00000000..639bd634 --- /dev/null +++ b/cases/0091-as-cast-dataframe-skips-class/repro/TypR/main.ty @@ -0,0 +1,7 @@ +type Sales <- dataframe[3]{ units: num, price: num }; + +let with_vat <- fn(s: Sales): num { 1.0 }; + +let raw <- data.frame(units = c(1.0, 2.0, 3.0), price = c(5.0, 6.0, 7.0)); +let s <- raw as! Sales; +with_vat(s); diff --git a/cases/0092-length-var-not-enforced-across-args/case.toml b/cases/0092-length-var-not-enforced-across-args/case.toml new file mode 100644 index 00000000..2b473c66 --- /dev/null +++ b/cases/0092-length-var-not-enforced-across-args/case.toml @@ -0,0 +1,7 @@ +title = "#N is not enforced across two arguments: [#N, num] accepts lengths 3 and 2" +cmd = "build" +layer = "type" +status = "open" +created = "2026-10-01" +origin = "perso" +checked = false diff --git a/cases/0092-length-var-not-enforced-across-args/expect.md b/cases/0092-length-var-not-enforced-across-args/expect.md new file mode 100644 index 00000000..dc4bf3ee --- /dev/null +++ b/cases/0092-length-var-not-enforced-across-args/expect.md @@ -0,0 +1,5 @@ +`rel(x: [#N, num], y: [#N, num])` called with `[1.0, 2.0, 3.0]` and `[1.0, 2.0]` is accepted: +`#N` should unify to a single length across the signature, so the second argument (length 2) +must be a type error against `N = 3`. + +Not to be promised on the landing page until fixed (see `typr.github.io` "What R lets through"). diff --git a/cases/0092-length-var-not-enforced-across-args/expect.toml b/cases/0092-length-var-not-enforced-across-args/expect.toml new file mode 100644 index 00000000..2623cdae --- /dev/null +++ b/cases/0092-length-var-not-enforced-across-args/expect.toml @@ -0,0 +1,4 @@ +# rel([1,2,3], [1,2]) binds #N to 3 then 2: the build must fail, so no R file is produced +[[rule]] +file = "R/main.R" +must_not_contain = "`r` <-" diff --git a/cases/0092-length-var-not-enforced-across-args/observed.txt b/cases/0092-length-var-not-enforced-across-args/observed.txt new file mode 100644 index 00000000..2c27f150 --- /dev/null +++ b/cases/0092-length-var-not-enforced-across-args/observed.txt @@ -0,0 +1,1144 @@ +# exit=0 + +## stdout/stderr + 0 .Rd file(s) written to man/ +R code successfully generated in the R/ folder + + Parsing... done (6 ms) + Type checking... done (16 ms) + Transpiling... done (73 ms) + Writing R files... done (56 ms) + Generating .Rd files... done (0 ms) + Generating vignette fragments... done (0 ms) + Compiling vignette templates... done (0 ms) + Updating NAMESPACE... failed +Warning: NAMESPACE update failed (devtools/roxygen2 may not be installed). + +R version 4.6.1 (2026-06-24) -- "Happy Hop" +Copyright (C) 2026 The R Foundation for Statistical Computing +Platform: x86_64-pc-linux-gnu + +R est un logiciel libre livré sans AUCUNE GARANTIE. +Vous pouvez le redistribuer sous certaines conditions. +Tapez 'license()' ou 'licence()' pour plus de détails. + +R est un projet collaboratif avec de nombreux contributeurs. +Tapez 'contributors()' pour plus d'information et +'citation()' pour la façon de le citer dans les publications. + +Tapez 'demo()' pour des démonstrations, 'help()' pour l'aide +en ligne ou 'help.start()' pour obtenir l'aide au format HTML. +Tapez 'q()' pour quitter R. + +> devtools::document('/tmp/typr-case-1233227-1790866963225481592/work', roclets = c('collate', 'namespace')) + +Erreur dans loadNamespace(x) : aucun package nommé ‘devtools’ n'est trouvé +Appels : loadNamespace -> withRestarts -> withOneRestart -> doWithOneRestart +Exécution arrêtée + + + +## R généré +=== R/generic_functions.R === +#' @include std.R +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +annotate_factor <- function(x, ...) UseMethod('annotate_factor', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as_vec <- function(x, ...) UseMethod('as_vec', x) +#' @export +bsystem2 <- function(x, ...) UseMethod('bsystem2', x) +#' @export +contains <- function(x, ...) UseMethod('contains', x) +#' @export +cvec <- function(x, ...) UseMethod('cvec', x) +#' @export +derive <- function(x, ...) UseMethod('derive', x) +#' @export +dir <- function(x, ...) UseMethod('dir', x) +#' @export +dir__create <- function(x, ...) UseMethod('dir__create', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +dot <- function(x, ...) UseMethod('dot', x) +#' @export +endsWith <- function(x, ...) UseMethod('endsWith', x) +#' @export +exec <- function(x, ...) UseMethod('exec', x) +#' @export +expect <- function(x, ...) UseMethod('expect', x) +#' @export +expect_equal <- function(x, ...) UseMethod('expect_equal', x) +#' @export +expect_false <- function(x, ...) UseMethod('expect_false', x) +#' @export +extend <- function(x, ...) UseMethod('extend', x) +#' @export +factor <- function(x, ...) UseMethod('factor', x) +#' @export +file__copy <- function(x, ...) UseMethod('file__copy', x) +#' @export +file__create <- function(x, ...) UseMethod('file__create', x) +#' @export +file__exists <- function(x, ...) UseMethod('file__exists', x) +#' @export +file__remove <- function(x, ...) UseMethod('file__remove', x) +#' @export +file__rename <- function(x, ...) UseMethod('file__rename', x) +#' @export +filter <- function(x, ...) UseMethod('filter', x) +#' @export +fold <- function(x, ...) UseMethod('fold', x) +#' @export +get <- function(x, ...) UseMethod('get', x) +#' @export +getwd <- function(x, ...) UseMethod('getwd', x) +#' @export +is_none <- function(x, ...) UseMethod('is_none', x) +#' @export +is_some <- function(x, ...) UseMethod('is_some', x) +#' @export +join <- function(x, ...) UseMethod('join', x) +#' @export +levels <- function(x, ...) UseMethod('levels', x) +#' @export +list__files <- function(x, ...) UseMethod('list__files', x) +#' @export +lvec <- function(x, ...) UseMethod('lvec', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +nlevels <- function(x, ...) UseMethod('nlevels', x) +#' @export +plot <- function(x, ...) UseMethod('plot', x) +#' @export +reduce <- function(x, ...) UseMethod('reduce', x) +#' @export +rel <- function(x, ...) UseMethod('rel', x) +#' @export +seq <- function(x, ...) UseMethod('seq', x) +#' @export +set <- function(x, ...) UseMethod('set', x) +#' @export +set_at <- function(x, ...) UseMethod('set_at', x) +#' @export +setwd <- function(x, ...) UseMethod('setwd', x) +#' @export +sort <- function(x, ...) UseMethod('sort', x) +#' @export +startsWith <- function(x, ...) UseMethod('startsWith', x) +#' @export +state <- function(x, ...) UseMethod('state', x) +#' @export +sum <- function(x, ...) UseMethod('sum', x) +#' @export +system2 <- function(x, ...) UseMethod('system2', x) +#' @export +t <- function(x, ...) UseMethod('t', x) +#' @export +to_native <- function(x, ...) UseMethod('to_native', x) +#' @export +to_nullable <- function(x, ...) UseMethod('to_nullable', x) +#' @export +unique <- function(x, ...) UseMethod('unique', x) +#' @export +unlink <- function(x, ...) UseMethod('unlink', x) +#' @export +unwrap <- function(x, ...) UseMethod('unwrap', x) +#' @export +unwrap_or <- function(x, ...) UseMethod('unwrap_or', x) +#' @export +update <- function(x, ...) UseMethod('update', x) +#' @export +version <- function(x, ...) UseMethod('version', x) + + +=== R/main.R === +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +#' @method rel numeric +`rel.numeric` <- (function(x, y) { + { + 1 |> as.Number() + } |> + as.Number() +}) |> + as.Generic() + +rel( + c(1 |> as.Number(), 2 |> as.Number(), 3 |> as.Number()) |> identity(), + c(1 |> as.Number(), 2 |> as.Number()) |> identity() +) + + +=== R/std.R === +# --- Fonctions Système --- + +sys.info <- function() Sys.info() +sys.getenv <- function() Sys.getenv() +sys.setenv <- function(var, val) Sys.setenv(var = val) +sys.time <- function() Sys.time() +sys.date <- function() Sys.Date() +sys.sleep <- function(n) Sys.sleep(n) +sys.which <- function(s) Sys.which(s) +sys.timezone <- function() Sys.timezone() +sys.setlocale <- function() Sys.setlocale() + +# --- Utilitaires de Typage --- + +#' @title Ajout d'une structure de classe +struct <- function(x, new_class, typed_dim = NULL) { + if (is.null(x)) return(NULL) + + # Fusion propre des classes sans doublons + class(x) <- unique(c(class(x), new_class)) + + if (!is.null(typed_dim)) { + attr(x, "typed_dim") <- typed_dim + } + + x +} + +#' @title Changement forcé de type +let_type <- function(x, new_class) { + # On retire les classes actuelles puis on applique le nouveau constructeur + unclass(x) |> new_class() +} + +#' @title Fusion shallow pour le spread operator (`{ ...x, a = 1 }`) +spread <- function(base, override) { + out <- base + for (nm in names(override)) { + out[[nm]] <- override[[nm]] + } + out +} + +#' @title Fusion explicite/spread pour les constructeurs record (`TypeName:{ ...x }`) +typr_spread_record <- function(explicit, spread) { + if (is.null(spread)) { + return(explicit) + } + if (!is.list(spread)) { + stop("`.spread` must be a list (record) in TypR constructors.") + } + merged <- spread + merged[names(explicit)] <- explicit + merged +} + +#' @title Contrat de type runtime pour le mode `typr build --checked` +#' (soundness_transpilation.md Phase A). Jamais un mode de production : un +#' oracle sémantique pour lab/ et cases/. `expected` est soit une chaîne +#' `typeof` simple ("integer"/"double"/"character"/"logical"/"list"), soit +#' un vecteur de classes nommées (tête = classe canonique, reste = les +#' variantes acceptées pour un type union). +typr_assert_type <- function(x, expected, loc, what) { + ok <- if (is.character(expected) && length(expected) > 1L) { + any(vapply(expected, function(cls) inherits(x, cls), logical(1))) + } else { + switch(expected, + integer = is.integer(x) || (is.numeric(x) && all(x == trunc(x))), + double = is.double(x), + character = is.character(x), + logical = is.logical(x), + list = is.list(x), + inherits(x, expected)) + } + if (!isTRUE(ok)) { + stop(sprintf( + "[typr --checked] %s at %s: expected %s, got class <%s> (typeof %s)", + what, loc, paste(expected, collapse = "/"), paste(class(x), collapse = ","), + typeof(x)), call. = FALSE) + } + 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") +apply <- function(X, ...) UseMethod("apply") +reduce <- function(vec, ...) UseMethod("reduce") +fold <- function(vec, ...) UseMethod("fold") +as_vec <- function(x, ...) UseMethod("as_vec") +max <- function(a, ...) UseMethod("max") +min <- function(a, ...) UseMethod("min") +replace <- function(a, ...) UseMethod("replace") +append <- function(a, ...) UseMethod("append") +plot <- function(a, ...) UseMethod("plot") +expect_equal <- function(a, ...) UseMethod("expect_equal") +set <- function(a, ...) UseMethod("set") +update <- function(a, ...) UseMethod("update") +state <- function(a, ...) UseMethod("state") +map <- function(a, ...) UseMethod("map") +derive <- function(a, ...) UseMethod("derive") +unwrap <- function(a, ...) UseMethod("unwrap") +unwrap_or <- function(a, ...) UseMethod("unwrap_or") +expect <- function(a, ...) UseMethod("expect") +is_some <- function(a, ...) UseMethod("is_some") +is_none <- function(a, ...) UseMethod("is_none") +factor <- function(a, ...) UseMethod("factor") +annotate_factor <- function(a, ...) UseMethod("annotate_factor") +nlevels <- function(a, ...) UseMethod("nlevels") + +# User validator hook for record types: `as.T` calls validate() after the +# internal validate_T. Default is identity; users extend it with validate.T. +validate <- function(x, ...) UseMethod("validate") +validate.default <- function(x) x + +# --- Méthodes par défaut et types de base --- + +max.default <- function(a, ...) base::max(a, ...) +min.default <- function(a, ...) base::min(a, ...) +replace.default <- function(a, ...) base::replace(a, ...) +append.default <- function(a, ...) base::append(a, ...) +plot.default <- function(a, ...) base::plot(a, ...) +expect_equal.default <- function(a, ...) testthat::expect_equal(a, ...) +# `cat` is a plain (non-S3-generic) base-R function; the `.default` is both the +# UseMethod fallback and where TypR's own boxing is peeled off each argument so +# `cat("x:", x)` prints bare values, not `attr(,"class")` noise. +cat.default <- function(...) { + args <- lapply(list(...), to_native) + do.call(base::cat, args) + invisible(NULL) +} + +# The string/file/system helpers below (default.ty, file.ty, system.ty) are +# plain, non-S3-generic base-R functions (see r_name_lint's Phase C database, +# soundness_transpilation.md). Without a `.default` fallback, the UseMethod +# stub generic_functions.R emits for each of them clobbers the base function +# with zero method to dispatch to — the exact `nlevels` bug shape, caught for +# all of these at once when the Phase C lint was first run against a fresh +# project. Argument order below mirrors each `.ty` signature (see +# configs/std/default.ty / file.ty / system.ty), which already matches base +# R's own argument order in every case. +nchar.default <- function(a, ...) base::nchar(a, ...) +sd.default <- function(a, ...) base::sd(a, ...) +substr.default <- function(a, b, e, ...) base::substr(a, b, e) +sub.default <- function(a, b, c, ...) base::sub(a, b, c, ...) +gsub.default <- function(a, b, c, ...) base::gsub(a, b, c, ...) +strsplit.default <- function(s, d, ...) base::strsplit(s, d, ...)[[1]] +tolower.default <- function(a, ...) base::tolower(a) +toupper.default <- function(a, ...) base::toupper(a) +startsWith.default <- function(a, b, ...) base::startsWith(a, b) +endsWith.default <- function(a, b, ...) base::endsWith(a, b) +grepl.default <- function(a, b, ...) base::grepl(a, b, ...) +setwd.default <- function(a, ...) base::setwd(a) +unlink.default <- function(a, ...) base::unlink(a, ...) == 0L +system2.default <- function(command, args, stdout, stderr, stdin, ...) { + base::system2(command, args = args, stdout = stdout, stderr = stderr, stdin = stdin, ...) +} + +# `version` is only ever meaningfully defined for `State` (see +# `version.State` below) — there is no sensible base-R fallback, so the +# `.default` gives a clear, localized error instead of the generic "no +# applicable method" UseMethod message. +version.default <- function(x, ...) { + stop(sprintf( + "version() is only defined for State values, got class <%s>", + paste(class(x), collapse = ",")), call. = FALSE) +} + +apply.default <- function(X, ...) { + # Si MARGIN est présent, c'est probablement un appel à base::apply + args <- list(...) + if (is.numeric(X) || is.array(X) || is.matrix(X)) { + return(do.call(base::apply, c(list(X), args))) + } + base::apply(X, ...) +} + +get.default <- function(a, name, ...) { + if (is.list(a)) a[[name]] else base::get(name, pos = a, ...) +} + +# Arrays (and atomic vectors) are already iterable: `as_vec` is the identity, +# R iterates them natively in `for (x in ...)`. +as_vec.default <- function(x, ...) x + +set_at.default <- function(a, i, val) replace(a, i, val) + +get.data <- function(a, name) { + a$data[[1]] +} + +get.list <- function(a, name) { + a$data[[1]][[name]] +} + +get.any <- function(a, name) { + a[[name]] +} + +Integer <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Integer", "integer", "Any", "Generic"), typed_dim = dim) +} + +Number <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Number", "numeric", "Any", "Generic"), typed_dim = dim) +} + +Character <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Character", "character", "Any", "Generic"), typed_dim = dim) +} + +Boolean <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Boolean", "logical", "Any", "Generic"), typed_dim = dim) +} + +as.Generic <- function(x) x |> struct(c('Generic', 'Any')) + +# Opérateur de coalescence nulle utilitaire +`%||%` <- function(a, b) if (!is.null(a)) a else b + +# --- Affichage des types de base --- + +print.Integer <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Character <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Boolean <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Number <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } + +# --- Opérateurs --- + +`%==%.default` <- function(x, y) { + unclass(x) == unclass(y) +} + +# --- Classe typed_vec --- + +typed_vec <- function(..., dim = 0) { + x <- list(...) + + if (length(x) == 0) { + return(structure( + list(data = list()), + class = "typed_vec", + typed_dim = dim + )) + } + + # Vérification si tous les éléments sont déjà des typed_vec + all_typed <- vapply(x, inherits, logical(1), what = "typed_vec") + + if (all(all_typed)) { + combined_data <- unlist(lapply(x, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = dim + )) + } + + structure( + list(data = x), + class = "typed_vec", + typed_dim = dim + ) +} + +length.typed_vec <- function(x) { + length(x$data) +} + +# No length.default: `length` is one of R's internal S3-primitive generics +# (.S3PrimitiveGenerics), not a plain closure-based generic like nchar/sd. +# `length` is also blacklisted from the typed UseMethod-stub codegen +# (BLACKLIST in standard_library.rs), so bare `length()` still resolves to +# the real base primitive, which already falls through to its internal C +# implementation for any class with no matching method — no `.default` +# needed. Adding one that forwards via `base::length(x)` would NOT bypass +# dispatch (internal-generic re-dispatch fires again on the still-classed +# `x` regardless of namespace-qualification) and recurses infinitely for +# every classed value, confirmed with a minimal `length.default <- +# function(x, ...) base::length(x); length(structure(1:3, class="foo"))`. + +`[[.typed_vec` <- function(x, i) { + if (is.character(i)) { + lapply(x$data, `[[`, i) + } else { + x$data[[i]] + } +} + +get.typed_vec <- function(a, name) { + a$data[[1]][[name]] +} + +apply.typed_vec <- function(X, FUN, ...) { + # Appliquer la fonction à chaque élément de data et ré-emballer + typed_vec(lapply(X$data, FUN, ...)) +} + +vec_apply <- function(f, ...) { + args <- list(...) + + # Normalisation en typed_vec + args <- lapply(args, function(x) { + if (!inherits(x, "typed_vec")) typed_vec(x) else x + }) + + lengths <- vapply(args, length, integer(1)) + n <- max(lengths) + + if (any(lengths == 0)) { + return(typed_vec(dim = 0)) + } + + if (any(n %% lengths != 0)) { + stop("Incompatible vector lengths for recycling") + } + + # Recyclage des données + recycled <- lapply(args, function(x) { + if (length(x) == n) x$data else rep(x$data, length.out = n) + }) + + results <- vector("list", n) + for (i in seq_len(n)) { + elements <- lapply(recycled, `[[`, i) + results[[i]] <- do.call(f, elements) + } + + # Si tous les résultats sont des typed_vec, on les combine + all_typed <- vapply(results, inherits, logical(1), what = "typed_vec") + + if (all(all_typed) && length(results) > 0) { + combined_data <- unlist(lapply(results, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = n + )) + } + + structure( + list(data = results), + class = "typed_vec", + typed_dim = n + ) +} + +vec_apply_fun <- function(fun_vec, ...) { + if (!inherits(fun_vec, "typed_vec")) fun_vec <- typed_vec(fun_vec) + + args <- lapply(list(...), function(x) { + if (!inherits(x, "typed_vec")) typed_vec(x) else x + }) + + lengths <- c(length(fun_vec), vapply(args, length, integer(1))) + n <- max(lengths) + + if (any(lengths == 0)) return(typed_vec(dim = 0)) + if (any(n %% lengths != 0)) stop("Incompatible vector lengths") + + funs <- if (length(fun_vec) == n) fun_vec$data else rep(fun_vec$data, length.out = n) + recycled_args <- lapply(args, function(x) { + if (length(x) == n) x$data else rep(x$data, length.out = n) + }) + + results <- vector("list", n) + for (i in seq_len(n)) { + f <- funs[[i]] + params <- lapply(recycled_args, `[[`, i) + results[[i]] <- do.call(f, params) + } + + all_typed <- vapply(results, inherits, logical(1), what = "typed_vec") + + if (all(all_typed) && length(results) > 0) { + combined_data <- unlist(lapply(results, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = n + )) + } + + structure( + list(data = results), + class = "typed_vec", + typed_dim = n + ) +} + +reduce.typed_vec <- function(vec, f, init = NULL) { + if (!inherits(vec, "typed_vec")) vec <- typed_vec(vec) + + n <- length(vec) + if (n == 0) { + if (is.null(init)) stop("Cannot reduce empty vector without initial value") + return(init) + } + + # Utilisation de la fonction Reduce de base pour plus de robustesse + res <- if (is.null(init)) { + Reduce(f, vec$data) + } else { + Reduce(f, vec$data, init = init) + } + + # Si le résultat est un typed_vec, on extrait la donnée (comportement original) + if (inherits(res, "typed_vec")) res <- res$data[[1]] + + structure( + list(data = list(res)), + class = "typed_vec", + typed_dim = 1 + ) +} + +sum.default <- function(x, ...) base::sum(x, ...) + +# Étape ③ : reduce sur un vecteur atomique nu — résultat scalaire, conforme à +# la signature `@reduce: ([#N, T], (T, T) -> T) -> T` (contrairement au quirk +# préexistant de reduce.typed_vec qui ré-emballe dans un typed_vec). +reduce.default <- function(vec, f, init = NULL) { + vals <- as.list(unclass(vec)) + if (length(vals) == 0) { + if (is.null(init)) stop("Cannot reduce empty vector without initial value") + return(init) + } + if (is.null(init)) { + Reduce(function(a, b) f(a, b), vals) + } else { + Reduce(function(a, b) f(a, b), vals, init = init) + } +} + +sum.typed_vec <- function(x, ...) { + reduce(x, `+`) +} + +vec_reduce <- function(vec, f, init = NULL) reduce(vec, f, init) + +fold.typed_vec <- function(vec, init, f, ...) { + Reduce(function(acc, x) f(acc, x), vec$data, init) +} + +# Étape ③ : version atomique directe (un scalaire isolé se comporte comme un +# vecteur de longueur 1, comme avant via le wrap typed_vec). +fold.default <- function(vec, init, f, ...) { + Reduce(function(acc, el) f(acc, el), as.list(unclass(vec)), init) +} + +extend <- function(vec, ...) UseMethod("extend") + +extend.typed_vec <- function(vec, x) { + structure( + list(data = c(vec$data, list(x))), + class = "typed_vec", + typed_dim = length(vec) + 1L + ) +} + +# Étape ③ : extension d'un vecteur atomique nu — c() strip le boxing de +# l'élément ajouté, TypR ré-annote depuis le type statique si nécessaire. +extend.default <- function(vec, x) { + c(vec, x) +} + +vec_extend <- function(vec, x) extend(vec, x) + +print.typed_vec <- function(x, ...) { + dims <- attr(x, "typed_dim") + data <- x$data + n <- length(data) + + if (n == 0) { + cat("typed_vec(0)\n") + return(invisible(x)) + } + + # Si pas de dimensions ou 1D, affichage linéaire amélioré + if (is.null(dims) || length(dims) <= 1 || (length(dims) == 1 && dims == 0)) { + cat("typed_vec [", n, "]\n", sep = "") + for (i in seq_len(n)) { + cat("[", i, "] ", sep = "") + el <- data[[i]] + if (is.function(el)) cat("\n") else print(el) + } + return(invisible(x)) + } + + # --- Affichage Multidimensionnel --- + + # Helper pour formater un élément individuel + format_el <- function(el) { + if (is.function(el)) return("") + if (inherits(el, "typed_vec")) return(paste0("")) + s <- format(el) + if (length(s) > 1) paste0(s[1], "..") else s + } + + # Helper pour afficher une tranche 2D (Matrice) + print_slice2d <- function(slice_data, nr, nc, header = "") { + if (nchar(header) > 0) cat(header, "\n") + + # Pré-formatage pour l'alignement + formatted <- vapply(slice_data, format_el, character(1)) + + # Remplissage par ligne (row first) + mat_form <- matrix(formatted, nrow = nr, ncol = nc, byrow = TRUE) + + # On utilise explicitement base::apply pour éviter le conflit S3 + col_widths <- base::apply(mat_form, 2, function(col) max(nchar(col), 4)) + + # En-tête des colonnes + cat(" ") + for (j in 1:nc) { + cat(sprintf("[%*s,%d]", col_widths[j] - 2, "", j), " ") + } + cat("\n") + + # Lignes + for (i in 1:nr) { + cat(sprintf("[%d,] ", i)) + for (j in 1:nc) { + cat(sprintf("%*s", col_widths[j], mat_form[i, j]), " ") + } + cat("\n") + } + } + + if (length(dims) == 2) { + print_slice2d(data, dims[1], dims[2]) + } else { + # Pour 3D et plus : on itère sur les dimensions supérieures + slice_size <- dims[1] * dims[2] + num_slices <- n / slice_size + + high_dims <- dims[3:length(dims)] + + for (s in 1:num_slices) { + # Calcul des indices pour les dimensions > 2 (basé sur le stockage column-major de R) + # On utilise arrayInd pour retrouver les coordonnées multi-D à partir de l'index linéaire + coords <- arrayInd(s, high_dims) + header <- paste0(", , ", paste(coords, collapse = ", ")) + + start_idx <- (s - 1) * slice_size + 1 + end_idx <- s * slice_size + + print_slice2d(data[start_idx:end_idx], dims[1], dims[2], header = header) + if (s < num_slices) cat("\n") + } + } + + invisible(x) +} + +# --- Option --- +# .Some(v)/.None transpile to structure(list('Some', body = v), class = +# c('Some', 'Option', 'Tag', 'list')) / structure(list('None'), class = +# c('None', 'Option', 'Tag', 'list')) — see the union variant constructor +# pipeline. Both variants share the 'Option' class, so a single method +# dispatching on it (rather than separate .Some/.None methods) covers both. + +unwrap.Option <- function(value, ...) { + if (inherits(value, "Some")) value$body else stop("The value is not unwrappable.") +} + +expect.Option <- function(value, msg, ...) { + if (inherits(value, "Some")) value$body else stop(msg) +} + +unwrap_or.Option <- function(value, alternative, ...) { + if (inherits(value, "Some")) value$body else alternative +} + +is_some.Option <- function(value, ...) inherits(value, "Some") + +is_none.Option <- function(value, ...) inherits(value, "None") + +# --- Factor --- + +# Standard constructor: looks up `x` in `levels` and returns the matching +# 1-based index (delegates the bounds check to annotate_factor). +# `factor` is also a base-R name; without `.default`, generic_functions.R's +# auto-generated UseMethod stub would shadow it with no fallback method. +factor.default <- function(x, levels, ...) { + idx <- match(x, levels) + if (is.na(idx)) { + stop(paste0("'", x, "' is not among Factor levels: [", paste(levels, collapse = ", "), "]")) + } + annotate_factor(idx, levels) +} + +# Annotator: wraps an integer index as an R factor with the given levels. +# Validates that 1 <= x <= length(levels). +annotate_factor.default <- function(x, levels, ...) { + if (x < 1L || x > length(levels)) { + stop(paste0("index ", x, " out of bounds for Factor[", paste(levels, collapse = ", "), "]")) + } + structure(x, class = "factor", levels = levels) +} + +# base::nlevels is a plain function (length(levels(x))), not S3-generic, so +# it has no registered nlevels.factor method for generic_functions.R's +# UseMethod stub to dispatch to. .default replicates base's own definition. +nlevels.default <- function(x, ...) length(levels(x)) + +# --- State --- +# Real shared mutable state, backed by an R environment (reference type). +# `version` auto-increments on every set()/update(). The constructor +# captures class(value) as a per-instance runtime fingerprint; set/update +# validate new values against it (defense-in-depth; the TypR static type +# checker is the real guarantee against misuse in well-typed code). +# +# `state` is defined as `state.default` (not a plain function): the project +# build's generated R/generic_functions.R emits `state <- function(x, ...) +# UseMethod('state', x)` for every typed stdlib name, which would otherwise +# shadow a plain `state <- function(value) {...}` definition here. Dispatch +# on .default catches every input class, since the constructor accepts any T. +state.default <- function(value, ...) { + e <- new.env(parent = emptyenv()) + e$value <- value + e$version <- 0L + e$.tag <- class(value) + structure(e, class = "State") +} + +get.State <- function(s, ...) s$value + +set.State <- function(s, value, ...) { + if (!identical(class(value), s$.tag)) { + stop(paste0( + "State type mismatch: expected ", paste(s$.tag, collapse = "/"), + ", got ", paste(class(value), collapse = "/") + )) + } + s$value <- value + s$version <- s$version + 1L + invisible(NULL) +} + +update.State <- function(s, f, ...) { + set.State(s, f(s$value)) +} + +version.State <- function(s, ...) s$version + +# Étape ③ (unification_arrays.md) : les tableaux d'éléments primitifs sont +# des vecteurs atomiques R nus. typr_collect est le collecteur commun de +# map : résultats tous scalaires « strippables » (atomiques, longueur 1, sans +# classe ou avec seulement le boxing transparent TypR) → vecteur atomique +# (unlist strip le boxing par élément), sinon typed_vec (records, valeurs à +# classe porteuse comme factor/alias opaques, éléments vecteurs). Helpers nus +# sans @signature (même statut que typed_vec/vec_apply/struct : jamais de +# stub UseMethod). +typr_scalar_strippable <- function(el) { + if (!is.atomic(el) || length(el) != 1L) return(FALSE) + cls <- attr(el, "class") + is.null(cls) || all(cls %in% c( + "Integer", "Number", "Character", "Boolean", + "integer", "numeric", "character", "logical", "Any", "Generic" + )) +} + +typr_collect <- function(results, dim = NULL) { + if (length(results) > 0L && + all(vapply(results, typr_scalar_strippable, logical(1))) && + length(unique(vapply(results, typeof, character(1)))) == 1L) { + unlist(results, use.names = FALSE) + } else if (is.null(dim)) { + do.call(typed_vec, results) + } else { + structure(list(data = results), class = "typed_vec", typed_dim = dim) + } +} + +# map composite -> primitif : le type statique du résultat est un tableau +# atomique, la valeur doit suivre (étape ③). +map.typed_vec <- function(a, f, ...) { + typr_collect(lapply(a$data, f), dim = attr(a, "typed_dim")) +} + +map.default <- function(a, f, ...) { + typr_collect(lapply(as.list(unclass(a)), f)) +} + +map.State <- function(s, f, ...) state(f(s$value)) + +filter.typed_vec <- function(a, f, ...) { + structure( + list(data = Filter(f, a$data)), + class = "typed_vec", + typed_dim = attr(a, "typed_dim") + ) +} + +filter.default <- function(a, f, ...) { + keep <- vapply(as.list(unclass(a)), function(el) isTRUE(all(unclass(f(el)))), logical(1)) + unclass(a)[keep] +} + +# --- Eq / Ord (ord.ty) --- +# Atomic element arrays dispatch to base R's own (already S3-generic) +# unique()/sort() — real ordering/equality on int/num/char/bool at C speed. +# Composite (typed_vec) arrays call the element type's own eq()/compare(), +# resolved by ordinary S3 dispatch at each call, exactly like unique.Point +# would if it existed as a dedicated method. +# +# Same footgun as `length.default` above, generalized: `unique`/`sort` are +# themselves plain closure-based S3 generics in base R (`function(x, ...) +# UseMethod("unique")`), so calling `base::unique(a, ...)` from inside our +# OWN `unique.default` re-enters `UseMethod("unique")`, which (namespace +# qualification notwithstanding — S3 dispatch resolves by search path, not +# lexical scope) finds our shadowing `unique.default` again and recurses +# forever (confirmed with a minimal repro: "C stack usage ... too close to +# the limit"). Call the base *method* directly instead of the generic. +unique.default <- function(a, ...) base:::unique.default(a, ...) +sort.default <- function(a, ...) base:::sort.default(a, ...) + +#' @title Invoke an implicit typeclass method (`eq`, `compare`, …) by name +#' +#' `unique.typed_vec`/`sort.typed_vec` live in std.R, sourced by +#' `load_module.R` into their own isolated environment (`parent = baseenv()`) +#' — a project file that `@include`s std.R gets a one-way COPY of its +#' bindings into its own separate environment, so a bare `eq(...)` call from +#' inside a std.R-defined function can never see a user's `eq.Point` (defined +#' only in the project's own environment): the copy doesn't create a shared +#' scope, and the function's closure still points at std.R's own (unrelated) +#' environment. +#' +#' Simply *fetching* the `eq` generic stub via `get(..., envir = parent.frame())` +#' and calling it directly isn't enough either: `eq` itself is a `UseMethod` +#' stub (`function(x, ...) UseMethod("eq")`, from generic_functions.R, a +#' *third*, separately-isolated environment) — dispatching to `eq.Point` +#' happens inside *that* call, whose own calling context is then this +#' function's frame, not the original project caller, so the inner dispatch +#' still fails to find `eq.Point`. Evaluating the call expression itself +#' (`eval(as.call(...), envir = ...)`) inside the caller's environment fixes +#' this at every level: any `UseMethod` dispatch that happens *during* that +#' evaluation resolves against that environment's lexical chain, where the +#' project's own `eq.Point` (or `eq.`) is visible — confirmed with a +#' minimal repro mirroring load_module.R's three-environment split (std.R / +#' generic_functions.R / project file). +typr_call_method <- function(name, ...) { + eval(as.call(c(as.name(name), list(...))), envir = parent.frame(2)) +} + +unique.typed_vec <- function(a, ...) { + out <- list() + for (el in a$data) { + is_dup <- FALSE + for (o in out) { + if (isTRUE(typr_call_method("eq", el, o))) { + is_dup <- TRUE + break + } + } + if (!is_dup) out[[length(out) + 1L]] <- el + } + structure(list(data = out), class = "typed_vec", typed_dim = length(out)) +} + +sort.typed_vec <- function(a, ...) { + data <- a$data + n <- length(data) + if (n >= 2) { + for (i in seq(2L, n)) { + key <- data[[i]] + j <- i - 1L + while (j >= 1L && typr_call_method("compare", data[[j]], key) > 0L) { + data[[j + 1L]] <- data[[j]] + j <- j - 1L + } + data[[j + 1L]] <- key + } + } + structure(list(data = data), class = "typed_vec", typed_dim = attr(a, "typed_dim")) +} + +derive.State <- function(s, f, ...) state(f(s$value)) + +# --- Interop (Niveau 0) --- +# to_native: lowering récursif TypR→R plat à la frontière des packages R externes. +# from_native: lifting R natif→TypR piloté par un descripteur de type ("int","num","char","bool","Any"). +# Ces fonctions sont la fondation du plan d'interop; elles ne modifient pas l'état TypR interne. + +to_native <- function(x, ...) UseMethod("to_native") + +to_native.default <- function(x, ...) x +to_native.Integer <- function(x, ...) as.integer(unclass(x)) +to_native.Number <- function(x, ...) as.numeric(unclass(x)) +to_native.Character <- function(x, ...) as.character(unclass(x)) +to_native.Boolean <- function(x, ...) as.logical(unclass(x)) + +to_native.typed_vec <- function(x, ...) { + if (length(x$data) == 0L) return(list()) + # lambda wrapper: keeps the closure chain so UseMethod can find to_native.* methods + # when called via lapply in load_module's isolated environments (parent=baseenv) + elems <- lapply(x$data, function(el) to_native(el)) + # Collapse to atomic vector when all elements share the same base storage type + if (all(vapply(elems, is.atomic, logical(1))) && + length(unique(vapply(elems, typeof, character(1)))) == 1L) { + do.call(c, elems) + } else { + elems + } +} + +# Handles records (class c("T","list")) and Tag variants not matched by a more specific method. +to_native.list <- function(x, ...) { + # lambda wrapper: keeps the closure chain so UseMethod can find to_native.* methods + # when called via lapply in load_module's isolated environments (parent=baseenv) + result <- lapply(unclass(x), function(el) to_native(el)) + class(result) <- NULL + result +} + +to_native.Some <- function(x, ...) to_native(x$body) +to_native.None <- function(x, ...) NULL +to_native.State <- function(x, ...) to_native(get.State(x)) + +from_native <- function(x, ...) UseMethod("from_native") + +# type_desc: "int" | "num" | "char" | "bool" | "Any" (default = identity) +from_native.default <- function(x, type_desc = "Any", ...) { + if (is.null(x)) return(NULL) + switch(type_desc, + "int" = Integer(as.integer(x)), + "num" = Number(as.numeric(x)), + "char" = Character(as.character(x)), + "bool" = Boolean(as.logical(x)), + x + ) +} + +# Typed lifting helpers: convert a plain R value to a specific TypR type. +# These are the idiomatic way to bring native R values into TypR-typed code. +from_int <- function(x, ...) UseMethod("from_int") +from_num <- function(x, ...) UseMethod("from_num") +from_char <- function(x, ...) UseMethod("from_char") +from_bool <- function(x, ...) UseMethod("from_bool") + +from_int.default <- function(x, ...) Integer(as.integer(x)) +from_num.default <- function(x, ...) Number(as.numeric(x)) +from_char.default <- function(x, ...) Character(as.character(x)) +from_bool.default <- function(x, ...) Boolean(as.logical(x)) + +# --- Interop (Niveau 2) --- +# from_nullable / to_nullable: bridge between R NULL and TypR Option. +# Use from_nullable when an @extern function may return NULL to represent absence. +# Use to_nullable when passing an Option to an external function expecting NULL. + +from_nullable <- function(x, ...) UseMethod("from_nullable") +from_nullable.default <- function(x, ...) { + if (is.null(x)) + structure(list("None"), class = c("None", "Option", "Tag", "list")) + else + structure(list("Some", body = x), class = c("Some", "Option", "Tag", "list")) +} + +to_nullable <- function(x, ...) UseMethod("to_nullable") +to_nullable.Option <- function(x, ...) if (inherits(x, "Some")) x$body else NULL +to_nullable.default <- function(x, ...) x + +# Foreign: identity function that serves as the runtime root for opaque Foreign aliases. +# `type DataFrame <- Foreign` compiles to `DataFrame <- Foreign` in R, so calling +# `DataFrame(x)` returns `x` unchanged — native R objects (data.frame, R6, S4, etc.) +# pass through without boxing. to_native.default already handles Foreign values correctly. +Foreign <- function(x, ...) x + + +=== R/types.R === +#' @include generic_functions.R +as.Array0 <- function(x) x |> struct(c('Array0', 'numeric', 'Any')) +as.Ord <- function(x) x |> struct(c('Ord', 'Any')) +as.Eq <- function(x) x |> struct(c('Eq', 'Any')) +as.Foreign <- function(x) x |> struct(c('Foreign', 'Any', 'None')) +as.Function58 <- function(x) x |> struct(c('Function58', 'Any')) +as.State0 <- function(x) x |> struct(c('State0', 'Any')) +as.State <- function(x) x |> struct(c('State', 'Any', 'None')) +as.Function53 <- function(x) x |> struct(c('Function53', 'Any')) +as.Function52 <- function(x) x |> struct(c('Function52', 'Any')) +as.Factor0 <- function(x) x |> struct(c('Factor0', 'Any')) +as.Factor <- function(x) x |> struct(c('Factor', 'integer', 'Any')) +as.Tag0 <- function(x) x |> struct(c('Tag0', 'Any')) +as.Array4 <- function(x) x |> struct(c('Array4', 'numeric', 'Any')) +as.Function38 <- function(x) x |> struct(c('Function38', 'Any')) +as.Union0 <- function(x) x |> struct(c('Union0', 'Any')) +as.Function33 <- function(x) x |> struct(c('Function33', 'Any')) +as.Function31 <- function(x) x |> struct(c('Function31', 'Any')) +as.Function30 <- function(x) x |> struct(c('Function30', 'Any')) +as.Function29 <- function(x) x |> struct(c('Function29', 'Any')) +as.Array1 <- function(x) x |> struct(c('Array1', 'integer', 'Any')) +as.Function19 <- function(x) x |> struct(c('Function19', 'Any')) +as.Function17 <- function(x) x |> struct(c('Function17', 'Any')) +as.Function16 <- function(x) x |> struct(c('Function16', 'Any')) +as.Function15 <- function(x) x |> struct(c('Function15', 'Any')) +as.Function14 <- function(x) x |> struct(c('Function14', 'Any')) +as.Function13 <- function(x) x |> struct(c('Function13', 'Any')) +as.Function11 <- function(x) x |> struct(c('Function11', 'Any')) +as.Function9 <- function(x) x |> struct(c('Function9', 'Any')) +as.Function8 <- function(x) x |> struct(c('Function8', 'Any')) +as.Empty0 <- function(x) x |> struct(c('Empty0', 'numeric', 'numeric', 'Any')) +as.Function2 <- function(x) x |> struct(c('Function2', 'Any')) +as.Function1 <- function(x) x |> struct(c('Function1', 'Any')) +as.Function0 <- function(x) x |> struct(c('Function0', 'Any')) +as.Integer <- function(x) x |> struct(c('Integer', 'integer', 'Any')) +as.Character <- function(x) x |> struct(c('Character', 'character', 'Any')) +as.Number <- function(x) x |> struct(c('Number', 'numeric', 'Any')) +as.Boolean <- function(x) x |> struct(c('Boolean', 'logical', 'Any')) diff --git a/cases/0092-length-var-not-enforced-across-args/repro/DESCRIPTION b/cases/0092-length-var-not-enforced-across-args/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0092-length-var-not-enforced-across-args/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/0092-length-var-not-enforced-across-args/repro/NAMESPACE b/cases/0092-length-var-not-enforced-across-args/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0092-length-var-not-enforced-across-args/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0092-length-var-not-enforced-across-args/repro/R/.gitkeep b/cases/0092-length-var-not-enforced-across-args/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0092-length-var-not-enforced-across-args/repro/TypR/main.ty b/cases/0092-length-var-not-enforced-across-args/repro/TypR/main.ty new file mode 100644 index 00000000..8437af2a --- /dev/null +++ b/cases/0092-length-var-not-enforced-across-args/repro/TypR/main.ty @@ -0,0 +1,5 @@ +let rel <- fn(x: [#N, num], y: [#N, num]): num { + 1.0 +}; + +let r <- rel([1.0, 2.0, 3.0], [1.0, 2.0]); diff --git a/cases/0093-refinement-lost-on-call-argument/case.toml b/cases/0093-refinement-lost-on-call-argument/case.toml new file mode 100644 index 00000000..9c04302b --- /dev/null +++ b/cases/0093-refinement-lost-on-call-argument/case.toml @@ -0,0 +1,7 @@ +title = "length refinement on a parameter is not checked when the argument is a call returning [num]" +cmd = "build" +layer = "transpile" +status = "open" +created = "2026-10-01" +origin = "perso" +checked = false diff --git a/cases/0093-refinement-lost-on-call-argument/expect.md b/cases/0093-refinement-lost-on-call-argument/expect.md new file mode 100644 index 00000000..7a4095dc --- /dev/null +++ b/cases/0093-refinement-lost-on-call-argument/expect.md @@ -0,0 +1,6 @@ +`h(n: int, x: [num] & length(>= 1))` called as `h(2, read_x())` with `read_x(): [num]` emits +`h(2L |> as.Integer(), read_x())`: the `length(>= 1)` obligation is dropped. Passing a literal or +a refined argument elsewhere should not cancel the check on the other. Expected a +`typr_refine_length_range(...)` wrapper around `read_x()`. + +Related symptom seen while probing: a parasitic `vapply` lifting is generated for `x: [num]`. diff --git a/cases/0093-refinement-lost-on-call-argument/expect.toml b/cases/0093-refinement-lost-on-call-argument/expect.toml new file mode 100644 index 00000000..891308dc --- /dev/null +++ b/cases/0093-refinement-lost-on-call-argument/expect.toml @@ -0,0 +1,4 @@ +# read_x() returns [num] (length unknown): the call must carry a runtime length check +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length_range(" diff --git a/cases/0093-refinement-lost-on-call-argument/observed.txt b/cases/0093-refinement-lost-on-call-argument/observed.txt new file mode 100644 index 00000000..bb99c630 --- /dev/null +++ b/cases/0093-refinement-lost-on-call-argument/observed.txt @@ -0,0 +1,1155 @@ +# exit=0 + +## stdout/stderr + 0 .Rd file(s) written to man/ +R code successfully generated in the R/ folder + + Parsing... done (7 ms) + Type checking... done (19 ms) + Transpiling... done (93 ms) + Writing R files... done (60 ms) + Generating .Rd files... done (0 ms) + Generating vignette fragments... done (0 ms) + Compiling vignette templates... done (0 ms) + Updating NAMESPACE... failed +Warning: NAMESPACE update failed (devtools/roxygen2 may not be installed). + +R version 4.6.1 (2026-06-24) -- "Happy Hop" +Copyright (C) 2026 The R Foundation for Statistical Computing +Platform: x86_64-pc-linux-gnu + +R est un logiciel libre livré sans AUCUNE GARANTIE. +Vous pouvez le redistribuer sous certaines conditions. +Tapez 'license()' ou 'licence()' pour plus de détails. + +R est un projet collaboratif avec de nombreux contributeurs. +Tapez 'contributors()' pour plus d'information et +'citation()' pour la façon de le citer dans les publications. + +Tapez 'demo()' pour des démonstrations, 'help()' pour l'aide +en ligne ou 'help.start()' pour obtenir l'aide au format HTML. +Tapez 'q()' pour quitter R. + +> devtools::document('/tmp/typr-case-1233264-1790866963821058005/work', roclets = c('collate', 'namespace')) + +Erreur dans loadNamespace(x) : aucun package nommé ‘devtools’ n'est trouvé +Appels : loadNamespace -> withRestarts -> withOneRestart -> doWithOneRestart +Exécution arrêtée + + + +## R généré +=== R/generic_functions.R === +#' @include std.R +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +annotate_factor <- function(x, ...) UseMethod('annotate_factor', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as_vec <- function(x, ...) UseMethod('as_vec', x) +#' @export +bsystem2 <- function(x, ...) UseMethod('bsystem2', x) +#' @export +contains <- function(x, ...) UseMethod('contains', x) +#' @export +cvec <- function(x, ...) UseMethod('cvec', x) +#' @export +derive <- function(x, ...) UseMethod('derive', x) +#' @export +dir <- function(x, ...) UseMethod('dir', x) +#' @export +dir__create <- function(x, ...) UseMethod('dir__create', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +dot <- function(x, ...) UseMethod('dot', x) +#' @export +endsWith <- function(x, ...) UseMethod('endsWith', x) +#' @export +exec <- function(x, ...) UseMethod('exec', x) +#' @export +expect <- function(x, ...) UseMethod('expect', x) +#' @export +expect_equal <- function(x, ...) UseMethod('expect_equal', x) +#' @export +expect_false <- function(x, ...) UseMethod('expect_false', x) +#' @export +extend <- function(x, ...) UseMethod('extend', x) +#' @export +factor <- function(x, ...) UseMethod('factor', x) +#' @export +file__copy <- function(x, ...) UseMethod('file__copy', x) +#' @export +file__create <- function(x, ...) UseMethod('file__create', x) +#' @export +file__exists <- function(x, ...) UseMethod('file__exists', x) +#' @export +file__remove <- function(x, ...) UseMethod('file__remove', x) +#' @export +file__rename <- function(x, ...) UseMethod('file__rename', x) +#' @export +filter <- function(x, ...) UseMethod('filter', x) +#' @export +fold <- function(x, ...) UseMethod('fold', x) +#' @export +get <- function(x, ...) UseMethod('get', x) +#' @export +getwd <- function(x, ...) UseMethod('getwd', x) +#' @export +h <- function(x, ...) UseMethod('h', x) +#' @export +is_none <- function(x, ...) UseMethod('is_none', x) +#' @export +is_some <- function(x, ...) UseMethod('is_some', x) +#' @export +join <- function(x, ...) UseMethod('join', x) +#' @export +levels <- function(x, ...) UseMethod('levels', x) +#' @export +list__files <- function(x, ...) UseMethod('list__files', x) +#' @export +lvec <- function(x, ...) UseMethod('lvec', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +nlevels <- function(x, ...) UseMethod('nlevels', x) +#' @export +plot <- function(x, ...) UseMethod('plot', x) +#' @export +read_x <- function(x, ...) UseMethod('read_x', x) +#' @export +reduce <- function(x, ...) UseMethod('reduce', x) +#' @export +seq <- function(x, ...) UseMethod('seq', x) +#' @export +set <- function(x, ...) UseMethod('set', x) +#' @export +set_at <- function(x, ...) UseMethod('set_at', x) +#' @export +setwd <- function(x, ...) UseMethod('setwd', x) +#' @export +sort <- function(x, ...) UseMethod('sort', x) +#' @export +startsWith <- function(x, ...) UseMethod('startsWith', x) +#' @export +state <- function(x, ...) UseMethod('state', x) +#' @export +sum <- function(x, ...) UseMethod('sum', x) +#' @export +system2 <- function(x, ...) UseMethod('system2', x) +#' @export +t <- function(x, ...) UseMethod('t', x) +#' @export +to_native <- function(x, ...) UseMethod('to_native', x) +#' @export +to_nullable <- function(x, ...) UseMethod('to_nullable', x) +#' @export +unique <- function(x, ...) UseMethod('unique', x) +#' @export +unlink <- function(x, ...) UseMethod('unlink', x) +#' @export +unwrap <- function(x, ...) UseMethod('unwrap', x) +#' @export +unwrap_or <- function(x, ...) UseMethod('unwrap_or', x) +#' @export +update <- function(x, ...) UseMethod('update', x) +#' @export +version <- function(x, ...) UseMethod('version', x) + + +=== R/main.R === +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +#' @method h integer +`h.integer` <- (function(n, x) { + { + x[[1L |> as.Integer()]] + } |> + as.Number() +}) |> + as.Generic() + +#' @method read_x +`read_x` <- (function() { + { + c(1 |> as.Number(), 2 |> as.Number()) |> identity() + } |> + identity() +}) |> + as.Function1() + +`a` <- h(2L |> as.Integer(), read_x()) + + +=== R/std.R === +# --- Fonctions Système --- + +sys.info <- function() Sys.info() +sys.getenv <- function() Sys.getenv() +sys.setenv <- function(var, val) Sys.setenv(var = val) +sys.time <- function() Sys.time() +sys.date <- function() Sys.Date() +sys.sleep <- function(n) Sys.sleep(n) +sys.which <- function(s) Sys.which(s) +sys.timezone <- function() Sys.timezone() +sys.setlocale <- function() Sys.setlocale() + +# --- Utilitaires de Typage --- + +#' @title Ajout d'une structure de classe +struct <- function(x, new_class, typed_dim = NULL) { + if (is.null(x)) return(NULL) + + # Fusion propre des classes sans doublons + class(x) <- unique(c(class(x), new_class)) + + if (!is.null(typed_dim)) { + attr(x, "typed_dim") <- typed_dim + } + + x +} + +#' @title Changement forcé de type +let_type <- function(x, new_class) { + # On retire les classes actuelles puis on applique le nouveau constructeur + unclass(x) |> new_class() +} + +#' @title Fusion shallow pour le spread operator (`{ ...x, a = 1 }`) +spread <- function(base, override) { + out <- base + for (nm in names(override)) { + out[[nm]] <- override[[nm]] + } + out +} + +#' @title Fusion explicite/spread pour les constructeurs record (`TypeName:{ ...x }`) +typr_spread_record <- function(explicit, spread) { + if (is.null(spread)) { + return(explicit) + } + if (!is.list(spread)) { + stop("`.spread` must be a list (record) in TypR constructors.") + } + merged <- spread + merged[names(explicit)] <- explicit + merged +} + +#' @title Contrat de type runtime pour le mode `typr build --checked` +#' (soundness_transpilation.md Phase A). Jamais un mode de production : un +#' oracle sémantique pour lab/ et cases/. `expected` est soit une chaîne +#' `typeof` simple ("integer"/"double"/"character"/"logical"/"list"), soit +#' un vecteur de classes nommées (tête = classe canonique, reste = les +#' variantes acceptées pour un type union). +typr_assert_type <- function(x, expected, loc, what) { + ok <- if (is.character(expected) && length(expected) > 1L) { + any(vapply(expected, function(cls) inherits(x, cls), logical(1))) + } else { + switch(expected, + integer = is.integer(x) || (is.numeric(x) && all(x == trunc(x))), + double = is.double(x), + character = is.character(x), + logical = is.logical(x), + list = is.list(x), + inherits(x, expected)) + } + if (!isTRUE(ok)) { + stop(sprintf( + "[typr --checked] %s at %s: expected %s, got class <%s> (typeof %s)", + what, loc, paste(expected, collapse = "/"), paste(class(x), collapse = ","), + typeof(x)), call. = FALSE) + } + 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") +apply <- function(X, ...) UseMethod("apply") +reduce <- function(vec, ...) UseMethod("reduce") +fold <- function(vec, ...) UseMethod("fold") +as_vec <- function(x, ...) UseMethod("as_vec") +max <- function(a, ...) UseMethod("max") +min <- function(a, ...) UseMethod("min") +replace <- function(a, ...) UseMethod("replace") +append <- function(a, ...) UseMethod("append") +plot <- function(a, ...) UseMethod("plot") +expect_equal <- function(a, ...) UseMethod("expect_equal") +set <- function(a, ...) UseMethod("set") +update <- function(a, ...) UseMethod("update") +state <- function(a, ...) UseMethod("state") +map <- function(a, ...) UseMethod("map") +derive <- function(a, ...) UseMethod("derive") +unwrap <- function(a, ...) UseMethod("unwrap") +unwrap_or <- function(a, ...) UseMethod("unwrap_or") +expect <- function(a, ...) UseMethod("expect") +is_some <- function(a, ...) UseMethod("is_some") +is_none <- function(a, ...) UseMethod("is_none") +factor <- function(a, ...) UseMethod("factor") +annotate_factor <- function(a, ...) UseMethod("annotate_factor") +nlevels <- function(a, ...) UseMethod("nlevels") + +# User validator hook for record types: `as.T` calls validate() after the +# internal validate_T. Default is identity; users extend it with validate.T. +validate <- function(x, ...) UseMethod("validate") +validate.default <- function(x) x + +# --- Méthodes par défaut et types de base --- + +max.default <- function(a, ...) base::max(a, ...) +min.default <- function(a, ...) base::min(a, ...) +replace.default <- function(a, ...) base::replace(a, ...) +append.default <- function(a, ...) base::append(a, ...) +plot.default <- function(a, ...) base::plot(a, ...) +expect_equal.default <- function(a, ...) testthat::expect_equal(a, ...) +# `cat` is a plain (non-S3-generic) base-R function; the `.default` is both the +# UseMethod fallback and where TypR's own boxing is peeled off each argument so +# `cat("x:", x)` prints bare values, not `attr(,"class")` noise. +cat.default <- function(...) { + args <- lapply(list(...), to_native) + do.call(base::cat, args) + invisible(NULL) +} + +# The string/file/system helpers below (default.ty, file.ty, system.ty) are +# plain, non-S3-generic base-R functions (see r_name_lint's Phase C database, +# soundness_transpilation.md). Without a `.default` fallback, the UseMethod +# stub generic_functions.R emits for each of them clobbers the base function +# with zero method to dispatch to — the exact `nlevels` bug shape, caught for +# all of these at once when the Phase C lint was first run against a fresh +# project. Argument order below mirrors each `.ty` signature (see +# configs/std/default.ty / file.ty / system.ty), which already matches base +# R's own argument order in every case. +nchar.default <- function(a, ...) base::nchar(a, ...) +sd.default <- function(a, ...) base::sd(a, ...) +substr.default <- function(a, b, e, ...) base::substr(a, b, e) +sub.default <- function(a, b, c, ...) base::sub(a, b, c, ...) +gsub.default <- function(a, b, c, ...) base::gsub(a, b, c, ...) +strsplit.default <- function(s, d, ...) base::strsplit(s, d, ...)[[1]] +tolower.default <- function(a, ...) base::tolower(a) +toupper.default <- function(a, ...) base::toupper(a) +startsWith.default <- function(a, b, ...) base::startsWith(a, b) +endsWith.default <- function(a, b, ...) base::endsWith(a, b) +grepl.default <- function(a, b, ...) base::grepl(a, b, ...) +setwd.default <- function(a, ...) base::setwd(a) +unlink.default <- function(a, ...) base::unlink(a, ...) == 0L +system2.default <- function(command, args, stdout, stderr, stdin, ...) { + base::system2(command, args = args, stdout = stdout, stderr = stderr, stdin = stdin, ...) +} + +# `version` is only ever meaningfully defined for `State` (see +# `version.State` below) — there is no sensible base-R fallback, so the +# `.default` gives a clear, localized error instead of the generic "no +# applicable method" UseMethod message. +version.default <- function(x, ...) { + stop(sprintf( + "version() is only defined for State values, got class <%s>", + paste(class(x), collapse = ",")), call. = FALSE) +} + +apply.default <- function(X, ...) { + # Si MARGIN est présent, c'est probablement un appel à base::apply + args <- list(...) + if (is.numeric(X) || is.array(X) || is.matrix(X)) { + return(do.call(base::apply, c(list(X), args))) + } + base::apply(X, ...) +} + +get.default <- function(a, name, ...) { + if (is.list(a)) a[[name]] else base::get(name, pos = a, ...) +} + +# Arrays (and atomic vectors) are already iterable: `as_vec` is the identity, +# R iterates them natively in `for (x in ...)`. +as_vec.default <- function(x, ...) x + +set_at.default <- function(a, i, val) replace(a, i, val) + +get.data <- function(a, name) { + a$data[[1]] +} + +get.list <- function(a, name) { + a$data[[1]][[name]] +} + +get.any <- function(a, name) { + a[[name]] +} + +Integer <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Integer", "integer", "Any", "Generic"), typed_dim = dim) +} + +Number <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Number", "numeric", "Any", "Generic"), typed_dim = dim) +} + +Character <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Character", "character", "Any", "Generic"), typed_dim = dim) +} + +Boolean <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Boolean", "logical", "Any", "Generic"), typed_dim = dim) +} + +as.Generic <- function(x) x |> struct(c('Generic', 'Any')) + +# Opérateur de coalescence nulle utilitaire +`%||%` <- function(a, b) if (!is.null(a)) a else b + +# --- Affichage des types de base --- + +print.Integer <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Character <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Boolean <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Number <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } + +# --- Opérateurs --- + +`%==%.default` <- function(x, y) { + unclass(x) == unclass(y) +} + +# --- Classe typed_vec --- + +typed_vec <- function(..., dim = 0) { + x <- list(...) + + if (length(x) == 0) { + return(structure( + list(data = list()), + class = "typed_vec", + typed_dim = dim + )) + } + + # Vérification si tous les éléments sont déjà des typed_vec + all_typed <- vapply(x, inherits, logical(1), what = "typed_vec") + + if (all(all_typed)) { + combined_data <- unlist(lapply(x, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = dim + )) + } + + structure( + list(data = x), + class = "typed_vec", + typed_dim = dim + ) +} + +length.typed_vec <- function(x) { + length(x$data) +} + +# No length.default: `length` is one of R's internal S3-primitive generics +# (.S3PrimitiveGenerics), not a plain closure-based generic like nchar/sd. +# `length` is also blacklisted from the typed UseMethod-stub codegen +# (BLACKLIST in standard_library.rs), so bare `length()` still resolves to +# the real base primitive, which already falls through to its internal C +# implementation for any class with no matching method — no `.default` +# needed. Adding one that forwards via `base::length(x)` would NOT bypass +# dispatch (internal-generic re-dispatch fires again on the still-classed +# `x` regardless of namespace-qualification) and recurses infinitely for +# every classed value, confirmed with a minimal `length.default <- +# function(x, ...) base::length(x); length(structure(1:3, class="foo"))`. + +`[[.typed_vec` <- function(x, i) { + if (is.character(i)) { + lapply(x$data, `[[`, i) + } else { + x$data[[i]] + } +} + +get.typed_vec <- function(a, name) { + a$data[[1]][[name]] +} + +apply.typed_vec <- function(X, FUN, ...) { + # Appliquer la fonction à chaque élément de data et ré-emballer + typed_vec(lapply(X$data, FUN, ...)) +} + +vec_apply <- function(f, ...) { + args <- list(...) + + # Normalisation en typed_vec + args <- lapply(args, function(x) { + if (!inherits(x, "typed_vec")) typed_vec(x) else x + }) + + lengths <- vapply(args, length, integer(1)) + n <- max(lengths) + + if (any(lengths == 0)) { + return(typed_vec(dim = 0)) + } + + if (any(n %% lengths != 0)) { + stop("Incompatible vector lengths for recycling") + } + + # Recyclage des données + recycled <- lapply(args, function(x) { + if (length(x) == n) x$data else rep(x$data, length.out = n) + }) + + results <- vector("list", n) + for (i in seq_len(n)) { + elements <- lapply(recycled, `[[`, i) + results[[i]] <- do.call(f, elements) + } + + # Si tous les résultats sont des typed_vec, on les combine + all_typed <- vapply(results, inherits, logical(1), what = "typed_vec") + + if (all(all_typed) && length(results) > 0) { + combined_data <- unlist(lapply(results, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = n + )) + } + + structure( + list(data = results), + class = "typed_vec", + typed_dim = n + ) +} + +vec_apply_fun <- function(fun_vec, ...) { + if (!inherits(fun_vec, "typed_vec")) fun_vec <- typed_vec(fun_vec) + + args <- lapply(list(...), function(x) { + if (!inherits(x, "typed_vec")) typed_vec(x) else x + }) + + lengths <- c(length(fun_vec), vapply(args, length, integer(1))) + n <- max(lengths) + + if (any(lengths == 0)) return(typed_vec(dim = 0)) + if (any(n %% lengths != 0)) stop("Incompatible vector lengths") + + funs <- if (length(fun_vec) == n) fun_vec$data else rep(fun_vec$data, length.out = n) + recycled_args <- lapply(args, function(x) { + if (length(x) == n) x$data else rep(x$data, length.out = n) + }) + + results <- vector("list", n) + for (i in seq_len(n)) { + f <- funs[[i]] + params <- lapply(recycled_args, `[[`, i) + results[[i]] <- do.call(f, params) + } + + all_typed <- vapply(results, inherits, logical(1), what = "typed_vec") + + if (all(all_typed) && length(results) > 0) { + combined_data <- unlist(lapply(results, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = n + )) + } + + structure( + list(data = results), + class = "typed_vec", + typed_dim = n + ) +} + +reduce.typed_vec <- function(vec, f, init = NULL) { + if (!inherits(vec, "typed_vec")) vec <- typed_vec(vec) + + n <- length(vec) + if (n == 0) { + if (is.null(init)) stop("Cannot reduce empty vector without initial value") + return(init) + } + + # Utilisation de la fonction Reduce de base pour plus de robustesse + res <- if (is.null(init)) { + Reduce(f, vec$data) + } else { + Reduce(f, vec$data, init = init) + } + + # Si le résultat est un typed_vec, on extrait la donnée (comportement original) + if (inherits(res, "typed_vec")) res <- res$data[[1]] + + structure( + list(data = list(res)), + class = "typed_vec", + typed_dim = 1 + ) +} + +sum.default <- function(x, ...) base::sum(x, ...) + +# Étape ③ : reduce sur un vecteur atomique nu — résultat scalaire, conforme à +# la signature `@reduce: ([#N, T], (T, T) -> T) -> T` (contrairement au quirk +# préexistant de reduce.typed_vec qui ré-emballe dans un typed_vec). +reduce.default <- function(vec, f, init = NULL) { + vals <- as.list(unclass(vec)) + if (length(vals) == 0) { + if (is.null(init)) stop("Cannot reduce empty vector without initial value") + return(init) + } + if (is.null(init)) { + Reduce(function(a, b) f(a, b), vals) + } else { + Reduce(function(a, b) f(a, b), vals, init = init) + } +} + +sum.typed_vec <- function(x, ...) { + reduce(x, `+`) +} + +vec_reduce <- function(vec, f, init = NULL) reduce(vec, f, init) + +fold.typed_vec <- function(vec, init, f, ...) { + Reduce(function(acc, x) f(acc, x), vec$data, init) +} + +# Étape ③ : version atomique directe (un scalaire isolé se comporte comme un +# vecteur de longueur 1, comme avant via le wrap typed_vec). +fold.default <- function(vec, init, f, ...) { + Reduce(function(acc, el) f(acc, el), as.list(unclass(vec)), init) +} + +extend <- function(vec, ...) UseMethod("extend") + +extend.typed_vec <- function(vec, x) { + structure( + list(data = c(vec$data, list(x))), + class = "typed_vec", + typed_dim = length(vec) + 1L + ) +} + +# Étape ③ : extension d'un vecteur atomique nu — c() strip le boxing de +# l'élément ajouté, TypR ré-annote depuis le type statique si nécessaire. +extend.default <- function(vec, x) { + c(vec, x) +} + +vec_extend <- function(vec, x) extend(vec, x) + +print.typed_vec <- function(x, ...) { + dims <- attr(x, "typed_dim") + data <- x$data + n <- length(data) + + if (n == 0) { + cat("typed_vec(0)\n") + return(invisible(x)) + } + + # Si pas de dimensions ou 1D, affichage linéaire amélioré + if (is.null(dims) || length(dims) <= 1 || (length(dims) == 1 && dims == 0)) { + cat("typed_vec [", n, "]\n", sep = "") + for (i in seq_len(n)) { + cat("[", i, "] ", sep = "") + el <- data[[i]] + if (is.function(el)) cat("\n") else print(el) + } + return(invisible(x)) + } + + # --- Affichage Multidimensionnel --- + + # Helper pour formater un élément individuel + format_el <- function(el) { + if (is.function(el)) return("") + if (inherits(el, "typed_vec")) return(paste0("")) + s <- format(el) + if (length(s) > 1) paste0(s[1], "..") else s + } + + # Helper pour afficher une tranche 2D (Matrice) + print_slice2d <- function(slice_data, nr, nc, header = "") { + if (nchar(header) > 0) cat(header, "\n") + + # Pré-formatage pour l'alignement + formatted <- vapply(slice_data, format_el, character(1)) + + # Remplissage par ligne (row first) + mat_form <- matrix(formatted, nrow = nr, ncol = nc, byrow = TRUE) + + # On utilise explicitement base::apply pour éviter le conflit S3 + col_widths <- base::apply(mat_form, 2, function(col) max(nchar(col), 4)) + + # En-tête des colonnes + cat(" ") + for (j in 1:nc) { + cat(sprintf("[%*s,%d]", col_widths[j] - 2, "", j), " ") + } + cat("\n") + + # Lignes + for (i in 1:nr) { + cat(sprintf("[%d,] ", i)) + for (j in 1:nc) { + cat(sprintf("%*s", col_widths[j], mat_form[i, j]), " ") + } + cat("\n") + } + } + + if (length(dims) == 2) { + print_slice2d(data, dims[1], dims[2]) + } else { + # Pour 3D et plus : on itère sur les dimensions supérieures + slice_size <- dims[1] * dims[2] + num_slices <- n / slice_size + + high_dims <- dims[3:length(dims)] + + for (s in 1:num_slices) { + # Calcul des indices pour les dimensions > 2 (basé sur le stockage column-major de R) + # On utilise arrayInd pour retrouver les coordonnées multi-D à partir de l'index linéaire + coords <- arrayInd(s, high_dims) + header <- paste0(", , ", paste(coords, collapse = ", ")) + + start_idx <- (s - 1) * slice_size + 1 + end_idx <- s * slice_size + + print_slice2d(data[start_idx:end_idx], dims[1], dims[2], header = header) + if (s < num_slices) cat("\n") + } + } + + invisible(x) +} + +# --- Option --- +# .Some(v)/.None transpile to structure(list('Some', body = v), class = +# c('Some', 'Option', 'Tag', 'list')) / structure(list('None'), class = +# c('None', 'Option', 'Tag', 'list')) — see the union variant constructor +# pipeline. Both variants share the 'Option' class, so a single method +# dispatching on it (rather than separate .Some/.None methods) covers both. + +unwrap.Option <- function(value, ...) { + if (inherits(value, "Some")) value$body else stop("The value is not unwrappable.") +} + +expect.Option <- function(value, msg, ...) { + if (inherits(value, "Some")) value$body else stop(msg) +} + +unwrap_or.Option <- function(value, alternative, ...) { + if (inherits(value, "Some")) value$body else alternative +} + +is_some.Option <- function(value, ...) inherits(value, "Some") + +is_none.Option <- function(value, ...) inherits(value, "None") + +# --- Factor --- + +# Standard constructor: looks up `x` in `levels` and returns the matching +# 1-based index (delegates the bounds check to annotate_factor). +# `factor` is also a base-R name; without `.default`, generic_functions.R's +# auto-generated UseMethod stub would shadow it with no fallback method. +factor.default <- function(x, levels, ...) { + idx <- match(x, levels) + if (is.na(idx)) { + stop(paste0("'", x, "' is not among Factor levels: [", paste(levels, collapse = ", "), "]")) + } + annotate_factor(idx, levels) +} + +# Annotator: wraps an integer index as an R factor with the given levels. +# Validates that 1 <= x <= length(levels). +annotate_factor.default <- function(x, levels, ...) { + if (x < 1L || x > length(levels)) { + stop(paste0("index ", x, " out of bounds for Factor[", paste(levels, collapse = ", "), "]")) + } + structure(x, class = "factor", levels = levels) +} + +# base::nlevels is a plain function (length(levels(x))), not S3-generic, so +# it has no registered nlevels.factor method for generic_functions.R's +# UseMethod stub to dispatch to. .default replicates base's own definition. +nlevels.default <- function(x, ...) length(levels(x)) + +# --- State --- +# Real shared mutable state, backed by an R environment (reference type). +# `version` auto-increments on every set()/update(). The constructor +# captures class(value) as a per-instance runtime fingerprint; set/update +# validate new values against it (defense-in-depth; the TypR static type +# checker is the real guarantee against misuse in well-typed code). +# +# `state` is defined as `state.default` (not a plain function): the project +# build's generated R/generic_functions.R emits `state <- function(x, ...) +# UseMethod('state', x)` for every typed stdlib name, which would otherwise +# shadow a plain `state <- function(value) {...}` definition here. Dispatch +# on .default catches every input class, since the constructor accepts any T. +state.default <- function(value, ...) { + e <- new.env(parent = emptyenv()) + e$value <- value + e$version <- 0L + e$.tag <- class(value) + structure(e, class = "State") +} + +get.State <- function(s, ...) s$value + +set.State <- function(s, value, ...) { + if (!identical(class(value), s$.tag)) { + stop(paste0( + "State type mismatch: expected ", paste(s$.tag, collapse = "/"), + ", got ", paste(class(value), collapse = "/") + )) + } + s$value <- value + s$version <- s$version + 1L + invisible(NULL) +} + +update.State <- function(s, f, ...) { + set.State(s, f(s$value)) +} + +version.State <- function(s, ...) s$version + +# Étape ③ (unification_arrays.md) : les tableaux d'éléments primitifs sont +# des vecteurs atomiques R nus. typr_collect est le collecteur commun de +# map : résultats tous scalaires « strippables » (atomiques, longueur 1, sans +# classe ou avec seulement le boxing transparent TypR) → vecteur atomique +# (unlist strip le boxing par élément), sinon typed_vec (records, valeurs à +# classe porteuse comme factor/alias opaques, éléments vecteurs). Helpers nus +# sans @signature (même statut que typed_vec/vec_apply/struct : jamais de +# stub UseMethod). +typr_scalar_strippable <- function(el) { + if (!is.atomic(el) || length(el) != 1L) return(FALSE) + cls <- attr(el, "class") + is.null(cls) || all(cls %in% c( + "Integer", "Number", "Character", "Boolean", + "integer", "numeric", "character", "logical", "Any", "Generic" + )) +} + +typr_collect <- function(results, dim = NULL) { + if (length(results) > 0L && + all(vapply(results, typr_scalar_strippable, logical(1))) && + length(unique(vapply(results, typeof, character(1)))) == 1L) { + unlist(results, use.names = FALSE) + } else if (is.null(dim)) { + do.call(typed_vec, results) + } else { + structure(list(data = results), class = "typed_vec", typed_dim = dim) + } +} + +# map composite -> primitif : le type statique du résultat est un tableau +# atomique, la valeur doit suivre (étape ③). +map.typed_vec <- function(a, f, ...) { + typr_collect(lapply(a$data, f), dim = attr(a, "typed_dim")) +} + +map.default <- function(a, f, ...) { + typr_collect(lapply(as.list(unclass(a)), f)) +} + +map.State <- function(s, f, ...) state(f(s$value)) + +filter.typed_vec <- function(a, f, ...) { + structure( + list(data = Filter(f, a$data)), + class = "typed_vec", + typed_dim = attr(a, "typed_dim") + ) +} + +filter.default <- function(a, f, ...) { + keep <- vapply(as.list(unclass(a)), function(el) isTRUE(all(unclass(f(el)))), logical(1)) + unclass(a)[keep] +} + +# --- Eq / Ord (ord.ty) --- +# Atomic element arrays dispatch to base R's own (already S3-generic) +# unique()/sort() — real ordering/equality on int/num/char/bool at C speed. +# Composite (typed_vec) arrays call the element type's own eq()/compare(), +# resolved by ordinary S3 dispatch at each call, exactly like unique.Point +# would if it existed as a dedicated method. +# +# Same footgun as `length.default` above, generalized: `unique`/`sort` are +# themselves plain closure-based S3 generics in base R (`function(x, ...) +# UseMethod("unique")`), so calling `base::unique(a, ...)` from inside our +# OWN `unique.default` re-enters `UseMethod("unique")`, which (namespace +# qualification notwithstanding — S3 dispatch resolves by search path, not +# lexical scope) finds our shadowing `unique.default` again and recurses +# forever (confirmed with a minimal repro: "C stack usage ... too close to +# the limit"). Call the base *method* directly instead of the generic. +unique.default <- function(a, ...) base:::unique.default(a, ...) +sort.default <- function(a, ...) base:::sort.default(a, ...) + +#' @title Invoke an implicit typeclass method (`eq`, `compare`, …) by name +#' +#' `unique.typed_vec`/`sort.typed_vec` live in std.R, sourced by +#' `load_module.R` into their own isolated environment (`parent = baseenv()`) +#' — a project file that `@include`s std.R gets a one-way COPY of its +#' bindings into its own separate environment, so a bare `eq(...)` call from +#' inside a std.R-defined function can never see a user's `eq.Point` (defined +#' only in the project's own environment): the copy doesn't create a shared +#' scope, and the function's closure still points at std.R's own (unrelated) +#' environment. +#' +#' Simply *fetching* the `eq` generic stub via `get(..., envir = parent.frame())` +#' and calling it directly isn't enough either: `eq` itself is a `UseMethod` +#' stub (`function(x, ...) UseMethod("eq")`, from generic_functions.R, a +#' *third*, separately-isolated environment) — dispatching to `eq.Point` +#' happens inside *that* call, whose own calling context is then this +#' function's frame, not the original project caller, so the inner dispatch +#' still fails to find `eq.Point`. Evaluating the call expression itself +#' (`eval(as.call(...), envir = ...)`) inside the caller's environment fixes +#' this at every level: any `UseMethod` dispatch that happens *during* that +#' evaluation resolves against that environment's lexical chain, where the +#' project's own `eq.Point` (or `eq.`) is visible — confirmed with a +#' minimal repro mirroring load_module.R's three-environment split (std.R / +#' generic_functions.R / project file). +typr_call_method <- function(name, ...) { + eval(as.call(c(as.name(name), list(...))), envir = parent.frame(2)) +} + +unique.typed_vec <- function(a, ...) { + out <- list() + for (el in a$data) { + is_dup <- FALSE + for (o in out) { + if (isTRUE(typr_call_method("eq", el, o))) { + is_dup <- TRUE + break + } + } + if (!is_dup) out[[length(out) + 1L]] <- el + } + structure(list(data = out), class = "typed_vec", typed_dim = length(out)) +} + +sort.typed_vec <- function(a, ...) { + data <- a$data + n <- length(data) + if (n >= 2) { + for (i in seq(2L, n)) { + key <- data[[i]] + j <- i - 1L + while (j >= 1L && typr_call_method("compare", data[[j]], key) > 0L) { + data[[j + 1L]] <- data[[j]] + j <- j - 1L + } + data[[j + 1L]] <- key + } + } + structure(list(data = data), class = "typed_vec", typed_dim = attr(a, "typed_dim")) +} + +derive.State <- function(s, f, ...) state(f(s$value)) + +# --- Interop (Niveau 0) --- +# to_native: lowering récursif TypR→R plat à la frontière des packages R externes. +# from_native: lifting R natif→TypR piloté par un descripteur de type ("int","num","char","bool","Any"). +# Ces fonctions sont la fondation du plan d'interop; elles ne modifient pas l'état TypR interne. + +to_native <- function(x, ...) UseMethod("to_native") + +to_native.default <- function(x, ...) x +to_native.Integer <- function(x, ...) as.integer(unclass(x)) +to_native.Number <- function(x, ...) as.numeric(unclass(x)) +to_native.Character <- function(x, ...) as.character(unclass(x)) +to_native.Boolean <- function(x, ...) as.logical(unclass(x)) + +to_native.typed_vec <- function(x, ...) { + if (length(x$data) == 0L) return(list()) + # lambda wrapper: keeps the closure chain so UseMethod can find to_native.* methods + # when called via lapply in load_module's isolated environments (parent=baseenv) + elems <- lapply(x$data, function(el) to_native(el)) + # Collapse to atomic vector when all elements share the same base storage type + if (all(vapply(elems, is.atomic, logical(1))) && + length(unique(vapply(elems, typeof, character(1)))) == 1L) { + do.call(c, elems) + } else { + elems + } +} + +# Handles records (class c("T","list")) and Tag variants not matched by a more specific method. +to_native.list <- function(x, ...) { + # lambda wrapper: keeps the closure chain so UseMethod can find to_native.* methods + # when called via lapply in load_module's isolated environments (parent=baseenv) + result <- lapply(unclass(x), function(el) to_native(el)) + class(result) <- NULL + result +} + +to_native.Some <- function(x, ...) to_native(x$body) +to_native.None <- function(x, ...) NULL +to_native.State <- function(x, ...) to_native(get.State(x)) + +from_native <- function(x, ...) UseMethod("from_native") + +# type_desc: "int" | "num" | "char" | "bool" | "Any" (default = identity) +from_native.default <- function(x, type_desc = "Any", ...) { + if (is.null(x)) return(NULL) + switch(type_desc, + "int" = Integer(as.integer(x)), + "num" = Number(as.numeric(x)), + "char" = Character(as.character(x)), + "bool" = Boolean(as.logical(x)), + x + ) +} + +# Typed lifting helpers: convert a plain R value to a specific TypR type. +# These are the idiomatic way to bring native R values into TypR-typed code. +from_int <- function(x, ...) UseMethod("from_int") +from_num <- function(x, ...) UseMethod("from_num") +from_char <- function(x, ...) UseMethod("from_char") +from_bool <- function(x, ...) UseMethod("from_bool") + +from_int.default <- function(x, ...) Integer(as.integer(x)) +from_num.default <- function(x, ...) Number(as.numeric(x)) +from_char.default <- function(x, ...) Character(as.character(x)) +from_bool.default <- function(x, ...) Boolean(as.logical(x)) + +# --- Interop (Niveau 2) --- +# from_nullable / to_nullable: bridge between R NULL and TypR Option. +# Use from_nullable when an @extern function may return NULL to represent absence. +# Use to_nullable when passing an Option to an external function expecting NULL. + +from_nullable <- function(x, ...) UseMethod("from_nullable") +from_nullable.default <- function(x, ...) { + if (is.null(x)) + structure(list("None"), class = c("None", "Option", "Tag", "list")) + else + structure(list("Some", body = x), class = c("Some", "Option", "Tag", "list")) +} + +to_nullable <- function(x, ...) UseMethod("to_nullable") +to_nullable.Option <- function(x, ...) if (inherits(x, "Some")) x$body else NULL +to_nullable.default <- function(x, ...) x + +# Foreign: identity function that serves as the runtime root for opaque Foreign aliases. +# `type DataFrame <- Foreign` compiles to `DataFrame <- Foreign` in R, so calling +# `DataFrame(x)` returns `x` unchanged — native R objects (data.frame, R6, S4, etc.) +# pass through without boxing. to_native.default already handles Foreign values correctly. +Foreign <- function(x, ...) x + + +=== R/types.R === +#' @include generic_functions.R +as.Function1 <- function(x) x |> struct(c('Function1', 'Any')) +as.Array1 <- function(x) x |> struct(c('Array1', 'numeric', 'Any')) +as.Function0 <- function(x) x |> struct(c('Function0', 'Any')) +as.Array0 <- function(x) x |> struct(c('Array0', 'numeric', 'numeric', 'Any')) +as.Ord <- function(x) x |> struct(c('Ord', 'Any')) +as.Eq <- function(x) x |> struct(c('Eq', 'Any')) +as.Foreign <- function(x) x |> struct(c('Foreign', 'Any', 'None')) +as.Function58 <- function(x) x |> struct(c('Function58', 'Any')) +as.State0 <- function(x) x |> struct(c('State0', 'Any')) +as.State <- function(x) x |> struct(c('State', 'Any', 'None')) +as.Function53 <- function(x) x |> struct(c('Function53', 'Any')) +as.Function52 <- function(x) x |> struct(c('Function52', 'Any')) +as.Factor0 <- function(x) x |> struct(c('Factor0', 'Any')) +as.Factor <- function(x) x |> struct(c('Factor', 'integer', 'Any')) +as.Tag0 <- function(x) x |> struct(c('Tag0', 'Any')) +as.Array4 <- function(x) x |> struct(c('Array4', 'numeric', 'numeric', 'numeric', 'Any')) +as.Function38 <- function(x) x |> struct(c('Function38', 'Any')) +as.Union0 <- function(x) x |> struct(c('Union0', 'Any')) +as.Function33 <- function(x) x |> struct(c('Function33', 'Any')) +as.Function31 <- function(x) x |> struct(c('Function31', 'Any')) +as.Function30 <- function(x) x |> struct(c('Function30', 'Any')) +as.Function29 <- function(x) x |> struct(c('Function29', 'Any')) +as.Array1 <- function(x) x |> struct(c('Array1', 'integer', 'Any')) +as.Function19 <- function(x) x |> struct(c('Function19', 'Any')) +as.Function17 <- function(x) x |> struct(c('Function17', 'Any')) +as.Function16 <- function(x) x |> struct(c('Function16', 'Any')) +as.Function15 <- function(x) x |> struct(c('Function15', 'Any')) +as.Function14 <- function(x) x |> struct(c('Function14', 'Any')) +as.Function13 <- function(x) x |> struct(c('Function13', 'Any')) +as.Function11 <- function(x) x |> struct(c('Function11', 'Any')) +as.Function9 <- function(x) x |> struct(c('Function9', 'Any')) +as.Function8 <- function(x) x |> struct(c('Function8', 'Any')) +as.Empty0 <- function(x) x |> struct(c('Empty0', 'integer', 'integer', 'numeric', 'numeric', 'numeric', 'Function0', 'Function1', 'Any')) +as.Function2 <- function(x) x |> struct(c('Function2', 'Any')) +as.Function1 <- function(x) x |> struct(c('Function1', 'Any')) +as.Function0 <- function(x) x |> struct(c('Function0', 'Any')) +as.Integer <- function(x) x |> struct(c('Integer', 'integer', 'Any')) +as.Character <- function(x) x |> struct(c('Character', 'character', 'Any')) +as.Number <- function(x) x |> struct(c('Number', 'numeric', 'Any')) +as.Boolean <- function(x) x |> struct(c('Boolean', 'logical', 'Any')) diff --git a/cases/0093-refinement-lost-on-call-argument/repro/DESCRIPTION b/cases/0093-refinement-lost-on-call-argument/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0093-refinement-lost-on-call-argument/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/0093-refinement-lost-on-call-argument/repro/NAMESPACE b/cases/0093-refinement-lost-on-call-argument/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0093-refinement-lost-on-call-argument/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0093-refinement-lost-on-call-argument/repro/R/.gitkeep b/cases/0093-refinement-lost-on-call-argument/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0093-refinement-lost-on-call-argument/repro/TypR/main.ty b/cases/0093-refinement-lost-on-call-argument/repro/TypR/main.ty new file mode 100644 index 00000000..16037f7e --- /dev/null +++ b/cases/0093-refinement-lost-on-call-argument/repro/TypR/main.ty @@ -0,0 +1,3 @@ +let h <- fn(n: int, x: [num] & length(>= 1)): num { x[1] }; +let read_x <- fn(): [num] { [1.0, 2.0] }; +let a <- h(2, read_x()); diff --git a/cases/0094-user-fn-named-like-builtin-skips-check/case.toml b/cases/0094-user-fn-named-like-builtin-skips-check/case.toml new file mode 100644 index 00000000..f3cb2bfb --- /dev/null +++ b/cases/0094-user-fn-named-like-builtin-skips-check/case.toml @@ -0,0 +1,7 @@ +title = "user function named like an R builtin (norm) accepts an incompatible call" +cmd = "build" +layer = "type" +status = "open" +created = "2026-10-01" +origin = "perso" +checked = false diff --git a/cases/0094-user-fn-named-like-builtin-skips-check/expect.md b/cases/0094-user-fn-named-like-builtin-skips-check/expect.md new file mode 100644 index 00000000..afcdb849 --- /dev/null +++ b/cases/0094-user-fn-named-like-builtin-skips-check/expect.md @@ -0,0 +1,3 @@ +`let norm <- fn(x: [3, num]): num {...}; norm([1.0, 2.0]);` type-checks. Renaming the function +(`rescale`) gives the expected error. A user definition sharing a name with an R builtin +(`norm`, from base) seems to fall back to `any` on call resolution, silently dropping the check. diff --git a/cases/0094-user-fn-named-like-builtin-skips-check/expect.toml b/cases/0094-user-fn-named-like-builtin-skips-check/expect.toml new file mode 100644 index 00000000..50844734 --- /dev/null +++ b/cases/0094-user-fn-named-like-builtin-skips-check/expect.toml @@ -0,0 +1,4 @@ +# norm([1.0, 2.0]) does not match [3, num]: must be a type error, so no R file +[[rule]] +file = "R/main.R" +must_not_contain = "`r` <-" diff --git a/cases/0094-user-fn-named-like-builtin-skips-check/observed.txt b/cases/0094-user-fn-named-like-builtin-skips-check/observed.txt new file mode 100644 index 00000000..5401ee01 --- /dev/null +++ b/cases/0094-user-fn-named-like-builtin-skips-check/observed.txt @@ -0,0 +1,1147 @@ +# exit=0 + +## stdout/stderr + 0 .Rd file(s) written to man/ +R code successfully generated in the R/ folder + + Parsing... done (4 ms) + Type checking... done (12 ms) + Transpiling... done (47 ms) + Writing R files... done (53 ms) + Generating .Rd files... done (0 ms) + Generating vignette fragments... done (0 ms) + Compiling vignette templates... done (0 ms) + Updating NAMESPACE... failed +Warning: NAMESPACE update failed (devtools/roxygen2 may not be installed). + +R version 4.6.1 (2026-06-24) -- "Happy Hop" +Copyright (C) 2026 The R Foundation for Statistical Computing +Platform: x86_64-pc-linux-gnu + +R est un logiciel libre livré sans AUCUNE GARANTIE. +Vous pouvez le redistribuer sous certaines conditions. +Tapez 'license()' ou 'licence()' pour plus de détails. + +R est un projet collaboratif avec de nombreux contributeurs. +Tapez 'contributors()' pour plus d'information et +'citation()' pour la façon de le citer dans les publications. + +Tapez 'demo()' pour des démonstrations, 'help()' pour l'aide +en ligne ou 'help.start()' pour obtenir l'aide au format HTML. +Tapez 'q()' pour quitter R. + +> devtools::document('/tmp/typr-case-1233301-1790866964434603078/work', roclets = c('collate', 'namespace')) + +Erreur dans loadNamespace(x) : aucun package nommé ‘devtools’ n'est trouvé +Appels : loadNamespace -> withRestarts -> withOneRestart -> doWithOneRestart +Exécution arrêtée + + + +## R généré +=== R/generic_functions.R === +#' @include std.R +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +annotate_factor <- function(x, ...) UseMethod('annotate_factor', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as_vec <- function(x, ...) UseMethod('as_vec', x) +#' @export +bsystem2 <- function(x, ...) UseMethod('bsystem2', x) +#' @export +contains <- function(x, ...) UseMethod('contains', x) +#' @export +cvec <- function(x, ...) UseMethod('cvec', x) +#' @export +derive <- function(x, ...) UseMethod('derive', x) +#' @export +dir <- function(x, ...) UseMethod('dir', x) +#' @export +dir__create <- function(x, ...) UseMethod('dir__create', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +dot <- function(x, ...) UseMethod('dot', x) +#' @export +endsWith <- function(x, ...) UseMethod('endsWith', x) +#' @export +exec <- function(x, ...) UseMethod('exec', x) +#' @export +expect <- function(x, ...) UseMethod('expect', x) +#' @export +expect_equal <- function(x, ...) UseMethod('expect_equal', x) +#' @export +expect_false <- function(x, ...) UseMethod('expect_false', x) +#' @export +extend <- function(x, ...) UseMethod('extend', x) +#' @export +factor <- function(x, ...) UseMethod('factor', x) +#' @export +file__copy <- function(x, ...) UseMethod('file__copy', x) +#' @export +file__create <- function(x, ...) UseMethod('file__create', x) +#' @export +file__exists <- function(x, ...) UseMethod('file__exists', x) +#' @export +file__remove <- function(x, ...) UseMethod('file__remove', x) +#' @export +file__rename <- function(x, ...) UseMethod('file__rename', x) +#' @export +filter <- function(x, ...) UseMethod('filter', x) +#' @export +fold <- function(x, ...) UseMethod('fold', x) +#' @export +get <- function(x, ...) UseMethod('get', x) +#' @export +getwd <- function(x, ...) UseMethod('getwd', x) +#' @export +is_none <- function(x, ...) UseMethod('is_none', x) +#' @export +is_some <- function(x, ...) UseMethod('is_some', x) +#' @export +join <- function(x, ...) UseMethod('join', x) +#' @export +levels <- function(x, ...) UseMethod('levels', x) +#' @export +list__files <- function(x, ...) UseMethod('list__files', x) +#' @export +lvec <- function(x, ...) UseMethod('lvec', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +nlevels <- function(x, ...) UseMethod('nlevels', x) +#' @export +norm <- function(x, ...) UseMethod('norm', x) +#' @export +plot <- function(x, ...) UseMethod('plot', x) +#' @export +reduce <- function(x, ...) UseMethod('reduce', x) +#' @export +seq <- function(x, ...) UseMethod('seq', x) +#' @export +set <- function(x, ...) UseMethod('set', x) +#' @export +set_at <- function(x, ...) UseMethod('set_at', x) +#' @export +setwd <- function(x, ...) UseMethod('setwd', x) +#' @export +sort <- function(x, ...) UseMethod('sort', x) +#' @export +startsWith <- function(x, ...) UseMethod('startsWith', x) +#' @export +state <- function(x, ...) UseMethod('state', x) +#' @export +sum <- function(x, ...) UseMethod('sum', x) +#' @export +system2 <- function(x, ...) UseMethod('system2', x) +#' @export +t <- function(x, ...) UseMethod('t', x) +#' @export +to_native <- function(x, ...) UseMethod('to_native', x) +#' @export +to_nullable <- function(x, ...) UseMethod('to_nullable', x) +#' @export +unique <- function(x, ...) UseMethod('unique', x) +#' @export +unlink <- function(x, ...) UseMethod('unlink', x) +#' @export +unwrap <- function(x, ...) UseMethod('unwrap', x) +#' @export +unwrap_or <- function(x, ...) UseMethod('unwrap_or', x) +#' @export +update <- function(x, ...) UseMethod('update', x) +#' @export +version <- function(x, ...) UseMethod('version', x) + +# ---- Generated `.default` fallbacks (see r_name_lint.rs) ---- +# `norm` is not S3-generic in `base`: without this, the stub above +# would strand it ("no applicable method for 'norm'"). +norm.default <- function(...) base::norm(...) + + +=== R/main.R === +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +#' @method norm numeric +`norm.numeric` <- (function(x) { + { + 1 |> as.Number() + } |> + as.Number() +}) |> + as.Generic() + +norm(c(1 |> as.Number(), 2 |> as.Number()) |> identity()) + + +=== R/std.R === +# --- Fonctions Système --- + +sys.info <- function() Sys.info() +sys.getenv <- function() Sys.getenv() +sys.setenv <- function(var, val) Sys.setenv(var = val) +sys.time <- function() Sys.time() +sys.date <- function() Sys.Date() +sys.sleep <- function(n) Sys.sleep(n) +sys.which <- function(s) Sys.which(s) +sys.timezone <- function() Sys.timezone() +sys.setlocale <- function() Sys.setlocale() + +# --- Utilitaires de Typage --- + +#' @title Ajout d'une structure de classe +struct <- function(x, new_class, typed_dim = NULL) { + if (is.null(x)) return(NULL) + + # Fusion propre des classes sans doublons + class(x) <- unique(c(class(x), new_class)) + + if (!is.null(typed_dim)) { + attr(x, "typed_dim") <- typed_dim + } + + x +} + +#' @title Changement forcé de type +let_type <- function(x, new_class) { + # On retire les classes actuelles puis on applique le nouveau constructeur + unclass(x) |> new_class() +} + +#' @title Fusion shallow pour le spread operator (`{ ...x, a = 1 }`) +spread <- function(base, override) { + out <- base + for (nm in names(override)) { + out[[nm]] <- override[[nm]] + } + out +} + +#' @title Fusion explicite/spread pour les constructeurs record (`TypeName:{ ...x }`) +typr_spread_record <- function(explicit, spread) { + if (is.null(spread)) { + return(explicit) + } + if (!is.list(spread)) { + stop("`.spread` must be a list (record) in TypR constructors.") + } + merged <- spread + merged[names(explicit)] <- explicit + merged +} + +#' @title Contrat de type runtime pour le mode `typr build --checked` +#' (soundness_transpilation.md Phase A). Jamais un mode de production : un +#' oracle sémantique pour lab/ et cases/. `expected` est soit une chaîne +#' `typeof` simple ("integer"/"double"/"character"/"logical"/"list"), soit +#' un vecteur de classes nommées (tête = classe canonique, reste = les +#' variantes acceptées pour un type union). +typr_assert_type <- function(x, expected, loc, what) { + ok <- if (is.character(expected) && length(expected) > 1L) { + any(vapply(expected, function(cls) inherits(x, cls), logical(1))) + } else { + switch(expected, + integer = is.integer(x) || (is.numeric(x) && all(x == trunc(x))), + double = is.double(x), + character = is.character(x), + logical = is.logical(x), + list = is.list(x), + inherits(x, expected)) + } + if (!isTRUE(ok)) { + stop(sprintf( + "[typr --checked] %s at %s: expected %s, got class <%s> (typeof %s)", + what, loc, paste(expected, collapse = "/"), paste(class(x), collapse = ","), + typeof(x)), call. = FALSE) + } + 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") +apply <- function(X, ...) UseMethod("apply") +reduce <- function(vec, ...) UseMethod("reduce") +fold <- function(vec, ...) UseMethod("fold") +as_vec <- function(x, ...) UseMethod("as_vec") +max <- function(a, ...) UseMethod("max") +min <- function(a, ...) UseMethod("min") +replace <- function(a, ...) UseMethod("replace") +append <- function(a, ...) UseMethod("append") +plot <- function(a, ...) UseMethod("plot") +expect_equal <- function(a, ...) UseMethod("expect_equal") +set <- function(a, ...) UseMethod("set") +update <- function(a, ...) UseMethod("update") +state <- function(a, ...) UseMethod("state") +map <- function(a, ...) UseMethod("map") +derive <- function(a, ...) UseMethod("derive") +unwrap <- function(a, ...) UseMethod("unwrap") +unwrap_or <- function(a, ...) UseMethod("unwrap_or") +expect <- function(a, ...) UseMethod("expect") +is_some <- function(a, ...) UseMethod("is_some") +is_none <- function(a, ...) UseMethod("is_none") +factor <- function(a, ...) UseMethod("factor") +annotate_factor <- function(a, ...) UseMethod("annotate_factor") +nlevels <- function(a, ...) UseMethod("nlevels") + +# User validator hook for record types: `as.T` calls validate() after the +# internal validate_T. Default is identity; users extend it with validate.T. +validate <- function(x, ...) UseMethod("validate") +validate.default <- function(x) x + +# --- Méthodes par défaut et types de base --- + +max.default <- function(a, ...) base::max(a, ...) +min.default <- function(a, ...) base::min(a, ...) +replace.default <- function(a, ...) base::replace(a, ...) +append.default <- function(a, ...) base::append(a, ...) +plot.default <- function(a, ...) base::plot(a, ...) +expect_equal.default <- function(a, ...) testthat::expect_equal(a, ...) +# `cat` is a plain (non-S3-generic) base-R function; the `.default` is both the +# UseMethod fallback and where TypR's own boxing is peeled off each argument so +# `cat("x:", x)` prints bare values, not `attr(,"class")` noise. +cat.default <- function(...) { + args <- lapply(list(...), to_native) + do.call(base::cat, args) + invisible(NULL) +} + +# The string/file/system helpers below (default.ty, file.ty, system.ty) are +# plain, non-S3-generic base-R functions (see r_name_lint's Phase C database, +# soundness_transpilation.md). Without a `.default` fallback, the UseMethod +# stub generic_functions.R emits for each of them clobbers the base function +# with zero method to dispatch to — the exact `nlevels` bug shape, caught for +# all of these at once when the Phase C lint was first run against a fresh +# project. Argument order below mirrors each `.ty` signature (see +# configs/std/default.ty / file.ty / system.ty), which already matches base +# R's own argument order in every case. +nchar.default <- function(a, ...) base::nchar(a, ...) +sd.default <- function(a, ...) base::sd(a, ...) +substr.default <- function(a, b, e, ...) base::substr(a, b, e) +sub.default <- function(a, b, c, ...) base::sub(a, b, c, ...) +gsub.default <- function(a, b, c, ...) base::gsub(a, b, c, ...) +strsplit.default <- function(s, d, ...) base::strsplit(s, d, ...)[[1]] +tolower.default <- function(a, ...) base::tolower(a) +toupper.default <- function(a, ...) base::toupper(a) +startsWith.default <- function(a, b, ...) base::startsWith(a, b) +endsWith.default <- function(a, b, ...) base::endsWith(a, b) +grepl.default <- function(a, b, ...) base::grepl(a, b, ...) +setwd.default <- function(a, ...) base::setwd(a) +unlink.default <- function(a, ...) base::unlink(a, ...) == 0L +system2.default <- function(command, args, stdout, stderr, stdin, ...) { + base::system2(command, args = args, stdout = stdout, stderr = stderr, stdin = stdin, ...) +} + +# `version` is only ever meaningfully defined for `State` (see +# `version.State` below) — there is no sensible base-R fallback, so the +# `.default` gives a clear, localized error instead of the generic "no +# applicable method" UseMethod message. +version.default <- function(x, ...) { + stop(sprintf( + "version() is only defined for State values, got class <%s>", + paste(class(x), collapse = ",")), call. = FALSE) +} + +apply.default <- function(X, ...) { + # Si MARGIN est présent, c'est probablement un appel à base::apply + args <- list(...) + if (is.numeric(X) || is.array(X) || is.matrix(X)) { + return(do.call(base::apply, c(list(X), args))) + } + base::apply(X, ...) +} + +get.default <- function(a, name, ...) { + if (is.list(a)) a[[name]] else base::get(name, pos = a, ...) +} + +# Arrays (and atomic vectors) are already iterable: `as_vec` is the identity, +# R iterates them natively in `for (x in ...)`. +as_vec.default <- function(x, ...) x + +set_at.default <- function(a, i, val) replace(a, i, val) + +get.data <- function(a, name) { + a$data[[1]] +} + +get.list <- function(a, name) { + a$data[[1]][[name]] +} + +get.any <- function(a, name) { + a[[name]] +} + +Integer <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Integer", "integer", "Any", "Generic"), typed_dim = dim) +} + +Number <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Number", "numeric", "Any", "Generic"), typed_dim = dim) +} + +Character <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Character", "character", "Any", "Generic"), typed_dim = dim) +} + +Boolean <- function(x) { + dim <- attr(x, "typed_dim") %||% length(x) + struct(x, c("Boolean", "logical", "Any", "Generic"), typed_dim = dim) +} + +as.Generic <- function(x) x |> struct(c('Generic', 'Any')) + +# Opérateur de coalescence nulle utilitaire +`%||%` <- function(a, b) if (!is.null(a)) a else b + +# --- Affichage des types de base --- + +print.Integer <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Character <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Boolean <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } +print.Number <- function(x, ...) { cat(unclass(x), fill = TRUE); invisible(x) } + +# --- Opérateurs --- + +`%==%.default` <- function(x, y) { + unclass(x) == unclass(y) +} + +# --- Classe typed_vec --- + +typed_vec <- function(..., dim = 0) { + x <- list(...) + + if (length(x) == 0) { + return(structure( + list(data = list()), + class = "typed_vec", + typed_dim = dim + )) + } + + # Vérification si tous les éléments sont déjà des typed_vec + all_typed <- vapply(x, inherits, logical(1), what = "typed_vec") + + if (all(all_typed)) { + combined_data <- unlist(lapply(x, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = dim + )) + } + + structure( + list(data = x), + class = "typed_vec", + typed_dim = dim + ) +} + +length.typed_vec <- function(x) { + length(x$data) +} + +# No length.default: `length` is one of R's internal S3-primitive generics +# (.S3PrimitiveGenerics), not a plain closure-based generic like nchar/sd. +# `length` is also blacklisted from the typed UseMethod-stub codegen +# (BLACKLIST in standard_library.rs), so bare `length()` still resolves to +# the real base primitive, which already falls through to its internal C +# implementation for any class with no matching method — no `.default` +# needed. Adding one that forwards via `base::length(x)` would NOT bypass +# dispatch (internal-generic re-dispatch fires again on the still-classed +# `x` regardless of namespace-qualification) and recurses infinitely for +# every classed value, confirmed with a minimal `length.default <- +# function(x, ...) base::length(x); length(structure(1:3, class="foo"))`. + +`[[.typed_vec` <- function(x, i) { + if (is.character(i)) { + lapply(x$data, `[[`, i) + } else { + x$data[[i]] + } +} + +get.typed_vec <- function(a, name) { + a$data[[1]][[name]] +} + +apply.typed_vec <- function(X, FUN, ...) { + # Appliquer la fonction à chaque élément de data et ré-emballer + typed_vec(lapply(X$data, FUN, ...)) +} + +vec_apply <- function(f, ...) { + args <- list(...) + + # Normalisation en typed_vec + args <- lapply(args, function(x) { + if (!inherits(x, "typed_vec")) typed_vec(x) else x + }) + + lengths <- vapply(args, length, integer(1)) + n <- max(lengths) + + if (any(lengths == 0)) { + return(typed_vec(dim = 0)) + } + + if (any(n %% lengths != 0)) { + stop("Incompatible vector lengths for recycling") + } + + # Recyclage des données + recycled <- lapply(args, function(x) { + if (length(x) == n) x$data else rep(x$data, length.out = n) + }) + + results <- vector("list", n) + for (i in seq_len(n)) { + elements <- lapply(recycled, `[[`, i) + results[[i]] <- do.call(f, elements) + } + + # Si tous les résultats sont des typed_vec, on les combine + all_typed <- vapply(results, inherits, logical(1), what = "typed_vec") + + if (all(all_typed) && length(results) > 0) { + combined_data <- unlist(lapply(results, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = n + )) + } + + structure( + list(data = results), + class = "typed_vec", + typed_dim = n + ) +} + +vec_apply_fun <- function(fun_vec, ...) { + if (!inherits(fun_vec, "typed_vec")) fun_vec <- typed_vec(fun_vec) + + args <- lapply(list(...), function(x) { + if (!inherits(x, "typed_vec")) typed_vec(x) else x + }) + + lengths <- c(length(fun_vec), vapply(args, length, integer(1))) + n <- max(lengths) + + if (any(lengths == 0)) return(typed_vec(dim = 0)) + if (any(n %% lengths != 0)) stop("Incompatible vector lengths") + + funs <- if (length(fun_vec) == n) fun_vec$data else rep(fun_vec$data, length.out = n) + recycled_args <- lapply(args, function(x) { + if (length(x) == n) x$data else rep(x$data, length.out = n) + }) + + results <- vector("list", n) + for (i in seq_len(n)) { + f <- funs[[i]] + params <- lapply(recycled_args, `[[`, i) + results[[i]] <- do.call(f, params) + } + + all_typed <- vapply(results, inherits, logical(1), what = "typed_vec") + + if (all(all_typed) && length(results) > 0) { + combined_data <- unlist(lapply(results, `[[`, "data"), recursive = FALSE) + return(structure( + list(data = combined_data), + class = "typed_vec", + typed_dim = n + )) + } + + structure( + list(data = results), + class = "typed_vec", + typed_dim = n + ) +} + +reduce.typed_vec <- function(vec, f, init = NULL) { + if (!inherits(vec, "typed_vec")) vec <- typed_vec(vec) + + n <- length(vec) + if (n == 0) { + if (is.null(init)) stop("Cannot reduce empty vector without initial value") + return(init) + } + + # Utilisation de la fonction Reduce de base pour plus de robustesse + res <- if (is.null(init)) { + Reduce(f, vec$data) + } else { + Reduce(f, vec$data, init = init) + } + + # Si le résultat est un typed_vec, on extrait la donnée (comportement original) + if (inherits(res, "typed_vec")) res <- res$data[[1]] + + structure( + list(data = list(res)), + class = "typed_vec", + typed_dim = 1 + ) +} + +sum.default <- function(x, ...) base::sum(x, ...) + +# Étape ③ : reduce sur un vecteur atomique nu — résultat scalaire, conforme à +# la signature `@reduce: ([#N, T], (T, T) -> T) -> T` (contrairement au quirk +# préexistant de reduce.typed_vec qui ré-emballe dans un typed_vec). +reduce.default <- function(vec, f, init = NULL) { + vals <- as.list(unclass(vec)) + if (length(vals) == 0) { + if (is.null(init)) stop("Cannot reduce empty vector without initial value") + return(init) + } + if (is.null(init)) { + Reduce(function(a, b) f(a, b), vals) + } else { + Reduce(function(a, b) f(a, b), vals, init = init) + } +} + +sum.typed_vec <- function(x, ...) { + reduce(x, `+`) +} + +vec_reduce <- function(vec, f, init = NULL) reduce(vec, f, init) + +fold.typed_vec <- function(vec, init, f, ...) { + Reduce(function(acc, x) f(acc, x), vec$data, init) +} + +# Étape ③ : version atomique directe (un scalaire isolé se comporte comme un +# vecteur de longueur 1, comme avant via le wrap typed_vec). +fold.default <- function(vec, init, f, ...) { + Reduce(function(acc, el) f(acc, el), as.list(unclass(vec)), init) +} + +extend <- function(vec, ...) UseMethod("extend") + +extend.typed_vec <- function(vec, x) { + structure( + list(data = c(vec$data, list(x))), + class = "typed_vec", + typed_dim = length(vec) + 1L + ) +} + +# Étape ③ : extension d'un vecteur atomique nu — c() strip le boxing de +# l'élément ajouté, TypR ré-annote depuis le type statique si nécessaire. +extend.default <- function(vec, x) { + c(vec, x) +} + +vec_extend <- function(vec, x) extend(vec, x) + +print.typed_vec <- function(x, ...) { + dims <- attr(x, "typed_dim") + data <- x$data + n <- length(data) + + if (n == 0) { + cat("typed_vec(0)\n") + return(invisible(x)) + } + + # Si pas de dimensions ou 1D, affichage linéaire amélioré + if (is.null(dims) || length(dims) <= 1 || (length(dims) == 1 && dims == 0)) { + cat("typed_vec [", n, "]\n", sep = "") + for (i in seq_len(n)) { + cat("[", i, "] ", sep = "") + el <- data[[i]] + if (is.function(el)) cat("\n") else print(el) + } + return(invisible(x)) + } + + # --- Affichage Multidimensionnel --- + + # Helper pour formater un élément individuel + format_el <- function(el) { + if (is.function(el)) return("") + if (inherits(el, "typed_vec")) return(paste0("")) + s <- format(el) + if (length(s) > 1) paste0(s[1], "..") else s + } + + # Helper pour afficher une tranche 2D (Matrice) + print_slice2d <- function(slice_data, nr, nc, header = "") { + if (nchar(header) > 0) cat(header, "\n") + + # Pré-formatage pour l'alignement + formatted <- vapply(slice_data, format_el, character(1)) + + # Remplissage par ligne (row first) + mat_form <- matrix(formatted, nrow = nr, ncol = nc, byrow = TRUE) + + # On utilise explicitement base::apply pour éviter le conflit S3 + col_widths <- base::apply(mat_form, 2, function(col) max(nchar(col), 4)) + + # En-tête des colonnes + cat(" ") + for (j in 1:nc) { + cat(sprintf("[%*s,%d]", col_widths[j] - 2, "", j), " ") + } + cat("\n") + + # Lignes + for (i in 1:nr) { + cat(sprintf("[%d,] ", i)) + for (j in 1:nc) { + cat(sprintf("%*s", col_widths[j], mat_form[i, j]), " ") + } + cat("\n") + } + } + + if (length(dims) == 2) { + print_slice2d(data, dims[1], dims[2]) + } else { + # Pour 3D et plus : on itère sur les dimensions supérieures + slice_size <- dims[1] * dims[2] + num_slices <- n / slice_size + + high_dims <- dims[3:length(dims)] + + for (s in 1:num_slices) { + # Calcul des indices pour les dimensions > 2 (basé sur le stockage column-major de R) + # On utilise arrayInd pour retrouver les coordonnées multi-D à partir de l'index linéaire + coords <- arrayInd(s, high_dims) + header <- paste0(", , ", paste(coords, collapse = ", ")) + + start_idx <- (s - 1) * slice_size + 1 + end_idx <- s * slice_size + + print_slice2d(data[start_idx:end_idx], dims[1], dims[2], header = header) + if (s < num_slices) cat("\n") + } + } + + invisible(x) +} + +# --- Option --- +# .Some(v)/.None transpile to structure(list('Some', body = v), class = +# c('Some', 'Option', 'Tag', 'list')) / structure(list('None'), class = +# c('None', 'Option', 'Tag', 'list')) — see the union variant constructor +# pipeline. Both variants share the 'Option' class, so a single method +# dispatching on it (rather than separate .Some/.None methods) covers both. + +unwrap.Option <- function(value, ...) { + if (inherits(value, "Some")) value$body else stop("The value is not unwrappable.") +} + +expect.Option <- function(value, msg, ...) { + if (inherits(value, "Some")) value$body else stop(msg) +} + +unwrap_or.Option <- function(value, alternative, ...) { + if (inherits(value, "Some")) value$body else alternative +} + +is_some.Option <- function(value, ...) inherits(value, "Some") + +is_none.Option <- function(value, ...) inherits(value, "None") + +# --- Factor --- + +# Standard constructor: looks up `x` in `levels` and returns the matching +# 1-based index (delegates the bounds check to annotate_factor). +# `factor` is also a base-R name; without `.default`, generic_functions.R's +# auto-generated UseMethod stub would shadow it with no fallback method. +factor.default <- function(x, levels, ...) { + idx <- match(x, levels) + if (is.na(idx)) { + stop(paste0("'", x, "' is not among Factor levels: [", paste(levels, collapse = ", "), "]")) + } + annotate_factor(idx, levels) +} + +# Annotator: wraps an integer index as an R factor with the given levels. +# Validates that 1 <= x <= length(levels). +annotate_factor.default <- function(x, levels, ...) { + if (x < 1L || x > length(levels)) { + stop(paste0("index ", x, " out of bounds for Factor[", paste(levels, collapse = ", "), "]")) + } + structure(x, class = "factor", levels = levels) +} + +# base::nlevels is a plain function (length(levels(x))), not S3-generic, so +# it has no registered nlevels.factor method for generic_functions.R's +# UseMethod stub to dispatch to. .default replicates base's own definition. +nlevels.default <- function(x, ...) length(levels(x)) + +# --- State --- +# Real shared mutable state, backed by an R environment (reference type). +# `version` auto-increments on every set()/update(). The constructor +# captures class(value) as a per-instance runtime fingerprint; set/update +# validate new values against it (defense-in-depth; the TypR static type +# checker is the real guarantee against misuse in well-typed code). +# +# `state` is defined as `state.default` (not a plain function): the project +# build's generated R/generic_functions.R emits `state <- function(x, ...) +# UseMethod('state', x)` for every typed stdlib name, which would otherwise +# shadow a plain `state <- function(value) {...}` definition here. Dispatch +# on .default catches every input class, since the constructor accepts any T. +state.default <- function(value, ...) { + e <- new.env(parent = emptyenv()) + e$value <- value + e$version <- 0L + e$.tag <- class(value) + structure(e, class = "State") +} + +get.State <- function(s, ...) s$value + +set.State <- function(s, value, ...) { + if (!identical(class(value), s$.tag)) { + stop(paste0( + "State type mismatch: expected ", paste(s$.tag, collapse = "/"), + ", got ", paste(class(value), collapse = "/") + )) + } + s$value <- value + s$version <- s$version + 1L + invisible(NULL) +} + +update.State <- function(s, f, ...) { + set.State(s, f(s$value)) +} + +version.State <- function(s, ...) s$version + +# Étape ③ (unification_arrays.md) : les tableaux d'éléments primitifs sont +# des vecteurs atomiques R nus. typr_collect est le collecteur commun de +# map : résultats tous scalaires « strippables » (atomiques, longueur 1, sans +# classe ou avec seulement le boxing transparent TypR) → vecteur atomique +# (unlist strip le boxing par élément), sinon typed_vec (records, valeurs à +# classe porteuse comme factor/alias opaques, éléments vecteurs). Helpers nus +# sans @signature (même statut que typed_vec/vec_apply/struct : jamais de +# stub UseMethod). +typr_scalar_strippable <- function(el) { + if (!is.atomic(el) || length(el) != 1L) return(FALSE) + cls <- attr(el, "class") + is.null(cls) || all(cls %in% c( + "Integer", "Number", "Character", "Boolean", + "integer", "numeric", "character", "logical", "Any", "Generic" + )) +} + +typr_collect <- function(results, dim = NULL) { + if (length(results) > 0L && + all(vapply(results, typr_scalar_strippable, logical(1))) && + length(unique(vapply(results, typeof, character(1)))) == 1L) { + unlist(results, use.names = FALSE) + } else if (is.null(dim)) { + do.call(typed_vec, results) + } else { + structure(list(data = results), class = "typed_vec", typed_dim = dim) + } +} + +# map composite -> primitif : le type statique du résultat est un tableau +# atomique, la valeur doit suivre (étape ③). +map.typed_vec <- function(a, f, ...) { + typr_collect(lapply(a$data, f), dim = attr(a, "typed_dim")) +} + +map.default <- function(a, f, ...) { + typr_collect(lapply(as.list(unclass(a)), f)) +} + +map.State <- function(s, f, ...) state(f(s$value)) + +filter.typed_vec <- function(a, f, ...) { + structure( + list(data = Filter(f, a$data)), + class = "typed_vec", + typed_dim = attr(a, "typed_dim") + ) +} + +filter.default <- function(a, f, ...) { + keep <- vapply(as.list(unclass(a)), function(el) isTRUE(all(unclass(f(el)))), logical(1)) + unclass(a)[keep] +} + +# --- Eq / Ord (ord.ty) --- +# Atomic element arrays dispatch to base R's own (already S3-generic) +# unique()/sort() — real ordering/equality on int/num/char/bool at C speed. +# Composite (typed_vec) arrays call the element type's own eq()/compare(), +# resolved by ordinary S3 dispatch at each call, exactly like unique.Point +# would if it existed as a dedicated method. +# +# Same footgun as `length.default` above, generalized: `unique`/`sort` are +# themselves plain closure-based S3 generics in base R (`function(x, ...) +# UseMethod("unique")`), so calling `base::unique(a, ...)` from inside our +# OWN `unique.default` re-enters `UseMethod("unique")`, which (namespace +# qualification notwithstanding — S3 dispatch resolves by search path, not +# lexical scope) finds our shadowing `unique.default` again and recurses +# forever (confirmed with a minimal repro: "C stack usage ... too close to +# the limit"). Call the base *method* directly instead of the generic. +unique.default <- function(a, ...) base:::unique.default(a, ...) +sort.default <- function(a, ...) base:::sort.default(a, ...) + +#' @title Invoke an implicit typeclass method (`eq`, `compare`, …) by name +#' +#' `unique.typed_vec`/`sort.typed_vec` live in std.R, sourced by +#' `load_module.R` into their own isolated environment (`parent = baseenv()`) +#' — a project file that `@include`s std.R gets a one-way COPY of its +#' bindings into its own separate environment, so a bare `eq(...)` call from +#' inside a std.R-defined function can never see a user's `eq.Point` (defined +#' only in the project's own environment): the copy doesn't create a shared +#' scope, and the function's closure still points at std.R's own (unrelated) +#' environment. +#' +#' Simply *fetching* the `eq` generic stub via `get(..., envir = parent.frame())` +#' and calling it directly isn't enough either: `eq` itself is a `UseMethod` +#' stub (`function(x, ...) UseMethod("eq")`, from generic_functions.R, a +#' *third*, separately-isolated environment) — dispatching to `eq.Point` +#' happens inside *that* call, whose own calling context is then this +#' function's frame, not the original project caller, so the inner dispatch +#' still fails to find `eq.Point`. Evaluating the call expression itself +#' (`eval(as.call(...), envir = ...)`) inside the caller's environment fixes +#' this at every level: any `UseMethod` dispatch that happens *during* that +#' evaluation resolves against that environment's lexical chain, where the +#' project's own `eq.Point` (or `eq.`) is visible — confirmed with a +#' minimal repro mirroring load_module.R's three-environment split (std.R / +#' generic_functions.R / project file). +typr_call_method <- function(name, ...) { + eval(as.call(c(as.name(name), list(...))), envir = parent.frame(2)) +} + +unique.typed_vec <- function(a, ...) { + out <- list() + for (el in a$data) { + is_dup <- FALSE + for (o in out) { + if (isTRUE(typr_call_method("eq", el, o))) { + is_dup <- TRUE + break + } + } + if (!is_dup) out[[length(out) + 1L]] <- el + } + structure(list(data = out), class = "typed_vec", typed_dim = length(out)) +} + +sort.typed_vec <- function(a, ...) { + data <- a$data + n <- length(data) + if (n >= 2) { + for (i in seq(2L, n)) { + key <- data[[i]] + j <- i - 1L + while (j >= 1L && typr_call_method("compare", data[[j]], key) > 0L) { + data[[j + 1L]] <- data[[j]] + j <- j - 1L + } + data[[j + 1L]] <- key + } + } + structure(list(data = data), class = "typed_vec", typed_dim = attr(a, "typed_dim")) +} + +derive.State <- function(s, f, ...) state(f(s$value)) + +# --- Interop (Niveau 0) --- +# to_native: lowering récursif TypR→R plat à la frontière des packages R externes. +# from_native: lifting R natif→TypR piloté par un descripteur de type ("int","num","char","bool","Any"). +# Ces fonctions sont la fondation du plan d'interop; elles ne modifient pas l'état TypR interne. + +to_native <- function(x, ...) UseMethod("to_native") + +to_native.default <- function(x, ...) x +to_native.Integer <- function(x, ...) as.integer(unclass(x)) +to_native.Number <- function(x, ...) as.numeric(unclass(x)) +to_native.Character <- function(x, ...) as.character(unclass(x)) +to_native.Boolean <- function(x, ...) as.logical(unclass(x)) + +to_native.typed_vec <- function(x, ...) { + if (length(x$data) == 0L) return(list()) + # lambda wrapper: keeps the closure chain so UseMethod can find to_native.* methods + # when called via lapply in load_module's isolated environments (parent=baseenv) + elems <- lapply(x$data, function(el) to_native(el)) + # Collapse to atomic vector when all elements share the same base storage type + if (all(vapply(elems, is.atomic, logical(1))) && + length(unique(vapply(elems, typeof, character(1)))) == 1L) { + do.call(c, elems) + } else { + elems + } +} + +# Handles records (class c("T","list")) and Tag variants not matched by a more specific method. +to_native.list <- function(x, ...) { + # lambda wrapper: keeps the closure chain so UseMethod can find to_native.* methods + # when called via lapply in load_module's isolated environments (parent=baseenv) + result <- lapply(unclass(x), function(el) to_native(el)) + class(result) <- NULL + result +} + +to_native.Some <- function(x, ...) to_native(x$body) +to_native.None <- function(x, ...) NULL +to_native.State <- function(x, ...) to_native(get.State(x)) + +from_native <- function(x, ...) UseMethod("from_native") + +# type_desc: "int" | "num" | "char" | "bool" | "Any" (default = identity) +from_native.default <- function(x, type_desc = "Any", ...) { + if (is.null(x)) return(NULL) + switch(type_desc, + "int" = Integer(as.integer(x)), + "num" = Number(as.numeric(x)), + "char" = Character(as.character(x)), + "bool" = Boolean(as.logical(x)), + x + ) +} + +# Typed lifting helpers: convert a plain R value to a specific TypR type. +# These are the idiomatic way to bring native R values into TypR-typed code. +from_int <- function(x, ...) UseMethod("from_int") +from_num <- function(x, ...) UseMethod("from_num") +from_char <- function(x, ...) UseMethod("from_char") +from_bool <- function(x, ...) UseMethod("from_bool") + +from_int.default <- function(x, ...) Integer(as.integer(x)) +from_num.default <- function(x, ...) Number(as.numeric(x)) +from_char.default <- function(x, ...) Character(as.character(x)) +from_bool.default <- function(x, ...) Boolean(as.logical(x)) + +# --- Interop (Niveau 2) --- +# from_nullable / to_nullable: bridge between R NULL and TypR Option. +# Use from_nullable when an @extern function may return NULL to represent absence. +# Use to_nullable when passing an Option to an external function expecting NULL. + +from_nullable <- function(x, ...) UseMethod("from_nullable") +from_nullable.default <- function(x, ...) { + if (is.null(x)) + structure(list("None"), class = c("None", "Option", "Tag", "list")) + else + structure(list("Some", body = x), class = c("Some", "Option", "Tag", "list")) +} + +to_nullable <- function(x, ...) UseMethod("to_nullable") +to_nullable.Option <- function(x, ...) if (inherits(x, "Some")) x$body else NULL +to_nullable.default <- function(x, ...) x + +# Foreign: identity function that serves as the runtime root for opaque Foreign aliases. +# `type DataFrame <- Foreign` compiles to `DataFrame <- Foreign` in R, so calling +# `DataFrame(x)` returns `x` unchanged — native R objects (data.frame, R6, S4, etc.) +# pass through without boxing. to_native.default already handles Foreign values correctly. +Foreign <- function(x, ...) x + + +=== R/types.R === +#' @include generic_functions.R +as.Function0 <- function(x) x |> struct(c('Function0', 'Any')) +as.Array0 <- function(x) x |> struct(c('Array0', 'numeric', 'Any')) +as.Ord <- function(x) x |> struct(c('Ord', 'Any')) +as.Eq <- function(x) x |> struct(c('Eq', 'Any')) +as.Foreign <- function(x) x |> struct(c('Foreign', 'Any', 'None')) +as.Function58 <- function(x) x |> struct(c('Function58', 'Any')) +as.State0 <- function(x) x |> struct(c('State0', 'Any')) +as.State <- function(x) x |> struct(c('State', 'Any', 'None')) +as.Function53 <- function(x) x |> struct(c('Function53', 'Any')) +as.Function52 <- function(x) x |> struct(c('Function52', 'Any')) +as.Factor0 <- function(x) x |> struct(c('Factor0', 'Any')) +as.Factor <- function(x) x |> struct(c('Factor', 'integer', 'Any')) +as.Tag0 <- function(x) x |> struct(c('Tag0', 'Any')) +as.Array4 <- function(x) x |> struct(c('Array4', 'numeric', 'Any')) +as.Function38 <- function(x) x |> struct(c('Function38', 'Any')) +as.Union0 <- function(x) x |> struct(c('Union0', 'Any')) +as.Function33 <- function(x) x |> struct(c('Function33', 'Any')) +as.Function31 <- function(x) x |> struct(c('Function31', 'Any')) +as.Function30 <- function(x) x |> struct(c('Function30', 'Any')) +as.Function29 <- function(x) x |> struct(c('Function29', 'Any')) +as.Array1 <- function(x) x |> struct(c('Array1', 'integer', 'Any')) +as.Function19 <- function(x) x |> struct(c('Function19', 'Any')) +as.Function17 <- function(x) x |> struct(c('Function17', 'Any')) +as.Function16 <- function(x) x |> struct(c('Function16', 'Any')) +as.Function15 <- function(x) x |> struct(c('Function15', 'Any')) +as.Function14 <- function(x) x |> struct(c('Function14', 'Any')) +as.Function13 <- function(x) x |> struct(c('Function13', 'Any')) +as.Function11 <- function(x) x |> struct(c('Function11', 'Any')) +as.Function9 <- function(x) x |> struct(c('Function9', 'Any')) +as.Function8 <- function(x) x |> struct(c('Function8', 'Any')) +as.Empty0 <- function(x) x |> struct(c('Empty0', 'numeric', 'numeric', 'Function0', 'Any')) +as.Function2 <- function(x) x |> struct(c('Function2', 'Any')) +as.Function1 <- function(x) x |> struct(c('Function1', 'Any')) +as.Function0 <- function(x) x |> struct(c('Function0', 'Any')) +as.Integer <- function(x) x |> struct(c('Integer', 'integer', 'Any')) +as.Character <- function(x) x |> struct(c('Character', 'character', 'Any')) +as.Number <- function(x) x |> struct(c('Number', 'numeric', 'Any')) +as.Boolean <- function(x) x |> struct(c('Boolean', 'logical', 'Any')) diff --git a/cases/0094-user-fn-named-like-builtin-skips-check/repro/DESCRIPTION b/cases/0094-user-fn-named-like-builtin-skips-check/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0094-user-fn-named-like-builtin-skips-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/0094-user-fn-named-like-builtin-skips-check/repro/NAMESPACE b/cases/0094-user-fn-named-like-builtin-skips-check/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0094-user-fn-named-like-builtin-skips-check/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0094-user-fn-named-like-builtin-skips-check/repro/R/.gitkeep b/cases/0094-user-fn-named-like-builtin-skips-check/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0094-user-fn-named-like-builtin-skips-check/repro/TypR/main.ty b/cases/0094-user-fn-named-like-builtin-skips-check/repro/TypR/main.ty new file mode 100644 index 00000000..b6575e0b --- /dev/null +++ b/cases/0094-user-fn-named-like-builtin-skips-check/repro/TypR/main.ty @@ -0,0 +1,5 @@ +let norm <- fn(x: [3, num]): num { + 1.0 +}; + +let r <- norm([1.0, 2.0]); diff --git a/cases/0095-na-type-error-prints-debug-struct/case.toml b/cases/0095-na-type-error-prints-debug-struct/case.toml new file mode 100644 index 00000000..1fafb1dc --- /dev/null +++ b/cases/0095-na-type-error-prints-debug-struct/case.toml @@ -0,0 +1,7 @@ +title = "NA in a type error is printed as NA(HelpData {...}); aliases of Foreign print as Foreign" +cmd = "check" +layer = "type" +status = "open" +created = "2026-10-01" +origin = "perso" +checked = false diff --git a/cases/0095-na-type-error-prints-debug-struct/expect.md b/cases/0095-na-type-error-prints-debug-struct/expect.md new file mode 100644 index 00000000..96a1a301 --- /dev/null +++ b/cases/0095-na-type-error-prints-debug-struct/expect.md @@ -0,0 +1,4 @@ +`fn(): char { NA }` is rejected (correct) but the message reads +`Found: NA(HelpData { offset: 61, end: 64, file_name: "main.ty" })`: the `Debug` form of the +`Lang::NA` node leaks into the user-facing text. Expected `Found: NA`. +Also: a `type Factor <- Foreign` alias is shown as `Foreign` in errors, not `Factor`. diff --git a/cases/0095-na-type-error-prints-debug-struct/expect.toml b/cases/0095-na-type-error-prints-debug-struct/expect.toml new file mode 100644 index 00000000..31345180 --- /dev/null +++ b/cases/0095-na-type-error-prints-debug-struct/expect.toml @@ -0,0 +1,3 @@ +[[rule]] +file = "@run" +must_not_contain = "HelpData" diff --git a/cases/0095-na-type-error-prints-debug-struct/observed.txt b/cases/0095-na-type-error-prints-debug-struct/observed.txt new file mode 100644 index 00000000..6223307e --- /dev/null +++ b/cases/0095-na-type-error-prints-debug-struct/observed.txt @@ -0,0 +1,26 @@ +# exit=1 + +## stdout/stderr + + Parsing... done (3 ms) + Type checking... failed +Type errors found: + × Type error: The output type of the function don't match it's type + │ annotation + │ Expected: char + │ Found: NA(HelpData { offset: 61, end: 64, file_name: "TypR/main.ty" }) + ╭─[TypR/main.ty:5:16] + 4 │ + 5 │ let g <- fn(): char { + · ▲ + · ╰── Expected char + 6 │ NA + · ┬ + · ╰── Received NA(HelpData { offset: 61, end: 64, file_name: "TypR/main.ty" }) + 7 │ }; + ╰──── + + + +## R généré + diff --git a/cases/0095-na-type-error-prints-debug-struct/repro/DESCRIPTION b/cases/0095-na-type-error-prints-debug-struct/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0095-na-type-error-prints-debug-struct/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/0095-na-type-error-prints-debug-struct/repro/NAMESPACE b/cases/0095-na-type-error-prints-debug-struct/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0095-na-type-error-prints-debug-struct/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0095-na-type-error-prints-debug-struct/repro/R/.gitkeep b/cases/0095-na-type-error-prints-debug-struct/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0095-na-type-error-prints-debug-struct/repro/TypR/main.ty b/cases/0095-na-type-error-prints-debug-struct/repro/TypR/main.ty new file mode 100644 index 00000000..bdfcfcf0 --- /dev/null +++ b/cases/0095-na-type-error-prints-debug-struct/repro/TypR/main.ty @@ -0,0 +1,7 @@ +let f <- fn(s: char): char { + s +}; + +let g <- fn(): char { + NA +}; diff --git a/cases/0096-generics-not-opaque-in-body/case.toml b/cases/0096-generics-not-opaque-in-body/case.toml new file mode 100644 index 00000000..bd213cbb --- /dev/null +++ b/cases/0096-generics-not-opaque-in-body/case.toml @@ -0,0 +1,7 @@ +title = "Bare/nested generics T, U are not opaque in the body: returning a U where a T is declared is accepted" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-10-06" +origin = "perso" +checked = false diff --git a/cases/0096-generics-not-opaque-in-body/expect.md b/cases/0096-generics-not-opaque-in-body/expect.md new file mode 100644 index 00000000..8f146303 --- /dev/null +++ b/cases/0096-generics-not-opaque-in-body/expect.md @@ -0,0 +1,10 @@ +`fn(a: T, b: U): T { b }` used to type-check: a generic written in a parameter type was +a flexible variable, so `U` unified with `T`. Both generics are now treated as empty-interface +rigid variables (one per name) inside the body, nested ones included (`[#N, T]`, `list { x: T }`). + +Expected: both functions of the repro are rejected, with a message naming `T`/`U` +(never `__RIGID_n`). Valid signatures (`fn(a: T, b: U): T { a }`, `fn(a: [#N, T]): [#N, T] { a }`) +must keep compiling; they are covered by unit tests in `type_checking/function.rs`. + +Where: `type_checking/function.rs` (`generic_rigids`, `substitute_generics`) and +`components/type/mod.rs` (`is_subtype_raw`: structural comparison of composites holding rigids). diff --git a/cases/0096-generics-not-opaque-in-body/expect.toml b/cases/0096-generics-not-opaque-in-body/expect.toml new file mode 100644 index 00000000..1053a022 --- /dev/null +++ b/cases/0096-generics-not-opaque-in-body/expect.toml @@ -0,0 +1,15 @@ +[[rule]] +file = "@run" +must_contain = "Expected: T" +[[rule]] +file = "@run" +must_contain = "Found: U" +[[rule]] +file = "@run" +must_contain = "Expected: [#N, T]" +[[rule]] +file = "@run" +must_not_contain = "__RIGID" +[[rule]] +file = "@run" +must_not_contain = "successful" diff --git a/cases/0096-generics-not-opaque-in-body/repro/DESCRIPTION b/cases/0096-generics-not-opaque-in-body/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0096-generics-not-opaque-in-body/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/0096-generics-not-opaque-in-body/repro/NAMESPACE b/cases/0096-generics-not-opaque-in-body/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0096-generics-not-opaque-in-body/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0096-generics-not-opaque-in-body/repro/TypR/main.ty b/cases/0096-generics-not-opaque-in-body/repro/TypR/main.ty new file mode 100644 index 00000000..dcb2acad --- /dev/null +++ b/cases/0096-generics-not-opaque-in-body/repro/TypR/main.ty @@ -0,0 +1,7 @@ +let swap_wrong <- fn(a: T, b: U): T { + b +}; + +let nested_wrong <- fn(a: [#N, T], b: [#N, U]): [#N, T] { + b +}; diff --git a/cases/0097-generic-binds-general-type-of-literal/case.toml b/cases/0097-generic-binds-general-type-of-literal/case.toml new file mode 100644 index 00000000..2d13e58e --- /dev/null +++ b/cases/0097-generic-binds-general-type-of-literal/case.toml @@ -0,0 +1,7 @@ +title = "A generic T bound by literal arguments should generalize them: f(3, 5) with f: (T, T) -> T is rejected" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-10-06" +origin = "perso" +checked = false diff --git a/cases/0097-generic-binds-general-type-of-literal/expect.md b/cases/0097-generic-binds-general-type-of-literal/expect.md new file mode 100644 index 00000000..5dd02f17 --- /dev/null +++ b/cases/0097-generic-binds-general-type-of-literal/expect.md @@ -0,0 +1,6 @@ +# 0097 — a generic binds the general type of a literal + +`f: (a: T, b: T) -> T` called as `f(3, 5)` was rejected because `T` was bound +to the literal types `3` then `5`, which differ. A generic now stands for the +general type: `7` becomes `int`, `"hello"` `char`, `true` `bool`, `1.5` `num`. +Mixed calls (`f(3, "a")`) are still rejected. diff --git a/cases/0097-generic-binds-general-type-of-literal/expect.toml b/cases/0097-generic-binds-general-type-of-literal/expect.toml new file mode 100644 index 00000000..a9052821 --- /dev/null +++ b/cases/0097-generic-binds-general-type-of-literal/expect.toml @@ -0,0 +1,6 @@ +[[rule]] +file = "@run" +must_contain = "successful" +[[rule]] +file = "@run" +must_not_contain = "No signature of function" diff --git a/cases/0097-generic-binds-general-type-of-literal/repro/DESCRIPTION b/cases/0097-generic-binds-general-type-of-literal/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0097-generic-binds-general-type-of-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/0097-generic-binds-general-type-of-literal/repro/NAMESPACE b/cases/0097-generic-binds-general-type-of-literal/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0097-generic-binds-general-type-of-literal/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0097-generic-binds-general-type-of-literal/repro/TypR/main.ty b/cases/0097-generic-binds-general-type-of-literal/repro/TypR/main.ty new file mode 100644 index 00000000..7b80fccb --- /dev/null +++ b/cases/0097-generic-binds-general-type-of-literal/repro/TypR/main.ty @@ -0,0 +1,4 @@ +let pick <- fn(a: T, b: T): T { a }; +let n: int <- pick(3, 5); +let s: char <- pick("a", "b"); +let b: bool <- pick(true, false); diff --git a/cases/0098-interface-id-binds-general-type-of-literal/case.toml b/cases/0098-interface-id-binds-general-type-of-literal/case.toml new file mode 100644 index 00000000..5d82f2ca --- /dev/null +++ b/cases/0098-interface-id-binds-general-type-of-literal/case.toml @@ -0,0 +1,7 @@ +title = "A repeated bare interface (a: Simple, b: Simple) should bind literal arguments to their general type: f(3, 5) was rejected as bound to 3 then 5" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-10-06" +origin = "perso" +checked = false diff --git a/cases/0098-interface-id-binds-general-type-of-literal/expect.md b/cases/0098-interface-id-binds-general-type-of-literal/expect.md new file mode 100644 index 00000000..02962940 --- /dev/null +++ b/cases/0098-interface-id-binds-general-type-of-literal/expect.md @@ -0,0 +1,5 @@ +# 0098 — a repeated bare interface binds the general type of a literal + +`f: (a: Simple, b: Simple) -> Simple` called as `f(3, 5)` was rejected with +"'Simple' is bound to '3' and then to '5'": the shared id was bound to the +literal types. It now binds the general type (`int`), as a free generic does. diff --git a/cases/0098-interface-id-binds-general-type-of-literal/expect.toml b/cases/0098-interface-id-binds-general-type-of-literal/expect.toml new file mode 100644 index 00000000..0ad11cb1 --- /dev/null +++ b/cases/0098-interface-id-binds-general-type-of-literal/expect.toml @@ -0,0 +1,6 @@ +[[rule]] +file = "@run" +must_contain = "successful" +[[rule]] +file = "@run" +must_not_contain = "bound to" diff --git a/cases/0098-interface-id-binds-general-type-of-literal/repro/DESCRIPTION b/cases/0098-interface-id-binds-general-type-of-literal/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0098-interface-id-binds-general-type-of-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/0098-interface-id-binds-general-type-of-literal/repro/NAMESPACE b/cases/0098-interface-id-binds-general-type-of-literal/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0098-interface-id-binds-general-type-of-literal/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0098-interface-id-binds-general-type-of-literal/repro/TypR/main.ty b/cases/0098-interface-id-binds-general-type-of-literal/repro/TypR/main.ty new file mode 100644 index 00000000..f4029946 --- /dev/null +++ b/cases/0098-interface-id-binds-general-type-of-literal/repro/TypR/main.ty @@ -0,0 +1,13 @@ +type Simple <- interface { + simple: (self: Self) -> Self +}; + +let simple <- fn(self: int): int { + self +}; + +let f <- fn(a: Simple, b: Simple): Simple { + a +}; + +f(3, 5) diff --git a/cases/0099-reassign-list-field/case.toml b/cases/0099-reassign-list-field/case.toml new file mode 100644 index 00000000..d8eb1931 --- /dev/null +++ b/cases/0099-reassign-list-field/case.toml @@ -0,0 +1,7 @@ +title = "Reassigning a list field (d$b <- true;) was a syntax error: only a bare variable was accepted on the left of <-" +cmd = "build" +layer = "parse" +status = "fixed" +created = "2026-10-06" +origin = "perso" +checked = false diff --git a/cases/0099-reassign-list-field/expect.md b/cases/0099-reassign-list-field/expect.md new file mode 100644 index 00000000..0ad5a451 --- /dev/null +++ b/cases/0099-reassign-list-field/expect.md @@ -0,0 +1,7 @@ +# 0099 — reassigning a list field + +`let d <- :{ a: 8, b: false};` then `d$b <- true;` was rejected with +"Unknown element `<- true;`": `assign` only accepted a bare variable on the +left of `<-`/`=`. The target may now be a `$` chain (`d$b`, `d$c$x`), parsed +as nested `Op::Dollar`; the type checker requires the new value to be a +subtype of the field's (widened) type, and an unknown field is reported. diff --git a/cases/0099-reassign-list-field/expect.toml b/cases/0099-reassign-list-field/expect.toml new file mode 100644 index 00000000..646c6496 --- /dev/null +++ b/cases/0099-reassign-list-field/expect.toml @@ -0,0 +1,6 @@ +[[rule]] +file = "R/main.R" +must_contain = "d$b <- TRUE" +[[rule]] +file = "R/main.R" +must_contain = "d$c$x <- \"v\"" diff --git a/cases/0099-reassign-list-field/repro/DESCRIPTION b/cases/0099-reassign-list-field/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0099-reassign-list-field/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/0099-reassign-list-field/repro/NAMESPACE b/cases/0099-reassign-list-field/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0099-reassign-list-field/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0099-reassign-list-field/repro/TypR/main.ty b/cases/0099-reassign-list-field/repro/TypR/main.ty new file mode 100644 index 00000000..10661cdb --- /dev/null +++ b/cases/0099-reassign-list-field/repro/TypR/main.ty @@ -0,0 +1,5 @@ +let d <- :{ a: 8, b: false, c: :{ x: "u" }}; +d$b <- true; +d$a <- 3; +d$c$x <- "v"; +print(d$b); diff --git a/cases/0100-field-access-on-vector-alias-unknown-element/case.toml b/cases/0100-field-access-on-vector-alias-unknown-element/case.toml new file mode 100644 index 00000000..57fc7744 --- /dev/null +++ b/cases/0100-field-access-on-vector-alias-unknown-element/case.toml @@ -0,0 +1,7 @@ +title = "self$position on a vector alias (type Position <- [2, int]) reported a parse-looking `Unknown element `elf$position + other,`` with the position one byte off and the line +1" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-10-08" +origin = "perso" +checked = false diff --git a/cases/0100-field-access-on-vector-alias-unknown-element/expect.md b/cases/0100-field-access-on-vector-alias-unknown-element/expect.md new file mode 100644 index 00000000..8fbcf6c6 --- /dev/null +++ b/cases/0100-field-access-on-vector-alias-unknown-element/expect.md @@ -0,0 +1,36 @@ +# `$` / `...` on a vector alias: misleading "Unknown element" error + +## Repro + +`Position` is a vector alias (`[2, int]`), but `move` treats it as a record: +`self$position` and `...self`. The code is genuinely wrong (the fix on the user +side is `self + other`); the bug is in the diagnostics. + +## Observed (see `observed.txt`) + +``` +× Type error: Unknown element `elf$position + other,` in `TypR/position.ty` at `13` +``` + +Three defects, which together made a type error read like a parse error: + +1. `TypeError::WrongExpression` reused the wording of `SyntaxError::UnknownElement` + ("Unknown element `…` in `file` at `line`") and named neither the field nor the type. +2. Every `Var`'s `HelpData` offset was one byte past the identifier's start: + `variable_exp` (`processes/parsing/elements.rs`) took the position from + `starting_char`'s *remaining* span → `elf$position`. The LSP compensated with + `saturating_sub(1)` in `inlay_hints.rs` / `code_actions.rs`. +3. The line in the message was `text[..offset].lines().count() + 1`, one too many + (`lines()` already counts the current, partial line). + +## Expected + +- `e$field` where `e` isn't a record / module / data frame → + `TypeError::FieldAccessOnNonRecord` (T050): "Cannot access field 'position': type + Position (= [2, int]) is not a record", underlining the field name + (`dollar_access.rs::non_record_access_error`). +- `...e` where `e` isn't a record → `TypeError::SpreadNonRecord` (T051), in both the + record literal (`type_checking/mod.rs`) and constructor (`constructor_call.rs`) paths. +- Var positions start at the identifier's first byte (LSP `-1` compensations removed). +- The remaining generic `WrongExpression` reads "This expression doesn't type-check" + and underlines the node's own range — no file/line duplicated in the text. diff --git a/cases/0100-field-access-on-vector-alias-unknown-element/expect.toml b/cases/0100-field-access-on-vector-alias-unknown-element/expect.toml new file mode 100644 index 00000000..b1edf53f --- /dev/null +++ b/cases/0100-field-access-on-vector-alias-unknown-element/expect.toml @@ -0,0 +1,28 @@ +# The generic WrongExpression message mimicked a syntax error. +[[rule]] +file = "@run" +must_not_contain = "Unknown element" + +# `$` on a non-record names the field and what the alias stands for. +[[rule]] +file = "@run" +must_contain = "Cannot access field 'position': type Position (= [2, int])" + +[[rule]] +file = "@run" +must_contain = "has no field 'position'" + +# `...self` on a non-record gets its own error. +[[rule]] +file = "@run" +must_contain = "Cannot spread a value of type Position (= [2, int])" + +# Positions: field error at `position` (line 7, col 18 = after `self$`), +# spread error at `self` (line 8, col 6) — not one byte past the start. +[[rule]] +file = "@run" +must_contain = "TypR/main.ty:7:18]" + +[[rule]] +file = "@run" +must_contain = "TypR/main.ty:8:6]" diff --git a/cases/0100-field-access-on-vector-alias-unknown-element/observed.txt b/cases/0100-field-access-on-vector-alias-unknown-element/observed.txt new file mode 100644 index 00000000..7f945586 --- /dev/null +++ b/cases/0100-field-access-on-vector-alias-unknown-element/observed.txt @@ -0,0 +1,18 @@ + × Type error: Unknown element `elf$position + + │ other,` in `TypR/position.ty` at `13` + ╭─[TypR/position.ty:12:14] + 11 │ :{ + 12 │ position: self$position + other, + · ▲ + · ╰── Error here + 13 │ ...self + ╰──── + + × Type error: Unknown element `elf` in `TypR/position.ty` at `14` + ╭─[TypR/position.ty:13:7] + 12 │ position: self$position + other, + 13 │ ...self + · ▲ + · ╰── Error here + 14 │ } + ╰──── diff --git a/cases/0100-field-access-on-vector-alias-unknown-element/repro/DESCRIPTION b/cases/0100-field-access-on-vector-alias-unknown-element/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0100-field-access-on-vector-alias-unknown-element/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/0100-field-access-on-vector-alias-unknown-element/repro/NAMESPACE b/cases/0100-field-access-on-vector-alias-unknown-element/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0100-field-access-on-vector-alias-unknown-element/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0100-field-access-on-vector-alias-unknown-element/repro/TypR/main.ty b/cases/0100-field-access-on-vector-alias-unknown-element/repro/TypR/main.ty new file mode 100644 index 00000000..e903a71d --- /dev/null +++ b/cases/0100-field-access-on-vector-alias-unknown-element/repro/TypR/main.ty @@ -0,0 +1,10 @@ +@pub +type Position <- [2, int]; + +@pub +let move <- fn(self: Position, other: Position): Position { + :{ + position: self$position + other, + ...self + } +}; diff --git a/cases/0101-hidden-dot-names/case.toml b/cases/0101-hidden-dot-names/case.toml new file mode 100644 index 00000000..b111182e --- /dev/null +++ b/cases/0101-hidden-dot-names/case.toml @@ -0,0 +1,7 @@ +title = "Hidden names: `.x` (R-style) and its ASCII spelling `__x` are one binding, compile to `.x`, and work as UFCS methods" +cmd = "run" +layer = "r-run" +status = "fixed" +created = "2026-10-11" +origin = "perso" +checked = false diff --git a/cases/0101-hidden-dot-names/expect.md b/cases/0101-hidden-dot-names/expect.md new file mode 100644 index 00000000..dd02cdd9 --- /dev/null +++ b/cases/0101-hidden-dot-names/expect.md @@ -0,0 +1,29 @@ +# hidden-dot-names + +A leading `.` makes a *hidden* name, as in R (`.cache`, `.helper`): private by name — it can't be +`@pub`/`@export` (S020) — and emitted unchanged in the R output (so `ls()` and R's default +`exportPattern("^[^\\.]")` hide it too). + +`__name` is the ASCII spelling of `.name`, consistent with the general `__` → `.` rule +(`as__character` → `as.character`). The parser normalizes a leading `__` to `.` +(`parsing/elements.rs::variable_exp`), so `.cache` and `__cache` are the **same** binding for the +type checker. That spelling is the one to use right after the UFCS/field `.` (`p.__norm()`, +`p.__secret`): `p..norm()` is rejected (S019) because it reads as the `..` spread token. +`p |> .norm()` is the other way to call it. + +## Ce qui devrait se passer + +`p.__norm()` and `p |> .norm()` both print `42`; `.twice(__cache)` prints `40`; the `Seg` +overload of `.norm` prints `7`. No `__` survives in the generated R. + +A hidden typed function is an S3 method like any other (`.norm.Pt`, `.norm.Seg`, generic `.norm` +in `generic_functions.R`): `Var::display_type` and `not_in_blacklist` used to treat *any* dot +as "already an R/S3 name", which a leading dot no longer means. Its generic carries no +`#' @export` (`typr-cli/src/project.rs`). + +## Anomalies (pre-fix) + +`.x` did not parse at all (`Unknown element`). With `__norm` defined and `.norm` used (or the +reverse) the two names were distinct bindings. In Project mode the roxygen tag of a typed hidden +function was `#' @method norm Pt` (the generic name split on every dot), and it got no S3 +suffix nor generic, so a second overload silently replaced the first. diff --git a/cases/0101-hidden-dot-names/expect.toml b/cases/0101-hidden-dot-names/expect.toml new file mode 100644 index 00000000..c445cfd1 --- /dev/null +++ b/cases/0101-hidden-dot-names/expect.toml @@ -0,0 +1,37 @@ +[[rule]] +file = "R/main.R" +must_contain = "`.cache` <- " + +[[rule]] +file = "R/main.R" +must_contain = ".norm(p)" + +[[rule]] +file = "R/main.R" +must_contain = ".twice(.cache)" + +# roxygen S3 tag: only the class suffix is split off (was `@method norm Pt`) +[[rule]] +file = "R/main.R" +must_contain = "#' @method .norm Pt" + +[[rule]] +file = "R/main.R" +must_not_contain = "__" + +[[rule]] +file = "R/main.R" +must_contain = "#' @method .norm Seg" + +# hidden generics get their UseMethod stub, but stay out of the NAMESPACE +[[rule]] +file = "R/generic_functions.R" +must_contain = ".norm <- function(x, ...) UseMethod('.norm', x)" + +[[rule]] +file = "R/generic_functions.R" +must_not_contain = "#' @export\n.norm" + +[[rule]] +file = "@run" +must_contain = "42\n42\n40\n7" diff --git a/cases/0101-hidden-dot-names/golden/R/generic_functions.R b/cases/0101-hidden-dot-names/golden/R/generic_functions.R new file mode 100644 index 00000000..28ba0209 --- /dev/null +++ b/cases/0101-hidden-dot-names/golden/R/generic_functions.R @@ -0,0 +1,132 @@ +#' @include std.R +.norm <- function(x, ...) UseMethod('.norm', x) +.norm <- function(x, ...) UseMethod('.norm', x) +.twice <- function(x, ...) UseMethod('.twice', x) +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +annotate_factor <- function(x, ...) UseMethod('annotate_factor', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as_vec <- function(x, ...) UseMethod('as_vec', x) +#' @export +bsystem2 <- function(x, ...) UseMethod('bsystem2', x) +#' @export +contains <- function(x, ...) UseMethod('contains', x) +#' @export +cvec <- function(x, ...) UseMethod('cvec', x) +#' @export +derive <- function(x, ...) UseMethod('derive', x) +#' @export +dir <- function(x, ...) UseMethod('dir', x) +#' @export +dir__create <- function(x, ...) UseMethod('dir__create', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +dot <- function(x, ...) UseMethod('dot', x) +#' @export +endsWith <- function(x, ...) UseMethod('endsWith', x) +#' @export +exec <- function(x, ...) UseMethod('exec', x) +#' @export +expect <- function(x, ...) UseMethod('expect', x) +#' @export +expect_equal <- function(x, ...) UseMethod('expect_equal', x) +#' @export +expect_false <- function(x, ...) UseMethod('expect_false', x) +#' @export +extend <- function(x, ...) UseMethod('extend', x) +#' @export +factor <- function(x, ...) UseMethod('factor', x) +#' @export +file__copy <- function(x, ...) UseMethod('file__copy', x) +#' @export +file__create <- function(x, ...) UseMethod('file__create', x) +#' @export +file__exists <- function(x, ...) UseMethod('file__exists', x) +#' @export +file__remove <- function(x, ...) UseMethod('file__remove', x) +#' @export +file__rename <- function(x, ...) UseMethod('file__rename', x) +#' @export +filter <- function(x, ...) UseMethod('filter', x) +#' @export +fold <- function(x, ...) UseMethod('fold', x) +#' @export +get <- function(x, ...) UseMethod('get', x) +#' @export +getwd <- function(x, ...) UseMethod('getwd', x) +#' @export +is_none <- function(x, ...) UseMethod('is_none', x) +#' @export +is_some <- function(x, ...) UseMethod('is_some', x) +#' @export +join <- function(x, ...) UseMethod('join', x) +#' @export +levels <- function(x, ...) UseMethod('levels', x) +#' @export +list__files <- function(x, ...) UseMethod('list__files', x) +#' @export +lvec <- function(x, ...) UseMethod('lvec', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +nlevels <- function(x, ...) UseMethod('nlevels', x) +#' @export +plot <- function(x, ...) UseMethod('plot', x) +#' @export +reduce <- function(x, ...) UseMethod('reduce', x) +#' @export +seq <- function(x, ...) UseMethod('seq', x) +#' @export +set <- function(x, ...) UseMethod('set', x) +#' @export +set_at <- function(x, ...) UseMethod('set_at', x) +#' @export +setwd <- function(x, ...) UseMethod('setwd', x) +#' @export +sort <- function(x, ...) UseMethod('sort', x) +#' @export +startsWith <- function(x, ...) UseMethod('startsWith', x) +#' @export +state <- function(x, ...) UseMethod('state', x) +#' @export +sum <- function(x, ...) UseMethod('sum', x) +#' @export +system2 <- function(x, ...) UseMethod('system2', x) +#' @export +t <- function(x, ...) UseMethod('t', x) +#' @export +to_native <- function(x, ...) UseMethod('to_native', x) +#' @export +to_nullable <- function(x, ...) UseMethod('to_nullable', x) +#' @export +unique <- function(x, ...) UseMethod('unique', x) +#' @export +unlink <- function(x, ...) UseMethod('unlink', x) +#' @export +unwrap <- function(x, ...) UseMethod('unwrap', x) +#' @export +unwrap_or <- function(x, ...) UseMethod('unwrap_or', x) +#' @export +update <- function(x, ...) UseMethod('update', x) +#' @export +version <- function(x, ...) UseMethod('version', x) diff --git a/cases/0101-hidden-dot-names/golden/R/main.R b/cases/0101-hidden-dot-names/golden/R/main.R new file mode 100644 index 00000000..c43d8437 --- /dev/null +++ b/cases/0101-hidden-dot-names/golden/R/main.R @@ -0,0 +1,113 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +Pt <- function(.secret, x, .spread = NULL) { + explicit <- list() + if (!missing(.secret)) { + explicit[[".secret"]] <- .secret + } + if (!missing(x)) { + explicit[["x"]] <- x + } + x <- typr_spread_record(explicit, .spread) + as.Pt(x) +} +as.Pt <- function(x) { + if (!inherits(x, "Pt")) { + class(x) <- c("Pt", "list") + } + x <- validate_Pt(x) + x <- validate(x) + x +} +validate_Pt <- function(x) { + required_fields <- c(".secret", "x") + missing_fields <- setdiff(required_fields, names(x)) + if (length(missing_fields) > 0) { + stop(paste0( + "Validation failed for type Pt: missing fields: ", + paste(missing_fields, collapse = ", ") + )) + } + if (!inherits(x[[".secret"]], "integer")) { + stop( + "Validation failed for type Pt: field '.secret' must be of class integer" + ) + } + if (!inherits(x[["x"]], "integer")) { + stop("Validation failed for type Pt: field 'x' must be of class integer") + } + x +} +#' @method .norm Pt +`.norm.Pt` <- (function(p) { + { + p[['x']] + p[['.secret']] + } |> + as.Integer() +}) |> + as.Generic() + +#' @method .twice integer +`.twice.integer` <- (function(a) { + { + a * 2L |> as.Integer() + } |> + as.Integer() +}) |> + as.Function1() + +`.cache` <- 20L |> as.Integer() + +`p` <- Pt(x = 1L |> as.Integer(), .secret = 41L |> as.Integer()) + +print(.norm(p)) +print(.norm(p)) +print(.twice(.cache)) +Seg <- function(a, b, .spread = NULL) { + explicit <- list() + if (!missing(a)) { + explicit[["a"]] <- a + } + if (!missing(b)) { + explicit[["b"]] <- b + } + x <- typr_spread_record(explicit, .spread) + as.Seg(x) +} +as.Seg <- function(x) { + if (!inherits(x, "Seg")) { + class(x) <- c("Seg", "list") + } + x <- validate_Seg(x) + x <- validate(x) + x +} +validate_Seg <- function(x) { + required_fields <- c("a", "b") + missing_fields <- setdiff(required_fields, names(x)) + if (length(missing_fields) > 0) { + stop(paste0( + "Validation failed for type Seg: missing fields: ", + paste(missing_fields, collapse = ", ") + )) + } + if (!inherits(x[["a"]], "integer")) { + stop("Validation failed for type Seg: field 'a' must be of class integer") + } + if (!inherits(x[["b"]], "integer")) { + stop("Validation failed for type Seg: field 'b' must be of class integer") + } + x +} +#' @method .norm Seg +`.norm.Seg` <- (function(s) { + { + s[['b']] - s[['a']] + } |> + as.Integer() +}) |> + as.Generic() + +print(.norm(Seg(a = 2L |> as.Integer(), b = 9L |> as.Integer()))) diff --git a/cases/0101-hidden-dot-names/observed.txt b/cases/0101-hidden-dot-names/observed.txt new file mode 100644 index 00000000..65ffda04 --- /dev/null +++ b/cases/0101-hidden-dot-names/observed.txt @@ -0,0 +1,4 @@ +# exit=1 + +## stdout/stderr +Syntax error: Unknown element `<- fn(p: Pt): int { p.x + p.__secret }; ...` diff --git a/cases/0101-hidden-dot-names/repro/DESCRIPTION b/cases/0101-hidden-dot-names/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0101-hidden-dot-names/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/0101-hidden-dot-names/repro/NAMESPACE b/cases/0101-hidden-dot-names/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0101-hidden-dot-names/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0101-hidden-dot-names/repro/TypR/main.ty b/cases/0101-hidden-dot-names/repro/TypR/main.ty new file mode 100644 index 00000000..e36ff3fd --- /dev/null +++ b/cases/0101-hidden-dot-names/repro/TypR/main.ty @@ -0,0 +1,13 @@ +type Pt <- list{ x: int, .secret: int }; + +let .norm <- fn(p: Pt): int { p.x + p.__secret }; +let .twice <- fn(a: int): int { a * 2 }; +let .cache <- 20; + +let p <- Pt:{ x = 1, .secret = 41 }; +print(p.__norm()); +print(p |> .norm()); +print(.twice(__cache)); +type Seg <- list{ a: int, b: int }; +let .norm <- fn(s: Seg): int { s.b - s.a }; +print(Seg:{ a = 2, b = 9 }.__norm()); diff --git a/cases/0102-underscore-name-not-backticked-at-use/case.toml b/cases/0102-underscore-name-not-backticked-at-use/case.toml new file mode 100644 index 00000000..8bd59a14 --- /dev/null +++ b/cases/0102-underscore-name-not-backticked-at-use/case.toml @@ -0,0 +1,7 @@ +title = "`_x` is backtick-quoted at its definition but emitted bare at its use sites — invalid R" +cmd = "run" +layer = "r-run" +status = "fixed" +created = "2026-10-11" +origin = "perso" +checked = false diff --git a/cases/0102-underscore-name-not-backticked-at-use/expect.md b/cases/0102-underscore-name-not-backticked-at-use/expect.md new file mode 100644 index 00000000..d085a0d1 --- /dev/null +++ b/cases/0102-underscore-name-not-backticked-at-use/expect.md @@ -0,0 +1,16 @@ +# underscore-name-not-backticked-at-use + +TypR identifiers may start with `_` (`starting_char`), which R only accepts backtick-quoted. +Definitions were always quoted (`format_backtick` in the `Let` arm), but the `Variable` and +`FunctionApp` arms of `transpiling/mod.rs` emitted the bare name, so `_x` produced R that does +not even parse. + +## Ce qui devrait se passer + +Use sites go through `transpiling/mod.rs::r_symbol`, which backticks a name starting with `_`: +`` `_inc`(`_x`) ``, printing `4`. The generic stub in `generic_functions.R` is quoted too +(`Var::add_backticks_if_needed`, formerly `add_backticks_if_percent`). + +## Anomalies (pre-fix) + +`print(_inc(_x))` → R parse error `unexpected input`. diff --git a/cases/0102-underscore-name-not-backticked-at-use/expect.toml b/cases/0102-underscore-name-not-backticked-at-use/expect.toml new file mode 100644 index 00000000..7185bdfd --- /dev/null +++ b/cases/0102-underscore-name-not-backticked-at-use/expect.toml @@ -0,0 +1,15 @@ +[[rule]] +file = "R/main.R" +must_contain = "`_inc`(`_x`)" + +[[rule]] +file = "R/generic_functions.R" +must_contain = "`_inc` <- function(x, ...)" + +[[rule]] +file = "@run" +must_contain = "4" + +[[rule]] +file = "@run" +must_not_contain = "failed" diff --git a/cases/0102-underscore-name-not-backticked-at-use/golden/R/generic_functions.R b/cases/0102-underscore-name-not-backticked-at-use/golden/R/generic_functions.R new file mode 100644 index 00000000..15f8c58c --- /dev/null +++ b/cases/0102-underscore-name-not-backticked-at-use/golden/R/generic_functions.R @@ -0,0 +1,131 @@ +#' @include std.R +#' @export +`_inc` <- function(x, ...) UseMethod('_inc', x) +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +add <- function(x, ...) UseMethod('add', x) +#' @export +annotate_factor <- function(x, ...) UseMethod('annotate_factor', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as__character <- function(x, ...) UseMethod('as__character', x) +#' @export +as_vec <- function(x, ...) UseMethod('as_vec', x) +#' @export +bsystem2 <- function(x, ...) UseMethod('bsystem2', x) +#' @export +contains <- function(x, ...) UseMethod('contains', x) +#' @export +cvec <- function(x, ...) UseMethod('cvec', x) +#' @export +derive <- function(x, ...) UseMethod('derive', x) +#' @export +dir <- function(x, ...) UseMethod('dir', x) +#' @export +dir__create <- function(x, ...) UseMethod('dir__create', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +div <- function(x, ...) UseMethod('div', x) +#' @export +dot <- function(x, ...) UseMethod('dot', x) +#' @export +endsWith <- function(x, ...) UseMethod('endsWith', x) +#' @export +exec <- function(x, ...) UseMethod('exec', x) +#' @export +expect <- function(x, ...) UseMethod('expect', x) +#' @export +expect_equal <- function(x, ...) UseMethod('expect_equal', x) +#' @export +expect_false <- function(x, ...) UseMethod('expect_false', x) +#' @export +extend <- function(x, ...) UseMethod('extend', x) +#' @export +factor <- function(x, ...) UseMethod('factor', x) +#' @export +file__copy <- function(x, ...) UseMethod('file__copy', x) +#' @export +file__create <- function(x, ...) UseMethod('file__create', x) +#' @export +file__exists <- function(x, ...) UseMethod('file__exists', x) +#' @export +file__remove <- function(x, ...) UseMethod('file__remove', x) +#' @export +file__rename <- function(x, ...) UseMethod('file__rename', x) +#' @export +filter <- function(x, ...) UseMethod('filter', x) +#' @export +fold <- function(x, ...) UseMethod('fold', x) +#' @export +get <- function(x, ...) UseMethod('get', x) +#' @export +getwd <- function(x, ...) UseMethod('getwd', x) +#' @export +is_none <- function(x, ...) UseMethod('is_none', x) +#' @export +is_some <- function(x, ...) UseMethod('is_some', x) +#' @export +join <- function(x, ...) UseMethod('join', x) +#' @export +levels <- function(x, ...) UseMethod('levels', x) +#' @export +list__files <- function(x, ...) UseMethod('list__files', x) +#' @export +lvec <- function(x, ...) UseMethod('lvec', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +map <- function(x, ...) UseMethod('map', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +minus <- function(x, ...) UseMethod('minus', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +mul <- function(x, ...) UseMethod('mul', x) +#' @export +nlevels <- function(x, ...) UseMethod('nlevels', x) +#' @export +plot <- function(x, ...) UseMethod('plot', x) +#' @export +reduce <- function(x, ...) UseMethod('reduce', x) +#' @export +seq <- function(x, ...) UseMethod('seq', x) +#' @export +set <- function(x, ...) UseMethod('set', x) +#' @export +set_at <- function(x, ...) UseMethod('set_at', x) +#' @export +setwd <- function(x, ...) UseMethod('setwd', x) +#' @export +sort <- function(x, ...) UseMethod('sort', x) +#' @export +startsWith <- function(x, ...) UseMethod('startsWith', x) +#' @export +state <- function(x, ...) UseMethod('state', x) +#' @export +sum <- function(x, ...) UseMethod('sum', x) +#' @export +system2 <- function(x, ...) UseMethod('system2', x) +#' @export +t <- function(x, ...) UseMethod('t', x) +#' @export +to_native <- function(x, ...) UseMethod('to_native', x) +#' @export +to_nullable <- function(x, ...) UseMethod('to_nullable', x) +#' @export +unique <- function(x, ...) UseMethod('unique', x) +#' @export +unlink <- function(x, ...) UseMethod('unlink', x) +#' @export +unwrap <- function(x, ...) UseMethod('unwrap', x) +#' @export +unwrap_or <- function(x, ...) UseMethod('unwrap_or', x) +#' @export +update <- function(x, ...) UseMethod('update', x) +#' @export +version <- function(x, ...) UseMethod('version', x) diff --git a/cases/0102-underscore-name-not-backticked-at-use/golden/R/main.R b/cases/0102-underscore-name-not-backticked-at-use/golden/R/main.R new file mode 100644 index 00000000..65bb71cf --- /dev/null +++ b/cases/0102-underscore-name-not-backticked-at-use/golden/R/main.R @@ -0,0 +1,16 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +`_x` <- 3L |> as.Integer() + +#' @method _inc integer +`_inc.integer` <- (function(a) { + { + a + 1L |> as.Integer() + } |> + as.Integer() +}) |> + as.Function0() + +print(`_inc`(`_x`)) diff --git a/cases/0102-underscore-name-not-backticked-at-use/observed.txt b/cases/0102-underscore-name-not-backticked-at-use/observed.txt new file mode 100644 index 00000000..cd735467 --- /dev/null +++ b/cases/0102-underscore-name-not-backticked-at-use/observed.txt @@ -0,0 +1,4 @@ +# exit=1 + +## stdout/stderr +Error: unexpected input in "print(_" diff --git a/cases/0102-underscore-name-not-backticked-at-use/repro/DESCRIPTION b/cases/0102-underscore-name-not-backticked-at-use/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0102-underscore-name-not-backticked-at-use/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/0102-underscore-name-not-backticked-at-use/repro/NAMESPACE b/cases/0102-underscore-name-not-backticked-at-use/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0102-underscore-name-not-backticked-at-use/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0102-underscore-name-not-backticked-at-use/repro/TypR/main.ty b/cases/0102-underscore-name-not-backticked-at-use/repro/TypR/main.ty new file mode 100644 index 00000000..53eccb28 --- /dev/null +++ b/cases/0102-underscore-name-not-backticked-at-use/repro/TypR/main.ty @@ -0,0 +1,3 @@ +let _x <- 3; +let _inc <- fn(a: int): int { a + 1 }; +print(_inc(_x)); diff --git a/cases/0103-record-field-name-with-underscore/case.toml b/cases/0103-record-field-name-with-underscore/case.toml new file mode 100644 index 00000000..2968b605 --- /dev/null +++ b/cases/0103-record-field-name-with-underscore/case.toml @@ -0,0 +1,7 @@ +title = "A record field name containing `_` (`list{ a_b = 1 }`, `Pt:{ x_y = 1 }`) fails to parse or silently becomes a tuple" +cmd = "run" +layer = "parse" +status = "fixed" +created = "2026-10-11" +origin = "perso" +checked = false diff --git a/cases/0103-record-field-name-with-underscore/expect.md b/cases/0103-record-field-name-with-underscore/expect.md new file mode 100644 index 00000000..3f495c52 --- /dev/null +++ b/cases/0103-record-field-name-with-underscore/expect.md @@ -0,0 +1,15 @@ +# record-field-name-with-underscore + +`name = value` fields (record literals, constructors, keyword records) were read by +`parsing/elements.rs::argument_val` with `alphanumeric1`, which stops at `_`. So `list{ a_b = 1 }` +was rejected (`list{...}` requires named fields), and `Pt:{ x_y = 1 }` fell back to another +parse — a `ForgottenSemicolon` warning and a `Tuple` instead of a `Pt`. + +## Ce qui devrait se passer + +`argument_name` accepts any identifier (`a_b`, hidden `.b`/`__b`) and still the bare +alphanumerics accepted before (`Name`). `p.x_y + r.a_b` prints `3`. + +## Anomalies (pre-fix) + +`Syntax error: list{...} requires named fields`, and `struct(list(x_y = ...), 'Tuple')` for `Pt`. diff --git a/cases/0103-record-field-name-with-underscore/expect.toml b/cases/0103-record-field-name-with-underscore/expect.toml new file mode 100644 index 00000000..93f05366 --- /dev/null +++ b/cases/0103-record-field-name-with-underscore/expect.toml @@ -0,0 +1,19 @@ +[[rule]] +file = "R/main.R" +must_contain = "Pt(x_y = " + +[[rule]] +file = "R/main.R" +must_contain = "list(a_b = " + +[[rule]] +file = "@run" +must_not_contain = "Syntax" + +[[rule]] +file = "@run" +must_contain = "3" + +[[rule]] +file = "@run" +must_not_contain = "failed" diff --git a/cases/0103-record-field-name-with-underscore/golden/R/main.R b/cases/0103-record-field-name-with-underscore/golden/R/main.R new file mode 100644 index 00000000..fc3b12af --- /dev/null +++ b/cases/0103-record-field-name-with-underscore/golden/R/main.R @@ -0,0 +1,39 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +Pt <- function(x_y, .spread = NULL) { + explicit <- list() + if (!missing(x_y)) { + explicit[["x_y"]] <- x_y + } + x <- typr_spread_record(explicit, .spread) + as.Pt(x) +} +as.Pt <- function(x) { + if (!inherits(x, "Pt")) { + class(x) <- c("Pt", "list") + } + x <- validate_Pt(x) + x <- validate(x) + x +} +validate_Pt <- function(x) { + required_fields <- c("x_y") + missing_fields <- setdiff(required_fields, names(x)) + if (length(missing_fields) > 0) { + stop(paste0( + "Validation failed for type Pt: missing fields: ", + paste(missing_fields, collapse = ", ") + )) + } + if (!inherits(x[["x_y"]], "integer")) { + stop("Validation failed for type Pt: field 'x_y' must be of class integer") + } + x +} +`p` <- Pt(x_y = 1L |> as.Integer()) + +`r` <- list(a_b = 2L |> as.Integer()) |> as.Record0() + +print(`+`(p[['x_y']], r[['a_b']])) diff --git a/cases/0103-record-field-name-with-underscore/observed.txt b/cases/0103-record-field-name-with-underscore/observed.txt new file mode 100644 index 00000000..a54465c8 --- /dev/null +++ b/cases/0103-record-field-name-with-underscore/observed.txt @@ -0,0 +1,4 @@ +# exit=1 + +## stdout/stderr +Syntax error: `list{...}` requires named fields (`name = value`) diff --git a/cases/0103-record-field-name-with-underscore/repro/DESCRIPTION b/cases/0103-record-field-name-with-underscore/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0103-record-field-name-with-underscore/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/0103-record-field-name-with-underscore/repro/NAMESPACE b/cases/0103-record-field-name-with-underscore/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0103-record-field-name-with-underscore/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0103-record-field-name-with-underscore/repro/TypR/main.ty b/cases/0103-record-field-name-with-underscore/repro/TypR/main.ty new file mode 100644 index 00000000..4fc8eb19 --- /dev/null +++ b/cases/0103-record-field-name-with-underscore/repro/TypR/main.ty @@ -0,0 +1,4 @@ +type Pt <- list{ x_y: int }; +let p <- Pt:{ x_y = 1 }; +let r <- list{ a_b = 2 }; +print(p.x_y + r.a_b); diff --git a/cases/0104-module-member-double-underscore-name/case.toml b/cases/0104-module-member-double-underscore-name/case.toml new file mode 100644 index 00000000..57422e5e --- /dev/null +++ b/cases/0104-module-member-double-underscore-name/case.toml @@ -0,0 +1,7 @@ +title = "A `@pub` module member with `__` in its name (`my__v`) is defined as `my.v` but exported and accessed as `M$my__v` — object not found at runtime" +cmd = "run" +layer = "r-run" +status = "fixed" +created = "2026-10-12" +origin = "perso" +checked = false diff --git a/cases/0104-module-member-double-underscore-name/expect.md b/cases/0104-module-member-double-underscore-name/expect.md new file mode 100644 index 00000000..7ea35d4b --- /dev/null +++ b/cases/0104-module-member-double-underscore-name/expect.md @@ -0,0 +1,25 @@ +# module-member-double-underscore-name + +`__` in a name stands for `.` in R (`my__v` → `my.v`), and a member's *definition* inside the +module's `local({...})` already applied it (the `Lang::Variable` arm of `transpiling/mod.rs`). +But the module's export lines (`M$name <- name`, `@export` re-exports, `registerS3method`, the +`--test` `M$.test_name` aliases and the test preamble) were built from the raw TypR name, and +`M::my__v` transpiled to `M$my__v`. A name starting with `_` was not quoted either (`M$_u`). + +## Ce qui devrait se passer + +Every module-side reference uses the name the definition actually binds: +`transpiling/mod.rs::module_member_name` (`__` → `.`, `_x` quoted) and `module_member_binding` +(plus the S3 class suffix, which a `__` name never gets — like its definition). `M::x` on a +module goes through the same conversion (`Op::Dollar` arm, `Type::Module` case). Prints `2`, +`5`, `7`, `2`. + +## Anomalies (pre-fix) + +``` +M$my__v <- my__v # but the binding is `my.v` +M$as__pt.Pt <- as__pt.Pt # but the binding is `as.pt`, with no S3 suffix +registerS3method("as__pt", "Pt", as__pt.Pt) +print(M$my__v) +``` +→ `objet 'my__v' introuvable`. diff --git a/cases/0104-module-member-double-underscore-name/expect.toml b/cases/0104-module-member-double-underscore-name/expect.toml new file mode 100644 index 00000000..6ee11092 --- /dev/null +++ b/cases/0104-module-member-double-underscore-name/expect.toml @@ -0,0 +1,32 @@ +[[rule]] +file = "R/main.R" +must_contain = "M$my.v <- my.v" + +[[rule]] +file = "R/main.R" +must_contain = "M$as.pt <- as.pt" + +[[rule]] +file = "R/main.R" +must_contain = "M$`_u` <- `_u`" + +[[rule]] +file = "R/main.R" +must_contain = "print(M$my.v)" + +[[rule]] +file = "R/main.R" +must_not_contain = "my__v" + +# a plain typed member keeps its S3 registration +[[rule]] +file = "R/main.R" +must_contain = "registerS3method(\"f\", \"Pt\", f.Pt)" + +[[rule]] +file = "@run" +must_contain = "2\n5\n7\n2" + +[[rule]] +file = "@run" +must_not_contain = "failed" diff --git a/cases/0104-module-member-double-underscore-name/golden/R/main.R b/cases/0104-module-member-double-underscore-name/golden/R/main.R new file mode 100644 index 00000000..de0898d1 --- /dev/null +++ b/cases/0104-module-member-double-underscore-name/golden/R/main.R @@ -0,0 +1,71 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +Pt <- function(x, .spread = NULL) { + explicit <- list() + if (!missing(x)) { + explicit[["x"]] <- x + } + x <- typr_spread_record(explicit, .spread) + as.Pt(x) +} +as.Pt <- function(x) { + if (!inherits(x, "Pt")) { + class(x) <- c("Pt", "list") + } + x <- validate_Pt(x) + x <- validate(x) + x +} +validate_Pt <- function(x) { + required_fields <- c("x") + missing_fields <- setdiff(required_fields, names(x)) + if (length(missing_fields) > 0) { + stop(paste0( + "Validation failed for type Pt: missing fields: ", + paste(missing_fields, collapse = ", ") + )) + } + if (!inherits(x[["x"]], "integer")) { + stop("Validation failed for type Pt: field 'x' must be of class integer") + } + x +} +f <- function(x, ...) UseMethod("f") +M <- new.env(parent = emptyenv()) +local({ + `my.v` <- 2L |> as.Integer() + + #' @method as pt + `as.pt` <- (function(p) { + { + p[['x']] + } |> + as.Integer() + }) |> + as.Generic() + + `_u` <- 7L |> as.Integer() + + #' @method f Pt + `f.Pt` <- (function(p) { + { + p[['x']] + 1L |> as.Integer() + } |> + as.Integer() + }) |> + as.Generic() + + M$my.v <- my.v + M$as.pt <- as.pt + M$`_u` <- `_u` + M$f.Pt <- f.Pt + registerS3method("f", "Pt", f.Pt) +}) +M$f <- f +f.Pt <- M$f.Pt +print(M$my.v) +print(M$as.pt(Pt(x = 5L |> as.Integer()))) +print(M$`_u`) +print(M$f(Pt(x = 1L |> as.Integer()))) diff --git a/cases/0104-module-member-double-underscore-name/observed.txt b/cases/0104-module-member-double-underscore-name/observed.txt new file mode 100644 index 00000000..6a12549a --- /dev/null +++ b/cases/0104-module-member-double-underscore-name/observed.txt @@ -0,0 +1,4 @@ +# exit=1 + +## stdout/stderr +Erreur dans eval(quote({ : objet 'my__v' introuvable diff --git a/cases/0104-module-member-double-underscore-name/repro/DESCRIPTION b/cases/0104-module-member-double-underscore-name/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0104-module-member-double-underscore-name/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/0104-module-member-double-underscore-name/repro/NAMESPACE b/cases/0104-module-member-double-underscore-name/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0104-module-member-double-underscore-name/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0104-module-member-double-underscore-name/repro/TypR/main.ty b/cases/0104-module-member-double-underscore-name/repro/TypR/main.ty new file mode 100644 index 00000000..3e72e2d9 --- /dev/null +++ b/cases/0104-module-member-double-underscore-name/repro/TypR/main.ty @@ -0,0 +1,13 @@ +type Pt <- list{ x: int }; + +module M { + @pub let my__v <- 2; + @pub let as__pt <- fn(p: Pt): int { p.x }; + @pub let _u <- 7; + @pub let f <- fn(p: Pt): int { p.x + 1 }; +}; + +print(M::my__v); +print(M::as__pt(Pt:{ x = 5 })); +print(M::_u); +print(M::f(Pt:{ x = 1 })); diff --git a/cases/0105-record-field-double-underscore-name/case.toml b/cases/0105-record-field-double-underscore-name/case.toml new file mode 100644 index 00000000..6d69fdac --- /dev/null +++ b/cases/0105-record-field-double-underscore-name/case.toml @@ -0,0 +1,7 @@ +title = "A record field with `__` (`a__b`) is built as `a__b` but read through `.` as `a.b` — field access returns NULL" +cmd = "run" +layer = "r-run" +status = "fixed" +created = "2026-10-12" +origin = "perso" +checked = false diff --git a/cases/0105-record-field-double-underscore-name/expect.md b/cases/0105-record-field-double-underscore-name/expect.md new file mode 100644 index 00000000..736b5f6b --- /dev/null +++ b/cases/0105-record-field-double-underscore-name/expect.md @@ -0,0 +1,28 @@ +# record-field-double-underscore-name + +`__` in a name stands for `.` in R. Field *access* through `.` already applied it +(`r.a__b` → `r[['a.b']]`), but every other place a field name reaches R used the raw TypR +name: record literals and constructor calls (`list(a__b = ...)`, `Qq(a__b = ...)`), the record +alias constructor/validator (`function(a__b)`, `explicit[["a__b"]]`, `required_fields`), `$` +access (`r$a__b`), nominal spreads (`a__b = q$a__b`) and record patterns in `match`. So the +value was stored under `a__b` and read back under `a.b` → `NULL`. + +## Ce qui devrait se passer + +A field `a__b` is `a.b` in R everywhere. Two helpers in `components/language/mod.rs`: +`r_name_string` (`__` → `.`, for string positions: `[['a.b']]`, `c("a.b")`) and +`r_name_symbol` (same, plus backticks for a leading `_`, for symbol positions: +`list(a.b = )`, `function(a.b)`, `x$a.b`). Used by `ArgumentValue::to_r`, the `Op::Dot` / +`Op::Dollar` arms, the record/dataframe alias generators and the `match` record patterns of +`transpiling/mod.rs`. A `_u` field is now also read right through `.` (`d[['_u']]`, it used to +go through the symbol path and become `` d[['`_u`']] ``). + +Prints `1 1 4 2 1 2 4 3`, one per line. + +## Anomalies (pre-fix) + +``` +`r` <- list(a__b = 1L, ...) +print(r[['a.b']]) # NULL +print(r$a__b) # 1 +``` diff --git a/cases/0105-record-field-double-underscore-name/expect.toml b/cases/0105-record-field-double-underscore-name/expect.toml new file mode 100644 index 00000000..e18c765d --- /dev/null +++ b/cases/0105-record-field-double-underscore-name/expect.toml @@ -0,0 +1,36 @@ +[[rule]] +file = "R/main.R" +must_not_contain = "a__b" + +[[rule]] +file = "R/main.R" +must_contain = "list(a.b = " + +[[rule]] +file = "R/main.R" +must_contain = "r$a.b" + +[[rule]] +file = "R/main.R" +must_contain = "`_u` = " + +[[rule]] +file = "R/main.R" +must_contain = "d[['_u']]" + +# constructor / validator of the record alias +[[rule]] +file = "R/main.R" +must_contain = "Qq <- function(a.b, c, .spread = NULL)" + +[[rule]] +file = "R/main.R" +must_contain = "required_fields <- c(\"a.b\", \"c\")" + +[[rule]] +file = "@run" +must_contain = "1\n1\n4\n2\n1\n2\n4\n3" + +[[rule]] +file = "@run" +must_not_contain = "NULL" diff --git a/cases/0105-record-field-double-underscore-name/golden/R/main.R b/cases/0105-record-field-double-underscore-name/golden/R/main.R new file mode 100644 index 00000000..8859411f --- /dev/null +++ b/cases/0105-record-field-double-underscore-name/golden/R/main.R @@ -0,0 +1,78 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +Qq <- function(a.b, c, .spread = NULL) { + explicit <- list() + if (!missing(a.b)) { + explicit[["a.b"]] <- a.b + } + if (!missing(c)) { + explicit[["c"]] <- c + } + x <- typr_spread_record(explicit, .spread) + as.Qq(x) +} +as.Qq <- function(x) { + if (!inherits(x, "Qq")) { + class(x) <- c("Qq", "list") + } + x <- validate_Qq(x) + x <- validate(x) + x +} +validate_Qq <- function(x) { + required_fields <- c("a.b", "c") + missing_fields <- setdiff(required_fields, names(x)) + if (length(missing_fields) > 0) { + stop(paste0( + "Validation failed for type Qq: missing fields: ", + paste(missing_fields, collapse = ", ") + )) + } + if (!inherits(x[["a.b"]], "integer")) { + stop("Validation failed for type Qq: field 'a.b' must be of class integer") + } + if (!inherits(x[["c"]], "integer")) { + stop("Validation failed for type Qq: field 'c' must be of class integer") + } + x +} +`r` <- list(a.b = 1L |> as.Integer(), c = 2L |> as.Integer()) |> as.Record0() + +`q` <- Qq(a.b = 2L |> as.Integer(), c = 3L |> as.Integer()) + +`q2` <- Qq(c = 4L |> as.Integer(), a.b = q$a.b) + +`s` <- spread(r, list(d = 1L |> as.Integer())) + +#' @method get_ab Qq +`get_ab.Qq` <- (function(x) { + { + x[['a.b']] + } |> + as.Integer() +}) |> + as.Generic() + +print(r[['a.b']]) +print(r$a.b) +print(`+`(q[['a.b']], q$a.b)) +print(q2[['a.b']]) +print(s[['a.b']]) +print(get_ab(q)) +`d` <- list(a.b = 8L |> as.Integer(), `_u` = 1L |> as.Integer()) |> as.Record2() + +d$a.b <- 3L |> as.Integer() +print(`+`(d[['a.b']], d[['_u']])) +`m` <- { + match_val__ <- d + if (is.list(match_val__) && !is.null(match_val__[["a.b"]])) { + v <- match_val__[["a.b"]] + v + } else { + 0L |> as.Integer() + } +} + +print(m) diff --git a/cases/0105-record-field-double-underscore-name/observed.txt b/cases/0105-record-field-double-underscore-name/observed.txt new file mode 100644 index 00000000..5ab40319 --- /dev/null +++ b/cases/0105-record-field-double-underscore-name/observed.txt @@ -0,0 +1,7 @@ +# exit=0 + +## stdout/stderr +NULL +1 +NULL +... diff --git a/cases/0105-record-field-double-underscore-name/repro/DESCRIPTION b/cases/0105-record-field-double-underscore-name/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0105-record-field-double-underscore-name/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/0105-record-field-double-underscore-name/repro/NAMESPACE b/cases/0105-record-field-double-underscore-name/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0105-record-field-double-underscore-name/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0105-record-field-double-underscore-name/repro/TypR/main.ty b/cases/0105-record-field-double-underscore-name/repro/TypR/main.ty new file mode 100644 index 00000000..d332526c --- /dev/null +++ b/cases/0105-record-field-double-underscore-name/repro/TypR/main.ty @@ -0,0 +1,24 @@ +type Qq <- list{ a__b: int, c: int }; + +let r <- list{ a__b = 1, c = 2 }; +let q <- Qq:{ a__b = 2, c = 3 }; +let q2 <- Qq:{ ..q, c = 4 }; +let s <- :{ ...r, d = 1 }; +let get_ab <- fn(x: Qq): int { x.a__b }; + +print(r.a__b); +print(r$a__b); +print(q.a__b + q$a__b); +print(q2.a__b); +print(s.a__b); +print(get_ab(q)); + +let d <- :{ a__b: 8, _u: 1 }; +d$a__b <- 3; +print(d.a__b + d._u); + +let m <- match d { + :{ a__b: v } => v, + _ => 0 +}; +print(m); diff --git a/cases/0106-dataframe-dot-column-access/case.toml b/cases/0106-dataframe-dot-column-access/case.toml new file mode 100644 index 00000000..9e4af936 --- /dev/null +++ b/cases/0106-dataframe-dot-column-access/case.toml @@ -0,0 +1,7 @@ +title = "`t.col` on a typed dataframe is rejected (Undefined variable 'col') while `t$col` type-checks" +cmd = "run" +layer = "type" +status = "fixed" +created = "2026-10-12" +origin = "perso" +checked = false diff --git a/cases/0106-dataframe-dot-column-access/expect.md b/cases/0106-dataframe-dot-column-access/expect.md new file mode 100644 index 00000000..278c3584 --- /dev/null +++ b/cases/0106-dataframe-dot-column-access/expect.md @@ -0,0 +1,23 @@ +# dataframe-dot-column-access + +`dot_pipe_access.rs` only knew `Type::Record` receivers for plain field access, so on a typed +dataframe (`Vec(DataFrame, N, Record{...})`) `t.colx` fell through to typing `colx` as a free +variable: `Undefined variable 'colx'`, then `This expression doesn't type-check`. `t$colx` +worked, through a dedicated arm of `dollar_access.rs`. + +## Ce qui devrait se passer + +`t.colx` types exactly like `t$colx`: a plain vector of the column's element type with the +dataframe's row count (`[2, int]`). The rule lives in one place, +`field_access::dataframe_column_or_push_error`, used by both operators (it replaces the two +duplicated `$` arms, identifier and string literal). An unknown column is a `FieldNotFound` +error on the dataframe type. In R, `t.colx` is `t[['colx']]`, which reads the column. + +Prints `[1] 1 2`, `[1] "a" "b"`, `[1] 6`. + +## Anomalies (pre-fix) + +``` +× Type error: Undefined variable 'colx' +× Type error: This expression doesn't type-check +``` diff --git a/cases/0106-dataframe-dot-column-access/expect.toml b/cases/0106-dataframe-dot-column-access/expect.toml new file mode 100644 index 00000000..450103a9 --- /dev/null +++ b/cases/0106-dataframe-dot-column-access/expect.toml @@ -0,0 +1,15 @@ +[[rule]] +file = "R/main.R" +must_contain = "t[['colx']]" + +[[rule]] +file = "R/main.R" +must_contain = "t[['col.y']]" + +[[rule]] +file = "@run" +must_not_contain = "Type error" + +[[rule]] +file = "@run" +must_contain = "[1] 1 2\n[1] \"a\" \"b\"\n[1] 6" diff --git a/cases/0106-dataframe-dot-column-access/golden/R/main.R b/cases/0106-dataframe-dot-column-access/golden/R/main.R new file mode 100644 index 00000000..9977b778 --- /dev/null +++ b/cases/0106-dataframe-dot-column-access/golden/R/main.R @@ -0,0 +1,63 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +Tab <- function(col.y, colx, .spread = NULL) { + explicit <- list() + if (!missing(col.y)) { + explicit[["col.y"]] <- col.y + } + if (!missing(colx)) { + explicit[["colx"]] <- colx + } + x <- typr_spread_record(explicit, .spread) + as.Tab(do.call(data.frame, c(x, list(stringsAsFactors = FALSE)))) +} +as.Tab <- function(x) { + if (!inherits(x, "Tab")) { + class(x) <- c("Tab", "data.frame", "list") + } + x <- validate_Tab(x) + x <- validate(x) + x +} +validate_Tab <- function(x) { + if (!is.data.frame(x)) { + stop("Validation failed for type Tab: expected a data.frame") + } + required_fields <- c("col.y", "colx") + missing_fields <- setdiff(required_fields, names(x)) + if (length(missing_fields) > 0) { + stop(paste0( + "Validation failed for type Tab: missing columns: ", + paste(missing_fields, collapse = ", ") + )) + } + if (!inherits(x[["col.y"]], "character")) { + stop( + "Validation failed for type Tab: column 'col.y' must be of class character" + ) + } + if (!inherits(x[["colx"]], "integer")) { + stop( + "Validation failed for type Tab: column 'colx' must be of class integer" + ) + } + if (nrow(x) != 2) { + stop(paste0( + "Validation failed for type Tab: expected 2 rows, got ", + nrow(x) + )) + } + x +} +`t` <- Tab( + colx = c(1L |> as.Integer(), 2L |> as.Integer()) |> identity(), + col.y = c("a" |> as.Character(), "b" |> as.Character()) |> identity() +) + +`a` <- t[['colx']] |> identity() + +print(a) +print(t[['col.y']]) +print(`+`(sum(t[['colx']]), sum(t$colx))) diff --git a/cases/0106-dataframe-dot-column-access/observed.txt b/cases/0106-dataframe-dot-column-access/observed.txt new file mode 100644 index 00000000..91d41c05 --- /dev/null +++ b/cases/0106-dataframe-dot-column-access/observed.txt @@ -0,0 +1,5 @@ +# exit=1 + +## stdout/stderr +Type error: Undefined variable 'colx' +Type error: This expression doesn't type-check diff --git a/cases/0106-dataframe-dot-column-access/repro/DESCRIPTION b/cases/0106-dataframe-dot-column-access/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0106-dataframe-dot-column-access/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/0106-dataframe-dot-column-access/repro/NAMESPACE b/cases/0106-dataframe-dot-column-access/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0106-dataframe-dot-column-access/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0106-dataframe-dot-column-access/repro/TypR/main.ty b/cases/0106-dataframe-dot-column-access/repro/TypR/main.ty new file mode 100644 index 00000000..deabe0b5 --- /dev/null +++ b/cases/0106-dataframe-dot-column-access/repro/TypR/main.ty @@ -0,0 +1,8 @@ +type Tab <- df[2]{ colx: int, col__y: char }; + +let t <- Tab:{ colx = [1, 2], col__y = ["a", "b"] }; +let a: [2, int] <- t.colx; + +print(a); +print(t.col__y); +print(sum(t.colx) + sum(t$colx)); diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index ac1cd83c..8fed6dd3 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -12,10 +12,12 @@ use crate::project::{ build_file, build_project, check_file, check_project, clean, cran, debug_file, document, generate_spg, load, new, - pkg_install, pkg_uninstall, pkgdown, run_file, run_file_keep, run_project, test, use_package, DebugOptions, + pkg_install, pkg_uninstall, pkgdown, run_file, run_file_keep_R_files, run_in_project, test, use_package, + DebugOptions, }; use crate::repl; use crate::standard_library::{standard_library, standard_library_doc}; +use crate::tutor; use clap::{Parser, Subcommand}; use std::path::PathBuf; @@ -159,6 +161,19 @@ enum Commands { cache_command: CacheCommands, }, Repl, + /// Learn TypR step by step, like vimtutor: short lessons you complete in + /// your editor, graded by the compiler, each one leading into the docs. + /// + /// `typr tutor` creates a `typr-tutor/` folder (or resumes it) and tells + /// you which file to open. To learn with an AI assistant instead, connect + /// it to `typr mcp` and use its `tutor` prompt: same lessons, same grading. + Tutor { + #[command(subcommand)] + tutor_command: Option, + /// The tutor workspace folder. + #[arg(long, global = true, value_name = "DIR", default_value = "typr-tutor")] + dir: PathBuf, + }, Lsp, /// Start an MCP server over stdio, exposing the compiler to AI agents /// (`check` and `build` tools — see crates/typr-mcp). @@ -351,6 +366,20 @@ enum CaseCommands { Show { id: String }, } +#[derive(Subcommand, Debug)] +enum TutorCommands { + /// Grade a lesson file (default: the first lesson not passed yet). + Check { lesson: Option }, + /// Show the next hint for a lesson (default: the current one). + Hint { lesson: Option }, + /// List the lessons and your progress. + List, + /// Restore a lesson file to its original content. + Reset { lesson: usize }, + /// Print a reference solution for a lesson. + Solution { lesson: usize }, +} + #[derive(Subcommand, Debug)] enum CacheCommands { /// Delete the cached table. The next build re-seeds it from the embedded @@ -441,6 +470,7 @@ fn skips_r_deps_check(command: &Option) -> bool { Some(Commands::Init) | Some(Commands::Lsp) | Some(Commands::Mcp) + | Some(Commands::Tutor { .. }) | Some(Commands::Std { .. }) | Some(Commands::Cache { .. }) | Some(Commands::Syntax { .. }) @@ -458,6 +488,7 @@ pub fn start() { crate::r_deps::warn_if_missing(); } + // $ typr [path].ty if let Some(path) = cli.file { if cli.command.is_none() { run_file(&path); @@ -466,12 +497,39 @@ pub fn start() { } match cli.command { - Some(Commands::Init) => crate::r_deps::init(), - Some(Commands::New { name, renv }) => new(&name, renv), + // $ typr run + Some(Commands::Run { + file, + profile, + checked, + strict, + }) => match file { + Some(path) => run_file_keep_R_files(&path, profile, checked, strict), + _ => run_in_project(profile, checked, strict), + }, + // $ typr build + Some(Commands::Build { + file, + test, + no_incremental, + checked, + strict, + }) => match file { + Some(path) => build_file(&path, test, checked, strict), + _ => build_project(test, no_incremental, checked, strict), + }, + // $ typr check Some(Commands::Check { file }) => match file { Some(path) => check_file(&path), _ => check_project(), }, + // $ typr test + Some(Commands::Test { profile }) => test(profile), + // $ typr new + Some(Commands::New { name, renv }) => new(&name, renv), + // $ typr init + Some(Commands::Init) => crate::r_deps::init(), + // $ typr graph diff Some(Commands::Graph { graph_command: Some(GraphCommands::Diff { @@ -481,6 +539,7 @@ pub fn start() { }), .. }) => crate::graph::graph_diff(&old_file, &new_file, &format), + // $ typr graph [file] Some(Commands::Graph { graph_command: None, file: Some(file), @@ -488,6 +547,7 @@ pub fn start() { focus, projection, }) => crate::graph::graph_file(&file, &format, focus.as_deref(), projection.as_deref()), + // $ typr graph Some(Commands::Graph { graph_command: None, file: None, @@ -496,25 +556,7 @@ pub fn start() { eprintln!("typr graph requires a FILE, or use `typr graph diff `"); std::process::exit(1); } - Some(Commands::Build { - file, - test, - no_incremental, - checked, - strict, - }) => match file { - Some(path) => build_file(&path, test, checked, strict), - _ => build_project(test, no_incremental, checked, strict), - }, - Some(Commands::Run { - file, - profile, - checked, - strict, - }) => match file { - Some(path) => run_file_keep(&path, profile, checked, strict), - _ => run_project(profile, checked, strict), - }, + // $ typr debug Some(Commands::Debug { file, ast, @@ -532,11 +574,12 @@ pub fn start() { show_files: files, }, ), - Some(Commands::Test { profile }) => test(profile), + // $ typr pkg Some(Commands::Pkg { pkg_command }) => match pkg_command { PkgCommands::Install { packages } => pkg_install(packages.as_deref()), PkgCommands::Uninstall => pkg_uninstall(), }, + // $ typr case Some(Commands::Case { case_command }) => match case_command { CaseCommands::List { status } => crate::cases::list(status), CaseCommands::Run { filter, status, keep } => crate::cases::run(filter, status, keep), @@ -545,6 +588,7 @@ pub fn start() { CaseCommands::Freeze { id } => crate::cases::freeze(&id), CaseCommands::Show { id } => crate::cases::show(&id), }, + // $ typr fuzz Some(Commands::Fuzz { fuzz_command }) => match fuzz_command { FuzzCommands::Run { n, @@ -555,20 +599,28 @@ pub fn start() { FuzzCommands::Stats { n, max_depth } => crate::fuzz::stats(n, max_depth), FuzzCommands::Promote { hash } => crate::fuzz::promote(&hash), }, + // $ typr document Some(Commands::Document) => document(), + // $ typr pkgdown Some(Commands::Pkgdown) => pkgdown(), Some(Commands::Use { package_name }) => { use_package(&package_name); try_auto_resolve_type_definition(std::path::Path::new("."), &package_name); } + // $ typr load Some(Commands::Load) => load(), + // $ typr cran Some(Commands::Cran) => cran(), + // $ typr std Some(Commands::Std { std_command }) => match std_command { None => standard_library(), Some(StdCommands::Doc { output, format }) => standard_library_doc(output, &format), }, + // $ typr clean Some(Commands::Clean) => clean(), + // $ typr cache Some(Commands::Cache { cache_command }) => run_cache_command(cache_command), + // $ typr lsp Some(Commands::Lsp) => { // Use a larger stack size (8MB) to avoid stack overflow // during deep recursive parsing/type-checking operations @@ -579,6 +631,7 @@ pub fn start() { .unwrap(); rt.block_on(typr_lsp::run_lsp()); } + // $ typr mcp Some(Commands::Mcp) => { // Same large-stack rationale as `Lsp`: type checking recurses. let rt = tokio::runtime::Builder::new_multi_thread() @@ -591,7 +644,26 @@ pub fn start() { std::process::exit(1); } } + // $ typr rpl Some(Commands::Repl) => repl::start(), + // $ typr tutor + Some(Commands::Tutor { tutor_command, dir }) => { + // Grading type-checks in process: same large-stack rationale as `Lsp`. + std::thread::Builder::new() + .stack_size(8 * 1024 * 1024) + .spawn(move || match tutor_command { + None => tutor::start(&dir), + Some(TutorCommands::Check { lesson }) => tutor::check(&dir, lesson), + Some(TutorCommands::Hint { lesson }) => tutor::hint(&dir, lesson), + Some(TutorCommands::List) => tutor::list(&dir), + Some(TutorCommands::Reset { lesson }) => tutor::reset(&dir, lesson), + Some(TutorCommands::Solution { lesson }) => tutor::solution(lesson), + }) + .unwrap() + .join() + .unwrap(); + } + // $ typr syntax Some(Commands::Syntax { json, target, @@ -599,9 +671,13 @@ pub fn start() { write, check, }) => run_syntax_command(json, target, output, write, check), + // $ typr spg Some(Commands::Spg { output }) => generate_spg(output), + // $ typr gen-types Some(Commands::GenTypes { package, out }) => crate::gen_types::run(&package, out), + // $ typr types Some(Commands::Types { types_command }) => run_types_command(types_command), + // $ typr search Some(Commands::Search { package }) => run_search_command(&package), _ => { println!("Please specify a subcommand or file to execute"); diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index fdfdc8f1..91ebc234 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -62,6 +62,7 @@ pub mod registry_validate; pub mod repl; pub mod standard_library; pub mod syntax; +pub mod tutor; pub mod type_definition; pub mod type_registry; pub mod vignette_renderer; diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index ec750f14..223cac6a 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -25,6 +25,7 @@ mod registry_validate; mod repl; mod standard_library; mod syntax; +mod tutor; mod type_definition; mod type_registry; mod vignette_renderer; diff --git a/crates/typr-cli/src/metaprogramming.rs b/crates/typr-cli/src/metaprogramming.rs index 82d584fa..488f3cb1 100644 --- a/crates/typr-cli/src/metaprogramming.rs +++ b/crates/typr-cli/src/metaprogramming.rs @@ -27,10 +27,13 @@ use typr_core::processes::parsing::parse; /// Walk up from `file_path`'s directory looking for a `DESCRIPTION` + /// `NAMESPACE` pair, returning the directory that contains them. Works for -/// both absolute paths (LSP, arbitrary process cwd) and the relative paths -/// the CLI uses (where it's always invoked from the project root). +/// both absolute paths (LSP, arbitrary process cwd) and relative ones: a +/// relative path is first made absolute against the cwd, otherwise a file +/// given from inside `TypR/` (`typr check object.ty`) would stop the walk at +/// `""` and never reach the project root. pub fn find_project_root(file_path: &str) -> Option { - let mut dir = Path::new(file_path).parent(); + let file_path = std::path::absolute(file_path).unwrap_or_else(|_| PathBuf::from(file_path)); + let mut dir = file_path.parent(); while let Some(d) = dir { if d.join("DESCRIPTION").exists() && d.join("NAMESPACE").exists() { return Some(d.to_path_buf()); diff --git a/crates/typr-cli/src/project.rs b/crates/typr-cli/src/project.rs index 5b38c630..75870273 100644 --- a/crates/typr-cli/src/project.rs +++ b/crates/typr-cli/src/project.rs @@ -82,6 +82,11 @@ fn print_step(title: &str) { /// `std.ty` instead of the user's file — neither is a state any working /// project depends on, and treating it as a mere warning would leave the /// silent-data-loss case exactly as silent as before. +/// `HiddenNameAfterDot` (`x..f()`) and `PublicHiddenName` (`@pub let .x`) +/// are fatal from day one: both only became reachable when hidden `.names` +/// were introduced (`x..f` didn't parse at all before), so no existing +/// project can depend on them, and each is a contradiction rather than a +/// style slip — the recovered AST would silently pick one reading. fn report_syntax_errors(errors: Vec) -> bool { if errors.is_empty() { return false; @@ -93,6 +98,8 @@ fn report_syntax_errors(errors: Vec) -> bool { | SyntaxError::SingleLetterTypeName { .. } | SyntaxError::KeywordRecordPositionalElements { .. } | SyntaxError::TupleDestructureArityMismatch { .. } + | SyntaxError::HiddenNameAfterDot(..) + | SyntaxError::PublicHiddenName { .. } ) }); if !warnings.is_empty() { @@ -526,7 +533,13 @@ pub fn write_header(context: Context, output_dir: &Path, environment: Environmen .map(|(var, _)| var.get_name()) .filter(|x| !x.contains("<-")) .map(|fn_name| { - let export = if environment.is_project() { "#' @export\n" } else { "" }.to_string(); + // A hidden `.name` stays out of the package's NAMESPACE. + let export = if environment.is_project() && !fn_name.starts_with('.') { + "#' @export\n" + } else { + "" + } + .to_string(); format!( "{}{} <- function(x, ...) UseMethod('{}', x)", export, @@ -756,11 +769,13 @@ pub fn check_project() { } pub fn check_file(path: &PathBuf) { + // The file may be given from anywhere inside the project (e.g. from + // `TypR/`), so anchor everything on the project root, not the cwd. + let dir = crate::metaprogramming::find_project_root(&path.to_string_lossy()).unwrap_or_else(|| PathBuf::from(".")); let context = crate::standard_library::load_project_type_definitions( - Path::new("."), + &dir, Context::default().set_environment(Environment::Project), ); - let dir = PathBuf::from("."); write_std_for_type_checking(&dir); let step = Step::new("Parsing"); @@ -1164,7 +1179,7 @@ fn rprof_wrap(r_body: &str) -> String { ) } -pub fn run_project(profile: bool, checked_mode: bool, strict_mode: bool) { +pub fn run_in_project(profile: bool, checked_mode: bool, strict_mode: bool) { build_project_impl(false, checked_mode, strict_mode, true, true, true); // Use the TypR loader instead of devtools to respect module encapsulation. // Top-level code in main.ty already runs as a side effect of sourcing it @@ -1228,8 +1243,9 @@ 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 the file while keeping the generated +/// R files (app.R, std.R, types.R, generic_functions.R) +pub fn run_file_keep_R_files(path: &Path, profile: bool, checked_mode: bool, strict_mode: bool) { run_file_impl(path, true, profile, checked_mode, strict_mode); } @@ -1258,6 +1274,7 @@ impl Drop for TempDirGuard { } } +// @explore fn run_file_impl(path: &Path, keep_files: bool, profile: bool, checked_mode: bool, strict_mode: bool) { let step = Step::new("Parsing"); let content = get_content(path); @@ -1644,7 +1661,7 @@ pub fn pkg_install(packages: Option<&[String]>) { } }; - let project_path = current_dir.to_str().unwrap(); + let project_path = r_path(¤t_dir); let r_command = format!("devtools::install_local('{}')", project_path); println!("Executing: R -e \"{}\"", r_command); @@ -1736,7 +1753,7 @@ pub fn pkgdown() { std::process::exit(1); } }; - let project_path = current_dir.to_str().unwrap_or("."); + let project_path = r_path(¤t_dir); let r_command = format!("pkgdown::build_site('{}')", project_path); let output = Command::new("R").arg("-e").arg(&r_command).output(); match output { @@ -1859,6 +1876,18 @@ fn document_from_spg(spg: &Spg, quiet: bool, manifest: Option<&mut cache::BuildM update_namespace(quiet, manifest); } +/// Format a path for embedding in a single-quoted R string literal. +/// +/// R accepts forward slashes as path separators on every platform, +/// Windows included — while backslashes in a Windows absolute path are +/// read by R's parser as escape sequences (`\U` starts a unicode +/// escape, which aborts commands like `devtools::install_local()`). +/// Converting to forward slashes is the standard fix and is a no-op on +/// Unix paths, which contain no backslashes. +fn r_path(p: &std::path::Path) -> String { + p.to_string_lossy().replace('\\', "/") +} + /// Run `devtools::document(roclets = c('collate', 'namespace'))`. When a /// build manifest is provided and the inputs of that run (`R/*.R` + /// `DESCRIPTION`) hash to the same value as the last successful run, the R @@ -1882,7 +1911,7 @@ fn update_namespace(quiet: bool, manifest: Option<&mut cache::BuildManifest>) { std::process::exit(1); } }; - let project_path = current_dir.to_str().unwrap_or("."); + let project_path = r_path(¤t_dir); let r_command = format!( "devtools::document('{}', roclets = c('collate', 'namespace'))", project_path @@ -2117,3 +2146,47 @@ mod roxygen_injection_tests { assert!(entries.is_empty()); } } + +#[cfg(test)] +mod r_path_tests { + use super::r_path; + use std::path::Path; + + /// The bug this fixes: a Windows absolute path interpolated into an + /// R string literal makes R's parser read `\U` as a unicode escape + /// and abort (`devtools::install_local('C:\Users\...')`). Forward + /// slashes are valid path separators in R on Windows too. + #[test] + fn windows_backslashes_become_forward_slashes() { + let p = Path::new(r"C:\Users\Desktop\TypR\mytestpkg"); + assert_eq!(r_path(p), "C:/Users/Desktop/TypR/mytestpkg"); + } + + /// Unix paths contain no backslashes — the conversion must be a + /// no-op so Linux/macOS behavior is unchanged. + #[test] + fn unix_paths_are_unchanged() { + let p = Path::new("/home/user/mytestpkg"); + assert_eq!(r_path(p), "/home/user/mytestpkg"); + } + + /// A bare "." (the fallback when the current directory can't be + /// read) must survive untouched. + #[test] + fn dot_fallback_is_unchanged() { + assert_eq!(r_path(Path::new(".")), "."); + } + + /// The generated R command must not contain any backslash that R + /// could misread as an escape sequence. + #[test] + fn generated_r_command_contains_no_backslash() { + let p = Path::new(r"C:\Users\Desktop\TypR\mytestpkg"); + let cmd = format!("devtools::install_local('{}')", r_path(p)); + assert!(!cmd.contains('\\'), "{}", cmd); + assert_eq!( + cmd, + "devtools::install_local('C:/Users/Desktop/TypR/mytestpkg')" + ); + } +} diff --git a/crates/typr-cli/src/tutor.rs b/crates/typr-cli/src/tutor.rs new file mode 100644 index 00000000..9e344fb6 --- /dev/null +++ b/crates/typr-cli/src/tutor.rs @@ -0,0 +1,402 @@ +//! `typr tutor`: the terminal front end of the TypR tutor, in the spirit of +//! `vimtutor` — no AI needed. +//! +//! The lessons, their grading and their hints live in `typr_mcp::tutor`, +//! shared with the MCP server's `tutor_*` tools, so a learner gets the same +//! verdict from the terminal and from an AI assistant. This module only owns +//! the workspace: a folder holding one editable `.ty` file per lesson plus a +//! small progress file. + +use std::collections::BTreeMap; +use std::fs; +use std::io::IsTerminal; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use typr_mcp::tutor::{self, Lesson, LESSONS}; + +const PROGRESS_FILE: &str = ".tutor-progress.json"; + +#[derive(Default, Serialize, Deserialize)] +struct Progress { + /// Lesson numbers passed at least once. + passed: Vec, + /// Hints already shown, per lesson number. + hints: BTreeMap, +} + +impl Progress { + fn load(dir: &Path) -> Progress { + fs::read_to_string(dir.join(PROGRESS_FILE)) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + fn save(&self, dir: &Path) { + if let Ok(json) = serde_json::to_string_pretty(self) { + let _ = fs::write(dir.join(PROGRESS_FILE), json); + } + } + + /// The first lesson not passed yet, or the last one when all are. + fn current(&self) -> &'static Lesson { + LESSONS + .iter() + .find(|l| !self.passed.contains(&l.number)) + .unwrap_or_else(|| LESSONS.last().unwrap()) + } +} + +struct Style { + on: bool, +} + +impl Style { + fn new() -> Style { + Style { + on: std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(), + } + } + fn paint(&self, code: &str, text: &str) -> String { + if self.on { + format!("\x1b[{code}m{text}\x1b[0m") + } else { + text.to_string() + } + } + fn bold(&self, t: &str) -> String { + self.paint("1", t) + } + fn green(&self, t: &str) -> String { + self.paint("32", t) + } + fn red(&self, t: &str) -> String { + self.paint("31", t) + } + fn yellow(&self, t: &str) -> String { + self.paint("33", t) + } + fn cyan(&self, t: &str) -> String { + self.paint("36", t) + } + fn dim(&self, t: &str) -> String { + self.paint("2", t) + } +} + +fn lesson_or_exit(number: usize) -> &'static Lesson { + tutor::find(number).unwrap_or_else(|| { + eprintln!("error: no lesson {number}: lessons are numbered 1 to {}", LESSONS.len()); + std::process::exit(1); + }) +} + +/// Create the workspace if needed. Returns true when it was just created. +fn ensure_workspace(dir: &Path) -> bool { + if dir.join(PROGRESS_FILE).exists() { + return false; + } + if let Err(e) = fs::create_dir_all(dir) { + eprintln!("error: cannot create {}: {e}", dir.display()); + std::process::exit(1); + } + for lesson in LESSONS { + let path = dir.join(lesson.file_name()); + // Never overwrite work: an existing folder may hold edited lessons. + if !path.exists() { + if let Err(e) = fs::write(&path, lesson.starter) { + eprintln!("error: cannot write {}: {e}", path.display()); + std::process::exit(1); + } + } + } + Progress::default().save(dir); + true +} + +fn require_workspace(dir: &Path) { + if !dir.join(PROGRESS_FILE).exists() { + eprintln!( + "error: no tutor workspace in {} — run `typr tutor` first to create it.", + dir.display() + ); + std::process::exit(1); + } +} + +fn dir_arg(dir: &Path) -> String { + if dir == Path::new("typr-tutor") { + String::new() + } else { + format!(" --dir {}", dir.display()) + } +} + +fn print_lesson_card(s: &Style, dir: &Path, lesson: &Lesson) { + println!( + " {} {}", + s.bold(&format!("Lesson {}/{}:", lesson.number, LESSONS.len())), + s.bold(lesson.title) + ); + println!(" {}", lesson.goal); + println!(); + println!( + " Open {}", + s.cyan(&dir.join(lesson.file_name()).display().to_string()) + ); + println!(" in your editor, follow the comments, save, then run:"); + println!(); + println!(" {}", s.cyan(&format!("typr tutor check{}", dir_arg(dir)))); +} + +/// `typr tutor`: create the workspace (or resume it) and say what to do next. +pub fn start(dir: &Path) { + let s = Style::new(); + let created = ensure_workspace(dir); + let progress = Progress::load(dir); + println!(); + if created { + println!(" {}", s.bold("Welcome to the TypR tutor!")); + println!(); + println!( + " {} short lessons, about 30 minutes in all. Each lesson is a TypR file:", + LESSONS.len() + ); + println!(" the comments teach, the code is yours to complete. The compiler grades"); + println!(" your answers, and every lesson ends with the documentation pages that"); + println!(" take you further. Nothing can break: `typr tutor reset N` restores a lesson."); + println!(); + println!(" Lessons written to {}", s.cyan(&dir.display().to_string())); + } else { + println!( + " {} {} of {} lessons done.", + s.bold("Welcome back!"), + progress.passed.len(), + LESSONS.len() + ); + } + println!(); + if progress.passed.len() == LESSONS.len() { + println!(" {}", s.green("You have completed every lesson.")); + println!(" Revisit any of them with `typr tutor check N`, or keep exploring:"); + print_docs(&s, LESSONS.last().unwrap()); + } else { + print_lesson_card(&s, dir, progress.current()); + } + println!(); + println!( + " {}", + s.dim("Other commands: typr tutor list | hint | reset N | solution N") + ); + println!( + " {}", + s.dim("Prefer learning with an AI assistant? See `typr tutor --help`.") + ); + println!(); +} + +fn print_docs(s: &Style, lesson: &Lesson) { + println!(); + println!(" {}", s.bold("Explore further:")); + for d in lesson.docs { + println!(" • {} {}", d.title, s.cyan(&d.url())); + } +} + +/// `typr tutor check [N]`: grade a lesson file (default: the current lesson). +pub fn check(dir: &Path, number: Option) { + require_workspace(dir); + let s = Style::new(); + let mut progress = Progress::load(dir); + let lesson = number.map(lesson_or_exit).unwrap_or_else(|| progress.current()); + let path = dir.join(lesson.file_name()); + let source = fs::read_to_string(&path).unwrap_or_else(|e| { + eprintln!( + "error: cannot read {}: {e} (restore it with `typr tutor reset {}`)", + path.display(), + lesson.number + ); + std::process::exit(1); + }); + + let grade = tutor::grade(lesson, &source); + println!(); + println!(" {}", s.bold(&format!("Lesson {}: {}", lesson.number, lesson.title))); + println!(); + + if !grade.diagnostics.is_empty() { + let label = if grade.passed { + "The compiler says (as expected):" + } else { + "The compiler says:" + }; + println!(" {label}"); + for d in &grade.diagnostics { + for (i, line) in d.message.lines().enumerate() { + if i == 0 { + println!(" {} {}", s.yellow(&format!("[{}]", d.code)), line); + } else { + println!(" {line}"); + } + } + } + for e in &grade.explanations { + println!(); + println!(" {} {}", s.yellow(&e.code), s.bold(&e.title)); + for line in wrap(&e.explanation, 72) { + println!(" {}", s.dim(&line)); + } + } + println!(); + } + + if grade.passed { + println!(" {} {}", s.green("✓"), s.green(&s.bold("Lesson passed!"))); + if !progress.passed.contains(&lesson.number) { + progress.passed.push(lesson.number); + progress.passed.sort_unstable(); + progress.save(dir); + } + print_docs(&s, lesson); + println!(); + if progress.passed.len() < LESSONS.len() { + println!(" {}", s.bold("Next:")); + print_lesson_card(&s, dir, progress.current()); + } else { + { + println!( + " {}", + s.green(&s.bold("You finished the TypR tutor. Congratulations!")) + ); + println!(); + println!(" Start a real project with `typr new my_package`,"); + println!(" and keep the reference at hand:"); + println!(" {}", s.cyan("https://we-data-ch.github.io/typr.github.io/docs/")); + } + } + } else { + println!(" {} {}", s.red("✗"), s.bold("Not yet:")); + for f in &grade.feedback { + for (i, line) in wrap(f, 70).iter().enumerate() { + println!(" {} {line}", if i == 0 { "-" } else { " " }); + } + } + println!(); + println!( + " {}", + s.dim(&format!( + "Stuck? `typr tutor hint{}` gives a hint ({} available).", + lesson_arg(number, lesson), + lesson.hints.len() + )) + ); + } + println!(); + if !grade.passed { + std::process::exit(1); + } +} + +fn lesson_arg(number: Option, lesson: &Lesson) -> String { + number.map(|_| format!(" {}", lesson.number)).unwrap_or_default() +} + +/// `typr tutor hint [N]`: reveal the next hint for a lesson. +pub fn hint(dir: &Path, number: Option) { + require_workspace(dir); + let s = Style::new(); + let mut progress = Progress::load(dir); + let lesson = number.map(lesson_or_exit).unwrap_or_else(|| progress.current()); + let shown = progress.hints.get(&lesson.number).copied().unwrap_or(0); + let level = (shown + 1).min(lesson.hints.len()); + progress.hints.insert(lesson.number, level); + progress.save(dir); + + println!(); + println!(" {}", s.bold(&format!("Lesson {}: {}", lesson.number, lesson.title))); + for (i, h) in lesson.hints.iter().take(level).enumerate() { + println!(); + println!(" {} {h}", s.yellow(&format!("Hint {}/{}:", i + 1, lesson.hints.len()))); + } + println!(); + if level == lesson.hints.len() { + println!( + " {}", + s.dim(&format!( + "That was the last hint. As a last resort: `typr tutor solution {}`.", + lesson.number + )) + ); + } else { + println!(" {}", s.dim("Run the same command again for the next hint.")); + } + println!(); +} + +/// `typr tutor list`: every lesson, with progress. +pub fn list(dir: &Path) { + let s = Style::new(); + let progress = Progress::load(dir); + let current = progress.current().number; + println!(); + for l in LESSONS { + let mark = if progress.passed.contains(&l.number) { + s.green("✓") + } else if l.number == current { + s.yellow("▸") + } else { + " ".to_string() + }; + println!(" {mark} {:>2}. {:<38} {}", l.number, l.title, s.dim(&l.file_name())); + } + println!(); + println!(" {} of {} lessons done.", progress.passed.len(), LESSONS.len()); + println!(); +} + +/// `typr tutor reset N`: restore a lesson file to its original content. +pub fn reset(dir: &Path, number: usize) { + require_workspace(dir); + let lesson = lesson_or_exit(number); + let path = dir.join(lesson.file_name()); + if let Err(e) = fs::write(&path, lesson.starter) { + eprintln!("error: cannot write {}: {e}", path.display()); + std::process::exit(1); + } + println!("Restored {}", path.display()); +} + +/// `typr tutor solution N`: print a reference solution. +pub fn solution(number: usize) { + let s = Style::new(); + let lesson = lesson_or_exit(number); + println!(); + println!( + " {}", + s.dim("A solution, not the solution: if yours passes `typr tutor check`, it is right too.") + ); + println!(); + for line in lesson.solution.lines() { + println!(" {line}"); + } + println!(); +} + +fn wrap(text: &str, width: usize) -> Vec { + let mut lines = Vec::new(); + let mut line = String::new(); + for word in text.split_whitespace() { + if !line.is_empty() && line.len() + 1 + word.len() > width { + lines.push(std::mem::take(&mut line)); + } + if !line.is_empty() { + line.push(' '); + } + line.push_str(word); + } + if !line.is_empty() { + lines.push(line); + } + lines +} diff --git a/crates/typr-core/src/components/context/mod.rs b/crates/typr-core/src/components/context/mod.rs index 85e68cdd..59c58f99 100644 --- a/crates/typr-core/src/components/context/mod.rs +++ b/crates/typr-core/src/components/context/mod.rs @@ -922,6 +922,7 @@ impl Context { .iter() .filter(|typ| (*typ).clone().to_module_type().is_err()) .filter(|typ| !typ.is_empty()) + .filter(|typ| !matches!(typ, Type::Interface(methods, _) if methods.is_empty())) // A supertype that still mentions an unresolved generic (e.g. a // record-kinded `%T` picked up structurally from a generic alias // like `Animator<%T> <- %T & list {...}`) has no R class name — @@ -999,7 +1000,7 @@ impl Context { .collect::>(); let mut result: Vec<(Var, Type)> = res .iter() - .map(|(var, typ)| (var.clone().add_backticks_if_percent(), typ.clone())) + .map(|(var, typ)| (var.clone().add_backticks_if_needed(), typ.clone())) .collect(); // Stable order: the list is rendered into generic_functions.R, which // must not reshuffle between builds (HashSet iteration is random). @@ -1317,10 +1318,15 @@ impl Context { } pub fn get_functions_from_name(&self, name: &str) -> Vec<(Var, Type)> { + // A typed definition shadows the untyped base-R placeholder of the same + // name: otherwise its variadic `Any` signature accepts every call the + // typed one rejects (`let double <- fn(x: int)…; double("two")`). + let shadowed = !self.typing_context.name_exists_outside_of_std(name); self.typing_context .entries_named(name) .into_iter() .filter(|(_, typ2)| typ2.is_function()) + .filter(|(_, typ2)| !(shadowed && typ2.is_unknown_function())) .collect() } diff --git a/crates/typr-core/src/components/context/vartype.rs b/crates/typr-core/src/components/context/vartype.rs index ae1a2f87..8387331f 100644 --- a/crates/typr-core/src/components/context/vartype.rs +++ b/crates/typr-core/src/components/context/vartype.rs @@ -397,6 +397,8 @@ impl VarType { Type::Boolean(_, _) => "logical".to_string(), Type::Number(_, _) => "numeric".to_string(), Type::Any(_) => "Any".to_string(), + // A bare generic carries no class (see `get_class_unquoted`). + Type::Generic(_, _) => "default".to_string(), // Atomic-representation arrays (step ③) are bare R atomic // vectors: no `as.ArrayN` class is ever applied, so the only // class S3 dispatch sees is the implicit one. @@ -559,6 +561,8 @@ impl VarType { Type::Number(_, _) => "number".to_string(), Type::Char(_, _) => "character".to_string(), Type::Alias(name, _, _, _) => name.to_string(), + // A bare generic carries no class: only `.default` dispatches. + Type::Generic(_, _) => "default".to_string(), _ => self .aliases .iter() @@ -577,6 +581,10 @@ impl VarType { Type::Boolean(_, _) => "logical".to_string(), Type::Number(_, _) => "numeric".to_string(), Type::Any(_) => "Any".to_string(), + // A bare generic (`a: T`) has no class of its own: `PartialEq` deems + // it equal to the auto-registered `Generic` alias, which no value + // carries, so only the `default` method is ever reached. + Type::Generic(_, _) => "default".to_string(), // Same rationale as `get_class`: atomic arrays only carry R's // implicit class (step ③, unification_arrays.md). Type::Vec(_, _, _, _) if self.atomic_array_elem(t).is_some() => { 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 69554375..57cba85e 100644 --- a/crates/typr-core/src/components/error_message/syntax_error.rs +++ b/crates/typr-core/src/components/error_message/syntax_error.rs @@ -48,6 +48,14 @@ pub enum SyntaxError { /// `Lovable @A` / `Lovable@Self`: the `@Id` bound suffix must be one capital /// letter touching the interface name. DetachedBoundSuffix(HelpData), + /// `x..f()` / `r..a`: a hidden name right after the UFCS/field `.` operator. + HiddenNameAfterDot(HelpData), + /// `@pub let .x` / `@export let __x`: a hidden name is private by name. + PublicHiddenName { + name: String, + annotation: String, + help_data: HelpData, + }, WithNode(Box, Box), } @@ -73,6 +81,8 @@ impl SyntaxError { SyntaxError::SingleEqualsComparison(h) => Some(h.clone()), SyntaxError::TupleDestructureArityMismatch { help_data, .. } => Some(help_data.clone()), SyntaxError::DetachedBoundSuffix(h) => Some(h.clone()), + SyntaxError::HiddenNameAfterDot(h) => Some(h.clone()), + SyntaxError::PublicHiddenName { help_data, .. } => Some(help_data.clone()), SyntaxError::WithNode(_, inner) => inner.get_help_data(), } } @@ -102,6 +112,8 @@ impl SyntaxError { SyntaxError::SingleEqualsComparison(..) => "S016", SyntaxError::TupleDestructureArityMismatch { .. } => "S017", SyntaxError::DetachedBoundSuffix(..) => "S018", + SyntaxError::HiddenNameAfterDot(..) => "S019", + SyntaxError::PublicHiddenName { .. } => "S020", SyntaxError::WithNode(_, inner) => inner.code(), } } @@ -170,6 +182,12 @@ impl SyntaxError { 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::HiddenNameAfterDot(_) => { + "A hidden name can't follow the `.` operator (`x..f` reads as the `..` spread): write `x.__f()` or `x |> .f()`".to_string() + } + SyntaxError::PublicHiddenName { name, annotation, .. } => { + format!("`{name}` is a hidden name (leading `.`/`__`), private by definition — it can't be `{annotation}`") + } SyntaxError::WithNode(_, inner) => inner.simple_message(), } } @@ -387,6 +405,32 @@ impl ErrorMsg for SyntaxError { .help("Write `Lovable@A` (no space, one-letter id)") .build() } + SyntaxError::HiddenNameAfterDot(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("A hidden name can't follow the `.` operator") + .pos_text("`..` here reads as the spread token") + .help("Spell the hidden name with `__` after a dot (`x.__f()`, `r.__a`), or use the pipe: `x |> .f()`") + .build() + } + SyntaxError::PublicHiddenName { + name, + annotation, + 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(), name.len())) + .text(format!("A hidden name can't be `{annotation}`")) + .pos_text("Hidden by its leading `.`/`__`") + .help(format!( + "Drop `{annotation}` to keep `{name}` private, or rename it without the leading `.`/`__` to make it public" + )) + .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 ec7ef667..da4cd40f 100644 --- a/crates/typr-core/src/components/error_message/type_error.rs +++ b/crates/typr-core/src/components/error_message/type_error.rs @@ -171,6 +171,12 @@ pub enum TypeError { /// 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), + /// `e$field` where `e`'s type is neither a record, a module nor a data + /// frame (e.g. a vector alias) — `(field_name, type_display, position)`. + FieldAccessOnNonRecord(String, String, HelpData), + /// `...e` (record literal or constructor spread) where `e` isn't a + /// record — `(type_display, position)`. + SpreadNonRecord(String, HelpData), } impl TypeError { @@ -226,6 +232,8 @@ impl TypeError { TypeError::ConflictingBound(_, _, _, h) => Some(h.clone()), TypeError::BoundCollidesWithGeneric(_, h) => Some(h.clone()), TypeError::IdBoundToTwoTypes(_, _, _, _, h) => Some(h.clone()), + TypeError::FieldAccessOnNonRecord(_, _, h) => Some(h.clone()), + TypeError::SpreadNonRecord(_, h) => Some(h.clone()), } } @@ -283,6 +291,8 @@ impl TypeError { TypeError::ConflictingBound(..) => "T047", TypeError::BoundCollidesWithGeneric(..) => "T048", TypeError::IdBoundToTwoTypes(..) => "T049", + TypeError::FieldAccessOnNonRecord(..) => "T050", + TypeError::SpreadNonRecord(..) => "T051", } } @@ -320,7 +330,7 @@ impl TypeError { TypeError::FieldNotFound((name, _), typ) => { format!("Field '{}' not found on type {}", name, typ.pretty()) } - TypeError::WrongExpression(_) => "Type error in expression".to_string(), + TypeError::WrongExpression(_) => "This expression doesn't type-check".to_string(), TypeError::WrongIndexing(t1, t2) => { format!("Cannot index {} with {}", t1.pretty(), t2.pretty()) } @@ -528,6 +538,12 @@ impl TypeError { TypeError::IdBoundToTwoTypes(id, t1, _, t2, _) => { format!("'{}' is bound to '{}' and then to '{}' in the same call.", id, t1, t2) } + TypeError::FieldAccessOnNonRecord(field, typ, _) => { + format!("Cannot access field '{}': type {} is not a record", field, typ) + } + TypeError::SpreadNonRecord(typ, _) => { + format!("Cannot spread a value of type {}: only records can be spread", typ) + } } } } @@ -549,6 +565,15 @@ fn safe_file_pos(help_data: &HelpData, span_len: usize) -> ((String, String), (u ((file, text), (offset, safe_len)) } +/// Like `safe_file_pos`, spanning the node's whole source range (`offset..end`) +/// minus the trailing whitespace the parser consumes after a token. +fn node_file_pos(help_data: &HelpData) -> ((String, String), (usize, usize)) { + let ((file, text), (offset, _)) = safe_file_pos(help_data, 0); + let end = help_data.get_end().clamp(offset, text.len()); + let len = text.get(offset..end).map_or(0, |s| s.trim_end().len()); + ((file, text), (offset, len)) +} + // main impl ErrorMsg for TypeError { fn display(self) -> String { @@ -758,16 +783,11 @@ impl ErrorMsg for TypeError { .build() } TypeError::WrongExpression(help_data) => { - let (file_name, text) = help_data.get_file_data().unwrap_or_else(default_file_data); - let offset = help_data.get_offset().min(text.len()); - let line = (text[..offset].lines().count() + 1) as u32; - let element = text[offset..].lines().next().unwrap_or("").trim().to_string(); - SingleBuilder::new(file_name.clone(), text) - .pos((offset, 0)) - .text(format!( - "Unknown element `{}` in `{}` at `{}`", - element, file_name, line - )) + let ((file_name, text), pos) = node_file_pos(&help_data); + SingleBuilder::new(file_name, text) + .pos(pos) + .text("This expression doesn't type-check") + .pos_text("Unexpected expression here") .build() } TypeError::WrongIndexing(t1, t2) => { @@ -1159,6 +1179,27 @@ impl ErrorMsg for TypeError { .help("One id names one type, so it can satisfy only one interface here. Use a different id for the second bound.") .build() } + TypeError::FieldAccessOnNonRecord(field, typ, help_data) => { + let ((file_name, text), pos) = safe_file_pos(&help_data, field.len()); + SingleBuilder::new(file_name, text) + .pos(pos) + .text(format!("Cannot access field '{}': type {} is not a record", field, typ)) + .pos_text(format!("{} has no field '{}'", typ, field)) + .help("`$` reads a field of a record (`list { ... }`), a module or a data frame.") + .build() + } + TypeError::SpreadNonRecord(typ, help_data) => { + let ((file_name, text), pos) = node_file_pos(&help_data); + SingleBuilder::new(file_name, text) + .pos(pos) + .text(format!( + "Cannot spread a value of type {}: only records can be spread", + typ + )) + .pos_text(format!("this is a {}", typ)) + .help("`...e` copies the fields of a record (`list { ... }`) into the one being built.") + .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); diff --git a/crates/typr-core/src/components/language/argument_value.rs b/crates/typr-core/src/components/language/argument_value.rs index cf66b82d..6b87073e 100644 --- a/crates/typr-core/src/components/language/argument_value.rs +++ b/crates/typr-core/src/components/language/argument_value.rs @@ -1,4 +1,5 @@ use crate::components::context::Context; +use crate::components::language::r_name_symbol; use crate::components::language::Lang; use crate::processes::transpiling::translatable::RTranslatable; use serde::{Deserialize, Serialize}; @@ -26,6 +27,6 @@ impl fmt::Display for ArgumentValue { impl RTranslatable for ArgumentValue { fn to_r(&self, cont: &Context) -> String { - format!("{} = {}", self.0, self.1.to_r(cont).0) + format!("{} = {}", r_name_symbol(&self.0), self.1.to_r(cont).0) } } diff --git a/crates/typr-core/src/components/language/mod.rs b/crates/typr-core/src/components/language/mod.rs index 6bea4b59..46694baf 100644 --- a/crates/typr-core/src/components/language/mod.rs +++ b/crates/typr-core/src/components/language/mod.rs @@ -1653,6 +1653,23 @@ impl fmt::Display for Lang { } } +/// A TypR field/argument name as R spells it, for a string position +/// (`x[["a.b"]]`, `c("a.b")`): `__` stands for `.` (`a__b` → `a.b`). +pub fn r_name_string(name: &str) -> String { + name.replace("__", ".") +} + +/// Same name in a symbol position (`list(a.b = 1)`, `function(a.b)`, +/// `x$a.b`): also backtick-quoted when R can't take it bare (`_x`). +pub fn r_name_symbol(name: &str) -> String { + let name = r_name_string(name); + if name.starts_with('_') { + format!("`{}`", name) + } else { + name + } +} + pub fn format_backtick(s: String) -> String { "`".to_string() + &s.replace("`", "") + "`" } diff --git a/crates/typr-core/src/components/language/var.rs b/crates/typr-core/src/components/language/var.rs index 1a6239ef..771fbf50 100644 --- a/crates/typr-core/src/components/language/var.rs +++ b/crates/typr-core/src/components/language/var.rs @@ -78,9 +78,11 @@ impl Var { self.set_type(typ) } - pub fn add_backticks_if_percent(self) -> Self { + /// Backtick-quote a name R can't take bare: a `%op%`, or one starting + /// with `_` (`_x`, legal in TypR, not in R). + pub fn add_backticks_if_needed(self) -> Self { let s = self.get_name(); - let res = if s.starts_with('%') && s.ends_with('%') { + let res = if (s.starts_with('%') && s.ends_with('%')) || s.starts_with('_') { format!("`{}`", s) } else { s.to_string() @@ -241,6 +243,12 @@ impl Var { self.name.to_string() } + /// A hidden name — `.cache`, or its ASCII spelling `__cache` (`__` → `.` + /// in R) — is private by name: it can't carry `@pub`/`@export`. + pub fn is_hidden(&self) -> bool { + self.name.starts_with('.') || (self.name.starts_with("__") && self.name.len() > 2) + } + pub fn get_type(&self) -> Type { self.related_type.clone() } @@ -323,7 +331,8 @@ impl Var { } pub fn display_type(self, cont: &Context) -> Self { - if !self.get_name().contains(".") { + // A dot means "already suffixed" — except a hidden name's leading one. + if !self.get_name().replace('`', "").trim_start_matches('.').contains('.') { let type_str = match self.get_type() { Type::Empty(_) | Type::Any(_) => "".to_string(), ty => { diff --git a/crates/typr-core/src/components/syntax/mod.rs b/crates/typr-core/src/components/syntax/mod.rs index 131020d3..377d94e9 100644 --- a/crates/typr-core/src/components/syntax/mod.rs +++ b/crates/typr-core/src/components/syntax/mod.rs @@ -478,6 +478,20 @@ impl SyntaxManifest { matches("\\.[A-Z][A-Za-z0-9_]*\\b", "\\.[A-Z][A-Za-z0-9_]*>"), ) .with_note("Union-variant tag, `.Variant` (`parsing/elements.rs::tag_exp`)."), + Rule::new( + "variables.hidden", + "variable.other.hidden.typr", + matches( + "(?", + ), + ) + .with_note( + "Hidden name, `.helper` — private by name, as in R \ + (`parsing/elements.rs::variable_exp`). Lowercase after the dot, so a tag \ + (`.Some`) never matches; the look-behind leaves the member of `x.f` to the \ + access operator. Ahead of `operators.access`, which owns a lone `.`.", + ), Rule::new( "types.sigil-generic", "entity.name.type.parameter.typr", @@ -595,8 +609,9 @@ impl SyntaxManifest { ) .with_note( "An identifier starts lowercase-or-underscore and continues in `[A-Za-z0-9_]` — \ - no dots, unlike R (`parsing/elements.rs::starting_char`/`body_char`). \ - `data.frame` is a single dedicated tag, not an identifier.", + no inner dots, unlike R (`parsing/elements.rs::starting_char`/`body_char`): \ + `__` stands for `.` (`as__character` → `as.character`). A *leading* dot is \ + the hidden-name rule above. `data.frame` is a single dedicated tag.", ), Rule::new("punctuation.terminator", "punctuation.terminator.typr", symbols(&[";"])), Rule::new( diff --git a/crates/typr-core/src/components/type/mod.rs b/crates/typr-core/src/components/type/mod.rs index 57093791..4546e35b 100644 --- a/crates/typr-core/src/components/type/mod.rs +++ b/crates/typr-core/src/components/type/mod.rs @@ -246,7 +246,9 @@ impl TypeSystem for Type { // `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, + // `PartialEq` ignores which generic sits inside a composite, so + // `[#N, __rigid_0] == [#N, __rigid_1]`: compare those structurally. + (typ1, typ2) if typ1 == typ2 && !(mentions_rigid(typ1) && mentions_rigid(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 diff --git a/crates/typr-core/src/processes/parsing/elements.rs b/crates/typr-core/src/processes/parsing/elements.rs index 0b84f257..5bdc13d8 100644 --- a/crates/typr-core/src/processes/parsing/elements.rs +++ b/crates/typr-core/src/processes/parsing/elements.rs @@ -11,6 +11,7 @@ use crate::processes::parsing::base_parse; use crate::processes::parsing::lang_token::LangToken; use crate::processes::parsing::operation_priority::PriorityTokens; use crate::processes::parsing::push_parse_error; +use crate::processes::parsing::push_parse_error_once; use crate::processes::parsing::types::if_type; use crate::processes::parsing::types::label; use crate::processes::parsing::types::ltype; @@ -312,11 +313,26 @@ fn body_char(s: Span) -> IResult { } pub fn variable_exp(s: Span) -> IResult { - let res = (starting_char, many0(body_char)).parse(s); - match res { - Ok((s, ((s1, h), v))) => { + // Position of the identifier's first character — `starting_char`'s own + // HelpData is taken from its *remaining* span, one byte too far. + let h: HelpData = s.clone().into(); + // An optional leading `.` makes a *hidden* name, as in R (`.cache`, + // `.helper`): private by name, never `@pub`/`@export`. It must be followed + // by `[a-z_]` like any identifier, so it never overlaps a tag (`.Some`), + // the spread/variadic tokens (`..`, `...`) or a number (`.5`). + let res = (opt(char('.')), starting_char, many0(body_char)).parse(s); + match res { + Ok((s, (dot, (s1, _), v))) => { let res2 = v.iter().map(|(val, _h)| *val).collect::(); - Ok((s, (format!("{}{}", s1, res2), h.clone()))) + let name = format!("{}{}{}", if dot.is_some() { "." } else { "" }, s1, res2); + // `__name` is the ASCII spelling of `.name` (usable where a + // leading dot can't go, e.g. `x.__f()`): both must be the *same* + // binding for the type checker, so normalize to the dotted form. + let name = match name.strip_prefix("__") { + Some(rest) if !rest.is_empty() => format!(".{}", rest), + _ => name, + }; + Ok((s, (name, h))) } Err(r) => Err(r), } @@ -418,16 +434,26 @@ fn equality_params(s: Span) -> IResult { terminated(alt((tag("="), tag(":"))), multispace0).parse(s) } +/// A field/argument name in `name = value`: any identifier (`a_b`, hidden +/// `.b`/`__b`), or the bare alphanumerics accepted historically (`Name`). +fn argument_name(s: Span) -> IResult { + alt(( + map(variable_exp, |(name, _)| name), + map(alphanumeric1, |name: Span| name.to_string()), + )) + .parse(s) +} + fn argument_val(s: Span) -> IResult { let res = ( - terminated(alphanumeric1, multispace0), + terminated(argument_name, multispace0), equality_params, parse_elements, opt(terminated(tag(","), multispace0)), ) .parse(s); match res { - Ok((s, (e1, _, e2, _))) => Ok((s, ArgumentValue(e1.to_string(), e2))), + Ok((s, (e1, _, e2, _))) => Ok((s, ArgumentValue(e1, e2))), Err(r) => Err(r), } } @@ -1936,9 +1962,22 @@ fn scope_impl(s: Span) -> IResult { } } +/// `x..f()` / `r..a`: a hidden name right after the UFCS/field `.` reads as +/// the `..` spread token. Reported (S019) but recovered as `x.(.f)()` — the +/// spelling to suggest is `x.__f()` (`__` → `.` in R) or `x |> .f()`. +fn starts_with_hidden_name(s: &Span) -> bool { + let mut chars = s.fragment().chars(); + chars.next() == Some('.') && chars.next().is_some_and(|c| c.is_ascii_lowercase() || c == '_') +} + fn element_operator_token(s: Span) -> IResult { match op.parse(s) { - Ok((s, op)) => Ok((s, LangToken::Operator(op))), + Ok((s, op)) => { + if matches!(op, Op::Dot(_)) && starts_with_hidden_name(&s) { + push_parse_error_once(SyntaxError::HiddenNameAfterDot(s.clone().into())); + } + Ok((s, LangToken::Operator(op))) + } Err(r) => Err(r), } } @@ -2158,6 +2197,23 @@ mod tests { assert_eq!(res, "hello", "Should return the variable name 'hello'"); } + #[test] + fn test_hidden_variable_never_takes_a_tag_or_spread() { + assert_eq!(variable_exp(".helper".into()).unwrap().1 .0, ".helper"); + assert_eq!(variable_exp("__helper".into()).unwrap().1 .0, ".helper"); + assert_eq!(variable_exp("as__character".into()).unwrap().1 .0, "as__character"); + assert!(variable_exp(".Some".into()).is_err()); + assert!(variable_exp("..x".into()).is_err()); + assert!(variable_exp(".5".into()).is_err()); + } + + #[test] + fn test_record_field_names_accept_underscore_and_hidden() { + for (src, field) in [("a_b = 1", "a_b"), (".c = 2", ".c"), ("Name = 3", "Name")] { + assert_eq!(argument_val(src.into()).unwrap().1 .0, field); + } + } + #[test] fn test_simple_variable1() { let res = variable_exp("hello".into()).unwrap().1 .0; diff --git a/crates/typr-core/src/processes/parsing/mod.rs b/crates/typr-core/src/processes/parsing/mod.rs index 5a757671..6bbf9e0b 100644 --- a/crates/typr-core/src/processes/parsing/mod.rs +++ b/crates/typr-core/src/processes/parsing/mod.rs @@ -70,6 +70,19 @@ pub fn push_parse_error(err: SyntaxError) { PARSE_ERRORS.with(|e| e.borrow_mut().push(err)); } +/// Like `push_parse_error`, for errors raised inside a combinator that `alt` +/// may retry at the same position: a second report of the same error code at +/// the same offset is dropped. +pub fn push_parse_error_once(err: SyntaxError) { + let key = |e: &SyntaxError| (e.code(), e.get_help_data().map(|h| (h.get_file_name(), h.get_offset()))); + PARSE_ERRORS.with(|e| { + let mut errors = e.borrow_mut(); + if !errors.iter().any(|known| key(known) == key(&err)) { + errors.push(err); + } + }); +} + fn take_parse_errors() -> Vec { PARSE_ERRORS.with(|e| e.borrow_mut().drain(..).collect()) } @@ -434,7 +447,15 @@ fn let_exp_impl(s: Span) -> IResult> { is_export: _, help_data: h, } => { - let vari = Var::from_language(var.deref().clone()).unwrap().to_language(); + let parsed = Var::from_language(var.deref().clone()).unwrap(); + if is_pub && parsed.is_hidden() { + push_parse_error(SyntaxError::PublicHiddenName { + name: parsed.get_name(), + annotation: frag.to_string(), + help_data: parsed.get_help_data(), + }); + } + let vari = parsed.to_language(); Lang::Let { variable: Box::new(vari), r#type: typ.clone(), @@ -956,9 +977,25 @@ fn assign(s: Span) -> IResult> { Ok((rest, v)) } +/// Left-hand side of a reassignment: a variable, optionally followed by a +/// `$field` chain (`d$b <- true;`, `d$b$c <- 1;`) to update a list field in +/// place. Built as nested `Op::Dollar` nodes with the same operand roles as an +/// ordinary `$` access (`rhs` = receiver, `lhs` = field name). +fn assign_target(s: Span) -> IResult { + let (s, (var, case)) = variable(s)?; + let (s, fields) = many0(preceded(terminated(tag("$"), multispace0), variable)).parse(s)?; + let target = fields.into_iter().fold(var, |receiver, (field, _)| Lang::Operator { + operator: Op::Dollar(field.get_help_data()), + help_data: receiver.get_help_data(), + rhs: Box::new(receiver), + lhs: Box::new(field), + }); + Ok((s, (target, case))) +} + fn assign_impl(s: Span) -> IResult> { let res = ( - variable, + assign_target, alt((terminated(tag("="), multispace0), terminated(tag("<-"), multispace0))), parse_elements, opt(terminated(tag(";"), multispace0)), @@ -2224,6 +2261,55 @@ mod tesus { } } + #[test] + fn test_hidden_names_dot_and_double_underscore_are_one_binding() { + let _ = take_parse_errors(); + for (src, name) in [("let .cache <- 1;", ".cache"), ("let __cache <- 1;", ".cache")] { + let le = let_exp(src.into()).unwrap().1; + match &le[0] { + Lang::Let { variable, .. } => { + assert_eq!(Var::from_language((**variable).clone()).unwrap().get_name(), name) + } + other => panic!("Expected Let, got {:?}", other), + } + } + assert!(take_parse_errors().is_empty()); + } + + #[test] + fn test_pub_or_export_hidden_name_is_error() { + for src in ["@pub let .cache <- 1;", "@export let __cache <- 1;"] { + let _ = take_parse_errors(); + let _ = let_exp(src.into()).unwrap(); + let errors = take_parse_errors(); + assert!( + matches!(errors.as_slice(), [SyntaxError::PublicHiddenName { .. }]), + "{src}: {errors:?}" + ); + } + let _ = take_parse_errors(); + let _ = let_exp("@testable let .cache <- 1;".into()).unwrap(); + assert!( + take_parse_errors().is_empty(), + "@testable stays allowed on a hidden name" + ); + } + + #[test] + fn test_hidden_name_after_dot_operator_is_reported_once() { + let _ = take_parse_errors(); + let _ = base_parse("x..twice();".into()).unwrap(); + let errors = take_parse_errors(); + assert!( + matches!(errors.as_slice(), [SyntaxError::HiddenNameAfterDot(_)]), + "{errors:?}" + ); + for ok in ["x.__twice();", "x |> .twice();", "r$.a;", "x.Some;"] { + let _ = base_parse(ok.into()).unwrap(); + assert!(take_parse_errors().is_empty(), "{ok} must not be reported"); + } + } + #[test] fn test_let_tuple_under_binding_is_arity_error() { let _ = take_parse_errors(); @@ -2434,6 +2520,32 @@ mod tesus { } } + #[test] + fn test_assign_list_field() { + let res = assign("d$c$x <- 1;".into()).unwrap().1; + match &res[0] { + Lang::Assign { identifier, .. } => match identifier.as_ref() { + Lang::Operator { + operator: Op::Dollar(_), + rhs: receiver, + lhs: field, + .. + } => { + assert!(matches!(field.as_ref(), Lang::Variable { name, .. } if name == "x")); + assert!(matches!( + receiver.as_ref(), + Lang::Operator { + operator: Op::Dollar(_), + .. + } + )); + } + other => panic!("Expected a `$` target, got {:?}", other), + }, + other => panic!("Expected Assign, got {:?}", other), + } + } + #[test] fn test_implicit_mutate_simple_variable() { let res = implicit_mutate("x!;".into()).unwrap().1; diff --git a/crates/typr-core/src/processes/parsing/types.rs b/crates/typr-core/src/processes/parsing/types.rs index 94cd7d3a..e5ae2109 100644 --- a/crates/typr-core/src/processes/parsing/types.rs +++ b/crates/typr-core/src/processes/parsing/types.rs @@ -764,7 +764,7 @@ fn interface(s: Span) -> IResult { let res = ( terminated(tag("interface"), multispace0), terminated(tag("{"), multispace0), - terminated(many1(interface_function), multispace0), + terminated(many0(interface_function), multispace0), terminated(tag("}"), multispace0), ) .parse(s); @@ -1408,6 +1408,12 @@ mod tests { assert_eq!(res, inter); } + #[test] + fn test_empty_interface_parsing() { + let res = interface("interface {}".into()).unwrap().1; + assert_eq!(res, builder::interface_type(&[])); + } + #[test] fn test_interface_parsing3() { let res = interface("interface { hey: (Self) -> num }".into()).unwrap().1; diff --git a/crates/typr-core/src/processes/transpiling/mod.rs b/crates/typr-core/src/processes/transpiling/mod.rs index 700e74de..0e3bfcd5 100644 --- a/crates/typr-core/src/processes/transpiling/mod.rs +++ b/crates/typr-core/src/processes/transpiling/mod.rs @@ -13,6 +13,7 @@ use crate::components::language::set_related_type_if_variable; use crate::components::language::var::Var; use crate::components::language::Lang; use crate::components::language::ModulePosition; +use crate::components::language::{r_name_string, r_name_symbol}; use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::array_type::ArrayType; use crate::components::r#type::function_type::FunctionType; @@ -64,6 +65,33 @@ pub fn escape_r_string(s: &str) -> String { /// `c(el1, …)`, or the typed empty vector (`integer(0)`, …) matching `elem` /// when there are no elements (`c()` alone would be `NULL`, a different R /// value entirely). +/// The R name a module member is bound under, as its own definition emits it +/// (`Lang::Variable` arm): `__` → `.` (`my__v` → `my.v`), and `_x` quoted. +fn module_member_name(v: &Var) -> String { + r_symbol(v.get_name().replace("__", ".")) +} + +/// Same, with the S3 class suffix a typed function's definition carries +/// (`f.Pt`). A `__` name gets none, exactly like its definition. +fn module_member_binding(v: &Var, cont: &Context) -> String { + if v.contains("__") { + module_member_name(v) + } else { + r_symbol(v.clone().display_type(cont).get_name()) + } +} + +/// A name used as a bare R symbol. TypR identifiers may start with `_` +/// (`_x`), which R only accepts backtick-quoted — definitions already are +/// (`format_backtick`), use sites go through here. +fn r_symbol(name: String) -> String { + if name.starts_with('_') { + format!("`{}`", name) + } else { + name + } +} + fn atomic_array_literal(array: &Lang, cont: &Context, elem: &Type) -> String { let lin_array = array .linearize_array() @@ -547,7 +575,13 @@ fn pattern_to_condition(pattern: &Lang, match_var: &str, _context: &Context) -> Lang::List { value: fields, .. } => { let conditions: Vec = fields .iter() - .map(|arg_val: &ArgumentValue| format!("!is.null({}[[\"{}\"]])", match_var, arg_val.get_argument())) + .map(|arg_val: &ArgumentValue| { + format!( + "!is.null({}[[\"{}\"]])", + match_var, + r_name_string(&arg_val.get_argument()) + ) + }) .collect(); let cond = if conditions.is_empty() { "is.list(".to_string() + match_var + ")" @@ -562,7 +596,7 @@ fn pattern_to_condition(pattern: &Lang, match_var: &str, _context: &Context) -> "{} <- {}[[\"{}\"]]", var_name, match_var, - arg_val.get_argument() + r_name_string(&arg_val.get_argument()) )) } else { None @@ -575,7 +609,13 @@ fn pattern_to_condition(pattern: &Lang, match_var: &str, _context: &Context) -> Lang::DataFrame { value: fields, .. } => { let conditions: Vec = fields .iter() - .map(|arg_val: &ArgumentValue| format!("!is.null({}[[\"{}\"]])", match_var, arg_val.get_argument())) + .map(|arg_val: &ArgumentValue| { + format!( + "!is.null({}[[\"{}\"]])", + match_var, + r_name_string(&arg_val.get_argument()) + ) + }) .collect(); let cond = if conditions.is_empty() { "is.data.frame(".to_string() + match_var + ")" @@ -590,7 +630,7 @@ fn pattern_to_condition(pattern: &Lang, match_var: &str, _context: &Context) -> "{} <- {}[[\"{}\"]]", var_name, match_var, - arg_val.get_argument() + r_name_string(&arg_val.get_argument()) )) } else { None @@ -667,12 +707,18 @@ impl RTranslatable<(String, Context)> for Lang { .to_r(&e1) .add("]]") .into(), - _ if is_dot => Translatable::from(cont.clone()) - .to_r(&e1) - .add("[['") - .to_r(&e2) - .add("']]") - .into(), + // The field name is a string here, not a symbol: no + // backticks, no S3 suffix — only `__` → `.`. + _ if is_dot => { + let field = match &e2 { + Lang::Variable { name, .. } => r_name_string(name), + _ => unreachable!(), + }; + Translatable::from(cont.clone()) + .to_r(&e1) + .add(&format!("[['{}']]", field)) + .into() + } _ => Translatable::from(cont.clone()) .to_r(&e2) .add("[['") @@ -742,7 +788,9 @@ impl RTranslatable<(String, Context)> for Lang { let name_str = name.replace("__", "."); format!("get({}, '{}')", e1.to_r(cont).0, name_str) } - (_, Lang::Variable { name, .. }) => format!("{}${}", e1.to_r(cont).0, name), + // A module member (`M::my__v`) or a record field + // (`r$a__b`): the R name its definition binds. + (_, Lang::Variable { name, .. }) => format!("{}${}", e1.to_r(cont).0, r_name_symbol(&name)), _ => format!("{}${}", e1.to_r(cont).0, e2.to_r(cont).0), }; (val, cont.clone()) @@ -870,7 +918,7 @@ impl RTranslatable<(String, Context)> for Lang { } else { var.display_type(cont).get_name() }; - (name.to_string(), cont.clone()) + (r_symbol(name), cont.clone()) } Lang::FunctionApp { identifier: exp, @@ -975,7 +1023,7 @@ impl RTranslatable<(String, Context)> for Lang { ty => format!("{}.{}", new_name, cont1.get_class_unquoted(&ty)), }; let (args, current_cont) = Translatable::from(cont1).join(&new_vals, ", ").into(); - (format!("{}({})", forced_name, args), current_cont) + (format!("{}({})", r_symbol(forced_name), args), current_cont) } }) .unwrap_or_else(|| { @@ -1054,7 +1102,7 @@ impl RTranslatable<(String, Context)> for Lang { let new_name = if &dotted[0..1] == "%" { format!("`{}`", dotted) } else { - dotted + r_symbol(dotted) }; // Which argument positions actually carry the vector // being mapped over (the others are passed through as @@ -1149,7 +1197,7 @@ impl RTranslatable<(String, Context)> for Lang { let new_name = if &name[0..1] == "%" { format!("`{}`", name.replace("__", ".")) } else { - name.replace("__", ".") + r_symbol(name.replace("__", ".")) }; (format!("{}({})", new_name, args), current_cont.clone()) }) @@ -1197,7 +1245,7 @@ impl RTranslatable<(String, Context)> for Lang { let new_name = if &name[0..1] == "%" { format!("`{}`", name.replace("__", ".")) } else { - name.replace("__", ".") + r_symbol(name.replace("__", ".")) }; (format!("vec_apply({}, {})", new_name, args), current_cont.clone()) }) @@ -1289,10 +1337,17 @@ impl RTranslatable<(String, Context)> for Lang { other => other, }; let method = match cont.get_environment() { - Environment::Project => format!( - "#' @method {}\n", - new_name.replace(".", " ").replace("`", "") - ), + // `generic.Class` → `@method generic Class`. Only the + // class suffix is split off: the generic itself may + // hold dots (`.helper`, `as__x` → `as.x`). + Environment::Project => { + let bare = new_name.replace("`", ""); + let suffix = cont.get_class_unquoted(&related_type); + match bare.strip_suffix(&format!(".{}", suffix)) { + Some(generic) => format!("#' @method {} {}\n", generic, suffix), + None => format!("#' @method {}\n", bare.replace(".", " ")), + } + } _ => "".to_string(), }; match related_type { @@ -1630,7 +1685,7 @@ impl RTranslatable<(String, Context)> for Lang { sorted_fields.sort_by_key(|f| f.get_argument_str()); let params = sorted_fields .iter() - .map(|f| f.get_argument_str()) + .map(|f| r_name_symbol(&f.get_argument_str())) .collect::>() .join(", "); // Each field is only added to `explicit` when the caller @@ -1641,8 +1696,9 @@ impl RTranslatable<(String, Context)> for Lang { let explicit_lines = sorted_fields .iter() .map(|f| { - let n = f.get_argument_str(); - format!(" if (!missing({n})) explicit[[\"{n}\"]] <- {n}") + let n = r_name_string(&f.get_argument_str()); + let sym = r_name_symbol(&f.get_argument_str()); + format!(" if (!missing({sym})) explicit[[\"{n}\"]] <- {sym}") }) .collect::>() .join("\n"); @@ -1712,6 +1768,10 @@ impl RTranslatable<(String, Context)> for Lang { .filter(|(_, alias_typ)| !alias_typ.has_generic()) .filter_map(|(var, alias_typ)| { let methods = facets::interface_facet(cont, alias_typ)?; + // An empty interface constrains nothing: no runtime class. + if methods.is_empty() { + return None; + } (seen_ifaces.insert(var.get_name()) && self_alias.is_subtype_raw(alias_typ, cont)) .then(|| (var.get_name(), methods.len())) }) @@ -1739,7 +1799,7 @@ impl RTranslatable<(String, Context)> for Lang { ); let fields_quoted = sorted_fields .iter() - .map(|f| format!("\"{}\"", f.get_argument_str())) + .map(|f| format!("\"{}\"", r_name_string(&f.get_argument_str()))) .collect::>() .join(", "); // Per-field type invariants, checked by class (`inherits`). @@ -1748,7 +1808,7 @@ impl RTranslatable<(String, Context)> for Lang { let field_checks = sorted_fields .iter() .filter_map(|f| { - let n = f.get_argument_str(); + let n = r_name_string(&f.get_argument_str()); record_field_class(&f.body_type(), cont).map(|cls| { format!( " if (!inherits(x[[\"{n}\"]], \"{cls}\")) stop(\"Validation failed for type {name}: field '{n}' must be of class {cls}\")" @@ -1788,14 +1848,15 @@ impl RTranslatable<(String, Context)> for Lang { sorted_fields.sort_by_key(|f| f.get_argument_str()); let params = sorted_fields .iter() - .map(|f| f.get_argument_str()) + .map(|f| r_name_symbol(&f.get_argument_str())) .collect::>() .join(", "); let explicit_lines = sorted_fields .iter() .map(|f| { - let n = f.get_argument_str(); - format!(" if (!missing({n})) explicit[[\"{n}\"]] <- {n}") + let n = r_name_string(&f.get_argument_str()); + let sym = r_name_symbol(&f.get_argument_str()); + format!(" if (!missing({sym})) explicit[[\"{n}\"]] <- {sym}") }) .collect::>() .join("\n"); @@ -1812,7 +1873,7 @@ impl RTranslatable<(String, Context)> for Lang { ); let fields_quoted = sorted_fields .iter() - .map(|f| format!("\"{}\"", f.get_argument_str())) + .map(|f| format!("\"{}\"", r_name_string(&f.get_argument_str()))) .collect::>() .join(", "); // Per-column type invariants, checked by class @@ -1820,7 +1881,7 @@ impl RTranslatable<(String, Context)> for Lang { let field_checks = sorted_fields .iter() .filter_map(|f| { - let n = f.get_argument_str(); + let n = r_name_string(&f.get_argument_str()); record_field_class(&f.body_type(), cont).map(|cls| { format!( " if (!inherits(x[[\"{n}\"]], \"{cls}\")) stop(\"Validation failed for type {name}: column '{n}' must be of class {cls}\")" @@ -2233,8 +2294,8 @@ impl RTranslatable<(String, Context)> for Lang { is_testable: true, .. } => Var::from_language(*var.clone()).map(|v| { - let raw = v.get_name(); - format!("{} <- {}$`.test_{}`", raw, name_str, raw) + let raw = module_member_name(&v); + format!("{} <- {}$`.test_{}`", raw, name_str, raw.trim_matches('`')) }), _ => None, }) @@ -2291,7 +2352,7 @@ impl RTranslatable<(String, Context)> for Lang { } = lang { if let Some(v) = Var::from_language(*var.clone()) { - let raw_name = v.get_name(); + let raw_name = module_member_name(&v); // Use `inner_cont`, not `cont`: `body_content` above rendered // this same `Let` via `inner_cont` (the re-typed module-body // context), so `display_type`'s alias lookup must use the same @@ -2299,7 +2360,7 @@ impl RTranslatable<(String, Context)> for Lang { // *different* auto-named alias (e.g. the function gets defined // as `animate_move.Record4` but exported/registered as // `animate_move.Record1` — a dangling reference at R runtime). - let typed_name = v.clone().display_type(&inner_cont).get_name(); + let typed_name = module_member_binding(&v, &inner_cont); // Export the (possibly type-suffixed) member into the module env exports.push(format!("{}${} <- {}", name_str, typed_name, typed_name)); @@ -2316,11 +2377,16 @@ impl RTranslatable<(String, Context)> for Lang { let class_name = inner_cont.get_class_unquoted(&var_type); exports.push(format!( "registerS3method(\"{}\", \"{}\", {})", - raw_name, class_name, typed_name + raw_name.trim_matches('`'), + class_name, + typed_name )); - let generic_def = - format!("{} <- function(x, ...) UseMethod(\"{}\")", raw_name, raw_name); + let generic_def = format!( + "{} <- function(x, ...) UseMethod(\"{}\")", + raw_name, + raw_name.trim_matches('`') + ); if !generics.contains(&generic_def) { generics.push(generic_def); generic_exports.push(format!("{}${} <- {}", name_str, raw_name, raw_name)); @@ -2371,19 +2437,24 @@ impl RTranslatable<(String, Context)> for Lang { } = lang { if let Some(v) = Var::from_language(*var.clone()) { - let raw_name = v.get_name(); - let typed_name = v.clone().display_type(&inner_cont).get_name(); + let raw_name = module_member_name(&v); + let typed_name = module_member_binding(&v, &inner_cont); let var_type = v.get_type(); if !var_type.is_empty() && typed_name != raw_name { let class_name = inner_cont.get_class_unquoted(&var_type); - let generic_def = - format!("{} <- function(x, ...) UseMethod(\"{}\")", raw_name, raw_name); + let generic_def = format!( + "{} <- function(x, ...) UseMethod(\"{}\")", + raw_name, + raw_name.trim_matches('`') + ); if !generics.contains(&generic_def) && !exports.contains(&generic_def) { exports.push(generic_def); } exports.push(format!( "registerS3method(\"{}\", \"{}\", {})", - raw_name, class_name, typed_name + raw_name.trim_matches('`'), + class_name, + typed_name )); } } @@ -2399,12 +2470,17 @@ impl RTranslatable<(String, Context)> for Lang { } = lang { if let Some(v) = Var::from_language(*var.clone()) { - let raw_name = v.get_name(); + let raw_name = module_member_name(&v); // Reference the actual (possibly type-suffixed) // binding emitted in the module body. Must use // `inner_cont` for the same reason as above. - let typed_name = v.clone().display_type(&inner_cont).get_name(); - exports.push(format!("{}$`.test_{}` <- {}", name_str, raw_name, typed_name)); + let typed_name = module_member_binding(&v, &inner_cont); + exports.push(format!( + "{}$`.test_{}` <- {}", + name_str, + raw_name.trim_matches('`'), + typed_name + )); } } } diff --git a/crates/typr-core/src/processes/type_checking/constructor_call.rs b/crates/typr-core/src/processes/type_checking/constructor_call.rs index e01a04bd..51f6abe8 100644 --- a/crates/typr-core/src/processes/type_checking/constructor_call.rs +++ b/crates/typr-core/src/processes/type_checking/constructor_call.rs @@ -12,6 +12,7 @@ use crate::components::language::Lang; use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::type_system::TypeSystem; use crate::components::r#type::Type; +use crate::processes::type_checking::field_access; use crate::processes::type_checking::flatten_operator_union; use crate::processes::type_checking::merge_record_fields_override; use crate::processes::type_checking::resolve_module_member_type; @@ -149,7 +150,10 @@ pub fn constructor_call( merge_record_fields_override(&spread_merged, &spread_fields.into_iter().collect::>()); } _ => { - errors.push(TypRError::Type(TypeError::WrongExpression(spread_expr.get_help_data()))); + errors.push(TypRError::Type(TypeError::SpreadNonRecord( + field_access::display_with_reduction(context, &tc.value), + spread_expr.get_help_data(), + ))); } } } 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 a2b8a413..04c3afd4 100644 --- a/crates/typr-core/src/processes/type_checking/dollar_access.rs +++ b/crates/typr-core/src/processes/type_checking/dollar_access.rs @@ -166,79 +166,24 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & (Type::UnknownFunction(h), Lang::FunctionApp { .. }, _) => { TypeContext::new(Type::UnknownFunction(h), expr.clone(), context.clone()).with_errors(errors) } - // DataFrame $ access: df$col -> [N, col_type] (VecType::Vector) + // DataFrame $ access: df$col / df$"col" -> [N, col_type] (VecType::Vector) ( - Type::Vec(VecType::DataFrame, _, ref body, h), + Type::Vec(VecType::DataFrame, _, _, _), Lang::Variable { - name, - help_data: ref vh, - .. - }, - _, - ) => match body.as_ref() { - Type::Record(fields, _) => { - match field_access::find_field_or_push_error(fields, &name, &ty1, vh.clone(), &mut errors) { - Some(field_ty) => { - let inner_type = match field_ty { - Type::Vec(_, _, inner, _) => *inner, - other => other, - }; - TypeContext::new( - Type::vec( - VecType::Vector, - reduced1.vec_length().unwrap_or_else(builder::any_type), - inner_type, - h.clone(), - ), - expr.clone(), - context.clone(), - ) - .with_errors(errors) - } - None => TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors), - } - } - _ => { - errors.push(TypRError::Type(TypeError::WrongExpression(expr.get_help_data()))); - TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors) + name, help_data: vh, .. } - }, - // DataFrame $ access with string literal: df$"col" - ( - Type::Vec(VecType::DataFrame, _, ref body, h), - Lang::Char { + | Lang::Char { value: name, - help_data: ref vh, + help_data: vh, }, _, - ) => match body.as_ref() { - Type::Record(fields, _) => { - match field_access::find_field_or_push_error(fields, &name, &ty1, vh.clone(), &mut errors) { - Some(field_ty) => { - let inner_type = match field_ty { - Type::Vec(_, _, inner, _) => *inner, - other => other, - }; - TypeContext::new( - Type::vec( - VecType::Vector, - reduced1.vec_length().unwrap_or_else(builder::any_type), - inner_type, - h.clone(), - ), - expr.clone(), - context.clone(), - ) - .with_errors(errors) - } - None => TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors), - } - } - _ => { - errors.push(TypRError::Type(TypeError::WrongExpression(expr.get_help_data()))); - TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors) - } - }, + ) => { + field_access::dataframe_column_or_push_error(&reduced1, &ty1, &name, vh, expr.get_help_data(), &mut errors) + .map(|ty| TypeContext::new(ty, expr.clone(), context.clone()).with_errors(errors.clone())) + .unwrap_or_else(|| { + TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors.clone()) + }) + } // DataFrame $ update with list: df$:{col: new_type} (Type::Vec(VecType::DataFrame, _, ref body, h), Lang::List { value: fields2, .. }, _) => match body.as_ref() { Type::Record(fields1, rh) => { @@ -325,12 +270,12 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & .with_errors(errors) } None => { - errors.push(TypRError::Type(TypeError::WrongExpression(expr.get_help_data()))); + errors.push(non_record_access_error(context, expr, e2, &ty1)); TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors) } }, Err(_) => { - errors.push(TypRError::Type(TypeError::WrongExpression(expr.get_help_data()))); + errors.push(non_record_access_error(context, expr, e2, &ty1)); TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors) } }, @@ -354,9 +299,29 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & errors.extend(tc.errors); TypeContext::new(tc.value, tc.lang, tc.context).with_errors(errors) } + (_, _, Op::Dollar(_)) => { + errors.push(non_record_access_error(context, expr, e2, &ty1)); + TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors) + } (_a, _b, _c) => { errors.push(TypRError::Type(TypeError::WrongExpression(expr.get_help_data()))); TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors) } } } + +/// `e1$field` on a type with no fields (a vector alias, a scalar, ...): +/// names the field and the receiver's type rather than the generic +/// `WrongExpression`, which only says the expression is wrong. +fn non_record_access_error(context: &Context, expr: &Lang, field: &Lang, receiver: &Type) -> TypRError { + match field { + Lang::Variable { name, help_data, .. } | Lang::Char { value: name, help_data } => { + TypRError::Type(TypeError::FieldAccessOnNonRecord( + name.clone(), + field_access::display_with_reduction(context, receiver), + help_data.clone(), + )) + } + _ => TypRError::Type(TypeError::WrongExpression(expr.get_help_data())), + } +} diff --git a/crates/typr-core/src/processes/type_checking/dot_pipe_access.rs b/crates/typr-core/src/processes/type_checking/dot_pipe_access.rs index d6a1cba9..116674a2 100644 --- a/crates/typr-core/src/processes/type_checking/dot_pipe_access.rs +++ b/crates/typr-core/src/processes/type_checking/dot_pipe_access.rs @@ -6,6 +6,7 @@ use crate::components::error_message::type_error::TypeError; use crate::components::error_message::typr_error::TypRError; use crate::components::language::Lang; use crate::components::r#type::type_system::TypeSystem; +use crate::components::r#type::vector_type::VecType; use crate::components::r#type::Type; use crate::processes::type_checking::field_access; use crate::processes::type_checking::type_context::TypeContext; @@ -52,6 +53,22 @@ pub fn dot_pipe_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang) -> // fall through to the catch-all `WrongExpression` error below. if let Lang::Variable { name, .. } | Lang::Char { value: name, .. } = e2 { let ty1 = e1.typing(context).value.reduce(context); + // `df.col`: a typed dataframe's column, exactly like `df$col`. + if let Type::Vec(VecType::DataFrame, _, _, _) = ty1 { + let mut errors = Vec::new(); + return field_access::dataframe_column_or_push_error( + &ty1, + &ty1, + name, + e2.get_help_data(), + expr.get_help_data(), + &mut errors, + ) + .map(|ty| TypeContext::new(ty, expr.clone(), context.clone()).with_errors(errors.clone())) + .unwrap_or_else(|| { + TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors.clone()) + }); + } if let Type::Record(fields, _) = ty1.clone() { let mut errors = Vec::new(); return field_access::find_field_or_push_error(&fields, name, &ty1, e2.get_help_data(), &mut errors) diff --git a/crates/typr-core/src/processes/type_checking/field_access.rs b/crates/typr-core/src/processes/type_checking/field_access.rs index ceedc1fa..1d30a8cf 100644 --- a/crates/typr-core/src/processes/type_checking/field_access.rs +++ b/crates/typr-core/src/processes/type_checking/field_access.rs @@ -6,11 +6,14 @@ //! These helpers factor out the lookup + error-push, leaving each call site //! to decide what to do with the found `Type` (return it directly, unwrap a //! `Vec` layer for a DataFrame column, etc). +use crate::components::context::Context; 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::language::Lang; use crate::components::r#type::argument_type::ArgumentType; +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; @@ -50,6 +53,41 @@ where } } +/// `df$col` / `df.col` on a typed dataframe (`reduced_df` is its reduced +/// `Vec(DataFrame, N, Record{...})`): the column, as a plain vector of the +/// column's element type with the dataframe's row count. `None` (after +/// pushing an error) when the column is unknown or the body isn't a record. +pub fn dataframe_column_or_push_error( + reduced_df: &Type, + receiver_type: &Type, + name: &str, + name_help: HelpData, + expr_help: HelpData, + errors: &mut Vec, +) -> Option { + let (body, h) = match reduced_df { + Type::Vec(VecType::DataFrame, _, body, h) => (body, h), + _ => return None, + }; + let fields = match body.as_ref() { + Type::Record(fields, _) => fields, + _ => { + errors.push(TypRError::Type(TypeError::WrongExpression(expr_help))); + return None; + } + }; + let inner_type = match find_field_or_push_error(fields, name, receiver_type, name_help, errors)? { + Type::Vec(_, _, inner, _) => *inner, + other => other, + }; + Some(Type::vec( + VecType::Vector, + reduced_df.vec_length().unwrap_or_else(builder::any_type), + inner_type, + h.clone(), + )) +} + /// `module.member` / `module$member` lookup: on miss, pushes /// `UndefinedVariable` (modules track missing members that way, unlike /// records/DataFrames). @@ -75,3 +113,15 @@ where } } } + +/// `ty` as shown in a "not a record" diagnostic: the alias name followed by +/// what it stands for (`Position (= Vec[2, int])`), so the user sees why +/// the field/spread is rejected without looking up the alias. +pub fn display_with_reduction(context: &Context, ty: &Type) -> String { + let (shown, reduced) = (ty.pretty(), ty.reduce(context).pretty()); + if shown == reduced { + shown + } else { + format!("{} (= {})", shown, reduced) + } +} diff --git a/crates/typr-core/src/processes/type_checking/function.rs b/crates/typr-core/src/processes/type_checking/function.rs index 7dab0900..6dc5aa7f 100644 --- a/crates/typr-core/src/processes/type_checking/function.rs +++ b/crates/typr-core/src/processes/type_checking/function.rs @@ -240,6 +240,36 @@ fn is_interface_return_only(params: &[ArgumentType], ret_ty: &Type, context: &Co !anchors.contains(&reduced_ret) } +/// Replaces every `Generic(name)` of `subs` inside `typ` (structurally, through +/// arrays, records, tuples, functions, tags and alias parameters). +fn substitute_generics(typ: &Type, subs: &std::collections::HashMap) -> Type { + let go = |t: &Type| substitute_generics(t, subs); + let go_args = |args: &[ArgumentType]| -> Vec { + args.iter() + .map(|a| ArgumentType::new(&a.get_argument_str(), &go(&a.get_type()))) + .collect() + }; + match typ { + Type::Generic(name, _) => subs.get(name).cloned().unwrap_or_else(|| typ.clone()), + Type::Vec(vt, _, elem, h) => match typ.vec_length() { + Some(len) => Type::vec(vt.clone(), len, go(elem), h.clone()), + None => typ.clone(), + }, + Type::Function(args, ret, h) => Type::Function(go_args(args), Box::new(go(ret)), h.clone()), + Type::Record(fields, h) => Type::Record( + go_args(&fields.iter().cloned().collect::>()) + .into_iter() + .collect(), + h.clone(), + ), + Type::Tuple(ts, h) => Type::Tuple(ts.iter().map(go).collect(), h.clone()), + Type::Tag(n, inner, h) => Type::Tag(n.clone(), Box::new(go(inner)), h.clone()), + Type::Multi(inner, h) => Type::Multi(Box::new(go(inner)), h.clone()), + Type::Alias(n, params, o, h) => Type::Alias(n.clone(), params.iter().map(go).collect(), *o, h.clone()), + _ => typ.clone(), + } +} + /// 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( @@ -397,14 +427,48 @@ pub fn function( // 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(); + // A generic written in a parameter type (`fn(a: T, b: U): T`, `[#N, T]`) is + // an empty-interface rigid: opaque in the body, one per name, so `T` and `U` + // cannot be mixed up. Nested occurrences (`[#N, T]`, the return type) are + // rewritten to the same rigid below. + let mut generic_rigids: std::collections::HashMap = std::collections::HashMap::new(); + for arg_typ in params.iter().filter(|a| !a.is_variadic()) { + let mut names: Vec = arg_typ + .body_type() + .extract_generics() + .into_iter() + .filter_map(|g| match g { + Type::Generic(name, _) => Some(name), + _ => None, + }) + .collect(); + names.sort(); + for name in names { + if name != "Self" && !is_rigid_name(&name) && !generic_rigids.contains_key(&name) { + let (rigid, new_ctx) = sub_context.clone().fresh_rigid_name(); + sub_context = + new_ctx.add_interface_constraint(rigid.clone(), Type::Interface(Default::default(), h.clone())); + generic_rigids.insert(name, Type::Generic(rigid, h.clone())); + } + } + } 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 param_type = if generic_rigids.is_empty() { + param_type + } else { + substitute_generics(¶m_type, &generic_rigids) + }; let reduced = reduce_type(&sub_context, ¶m_type); - if facets::interface_facet(&sub_context, &reduced).is_some() { + let already_rigid = matches!(¶m_type, Type::Generic(n, _) if is_rigid_name(n)); + if already_rigid { + 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); + } else if facets::interface_facet(&sub_context, &reduced).is_some() { let id = match normalized_param.get_type() { Type::Bounded(id, _, _) if !arg_typ.is_variadic() => Some(id), _ => None, @@ -440,8 +504,10 @@ pub fn function( // 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 { + } else if generic_rigids.is_empty() { ret_ty.clone() + } else { + substitute_generics(ret_ty, &generic_rigids) }; // `Self:{ ... }` (generic_constructor.md §4.1): bind `Self` to whatever @@ -470,6 +536,14 @@ pub fn function( 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); + let back: std::collections::HashMap = generic_rigids + .iter() + .filter_map(|(name, rigid)| match rigid { + Type::Generic(r, _) => Some((r.clone(), Type::Generic(name.clone(), h.clone()))), + _ => None, + }) + .collect(); + let found = substitute_generics(&found, &back); errors.push(builder::unmatching_return_type(ret_ty, &found)) }); // Structural types registered on the fly while typing the body (e.g. the @@ -1289,4 +1363,24 @@ mod tests { fn test_bare_interface_return_is_tied_to_the_shared_variable() { assert!(body_is_ok("fn(a: Lovable, b: Lovable): Lovable { b }")); } + + // A bare generic written as a whole parameter type is an empty-interface rigid. + #[test] + fn test_bare_generics_are_rigid_in_the_body() { + assert!(body_is_ok("fn(a: T, b: U): T { a }")); + assert!(body_is_ok("fn(a: T, b: U): list { a: T, b: U } { list(a = a, b = b) }")); + assert!(!body_is_ok("fn(a: T, b: U): T { b }")); + assert!(!body_is_ok( + "fn(a: T, b: U): list { a: U, b: T } { list(a = a, b = b) }" + )); + assert!(!body_is_ok("fn(x: T): T { x + 1 }")); + } + + #[test] + fn test_nested_generics_are_rigid_in_the_body() { + assert!(body_is_ok("fn(a: [#N, T]): [#N, T] { a }")); + assert!(body_is_ok("fn(a: [#N, T], b: U): U { b }")); + assert!(!body_is_ok("fn(a: [#N, T], b: U): T { b }")); + assert!(!body_is_ok("fn(a: [#N, T], b: [#N, U]): [#N, T] { b }")); + } } diff --git a/crates/typr-core/src/processes/type_checking/mod.rs b/crates/typr-core/src/processes/type_checking/mod.rs index 4bda0cb2..48b329a6 100644 --- a/crates/typr-core/src/processes/type_checking/mod.rs +++ b/crates/typr-core/src/processes/type_checking/mod.rs @@ -385,8 +385,10 @@ pub fn eval(context: &Context, expr: &Lang) -> TypeContext { ) .with_errors(errors) } else { + // Report the widened type the check actually used (`bool`, + // not the literal `false` an unannotated `let` inferred). errors.push(TypRError::Type(TypeError::Let( - left_type.clone().set_help_data(h.clone()), + widened_left_type.set_help_data(h.clone()), right_type.clone(), ))); TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors) @@ -1548,7 +1550,10 @@ fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { unresolved.push(kinded); } _ => { - errors.push(TypRError::Type(TypeError::WrongExpression(spread_expr.get_help_data()))); + errors.push(TypRError::Type(TypeError::SpreadNonRecord( + field_access::display_with_reduction(context, &tc.value), + spread_expr.get_help_data(), + ))); } } } @@ -3733,8 +3738,8 @@ p"#; result .errors .iter() - .any(|e| matches!(e, TypRError::Type(TypeError::WrongExpression(..)))), - "Expected a WrongExpression error for spreading a non-record, got: {:?}", + .any(|e| matches!(e, TypRError::Type(TypeError::SpreadNonRecord(..)))), + "Expected a SpreadNonRecord error for spreading a non-record, got: {:?}", result.errors ); } @@ -4476,6 +4481,43 @@ p"#; ); } + fn errors_of(src: &str) -> bool { + use crate::processes::parsing::parse_from_string; + let ast = parse_from_string(src, "test.ty"); + typing_with_errors(&Context::default(), &ast).has_errors() + } + + #[test] + fn user_function_shadows_untyped_r_builtin_of_same_name() { + // `double` is also a base-R name, preloaded as an untyped variadic + // `Any` function. A typed definition must replace it, not sit beside + // it and accept the calls the typed signature rejects. + let def = "let double <- fn(x: int): int { x * 2 };"; + assert!(errors_of(&format!("{def} double(\"two\");"))); + assert!(!errors_of(&format!("{def} double(4);"))); + } + + #[test] + fn shadowing_a_builtin_keeps_vectorized_calls_typed() { + // Previously `v.double()` fell through to the builtin and came back + // `any`, which then failed against the `Vec[3, int]` annotation. + assert!(!errors_of( + "let double <- fn(x: int): int { x * 2 };\ + let v: Vec[3, int] <- [1, 2, 3];\ + let w: Vec[3, int] <- v.double();" + )); + } + + #[test] + fn literal_tag_payload_is_accepted_by_a_union_parameter() { + // `.Circle(2.0)` has payload type `2.0` (a singleton); the union's + // member says `num`. A literal is a subtype of its base. + let def = "type Shape <- .Circle(num) | .Square(num);\ + let id <- fn(s: Shape): Shape { s };"; + assert!(!errors_of(&format!("{def} id(.Circle(2.0));"))); + assert!(errors_of(&format!("{def} id(.Circle(\"x\"));"))); + } + // ===================================================================== // Phase 4 (audit_type_checking.md) — operators, control flow, assignment. // ===================================================================== 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 31d83f8a..79b441b1 100644 --- a/crates/typr-core/src/processes/type_checking/signature_normalization.rs +++ b/crates/typr-core/src/processes/type_checking/signature_normalization.rs @@ -253,6 +253,9 @@ fn bind_ids( if !concrete.is_subtype_raw(&reduce_type(context, bound), context) { return false; } + // A literal argument binds the id to its general type (`3` → `int`), + // as for a free generic: `f(3, 5)` must not clash on `3` vs `5`. + let concrete = &concrete.clone().generalize(); match binds.iter().find(|(k, _, _)| k == id) { Some((_, previous, first_arg)) => { let same = same_type(previous, concrete, context); diff --git a/crates/typr-core/src/processes/type_checking/unification.rs b/crates/typr-core/src/processes/type_checking/unification.rs index e2ba25a8..24e27b22 100644 --- a/crates/typr-core/src/processes/type_checking/unification.rs +++ b/crates/typr-core/src/processes/type_checking/unification.rs @@ -1,6 +1,7 @@ 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_system::TypeSystem; use crate::components::r#type::Type; use crate::processes::type_checking::type_comparison; use crate::utils::builder; @@ -256,7 +257,10 @@ fn unification_helper(values: &[Type], type1: &Type, type2: &Type) -> Option T` agrees on `int`. + Some(vec![(Type::Generic(g.clone(), h.clone()), t.clone().generalize())]) } } @@ -367,6 +371,18 @@ fn unification_helper(values: &[Type], type1: &Type, type2: &Type) -> Option + { + Some(vec![]) + } + // Default case - types are not unifiable _ => None, } diff --git a/crates/typr-core/src/utils/standard_library.rs b/crates/typr-core/src/utils/standard_library.rs index 2696b832..6a83a619 100644 --- a/crates/typr-core/src/utils/standard_library.rs +++ b/crates/typr-core/src/utils/standard_library.rs @@ -93,7 +93,9 @@ pub fn not_in_blacklist(name: &str) -> bool { && !name.contains(":") && !name.contains("-") && !name.contains(".POSIXt") - && !name.contains(".") + // An inner dot is an R name (`as.character`); a leading one is a + // user's hidden `.name`, which gets its generic like any function. + && !name.trim_start_matches('.').contains(".") } /// Validate that vectorization is consistent across arguments 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 e6a226e2..4e007dc5 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,5 @@ --- source: crates/typr-core/tests/snapshot_tests.rs -assertion_line: 287 expression: ty --- Function( @@ -11,7 +10,7 @@ Function( "a", ), HelpData { - offset: 15, + offset: 14, end: 15, file_name: "", }, @@ -34,7 +33,7 @@ Function( "b", ), HelpData { - offset: 23, + offset: 22, end: 23, file_name: "", }, 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 f0b1cec5..7e471310 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,5 @@ --- source: crates/typr-graph/tests/walkthrough.rs -assertion_line: 53 expression: json --- { @@ -262,7 +261,7 @@ expression: json "name": "+", "span": { "file": "fil_rouge", - "start": 193, + "start": 192, "end": 204 }, "type": "int", @@ -322,7 +321,7 @@ expression: json "kind": "Apply", "span": { "file": "fil_rouge", - "start": 197, + "start": 196, "end": 204 }, "type": "int", @@ -371,7 +370,7 @@ expression: json "kind": "Access", "span": { "file": "fil_rouge", - "start": 200, + "start": 199, "end": 202 }, "type": "int", @@ -414,7 +413,7 @@ expression: json "name": "a", "span": { "file": "fil_rouge", - "start": 180, + "start": 179, "end": 192 }, "type": "int", @@ -463,7 +462,7 @@ expression: json "kind": "Access", "span": { "file": "fil_rouge", - "start": 183, + "start": 182, "end": 185 }, "type": "int", @@ -708,7 +707,7 @@ expression: json "name": "*", "span": { "file": "fil_rouge", - "start": 124, + "start": 123, "end": 129 }, "type": "int", @@ -767,7 +766,7 @@ expression: json "name": "total", "span": { "file": "fil_rouge", - "start": 323, + "start": 322, "end": 341 }, "type": "int", @@ -829,7 +828,7 @@ expression: json "name": "+", "span": { "file": "fil_rouge", - "start": 323, + "start": 322, "end": 331 }, "type": "int", @@ -890,7 +889,7 @@ expression: json "kind": "Apply", "span": { "file": "fil_rouge", - "start": 323, + "start": 322, "end": 331 }, "type": "int", diff --git a/crates/typr-lsp/src/handlers/code_actions.rs b/crates/typr-lsp/src/handlers/code_actions.rs index 33ad7368..a8022d72 100644 --- a/crates/typr-lsp/src/handlers/code_actions.rs +++ b/crates/typr-lsp/src/handlers/code_actions.rs @@ -134,9 +134,7 @@ fn annotation_quick_fix(lang: &Lang, context: &Context, content: &str, range: Ra } let var = Var::try_from(variable).ok()?; let name = var.get_name(); - // See `collect_inlay_hints`'s `Lang::Let` arm: a `Var`'s `HelpData` offset - // is captured one byte past the identifier's real start. - let real_start_offset = var.get_help_data().get_offset().saturating_sub(1); + let real_start_offset = var.get_help_data().get_offset(); let end_offset = real_start_offset + name.len(); let start_line = offset_to_position(real_start_offset, content).line; let end_pos = offset_to_position(end_offset, content); diff --git a/crates/typr-lsp/src/handlers/completions.rs b/crates/typr-lsp/src/handlers/completions.rs index a08b164b..9f56a3a3 100644 --- a/crates/typr-lsp/src/handlers/completions.rs +++ b/crates/typr-lsp/src/handlers/completions.rs @@ -251,6 +251,14 @@ fn detect_completion_context(prefix: &str) -> CompletionCtx { } } + // `M::` — module member access (the dot form `M.` is handled above). + if let Some(before) = trimmed.strip_suffix("::") { + let expr = extract_last_expression(before.trim_end()); + if expr.chars().next().is_some_and(|c| c.is_alphabetic()) { + return CompletionCtx::Module(expr); + } + } + if let Some(colon_pos) = trimmed.rfind(':') { let after_colon = &trimmed[colon_pos + 1..]; if !after_colon.contains('=') && !after_colon.contains(';') { @@ -258,7 +266,11 @@ fn detect_completion_context(prefix: &str) -> CompletionCtx { } } - if trimmed.trim_start().starts_with("type ") && trimmed.contains('=') { + // On the cursor's own line only: the prefix spans up to 10 lines, so a + // `type ...` declaration just above used to turn every expression + // completion below it into type completions. + let trimmed_line = current_line.trim_end(); + if trimmed_line.starts_with("type ") && trimmed_line.contains('=') { return CompletionCtx::Type; } @@ -312,10 +324,14 @@ fn extract_last_expression(s: &str) -> String { break; } '<' | '>' if depth_paren == 0 && depth_bracket == 0 && depth_brace == 0 => { - if i > 0 && last_statement.as_bytes().get(i - 1) == Some(&b'-') { + // `->` is an arrow inside the expression: keep scanning. + if ch == '>' && i > 0 && last_statement.as_bytes().get(i - 1) == Some(&b'-') { continue; } - start = i + 1; + // `<-` binds: the expression starts after the `-`, not at it + // (`let a <- p` used to yield `- p`). + let arrow = ch == '<' && last_statement.as_bytes().get(i + 1) == Some(&b'-'); + start = if arrow { i + 2 } else { i + 1 }; break; } _ => {} @@ -405,31 +421,42 @@ fn get_module_completions( module_name: &str, doc_map: &HashMap, ) -> Vec { - let module_context = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - context.extract_module_as_vartype(module_name) - })); - - let module_ctx = match module_context { - Ok(ctx) => ctx, - Err(_) => return Vec::new(), + // Only the module's own public members — its `Type::Module` signature — + // not `extract_module_as_vartype`, whose context starts from + // `Context::default()` and so also lists the whole standard library. + let members = match context.get_type_from_variable(&Var::from_name(module_name)) { + Ok(Type::Module(args, _, _)) => args, + _ => return Vec::new(), }; - let mut items = Vec::new(); - - for (var, typ) in module_ctx.variables() { - let kind = if typ.is_function() { - CompletionItemKind::FUNCTION - } else { - CompletionItemKind::VARIABLE - }; - items.push(var_to_completion_item(var, typ, kind, doc_map)); - } - - for (var, typ) in module_ctx.aliases() { - items.push(var_to_completion_item(var, typ, CompletionItemKind::INTERFACE, doc_map)); - } + members + .iter() + // `M::` is always outside `M`: a hidden `.name` is private by + // definition (it can't be `@pub`, S020), so it's never offered here. + .filter_map(|arg| { + std::panic::catch_unwind(|| arg.get_argument_str()) + .ok() + .map(|name| (name, arg)) + }) + .filter(|(name, _)| !is_hidden_name(name)) + .map(|(name, arg)| { + let typ = arg.get_type(); + let kind = if name.chars().next().is_some_and(|c| c.is_uppercase()) { + CompletionItemKind::INTERFACE + } else if typ.is_function() { + CompletionItemKind::FUNCTION + } else { + CompletionItemKind::VARIABLE + }; + var_to_completion_item(&Var::from_name(&name), &typ, kind, doc_map) + }) + .collect() +} - items +/// A hidden name (`.helper`, private by name). Its ASCII spelling `__helper` +/// is normalized to the dotted form by the parser, so the dot is enough. +fn is_hidden_name(name: &str) -> bool { + name.trim_matches('`').starts_with('.') } /// Suggest sibling `.ty` files for `mod ;`. The directory scanned mirrors @@ -1032,3 +1059,100 @@ mod completion_resolve_tests { assert_eq!(file_path, Some("test.ty")); } } + +#[cfg(test)] +mod hidden_name_tests { + use super::*; + + const DOC: &str = "type Pt <- list{ x: int };\n\ + module M {\n\ + let .hid <- 1;\n\ + @testable let .hidt <- 2;\n\ + let .hidf <- fn(p: Pt): int { p.x };\n\ + @pub let pub_g <- fn(p: Pt): int { p.x };\n\ + };\n\ + let .top <- 3;\n\ + let .topf <- fn(p: Pt): int { p.x };\n\ + let p <- Pt:{ x = 1 };\n"; + + fn labels_at_end(content: &str, path: &str) -> Vec { + let line = content.lines().count() as u32 - 1; + let col = content.lines().last().unwrap().len() as u32; + get_completions_at(content, line, col, path) + .0 + .into_iter() + .map(|i| i.label) + .collect() + } + + fn hidden(labels: &[String]) -> Vec<&str> { + labels + .iter() + .map(|l| l.as_str()) + .filter(|l| l.starts_with('.')) + .collect() + } + + /// `M::` lists M's public members only — not its hidden ones (never + /// public, S020), and not the whole standard library either. + #[test] + fn module_member_completion_lists_public_members_only() { + let labels = labels_at_end(&format!("{DOC}let a <- M::"), "test.ty"); + assert_eq!(labels, vec!["pub_g".to_string()]); + } + + /// The current file's own hidden names are in scope (it is their module); + /// the module's are not, whatever the completion kind. + #[test] + fn hidden_names_only_complete_inside_their_module() { + let expr = labels_at_end(&format!("{DOC}let a <- "), "test.ty"); + assert_eq!(hidden(&expr), vec![".top", ".topf"], "expression completions"); + for tail in ["let a <- p.", "let a <- p |> "] { + let labels = labels_at_end(&format!("{DOC}{tail}"), "test.ty"); + assert_eq!(hidden(&labels), vec![".topf"], "{tail}"); + } + } + + /// Same across files: a file module's hidden names stay out of the + /// importer's completions, even after `use utils::*`. + #[test] + fn hidden_names_of_a_file_module_do_not_leak() { + let dir = std::env::temp_dir().join(format!("typr_lsp_hidden_{}", std::process::id())); + let typr_dir = dir.join("TypR"); + let _ = std::fs::create_dir_all(&typr_dir); + std::fs::write(dir.join("DESCRIPTION"), "Package: x\n").unwrap(); + std::fs::write(dir.join("NAMESPACE"), "").unwrap(); + std::fs::write( + typr_dir.join("utils.ty"), + "type Pt <- list{ x: int };\nlet .uhid <- 1;\nlet .uhidf <- fn(p: Pt): int { p.x };\n@pub let pub_u <- fn(p: Pt): int { p.x };\n", + ) + .unwrap(); + let main_file = typr_dir.join("main.ty"); + let mut seen = Vec::new(); + for tail in ["let a <- utils::", "let a <- ", "let a <- p."] { + let content = format!("mod utils;\nuse utils::*;\nlet p <- Pt:{{ x = 1 }};\n{tail}"); + std::fs::write(&main_file, &content).unwrap(); + let labels = labels_at_end(&content, main_file.to_str().unwrap()); + seen.push((tail, labels.contains(&"pub_u".to_string()), labels)); + } + let _ = std::fs::remove_dir_all(&dir); + for (tail, has_pub, labels) in seen { + assert!(has_pub, "{tail}: public member missing"); + assert!(hidden(&labels).is_empty(), "{tail}: {:?}", hidden(&labels)); + } + } + + #[test] + fn module_access_and_assignment_contexts_are_detected() { + assert!(matches!(detect_completion_context("let a <- M::"), CompletionCtx::Module(m) if m == "M")); + // A `type` declaration a few lines above no longer turns the + // expression position into a type position. + let prefix = "type Pt <- list{ x: int };\nlet a <- "; + assert!(matches!(detect_completion_context(prefix), CompletionCtx::Expression)); + assert_eq!(extract_last_expression("let a <- p"), "p"); + assert_eq!( + extract_last_expression("let f <- fn(x: int) -> int"), + "fn(x: int) -> int" + ); + } +} diff --git a/crates/typr-lsp/src/handlers/goto_definition.rs b/crates/typr-lsp/src/handlers/goto_definition.rs index ab4cc22b..0beab7b4 100644 --- a/crates/typr-lsp/src/handlers/goto_definition.rs +++ b/crates/typr-lsp/src/handlers/goto_definition.rs @@ -405,11 +405,7 @@ mod goto_definition_tests { assert_eq!(def.range.start.line, 1); let decl_col = content.lines().nth(1).unwrap().find("pi_approx").unwrap() as u32; - // +1: `Var`'s `HelpData` is captured from `starting_char`'s - // *remaining* span (i.e. just past the first character) rather than - // the identifier's own start — a pre-existing typr-core parsing - // quirk (see `reference-ty-gotchas`), not specific to this lookup. - assert_eq!(def.range.start.character, decl_col + 1); + assert_eq!(def.range.start.character, decl_col); } /// A function parameter, used inside the body, must resolve to its own diff --git a/crates/typr-lsp/src/handlers/inlay_hints.rs b/crates/typr-lsp/src/handlers/inlay_hints.rs index 40214e64..d3feb593 100644 --- a/crates/typr-lsp/src/handlers/inlay_hints.rs +++ b/crates/typr-lsp/src/handlers/inlay_hints.rs @@ -87,15 +87,9 @@ fn collect_inlay_hints(lang: &Lang, context: &Context, content: &str, hints: &mu if let Ok(var) = Var::try_from(variable) { let types = context.get_types_from_name(&var.get_name()); if let Some(typ) = types.last().filter(|t| !matches!(t, Type::Empty(_))) { - // `Var`'s `HelpData` offset is captured one byte past - // the identifier's real start (see the `+1` note on - // `module_field_access_resolves_to_member_let` — the - // same parser quirk applies to every `Var`, not just - // module fields), so the identifier's real start is - // one byte earlier; from there, its end is `name`'s - // byte length further. + // The identifier ends `name`'s byte length past its start. let name = var.get_name(); - let real_start_offset = var.get_help_data().get_offset().saturating_sub(1); + let real_start_offset = var.get_help_data().get_offset(); let end_offset = real_start_offset + name.len(); let position = offset_to_position(end_offset, content); hints.push(InlayHint { diff --git a/crates/typr-lsp/src/handlers/semantic_tokens.rs b/crates/typr-lsp/src/handlers/semantic_tokens.rs index ce48a108..f40a0740 100644 --- a/crates/typr-lsp/src/handlers/semantic_tokens.rs +++ b/crates/typr-lsp/src/handlers/semantic_tokens.rs @@ -109,10 +109,7 @@ fn encode_semantic_tokens(raw: &[RawToken], content: &str) -> Vec /// Locate a `Lang::Variable`/`Var` identifier's byte span from its /// `HelpData`. The parser uses two different conventions depending on which /// grammar branch matched: a lowercase-first name goes through -/// `variable_exp`, whose per-character combinator loop returns the -/// *remaining* span after consuming just the first character — so the -/// offset lands one byte past the real start (the same quirk -/// `collect_inlay_hints` already corrects for `let` type hints). A +/// `variable_exp`, whose offset is the identifier's real start. A /// Pascal-case name goes through `pascal_case_helper`, which consumes the /// whole identifier in one shot and returns the remaining span *after* it — /// so the offset lands at the identifier's end. Returns `None` for @@ -127,8 +124,7 @@ fn lang_variable_span(name: &str, help_data: &HelpData) -> Option<(usize, usize) let start = end.checked_sub(name.len())?; Some((start, end)) } else { - let start = offset.checked_sub(1)?; - Some((start, start + name.len())) + Some((offset, offset + name.len())) } } diff --git a/crates/typr-lsp/src/metaprogramming.rs b/crates/typr-lsp/src/metaprogramming.rs index 8de266a4..cf47517f 100644 --- a/crates/typr-lsp/src/metaprogramming.rs +++ b/crates/typr-lsp/src/metaprogramming.rs @@ -25,10 +25,13 @@ use typr_core::processes::parsing::parse; /// Walk up from `file_path`'s directory looking for a `DESCRIPTION` + /// `NAMESPACE` pair, returning the directory that contains them. Works for -/// both absolute paths (LSP, arbitrary process cwd) and the relative paths -/// the CLI uses (where it's always invoked from the project root). +/// both absolute paths (LSP, arbitrary process cwd) and relative ones: a +/// relative path is first made absolute against the cwd, otherwise a file +/// given from inside `TypR/` (`typr check object.ty`) would stop the walk at +/// `""` and never reach the project root. pub fn find_project_root(file_path: &str) -> Option { - let mut dir = Path::new(file_path).parent(); + let file_path = std::path::absolute(file_path).unwrap_or_else(|_| PathBuf::from(file_path)); + let mut dir = file_path.parent(); while let Some(d) = dir { if d.join("DESCRIPTION").exists() && d.join("NAMESPACE").exists() { return Some(d.to_path_buf()); diff --git a/crates/typr-mcp/data/tutor/01-hello.ty b/crates/typr-mcp/data/tutor/01-hello.ty new file mode 100644 index 00000000..07185648 --- /dev/null +++ b/crates/typr-mcp/data/tutor/01-hello.ty @@ -0,0 +1,34 @@ +# ============================================================================= +# TypR tutor · Lesson 1 of 13 · Say hello +# ============================================================================= +# +# Welcome to the TypR tutor! +# +# This file is both a lesson and a TypR program. You read the comments (the +# lines starting with '#'), you edit the code, and the compiler tells you +# whether you got it right. Nothing here can break: if you get lost, run +# +# typr tutor reset 1 +# +# and this file comes back exactly as it was. +# +# TypR is R with types. You write code that looks almost like R, a compiler +# checks it, and the output is ordinary, readable R code. +# +# One habit to pick up right away: every statement ends with a ';'. +# +# print("like this"); +# +# ----------------------------------------------------------------------------- +# >>> EXERCISE +# +# The line below does not compile: '???' is not TypR. Replace '???' so the +# program prints the text "Hello, TypR!" (with the quotes, it is a string). +# +# Then save the file and run, in your terminal: +# +# typr tutor check +# +# ----------------------------------------------------------------------------- + +print(???); diff --git a/crates/typr-mcp/data/tutor/02-types.ty b/crates/typr-mcp/data/tutor/02-types.ty new file mode 100644 index 00000000..376ae126 --- /dev/null +++ b/crates/typr-mcp/data/tutor/02-types.ty @@ -0,0 +1,35 @@ +# ============================================================================= +# TypR tutor · Lesson 2 of 13 · Values and their types +# ============================================================================= +# +# You declare a name with 'let' and give it a value with '<-', like in R. +# You may also say what TYPE of value it holds, after a colon: +# +# let city: char <- "Geneva"; +# +# The compiler now remembers that 'city' is a character string, and will +# complain if you ever treat it as something else. +# +# TypR has four primitive types: +# +# int whole numbers 42 +# num decimal numbers 3.14 +# char text (strings) "hello" +# bool true or false true +# +# Careful: 42 is an int, 42.0 is a num. TypR keeps them apart, R does not. +# +# ----------------------------------------------------------------------------- +# >>> EXERCISE +# +# Replace each '???' with the right type, so that every annotation matches +# its value. Then run: typr tutor check +# +# ----------------------------------------------------------------------------- + +let name: ??? <- "Ada"; +let age: ??? <- 36; +let height: ??? <- 1.68; +let is_admin: ??? <- false; + +print(name); diff --git a/crates/typr-mcp/data/tutor/03-break-it.ty b/crates/typr-mcp/data/tutor/03-break-it.ty new file mode 100644 index 00000000..4fc620d6 --- /dev/null +++ b/crates/typr-mcp/data/tutor/03-break-it.ty @@ -0,0 +1,31 @@ +# ============================================================================= +# TypR tutor · Lesson 3 of 13 · Break it on purpose +# ============================================================================= +# +# The compiler's job is to say NO to code that would fail at run time. +# Before learning to please it, let's see it at work. +# +# Below is a small typed function. 'fn(x: int): int' reads: "a function that +# takes an int called x and returns an int". +# +# In plain R, twice("two") would only blow up when the line runs, maybe in +# production, maybe months later. In TypR it never gets that far. +# +# ----------------------------------------------------------------------------- +# >>> EXERCISE (an inverted one: this time, success means an error!) +# +# This program compiles fine. Add a line at the end that calls 'twice' with +# a value of the wrong type, so that the compiler REJECTS the program with a +# type error. Then run: typr tutor check +# +# Read the error message carefully: it says what was expected, what it +# received, and where. Every error has a code (like T001); the tutor shows +# a longer explanation for it. +# +# ----------------------------------------------------------------------------- + +let twice <- fn(x: int): int { + x * 2 +}; + +print(twice(21)); diff --git a/crates/typr-mcp/data/tutor/04-fix-it.ty b/crates/typr-mcp/data/tutor/04-fix-it.ty new file mode 100644 index 00000000..3626788d --- /dev/null +++ b/crates/typr-mcp/data/tutor/04-fix-it.ty @@ -0,0 +1,31 @@ +# ============================================================================= +# TypR tutor · Lesson 4 of 13 · Fix it +# ============================================================================= +# +# Now the other way round: this program has TWO type errors. Run +# +# typr tutor check +# +# first, BEFORE changing anything, and read what the compiler says. +# +# Two things to know: +# +# - "2026" (with quotes) is a char, 2026 (without) is an int. +# - TypR never turns an int into a num behind your back: a function that +# expects a num wants 3.0, not 3. +# +# ----------------------------------------------------------------------------- +# >>> EXERCISE +# +# Fix both errors by changing the VALUES, not the types: keep the +# annotations ': int' and '(x: num): num' exactly as they are. +# +# ----------------------------------------------------------------------------- + +let year: int <- "2026"; + +let square <- fn(x: num): num { + x * x +}; + +print(square(3)); diff --git a/crates/typr-mcp/data/tutor/05-functions.ty b/crates/typr-mcp/data/tutor/05-functions.ty new file mode 100644 index 00000000..c0a68f77 --- /dev/null +++ b/crates/typr-mcp/data/tutor/05-functions.ty @@ -0,0 +1,31 @@ +# ============================================================================= +# TypR tutor · Lesson 5 of 13 · Write a typed function +# ============================================================================= +# +# A function is a value like any other: you bind it with 'let'. +# +# let add <- fn(a: int, b: int): int { +# a + b +# }; +# +# - each parameter has a type: a: int, b: int +# - the return type comes after ')': ': int' +# - the last expression of the body is the returned value (no 'return') +# - the whole definition ends with ';', like any statement +# +# The return type is mandatory: it is the promise your function makes, and +# the compiler checks that the body keeps it. +# +# ----------------------------------------------------------------------------- +# >>> EXERCISE +# +# Write 'perimeter': it takes the width and the height of a rectangle (two +# num) and returns its perimeter (a num): 2 times (width + height). +# +# Remember lesson 2: 2 is an int, 2.0 is a num. +# +# ----------------------------------------------------------------------------- + +let perimeter <- ???; + +print(perimeter(3.0, 4.0)); diff --git a/crates/typr-mcp/data/tutor/06-three-calls.ty b/crates/typr-mcp/data/tutor/06-three-calls.ty new file mode 100644 index 00000000..f4f89cca --- /dev/null +++ b/crates/typr-mcp/data/tutor/06-three-calls.ty @@ -0,0 +1,34 @@ +# ============================================================================= +# TypR tutor · Lesson 6 of 13 · One function, three ways to call it +# ============================================================================= +# +# In TypR, the first argument of any function can move in front of it. +# These three lines are exactly the same call: +# +# add(5, 3) # classic call +# (5) |> add(3) # pipe: "take 5, then add 3" +# (5).add(3) # method style: "5, add 3" +# +# This works for ANY function, yours or R's, without declaring anything. +# It is called "uniform function call syntax". It shines when calls nest: +# reading add(add(1, 2), 3) means reading from the inside out, while +# (1).add(2).add(3) reads left to right. +# +# ----------------------------------------------------------------------------- +# >>> EXERCISE +# +# Rewrite 'nested' twice, keeping the same result: +# - 'piped' using the pipe |> +# - 'chained' using the method style .add(...) +# +# ----------------------------------------------------------------------------- + +let add <- fn(a: int, b: int): int { + a + b +}; + +let nested <- add(add(1, 2), 3); +let piped <- ???; +let chained <- ???; + +print(chained); diff --git a/crates/typr-mcp/data/tutor/07-vectorization.ty b/crates/typr-mcp/data/tutor/07-vectorization.ty new file mode 100644 index 00000000..562b6776 --- /dev/null +++ b/crates/typr-mcp/data/tutor/07-vectorization.ty @@ -0,0 +1,47 @@ +# ============================================================================= +# TypR tutor · Lesson 7 of 13 · Write once, run on many +# ============================================================================= +# +# In R you often write a loop, or reach for sapply(), to apply a function to +# every element of a vector. In TypR you don't. You write the function for ONE +# value, and the compiler lifts it when you hand it many: +# +# let double <- fn(x: int): int { x * 2 }; +# +# double(4) # -> 8 +# double([1, 2, 3]) # -> [2, 4, 6] same function, no loop +# +# [1, 2, 3] is an array. Its type is written Vec[3, int] : "three ints". +# The length is part of the type, so the compiler knows the result of +# double([1, 2, 3]) has three elements too. +# +# Lifting is still type-checked: double(["a", "b"]) is refused, because +# 'double' wants ints, whether you give it one or three. +# +# The same goes for functions of several arguments: scale([1, 2, 3], 10) pairs +# each element with the 10. +# +# Why not simply loop? Because "to apply f to each element" is something the +# type system can see, so you don't have to say it. The longer story is in +# the documentation page linked when you pass this lesson. +# +# ----------------------------------------------------------------------------- +# >>> EXERCISE +# +# 1. Write 'to_fahrenheit': it takes one temperature in Celsius (a num) and +# returns it in Fahrenheit (a num): celsius * 1.8 + 32.0 +# 2. Replace the second '???' by a call to to_fahrenheit on the whole array +# 'temps', in one call: to_fahrenheit(temps). No loop. +# +# The annotation Vec[3, num] is your safety net: if you call the function +# on a single value, the compiler will tell you it got a num, not three. +# +# ----------------------------------------------------------------------------- + +let to_fahrenheit <- ???; + +let temps <- [0.0, 20.0, 37.0]; + +let fahrenheit: Vec[3, num] <- ???; + +print(fahrenheit); diff --git a/crates/typr-mcp/data/tutor/08-records.ty b/crates/typr-mcp/data/tutor/08-records.ty new file mode 100644 index 00000000..702d7735 --- /dev/null +++ b/crates/typr-mcp/data/tutor/08-records.ty @@ -0,0 +1,39 @@ +# ============================================================================= +# TypR tutor · Lesson 8 of 13 · Model your data with records +# ============================================================================= +# +# An R list with named fields becomes, in TypR, a RECORD type: +# +# type Person <- list { +# name: char, +# age: int +# }; +# +# Type names start with an uppercase letter (PascalCase); variable names +# are lowercase (snake_case). The parser enforces it. +# +# You build a value of that type with TypeName:{ field = value, ... } +# +# let alice <- Person:{ name = "Alice", age = 25 }; +# +# and read a field with '$', like in R: alice$age +# +# In the generated R, 'Person' becomes a real constructor that VALIDATES its +# fields: a wrong field type is caught at compile time AND at run time. +# +# ----------------------------------------------------------------------------- +# >>> EXERCISE +# +# 1. Complete the type 'Book' with two fields: 'title' (a char) and +# 'pages' (an int). +# 2. Build 'dune', a Book titled "Dune" with 412 pages. +# +# ----------------------------------------------------------------------------- + +type Book <- list { + ??? +}; + +let dune <- ???; + +print(dune$title); diff --git a/crates/typr-mcp/data/tutor/09-methods.ty b/crates/typr-mcp/data/tutor/09-methods.ty new file mode 100644 index 00000000..84e23340 --- /dev/null +++ b/crates/typr-mcp/data/tutor/09-methods.ty @@ -0,0 +1,32 @@ +# ============================================================================= +# TypR tutor · Lesson 9 of 13 · Functions on your types +# ============================================================================= +# +# Lessons 6 and 8 meet here. A function whose first parameter is a Book can +# be called "on" a book, with the method style: +# +# let describe <- fn(b: Book): char { b$title }; +# dune.describe(); # same as describe(dune) +# +# No class, no 'impl' block: any function does it. And since the compiler +# knows 'dune' is a Book, it knows which 'describe' you mean. +# +# Records are STRUCTURAL: a function that takes a Book accepts any record +# having at least the fields of Book. Extra fields are fine. +# +# ----------------------------------------------------------------------------- +# >>> EXERCISE +# +# 1. Write 'is_long': it takes a Book and returns a bool, true when the book +# has more than 300 pages. +# 2. Replace the last '???' by a call to is_long on 'dune', in method style. +# +# ----------------------------------------------------------------------------- + +type Book <- list { title: char, pages: int }; + +let dune <- Book:{ title = "Dune", pages = 412 }; + +let is_long <- ???; + +print(???); diff --git a/crates/typr-mcp/data/tutor/10-unions-match.ty b/crates/typr-mcp/data/tutor/10-unions-match.ty new file mode 100644 index 00000000..0f94732c --- /dev/null +++ b/crates/typr-mcp/data/tutor/10-unions-match.ty @@ -0,0 +1,42 @@ +# ============================================================================= +# TypR tutor · Lesson 10 of 13 · Unions and pattern matching +# ============================================================================= +# +# Sometimes a value is "one of several shapes". A UNION lists the cases, +# each one a TAG starting with a dot, optionally carrying a value: +# +# type Shape <- .Circle(num) | .Square(num); +# let c: Shape <- .Circle(2.0); +# +# 'match' looks at which tag you have and opens it: +# +# match c { +# .Circle(r) => 3.14 * r * r, +# .Square(side) => side * side, +# } +# +# The compiler checks that the match is EXHAUSTIVE: forget a case and the +# program does not compile. Add a tag to a union, and the compiler shows you +# every match you must update. Plain R's switch() cannot do that. +# +# ----------------------------------------------------------------------------- +# >>> EXERCISE +# +# Someone added '.Triangle(num)' (an equilateral triangle, given its side) +# to Shape, but forgot to update 'area'. Run typr tutor check to see +# the compiler catch it, then add the missing arm. The area of an +# equilateral triangle of side s is about 0.433 * s * s. +# +# ----------------------------------------------------------------------------- + +type Shape <- .Circle(num) | .Square(num) | .Triangle(num); + +let area <- fn(s: Shape): num { + match s { + .Circle(r) => 3.14 * r * r, + .Square(side) => side * side, + } +}; + +let t: Shape <- .Triangle(2.0); +print(area(t)); diff --git a/crates/typr-mcp/data/tutor/11-generics.ty b/crates/typr-mcp/data/tutor/11-generics.ty new file mode 100644 index 00000000..d98a6dc5 --- /dev/null +++ b/crates/typr-mcp/data/tutor/11-generics.ty @@ -0,0 +1,35 @@ +# ============================================================================= +# TypR tutor · Lesson 11 of 13 · Generics: no more NULL surprises +# ============================================================================= +# +# In R, "maybe there is no value" is often a NULL or an NA that slips +# through and breaks something far away. TypR makes absence explicit: +# +# type Option <- .Some(T) | .None; +# +# 'T' is a type parameter: Option holds maybe an int, Option +# maybe a char. The compiler forces you to handle .None, because to get the +# value out of a .Some you have to 'match' it (lesson 10). +# +# A function can be generic too: write T in its signature, and TypR works +# out what T is at each call. +# +# let first_or <- fn(o: Option, fallback: T): T { ... }; +# +# ----------------------------------------------------------------------------- +# >>> EXERCISE +# +# Write 'unwrap_or': given an Option and a default value of type T, it +# returns the value inside .Some, or the default when it is .None. +# Both calls at the bottom must compile, one with int, one with char. +# +# ----------------------------------------------------------------------------- + +type Option <- .Some(T) | .None; + +let unwrap_or <- ???; + +let a: int <- unwrap_or(.Some(4), 0); +let b: char <- unwrap_or(.None, "empty"); + +print(b); diff --git a/crates/typr-mcp/data/tutor/12-talk-to-r.ty b/crates/typr-mcp/data/tutor/12-talk-to-r.ty new file mode 100644 index 00000000..f8004463 --- /dev/null +++ b/crates/typr-mcp/data/tutor/12-talk-to-r.ty @@ -0,0 +1,33 @@ +# ============================================================================= +# TypR tutor · Lesson 12 of 13 · Talk to R +# ============================================================================= +# +# Every R function and every R package stays available from TypR. But the +# compiler cannot guess the types of a function it has never seen: what it +# returns is 'any', a value it knows nothing about. You can use an 'any' +# freely, but you cannot pretend it is a char. +# +# To type an R function, declare its SIGNATURE with '@': +# +# @nchar: (char) -> int; +# +# This generates no code at all; it only teaches the compiler. From then +# on, every call to nchar is checked like one of your own functions. +# +# TypR's standard library already declares many base R functions this way. +# For the rest (base R or any package), you write the '@' line yourself. +# +# ----------------------------------------------------------------------------- +# >>> EXERCISE +# +# R's strrep(x, times) repeats the string x 'times' times: +# strrep("-", 3) gives "---". This program does not compile, because +# strrep returns 'any' and 'line' must be a char. +# +# Add a signature for strrep ABOVE its first use, so that it compiles. +# +# ----------------------------------------------------------------------------- + +let line: char <- strrep("-", 20); + +print(line); diff --git a/crates/typr-mcp/data/tutor/13-graduation.ty b/crates/typr-mcp/data/tutor/13-graduation.ty new file mode 100644 index 00000000..8d490aec --- /dev/null +++ b/crates/typr-mcp/data/tutor/13-graduation.ty @@ -0,0 +1,28 @@ +# ============================================================================= +# TypR tutor · Lesson 13 of 13 · Your own program +# ============================================================================= +# +# You now know the heart of TypR: +# +# let x: T <- ...; values and the four primitive types +# fn(a: T): U { ... } typed functions +# x |> f(y) x.f(y) three ways to call any function +# type R <- list { ... } records, built with R:{ ... } +# .Tag(T) | .Other unions, opened with an exhaustive 'match' +# Option generics +# @f: (T) -> U; typing R functions +# +# and that the compiler is a colleague who reads your code before it runs. +# +# ----------------------------------------------------------------------------- +# >>> EXERCISE (free style) +# +# Erase this lesson and write a small program of your own, on any subject +# you like (a to-do list, a shop, a game...). It must compile, and use at +# least: a 'type', a typed function 'fn(', and a 'match'. +# +# Stuck for an idea? Model a traffic light: .Red | .Orange | .Green, and a +# function that returns the next colour. +# +# ----------------------------------------------------------------------------- + diff --git a/crates/typr-mcp/data/tutor/solutions/01-hello.ty b/crates/typr-mcp/data/tutor/solutions/01-hello.ty new file mode 100644 index 00000000..0322da70 --- /dev/null +++ b/crates/typr-mcp/data/tutor/solutions/01-hello.ty @@ -0,0 +1 @@ +print("Hello, TypR!"); diff --git a/crates/typr-mcp/data/tutor/solutions/02-types.ty b/crates/typr-mcp/data/tutor/solutions/02-types.ty new file mode 100644 index 00000000..f284bcfb --- /dev/null +++ b/crates/typr-mcp/data/tutor/solutions/02-types.ty @@ -0,0 +1,6 @@ +let name: char <- "Ada"; +let age: int <- 36; +let height: num <- 1.68; +let is_admin: bool <- false; + +print(name); diff --git a/crates/typr-mcp/data/tutor/solutions/03-break-it.ty b/crates/typr-mcp/data/tutor/solutions/03-break-it.ty new file mode 100644 index 00000000..2125494e --- /dev/null +++ b/crates/typr-mcp/data/tutor/solutions/03-break-it.ty @@ -0,0 +1,6 @@ +let twice <- fn(x: int): int { + x * 2 +}; + +print(twice(21)); +twice("two"); diff --git a/crates/typr-mcp/data/tutor/solutions/04-fix-it.ty b/crates/typr-mcp/data/tutor/solutions/04-fix-it.ty new file mode 100644 index 00000000..66a06e9a --- /dev/null +++ b/crates/typr-mcp/data/tutor/solutions/04-fix-it.ty @@ -0,0 +1,7 @@ +let year: int <- 2026; + +let square <- fn(x: num): num { + x * x +}; + +print(square(3.0)); diff --git a/crates/typr-mcp/data/tutor/solutions/05-functions.ty b/crates/typr-mcp/data/tutor/solutions/05-functions.ty new file mode 100644 index 00000000..dd47bfef --- /dev/null +++ b/crates/typr-mcp/data/tutor/solutions/05-functions.ty @@ -0,0 +1,5 @@ +let perimeter <- fn(width: num, height: num): num { + 2.0 * (width + height) +}; + +print(perimeter(3.0, 4.0)); diff --git a/crates/typr-mcp/data/tutor/solutions/06-three-calls.ty b/crates/typr-mcp/data/tutor/solutions/06-three-calls.ty new file mode 100644 index 00000000..3e103b02 --- /dev/null +++ b/crates/typr-mcp/data/tutor/solutions/06-three-calls.ty @@ -0,0 +1,9 @@ +let add <- fn(a: int, b: int): int { + a + b +}; + +let nested <- add(add(1, 2), 3); +let piped <- (1) |> add(2) |> add(3); +let chained <- (1).add(2).add(3); + +print(chained); diff --git a/crates/typr-mcp/data/tutor/solutions/07-vectorization.ty b/crates/typr-mcp/data/tutor/solutions/07-vectorization.ty new file mode 100644 index 00000000..ce6c2b6d --- /dev/null +++ b/crates/typr-mcp/data/tutor/solutions/07-vectorization.ty @@ -0,0 +1,9 @@ +let to_fahrenheit <- fn(c: num): num { + c * 1.8 + 32.0 +}; + +let temps <- [0.0, 20.0, 37.0]; + +let fahrenheit: Vec[3, num] <- to_fahrenheit(temps); + +print(fahrenheit); diff --git a/crates/typr-mcp/data/tutor/solutions/08-records.ty b/crates/typr-mcp/data/tutor/solutions/08-records.ty new file mode 100644 index 00000000..20ef37d5 --- /dev/null +++ b/crates/typr-mcp/data/tutor/solutions/08-records.ty @@ -0,0 +1,8 @@ +type Book <- list { + title: char, + pages: int +}; + +let dune <- Book:{ title = "Dune", pages = 412 }; + +print(dune$title); diff --git a/crates/typr-mcp/data/tutor/solutions/09-methods.ty b/crates/typr-mcp/data/tutor/solutions/09-methods.ty new file mode 100644 index 00000000..d0749544 --- /dev/null +++ b/crates/typr-mcp/data/tutor/solutions/09-methods.ty @@ -0,0 +1,9 @@ +type Book <- list { title: char, pages: int }; + +let dune <- Book:{ title = "Dune", pages = 412 }; + +let is_long <- fn(b: Book): bool { + b$pages > 300 +}; + +print(dune.is_long()); diff --git a/crates/typr-mcp/data/tutor/solutions/10-unions-match.ty b/crates/typr-mcp/data/tutor/solutions/10-unions-match.ty new file mode 100644 index 00000000..fc83338a --- /dev/null +++ b/crates/typr-mcp/data/tutor/solutions/10-unions-match.ty @@ -0,0 +1,12 @@ +type Shape <- .Circle(num) | .Square(num) | .Triangle(num); + +let area <- fn(s: Shape): num { + match s { + .Circle(r) => 3.14 * r * r, + .Square(side) => side * side, + .Triangle(side) => 0.433 * side * side, + } +}; + +let t: Shape <- .Triangle(2.0); +print(area(t)); diff --git a/crates/typr-mcp/data/tutor/solutions/11-generics.ty b/crates/typr-mcp/data/tutor/solutions/11-generics.ty new file mode 100644 index 00000000..1dc0e004 --- /dev/null +++ b/crates/typr-mcp/data/tutor/solutions/11-generics.ty @@ -0,0 +1,13 @@ +type Option <- .Some(T) | .None; + +let unwrap_or <- fn(o: Option, default: T): T { + match o { + .Some(x) => x, + .None => default, + } +}; + +let a: int <- unwrap_or(.Some(4), 0); +let b: char <- unwrap_or(.None, "empty"); + +print(b); diff --git a/crates/typr-mcp/data/tutor/solutions/12-talk-to-r.ty b/crates/typr-mcp/data/tutor/solutions/12-talk-to-r.ty new file mode 100644 index 00000000..649c47a4 --- /dev/null +++ b/crates/typr-mcp/data/tutor/solutions/12-talk-to-r.ty @@ -0,0 +1,5 @@ +@strrep: (char, int) -> char; + +let line: char <- strrep("-", 20); + +print(line); diff --git a/crates/typr-mcp/data/tutor/solutions/13-graduation.ty b/crates/typr-mcp/data/tutor/solutions/13-graduation.ty new file mode 100644 index 00000000..3450c8fd --- /dev/null +++ b/crates/typr-mcp/data/tutor/solutions/13-graduation.ty @@ -0,0 +1,12 @@ +type Light <- .Red | .Orange | .Green; + +let next <- fn(l: Light): Light { + match l { + .Red => .Green, + .Green => .Orange, + .Orange => .Red, + } +}; + +let now: Light <- .Red; +let later <- now.next().next(); diff --git a/crates/typr-mcp/data/tutor/std.ty b/crates/typr-mcp/data/tutor/std.ty new file mode 100644 index 00000000..02e434c2 --- /dev/null +++ b/crates/typr-mcp/data/tutor/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-mcp/src/explain.rs b/crates/typr-mcp/src/explain.rs index a128f8d9..cdf7c93a 100644 --- a/crates/typr-mcp/src/explain.rs +++ b/crates/typr-mcp/src/explain.rs @@ -195,6 +195,26 @@ pub const ENTRIES: &[ExplainEntry] = &[ bad: "let f <- fn(a: Lovable @A): bool { true };", good: "let f <- fn(a: Lovable@A): bool { true };", }, + ExplainEntry { + code: "S019", + title: "Hidden name after the `.` operator", + explanation: "A hidden name starts with `.` (`.helper`, private by name, as in R). Right \ + after the UFCS/field `.` operator that gives `x..helper()`, which reads as the `..` \ + spread token. After a dot, spell the hidden name with its ASCII form `__` (which \ + compiles to `.` in R), or call it through the pipe.", + bad: "let .twice <- fn(a: int): int { a * 2 }; let y <- 3..twice();", + good: "let .twice <- fn(a: int): int { a * 2 }; let y <- 3.__twice();", + }, + ExplainEntry { + code: "S020", + title: "Hidden name marked `@pub`/`@export`", + explanation: "A leading `.` (or its ASCII spelling `__`) makes a name hidden, i.e. \ + private by definition — the same convention R's default `exportPattern(\"^[^\\\\.]\")` \ + follows. Annotating it `@pub` or `@export` contradicts the name: either drop the \ + annotation or drop the leading dot.", + bad: "module M { @pub let .cache <- 0; };", + good: "module M { let .cache <- 0; @pub let cache <- 0; };", + }, ExplainEntry { code: "T001", title: "Type mismatch on a 'let' binding", @@ -606,6 +626,23 @@ pub const ENTRIES: &[ExplainEntry] = &[ 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);", }, + ExplainEntry { + code: "T050", + title: "Field access on a value that isn't a record", + explanation: "`e$field` reads a field of a record (`list { ... }`), a module or a data frame. \ + Any other type — a vector alias like `[2, int]`, a scalar — has no named fields; \ + the message shows what an alias stands for so the mismatch is visible.", + bad: "type Position <- [2, int];\nlet p: Position <- [1, 2];\np$x;", + good: "type Position <- list { x: int, y: int };\nlet p <- :{ x: 1, y: 2 };\np$x;", + }, + ExplainEntry { + code: "T051", + title: "Spreading a value that isn't a record", + explanation: "`...e` in a record literal or a constructor copies `e`'s fields into the \ + record being built, so `e` must itself be a record.", + bad: "let v <- [1, 2];\nlet r <- :{ a: 1, ...v };", + good: "let v <- :{ b: 2 };\nlet r <- :{ a: 1, ...v };", + }, ]; pub fn find(code: &str) -> Option<&'static ExplainEntry> { diff --git a/crates/typr-mcp/src/feedback.rs b/crates/typr-mcp/src/feedback.rs new file mode 100644 index 00000000..b446b55a --- /dev/null +++ b/crates/typr-mcp/src/feedback.rs @@ -0,0 +1,458 @@ +//! The feedback loop: a local interaction log, plus `report_issue`, which +//! turns a failure the agent ran into into a pre-filled GitHub issue. +//! +//! **Local log.** Every tool call and resource read is appended, one JSON +//! object per line, to `/-.jsonl` — one file per +//! server process, i.e. per MCP session. The log never leaves the machine: +//! it is there so the user (or the team, when the user chooses to share a +//! file) can replay what an agent tried, which diagnostics it hit, and where +//! it went in circles. Only the [`MAX_SESSIONS`] most recent files are kept. +//! +//! - `TYPR_MCP_LOG=off` (or `0`/`false`) disables it. +//! - `TYPR_MCP_LOG_DIR` overrides the directory; otherwise +//! `$XDG_DATA_HOME/typr/mcp-logs`, falling back to +//! `~/.local/share/typr/mcp-logs`. +//! +//! Logging is best-effort: a directory that can't be created or a write that +//! fails silently disables it rather than failing the tool call. +//! +//! **Reports.** `report_issue` never sends anything itself. It re-checks the +//! agent's *anonymized* repro in-process (so the team gets code that still +//! shows the bug, not the user's private code), writes the full report +//! next to the log, and returns a `github.com/.../issues/new?...` URL the +//! user opens, reviews and submits — a human is always the one who publishes. + +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{check_source, Diagnostic}; + +/// How many session logs to keep; older ones are deleted when a new session +/// starts. +const MAX_SESSIONS: usize = 50; + +/// Where the "new issue" link points. +const ISSUES_NEW_URL: &str = "https://github.com/we-data-ch/typr/issues/new"; + +/// GitHub rejects (414) or truncates pre-filled URLs well before browsers do; +/// past this many encoded bytes the body is cut and points at the local file. +const MAX_URL_LEN: usize = 7000; + +fn now_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +fn logging_enabled() -> bool { + !matches!( + std::env::var("TYPR_MCP_LOG").map(|v| v.to_lowercase()).as_deref(), + Ok("off" | "0" | "false" | "no") + ) +} + +fn log_dir() -> Option { + if let Some(dir) = std::env::var_os("TYPR_MCP_LOG_DIR") { + return Some(PathBuf::from(dir)); + } + let data_home = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?; + Some(data_home.join("typr").join("mcp-logs")) +} + +/// Delete all but the `keep - 1` newest `*.jsonl` session logs, leaving room +/// for the one about to be created. File names start with a unix timestamp, +/// so lexical order is chronological. +fn prune_old_sessions(dir: &Path, keep: usize) { + let Ok(entries) = fs::read_dir(dir) else { return }; + let mut logs: Vec = entries + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|ext| ext == "jsonl")) + .collect(); + logs.sort(); + let excess = (logs.len() + 1).saturating_sub(keep); + for path in logs.into_iter().take(excess) { + let _ = fs::remove_file(path); + } +} + +/// Append-only JSONL log of one MCP session. A disabled log (`file: None`) +/// accepts every call and does nothing. +#[derive(Default)] +pub struct SessionLog { + session_id: String, + dir: Option, + file: Mutex>, +} + +impl SessionLog { + /// Open the log for a new session, honouring `TYPR_MCP_LOG`/`TYPR_MCP_LOG_DIR`. + pub fn from_env() -> Self { + if !logging_enabled() { + return Self::default(); + } + match log_dir() { + Some(dir) => Self::open_in(dir), + None => Self::default(), + } + } + + pub fn open_in(dir: PathBuf) -> Self { + let session_id = format!("{}-{}", now_ms() / 1000, std::process::id()); + if fs::create_dir_all(&dir).is_err() { + return Self::default(); + } + prune_old_sessions(&dir, MAX_SESSIONS); + let file = OpenOptions::new() + .create(true) + .append(true) + .open(dir.join(format!("{session_id}.jsonl"))) + .ok(); + Self { + session_id, + dir: file.as_ref().map(|_| dir), + file: Mutex::new(file), + } + } + + pub fn session_id(&self) -> &str { + &self.session_id + } + + #[cfg(test)] + pub fn path(&self) -> Option { + self.dir.as_ref().map(|d| d.join(format!("{}.jsonl", self.session_id))) + } + + /// Record one event: `kind` is `"tool"`, `"resource"` or `"report"`, + /// `name` the tool/URI, `input`/`output` whatever the call received and + /// returned. + pub fn record(&self, kind: &str, name: &str, input: serde_json::Value, output: serde_json::Value, ms: u128) { + let Ok(mut guard) = self.file.lock() else { return }; + let Some(file) = guard.as_mut() else { return }; + let line = serde_json::json!({ + "ts_ms": now_ms(), + "kind": kind, + "name": name, + "duration_ms": ms, + "input": input, + "output": output, + }); + if writeln!(file, "{line}").is_err() { + *guard = None; + } + } +} + +/// What went wrong, from the agent's point of view — the team triages by it. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum IssueKind { + /// The compiler rejects code that should be valid TypR. + FalsePositive, + /// The compiler accepts code it should reject. + FalseNegative, + /// An internal error (`S000`) or a panic instead of a proper diagnostic. + Crash, + /// `build` produced R code that is wrong or fails to run. + WrongOutput, + /// The diagnostic is correct but its message or `explain` entry didn't + /// help fix it. + ConfusingMessage, + /// A feature, function or doc page the agent needed doesn't exist. + MissingFeatureOrDocs, + Other, +} + +impl IssueKind { + fn label(self) -> &'static str { + match self { + IssueKind::FalsePositive => "valid code rejected", + IssueKind::FalseNegative => "invalid code accepted", + IssueKind::Crash => "internal error / crash", + IssueKind::WrongOutput => "wrong R output", + IssueKind::ConfusingMessage => "confusing diagnostic", + IssueKind::MissingFeatureOrDocs => "missing feature or docs", + IssueKind::Other => "other", + } + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ReportParams { + /// One-line summary, e.g. "T012 on a record spread inside a generic function". + pub title: String, + pub kind: IssueKind, + /// Minimal, ANONYMIZED TypR source that shows the problem. Never paste + /// the user's code as-is: rename identifiers, replace string/number + /// literals and strip unrelated lines until it is a small, generic + /// example that still triggers the same behavior. It is re-checked + /// before the report is built. + pub repro: String, + /// Diagnostic codes the ORIGINAL code produced (e.g. ["T012"]). The + /// repro is checked against them, so the team knows it reproduces the + /// same failure. Leave empty when the bug is that no error was raised. + #[serde(default)] + pub original_codes: Vec, + /// What should have happened instead (the expected diagnostic, R output, behavior...). + pub expected: String, + /// Optional extra context: what the agent was trying to do, what it already + /// tried. Same rule as `repro`: nothing identifying about the user's project. + #[serde(default)] + pub notes: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct ReportResult { + /// True when the repro produces every code in `original_codes` (or, with + /// no original codes, at least compiles to the same "no error" verdict + /// `kind` implies). False means the repro drifted from the real bug: + /// adjust it and report again rather than send a misleading issue. + pub reproduced: bool, + /// What `check` says about the repro right now. + pub repro_diagnostics: Vec, + /// Pre-filled "new issue" link. Give it to the user to open, review and + /// submit — nothing has been sent. + pub issue_url: String, + /// Where the full report was saved locally, if logging is enabled. + pub report_path: Option, + /// The issue body, in case the user prefers to paste it themselves. + pub body: String, +} + +/// Percent-encode everything but RFC 3986 unreserved characters. +fn url_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +fn issue_url(title: &str, body: &str) -> String { + format!( + "{ISSUES_NEW_URL}?labels=mcp-feedback&title={}&body={}", + url_encode(title), + url_encode(body) + ) +} + +/// Does the repro behave like the original failure? +fn reproduces(kind: IssueKind, original_codes: &[String], diagnostics: &[Diagnostic]) -> bool { + if original_codes.is_empty() { + // "Should have been rejected" bugs reproduce when the compiler still + // accepts the code; for the rest there is nothing to compare against. + return match kind { + IssueKind::FalseNegative | IssueKind::WrongOutput => diagnostics.is_empty(), + _ => true, + }; + } + original_codes + .iter() + .all(|want| diagnostics.iter().any(|d| d.code.eq_ignore_ascii_case(want.trim()))) +} + +fn render_body(p: &ReportParams, reproduced: bool, diagnostics: &[Diagnostic], session_id: &str) -> String { + let mut body = String::new(); + body.push_str(&format!("**Kind:** {}\n", p.kind.label())); + body.push_str(&format!("**typr version:** {}\n", env!("CARGO_PKG_VERSION"))); + body.push_str(&format!("**Reported via:** typr mcp (session `{session_id}`)\n\n")); + + body.push_str("### Repro (anonymized)\n\n```typr\n"); + body.push_str(p.repro.trim_end()); + body.push_str("\n```\n\n"); + + body.push_str("### Observed\n\n"); + if diagnostics.is_empty() { + body.push_str("No diagnostics: `check` accepts this code.\n"); + } else { + for d in diagnostics { + body.push_str(&format!("- `{}` {}\n", d.code, d.message.trim())); + } + } + if !p.original_codes.is_empty() { + body.push_str(&format!( + "\nOriginal code produced: {} — {}.\n", + p.original_codes.join(", "), + if reproduced { + "reproduced by the snippet above" + } else { + "**NOT reproduced by the snippet above**" + } + )); + } + + body.push_str("\n### Expected\n\n"); + body.push_str(p.expected.trim()); + body.push('\n'); + + if let Some(notes) = p.notes.as_deref().filter(|n| !n.trim().is_empty()) { + body.push_str("\n### Notes\n\n"); + body.push_str(notes.trim()); + body.push('\n'); + } + body +} + +/// Build (but never send) an issue report — see the module docs. +pub fn build_report(p: &ReportParams, log: &SessionLog) -> ReportResult { + let repro_diagnostics = check_source(&p.repro).diagnostics; + let reproduced = reproduces(p.kind, &p.original_codes, &repro_diagnostics); + let session_id = if log.session_id().is_empty() { + "unlogged" + } else { + log.session_id() + }; + let body = render_body(p, reproduced, &repro_diagnostics, session_id); + let title = format!("[mcp] {}", p.title.trim()); + + let report_path = log.dir.as_ref().and_then(|dir| { + let path = dir.join(format!("report-{}.md", now_ms())); + fs::write(&path, format!("# {title}\n\n{body}")).ok()?; + Some(path.display().to_string()) + }); + + let mut url = issue_url(&title, &body); + if url.len() > MAX_URL_LEN { + let note = match &report_path { + Some(path) => format!("\n\n_(truncated — the full report is in `{path}`)_\n"), + None => "\n\n_(truncated)_\n".to_string(), + }; + // Cut on a char boundary, conservatively: encoding grows text ≤ 9x. + let mut cut = body.len().min(MAX_URL_LEN / 9); + while !body.is_char_boundary(cut) { + cut -= 1; + } + url = issue_url(&title, &format!("{}{note}", &body[..cut])); + } + + ReportResult { + reproduced, + repro_diagnostics, + issue_url: url, + report_path, + body, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(repro: &str, kind: IssueKind, codes: &[&str]) -> ReportParams { + ReportParams { + title: "test".into(), + kind, + repro: repro.into(), + original_codes: codes.iter().map(|c| c.to_string()).collect(), + expected: "should not happen".into(), + notes: None, + } + } + + fn scratch_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("typr-mcp-test-{name}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + dir + } + + #[test] + fn url_encode_keeps_unreserved_and_escapes_the_rest() { + assert_eq!(url_encode("a-b_c.d~"), "a-b_c.d~"); + assert_eq!(url_encode("a b&c=d\n"), "a%20b%26c%3Dd%0A"); + assert_eq!(url_encode("é"), "%C3%A9"); + } + + #[test] + fn repro_with_the_original_code_is_reproduced() { + let p = params("let x: int <- \"oops\";", IssueKind::FalsePositive, &[]); + let code = check_source(&p.repro).diagnostics[0].code.clone(); + let p = params(&p.repro, IssueKind::FalsePositive, &[&code]); + let r = build_report(&p, &SessionLog::default()); + assert!(r.reproduced, "{:?}", r.repro_diagnostics); + assert!(r.issue_url.starts_with(ISSUES_NEW_URL)); + assert!(r.body.contains(&code)); + assert!(r.report_path.is_none()); + } + + #[test] + fn repro_that_drifted_is_flagged() { + let p = params("let x: int <- 42;", IssueKind::FalsePositive, &["T001"]); + let r = build_report(&p, &SessionLog::default()); + assert!(!r.reproduced); + assert!(r.body.contains("NOT reproduced")); + } + + #[test] + fn false_negative_reproduces_when_the_code_is_still_accepted() { + let p = params("let x: int <- 42;", IssueKind::FalseNegative, &[]); + assert!(build_report(&p, &SessionLog::default()).reproduced); + } + + #[test] + fn oversized_report_still_yields_a_bounded_url() { + let big = "let x: int <- 42;\n".repeat(2000); + let p = params(&big, IssueKind::Other, &[]); + let r = build_report(&p, &SessionLog::default()); + assert!(r.issue_url.len() <= MAX_URL_LEN + 500, "{}", r.issue_url.len()); + assert!(r.issue_url.contains("truncated")); + } + + #[test] + fn session_log_writes_one_json_line_per_event_and_saves_reports() { + let dir = scratch_dir("log"); + let log = SessionLog::open_in(dir.clone()); + log.record( + "tool", + "check", + serde_json::json!({"source": "x"}), + serde_json::json!({"ok": true}), + 3, + ); + log.record( + "tool", + "explain", + serde_json::json!({"code": "T001"}), + serde_json::json!({}), + 1, + ); + let text = fs::read_to_string(log.path().unwrap()).unwrap(); + let lines: Vec = text.lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0]["name"], "check"); + assert_eq!(lines[1]["input"]["code"], "T001"); + + let r = build_report(¶ms("let x: int <- 42;", IssueKind::Other, &[]), &log); + assert!(Path::new(r.report_path.as_deref().unwrap()).exists()); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn old_sessions_are_pruned() { + let dir = scratch_dir("prune"); + fs::create_dir_all(&dir).unwrap(); + for i in 0..5 { + fs::write(dir.join(format!("{i:03}-1.jsonl")), "").unwrap(); + } + fs::write(dir.join("report-1.md"), "").unwrap(); + prune_old_sessions(&dir, 3); + let mut left: Vec = fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); + left.sort(); + assert_eq!(left, vec!["003-1.jsonl", "004-1.jsonl", "report-1.md"]); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/crates/typr-mcp/src/lib.rs b/crates/typr-mcp/src/lib.rs index 83ecbe87..bca433a3 100644 --- a/crates/typr-mcp/src/lib.rs +++ b/crates/typr-mcp/src/lib.rs @@ -34,6 +34,14 @@ //! see that module's docs for the handful of declared-but-dead codes it //! doesn't (and can't) cover. //! +//! `report_issue` and the local session log close the feedback loop — see +//! [`feedback`] for what is recorded, where, and how to turn it off +//! (`TYPR_MCP_LOG=off`). +//! +//! The `tutor_*` tools and the `tutor` prompt run the TypR tutor (see +//! [`tutor`]), an interactive course for newcomers shared with the +//! `typr tutor` terminal command: same lessons, same compiler-backed grading. +//! //! Type checking is recursive, so a long-lived server must drive it from a //! large-stack thread — see [`run_stdio`]. //! @@ -58,8 +66,9 @@ use rmcp::handler::server::wrapper::{Json, Parameters}; use rmcp::model::{ - ListResourcesResult, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, - Resource, ResourceContents, ServerCapabilities, ServerInfo, + GetPromptRequestParams, GetPromptResponse, GetPromptResult, ListPromptsResult, ListResourcesResult, + PaginatedRequestParams, Prompt, PromptArgument, PromptMessage, ReadResourceRequestParams, ReadResourceResponse, + ReadResourceResult, Resource, ResourceContents, Role, ServerCapabilities, ServerInfo, }; use rmcp::service::{RequestContext, RoleServer}; use rmcp::transport::stdio; @@ -69,6 +78,13 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; mod explain; +mod feedback; +pub mod tutor; + +use std::sync::Arc; +use std::time::Instant; + +use feedback::{ReportParams, ReportResult, SessionLog}; use typr_core::components::context::config::{Config, Environment}; use typr_core::components::error_message::syntax_error::SyntaxError; @@ -263,7 +279,12 @@ fn panic_payload_diagnostic(payload: Box) -> Diagnosti } fn check_source(source: &str) -> CheckResult { - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check_source_inner(source))) { + check_source_as(source, "main.ty") +} + +/// [`check_source`], with `file_name` in the diagnostics instead of `main.ty`. +fn check_source_as(source: &str, file_name: &str) -> CheckResult { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check_source_inner(source, file_name))) { Ok(result) => result, Err(payload) => CheckResult { ok: false, @@ -272,8 +293,8 @@ fn check_source(source: &str) -> CheckResult { } } -fn check_source_inner(source: &str) -> CheckResult { - let parsed = parse_from_string_with_errors(source, "main.ty"); +fn check_source_inner(source: &str, file_name: &str) -> CheckResult { + let parsed = parse_from_string_with_errors(source, file_name); if parsed.has_errors() { let diagnostics = parsed .errors @@ -575,20 +596,165 @@ fn search_docs(query: &str) -> DocsSearchResult { DocsSearchResult { matches } } +#[derive(Debug, Serialize, JsonSchema)] +pub struct TutorLessonSummary { + pub number: usize, + pub title: String, + pub goal: String, + /// File name in a `typr tutor` workspace (`typr-tutor/`), for a + /// learner who also has the terminal version open. + pub file: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct TutorLessonsResult { + pub lessons: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TutorLessonParams { + /// Lesson number, starting at 1 (see `tutor_lessons`). + pub lesson: usize, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct TutorDocLink { + pub title: String, + pub url: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct TutorLessonResult { + pub number: usize, + pub title: String, + pub goal: String, + pub file: String, + /// The lesson file: prose in `#` comments, then the exercise code with + /// its `???` gaps or bugs. Show it (or teach from it); the learner edits it. + pub content: String, + /// How many hints `tutor_hint` can give for this lesson. + pub hint_count: usize, + /// Pages to point the learner to once the lesson is passed. + pub docs: Vec, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TutorCheckParams { + /// Lesson number, starting at 1. + pub lesson: usize, + /// The learner's version of the lesson file (comments may be kept). + pub source: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct TutorCheckResult { + #[serde(flatten)] + pub grade: tutor::Grade, + /// Present once the lesson is passed: where to read more, then the next + /// lesson's number (absent after the last one). + pub docs: Vec, + pub next_lesson: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct TutorHintParams { + /// Lesson number, starting at 1. + pub lesson: usize, + /// Which hint, starting at 1. Hints go from vague to nearly the answer: + /// ask for the next one only when the previous didn't unblock the learner. + pub level: usize, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct TutorHintResult { + pub lesson: usize, + pub level: usize, + pub hint: String, + pub hint_count: usize, +} + +fn unknown_lesson(number: usize) -> McpError { + McpError::invalid_params( + format!("no lesson {number}: lessons are numbered 1 to {}", tutor::LESSONS.len()), + None, + ) +} + +fn tutor_doc_links(lesson: &tutor::Lesson) -> Vec { + lesson + .docs + .iter() + .map(|d| TutorDocLink { + title: d.title.to_string(), + url: d.url(), + }) + .collect() +} + +const TUTOR_PROMPT: &str = "You are the TypR tutor, the AI companion of `typr tutor` (TypR's \ +answer to vimtutor). The learner knows some R and is discovering TypR, a typed superset of R \ +that compiles to plain R. + +How to teach: +- Follow the lessons in order unless the learner asks otherwise: `tutor_lessons` lists them, \ +`tutor_lesson` returns a lesson file (prose in `#` comments, then an exercise with `???` gaps \ +or a bug). Present the lesson in your own words, briefly, then show the exercise. +- The learner writes the code, not you. Never hand over a full solution unless they \ +explicitly ask for it after trying. Prefer a question that points at the problem over the fix. +- Grade every attempt with `tutor_check` (pass the learner's code as-is). Rely on its verdict, \ +not on your own reading: it runs the real compiler. When it fails, start from its `feedback`, \ +and explain diagnostics with its `explanations` (or the `explain` tool) in plain words. +- When the learner is stuck, give hints with `tutor_hint`, one level at a time. +- When a lesson passes, celebrate briefly, then ALWAYS offer the `docs` links it returns and \ +say in one sentence what each page will teach them: the tutor is a doorway into the \ +documentation. Use `docs_search` for any question beyond the lesson. +- Encourage experiments: `check` and `build` (to see the generated R) work on any snippet. +- If the learner has a `typr-tutor/` folder from the terminal version, they can edit the \ +lesson files there and you can read them; both versions grade the same way. +- If the compiler rejects code that the lesson says is valid, say so honestly and offer \ +`report_issue`. +- Answer in the learner's language; keep code, keywords and error codes as they are."; + +/// The server holds the session's interaction log (see [`feedback`]); the +/// tools themselves stay pure functions of their input. #[derive(Clone, Default)] -pub struct TyprMcpServer; +pub struct TyprMcpServer { + log: Arc, +} + +impl TyprMcpServer { + /// Run `f`, then record its input and output in the session log. + fn logged(&self, kind: &str, name: &str, input: &I, f: impl FnOnce() -> O) -> O { + let start = Instant::now(); + let output = f(); + self.log.record( + kind, + name, + serde_json::to_value(input).unwrap_or_default(), + serde_json::to_value(&output).unwrap_or_default(), + start.elapsed().as_millis(), + ); + output + } +} #[tool_router] impl TyprMcpServer { pub fn new() -> Self { - Self + Self { + log: Arc::new(SessionLog::from_env()), + } } #[tool(description = "Type-check TypR source code in-process (no filesystem, no project \ directory) and report diagnostics with stable error codes (T0xx for type errors, \ S0xx for syntax errors).")] async fn check(&self, Parameters(CheckParams { source }): Parameters) -> Json { - Json(check_source(&source)) + Json( + self.logged("tool", "check", &serde_json::json!({ "source": source }), || { + check_source(&source) + }), + ) } #[tool( @@ -600,7 +766,19 @@ impl TyprMcpServer { trusting the output." )] async fn build(&self, Parameters(CheckParams { source }): Parameters) -> Json { - Json(build_source(&source)) + let start = Instant::now(); + let result = build_source(&source); + // The inlined std lib makes `r_code` large and identical across calls: + // log only the user's part of it. + let main_code = result.r_code.split("# === Main Code ===\n").nth(1).unwrap_or(""); + self.log.record( + "tool", + "build", + serde_json::json!({ "source": source }), + serde_json::json!({ "ok": result.ok, "diagnostics": result.diagnostics, "main_code": main_code }), + start.elapsed().as_millis(), + ); + Json(result) } #[tool(description = "Explain a TypR diagnostic code (T0xx/S0xx, from a `check`/`build` \ @@ -609,7 +787,11 @@ impl TyprMcpServer { produce — `found: false` means this code is declared but dead (unreachable in the \ current compiler), so fall back to the short `message` `check`/`build` already returned.")] async fn explain(&self, Parameters(ExplainParams { code }): Parameters) -> Json { - Json(explain_code(&code)) + Json( + self.logged("tool", "explain", &serde_json::json!({ "code": code }), || { + explain_code(&code) + }), + ) } #[tool(description = "Search typr.github.io/docs/ by keyword and get back matching pages \ @@ -624,14 +806,145 @@ impl TyprMcpServer { &self, Parameters(DocsSearchParams { query }): Parameters, ) -> Json { - Json(search_docs(&query)) + Json( + self.logged("tool", "docs_search", &serde_json::json!({ "query": query }), || { + search_docs(&query) + }), + ) + } + + #[tool( + description = "TypR tutor (an interactive course like vimtutor): list the lessons, in \ + order, with their number, title and goal. Use with the `tutor` prompt." + )] + async fn tutor_lessons(&self) -> Json { + Json(self.logged("tool", "tutor_lessons", &serde_json::Value::Null, || { + TutorLessonsResult { + lessons: tutor::LESSONS + .iter() + .map(|l| TutorLessonSummary { + number: l.number, + title: l.title.to_string(), + goal: l.goal.to_string(), + file: l.file_name(), + }) + .collect(), + } + })) + } + + #[tool( + description = "TypR tutor: get one lesson file — its prose (in `#` comments) and its \ + exercise (code with `???` gaps or a bug for the learner to fix), plus the docs pages it \ + leads to. The learner writes the answer; grade it with `tutor_check`." + )] + async fn tutor_lesson( + &self, + Parameters(TutorLessonParams { lesson }): Parameters, + ) -> Result, McpError> { + let l = tutor::find(lesson).ok_or_else(|| unknown_lesson(lesson))?; + Ok(Json(self.logged( + "tool", + "tutor_lesson", + &serde_json::json!({ "lesson": lesson }), + || TutorLessonResult { + number: l.number, + title: l.title.to_string(), + goal: l.goal.to_string(), + file: l.file_name(), + content: l.starter.to_string(), + hint_count: l.hints.len(), + docs: tutor_doc_links(l), + }, + ))) + } + + #[tool(description = "TypR tutor: grade the learner's attempt at a lesson with the real \ + compiler. Returns `passed`, the compiler's `diagnostics`, longer `explanations` of their \ + codes, and `feedback` saying what is still missing in the tutor's words. Once passed, \ + `docs` lists the pages to read next and `next_lesson` the following lesson.")] + async fn tutor_check( + &self, + Parameters(TutorCheckParams { lesson, source }): Parameters, + ) -> Result, McpError> { + let l = tutor::find(lesson).ok_or_else(|| unknown_lesson(lesson))?; + let input = serde_json::json!({ "lesson": lesson, "source": source }); + Ok(Json(self.logged("tool", "tutor_check", &input, || { + let grade = tutor::grade(l, &source); + let passed = grade.passed; + TutorCheckResult { + grade, + docs: if passed { tutor_doc_links(l) } else { Vec::new() }, + next_lesson: tutor::find(lesson + 1).filter(|_| passed).map(|n| n.number), + } + }))) + } + + #[tool( + description = "TypR tutor: get a hint for a lesson. Hints go from vague (level 1) to \ + nearly the answer (last level): give them one at a time, only as far as the learner \ + needs." + )] + async fn tutor_hint( + &self, + Parameters(TutorHintParams { lesson, level }): Parameters, + ) -> Result, McpError> { + let l = tutor::find(lesson).ok_or_else(|| unknown_lesson(lesson))?; + let index = level.clamp(1, l.hints.len()) - 1; + Ok(Json(self.logged( + "tool", + "tutor_hint", + &serde_json::json!({ "lesson": lesson, "level": level }), + || TutorHintResult { + lesson, + level: index + 1, + hint: l.hints[index].to_string(), + hint_count: l.hints.len(), + }, + ))) + } + + #[tool( + description = "Prepare a bug report for the TypR team, ONLY when the user asks you to \ + report a problem (or agrees when you offer to). Use it for: valid code rejected, invalid \ + code accepted, an internal error (S000), wrong R output, a diagnostic/`explain` entry \ + that didn't help, or a missing feature/doc. `repro` must be ANONYMIZED: never the \ + user's code verbatim — rename identifiers, replace literals, drop unrelated lines until \ + it is a small generic snippet that still triggers the same behavior. Pass the original \ + failure's codes in `original_codes`; the repro is re-checked against them and \ + `reproduced: false` means it drifted — fix the repro and call again. Nothing is sent: \ + the result holds an `issue_url` (pre-filled GitHub issue) that you hand to the user to \ + open, review and submit, plus the local `report_path`." + )] + async fn report_issue(&self, Parameters(params): Parameters) -> Json { + let result = feedback::build_report(¶ms, &self.log); + self.log.record( + "report", + "report_issue", + serde_json::json!({ + "title": params.title, + "kind": params.kind, + "repro": params.repro, + "original_codes": params.original_codes, + }), + serde_json::json!({ "reproduced": result.reproduced, "report_path": result.report_path }), + 0, + ); + Json(result) } } #[tool_handler] impl ServerHandler for TyprMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().enable_resources().build()).with_instructions( + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .enable_prompts() + .build(), + ) + .with_instructions( "TypR compiler tools. `check` type-checks TypR source and returns diagnostics \ tagged with stable error codes. `build` does the same but also returns the \ transpiled R code. `explain` takes one of those codes and returns a longer \ @@ -651,8 +964,73 @@ impl ServerHandler for TyprMcpServer { gotchas, the sanctioned ways to drop out of the type system, and a side-by-side \ R/TypR comparison — read these to write idiomatic TypR instead of R with type \ annotations bolted on. `typr://docs-index` lists every page on the docs site with \ - its URL, for browsing the full table `docs_search` searches over.", + its URL, for browsing the full table `docs_search` searches over. \ + `report_issue` prepares a GitHub issue for the TypR team — the language is young, \ + so when a diagnostic looks wrong (valid code rejected, S000 internal error, R \ + output that misbehaves) or `explain`/the docs didn't get you unstuck, tell the user \ + and offer to report it; call it only once they agree, with an anonymized repro. \ + `tutor_lessons`/`tutor_lesson`/`tutor_check`/`tutor_hint` run an interactive TypR \ + course for newcomers (the `tutor` prompt explains how to teach it): when a user \ + wants to learn TypR, offer it.", + ) + } + + async fn list_prompts( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListPromptsResult::with_all_items(vec![Prompt::new( + "tutor", + Some("Learn TypR step by step with an AI tutor: hands-on lessons graded by the real compiler, each one leading into the documentation."), + Some(vec![PromptArgument::new("lesson") + .with_description("Lesson number to start from (default: 1).") + .with_required(false)]), ) + .with_title("TypR tutor")])) + } + + async fn get_prompt( + &self, + request: GetPromptRequestParams, + _context: RequestContext, + ) -> Result { + if request.name != "tutor" { + return Err(McpError::invalid_params( + format!("unknown prompt: {}", request.name), + None, + )); + } + let start = request + .arguments + .as_ref() + .and_then(|args| args.get("lesson")) + .and_then(|v| { + v.as_u64() + .map(|n| n as usize) + .or_else(|| v.as_str()?.trim().parse().ok()) + }) + .unwrap_or(1); + let lesson = tutor::find(start).ok_or_else(|| unknown_lesson(start))?; + self.log.record( + "prompt", + "tutor", + serde_json::json!({ "lesson": start }), + serde_json::Value::Null, + 0, + ); + let kickoff = format!( + "Start the TypR tutor at lesson {} of {} (\"{}\"). Welcome me in two or three sentences, \ + tell me how the course works, then fetch the lesson with `tutor_lesson` and teach it.", + lesson.number, + tutor::LESSONS.len(), + lesson.title + ); + Ok(GetPromptResult::new(vec![ + PromptMessage::new_text(Role::User, TUTOR_PROMPT), + PromptMessage::new_text(Role::User, kickoff), + ]) + .into()) } async fn list_resources( @@ -668,7 +1046,16 @@ impl ServerHandler for TyprMcpServer { request: ReadResourceRequestParams, _context: RequestContext, ) -> Result { - read_syntax_resource(&request.uri).map(Into::into) + let result = read_syntax_resource(&request.uri); + // Log which resources the agent reads, not their (static) contents. + self.log.record( + "resource", + &request.uri, + serde_json::Value::Null, + serde_json::json!({ "ok": result.is_ok() }), + 0, + ); + result.map(Into::into) } } diff --git a/crates/typr-mcp/src/tutor.rs b/crates/typr-mcp/src/tutor.rs new file mode 100644 index 00000000..24ac29c1 --- /dev/null +++ b/crates/typr-mcp/src/tutor.rs @@ -0,0 +1,816 @@ +//! The TypR tutor: a short, hands-on course in the spirit of `vimtutor`. +//! +//! Each lesson is a TypR file (`data/tutor/NN-slug.ty`) that is at once the +//! lesson's prose (in `#` comments) and its exercise (code with a gap or a +//! bug). The learner edits the file and asks for it to be graded; grading +//! runs the real type checker in process ([`crate::check_source`]) plus a few +//! substring rules, so a lesson can never accept code the compiler rejects. +//! +//! The same catalog serves two front ends: +//! +//! - **without AI**: `typr tutor` (crates/typr-cli/src/tutor.rs) copies the +//! lessons into a workspace folder and grades them from the terminal; +//! - **with AI**: the `tutor_*` MCP tools and the `tutor` prompt +//! (crates/typr-mcp/src/lib.rs) let an assistant walk a learner through +//! the same lessons, grading with the same [`grade`]. +//! +//! Every lesson ends by pointing at the documentation pages it summarizes +//! ([`Lesson::docs`]): the tutor is a doorway into the docs, not a substitute +//! for them. `tests::every_doc_link_is_in_the_docs_index` keeps those links +//! in step with the vendored `data/docs-index.md`. +//! +//! Adding a lesson: write the starter and its solution under `data/tutor/`, +//! add a [`Lesson`] row below, and run `cargo test -p typr-mcp tutor` — the +//! tests check that the starter fails, the solution passes, and the +//! "Lesson N of M" header matches. + +use schemars::JsonSchema; +use serde::Serialize; + +use crate::{check_source_as, explain, Diagnostic}; + +const DOCS: &str = "https://we-data-ch.github.io/typr.github.io/docs/"; + +/// What the compiler must say about the learner's code for the lesson to pass. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Expect { + /// Type-checks cleanly. + Compiles, + /// Parses, but is rejected by the type checker (an inverted exercise: + /// the learner writes code the compiler must refuse). + TypeError, +} + +/// A substring the learner's code must contain (or must not contain). +/// Matched with whitespace removed on both sides and comments stripped, so +/// `fn(x: num)` matches `fn( x:num )` and nothing in the prose counts. +pub struct Rule { + pub pattern: &'static str, + /// Shown to the learner when the rule is broken. + pub message: &'static str, +} + +pub struct DocLink { + pub title: &'static str, + /// Path under `typr.github.io/docs/`, e.g. `reference/types`. + pub path: &'static str, +} + +impl DocLink { + pub fn url(&self) -> String { + format!("{DOCS}{}", self.path) + } +} + +pub struct Lesson { + pub number: usize, + /// File name in the learner's workspace, without extension. + pub slug: &'static str, + pub title: &'static str, + /// One sentence: what the learner can do after this lesson. + pub goal: &'static str, + /// The lesson file as handed to the learner: prose + exercise. + pub starter: &'static str, + /// A reference solution (never shown unless asked for). + pub solution: &'static str, + pub expect: Expect, + pub requires: &'static [Rule], + pub forbids: &'static [Rule], + /// Progressive hints, vaguest first; the last one nearly gives it away. + pub hints: &'static [&'static str], + /// Where to read more, shown once the lesson is passed. + pub docs: &'static [DocLink], +} + +impl Lesson { + pub fn file_name(&self) -> String { + format!("{}.ty", self.slug) + } +} + +const NO_GAPS: Rule = Rule { + pattern: "???", + message: "There is still a '???' in your code: replace every one of them.", +}; + +macro_rules! lesson_files { + ($slug:literal) => { + ( + include_str!(concat!("../data/tutor/", $slug, ".ty")), + include_str!(concat!("../data/tutor/solutions/", $slug, ".ty")), + ) + }; +} + +const L01: (&str, &str) = lesson_files!("01-hello"); +const L02: (&str, &str) = lesson_files!("02-types"); +const L03: (&str, &str) = lesson_files!("03-break-it"); +const L04: (&str, &str) = lesson_files!("04-fix-it"); +const L05: (&str, &str) = lesson_files!("05-functions"); +const L06: (&str, &str) = lesson_files!("06-three-calls"); +const L07: (&str, &str) = lesson_files!("07-vectorization"); +const L08: (&str, &str) = lesson_files!("08-records"); +const L09: (&str, &str) = lesson_files!("09-methods"); +const L10: (&str, &str) = lesson_files!("10-unions-match"); +const L11: (&str, &str) = lesson_files!("11-generics"); +const L12: (&str, &str) = lesson_files!("12-talk-to-r"); +const L13: (&str, &str) = lesson_files!("13-graduation"); + +pub const LESSONS: &[Lesson] = &[ + Lesson { + number: 1, + slug: "01-hello", + title: "Say hello", + goal: "Write and check a first TypR statement, ending it with ';'.", + starter: L01.0, + solution: L01.1, + expect: Expect::Compiles, + requires: &[Rule { + pattern: "\"Hello, TypR!\"", + message: "The program should print exactly \"Hello, TypR!\" (a string, in double quotes).", + }], + forbids: &[NO_GAPS], + hints: &[ + "Text in TypR is written between double quotes, as in R.", + "print() takes the string as its argument: print(\"...\");", + ], + docs: &[ + DocLink { + title: "Getting started", + path: "intro", + }, + DocLink { + title: "The beauty of syntax (why the ';')", + path: "philosophy/beautiful_syntax", + }, + ], + }, + Lesson { + number: 2, + slug: "02-types", + title: "Values and their types", + goal: "Annotate a binding with one of the four primitive types: int, num, char, bool.", + starter: L02.0, + solution: L02.1, + expect: Expect::Compiles, + requires: &[ + Rule { + pattern: "let name: char", + message: "\"Ada\" is text: its type is char.", + }, + Rule { + pattern: "let age: int", + message: "36 has no decimal point: its type is int.", + }, + Rule { + pattern: "let height: num", + message: "1.68 has a decimal point: its type is num.", + }, + Rule { + pattern: "let is_admin: bool", + message: "false is a truth value: its type is bool.", + }, + ], + forbids: &[NO_GAPS], + hints: &[ + "The table in the lesson lists one example value for each type: match each value to its row.", + "Watch the numbers: one has a decimal point, the other does not.", + ], + docs: &[ + DocLink { + title: "Types", + path: "reference/types", + }, + DocLink { + title: "Lexicon & Literals", + path: "reference/lexicon", + }, + DocLink { + title: "Types for Beginners", + path: "tutorials/typr-for-dummies", + }, + ], + }, + Lesson { + number: 3, + slug: "03-break-it", + title: "Break it on purpose", + goal: "See the type checker reject a call with a wrong argument type, and read its error.", + starter: L03.0, + solution: L03.1, + expect: Expect::TypeError, + requires: &[Rule { + pattern: "let twice <- fn(x: int): int", + message: "Keep the definition of 'twice' as it is: only add a new line at the end.", + }], + forbids: &[], + hints: &[ + "'twice' expects an int. What other kinds of values do you know from lesson 2?", + "Try calling it with a string: twice(\"two\");", + ], + docs: &[ + DocLink { + title: "Why this type system", + path: "philosophy/type-system-design", + }, + DocLink { + title: "Philosophy: freedom versus safety", + path: "philosophy/intro", + }, + ], + }, + Lesson { + number: 4, + slug: "04-fix-it", + title: "Fix it", + goal: "Read two type errors and fix the values without weakening the types.", + starter: L04.0, + solution: L04.1, + expect: Expect::Compiles, + requires: &[ + Rule { + pattern: "let year: int <-", + message: "Keep 'let year: int': fix the value, not the annotation.", + }, + Rule { + pattern: "fn(x: num): num", + message: "Keep 'fn(x: num): num': fix the argument, not the signature.", + }, + Rule { + pattern: "print(square(", + message: "Keep the call to square at the end.", + }, + ], + forbids: &[], + hints: &[ + "The first error is about \"2026\": what makes it a char rather than an int?", + "The second one: square expects a num, and 3 is an int. How do you write three as a num?", + ], + docs: &[ + DocLink { + title: "Types", + path: "reference/types", + }, + DocLink { + title: "Known Pitfalls", + path: "concepts/known-pitfalls", + }, + ], + }, + Lesson { + number: 5, + slug: "05-functions", + title: "Write a typed function", + goal: "Define a function with typed parameters and a return type.", + starter: L05.0, + solution: L05.1, + expect: Expect::Compiles, + requires: &[ + Rule { + pattern: "let perimeter <- fn(", + message: "Define perimeter with fn(...).", + }, + Rule { + pattern: "print(perimeter(3.0, 4.0))", + message: "Keep the call perimeter(3.0, 4.0) at the end.", + }, + ], + forbids: &[NO_GAPS], + hints: &[ + "Start from the shape of 'add' in the lesson: fn(a: T, b: T): T { ... }", + "Both parameters and the result are num, and the body is 2.0 * (width + height).", + ], + docs: &[DocLink { + title: "Functions", + path: "reference/functions", + }], + }, + Lesson { + number: 6, + slug: "06-three-calls", + title: "One function, three ways to call it", + goal: "Use the pipe |> and the method style x.f(y) on any function.", + starter: L06.0, + solution: L06.1, + expect: Expect::Compiles, + requires: &[ + Rule { + pattern: "|> add(", + message: "'piped' should use the pipe: ... |> add(...)", + }, + Rule { + pattern: ").add(", + message: "'chained' should use the method style: (1).add(...)", + }, + ], + forbids: &[NO_GAPS], + hints: &[ + "The innermost call, add(1, 2), comes first in both rewrites: start from (1).", + "piped: (1) |> add(2) |> add(3) and chained follows the same idea with dots.", + ], + docs: &[ + DocLink { + title: "Functions", + path: "reference/functions", + }, + DocLink { + title: "TypR vs R", + path: "reference/cheatsheet", + }, + ], + }, + Lesson { + number: 7, + slug: "07-vectorization", + title: "Write once, run on many", + goal: "Call a function written for one value on a whole array, with no loop.", + starter: L07.0, + solution: L07.1, + expect: Expect::Compiles, + requires: &[ + Rule { + pattern: "to_fahrenheit(temps)", + message: "call the function on the whole array: to_fahrenheit(temps)", + }, + Rule { + pattern: "fn(", + message: "'to_fahrenheit' should be a function: fn(c: num): num { ... }", + }, + ], + forbids: &[NO_GAPS], + hints: &[ + "Write the function for ONE temperature, like lesson 5: fn(c: num): num { ... }", + "The body is the formula from the comment: c * 1.8 + 32.0 (remember 1.8 and 32.0 are nums).", + "No loop and no map: fahrenheit is just to_fahrenheit(temps). The compiler lifts the call.", + ], + docs: &[ + DocLink { + title: "Vectorization by design", + path: "philosophy/vectorization_by_design", + }, + DocLink { + title: "Functions", + path: "reference/functions", + }, + ], + }, + Lesson { + number: 8, + slug: "08-records", + title: "Model your data with records", + goal: "Declare a record type and build a value of it.", + starter: L08.0, + solution: L08.1, + expect: Expect::Compiles, + requires: &[ + Rule { + pattern: "title: char", + message: "Book needs a field 'title: char'.", + }, + Rule { + pattern: "pages: int", + message: "Book needs a field 'pages: int'.", + }, + Rule { + pattern: "Book:{", + message: "Build 'dune' with the constructor syntax Book:{ ... }.", + }, + ], + forbids: &[NO_GAPS], + hints: &[ + "Fields of a type are written 'name: type', separated by commas.", + "Building a value uses '=' instead: Book:{ title = \"...\", pages = ... }", + ], + docs: &[ + DocLink { + title: "Records & Constructors", + path: "reference/records", + }, + DocLink { + title: "Model data with TypR types", + path: "tutorials/typed-data-modeling", + }, + ], + }, + Lesson { + number: 9, + slug: "09-methods", + title: "Functions on your types", + goal: "Write a function on a record and call it in method style.", + starter: L09.0, + solution: L09.1, + expect: Expect::Compiles, + requires: &[ + Rule { + pattern: "let is_long <- fn(", + message: "Define is_long with fn(...).", + }, + Rule { + pattern: "dune.is_long()", + message: "Call it in method style: dune.is_long()", + }, + ], + forbids: &[NO_GAPS], + hints: &[ + "The signature is fn(b: Book): bool, and the body compares b$pages with 300.", + "Then the call is: print(dune.is_long());", + ], + docs: &[ + DocLink { + title: "Functions", + path: "reference/functions", + }, + DocLink { + title: "Interfaces & Structural Validation", + path: "reference/interfaces", + }, + ], + }, + Lesson { + number: 10, + slug: "10-unions-match", + title: "Unions and pattern matching", + goal: "Handle every case of a union with an exhaustive match.", + starter: L10.0, + solution: L10.1, + expect: Expect::Compiles, + requires: &[ + Rule { + pattern: "| .Triangle(num)", + message: "Keep .Triangle(num) in Shape: the point is to handle it, not remove it.", + }, + Rule { + pattern: "match s", + message: "Keep the match in 'area'.", + }, + ], + forbids: &[], + hints: &[ + "Each arm has the form .Tag(name) => expression, with a comma at the end.", + "Add: .Triangle(side) => 0.433 * side * side,", + ], + docs: &[ + DocLink { + title: "Unions, Tags & Pattern Matching", + path: "reference/unions-patterns", + }, + DocLink { + title: "Control flow", + path: "reference/control-flow", + }, + ], + }, + Lesson { + number: 11, + slug: "11-generics", + title: "Generics: no more NULL surprises", + goal: "Write a generic function over Option.", + starter: L11.0, + solution: L11.1, + expect: Expect::Compiles, + requires: &[ + Rule { + pattern: "let unwrap_or <- fn(", + message: "Define unwrap_or with fn(...).", + }, + Rule { + pattern: "match", + message: "Use a match to look inside the Option.", + }, + Rule { + pattern: "let b: char <- unwrap_or(.None, \"empty\")", + message: "Keep both calls at the bottom unchanged: they test your function.", + }, + ], + forbids: &[NO_GAPS], + hints: &[ + "The signature: fn(o: Option, default: T): T", + "The body matches o: .Some(x) => x, and .None => the default.", + ], + docs: &[ + DocLink { + title: "Generics & Kind Sigils", + path: "concepts/generics-kind", + }, + DocLink { + title: "Type Constructors & Aliases", + path: "concepts/type-constructors", + }, + ], + }, + Lesson { + number: 12, + slug: "12-talk-to-r", + title: "Talk to R", + goal: "Type an existing R function with an @ signature.", + starter: L12.0, + solution: L12.1, + expect: Expect::Compiles, + requires: &[ + Rule { + pattern: "@strrep:", + message: "Declare strrep's signature with @strrep: (...) -> ...;", + }, + Rule { + pattern: "let line: char <- strrep(", + message: "Keep 'let line: char <- strrep(...)': the signature is what makes it compile.", + }, + ], + forbids: &[], + hints: &[ + "The shape is @name: (parameter types) -> return type;", + "strrep takes a char and an int, and returns a char.", + ], + docs: &[ + DocLink { + title: "Type existing R functions", + path: "howto/type-r-functions", + }, + DocLink { + title: "Signatures, @extern & Foreign", + path: "reference/signatures", + }, + DocLink { + title: "Use dplyr/tidyr from TypR", + path: "howto/use-dplyr-tidyr", + }, + DocLink { + title: "Escape Hatches", + path: "reference/escape-hatches", + }, + ], + }, + Lesson { + number: 13, + slug: "13-graduation", + title: "Your own program", + goal: "Combine a type, a typed function and a match in a program of your own.", + starter: L13.0, + solution: L13.1, + expect: Expect::Compiles, + requires: &[ + Rule { + pattern: "type ", + message: "Your program needs at least one 'type'.", + }, + Rule { + pattern: "fn(", + message: "Your program needs at least one typed function 'fn(...)'.", + }, + Rule { + pattern: "match ", + message: "Your program needs at least one 'match'.", + }, + ], + forbids: &[NO_GAPS], + hints: &[ + "Start small: a union type with three tags, then a function that matches on it.", + "Lesson 10's file is a good model: a union, a function with a match, one call.", + ], + docs: &[ + DocLink { + title: "Create your first TypR package", + path: "tutorials/first-package", + }, + DocLink { + title: "Migrate an existing R package", + path: "tutorials/migrate-r-package", + }, + DocLink { + title: "Connect an AI assistant via MCP", + path: "howto/mcp-server", + }, + DocLink { + title: "Reference", + path: "reference/intro", + }, + ], + }, +]; + +pub fn find(number: usize) -> Option<&'static Lesson> { + LESSONS.iter().find(|l| l.number == number) +} + +/// Remove comments: a `#` followed by whitespace, another `#`, a quote or +/// end of line starts one, outside string literals. A `#` followed by a +/// letter is a kind sigil (`#N`), not a comment. +fn strip_comments(source: &str) -> String { + let mut out = String::with_capacity(source.len()); + for line in source.lines() { + let chars: Vec = line.chars().collect(); + let mut quote: Option = None; + let mut end = chars.len(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + match quote { + Some(q) => { + if c == '\\' { + i += 1; + } else if c == q { + quote = None; + } + } + None => { + if c == '"' || c == '\'' { + quote = Some(c); + } else if c == '#' && chars.get(i + 1).map_or(true, |n| !n.is_alphanumeric()) { + end = i; + break; + } + } + } + i += 1; + } + out.extend(&chars[..end]); + out.push('\n'); + } + out +} + +fn squash(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace()).collect() +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct Explanation { + pub code: String, + pub title: String, + pub explanation: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +pub struct Grade { + pub lesson: usize, + pub passed: bool, + /// What the compiler said about the code (with stable T0xx/S0xx codes). + pub diagnostics: Vec, + /// Long explanations for the diagnostics' codes, when one exists. + pub explanations: Vec, + /// Why the lesson is not passed yet, in the tutor's words. + pub feedback: Vec, +} + +pub fn grade(lesson: &Lesson, source: &str) -> Grade { + let code = squash(&strip_comments(source)); + let mut feedback = Vec::new(); + + for rule in lesson.forbids { + if code.contains(&squash(rule.pattern)) { + feedback.push(rule.message.to_string()); + } + } + + let result = check_source_as(source, &lesson.file_name()); + let syntax_error = result.diagnostics.iter().any(|d| d.code.starts_with('S')); + match lesson.expect { + // A leftover '???' already explains the syntax error it causes. + Expect::Compiles if !result.ok && !(syntax_error && !feedback.is_empty()) => { + feedback.push(if syntax_error { + "The compiler cannot read the program yet (syntax error, S code): check the line it points at." + .to_string() + } else { + "The program does not type-check yet: read the compiler's diagnostics.".to_string() + }); + } + Expect::TypeError if result.ok => { + feedback.push( + "The compiler still accepts the program: add a call that passes a value of the wrong type.".to_string(), + ); + } + Expect::TypeError if syntax_error => { + feedback.push( + "That is a syntax error (S code): the compiler could not even read the line. Write a call \ + that is valid TypR, but with an argument of the wrong type." + .to_string(), + ); + } + _ => {} + } + + for rule in lesson.requires { + if !code.contains(&squash(rule.pattern)) { + feedback.push(rule.message.to_string()); + } + } + + let mut explanations: Vec = Vec::new(); + for d in &result.diagnostics { + if explanations.iter().any(|e| e.code == d.code) { + continue; + } + if let Some(e) = explain::find(&d.code) { + explanations.push(Explanation { + code: e.code.to_string(), + title: e.title.to_string(), + explanation: e.explanation.to_string(), + }); + } + } + + Grade { + lesson: lesson.number, + passed: feedback.is_empty(), + diagnostics: result.diagnostics, + explanations, + feedback, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lessons_are_numbered_one_to_n() { + for (i, lesson) in LESSONS.iter().enumerate() { + assert_eq!(lesson.number, i + 1, "{}", lesson.slug); + assert!(lesson.slug.starts_with(&format!("{:02}-", i + 1)), "{}", lesson.slug); + } + } + + #[test] + fn every_starter_announces_its_number() { + let total = LESSONS.len(); + for lesson in LESSONS { + let header = format!("Lesson {} of {total}", lesson.number); + assert!(lesson.starter.contains(&header), "{}: missing '{header}'", lesson.slug); + assert!( + lesson.starter.contains(lesson.title), + "{}: title not in header", + lesson.slug + ); + } + } + + #[test] + fn every_starter_fails_and_every_solution_passes() { + for lesson in LESSONS { + let starter = grade(lesson, lesson.starter); + assert!(!starter.passed, "{}: the untouched starter already passes", lesson.slug); + let solution = grade(lesson, lesson.solution); + assert!( + solution.passed, + "{}: the solution fails: {:?} {:?}", + lesson.slug, solution.feedback, solution.diagnostics + ); + } + } + + #[test] + fn every_lesson_has_hints_and_docs() { + for lesson in LESSONS { + assert!(!lesson.hints.is_empty(), "{}", lesson.slug); + assert!(!lesson.docs.is_empty(), "{}", lesson.slug); + } + } + + /// A dead link at the end of a lesson is a door that opens on a wall. + #[test] + fn every_doc_link_is_in_the_docs_index() { + for lesson in LESSONS { + for doc in lesson.docs { + let url = doc.url(); + assert!( + crate::DOCS_INDEX_MD.contains(&format!("| {url} |")), + "{}: {url} is not in data/docs-index.md", + lesson.slug + ); + } + } + } + + #[test] + fn comments_do_not_count_toward_rules() { + let lesson = find(6).unwrap(); + let g = grade( + lesson, + "# |> add( and ).add(\nlet add <- fn(a: int, b: int): int { a + b };", + ); + assert!(!g.passed); + assert_eq!(g.feedback.len(), 2, "{:?}", g.feedback); + } + + #[test] + fn strip_comments_keeps_hashes_in_strings_and_sigils() { + assert_eq!(strip_comments("print(\"#1\"); # note"), "print(\"#1\"); \n"); + assert_eq!(strip_comments("fn(x: #N) # c"), "fn(x: #N) \n"); + } + + #[test] + fn break_it_lesson_rejects_a_syntax_error_as_the_answer() { + let lesson = find(3).unwrap(); + let source = format!("{}\ntwice(;\n", lesson.starter); + let g = grade(lesson, &source); + assert!(!g.passed); + } + + #[test] + fn failing_grade_carries_explanations() { + let lesson = find(4).unwrap(); + let g = grade(lesson, lesson.starter); + assert!(!g.diagnostics.is_empty()); + assert!(!g.explanations.is_empty(), "{:?}", g.diagnostics); + } +} diff --git a/editors/vim/syntax/typr.vim b/editors/vim/syntax/typr.vim index edc89e9c..46063fd2 100644 --- a/editors/vim/syntax/typr.vim +++ b/editors/vim/syntax/typr.vim @@ -39,6 +39,7 @@ syn match typrOperatorsArrow "\v\-\>|\=\>" syn match typrTypesAlias "\v<[A-Z][A-Za-z0-9_]+>" syn match typrTypesGeneric "\v<[A-Z][A-Za-z0-9_]@!" syn match typrTypesSigilGeneric "\v[#%@^?$]%(Self|[A-Z])[A-Za-z0-9_]@!" +syn match typrVariablesHidden "\v%([A-Za-z0-9_.)\]])@" 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*[\[(])@=" @@ -81,6 +82,7 @@ hi def link typrTypesBuiltinIndexed Type hi def link typrTypesConstructor Function hi def link typrKeywordsBlock Keyword hi def link typrTypesVariant Identifier +hi def link typrVariablesHidden Identifier hi def link typrTypesSigilGeneric Type hi def link typrTypesGeneric Type hi def link typrTypesAlias Type diff --git a/editors/vscode/syntaxes/typr.tmLanguage.json b/editors/vscode/syntaxes/typr.tmLanguage.json index 0b882236..3a21ec85 100644 --- a/editors/vscode/syntaxes/typr.tmLanguage.json +++ b/editors/vscode/syntaxes/typr.tmLanguage.json @@ -69,6 +69,9 @@ { "include": "#types.variant" }, + { + "include": "#variables.hidden" + }, { "include": "#types.sigil-generic" }, @@ -503,8 +506,17 @@ } ] }, + "variables.hidden": { + "comment": "Hidden name, `.helper` — private by name, as in R (`parsing/elements.rs::variable_exp`). Lowercase after the dot, so a tag (`.Some`) never matches; the look-behind leaves the member of `x.f` to the access operator. Ahead of `operators.access`, which owns a lone `.`.", + "patterns": [ + { + "match": "(? +``` + +Autrement dit : **rendre visible le contrat entre les blocs du programme**. + +--- + +# 3. Mais le vrai problème R commence avec scalaire ≠ vecteur + +C'est l'un des résultats les plus intéressants de la recherche empirique. + +Dans R, `5` et `c(5, 6, 7)` ont le même `typeof`: `double`. Pourtant, certaines fonctions veulent véritablement une valeur unique. + +L'étude de Turcotte et al. trouve que **33,33 % des paramètres observés sont des scalaires** dans leur corpus. ([alexi turcotte][5]) + +Ils donnent un exemple très concret : + +```r +hankel.matrix <- function(n, x) { + if (n != trunc(n)) + stop("n is not an integer") + + if (!is.vector(x)) + stop("x is not a vector") + + if (length(x) < n) + stop("x is too short") + + ... +} +``` + +Ici, le développeur sait déjà quelque chose de beaucoup plus précis que : + +```text +n : numeric +x : vector +``` + +Il sait : + +```text +n : integer scalar +x : numeric vector +length(x) >= n +``` + +C'est exactement le genre d'information qu'un système de types R intéressant doit être capable d'exprimer. ([alexi turcotte][5]) + +--- + +# 4. Le cas encore plus R : les longueurs de vecteurs + +Le papier de 2019 prend un exemple particulièrement révélateur : + +```r +norm <- function(data) { + data / c(1, 2, 4) +} +``` + +Puis : + +```r +norm(c(5, 4, 8)) +``` + +fonctionne comme attendu, mais : + +```r +norm(c(5, 2)) +``` + +déclenche le **recycling** de R et produit implicitement une valeur supplémentaire. + +Les auteurs proposent alors plusieurs niveaux de typage possibles : + +```text +(double, double) -> double +(integer, integer) -> integer +``` + +ou quelque chose de plus précis : + +```text +double[2] × double[2] -> double[2] +double[n] × double[n] -> double[n] +``` + + + +C'est un point extrêmement important : **la taille n'est pas seulement une propriété de données ; dans R, elle peut changer la sémantique d'une opération.** + +--- + +# 5. Les data frames : probablement le besoin le plus spécifiquement “R” + +C'est là que la recherche devient particulièrement intéressante pour ton projet. + +Le papier de 2019 dit explicitement que les data frames sont au cœur des analyses R et qu'un type de data frame suffisamment riche pourrait vérifier : + +* les noms des colonnes ; +* le type de chaque colonne ; +* le nombre de lignes ; +* la compatibilité entre plusieurs data frames. + +L'étude empirique de 2020 confirme leur importance : parmi les classes les plus fréquentes dans les appels observés, les matrices représentent environ 12 %, les `data.frame` 7,5 %, les formules 2 %, les facteurs 2 % et les tibbles 2 %. Les classes représentent environ 31 % des types d'arguments observés. ([alexi turcotte][5]) + +Et l'étude montre un exemple beaucoup plus proche des vrais bugs R : + +```r +df <- data.frame(revenue = c(100, 200, 300)) + +vat <- df$reveneu * 0.21 +``` + +La faute de frappe `reveneu` produit `NULL`, puis le problème peut réapparaître beaucoup plus loin sous une forme comme un résultat numérique inattendu. Le système présenté en 2026 propose justement qu'un type de data frame connaissant la colonne `revenue` puisse **refuser `df$reveneu` immédiatement**. ([DRA event platform (Indico) (Indico)][10]) + +À mon avis, **c'est beaucoup plus représentatif du besoin R qu'un simple `integer` contre `character`.** + +--- + +# 6. Le cas `factor` montre que “type” signifie aussi sémantique + +Le poster 2026 donne cet exemple : + +```r +scores <- factor(c("1", "2", "3")) +wt <- c(0.2, 0.3, 0.5) + +weighted.mean(scores, wt) +``` + +R accepte l'appel jusqu'à produire : + +```text +Warning: '*' not meaningful for factors +NA +``` + +Le problème est que `scores` est stocké comme des codes entiers, mais **sémantiquement ce n'est pas un vecteur numérique ordinaire**. Le système proposé peut donc distinguer un `int` d'un entier classique et refuser l'appel à `weighted.mean`. ([DRA event platform (Indico) (Indico)][10]) + +C'est une autre leçon essentielle : + +> Les utilisateurs de R ne veulent pas seulement typer la représentation mémoire ; ils veulent typer **ce que les données signifient**. + +C'est exactement la raison pour laquelle S3, `Date`, `POSIXct`, `factor`, matrices, tibbles, etc. sont si importants. + +--- + +# 7. Les assertions montrent ce que les développeurs veulent vraiment exprimer + +C'est probablement la donnée la plus utile pour ta question. + +L'étude de 2020 regarde les `stopifnot()` et assertions réellement écrits dans les packages. + +Exemple : + +```r +stopifnot( + is.character(x), + length(x) == 1L, + !is.na(x) +) +``` + +Ce n'est donc pas simplement : + +```text +x : character +``` + +mais : + +```text +x : character + ∩ length(1) + ∩ non-NA +``` + +L'étude trouve 1 995 assertions dans 153 des 412 packages analysés. Leur système pouvait **complètement remplacer 50,4 %** des assertions et **simplifier 61,3 %** d'entre elles. À l'échelle de tout CRAN, ils comptent 32,3 milliers d'assertions dans 15,9 milliers de fonctions. ([alexi turcotte][5]) + +Checkmate arrive exactement au même endroit, mais avec une DSL d'assertions. Par exemple : + +```text +character scalar +factor de longueur ≥ 1 sans NA +integer de longueur 3 avec valeurs positives +numeric scalar dans [0,1] +numeric vector positif, fini et sans NA +``` + +([The R Journal][2]) + +Donc le besoin pratique peut être formulé ainsi : + +> **« Je veux pouvoir exprimer en une seule spécification ce que je suis actuellement obligé d'écrire avec `is.*`, `length()`, `is.na()`, des comparaisons et des validations personnalisées. »** + +C'est une conclusion très forte. + +--- + +# 8. Ce que l'étude de 2020 dit sur les types réellement utilisés + +Le corpus est particulièrement intéressant parce qu'il ne repose pas sur des opinions : les auteurs ont observé **25 215 fonctions dans 412 packages**. ([alexi turcotte][5]) + +Les catégories les plus fréquentes parmi les paramètres sont notamment : + +| Catégorie | Part approximative | +| ---------------------------- | -----------------: | +| scalaires | 33,3 % | +| classes | 23,1 % | +| vecteurs | 12,4 % | +| `...` | 8,7 % | +| `NULL` | 7,3 % | +| `any` | 7,2 % | +| listes | 3,4 % | +| vecteurs pouvant contenir NA | 2,8 % | + +([alexi turcotte][5]) + +Et **58 % des fonctions** étaient monomorphes selon leur modèle, tandis que 42 % avaient au moins un paramètre ou retour polymorphe. ([alexi turcotte][5]) + +Cela donne une image assez différente de « R est tellement dynamique qu'on ne peut pas le typer ». + +Une très grande quantité du code possède en réalité des **contrats suffisamment réguliers pour être décrits**. + +--- + +# 9. Le point où les travaux récents vont encore plus loin : typer les pipelines de données + +Le projet de D3S est particulièrement révélateur parce qu'il ne part plus du problème abstrait « ajouter des types à R ». + +Il part d'un vrai bout de code R : + +```r +tdec <- summarize( + group_by(titanic, decade = round(age / 10)), + count = sum(survived) +) + +ggplot(tdec, aes(x = decade, y = count)) + + geom_point() +``` + +Et pose exactement les questions qu'un développeur humain se pose : + +```text +titanic.age : numeric ? +titanic.survived : numeric/logical ? + +group_by(...) : quelles colonnes restent ? + +summarize(...) : quelles nouvelles colonnes sont produites ? + +tdec : possède-t-il decade et count ? + +aes(...) : x et y existent-ils ? + +geom_point() : quels champs exige-t-il ? +``` + +Le projet cherche précisément à typer ces transformations de **forme de data frame** et la grammaire de `ggplot2`. ([d3s.mff.cuni.cz][9]) + +À mon sens, c'est aujourd'hui **l'exemple le plus représentatif du “type system for R” spécifique au langage**, parce qu'il combine : + +```text +types des valeurs ++ +longueurs ++ +classes ++ +schéma de data frame ++ +transformation du schéma ++ +polymorphisme ++ +contexte d'évaluation +``` + +--- + +# 10. Et le travail 2026 confirme cette direction + +Le système présenté à useR! 2026 est maintenant fondé sur des **set-theoretic types** et supporte explicitement : + +```text +vecteurs +scalaires +listes +records +classes S3 +data frames +polymorphisme +fonctions génériques +``` + +Le poster donne par exemple : + +```text +df : { id : dbl, ... } +``` + +et peut accepter : + +```r +data.frame(id = 1L:3L) +``` + +mais refuser : + +```r +data.frame(id = c("a", "b", "c")) +``` + +parce que `id` devait être numérique. ([DRA event platform (Indico) (Indico)][10]) + +Il introduit aussi des fonctions comme : + +```text +nrow : vector | array | data.frame → integer +``` + +et : + +```text +df$col : data.frame containing col:'a → 'a +``` + +C'est une manière très intéressante de formaliser une chose que les développeurs R font déjà mentalement. ([DRA event platform (Indico) (Indico)][10]) + +--- + +# 11. Le problème du “type” est donc en réalité constitué de 5 couches + +Après avoir regroupé tout ça, je réduirais ce que les gens cherchent à : + +```text +1. Valeur + double, integer, character, logical... + +2. Cardinalité / forme + scalar, vector, matrix, length(n)... + +3. Valeur manquante + T vs T pouvant contenir NA + T vs NULL + +4. Structure / sémantique + factor, Date, POSIXct, data.frame, Model... + +5. Contrat relationnel + x et y ont le même type + output dépend du type de x + dataframe après transformation possède telles colonnes +``` + +C'est remarquable parce que cela converge presque exactement avec le poster 2026 : il présente explicitement le **storage type**, la **shape** et les **S3 classes** comme des informations que R cache actuellement au point d'utilisation. ([DRA event platform (Indico) (Indico)][10]) + +--- + +# 12. Quel est alors l'exemple le plus représentatif ? + +Je distinguerais deux réponses. + +### Le meilleur exemple de typage “général” + +Une fonction R avec un vrai contrat : + +```text +function( + x : numeric vector, + n : positive integer scalar +) -> numeric vector +``` + +avec éventuellement : + +```text +length(x) >= n +``` + +C'est représentatif parce qu'il correspond directement aux assertions que les développeurs écrivent déjà dans leurs fonctions. ([alexi turcotte][5]) + +### Le meilleur exemple de typage “R” + +Je choisirais plutôt **un pipeline de data frame** : + +```r +penguins + |> filter(...) + |> mutate(...) + |> summarise(...) + |> ggplot(aes(...)) +``` + +avec un système capable de savoir : + +```text +penguins + : { + species : factor, + body_mass_g : double, + ... + } + +filter(...) + : conserve le schéma + +mutate(...) + : ajoute/modifie des colonnes + +summarise(...) + : change le nombre de lignes + + produit un nouveau schéma + +ggplot(...) + : exige des colonnes existantes +``` + +C'est celui qui capture le mieux ce qui est **particulier à R**, plutôt que de simplement reproduire TypeScript/Rust/Haskell. + +--- + +# 13. Et pour TypR, je pense que l'exemple encore plus fort serait celui-ci + +En combinant les résultats de toutes ces sources : + +```text +type Customer = { + id: int, + name: chr, + revenue: dbl +} + +load_customers : path → DataFrame + +normalize : + DataFrame<{ revenue: dbl, ... }> + → DataFrame<{ revenue: dbl, ... }> + +average_revenue : + DataFrame<{ revenue: dbl, ... }> + → dbl1 +``` + +Puis : + +```text +customers + |> load_customers + |> normalize + |> average_revenue +``` + +Et le système pourrait détecter : + +```text +df$reveneu +``` + +ou : + +```text +average_revenue(df_with_revenue = character) +``` + +ou encore : + +```text +mean(revenue) +``` + +lorsque `revenue` n'existe pas dans le schéma. + +Cela combine **les trois motivations qui reviennent le plus souvent** : + +> **contrats de fonctions + structure des données + prévention des erreurs dans les pipelines.** + +Et c'est beaucoup plus proche de ce que les travaux R récents essaient effectivement de résoudre que l'image classique « R mais avec `int`, `bool`, `string` ». + +Un dernier élément renforce cette interprétation : vctrs affirme explicitement que lorsqu'un développeur ne peut pas prédire le **type et la taille** des variables simplement en lisant le code, cela rend le code difficile à raisonner sur papier. Leur notion de type/size stability est justement née de ce problème de lecture du code. ([vctrs.r-lib.org][3]) + +### Ma synthèse + +Je formulerais donc le besoin utilisateur comme : + +> **Les développeurs R ne demandent pas principalement de pouvoir déclarer des types. Ils demandent de pouvoir déclarer et vérifier les invariants que R laisse actuellement implicites : le type et la cardinalité des valeurs, leur structure sémantique, le schéma des data frames et les relations entre les entrées et sorties des fonctions.** + +C'est, à mon avis, une formulation beaucoup plus solide pour positionner TypR. + +Les sources primaires les plus importantes que j'ai trouvées sont l'étude empirique OOPSLA 2020, le travail fondateur de 2019, le projet D3S actuel et le poster useR! 2026. ([alexi turcotte][5]) + +[1]: https://stat.ethz.ch/pipermail/r-devel/2013-August/067331.html?utm_source=chatgpt.com "[Rd] Type annotations for R function parameters." +[2]: https://journal.r-project.org/articles/RJ-2017-028/?utm_source=chatgpt.com "The R Journal: checkmate: Fast Argument Checks for Defensive R Programming" +[3]: https://vctrs.r-lib.org/articles/stability.html?utm_source=chatgpt.com "Type and size stability • vctrs" +[4]: https://stackoverflow.com/questions/58781871/r-programming-is-it-possible-to-declare-return-or-parameter-types-of-functions?utm_source=chatgpt.com "R programming: Is it possible to declare return or parameter types of functions? - Stack Overflow" +[5]: https://reallytg.github.io/files/papers/types-for-r-oopsla20-final.pdf "Designing Types for R, Empirically" +[6]: https://stat.ethz.ch/pipermail/r-devel/2025-September/084164.html?utm_source=chatgpt.com "[Rd] Declaring Types at Function Declaration" +[7]: https://stat.ethz.ch/pipermail/r-devel/2025-November/084223.html?utm_source=chatgpt.com "[Rd] Declaring Types at Function Declaration" +[8]: https://events.digital-research.academy/event/109/contributions/482/ "useR! 2026 (6-July 9, 2026): A Type System for the R Language · DRA event platform (Indico)" +[9]: https://d3s.mff.cuni.cz/projects/primus24/?utm_source=chatgpt.com "Type systems for data-centric programming | D3S" +[10]: https://events.digital-research.academy/event/109/contributions/482/attachments/103/321/A%20Type%20System%20for%20the%20R%20Language.pdf "A Type System for the R Language" diff --git a/src/main.rs b/src/main.rs index 1bdd7323..ce6ae162 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,5 +4,6 @@ //! All CLI functionality is provided by the typr-cli crate. fn main() { + // `start` start the cli with `typr ...` typr_cli::start() } diff --git a/syntaxe.md b/syntaxe.md new file mode 100644 index 00000000..f4f6a973 --- /dev/null +++ b/syntaxe.md @@ -0,0 +1,738 @@ +# TypR — carte de la syntaxe + +> Référence non officielle, établie par lecture directe du parseur (`crates/typr-core/src/processes/parsing/`) +> — reflète l'état du code à la date de génération, pas une spécification figée. +> +> Sources principales : `parsing/mod.rs`, `parsing/elements.rs`, `parsing/types.rs`, +> `components/language/operators.rs`, `parsing/indexation.rs`, `components/language/mod.rs`. + +## Sommaire + +1. [Lexique & littéraux](#1--lexique--littéraux) +2. [Liaisons & mutation](#2--liaisons--mutation) +3. [Système de types](#3--système-de-types) +4. [Alias, opaque, typeconstructor, signatures](#4--alias-opaque-typeconstructor-signatures) +5. [Fonctions](#5--fonctions) +6. [Records & constructeurs](#6--records--constructeurs) +7. [Unions, tags & `match`](#7--unions-tags--match) +8. [Opérateurs & précédence](#8--opérateurs--précédence) +9. [Contrôle de flux](#9--contrôle-de-flux) +10. [Modules & imports](#10--modules--imports) +11. [Interfaces & validation structurelle](#11--interfaces--validation-structurelle) +12. [Échappatoires vers du code brut](#12--échappatoires-vers-du-code-brut) +13. [TypR vs R — ce qui change vraiment](#13--typr-vs-r--ce-qui-change-vraiment) +14. [Ambiguïtés & pièges connus du parseur](#14--ambiguïtés--pièges-connus-du-parseur) + +--- + +## 1 · Lexique & littéraux + +Chaque instruction de haut niveau se termine par `;`. L'oubli est toléré par le parseur mais +signalé (`SyntaxError::ForgottenSemicolon`) — sauf pour la toute dernière expression d'un bloc, +qui joue le rôle de valeur de retour implicite (comme en R). + +| Littéral | Syntaxe | Remarque | +|---|---|---| +| Entier | `42`, `-7` | `Lang::Integer` | +| Nombre | `3.14`, `-0.5` | un point décimal est obligatoire pour `Number`, sinon c'est un `Integer` | +| Chaîne | `"texte"` ou `'texte'` | guillemets simples et doubles interchangeables ; échappe `\" \' \\ \n \t` | +| Booléen | `true` / `TRUE`, `false` / `FALSE` | forme minuscule *et* majuscule acceptées | +| Nul | `null` / `NULL` | `Lang::Null` — distinct de `NA` | +| Manquant | `na` / `NA` | `Lang::NA` — distinct de `null` | +| Identifiant variable | `mon_nom` | doit commencer par `a-z_` ; `snake_case` attendu ; `__` → `.` en R (`as__x` → `as.x`), pour les variables, fonctions, membres de module et champs de record | +| Identifiant caché | `.cache`, `__cache` | `.` puis `a-z_` ; privé par le nom (jamais `@pub`/`@export`, S020) — `__` en tête ≡ `.` (même liaison) | +| Identifiant type | `MonType` | `PascalCase` obligatoire pour `type`/`opaque`/alias | +| Identifiant cité | `` `+` ``, `` `nom bizarre` `` | backticks — utile pour nommer un opérateur custom | +| Commentaire | `# commentaire` | `//` n'existe pas — un fichier `.ty` avec `//` échoue silencieusement (piège connu, voir §14) | + +Le parseur distingue explicitement `let NomPascal <- ...` (erreur `LetInsteadOfType`) de +`type nom_snake <- ...` (erreur `TypeInsteadOfLet`) — la casse du premier identifiant n'est donc +pas un simple style, elle est vérifiée et corrigée avec un message dédié. + +--- + +## 2 · Liaisons & mutation + +### Déclaration `let` + +```typr +let x <- 42; +let y: int <- 5; +@pub let z <- "hello"; # exportée : publique + testable +@testable let cache <- state(0); # privée, visible en build --test via M$.test_cache +@export let api <- fn(x: int): int { x }; # @pub + #' @export roxygen2 +``` + +`<-` et `=` sont interchangeables comme opérateur d'affectation dans `let`. Un seul `=` n'est en +revanche **jamais** un opérateur infixe binaire dans une expression — il est réservé aux champs +nommés (`x = 1`), à l'affectation top-level et aux valeurs par défaut de paramètres. + +### Déstructuration de tuple + +```typr +let :{a, b, c} <- :{1, 2, 3}; +let :{a, _, c} <- :{1, 2, 3}; # wildcard : ignore l'élément +``` + +Désucré en une variable temporaire + accès positionnel par point (`__tuple_tmp__.1`, `.2`, ...). + +### Affectation & mutation + +```typr +x <- 10; # Assign sur une variable déjà liée +x <- x + 1; + +# sucre "mutation implicite" : x!; réaffecte x au résultat de l'expression +x |> f() |> g()!; # ≡ x <- x |> f() |> g(); +obj.method()!; # ≡ obj <- obj.method(); +``` + +`expr!;` exige que la tête de la chaîne `.`/`|>` soit une variable assignable ; `3!;` par exemple +est rejeté au parsing. + +--- + +## 3 · Système de types + +### Primitifs + +`int` · `num` · `bool` · `char` · `null` · `na` · `Any` · `Empty` · `Self` + +Les littéraux peuvent aussi apparaître *comme type* (types singleton) : `3`, `3.14`, `true`, +`"chat"` sont des types valides, plus précis que `int`/`num`/`bool`/`char`. + +### Records (`list{...}`) + +```typr +type Point <- list { x: int, y: int }; +type Config <- record { name: char, timeout: int }; # synonyme explicite +``` + +### Tuples + +```typr +tuple{int, char} # explicite +Tuple[int, char] # notation crochets +Tuple[T..., U] # variadique : T... capture une séquence de types +``` + +### Tableaux, vecteurs, dataframes + +| Forme | Exemple | Sens | +|---|---|---| +| Array (S3, court) | `[int]` | tableau d'entiers, taille libre (`Any`) | +| Array (S3, complet) | `[#N, int]` | taille indexée par le générique `#N` | +| `Array[...]` | `Array[3, int]` | variante nommée, taille fixe = 3 | +| `Vec[...]` | `Vec[num]`, `Vec[#N, num]` | vecteur R natif | +| `dataframe[...]{...}` | `df[#N]{ name: char, age: int }` | `df` = alias court de `dataframe` ; colonne : `t.age` ≡ `t$age` → `[#N, int]` | +| Record générique nommé | `Tibble[3]{ id: int, active: bool }` | nécessite une déclaration `typeconstructor` préalable | + +Règle d'or du parseur : un bloc `{ ... }` qui suit fait toujours de la construction un *record* — +`Tibble[3]` seul (sans accolades) est un simple alias paramétré, `Tibble[3]{ ... }` est un record +constructeur. + +### Génériques & sigils de « kind » + +```typr +let id <- fn(x: T): T { x }; # T majuscule = générique libre +#N # générique "indice" (dimension de tableau) +$T # générique "label" (nom de champ) +%R # générique contraint : doit être un Record +@I # générique contraint : doit être une Interface +^S # générique contraint : doit être un char +?B # générique contraint : doit être un bool +``` + +### Unions & intersections + +```typr +type Shape <- .Circle(num) | .Square(num); # union par tags +type Combined <- Movable & Drawable; # intersection d'interfaces +``` + +### Fonctions & interfaces (en position de type) + +```typr +(int, char) -> bool # type fonction anonyme +(a: int, b: int) -> int # noms de paramètres optionnels, ignorés au typage +interface { view: (Self) -> char } # capacité structurelle +``` + +Écrire `fn(a: int) -> int` en *position de type* (au lieu de `(int) -> int`) est capté +explicitement et renvoie `SyntaxError::FunctionTypeSyntax` — `fn(...)` n'existe qu'au niveau des +expressions, jamais des types. + +### Arithmétique de types & conditions + +```typr +type Combined <- A + B; # Type::Operator sur des index/dimensions +T if T1 in T2 # type conditionnel (raffinement expérimental) +``` + +--- + +## 4 · Alias, opaque, typeconstructor, signatures + +```typr +type Meters <- int; # alias transparent +opaque Meters <- int; # alias opaque : le sous-jacent est caché du typage externe +type Option = .Some(T) | .None; # alias générique — , pas [T] +opaque Factor <- int; # paramètre "fantôme" : L n'apparaît que dans de futures @signature + +typeconstructor Tibble[N] record; # enregistre un constructeur générique de record +typeconstructor Matrix[N, M, T] recursive; +``` + +#### Signatures (déclaration de type sans corps) + +```typr +@map: (a: [#N, T], f: (T) -> U) -> [#N, U]; # générique +@add: (a: int, b: int) -> int; # surcharge : répéter @add avec d'autres types +@add: (a: num, b: num) -> num; +@as__character: (Self) -> char; # "__" → "." en sortie R (as.character) + +@extern stats::sd: (x: [Any, num]) -> num; # fonction R externe, appel réel package::fn +@extern base::readRDS: (path: char) -> Foreign; # nom nu = fonction déjà visible sans préfixe pkg:: +@importFrom dplyr filter select mutate; # hisse un @importFrom roxygen2 +``` + +Une signature `.ty` peut contenir un vrai corps `let nom <- fn(...){...}` et être type-checkée +avec succès — mais ce corps est **silencieusement jeté** : seule la paire `(nom, type)` survit. +Le seul patron qui marche pour la stdlib est signature seule + implémentation R écrite à la main +dans `std.R`. + +#### `Foreign` — valeurs R externes opaques + +`opaque Foreign <- Any;` (`configs/std/foreign.ty`) est le type compagnon idiomatique de +`@extern` pour *nommer* une valeur R qui existe déjà à l'extérieur (objet S3/S4/RC/R6, package +tiers) sans jamais la construire depuis TypR : + +```typr +type LmModel <- Foreign; +@extern base::readRDS: (path: char) -> LmModel; +@extern stats::coef: (m: LmModel) -> Foreign; # accesseur dédié pour toucher au contenu + +let m: LmModel <- readRDS("modele.rds"); +``` + +La valeur traverse `let` annoté, arguments et retours de fonction **sans jamais être touchée** +(pas de `as.X()`/`struct()` appliqué). Deux limites volontaires à connaître : `m.champ`/`m$champ` +ne type-check **jamais** (`Any` n'a aucun champ structurel connu — il faut un accesseur `@extern` +dédié pour chaque champ/slot/méthode) ; et un appel `@extern` est **positionnel uniquement**, donc +impossible d'appeler directement une fonction R qui exige des arguments nommés (`new("X", x=1)`). +Détail complet — mécanisme, quatre bugs trouvés et corrigés, grille de conformité — dans +`CLAUDE.md` § "Foreign R Values" et `interop_matrix.md` (racine du repo). + +--- + +## 5 · Fonctions + +```typr +let add <- fn(a: int, b: int): int { a + b }; +let greet <- fn(name: char, greeting: char = "Hello"): char { greeting }; # défaut, param final seulement +greet("World"); # "Hello" +greet("World", "Hi"); # "Hi" + +let sum_all <- fn(...xs: int): int { /* ... */ }; # variadique + +let sq <- \(x) x * x; # lambda non typée +let add5 <- \add(a = 5); # application partielle d'une fonction +let origin <- \Point:{ x = 0, y = 0 }; # application partielle d'un constructeur de record +``` + +`fn(...)` exige toujours `: Type` de retour — l'omettre panique explicitement ("You forgot to +specify the function return type"). `\(...)` (lambda) n'a en revanche ni type de paramètres ni +type de retour déclarés. + +**Application partielle vs lambda** : les deux utilisent `\`, désambiguïsés par ce qui suit — +`\(` → lambda, `\identifiant(` → application partielle. `PartialApp` se désucre en +`Lang::Function` pendant le typage — il n'atteint jamais la transpilation. + +--- + +## 6 · Records & constructeurs + +```typr +let p <- :{ x = 1, y = 2 }; # record anonyme (nécessite un contexte de type) +let p <- Point:{ x = 1, y = 2 }; # constructeur explicite → transpile en Point(x=1, y=2) +let q <- Point:{ ...p, y = 9 }; # spread "runtime" : fusion structurelle, override après +let r <- mod$Point:{ x = 1, y = 2 }; # constructeur qualifié par chemin de module + +let arr <- IntBox:[1, 2, 3]; # ArrayConstructorCall +``` + +Autres écritures équivalentes du littéral record : `record{...}`, `object{...}`, `list{...}`, +`:{...}` — la forme est choisie par la *forme* des champs, pas par le mot-clé : des champs +`nom = valeur` donnent un record (`Lang::List`), des valeurs positionnelles donnent un tuple +(`Lang::Tuple`) même avec `list{1, 2, 3}`. + +#### Spread — deux mécanismes distincts + +```typr +Point:{ ..source } # spread "statique" nominal — un seul par appel +Point:{ ...source } # spread "runtime" structurel — un seul dans un constructeur, plusieurs dans un record littéral +:{ ...a, ...b, z = 1 } # record littéral : plusieurs spreads runtime autorisés, fusionnés dans l'ordre puis overridés +``` + +#### Named type embedding + +```typr +type Widget <- list { embed coords: Position, label: char }; +``` + +`embed` est un mot-clé « doux » — reconnu seulement devant `nom: Type` avec espace derrière, donc +un champ réellement nommé `embed` reste parsable. + +--- + +## 7 · Unions, tags & `match` + +```typr +type Shape <- .Circle(num) | .Square(num); +let s <- .Circle(3.14); +let n <- .None; # tag sans valeur + +match s { + .Circle(r) => r * 2.0, + .Square(side) => side, +} +``` + +#### Motifs de `match` disponibles + +| Motif | Exemple | +|---|---| +| Tag avec binding | `.Some(a) => a` | +| Tag sans binding | `.None => 0` | +| Motif de type | `x as int => x + 1` | +| Motif record | `:{nom: n, age: a} => a` ou `list(nom = n, age = a) => a` | +| Motif tuple | `:{a, b} => a` | +| Joker | `_ => défaut` | +| Variable simple | `v => v` | + +#### Constructeur d'union qualifié + +```typr +type Color <- .Red | .Blue; +Color.Red # référence qualifiée à un tag sans charge utile (bare, sans `:{...}`) + +type Rgb <- list { r: int, g: int, b: int }; +type Palette <- .Red | .Blue | Rgb; +Palette.Rgb:{ r = 10, g = 20, b = 30 } # Rgb est un alias record utilisé tel quel comme membre + # de l'union (pas un tag `.Rgb(...)`) — son constructeur + # généré accepte bien des champs nommés. +``` + +**Piège** (audit_type_checking.md U1, corrigé) : la syntaxe `Union.Variant:{ field = val }` / +`TagName:{ field = val }` ne fonctionne **que** quand `Variant` est un alias record utilisé +directement comme membre de l'union (cas `Rgb` ci-dessus) — jamais pour un vrai tag `.Variant(...)` +(scalaire ou à corps record), qu'on l'écrive avec ou sans `:{...}`. Le constructeur généré pour un +tag prend toujours **une seule charge utile positionnelle** (`Variant <- function(x) {...}`), jamais +des champs nommés séparés. Pour construire un tag, utiliser `.Variant(valeur)` — et +`.Variant(:{ ... })` quand la charge utile est elle-même un record. Rejeté à la vérification de +type (`TagFieldConstructorNotSupported`) plutôt que de compiler vers du R qui plante. + +--- + +## 8 · Opérateurs & précédence + +Précédence réelle (la plus élevée se regroupe en premier) — notez que l'accès membre / pipe se +lie *plus fort* que l'arithmétique, contrairement à la plupart des langages. + +| Rang | Opérateurs | Rôle | +|---|---|---| +| 4 (fort) | `.` `\|>` `$` `::` `as!` `in` | accès membre / UFCS, pipe, cast validant | +| 3 | `*` `/` `%` `@` | multiplicatif, produit matriciel | +| 2 | `+` `-` | additif | +| 1 (faible) | `== != < > <= >=`, `and/&&/&`, `or/\|\|/\|`, `%op%` | comparaison, logique, opérateurs custom | + +### UFCS — `.` et `|>` + +```typr +x.f(y) # ≡ f(x, y) — appel de méthode uniforme +x |> f() |> g() # pipe — même désucrage +t.1 # accès positionnel d'un tuple (index 1-based) +mod$member # accès champ de record / module — "::" est un alias historique de "$" +``` + +```typr +# TypR +data |> filter(x > 0) |> mean() +``` +```r +# R +data |> filter(x > 0) |> mean() +# ou via magrittr : data %>% filter(...) %>% mean() +``` + +### Cast validant + +```typr +x as! Point # appelle validate_Point(x) à l'exécution +xs as! [Any, int] # cast vers un type structurel inline (pas un alias) +``` + +### Intervalles + +```typr +1:10 # ≡ seq(1, 10, 1) +1:2:10 # ≡ seq(1, 10, 2) — pas explicite au milieu +``` + +Les variantes doublées (`++ -- ** // %% @@ .. $$ |>>`) et `@`/`@@`/`=` en infixe ont été retirées +du tokenizer (`components/language/operators.rs::op()`/`pipe_op()`) — aucune n'avait de bras de +typage/transpilation ni de signature stdlib `` `op` `` pour les porter, contrairement à +`+ - * / % && || ...` qui retombent réellement sur un appel de fonction stdlib. Les laisser +tokeniser silencieusement un typo (ex. un commentaire `//` de style C, TypR n'utilisant que `#`) +en un opérateur "valide" ne faisait que déplacer l'erreur vers un message confus, loin du vrai +problème ; voir §14. `@` en tête de ligne appartient de toute façon aux annotations (`@pub`, +`@extern`...), pas aux opérateurs. + +--- + +## 9 · Contrôle de flux + +```typr +if (x > 0) { "positif" } else if (x < 0) { "négatif" } else { "zéro" } + +for (item in items) { print(item); }; +while (n > 0) { n <- n - 1; }; +loop { /* ... */ break; }; + +next; # continue de boucle +break; +return valeur; +``` + +Toute forme itérable dotée d'une fonction `as_vec: (Self) -> [T]` peut être utilisée dans un +`for (... in ...)`, pas seulement les tableaux. + +--- + +## 10 · Modules & imports + +```typr +module Math { + let pi <- 3.14159; + @pub let pi_approx <- 3.14; + @pub opaque Radians <- num; +}; + +use Math::pi_approx; +use Math::{pi_approx, sin as s}; +use Math::*; + +import Math; +import Math as M; + +mod Utils; # forme d'import historique équivalente +library(dplyr); # dépendance R classique +use("dplyr", c("filter", "select")); # adaptateur legacy +``` + +Un module compile vers un environnement R ; les membres non `@pub` restent invisibles depuis +l'extérieur (sauf en build `--test` via `@testable`, exposés en `M$.test_nom`). + +Noms cachés : un `.` en tête (`.cache`, `.helper`, champ `.secret`) rend un nom privé *par sa +syntaxe*, comme en R — émis tel quel (`ls()` et l'`exportPattern("^[^\\.]")` par défaut d'un +package l'ignorent), son générique S3 ne reçoit pas de `#' @export`. `@pub`/`@export` sur un nom +caché est une erreur (S020) ; `@testable` reste permis. `__nom` est l'orthographe ASCII de `.nom` +(même liaison), obligatoire juste après l'opérateur `.` : `x..helper()` se lirait comme le +spread `..` et est refusé (S019) — écrire `x.__helper()` ou `x |> .helper()`. + +--- + +## 11 · Interfaces & validation structurelle + +```typr +type Movable <- interface { mv: (Self, int, int) -> Self }; + +let p <- Point:{ x = 1, y = 2 }; +let q <- Movable(p); # validateur compile-time — transpile en `q <- p`, jamais un appel réel +``` + +Une interface décrit une **capacité structurelle** : n'importe quel type dont les fonctions +libres ont ce type comme premier paramètre « l'implémente », sans mot-clé `impl`. Appeler `I(x)` +où `I` est un alias d'interface n'est donc jamais un vrai appel de fonction (les alias vivent dans +un espace de noms séparé des variables) — c'est un contrôle de compatibilité à la compilation qui +échoue avec `InterfaceNotSatisfied` / `IncompatibleInterfaceMethod`. + +--- + +## 12 · Échappatoires vers du code brut + +```typr +extern (x: int, y: char) -> char r#"paste0(x, y)"#; # corps R brut, typé en entrée/sortie + +function(x, y) { x + y } # fonction R brute non typée (RFunction), corps capturé tel quel + +R { # bloc R brut non typé (RBlock) : valeur immédiate, pas un appel + df %>% + dplyr::filter(x > 1) %>% + dplyr::mutate(z = y + 1) +} + +JS { /* ... */ } # bloc JavaScript brut (cible JS) + +Class("data.frame", "tbl") # type dénotant une classe R existante (RClass) + +@{ 1 + x * 2 }@ # bloc vectoriel : re-parsé comme une séquence d'éléments TypR + # (littéraux/appels/variables) — pas du R arbitraire, contrairement à `R { ... }` +``` + +Ce sont les points de sortie délibérés du système de types : `extern` garde une signature +vérifiée par TypR autour d'un corps R opaque ; `function(...)` / `R { ... }` / `JS { ... }` n'ont +aucune vérification du tout. `R { ... }` capture son corps tel quel, accolades équilibrées (comme +`function(...)`), et transpile en un simple bloc R `{ ... }` — qui s'évalue déjà à la valeur de sa +dernière instruction, donc aucun wrapper ni appel n'est émis. C'est la forme à privilégier pour du +R idiomatique difficilement typable (pipes `%>%`/`|>`, NSE dplyr, formules `~`, ...) qu'on veut +juste utiliser pour produire une valeur, sans se soucier du système de types — contrairement à +`@{ ... }@`, dont le contenu doit rester une séquence d'éléments TypR valides. + +--- + +## 13 · TypR vs R — ce qui change vraiment + +| Aspect | R classique | TypR | +|---|---|---| +| Fin d'instruction | saut de ligne suffit | `;` attendu (sinon avertissement de parsing) | +| Typage | dynamique, aucune annotation | statique ; `fn(...)` exige toujours un type de retour | +| Casse des noms | convention libre | vérifiée par le parseur : `snake_case` pour `let`, `PascalCase` pour `type`/alias | +| Listes | `list(a = 1, b = 2)`, sans validation | `list{a=1,b=2}` génère un vrai constructeur + validateur (`as.T`, `validate_T`) | +| Sommes de types | pas de type natif — conventions `class` ad hoc | tags `.A(T) \| .B` + `match` exhaustif | +| Appel de méthode | `UseMethod`/S3, ou `$` sur les objets R6/environnement | UFCS générale : `x.f(y)` ≡ `f(x, y)` pour *tout* type | +| Interfaces | aucune notion formelle | `interface { ... }` + validateur structurel `I(x)` à la compilation | +| Pattern matching | `switch()`, peu typé | `match` avec motifs tag/type/record/tuple/joker | +| Modules | packages / `local()` / environnements ad hoc | `module M { ... }` → environnement R, visibilité `@pub` explicite | +| Génériques | inexistants (dispatch S3 dynamique) | paramètres de type `T`, sigils de kind (`%R @I ^S ?B #N`) | +| Mutation | `x <- f(x)` explicite | sucre `expr!;` équivalent, plus `State` pour la mutation partagée réelle | +| Sortie | du R directement exécuté | compile vers du R idiomatique (`R/*.R`) — 100% du R généré reste lisible et exécutable tel quel | + +> **À retenir** : TypR n'est pas un nouveau runtime — c'est une couche de vérification statique et +> de sucre syntaxique (UFCS, tags, match, interfaces, modules, générique) qui se désucre +> entièrement en R conventionnel avant exécution. Rien de tout ceci n'existe à l'exécution R ; +> tout est résolu par le compilateur TypR. + +--- + +## 14 · Ambiguïtés & pièges connus du parseur + +Le parseur est fait de dizaines de petits combinators `nom` assemblés via `alt()`, sans grammaire +déclarative unique — la plupart des pièges viennent d'un ordre d'essai particulier, ou d'un +tokenizer générique réutilisé dans deux contextes différents. Tout ce qui suit est vérifié +directement dans le code source, pas déduit. + +### Instruction sans `;` avalant la suivante — *corrigé* + +`elements()` assemblait autrefois ses tokens via +`many1(alt((as_excl_operator_token, single_element_token, element_operator_token)))`, sans +imposer l'alternance `Expression (Opérateur Expression)*`. Deux `single_element_token` +consécutifs — donc deux instructions collées faute de `;` — étaient acceptés dans le même vecteur +de tokens sans erreur. `variable_exp` n'excluant aucun mot-clé, la fonction/l'identifiant de +l'instruction suivante finissait comme jeton « expression » surnuméraire, silencieusement absorbé +dans le corps de la première instruction. La seconde instruction disparaissait purement et +simplement de l'AST, sans le moindre message d'erreur. + +```typr +let add_object <- fn(self: Scene, object: Object): Scene { + Scene:{ objects: self.objects.extend(object), ...self } +} // <- ';' manquant ici + +@pub +let print <- fn(self: Scene): Empty { /* ... */ }; // avalé, invisible dans l'AST +``` + +Fixé (`cases/0012`) en remplaçant le tokenizer par +`single_element_token, many0(pair(operator_like_token, single_element_token))` : un jeton +non-opérateur après une expression complète arrête désormais `many0` *sans consommer d'entrée*, +laissant l'instruction suivante au parseur appelant plutôt que de l'avaler. L'oubli d'un `;` +redevient une simple `ForgottenSemicolon` récupérable, pas une perte silencieuse de code. + +### `@` en tête de ligne juste après une instruction non terminée — *corrigé* + +Conséquence directe du piège précédent : le tokenizer générique d'opérateurs reconnaissait aussi +`@`/`@@` comme `Op::At`/`Op::At2` — un opérateur binaire qui n'a jamais eu le moindre bras de +typage ou de transpilation (syntaxe totalement morte à l'exécution). Une instruction sans `;` +suivie d'une déclaration `@pub`/`@export`/`@testable` se retrouvait donc avec son `@` avalé comme +« continuation d'opérateur » du corps précédent, corrompant la première instruction *et* +dépouillant la seconde de son annotation de visibilité. + +Fixé en retirant `@`/`@@` de la liste d'opérateurs infixes génériques +(`components/language/operators.rs::op()`). `@` reste pleinement actif, mais uniquement comme +*préfixe* d'annotation (`@pub`, `@export`, `@testable`, `@extern`, `@nom: Type;`), reconnu par des +parseurs dédiés essayés avant la grammaire d'expression générale. + +### Opérateurs morts du tokenizer — *purgés* + +Suite directe du point précédent : `@`/`@@` n'étaient pas les seuls jetons reconnus par `op()`/ +`pipe_op()` sans avoir le moindre bras de typage/transpilation ni de signature stdlib `` `op` `` +pour les porter. `++ -- ** // %% .. $$ |>>` ainsi qu'un `=` infixe (`Op::Eq2`, déjà exclu de +`bool_op()`) étaient dans le même cas — tombant sur le bras générique de `Lang::Operator` qui +désucre tout opérateur non géré explicitement en appel de `` `op`(e1, e2) ``, sans jamais trouver +de définition correspondante. Le risque concret : un typo courant (`//` pour un commentaire +C-style, alors que TypR n'utilise que `#`) parsait *avec succès* comme une division, et n'échouait +qu'au typage, avec un message éloigné du vrai problème (« Function `` `//` `` not defined »). + +Purgé en retirant ces jetons de `op()`/`pipe_op()` et les variantes `Op` correspondantes +(`Add2/Minus2/Mul2/Div2/Modulo2/Dot2/Dollar2/Pipe2/At/At2/Eq2`) entièrement de l'énum — elles +n'étaient produites nulle part ailleurs dans la base de code une fois le tokenizer purgé. + +Un `//` égaré échoue désormais dès le parsing au lieu de produire un opérateur fantôme — mais pas +comme une erreur brute : un parseur dédié `wrong_comment` (`parsing/mod.rs`, à côté de `comment()`) +le reconnaît explicitement, le traite exactement comme un vrai commentaire `#` (consommé jusqu'à +fin de ligne, ignoré, non fatal) et pousse un `SyntaxError::WrongCommentSyntax` récupérable. Sans +ce traitement dédié, `//` serait retombé sur le piège générique décrit juste en dessous (texte de +fin d'instruction imparsable = **silencieusement tronqué de l'AST**, pas juste mal signalé) — un +typo `//` est un cas assez courant (habitude venue d'autres langages) pour mériter mieux qu'un +`SyntaxError::UnknownElement` générique. + +> ⚠️ **Tentative avortée pour `=` en position de comparaison** (`if (a = b)`) : un plan initial +> prévoyait la même récupération dédiée qu'au-dessus pour `//`, en repêchant un `=` isolé dans +> `op()` lui-même (retomber sur `Op::Eq`, avec un `SyntaxError::SingleEqualsComparison` récupérable). +> Abandonné : `op()` est aussi appelé par la grammaire de *type* (`types.rs::index_operator`/ +> `compute_operators`, pour l'arithmétique de types `type Combined <- A + B;`), qui se trouve juste +> avant le séparateur `= valeur` d'un paramètre par défaut (`greeting: char = "Hello"`). Là, `=` +> n'a aucun sens d'opérateur — le repêcher en `Op::Eq` faisait paniquer `compute_operators` sur une +> combinaison non gérée (`compute_operators`'s `_ => panic!()`, testé via +> `test_non_trailing_default_param_is_an_error` et 3 autres tests `function::tests` cassés). +> `=` reste donc simplement absent du tokenizer, sans erreur dédiée — un futur correctif devrait +> cibler spécifiquement le tokenizer d'expressions (`elements.rs`), pas la primitive `op()` partagée. + +### Découverte connexe — le CLI avalait toutes les erreurs de syntaxe (`typr check`/`build`) — *corrigé* + +En vérifiant le point ci-dessus, découvert que `typr check`/`typr build` (contrairement à +`typr debug --ast`) n'affichaient **aucune** erreur de syntaxe collectée au parsing — +`engine.rs::parse_code`/`parse_code_with_info`/`parse_code_from_str` appelaient +`parse_with_errors()` puis jetaient silencieusement `.errors`, ne gardant que `.ast`. Même chose +une deuxième fois, indépendamment, dans `metaprogramming.rs::ModuleExpander::expand_one` pour +*chaque fichier de module importé* (`mod x;`) : son propre `parse(...)` local jetait aussi ses +erreurs. Concrètement, tout contenu de fin de fichier illisible par la grammaire +(`SyntaxError::UnknownElement`, ou tout autre `push_parse_error` récupérable comme +`WrongCommentSyntax` ci-dessus) était **invisible** via `check_file`/`check_project` dans +`typr-cli/src/project.rs`, alors que `typr debug --ast` les affichait très bien. Reproductible avec +n'importe quel charabia de fin d'instruction, dans le fichier d'entrée ou dans n'importe quel module +importé. + +Corrigé en changeant les signatures de `parse_code`/`parse_code_with_info`/`parse_code_from_str` +(`typr-cli/src/engine.rs`) pour renvoyer aussi `Vec` (entry file + tous les modules +importés, via un nouveau champ `ExpansionInfo::syntax_errors` alimenté par `expand_one`), et en +câblant les 7 points d'appel dans `project.rs` (`check_project`, `check_file`, `build_project_impl`, +`build_file`, `run_file_impl`, `generate_spg`, `document_impl`) sur un nouveau helper +`report_syntax_errors`. Politique de sévérité (décidée explicitement, pas par défaut) : seul +`SyntaxError::UnknownElement` (code réellement perdu de l'AST) fait échouer le pipeline +(`step.fail()` + `exit(1)`) — tout le reste (`ForgottenSemicolon`, `WrongCommentSyntax`, +`LetInsteadOfType`, ...) est toujours entièrement récupéré par le parseur et n'est affiché que +comme avertissement, pour ne pas casser tout projet existant dont le dernier `let` n'a pas de `;` +final (un style aujourd'hui parfaitement toléré). + +En chemin, corrigé un bug d'étiquetage préexistant révélé par ce câblage : le template partagé +`MsgTemplate::Single`/`Double` (`message_template.rs`) avait `"Type error: {text}"` codé en dur — +`SyntaxError::display()` n'avait simplement jamais été appelé en dehors des tests avant cette +correction, donc personne n'avait remarqué qu'une vraie erreur de syntaxe s'affichait comme +« Type error: TypR comments use `#`, not `//` ». `SingleBuilder`/`DoubleBuilder` ont gagné un champ +`kind` (défaut `"Type error"`, préservant les 24 sites d'appel de `type_error.rs` sans changement) ; +les 13 sites de `syntax_error.rs` appellent désormais `.kind("Syntax error")`. + +### Un seul `=` n'est jamais un opérateur binaire + +`bool_op()` exclut délibérément `tag("=")` de ses alternatives, et `op()` n'a plus aucun repli sur +un `=` isolé (voir la tentative avortée documentée juste au-dessus — `compute_operators` paniquerait +dessus, ce qui est exactement ce qui a été observé en essayant). `=` n'existe que dans des positions +de grammaire dédiées : champs nommés (`x = 1`), affectation top-level (`assign()`), valeurs par +défaut de paramètre. Un test d'égalité s'écrit toujours `==`. + +```typr +if (a = b) { /* ... */ } // ne teste PAS l'égalité — utiliser a == b +``` + +### `record` vs `tuple` : la forme des champs décide, pas le mot-clé + +`list{...}`/`:{...}` peuvent produire un `Lang::List` (record) *ou* un `Lang::Tuple` selon la +forme des éléments — `record` est toujours essayé en premier dans `single_element`, mais son +parseur de champ (`argument_val`) exige la forme `nom = valeur` ; s'il échoue sur un premier +élément positionnel, tout le bloc retombe sur `tuple_exp`. + +```typr +list{ a = 1, b = 2 } // → Lang::List (record, champs nommés) +list{ 1, 2, 3 } // → Lang::Tuple (même mot-clé "list", positionnel !) +:{ x = 1, y = 2 } // → Lang::List +:{ 1, 2, 3 } // → Lang::Tuple +``` + +### La « règle d'or » des blocs `{...}` génériques + +Un bloc `{ ... }` qui suit un nom de type paramétré en fait toujours un *record constructeur* +(`Tibble[3]{ id: int }`), jamais un simple usage d'alias. Deux violations de cette règle sont +détectées explicitement plutôt que de mal parser silencieusement : + +```typr +Df[8, int]{ name: char } // SyntaxError::RecordConstructorIndex — un seul index autorisé +Array[5, { a: int }] // SyntaxError::RecordInRecursiveParams — bloc record interdit ici +``` + +Un cas plus subtil couvert par un test de régression dédié : `ltype` ne doit pas confondre le +type de retour d'une fonction avec le corps qui suit — `fn(i: int): Feux { .Rouge }` doit +s'arrêter à `Feux` et laisser `{ .Rouge }` au corps de la fonction, pas l'avaler comme si +`Feux{...}` était un record constructeur. + +### Alias de type PascalCase à une seule lettre : imparsable — *ouvert* + +`pascal_case_no_space` (utilisé par `type_alias`, donc par `type`/`opaque`/`typeconstructor`) est +défini comme `(one_of("A".."Z"), alphanumeric1)` — `alphanumeric1` exige **au moins un** caractère +alphanumérique après la majuscule initiale. Un nom de type d'une seule lettre échoue donc +totalement à ce niveau et retombe ailleurs dans la grammaire (souvent une variable bidon). + +```typr +type A <- int; // n'est PAS un Alias — utiliser au moins 2 caractères +type Ab <- int; // OK +``` + +Asymétrie notable : les noms de *tag*/variante d'union utilisent `pascal_case_helper` +(`opt(alpha1)` — le suffixe est optionnel), donc `.X` (tag à une lettre) parse très bien. Seuls +les noms d'alias de type exigent 2 caractères ou plus. + +### `fn(...)` interdit en position de type + +`fn(args) -> Type` ressemble à un type fonction mais n'est légal qu'au niveau expression ; en +position de type, seule la forme `(Type, Type) -> Type` (sans `fn`) est valide. Le parseur capture +spécifiquement le cas d'erreur (`fn_function_type_error`) pour donner un message ciblé plutôt +qu'un échec générique. + +```typr +let f: fn(a: int) -> int <- ...; // SyntaxError::FunctionTypeSyntax +let f: (int) -> int <- ...; // OK +``` + +### Commentaires `.ty` : uniquement `#` + +`comment()` ne reconnaît que `tag("#")` — il n'existe aucune alternative `//` nulle part dans +`base_parse`. Un fichier de signatures stdlib écrit avec des commentaires `//` n'est pas juste mal +commenté : il échoue à parser et `build_typed_vartype` l'ignore silencieusement en entier +(« Skipped … (syntax not supported) ») — surveiller cette ligne dans la sortie de `typr std`. + +### `use` : un seul mot-clé, quatre grammaires distinctes + +`use` introduit quatre constructions entièrement différentes, désambiguïsées seulement par ce qui +suit immédiatement, dans cet ordre d'essai : + +| Forme | Reconnue si | Produit | +|---|---|---| +| `use("dplyr", c(...));` | `use(` littéral, sans espace | adaptateur R legacy (`Lang::Use`) | +| `use Math::pi;` | un `::` suit le premier identifiant | `Lang::UseModule` | +| `use MonAlias;` | identifiant `PascalCase`, pas de `::` | `Lang::Import` (type) | +| `use ma_variable;` | identifiant `snake_case`, pas de `::` | `Let`/`Alias` (variable) | + +### Détails plus fins + +- `in` comme opérateur de type-condition est tokenisé `tag("in ")` — l'espace final fait partie + du motif, donc `in` immédiatement suivi d'une parenthèse sans espace ne matchera pas. +- `embed` (embedding de type nommé sur un champ de record) est un mot-clé « doux » reconnu + seulement via `tag("embed") + multispace1` — un champ réellement nommé `embed` (suivi + directement de `:`, sans espace) reste donc parsable comme nom de champ ordinaire. +- Le spread statique `..source` (nominal) exige explicitement `not(char('.'))` après les deux + points pour ne jamais être confondu avec `...source` (spread runtime) ou `...` (paramètre + variadique) — les trois partagent un préfixe visuel. +- `nom::alt()` plafonne à 21 alternatives par tuple : `single_element` et `single_type` sont + chacun scindés en deux `alt()` imbriqués pour rester sous la limite. Ajouter un nouveau + combinator à l'un de ces points d'entrée demande de respecter ce découpage, pas d'ajouter une + 22ᵉ branche à plat.