diff --git a/.claude/skills/case/SKILL.md b/.claude/skills/case/SKILL.md deleted file mode 100644 index 195e16d7..00000000 --- a/.claude/skills/case/SKILL.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -name: case -description: Import a TypR bug into cases/ and drive it from repro to fix. Use when the user hits a bug in a TypR project and wants it turned into a reproducible case (from a `typr case snapshot` bundle, or directly from a live project directory plus a description of what's wrong) and then fixed here. Applies to the typr repo root (cases/ lives there). ---- - -# /case — from a broken project to a fixed, cataloged case - -Shortcuts the transition described in `cases/README.md` and CLAUDE.md's "Reproducible bug -catalog" section: today, going from "I hit a bug in my TypR project" to "Claude has a -reproducible case and is fixing it" needs several manual steps (mark the code, `typr case -snapshot`, move the bundle, hand-write `expect.toml`). This skill does all of that in one -invocation, and — the reason it exists — accepts a **live project directory plus your own prose -description of the problem** as an alternative to pre-marking the code with `#@case` comments. - -## Reading `args` - -`args` is free text, not a fixed flag syntax. It contains, in whatever order the user typed it: - -- **A filesystem path**, to one of two things: - - a `.case/` bundle already produced by `typr case snapshot` (has `case.toml` + - `repro/` at its top level) — **bundle mode**; - - a live TypR project directory (has `TypR/main.ty`, no `case.toml`) that has *not* been - snapshotted — **project mode**. -- **A prose description** of the bug — what's wrong, what should happen instead. In project - mode this is the *only* source of the problem description (no `#@case` marker required). In - bundle mode, treat it as additional context layered on top of whatever `snapshot` already - captured in `.case.md`/`expect.md`. -- Optionally, hints about where the bug lives (a runtime crash vs. wrong generated R vs. a type - error) — use these to pick `cmd`/`layer`, see step 2/3 below. - -If `args` contains no resolvable path, stop and ask the user for one via AskUserQuestion — -don't guess a path. A description alone, with nothing to build a `repro/` from, isn't enough. - -## Steps - -1. **Resolve the path and mode.** Check for `case.toml` at the path's top level → bundle mode. - Check for `TypR/main.ty` with no `case.toml` → project mode. If the path exists but matches - neither shape, say so and ask what it is rather than guessing. - -2. **Bundle mode.** - - Count existing dirs under `cases/` in *this* repo, zero-pad `+1` to 4 digits → `NNNN`. - - `mv cases/-` (slug = the bundle's dirname minus the trailing - `.case`). Don't re-copy or re-curate `repro/` — `snapshot` already curated it - (`TypR/` + `DESCRIPTION`/`NAMESPACE`, no `renv/`/`.git/`). - - If the user's prose in `args` adds anything not already in the bundle's `expect.md`, - append it there under a `## Précisions (session)` heading instead of discarding it. - -3. **Project mode.** - - From the prose, infer: - - `cmd`/`layer`: mentions of running the program, wrong printed/returned values, a crash - "when I run it", wrong dispatch at runtime → `cmd=run`, `layer=r-run`. Mentions of wrong - *generated R* (visible without running it) → `cmd=build`, `layer=transpile`. A pure type - error caught before transpilation → `cmd=check`, `layer=type`. Default, if unclear: - `cmd=build`, `layer=transpile`. - - `slug`: a short kebab-case handle (2-5 words) summarizing the bug. - - From the typr repo root: `cargo build --workspace` (get a current binary), then: - ``` - target/debug/typr case add --from --cmd --layer - ``` - This curates the copy, scaffolds `case.toml`, and captures a real `observed.txt` (a build - or run of the case as it stands right now). - - Rewrite `cases/-/expect.md`: a `# ` heading, a "## Ce qui devrait se - passer" section paraphrasing the user's prose, and an "## Anomalies" section that connects - that prose to what `observed.txt` actually shows going wrong (don't just paste the user's - text verbatim — ground it in the captured output). - -4. **Write `expect.toml` rules** (both modes). Read `observed.txt` and `expect.md`. Translate - each anomaly into `must_contain`/`must_not_contain` rules — substrings of real captured - output, never hand-written expected R (see `cases/README.md`, "L'oracle : on ne décrit jamais - le R attendu en entier"). For `layer = "r-run"` cases, target `file = "@run"` — this greps - the captured stdout+stderr of an actual `Rscript` execution rather than a generated file - (e.g. `must_not_contain = "no applicable method"`). Skim 2-3 existing `cases/*/expect.toml` - first to match the house style before writing new ones. - -5. **Confirm it reproduces**: `target/debug/typr case run ` from the repo root. Expect - `OPEN`. If it comes back `READY`, or fails in a way that doesn't match the described bug, the - rules are wrong (too loose, or targeting the wrong file) — fix them before touching - typr-core. Fixing code against a rule that isn't actually exercising the bug proves nothing. - -6. **Fix it.** Normal typr-core debugging: identify the failing pipeline stage (parsing / type- - checking / transpiling — CLAUDE.md's "Debugging Workflow" section has the playbook), fix it, - `cargo build --workspace`. - -7. **Confirm the fix**: `target/debug/typr case run ` → expect `READY`. Loop 6-7 until it is. - -8. **Freeze**: `target/debug/typr case freeze ` (captures golden `R/*.R`, flips `status` - to `fixed`). Then `target/debug/typr case run --status fixed` as a regression guard — must - print `PASS` and exit 0. - -9. **Report**: the case id, the root cause, what changed and where, and — if this bug class is - generic enough to deserve one — the `cargo test -p typr-core ::` command for a - proper unit test beyond the case harness. - -## Notes - -- Run every `typr case …` command from the typr repo root: `cases/` and the sandboxing in - `cases.rs` are resolved relative to cwd. -- `cases/README.md` and CLAUDE.md's "Reproducible bug catalog" section are the canonical docs - for how the harness works; re-read them if anything here seems out of date with the code. -- The oracle is always rules grepped against real captured output (generated R, or — for - `r-run` cases — real `Rscript` stdout+stderr via `file = "@run"`). Never author the expected R - by hand, and never golden-diff `@run` (its capture includes CLI progress timings that vary - every run — grep only). -- `case.toml` has a `checked = true` field (default `false`): appends `--checked` to the - replayed `cmd` (`build`/`run` only). Set it when the bug is specifically about - `typr_assert_type` runtime boundary checks (soundness_transpilation.md Phase A) — e.g. a - false-positive/false-negative assertion, not just "the program crashes". - -### `@extern`/`Foreign` bugs (interop with real R values) - -If the bug involves a value from outside TypR's own type system — an S3/S4/RC/R6 object, a -third-party package, anything reached via `@extern` — this is the `interop_matrix.md` (repo -root) family, soundness_transpilation.md Phase D. Before scaffolding a new case: - -- **Check the matrix first.** The cell (provenance row × TypR-boundary column) may already be - covered by an existing `cases/00NN-...` — read `interop_matrix.md`'s grid and its "Backlog" - section before assuming this is new. -- **`typr case add --from ` won't carry extra files.** It curates only `TypR/` + - `DESCRIPTION`/`NAMESPACE` (`copy_repro_curated` in `cases.rs`) — any `repro/fixtures/*.rds` the - repro needs must be added by hand into the scaffolded `repro/` afterward, same as the existing - `cases/0018`-`0029` do. -- **Never construct the foreign value from TypR source or a hand-written companion `.R` file - sourced at run.** TypR's project loader (`load_module.R`) sources every `R/*.R` into its own - *isolated* environment, only sharing bindings via `@include` tags TypR itself generates — a - hand-placed R file isn't reachable from `main.ty` without that plumbing. Instead: generate the - value once with a plain R script (extend `tools/gen_interop_fixtures.R` if it's a new fixture, - same "run by hand, `.rds` committed" convention as `tools/gen_r_name_db.R`) and read it back - via `@extern base::readRDS: (path: char) -> Foreign;`. -- **After fixing**, update the matching cell in `interop_matrix.md` to link the new case (or add - a row/column if this opens a genuinely new one), not just `cases/README.md`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 551c8ecd..2f6538bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,6 +192,11 @@ jobs: sudo apt-get install -y r-base-core sudo Rscript -e "install.packages(c('R6','S7','Matrix'), repos='https://cloud.r-project.org')" + - name: Install air (formats the generated R code) + run: | + curl -LsSf https://github.com/posit-dev/air/releases/latest/download/air-installer.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Build typr run: cargo build --release @@ -203,3 +208,268 @@ jobs: echo '```' >> "$GITHUB_STEP_SUMMARY" tail -5 case-run.log >> "$GITHUB_STEP_SUMMARY" echo '```' >> "$GITHUB_STEP_SUMMARY" + + packaging: + name: Packaging (générateur Homebrew) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Installer shellcheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + + - name: render.sh — shellcheck + run: shellcheck -s sh packaging/homebrew/render.sh + + - name: render.sh — analyse syntaxique + run: | + # `dash -n` avec le dash du runner : c'est l'interpréteur le plus + # strict, il refuse les extensions de bash que `sh -n` tolère. + dash -n packaging/homebrew/render.sh + bash -n packaging/homebrew/render.sh + bash -n packaging/homebrew/tests/run-tests.sh + + - name: render.sh — suite de tests + run: bash packaging/homebrew/tests/run-tests.sh + + # Les manifestes WinGet et Scoop sont validés ici sur la fixture, donc sans + # réseau et sans release : c'est le même code de substitution que la release + # exercera, avec des empreintes factices. Ce que la fixture ne peut pas + # vérifier — WinGet qui installe vraiment, Scoop qui télécharge et vérifie une + # empreinte — appartient au job `winget` et au job `scoop` de release.yml, qui + # travaillent sur une release publiée. + # + # `packaging/tests/validate-schemas.py` valide contre les JSON Schema officiels + # de winget-pkgs et de Scoop, téléchargés depuis leurs dépôts : winget-pkgs + # n'accepte que le sien, et un schéma recopié ici validerait moins que ce qui + # sera exigé dès la version suivante. + packaging-win: + name: Packaging (WinGet + Scoop) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Installer shellcheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + + - name: Installer les dépendances de validation + run: python3 -m pip install --user --upgrade pyyaml jsonschema + + - name: Cache des schémas officiels + uses: actions/cache@v5 + with: + path: ~/.cache/typr-packaging/schemas + key: winget-scoop-schemas-${{ hashFiles('packaging/**/*.in') }} + + - name: render.sh — shellcheck + run: shellcheck -s sh packaging/render.sh + + - name: render.sh — analyse syntaxique + run: | + # `dash -n` avec le dash du runner : c'est l'interpréteur le plus + # strict, il refuse les extensions de bash que `sh -n` tolère. + dash -n packaging/render.sh + bash -n packaging/render.sh + bash -n packaging/tests/run-tests.sh + python3 -m py_compile packaging/tests/validate-schemas.py + + - name: render.sh — suite de tests + run: bash packaging/tests/run-tests.sh + + # La suite tolère que les schémas soient indisponibles — le réseau peut + # tomber, et une suite qui échoue pour ça n'apprend rien. Ici, hors de la + # suite, la validation est obligatoire : un manifeste que winget-pkgs ou le + # dépôt Scoop refuseraient doit arrêter la PR, pas la release. + - name: Manifestes rendus — schémas officiels + run: | + set -euo pipefail + packaging/render.sh \ + --tag v9.9.9 \ + --checksums packaging/tests/fixtures/checksums-v9.9.9.txt \ + --release-date 2026-01-02 \ + --out rendered + python3 packaging/tests/validate-schemas.py rendered + + # La validation par Homebrew ne peut pas tourner dans le job précédent : ni + # `brew style` ni `brew audit` n'existent sous Linux, et les deux sont justement + # ce qui décide si `brew install` fonctionnera. Elle a donc lieu ici, sur un + # runner macOS, à chaque PR — sinon une formule que Homebrew refuse ne se + # verrait qu'à la release, quand le tag est déjà poussé et qu'il faut en créer + # un autre pour la corriger. + # + # Le rendu se fait sur la fixture, donc sans réseau et sans release : c'est le + # même code de substitution que la release exercera, avec des empreintes + # factices. La vérification des empreintes *réelles* appartient au job `brew` + # de release.yml, qui peut télécharger les archives publiées. + packaging-audit: + name: Packaging (audit Homebrew) + runs-on: macos-latest + steps: + - uses: actions/checkout@v5 + + - name: Rendre la formule depuis la fixture + run: | + set -euo pipefail + packaging/homebrew/render.sh \ + --tag v9.9.9 \ + --checksums packaging/homebrew/tests/fixtures/checksums-v9.9.9.txt \ + --out tap + cat tap/Formula/typr.rb + + - name: brew style et brew audit + run: | + set -euo pipefail + brew tap-new --no-git we-data-ch/typr + TAP_DIR="$(brew --repository we-data-ch/typr)" + mkdir -p "$TAP_DIR/Formula" + cp tap/Formula/typr.rb "$TAP_DIR/Formula/typr.rb" + cp tap/README.md "$TAP_DIR/README.md" + + brew readall we-data-ch/typr + brew style we-data-ch/typr/typr + brew audit --strict --formula we-data-ch/typr/typr + + install: + name: Install scripts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + # La suite Unix tourne nativement : `sh` et `dash` existent sur le runner, + # et c'est `sh` qui interprète le script chez la plupart des utilisateurs. + # Le conteneur ne servirait qu'à re-vérifier les mêmes interpréteurs avec + # un système de fichiers différent — ce que fait déjà le job `test` pour + # le compilateur. + - name: Installer shellcheck + run: | + sudo apt-get update + sudo apt-get install -y shellcheck + + - name: install.sh — shellcheck (script) + run: shellcheck -s sh install/install.sh + + - name: install.sh — shellcheck (tests) + run: shellcheck -s bash install/tests/run-tests.sh + + - name: install.sh — analyse syntaxique + run: | + # `sh -n` avec le dash du runner : c'est l'interpréteur le plus + # strict, il refuse les extensions de bash que `sh -n` tolère. + dash -n install/install.sh + bash -n install/install.sh + + - name: install.sh — suite de tests + run: bash install/tests/run-tests.sh + + # install.ps1 est une suite séparée : elle a besoin de PowerShell, de + # PSScriptAnalyzer et d'un serveur de fixtures en Python. Le conteneur + # est construit depuis install/tests/Dockerfile, épinglé sur un tag + # PowerShell précis — `latest` ferait échouer la CI au jour où la base + # change, sans qu'aucune modification du dépôt soit en cause. + - name: install.ps1 — build de l image de test + run: docker build -t typr-install-tests -f install/tests/Dockerfile . + + - name: install.ps1 — suite de tests + run: >- + docker run --rm -v "$PWD:/w" -w /w typr-install-tests + pwsh -NoProfile -File install/tests/run-tests.ps1 + + # Les deux suites précédentes tournent sous Linux : ni le registre Windows, + # ni %LOCALAPPDATA%, ni Expand-Archive ne sont réellement exercés. Ce job + # attrape ce que Linux ne peut pas voir. + # + # Il ne remplace pas la preuve qu'un binaire y démarre : le runner n'a pas de + # VC++ Redistributable, et la fixture n'est de toute façon pas un exécutable + # Windows. La validation de bout en bout se fait sur une machine réelle, avec + # une vraie release publiée. + install-windows: + name: Install scripts (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + + - name: Le script s'exécute et distingue ses deux codes d'échec + shell: pwsh + run: | + # Rien n'est installé : -DryRun ne télécharge rien, et le ORIGIN + # explicite évite de dépendre du réseau. On vérifie surtout que le + # script tourne sous Windows PowerShell 5.1 — le runtime le plus + # ancien encore présent sur une machine de bureau — et pas seulement + # sous PowerShell 7. + $env:TYPR_INSTALL_ORIGIN = 'https://we-data-ch.github.io/typr.github.io' + + & ./install/install.ps1 -DryRun -Version 'v0.0.0' + if ($LASTEXITCODE -ne 0) { throw "-DryRun a échoué (code $LASTEXITCODE)" } + + & ./install/install.ps1 -DryRun -Version 'pas-un-tag' + if ($LASTEXITCODE -ne 2) { + throw "un tag illisible doit sortir en 2 (code obtenu : $LASTEXITCODE)" + } + + Write-Host "install.ps1 — PowerShell $($PSVersionTable.PSVersion) sur $env:OS" + # Le dernier code natif (2, attendu) ferait sinon échouer l'étape. + exit 0 + + install-hosting: + name: Install hosting + runs-on: ubuntu-latest + needs: install + # Le dépôt de documentation est poussé seulement sur les branches publiées : + # un push sur une PR laisserait une page d installation qui peut mentir. + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + steps: + - uses: actions/checkout@v5 + + - name: Vérifier le jeton de déploiement + env: + DOCS_DEPLOY_TOKEN: ${{ secrets.DOCS_DEPLOY_TOKEN }} + run: | + if [ -z "$DOCS_DEPLOY_TOKEN" ]; then + echo "::error::DOCS_DEPLOY_TOKEN absent : la copie vers typr.github.io ne peut pas avoir lieu." + exit 1 + fi + + # Les scripts d installation sont servis depuis le dépôt de documentation, + # pas depuis celui-ci : c est là que GitHub Pages est branché, et les deux + # URLs doivent rester stables même si le code bouge. + # + # On ne copie que ce que Pages sert. Les tests et le Dockerfile n ont rien + # à faire dans le site, et le publier reviendrait à exposer la mécanique de + # test — y compris les détails du protocole de fixture. + - name: Copier les scripts vers le dépôt de documentation + env: + DOCS_DEPLOY_TOKEN: ${{ secrets.DOCS_DEPLOY_TOKEN }} + run: | + git clone --depth 1 \ + "https://x-access-token:${DOCS_DEPLOY_TOKEN}@github.com/we-data-ch/typr.github.io.git" \ + site + + mkdir -p site/static/install + # Le BOM est nécessaire : le script affiche des accents, et Pages sert + # le fichier tel quel. Sans BOM, un navigateur et PS 5.1 le liraient + # en ANSI. + cp install/install.sh install/install.ps1 site/static/install/ + + # Aucun autre fichier n est attendu sous static/install : un reste + # d une version précédente (un vieux install.bat) continuerait d être + # servi à l adresse où il a déjà été indexé. + git -C site status --porcelain + git -C site add static/install/install.sh static/install/install.ps1 + if git -C site diff --cached --quiet; then + echo "Les scripts publiés sont déjà à jour." + exit 0 + fi + + git -C site -c user.name='github-actions[bot]' \ + -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + commit -m "install : scripts d installation ($(git rev-parse --short HEAD))" + + # Un push direct sur main est refusé si la branche est protégée. Le + # contournement par empty commit + force n est pas utilisé : mieux + # vaut voir le job échouer bruyamment qu écraser une protection + # posée par un humain. + git -C site push diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c63ea9f7..ff07b021 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,19 +74,41 @@ jobs: fail-fast: false matrix: include: - - target: x86_64-unknown-linux-gnu + # Linux musl — la cible que vise le script d'installation par défaut. + # Liée statiquement : aucune contrainte de glibc, donc le binaire + # démarre sur Rocky 9, Debian 12, Ubuntu 22.04 *et* Alpine. C'est le + # correctif du défaut mesuré sur v0.5.12 (`GLIBC_2.39 not found`). + - target: x86_64-unknown-linux-musl os: ubuntu-latest archive: tar.gz - - target: aarch64-unknown-linux-gnu + cross: true + - target: aarch64-unknown-linux-musl os: ubuntu-latest archive: tar.gz cross: true + # Linux GNU — repli, construit sur 22.04 et non sur `latest` : `ubuntu-latest` + # passe à Ubuntu 26.04 en novembre 2026 (actions/runner-images#14748) et + # ferait monter le plancher de glibc de 2.35 à ~2.42 sans qu'une ligne du + # dépôt change. Ici le plancher est figé, quoi qu'il arrive au runner. + - target: x86_64-unknown-linux-gnu + os: ubuntu-22.04 + archive: tar.gz + - target: aarch64-unknown-linux-gnu + os: ubuntu-22.04 + archive: tar.gz + cross: true + # `+crt-static` lie le runtime C : VCRUNTIME140.dll et les + # api-ms-win-crt-* du VC++ Redistributable disparaissent des imports. + # Sans ce flag, l'installation aboutit puis `typr --version` échoue + # sur un Windows 10/11 frais (DLL introuvable). - target: x86_64-pc-windows-msvc os: windows-latest archive: zip + rustflags: -C target-feature=+crt-static - target: aarch64-pc-windows-msvc os: windows-latest archive: zip + rustflags: -C target-feature=+crt-static - target: x86_64-apple-darwin os: macos-latest archive: tar.gz @@ -107,16 +129,23 @@ jobs: with: key: ${{ matrix.target }} - - name: Install cross (Linux ARM) + - name: Install cross (Linux non natif) if: matrix.cross run: cargo install cross --git https://github.com/cross-rs/cross - name: Build (cross) if: matrix.cross + env: + # `-C target-feature=+crt-static` sur les cibles `*-msvc` ; vide + # partout ailleurs. Comme `--target` est toujours passé, cargo + # n'applique pas RUSTFLAGS aux build scripts ni aux proc macros. + RUSTFLAGS: ${{ matrix.rustflags }} run: cross build --release --target ${{ matrix.target }} - name: Build (native) if: ${{ !matrix.cross }} + env: + RUSTFLAGS: ${{ matrix.rustflags }} run: cargo build --release --target ${{ matrix.target }} - name: Prepare artifacts (Unix) @@ -132,6 +161,64 @@ jobs: cd target/${{ matrix.target }}/release Compress-Archive -Path ${{ env.BINARY_NAME }}.exe -DestinationPath ../../../${{ env.BINARY_NAME }}-${{ needs.verify.outputs.tag }}-${{ matrix.target }}.zip + # La liaison statique du runtime C ne se constate qu'en inspectant le PE + # produit : rustc accepte le flag, mais rien ne garantit qu'il ait été + # honoré. C'était le point non vérifié de l'analyse, donc on le vérifie ici + # plutôt que de le croire — sinon le défaut revient en silence au premier + # changement de dépendance. + - name: Runtime C statique (Windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + $exe = "target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}.exe" + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $vs = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationPath + if (-not $vs) { throw "Outils C++ de Visual Studio introuvables sur le runner" } + $vcvars = Join-Path $vs "VC\Auxiliary\Build\vcvars64.bat" + + # dumpbin n'est pas dans le PATH du runner : il faut l'environnement VC. + $dlls = cmd /c "`"$vcvars`" >nul 2>&1 && dumpbin /nologo /dependents `"$exe`"" + if ($LASTEXITCODE -ne 0) { throw "dumpbin /dependents a échoué sur $exe" } + Write-Host $dlls + + # UCRT et VCRUNTIME viennent tous deux du VC++ Redistributable, absent + # d'un Windows 10/11 frais. S'ils réapparaissent, l'installation + # paraîtra réussie et `typr --version` échouera. + $runtime = $dlls | Select-String -Pattern 'VCRUNTIME140|VCRUNTIME140_1|MSVCP140|api-ms-win-crt-|ucrtbase\.dll' + if ($runtime) { + Write-Host "::error::le binaire importe encore le runtime du VC++ Redistributable :" + Write-Host $runtime + exit 1 + } + Write-Host "Runtime C statique : aucune DLL du VC++ Redistributable importée." + + # C'est cette étape — `typr --version` dans une distro vierge — qui échouait + # en septembre 2026 avec les binaires GNU, sur les 3 premières + # distributions. Un binaire musl qui trahirait un interpréteur + # (`PT_INTERP`) ne fonctionnerait que sur Alpine, et le défaut + # reviendrait sans qu'aucun test ne le voie : d'où les deux garde-fous. + - name: Binaire musl statique et exécutable + if: matrix.target == 'x86_64-unknown-linux-musl' + run: | + set -euo pipefail + BIN="$PWD/target/${{ matrix.target }}/release/${{ env.BINARY_NAME }}" + file "$BIN" + # On lit la table de segments en entier plutôt que de s'arrêter à la + # première correspondance : `grep -q` sous `pipefail` laisse la + # garde-fou dépendre du tampon de pipe, et le décompte sert aussi au + # message d'erreur. + INTERP=$(readelf -lW "$BIN" | grep -cE '\bINTERP\b' || true) + if [ "$INTERP" -ne 0 ]; then + echo "::error::$BIN déclare un interpréteur — il n'est pas statique et ne tournera que sur Alpine" + exit 1 + fi + for IMAGE in rockylinux:9 debian:12 alpine:3.20; do + echo "→ $IMAGE" + docker run --rm -v "$BIN:/w/typr:ro" "$IMAGE" sh -c '/w/typr --version' + done + - uses: actions/upload-artifact@v4 with: name: ${{ env.BINARY_NAME }}-${{ matrix.target }} @@ -545,3 +632,419 @@ jobs: if: env.DOCS_DISPATCH_TOKEN == '' run: | echo "::warning title=Doc non rafraichie::DOCS_DISPATCH_TOKEN absent ; typr.github.io n'a pas ete redeploye pour cette release." + + # --------------------------------------------------------------------------- + # 8b. Homebrew — le tap reçoit une formule *générée*, jamais écrite à la main. + # + # Un SHA-256 recopié à la main finit toujours par désigner un binaire qui + # n'est plus celui de la release : l'erreur n'apparaît qu'à + # l'installation, chez l'utilisateur. Ici les empreintes sont lues dans le + # `checksums.txt` que la release vient de publier, puis vérifiées contre + # l'archive réellement téléchargée avant d'être commitées. + # + # Indépendant des autres canaux, comme `vscode` et `vim` : un push refusé + # sur le tap ne doit pas empêcher les binaires d'arriver. + # --------------------------------------------------------------------------- + brew: + name: Homebrew tap + needs: [verify, release] + runs-on: macos-latest + # Au niveau du job pour que l'étape de publication puisse tester sa présence + # dans son propre `if` : un `env` de step n'y est pas visible. + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.verify.outputs.tag }} + + - name: Récupérer checksums.txt de la release + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + set -euo pipefail + curl -fsSfL \ + "https://github.com/${{ github.repository }}/releases/download/$TAG/checksums.txt" \ + -o checksums.txt + cat checksums.txt + + - name: Générer la formule et le README du tap + run: | + set -euo pipefail + packaging/homebrew/render.sh \ + --tag "${{ needs.verify.outputs.tag }}" \ + --checksums checksums.txt \ + --out tap + cat tap/Formula/typr.rb + + # La formule reprend `checksums.txt`, donc son empreinte est correcte par + # construction — sauf si le fichier a été réécrit entre le rendu et cet + # instant, ou si la ligne lue était celle d'un autre artefact. On + # télécharge donc une archive pour de vrai et on la compare : c'est le seul + # endroit où une erreur de sélection se verrait avant le commit. + - name: L'empreinte de la formule correspond à l'archive publiée + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + set -euo pipefail + ARTIFACT="typr-$TAG-aarch64-apple-darwin.tar.gz" + curl -fsSfL \ + "https://github.com/${{ github.repository }}/releases/download/$TAG/$ARTIFACT" \ + -o "$ARTIFACT" + ACTUAL=$(shasum -a 256 "$ARTIFACT" | cut -d' ' -f1) + rm -f "$ARTIFACT" + if ! grep -Fq "$ACTUAL" tap/Formula/typr.rb; then + echo "::error::l'empreinte de $ARTIFACT ($ACTUAL) ne figure pas dans la formule rendue" + exit 1 + fi + echo "archive publiée et formule d'accord : $ACTUAL" + + # `brew style` et `brew audit` jugent la formule comme le ferait + # l'utilisateur, et rien ici ne les remplace : ni ruby ni Homebrew ne + # tournent sous Linux. Une formule qu'ils refusent n'est pas committée. + # + # Le fichier est déposé dans un vrai tap (`Library/Taps//`) : + # c'est ce que fait `brew tap`, et c'est ce que rubocop attend pour + # connaître le nom du tap. + - name: Homebrew accepte la formule + run: | + set -euo pipefail + TAP_DIR="$(brew --repository)/Library/Taps/we-data-ch/homebrew-typr" + mkdir -p "$TAP_DIR/Formula" + cp tap/Formula/typr.rb "$TAP_DIR/Formula/typr.rb" + cp tap/README.md "$TAP_DIR/README.md" + + # `brew readall` va plus loin que `brew style` : il charge la formule + # comme le fera `brew install`. Une formule que Homebrew sait lire mais + # pas charger est detectable ici, et nulle part ailleurs. + brew readall "$TAP_DIR" + brew style "$TAP_DIR/Formula/typr.rb" + brew audit --strict --formula="$TAP_DIR/Formula/typr.rb" + + - name: Pousser la formule vers le tap + if: env.HOMEBREW_TAP_TOKEN != '' + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + set -euo pipefail + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + # Clone complet, pas `--depth 1` comme les autres canaux : en cas de + # push refusé il faut pouvoir rebaser, et un clone superficiel refuse + # de rebaser sur un historique qu'il n'a pas. Le tap fait quelques + # dizaines de kilo-octets. + git clone \ + "https://x-access-token:$HOMEBREW_TAP_TOKEN@github.com/we-data-ch/homebrew-typr.git" \ + taprepo + + mkdir -p taprepo/Formula + cp tap/Formula/typr.rb taprepo/Formula/typr.rb + cp tap/README.md taprepo/README.md + cp LICENSE taprepo/LICENSE + + git -C taprepo add Formula/typr.rb README.md LICENSE + if git -C taprepo diff --cached --quiet; then + echo "Le tap est déjà à jour ($VERSION)." + exit 0 + fi + git -C taprepo commit -m "TypR $VERSION" + + # Le tap a ses propres contributeurs : on rebase plutôt que d'écraser. + for attempt in 1 2 3; do + if git -C taprepo push origin HEAD:main; then + echo "tap $VERSION poussé (tentative $attempt)." + exit 0 + fi + echo "push refusé — rebase sur origin/main et nouvelle tentative" + git -C taprepo fetch origin main + git -C taprepo rebase origin/main + done + echo "::error::impossible de pousser la formule vers le tap après 3 tentatives" + exit 1 + + # Sans jeton, le tap reste sur la version précédente : on le dit, on ne + # casse pas toute la release pour un seul canal — la règle que RELEASING.md + # énonce déjà. + - name: HOMEBREW_TAP_TOKEN absent — tap non mis à jour + if: env.HOMEBREW_TAP_TOKEN == '' + run: | + echo "::warning title=Homebrew non mis a jour::HOMEBREW_TAP_TOKEN absent ; le tap garde sa version precedente." + + # --------------------------------------------------------------------------- + # WinGet — pull request vers microsoft/winget-pkgs + # + # Ce job dépend d'un jeton GitHub ayant `public_repo` : winget-pkgs est un dépôt + # public, dans lequel il faut pouvoir ouvrir une pull request. Sans jeton, les + # étapes de rendu et de contrôle s'exécutent quand même, et seule la soumission + # est sautée. + # --------------------------------------------------------------------------- + winget: + name: WinGet manifest + needs: [verify, release] + runs-on: windows-latest + # Au niveau du job pour que l'étape de publication puisse tester sa présence + # dans son propre `if` : un `env` de step n'y est pas visible. + env: + WINGET_CREATE_TOKEN: ${{ secrets.WINGET_CREATE_GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.verify.outputs.tag }} + + - name: Récupérer checksums.txt de la release + shell: bash + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + set -euo pipefail + curl -fsSfL \ + "https://github.com/${{ github.repository }}/releases/download/$TAG/checksums.txt" \ + -o checksums.txt + cat checksums.txt + + - name: Générer les manifestes depuis checksums.txt + shell: bash + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + set -euo pipefail + packaging/render.sh --tag "$TAG" --checksums checksums.txt --out manifests + find manifests -type f | sort + + # Les manifestes reprennent `checksums.txt`, donc leur empreinte est + # correcte par construction — sauf si le fichier a été réécrit entre le + # rendu et cet instant, ou si la ligne lue était celle d'un autre artefact. + # On télécharge donc les deux archives pour de vrai et on les compare : + # c'est le seul endroit où une erreur de sélection se verrait avant la + # soumission. `Get-FileHash` plutôt qu'un `sha256sum` de Git Bash, pour + # ne pas dépendre du jeu d'outils embarqué par Git for Windows. + - name: Les empreintes des manifestes correspondent aux archives publiées + shell: pwsh + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + $ErrorActionPreference = 'Stop' + $targets = @('x86_64', 'aarch64') + # Les manifestes sont lus et accolés en une seule chaîne : `Contains` + # sur un tableau teste l'égalité d'un élément, pas la présence d'un + # motif — l'assertion passerait donc pour n'importe quelle empreinte. + $manifests = (Get-ChildItem -Recurse manifests -File | + ForEach-Object { Get-Content -Raw $_.FullName }) -join "`n" + + foreach ($target in $targets) { + $artifact = "typr-$env:TAG-$target-pc-windows-msvc.zip" + $url = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:TAG/$artifact" + Invoke-WebRequest -Uri $url -OutFile $artifact + $actual = (Get-FileHash -Algorithm SHA256 $artifact).Hash.ToLowerInvariant() + Remove-Item $artifact + + if (-not $manifests.Contains($actual)) { + throw "l'empreinte de $artifact ($actual) ne figure dans aucun manifeste rendu" + } + Write-Host "$artifact : $actual, présente dans les manifestes rendus" + } + + # `wingetcreate submit ` est la commande prévue pour une première + # soumission : `update` exigerait que le paquet existe déjà dans + # winget-pkgs. Le chemin de destination (`manifests/w/we-data-ch/typpr/ + # /`) est calculé par l'outil d'après le contenu des manifestes ; + # le dossier rendu n'a donc pas besoin d'être pré-dispositionné. + # + # L'outil valide les fichiers avec le validateur officiel de WinGet avant + # d'ouvrir la pull request : c'est le même contrôle que winget-pkgs + # appliquera, et il échoue ici plutôt qu'après la fusion. + # + # Le jeton passe par la variable d'environnement `WINGET_CREATE_GITHUB_TOKEN` + # et non par `--token` : sur la ligne de commande il apparaîtrait dans la + # liste des processus du runner et dans les journaux. + # + # `wingetcreate` réécrit les manifestes avec son propre sérialiseur : les + # commentaires de nos gabarits ne se retrouvent donc pas dans la pull + # request. Ce qui compte — les champs — est celui que l'outil a validé. + - name: wingetcreate + if: env.WINGET_CREATE_TOKEN != '' + shell: pwsh + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + $ErrorActionPreference = 'Stop' + + Invoke-WebRequest ` + -Uri https://github.com/microsoft/winget-create/releases/latest/download/wingetcreate.exe ` + -OutFile wingetcreate.exe + + $env:WINGET_CREATE_GITHUB_TOKEN = $env:WINGET_CREATE_TOKEN + & .\wingetcreate.exe submit --prtitle "TypR $env:VERSION" --no-open manifests/winget + if ($LASTEXITCODE -ne 0) { + throw "wingetcreate submit a échoué (code $LASTEXITCODE)" + } + + # `wingetcreate` n'a pas de commande `validate` autonome, et `submit` exige + # une authentification même pour valider : sans jeton, la validation + # officielle ne peut pas tourner ici. Elle a alors lieu à chaque PR, dans + # le job `packaging-win` de ci.yml, contre les schémas JSON de winget-pkgs. + - name: WINGET_CREATE_GITHUB_TOKEN absent — manifestes non soumis + if: env.WINGET_CREATE_TOKEN == '' + shell: bash + run: | + echo "::warning title=WinGet non publie::WINGET_CREATE_GITHUB_TOKEN absent ; les manifestes rendus sont valides (job packaging-win de ci.yml) mais aucune pull request n'est ouverte." + + # --------------------------------------------------------------------------- + # Scoop — commit vers we-data-ch/scoop-bucket, après installation réelle + # + # Contrairement à WinGet, un bucket est un dépôt ordinaire : pas de compte à + # créer, pas de validation à passer. Le contrôle utile n'est donc pas la + # validation du manifeste — Scoop n'en fait pas au commit — mais + # l'installation : `scoop install ` télécharge l'archive + # publiée, en vérifie l'empreinte, l'extrait et crée le shim `typr`. C'est le + # seul endroit où toute la chaîne est exercée pour de vrai. + # --------------------------------------------------------------------------- + scoop: + name: Scoop bucket + needs: [verify, release] + runs-on: windows-latest + env: + SCOOP_BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }} + # Le bucket est écrit par un clone HTTPS : le jeton apparaît dans l'URL + # donnée à git, jamais dans un argument de ligne de commande. + BUCKET_URL: https://x-access-token:${{ secrets.SCOOP_BUCKET_TOKEN }}@github.com/we-data-ch/scoop-bucket.git + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.verify.outputs.tag }} + + - name: Récupérer checksums.txt de la release + shell: bash + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + set -euo pipefail + curl -fsSfL \ + "https://github.com/${{ github.repository }}/releases/download/$TAG/checksums.txt" \ + -o checksums.txt + cat checksums.txt + + - name: Générer les manifestes depuis checksums.txt + shell: bash + env: + TAG: ${{ needs.verify.outputs.tag }} + run: | + set -euo pipefail + packaging/render.sh --tag "$TAG" --checksums checksums.txt --out manifests + find manifests -type f | sort + + # Installation réelle depuis le manifeste local : Scoop accepte un chemin + # vers un fichier de manifeste, ce qui évite d'avoir à publier le bucket + # avant de pouvoir tester. L'archive est celle de la release, son + # empreinte est celle du manifeste — une erreur de sélection se verrait + # ici, à l'extraction. + # + # `scoop install` échoue si le hash ne correspond pas, mais on le vérifie + # quand même séparément : Scoop passe alors par aria2, dont les messages + # parlent de miroir et pas d'empreinte. + - name: Scoop installe le binaire publié et le lance + shell: pwsh + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + $ErrorActionPreference = 'Stop' + + # Le manifeste porte un tiret cadratin ; lu avec l'encodage de + # console par défaut de Windows PowerShell 5.1, il devient illisible. + # `pwsh` est en UTF-8 par défaut, on le dit quand même. + [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + $OutputEncoding = [System.Text.Encoding]::UTF8 + + Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression + Write-Host "scoop : $(scoop --version)" + + scoop install "manifests\scoop\typr.json" + if ($LASTEXITCODE -ne 0) { + throw "scoop install a échoué (code $LASTEXITCODE)" + } + + # L'installateur de Scoop met les shims dans le PATH *de l'utilisateur*, + # donc du PATH du processus seulement à la prochaine session : sans + # cette ligne, `typr` serait introuvable alors que l'installation a + # réussi. + $env:PATH = "$env:USERPROFILE\scoop\shims;$env:PATH" + + # `typr --version` répond `typr ` (clap, `#[command(version)]`). + $reported = (& typr --version) -join ' ' + Write-Host "typr --version : $reported" + if ($reported -notmatch [regex]::Escape($env:VERSION)) { + throw "le binaire installé annonce « $reported », pas la version $env:VERSION de la release" + } + + - name: Pousser le bucket + if: env.SCOOP_BUCKET_TOKEN != '' + shell: bash + env: + VERSION: ${{ needs.verify.outputs.version }} + run: | + set -euo pipefail + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + # Clone complet, pas `--depth 1` : en cas de push refusé il faut pouvoir + # rebaser, et un clone superficiel refuse de rebaser sur un historique + # qu'il n'a pas. + if ! git clone "$BUCKET_URL" bucket; then + echo "::error::le dépôt we-data-ch/scoop-bucket est inaccessible — il doit exister avant la première release (README.md du dépôt : « Prérequis »)." + exit 1 + fi + + mkdir -p bucket/bucket bucket/bin/typr + cp manifests/scoop/bucket/typr.json bucket/bucket/typr.json + cp "manifests/scoop/bin/typr/$VERSION.json" "bucket/bin/typr/$VERSION.json" + cp manifests/scoop/README.md bucket/README.md + cp LICENSE bucket/LICENSE + + # Un bucket garde l'historique : `typr update` sert à cela. On n'en + # conserve que les dix dernières versions, classées par ordre d'ajout + # dans l'historique — un tri alphabétique des versions serait faux, et + # `0.5.9` se retrouverait devant `0.5.12`. + # + # `git log` sort du plus récent au plus ancien : il suffit donc de + # laisser passer les dix premières lignes pour purger les plus anciennes. + # + # Le tout premier commit tombe dans un dépôt vide, où `git log` n'a rien + # à raconter et sortirait en erreur. + if git -C bucket rev-parse --verify HEAD >/dev/null 2>&1; then + git -C bucket log --diff-filter=A --name-only --format= -- 'bucket/bin/typr/*.json' \ + | grep -v '^$' | awk '!seen[$0]++' | tail -n +11 \ + | while read -r old; do + git -C bucket rm -q "$old" + echo " purgé : $old" + done + else + echo "bucket vide : premier commit, rien à purger." + fi + + git -C bucket add bucket/typr.json "bucket/bin/typr/$VERSION.json" README.md LICENSE + if git -C bucket diff --cached --quiet; then + echo "Le bucket est déjà à jour ($VERSION)." + exit 0 + fi + git -C bucket commit -m "TypR $VERSION" + + # Le bucket a ses propres contributeurs : on rebase plutôt que + # d'écraser. + for attempt in 1 2 3; do + if git -C bucket push origin HEAD:main; then + echo "bucket $VERSION poussé (tentative $attempt)." + exit 0 + done + echo "push refusé — rebase sur origin/main et nouvelle tentative" + git -C bucket fetch origin main + git -C bucket rebase origin/main + done + echo "::error::impossible de pousser le bucket après 3 tentatives" + exit 1 + + - name: SCOOP_BUCKET_TOKEN absent — bucket non mis à jour + if: env.SCOOP_BUCKET_TOKEN == '' + shell: bash + run: | + echo "::warning title=Scoop non publie::SCOOP_BUCKET_TOKEN absent ; le bucket garde sa version precedente (l installation reelle, elle, a ete verifiee)." diff --git a/.gitignore b/.gitignore index 19fdf112..4cac2d82 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,7 @@ lcov.info # Agent guidelines AGENTS.md CLAUDE.md +/.claude/ # Documentation function_type_checking.md diff --git a/Cargo.lock b/Cargo.lock index 85b8ea51..ef666aa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,6 +151,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -204,7 +213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core", ] @@ -301,6 +310,15 @@ dependencies = [ "num-traits 0.1.43", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -334,6 +352,16 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.24.1" @@ -391,6 +419,16 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -576,6 +614,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1387,6 +1435,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1835,9 +1894,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "typr" -version = "0.5.12" +version = "0.6.0" dependencies = [ "typr-cli", "typr-core", @@ -1845,7 +1910,7 @@ dependencies = [ [[package]] name = "typr-cli" -version = "0.5.12" +version = "0.6.0" dependencies = [ "anyhow", "bincode", @@ -1859,19 +1924,21 @@ dependencies = [ "rustyline", "serde", "serde_json", + "sha2", "syntect", "tap", "thiserror", "tokio", "toml", "typr-core", + "typr-graph", "typr-lsp", "typr-mcp", ] [[package]] name = "typr-core" -version = "0.5.12" +version = "0.6.0" dependencies = [ "anyhow", "bincode", @@ -1891,9 +1958,19 @@ dependencies = [ "typr-core", ] +[[package]] +name = "typr-graph" +version = "0.6.0" +dependencies = [ + "insta", + "serde", + "serde_json", + "typr-core", +] + [[package]] name = "typr-lsp" -version = "0.5.12" +version = "0.6.0" dependencies = [ "anyhow", "bincode", @@ -1919,7 +1996,7 @@ dependencies = [ [[package]] name = "typr-mcp" -version = "0.5.12" +version = "0.6.0" dependencies = [ "anyhow", "rmcp", @@ -1932,12 +2009,13 @@ dependencies = [ [[package]] name = "typr-wasm" -version = "0.5.12" +version = "0.6.0" dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", "typr-core", + "typr-graph", "wasm-bindgen", "web-sys", ] @@ -2001,6 +2079,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index f064cc27..c37d7956 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/typr-lsp", "crates/typr-wasm", "crates/typr-mcp", + "crates/typr-graph", ] [package] @@ -32,7 +33,7 @@ typr-cli.workspace = true typr-core.workspace = true [workspace.package] -version = "0.5.12" +version = "0.6.0" edition = "2021" authors = ["Fabrice Hategekimana "] license = "Apache-2.0" @@ -52,6 +53,7 @@ rand = "0.10.0" anyhow = "1.0.100" countmap = "0.2" toml = "0.8" +sha2 = "0.10" clap = { version = "4.5", features = ["derive"] } tokio = { version = "1", features = ["full"] } @@ -63,11 +65,12 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" -typr-core = { path = "crates/typr-core", version = "0.5" } -typr-cli = { path = "crates/typr-cli", version = "0.5" } -typr-lsp = { path = "crates/typr-lsp", version = "0.5" } -typr-wasm = { path = "crates/typr-wasm", version = "0.5" } -typr-mcp = { path = "crates/typr-mcp", version = "0.5" } +typr-core = { path = "crates/typr-core", version = "0.6" } +typr-cli = { path = "crates/typr-cli", version = "0.6" } +typr-lsp = { path = "crates/typr-lsp", version = "0.6" } +typr-wasm = { path = "crates/typr-wasm", version = "0.6" } +typr-mcp = { path = "crates/typr-mcp", version = "0.6" } +typr-graph = { path = "crates/typr-graph", version = "0.6" } [profile.release] opt-level = 3 diff --git a/R/MyModule.R b/R/MyModule.R new file mode 100644 index 00000000..b876fb23 --- /dev/null +++ b/R/MyModule.R @@ -0,0 +1,7 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R +MyModule <- new.env(parent = emptyenv()) +local({ + +}) \ No newline at end of file diff --git a/RELEASING.md b/RELEASING.md index ed9fb4f1..b0cdd135 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -94,16 +94,20 @@ tag vX.Y.Z │ ├─ verify ......... refuse si tag ≠ Cargo.toml ≠ éditeurs │ - ├─ build .......... 6 cibles (Linux/Windows/macOS × x86_64/aarch64) + ├─ build .......... 8 cibles (Linux musl + Linux GNU + Windows + macOS, + │ × x86_64/aarch64) │ └─ release .... GitHub Release + checksums SHA-256 │ ├─ docker ..... image depuis le binaire Linux, tags X.Y.Z + latest │ ├─ rstudio .... tarball R avec les binaires frais embarqués │ └─ docs ....... repository_dispatch → reconstruction du site │ - ├─ crates-io ...... typr-core → typr-lsp → typr-cli → typr - ├─ wasm ........... WASM + version.json → dépôt du playground - ├─ vscode ......... .vsix → Marketplace + Open VSX + attaché à la release - └─ vim ............ tarball du plugin, attaché à la release + ├─ crates-io ...... typr-core → typr-lsp → typr-cli → typr + ├─ wasm ........... WASM + version.json → dépôt du playground + ├─ vscode ......... .vsix → Marketplace + Open VSX + attaché à la release + ├─ vim ............ tarball du plugin, attaché à la release + ├─ brew ........... formule générée → tap `we-data-ch/homebrew-typr` + ├─ winget ......... manifestes générés → PR `microsoft/winget-pkgs` + └─ scoop .......... manifeste généré → bucket `we-data-ch/scoop-bucket` ``` Les branches sont indépendantes : si le Marketplace échoue, les binaires et @@ -150,6 +154,76 @@ en 0.5.7 et que les releases GitHub annonçaient 0.5.7. | `VSCE_PAT` | `vscode` | Azure DevOps → PAT, scope *Marketplace: Manage* | | `PLAYGROUND_DEPLOY_TOKEN` | `wasm` | PAT GitHub, scope `repo` sur le dépôt du playground | | `DOCS_DISPATCH_TOKEN` | `docs` | PAT GitHub, scope `repo` sur `typr.github.io` | +| `HOMEBREW_TAP_TOKEN` | `brew` | PAT GitHub, scope `repo` sur `homebrew-typr` | +| `WINGET_CREATE_GITHUB_TOKEN` | `winget` | PAT GitHub, scope `public_repo` (lu par `wingetcreate` dans l'environnement) | +| `SCOOP_BUCKET_TOKEN` | `scoop` | PAT GitHub, écriture sur `scoop-bucket` | + +## Le tap Homebrew + +`we-data-ch/homebrew-typr` ne contient que `Formula/typr.rb`, `README.md` et +`LICENSE` — aucun binaire. Le job `brew` génère la formule à partir du +`checksums.txt` de la release, la fait valider par `brew style` et +`brew audit --strict`, puis la commite. La formule n'est donc jamais écrite à la +main : voir `packaging/homebrew/README.md`. + +Le dépôt a été créé le 30 septembre 2026 et contient déjà la formule de +v0.5.12 ; la CI le tient ensuite à chaque tag. S'il fallait le refaire, le geste +est : + +```bash +gh repo create we-data-ch/homebrew-typr --public --description "Tap Homebrew pour TypR" +``` + +Deux façons de le tenir, à trancher une fois pour toutes : + +- **`main` libre** — la CI pousse directement, comme `vscode` et `wasm` le font + pour leurs dépôts. Le tag fait foi, et le tap ne peut jamais être en retard. +- **`main` protégé, admission via le dépôt `we-data-ch/homebrew-typr-autoupdate`** + — la CI n'écrit plus dans le tap mais dans le dépôt d'admission, qui ouvre une + PR que l'on relit. `homebrew-typr/typr` reste installable entre-temps, par ce que + `brew install` lit est la branche par défaut. Le tap devient alors impossible à + modifier sans relecture humaine, au prix d'un `brew update-autoupdate` à + installer sur les postes des mainteneurs. + +Le choix du lot 3 est le premier : il est cohérent avec le reste de la chaîne, et +une formule générée par la CI depuis le `checksums.txt` n'a pas à être relue — ce +qui la relèverait, c'est son unicité, pas sa rédaction. + +## Les canaux WinGet et Scoop + +Aucun binaire non plus : `winget-pkgs` ne publie que trois fichiers YAML, et le +bucket Scoop que des URL et leurs SHA-256. Les deux canaux lisent le **même** +`checksums.txt` que la formule Homebrew, et sont rendus par le même script, +`packaging/render.sh` — voir `packaging/README.md`. + +Ce que les deux jobs font de plus que le tap, c'est **installer pour de vrai** +avant de publier : + +- `winget` télécharge les deux archives Windows et compare leurs empreintes à + celles des manifestes rendus, puis passe le dossier à `wingetcreate submit`, + qui le valide avec le validateur officiel avant d'ouvrir la PR ; +- `scoop` lance `scoop install` sur le manifeste local, attend le shim `typr`, et + exige que `typr --version` annonce la version de la release. + +Un canal qui refuse d'installer ne publie donc rien — mieux vaut un job rouge +qu'un manifeste que personne ne peut installer. + +**Le dépôt `we-data-ch/scoop-bucket` doit exister avant le premier tag.** Le job +`clone` échoue bruyamment s'il est absent, parce qu'un `git push` vers une URL +inexistante échouerait lui aussi, mais trois minutes plus tard : + +```bash +gh repo create we-data-ch/scoop-bucket --public --description "Bucket Scoop pour TypR" +``` + +Les secrets manquants ne bloquent pas la release : le rendu et les installations +locales ont lieu, seule la publication est sautée avec un avertissement. C'est +voulu — pour qu'un tag serve à autre chose qu'à produire un job rouge. + +Le bucket garde dix versions, purgées par ordre d'ajout dans l'historique et non +par ordre alphabétique. `typr update` sert à cela : sans purge, un utilisateur qui +n'aurait pas fait `scoop update` pendant un an verrait une liste de manifests +toujours plus longue. ## Aucun binaire dans le dépôt diff --git a/STORAGE.db b/STORAGE.db new file mode 100644 index 00000000..e69de29b diff --git a/cases/0062-untyped-r-function-callable/case.toml b/cases/0062-untyped-r-function-callable/case.toml new file mode 100644 index 00000000..974bf80e --- /dev/null +++ b/cases/0062-untyped-r-function-callable/case.toml @@ -0,0 +1,6 @@ +title = "untyped R function (`function(a, b)`) is callable, arity-checked, result `Any` (RFC 0028)" +cmd = "build" +layer = "type" +status = "fixed" +created = "2026-09-12" +origin = "perso" diff --git a/cases/0062-untyped-r-function-callable/expect.md b/cases/0062-untyped-r-function-callable/expect.md new file mode 100644 index 00000000..f1ed659c --- /dev/null +++ b/cases/0062-untyped-r-function-callable/expect.md @@ -0,0 +1,28 @@ +# untyped-r-function-callable + +Source: `rfcs/0028-calling-untyped-r-functions.md`, accepted 2026-09-12 +(we-data-ch/typr#28, tracking we-data-ch/typr#29). + +## Ce qui devrait se passer + +`function(a, b) { ... }` parses to `Lang::RFunction` and used to type as the placeholder +`Type::UnknownFunction`, which carries no parameter list — so any call with at least one +argument failed with `No signature of function 'my_addition' matches this call ... () -> +UnknownFunction`, even though the emitted R was already correct. Per the RFC, `Lang::RFunction` +with `n` parameters now types as `(Any, ..., Any) -> Any`: arity is checked (exactly `n` +arguments), argument types are not, and the result must be brought back into the type system +explicitly with `as!` — the same shape as the rest of TypR's opaque values. + +This is the exact `docs/philosophy/intro.md` example ("weak on safety, strong on freedom"), +which the RFC's Motivation section shows failing to compile on 0.5.10. + +## Vérification + +Implemented alongside this case: `Lang::RFunction`'s typing rule +(`processes/type_checking/mod.rs`) now builds a real `Type::Function` instead of +`Type::UnknownFunction`. The emitted R is unchanged — `my_addition(num1, num2)` — since only the +type checker ever rejected this call. + +## Statut + +Kept as a regression net for the RFC's core guarantee: an untyped R function must stay callable. diff --git a/cases/0062-untyped-r-function-callable/expect.toml b/cases/0062-untyped-r-function-callable/expect.toml new file mode 100644 index 00000000..5081f647 --- /dev/null +++ b/cases/0062-untyped-r-function-callable/expect.toml @@ -0,0 +1,15 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +[[rule]] +file = "@run" +must_not_contain = "doesn't match type" + +[[rule]] +file = "R/main.R" +must_contain = "my_addition(num1, num2)" diff --git a/cases/0062-untyped-r-function-callable/repro/TypR/main.ty b/cases/0062-untyped-r-function-callable/repro/TypR/main.ty new file mode 100644 index 00000000..5b012eba --- /dev/null +++ b/cases/0062-untyped-r-function-callable/repro/TypR/main.ty @@ -0,0 +1,13 @@ +#@case untyped-r-function-callable: RFC 0028 (0028-calling-untyped-r-functions) +# makes an untyped R function (`function(a, b) {...}`) callable: arity is +# checked, argument types are not, and the result is typed Any. This is the +# exact motivating example from the RFC and from docs/philosophy/intro.md. +let num1 <- 3; +let num2 <- 7; + +let my_addition <- function(a, b) { + a + b +}; + +let total: int <- my_addition(num1, num2) as! int; +print(total); diff --git a/cases/0063-untyped-r-function-arity-error/case.toml b/cases/0063-untyped-r-function-arity-error/case.toml new file mode 100644 index 00000000..bb73c686 --- /dev/null +++ b/cases/0063-untyped-r-function-arity-error/case.toml @@ -0,0 +1,6 @@ +title = "wrong arity on an untyped R function reports a dedicated message, not `UnknownFunction`" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-12" +origin = "perso" diff --git a/cases/0063-untyped-r-function-arity-error/expect.md b/cases/0063-untyped-r-function-arity-error/expect.md new file mode 100644 index 00000000..591606f0 --- /dev/null +++ b/cases/0063-untyped-r-function-arity-error/expect.md @@ -0,0 +1,37 @@ +# untyped-r-function-arity-error + +Source: `rfcs/0028-calling-untyped-r-functions.md`, accepted 2026-09-12 +(we-data-ch/typr#28, tracking we-data-ch/typr#29), Reference-level explanation, "Error messages". + +## Ce qui devrait se passer + +Before the RFC, any call to an untyped R function was rejected by the *arity* check that +`Type::UnknownFunction` (0-ary) implicitly enforced, and the message printed the internal +placeholder back at the user: + +``` +Type error: No signature of function 'my_addition' matches this call. + help: 'my_addition' exists but none of its signature(s) accepts these arguments: + () -> UnknownFunction +``` + +Now that the function is callable, the only thing left to check is arity, and the RFC specifies +a dedicated message for it instead of reusing the generic no-matching-signature wording: + +``` +Type error: 'my_addition' is an untyped R function taking 2 argument(s), called with 3. + help: its body is not type-checked; only the number of arguments is. +``` + +## Vérification + +Implemented alongside case `untyped-r-function-callable`: `TypeError::UntypedFunctionArity` +(`components/error_message/type_error.rs`, code `T044`), raised in +`processes/type_checking/function_application.rs` when a call's argument count doesn't match a +signature that is exactly one, non-variadic, all-`Any` parameters returning `Any` — the shape +`Lang::RFunction`'s typing rule now produces (`FunctionType::is_r_function`). + +## Statut + +Kept as a regression net: the internal `UnknownFunction` placeholder must never resurface in a +user-facing error message again. diff --git a/cases/0063-untyped-r-function-arity-error/expect.toml b/cases/0063-untyped-r-function-arity-error/expect.toml new file mode 100644 index 00000000..0499d914 --- /dev/null +++ b/cases/0063-untyped-r-function-arity-error/expect.toml @@ -0,0 +1,15 @@ +[[rule]] +file = "@run" +must_contain = "Type errors found" + +[[rule]] +file = "@run" +must_contain = "is an untyped R function taking 2 argument(s)" + +[[rule]] +file = "@run" +must_contain = "only the number of arguments is" + +[[rule]] +file = "@run" +must_not_contain = "UnknownFunction" diff --git a/cases/0063-untyped-r-function-arity-error/repro/TypR/main.ty b/cases/0063-untyped-r-function-arity-error/repro/TypR/main.ty new file mode 100644 index 00000000..7f732a2b --- /dev/null +++ b/cases/0063-untyped-r-function-arity-error/repro/TypR/main.ty @@ -0,0 +1,9 @@ +#@case untyped-r-function-arity-error: RFC 0028 - calling an untyped R +# function with the wrong number of arguments must report a specific arity +# error, not the generic "no signature matches" message that exposed the +# internal UnknownFunction placeholder. +let my_addition <- function(a, b) { + a + b +}; + +my_addition(1, 2, 3) diff --git a/cases/0064-untyped-preloaded-builtin-variadic/case.toml b/cases/0064-untyped-preloaded-builtin-variadic/case.toml new file mode 100644 index 00000000..3cc9fc85 --- /dev/null +++ b/cases/0064-untyped-preloaded-builtin-variadic/case.toml @@ -0,0 +1,6 @@ +title = "preloaded untyped base-R builtins (`Position`, `t`, `Reduce`, …) become variadic (RFC 0028)" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-12" +origin = "perso" diff --git a/cases/0064-untyped-preloaded-builtin-variadic/expect.md b/cases/0064-untyped-preloaded-builtin-variadic/expect.md new file mode 100644 index 00000000..90e834bc --- /dev/null +++ b/cases/0064-untyped-preloaded-builtin-variadic/expect.md @@ -0,0 +1,29 @@ +# untyped-preloaded-builtin-variadic + +Source: `rfcs/0028-calling-untyped-r-functions.md`, accepted 2026-09-12 +(we-data-ch/typr#28, tracking we-data-ch/typr#29), Motivation and point 4 of the Reference-level +explanation ("Point 4 is the load-bearing half"). + +## Ce qui devrait se passer + +The untyped names from `functions_R.txt` (`Position`, `t`, `Reduce`, …) are preloaded as +`(Any, UnknownFunction)` so that referring to them is not an "undefined variable" error. +Before the RFC, `Type::UnknownFunction` was effectively 0-ary (`FunctionType::new(VecType::Empty, +vec![], ...)`), so `Position()` type-checked but `Position(1, 2)` failed the same way a +user-written `function(...)` did. Fixing only `Lang::RFunction` (case +`untyped-r-function-callable`) without also fixing `UnknownFunction` would leave the same bug +reachable through every untyped base-R name — this is the "confusing version of the bug rather +than a fix" the RFC warns about. `UnknownFunction` must be variadic: any number of arguments, +result `Any`. + +## Vérification + +Implemented alongside case `untyped-r-function-callable`: `Type::UnknownFunction`'s conversion to +`FunctionType` (`components/type/mod.rs`'s `to_function_type`, and the matching +`TryFrom for FunctionType` arm in `components/type/function_type.rs`) now produces a single +variadic `Any` parameter returning `Any`, instead of zero parameters. + +## Statut + +Kept as a regression net: every untyped base-R builtin must stay callable with arguments, not +just referenceable by name. diff --git a/cases/0064-untyped-preloaded-builtin-variadic/expect.toml b/cases/0064-untyped-preloaded-builtin-variadic/expect.toml new file mode 100644 index 00000000..e29aeef5 --- /dev/null +++ b/cases/0064-untyped-preloaded-builtin-variadic/expect.toml @@ -0,0 +1,11 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +[[rule]] +file = "@run" +must_not_contain = "UnknownFunction" diff --git a/cases/0064-untyped-preloaded-builtin-variadic/repro/TypR/main.ty b/cases/0064-untyped-preloaded-builtin-variadic/repro/TypR/main.ty new file mode 100644 index 00000000..0c92f13f --- /dev/null +++ b/cases/0064-untyped-preloaded-builtin-variadic/repro/TypR/main.ty @@ -0,0 +1,6 @@ +#@case untyped-preloaded-builtin-variadic: RFC 0028 - preloaded untyped +# base-R builtins (functions_R.txt: Position, t, Reduce, ...) are preloaded +# as `(Any, UnknownFunction)`; `UnknownFunction` must be variadic so calling +# them with arguments type-checks, not just referencing the bare name. +let x: int <- Position(1, 2) as! int; +print(x); diff --git a/cases/0065-reassign-widen-literal/case.toml b/cases/0065-reassign-widen-literal/case.toml new file mode 100644 index 00000000..2e382bd7 --- /dev/null +++ b/cases/0065-reassign-widen-literal/case.toml @@ -0,0 +1,7 @@ +title = "reassignment narrows a mutable variable to the RHS literal's singleton type" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-12" +origin = "perso" +checked = false diff --git a/cases/0065-reassign-widen-literal/expect.md b/cases/0065-reassign-widen-literal/expect.md new file mode 100644 index 00000000..dc2ac7f9 --- /dev/null +++ b/cases/0065-reassign-widen-literal/expect.md @@ -0,0 +1,36 @@ +# reassign-widen-literal + +Source: found while updating `docs/reference/bindings-mutation.md` on `typr.github.io` — its +"Reassignment & mutation" example (`let x <- 0; x <- 10; x <- x + 1; x |> f() |> g()!;`) no +longer type-checks. + +## Ce qui devrait se passer + +Reassigning an already-bound `let`-declared variable (`x <- 10;`, no `let`) to a new value of the +same base kind should keep working across any number of reassignments — that's the entire point +of `docs/reference/bindings-mutation.md`'s "Reassignment & mutation" section and of the `expr!;` +implicit-mutation sugar (`ai_context/in_place.md`), which desugars to exactly this form +(`lhs <- expr`). + +## Anomalies + +`observed.txt` shows three failures for one linear sequence of reassignments to a single `int` +variable: + +1. `let x <- 0;` (no annotation) infers `x` as the *singleton* literal type `Integer(Val(0))` — + confirmed deliberate elsewhere (`test_let_expression0`, + `crates/typr-core/src/processes/type_checking/let_expression.rs`: `let a <- 5;` types `a` as + `integer_type(5)`). Reassigning to a *different* literal (`x <- 10;`) then fails the subtype + check outright: "type 0 doesn't match type 10". +2. Even past that (e.g. with an explicit `let x: int <- 0;` instead), the *first* successful + assignment rebinds `x`'s type in context to the literal RHS type (`Integer(Val(10))`) rather + than keeping it widened — so the *next* reassignment fails just the same: + "type 10 doesn't match type int" — `x`'s type keeps narrowing one literal at a time instead + of staying at its base kind. +3. The `!;` implicit-mutation sugar (`x |> f() |> g()!;`) desugars to the same `Assign` node + (`ai_context/in_place.md` §5.2), so it inherits the same failure once `x` has narrowed. + +Root cause: `Lang::Assign`'s typing arm (`processes/type_checking/mod.rs`) stores the RHS's raw +type as the variable's new type on every successful assignment, instead of widening a literal +singleton to its base kind the way `Type::generalize()` already does elsewhere (e.g. +`cases/0017-char-if-widening`). diff --git a/cases/0065-reassign-widen-literal/expect.toml b/cases/0065-reassign-widen-literal/expect.toml new file mode 100644 index 00000000..aedb1f85 --- /dev/null +++ b/cases/0065-reassign-widen-literal/expect.toml @@ -0,0 +1,11 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +[[rule]] +file = "@run" +must_not_contain = "doesn't match type" diff --git a/cases/0065-reassign-widen-literal/observed.txt b/cases/0065-reassign-widen-literal/observed.txt new file mode 100644 index 00000000..90527111 --- /dev/null +++ b/cases/0065-reassign-widen-literal/observed.txt @@ -0,0 +1,41 @@ +# exit=1 + +## stdout/stderr + + Parsing... done (7 ms) + Type checking... failed +Type errors found: + × Type error: type 0 doesn't match type 10 + ╭─[TypR/main.ty:5:2] + 4 │ + 5 │ x <- 10; + · ▲ ┬ + · │ ╰── Received 10 + · ╰── Expected 0 + 6 │ x <- x + 1; + ╰──── + + × Type error: type 0 doesn't match type int + ╭─[TypR/main.ty:6:2] + 5 │ x <- 10; + 6 │ x <- x + 1; + · ▲ ┬ + · │ ╰── Received int + · ╰── Expected 0 + 7 │ + ╰──── + + × Type error: type 0 doesn't match type int + ╭─[TypR/main.ty:8:14] + 7 │ + 8 │ x |> f() |> g()!; + · ┬ ▲ + · │ ╰── Expected 0 + · ╰── Received int + 9 │ + ╰──── + + + +## R généré + diff --git a/cases/0065-reassign-widen-literal/repro/DESCRIPTION b/cases/0065-reassign-widen-literal/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0065-reassign-widen-literal/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0065-reassign-widen-literal/repro/NAMESPACE b/cases/0065-reassign-widen-literal/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0065-reassign-widen-literal/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0065-reassign-widen-literal/repro/R/.gitkeep b/cases/0065-reassign-widen-literal/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0065-reassign-widen-literal/repro/TypR/main.ty b/cases/0065-reassign-widen-literal/repro/TypR/main.ty new file mode 100644 index 00000000..47111a6c --- /dev/null +++ b/cases/0065-reassign-widen-literal/repro/TypR/main.ty @@ -0,0 +1,10 @@ +let x <- 0; +let f <- fn(a: int): int { a + 1 }; +let g <- fn(a: int): int { a * 2 }; + +x <- 10; +x <- x + 1; + +x |> f() |> g()!; + +print(x); diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/cache/typr/types/gizmo/35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88/typr-def.toml b/cases/0066-external-type-definition-low-tier-degrades-to-any/cache/typr/types/gizmo/35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88/typr-def.toml new file mode 100644 index 00000000..02e3ab4b --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/cache/typr/types/gizmo/35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88/typr-def.toml @@ -0,0 +1,13 @@ +format_version = 1 +[package] +name = "gizmo" +since = "1.0.0" +[definition] +version = "0.1.0" +tier = "T3" +[provider] +type = "community" +repository = "github:test/typr-gizmo" +[capabilities] +r_shims = false +extern_raw = false diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/case.toml b/cases/0066-external-type-definition-low-tier-degrades-to-any/case.toml new file mode 100644 index 00000000..fd0a5ecf --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/case.toml @@ -0,0 +1,6 @@ +title = "a locked external Type Definition below the project's `trust` threshold degrades to Any, never a hard error" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-13" +origin = "perso" diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.md b/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.md new file mode 100644 index 00000000..ecd1ff2a --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.md @@ -0,0 +1,44 @@ +# external-type-definition-low-tier-degrades-to-any + +Source: `typR/registry.md` §5.4 (D2 — "an unreliable definition widens to `Any`, it never fails +a build") and §13 J2, last open checklist item ("`cases/`: définition absente / tier bas / +version trop ancienne → jamais d'erreur dure"). + +## Ce qui devrait se passer + +`gizmo` is locked in `typr.lock` at tier `T3` and cached under +`cache/typr/types/gizmo//` (this case bundles that cache directory itself, mirrored at +`$XDG_CACHE_HOME/typr/types/…`, since the real cache lives outside any project directory — +`cases.rs::build_sandbox` now copies a case's `cache/` folder into the sandbox and points +`$XDG_CACHE_HOME` at it before invoking `typr`). Its one declared symbol, +`@spin_gizmo: (int) -> int;`, is a plain declaration with no real R binding behind it. + +The project has no `typr.toml`, so `standard_library::load_project_type_definitions` applies the +default `trust = "T2"`. `T3 < T2`, so `names_below_trust` must widen `spin_gizmo` to +`VarType::degrade_to_any` — the `(Any, UnknownFunction)` pair, callable with any argument type, +arity-checked against the declared parameter count. + +`main.ty` calls `spin_gizmo("not-an-int")` — the wrong argument *type* for the declared +`(int) -> int` signature, but the right *arity* (one argument). This is a 3-way discriminator: + +- degradation broken and the definition never loads at all → `spin_gizmo` is an unknown symbol → + hard type error ("unknown function"/"Type errors found"); +- degradation broken but the definition loads with its literal declared signature → the `string` + argument doesn't match `int` → hard type error; +- degradation working as designed → arity matches, argument type isn't checked → `typr check` + succeeds. + +## Vérification + +Implemented already (`typR/registry.md` §13 J2, "seuil `trust` + règle de dégradation" and +"brancher `typr.lock` sur `check`/`build`/`run`"): `crates/typr-cli/src/standard_library.rs` +(`load_external_ty_definitions`, `names_below_trust`, `load_project_type_definitions`) and +`crates/typr-cli/src/type_registry.rs` (`resolve_locked_definitions`). This case is the +end-to-end regression net through the real CLI that those checklist items were missing — +previously only exercised by `type_registry.rs`'s `#[cfg(test)]` module, not by `typr case run`. + +## Statut + +Kept as a regression net: any future change that stops loading a locked, cached, low-tier +definition — or that starts enforcing its declared types instead of degrading them — breaks +this case. diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.toml b/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.toml new file mode 100644 index 00000000..0cacae24 --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/expect.toml @@ -0,0 +1,20 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +# If cache resolution were broken (e.g. `typr.lock`'s digest not matching +# the cache, or `$XDG_CACHE_HOME` not reaching the sandboxed invocation), +# `resolve_one_locked_definition` skips the package with one of these +# messages and `spin_gizmo` falls back to a genuinely unknown symbol — which +# *does* raise a hard type error (unlike a degraded-but-loaded definition). +[[rule]] +file = "@run" +must_not_contain = "not in the local cache" + +[[rule]] +file = "@run" +must_not_contain = "no longer matches typr.lock" diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/TypR/main.ty b/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/TypR/main.ty new file mode 100644 index 00000000..351f3682 --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/TypR/main.ty @@ -0,0 +1,9 @@ +#@case external-type-definition-low-tier-degrades-to-any: registry.md §5.4 (D2) — +# a locked external Type Definition entry below the project's `trust` threshold +# must degrade to a variadic `Any` function, never a hard type error. `gizmo` +# is declared `@spin_gizmo: (int) -> int;` at tier `T3` (typr.lock, cache/) but +# the project has no `typr.toml`, so the default `trust = "T2"` applies: +# `spin_gizmo` must load as callable-with-anything, not as its strict +# signature and not as a wholly unknown symbol. +let result <- spin_gizmo("not-an-int"); +print(result); diff --git a/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/typr.lock b/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/typr.lock new file mode 100644 index 00000000..8af6f6f0 --- /dev/null +++ b/cases/0066-external-type-definition-low-tier-degrades-to-any/repro/typr.lock @@ -0,0 +1,10 @@ +# typr.lock — generated by `typr types`, commit this file. +# See typR/registry.md §7.1 and rfcs/0031-external-type-definitions.md. + +[[definition]] +package = "gizmo" +repository = "github:test/typr-gizmo" +version = "0.1.0" +rev = "0000000000000000000000000000000000000000" +digest = "sha256:35ec2ef2f2cb7a5cdb1558be7846c2630f6be628afc09b1690ceda337263ea88" +tier = "T3" diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/cache/typr/types/sprocket/1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7/typr-def.toml b/cases/0067-external-type-definition-old-version-degrades-to-any/cache/typr/types/sprocket/1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7/typr-def.toml new file mode 100644 index 00000000..e90d0a3c --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/cache/typr/types/sprocket/1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7/typr-def.toml @@ -0,0 +1,13 @@ +format_version = 1 +[package] +name = "sprocket" +since = "2.0.0" +[definition] +version = "0.1.0" +tier = "T1" +[provider] +type = "community" +repository = "github:test/typr-sprocket" +[capabilities] +r_shims = false +extern_raw = false diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/case.toml b/cases/0067-external-type-definition-old-version-degrades-to-any/case.toml new file mode 100644 index 00000000..aeecdc1c --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/case.toml @@ -0,0 +1,6 @@ +title = "a locked external Type Definition observed below its manifest's `since` floor degrades to Any even at tier T1" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-13" +origin = "perso" diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/expect.md b/cases/0067-external-type-definition-old-version-degrades-to-any/expect.md new file mode 100644 index 00000000..bf0b3580 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/expect.md @@ -0,0 +1,34 @@ +# external-type-definition-old-version-degrades-to-any + +Source: `typR/registry.md` §7.2 ("Compatibilité de versions : borne minimale, pas plage +fermée") and §13 J2, last open checklist item. + +## Ce qui devrait se passer + +`sprocket` is locked at tier `T1` — normally trusted outright, regardless of the project's +`trust` threshold — but `typr.lock` records `r_version_seen = "1.0.0"`, below the manifest's +`since = "2.0.0"` floor. Per §7.2, a version below `since` must **warn and degrade to `Any`**, +never refuse the build, and the degradation applies to *every* declared name in the definition, +independent of that name's own tier (`standard_library::degrade_if_version_out_of_range` widens +`all_declared_names`, not just the ones under `trust`). + +`main.ty` calls `turn_sprocket("not-an-int")` — right arity, wrong argument type for the +declared `(int) -> int` — the same 3-way discriminator as case +`0066-external-type-definition-low-tier-degrades-to-any`: unknown symbol or an enforced strict +signature would both raise a hard type error; a correctly degraded `Any` function does not. + +## Vérification + +Implemented already: `crates/typr-cli/src/standard_library.rs` +(`degrade_if_version_out_of_range`, `version_less_than`) and +`crates/typr-cli/src/type_registry.rs` (`resolve_locked_definitions`, +`observed_r_package_version`). Previously only exercised by unit tests in those two files' +`#[cfg(test)]` modules (`load_project_type_definitions_degrades_when_locked_version_is_below_since`) +— this case is the same scenario replayed through the real `typr` binary via `typr case run`, +using a case-bundled `cache/` directory (`cases.rs::build_sandbox`, `$XDG_CACHE_HOME`) since the +real cache lives outside any project directory (§7.4). + +## Statut + +Kept as a regression net for the version-floor half of D2/§7.2, alongside case +`0066-external-type-definition-low-tier-degrades-to-any` for the tier half. diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/expect.toml b/cases/0067-external-type-definition-old-version-degrades-to-any/expect.toml new file mode 100644 index 00000000..5a2f588a --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/expect.toml @@ -0,0 +1,18 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +# Same 3-way discriminator as case 0066: if cache/lock resolution were +# broken, `turn_sprocket` would fall back to a genuinely unknown symbol, +# which raises a hard type error instead of degrading. +[[rule]] +file = "@run" +must_not_contain = "not in the local cache" + +[[rule]] +file = "@run" +must_not_contain = "no longer matches typr.lock" diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/repro/TypR/main.ty b/cases/0067-external-type-definition-old-version-degrades-to-any/repro/TypR/main.ty new file mode 100644 index 00000000..49416a9f --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/repro/TypR/main.ty @@ -0,0 +1,9 @@ +#@case external-type-definition-old-version-degrades-to-any: registry.md §7.2 — +# a locked external Type Definition whose observed R package version +# (`typr.lock`'s `r_version_seen`) is below the manifest's `since` floor must +# degrade to Any for every one of its names, even at tier `T1` (normally +# trusted outright). `sprocket` declares `@turn_sprocket: (int) -> int;` at +# tier `T1`, `since = "2.0.0"`, but was locked against R package `1.0.0` — +# below the floor. +let result <- turn_sprocket("not-an-int"); +print(result); diff --git a/cases/0067-external-type-definition-old-version-degrades-to-any/repro/typr.lock b/cases/0067-external-type-definition-old-version-degrades-to-any/repro/typr.lock new file mode 100644 index 00000000..42c93e50 --- /dev/null +++ b/cases/0067-external-type-definition-old-version-degrades-to-any/repro/typr.lock @@ -0,0 +1,11 @@ +# typr.lock — generated by `typr types`, commit this file. +# See typR/registry.md §7.1 and rfcs/0031-external-type-definitions.md. + +[[definition]] +package = "sprocket" +repository = "github:test/typr-sprocket" +version = "0.1.0" +rev = "0000000000000000000000000000000000000000" +digest = "sha256:1fbb5cf3c47e33fea5012716e2b2667f221fad0b69db66c705e347258d9bc8b7" +tier = "T1" +r_version_seen = "1.0.0" diff --git a/cases/0068-generic-record-binding-nondeterministic/case.toml b/cases/0068-generic-record-binding-nondeterministic/case.toml new file mode 100644 index 00000000..c68ee3fd --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/case.toml @@ -0,0 +1,7 @@ +title = "generic record parameter binds T to a random field type (HashSet iteration order)" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-28" +origin = "perso" +checked = false diff --git a/cases/0068-generic-record-binding-nondeterministic/expect.md b/cases/0068-generic-record-binding-nondeterministic/expect.md new file mode 100644 index 00000000..14bec810 --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/expect.md @@ -0,0 +1,16 @@ +# generic-record-binding-nondeterministic + +## Ce qui devrait se passer + +`first(p: list { a: T, b: U, c: V }): T` appelé avec `:{ a = 1, b = "s", c = true }` doit +lier `T` au type du champ `a` (donc `int`) à chaque exécution : `let y: int <- first(r)` passe +toujours. + +## Anomalies + +`observed.txt` montre `type int doesn't match type true` / `... "t"` : `T` a été lié au type +d'un autre champ (`c` ou `b`). Le résultat change d'une exécution à l'autre (un appel isolé +échoue environ 2 fois sur 3) : l'arm `Record/Record` de `get_gen_type` +(`type_checking/mod.rs`) fait un `zip` sur deux `HashSet` et apparie donc les +champs dans un ordre arbitraire. Le repro contient 8 appels indépendants pour que la +probabilité qu'ils passent tous par hasard soit négligeable tant que le bug existe. diff --git a/cases/0068-generic-record-binding-nondeterministic/expect.toml b/cases/0068-generic-record-binding-nondeterministic/expect.toml new file mode 100644 index 00000000..aedb1f85 --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/expect.toml @@ -0,0 +1,11 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" + +[[rule]] +file = "@run" +must_not_contain = "doesn't match type" diff --git a/cases/0068-generic-record-binding-nondeterministic/observed.txt b/cases/0068-generic-record-binding-nondeterministic/observed.txt new file mode 100644 index 00000000..c5bddad5 --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/observed.txt @@ -0,0 +1,61 @@ +# exit=1 + +## stdout/stderr + + Parsing... done (9 ms) + Type checking... failed +Type errors found: + × Type error: type int doesn't match type true + ╭─[TypR/main.ty:7:9] + 6 │ let r1 <- :{ a = 1, b = "s", c = true }; + 7 │ let y1: int <- first(r1); + · ▲ ┬ + · │ ╰── Received true + · ╰── Expected int + 8 │ let r2 <- :{ a = 2, b = "t", c = false }; + ╰──── + + × Type error: type int doesn't match type "t" + ╭─[TypR/main.ty:9:9] + 8 │ let r2 <- :{ a = 2, b = "t", c = false }; + 9 │ let y2: int <- first(r2); + · ▲ ┬ + · │ ╰── Received "t" + · ╰── Expected int + 10 │ let r3 <- :{ a = 3, b = "u", c = true }; + ╰──── + + × Type error: type int doesn't match type true + ╭─[TypR/main.ty:11:9] + 10 │ let r3 <- :{ a = 3, b = "u", c = true }; + 11 │ let y3: int <- first(r3); + · ▲ ┬ + · │ ╰── Received true + · ╰── Expected int + 12 │ let r4 <- :{ a = 4, b = "v", c = false }; + ╰──── + + × Type error: type int doesn't match type false + ╭─[TypR/main.ty:13:9] + 12 │ let r4 <- :{ a = 4, b = "v", c = false }; + 13 │ let y4: int <- first(r4); + · ▲ ┬ + · │ ╰── Received false + · ╰── Expected int + 14 │ let r5 <- :{ a = 5, b = "w", c = true }; + ╰──── + + × Type error: type int doesn't match type false + ╭─[TypR/main.ty:17:9] + 16 │ let r6 <- :{ a = 6, b = "x", c = false }; + 17 │ let y6: int <- first(r6); + · ▲ ┬ + · │ ╰── Received false + · ╰── Expected int + 18 │ let r7 <- :{ a = 7, b = "y", c = true }; + ╰──── + + + +## R généré + diff --git a/cases/0068-generic-record-binding-nondeterministic/repro/DESCRIPTION b/cases/0068-generic-record-binding-nondeterministic/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0068-generic-record-binding-nondeterministic/repro/NAMESPACE b/cases/0068-generic-record-binding-nondeterministic/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0068-generic-record-binding-nondeterministic/repro/R/.gitkeep b/cases/0068-generic-record-binding-nondeterministic/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0068-generic-record-binding-nondeterministic/repro/TypR/main.ty b/cases/0068-generic-record-binding-nondeterministic/repro/TypR/main.ty new file mode 100644 index 00000000..beeecefa --- /dev/null +++ b/cases/0068-generic-record-binding-nondeterministic/repro/TypR/main.ty @@ -0,0 +1,21 @@ +# A generic record parameter with several fields of different types: the +# checker must bind T to the type of field `a` every time, regardless of +# the internal iteration order of the record's field set. +let first <- fn(p: list { a: T, b: U, c: V }): T { p.a }; + +let r1 <- :{ a = 1, b = "s", c = true }; +let y1: int <- first(r1); +let r2 <- :{ a = 2, b = "t", c = false }; +let y2: int <- first(r2); +let r3 <- :{ a = 3, b = "u", c = true }; +let y3: int <- first(r3); +let r4 <- :{ a = 4, b = "v", c = false }; +let y4: int <- first(r4); +let r5 <- :{ a = 5, b = "w", c = true }; +let y5: int <- first(r5); +let r6 <- :{ a = 6, b = "x", c = false }; +let y6: int <- first(r6); +let r7 <- :{ a = 7, b = "y", c = true }; +let y7: int <- first(r7); +let r8 <- :{ a = 8, b = "z", c = false }; +let y8: int <- first(r8); diff --git a/cases/0069-record-arg-not-first-unchecked/case.toml b/cases/0069-record-arg-not-first-unchecked/case.toml new file mode 100644 index 00000000..19586ecc --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/case.toml @@ -0,0 +1,7 @@ +title = "a record-typed argument that is not the first is never checked against its parameter" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-28" +origin = "perso" +checked = false diff --git a/cases/0069-record-arg-not-first-unchecked/expect.md b/cases/0069-record-arg-not-first-unchecked/expect.md new file mode 100644 index 00000000..1683c332 --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/expect.md @@ -0,0 +1,26 @@ +# record-arg-not-first-unchecked + +## Ce qui devrait se passer + +Un argument de type record en position 2 ou plus doit être vérifié contre le type du +paramètre, comme l'est le premier argument (`take(q)` avec `q: Qq` pour `p: Pt` est rejeté) +et comme le sont les arguments scalaires. Les trois appels du repro sont invalides : +un record d'un autre type (`Qq` pour `Pt`), un record dont le champ `x` a le mauvais type +(`Rr { x: char }` pour `Pt { x: int }`), et le même en 3ᵉ position. + +## Anomalies + +`observed.txt` : le checker termine sans aucune erreur. Cause : `get_gen_type` +(`type_checking/mod.rs`, arm `Record/Record`) renvoie toujours `Some(..)` — c'est un +collecteur de liaisons de génériques utilisé comme porte d'entrée par +`Context::get_unification_map`, qui ne rejette donc jamais deux records. Pour le premier +argument, `filter_by_first_param` fait un vrai contrôle de sous-typage à part, ce qui masque +le trou. (Les arguments de type *fonction* ne sont en revanche pas concernés : un premier +diagnostic les disait non vérifiés, mais c'était un artefact du nom `apply`, qui collisionne +avec l'`apply` de la std.) + +Deuxième cause, visible sur les records anonymes (`:{ z = 1 }` passé pour un `Pt`) : le dernier +arm de `get_gen_type` teste `is_subtype(.., &Context::empty())`. Avec un contexte vide, l'alias +`Pt` ne se résout pas et se réduit à `Any` (cf. S4 de `audit_type_checking.md`), dont tout type +est sous-type. Le correctif renvoie `None` pour un alias transparent en paramètre, ce qui laisse +`match_types_to_generic` retenter sur les types réduits, avec le vrai contexte. diff --git a/cases/0069-record-arg-not-first-unchecked/expect.toml b/cases/0069-record-arg-not-first-unchecked/expect.toml new file mode 100644 index 00000000..7a60167c --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/expect.toml @@ -0,0 +1,19 @@ +[[rule]] +file = "@run" +must_contain = "No signature of function 'take_other' matches this call" + +[[rule]] +file = "@run" +must_contain = "No signature of function 'take_field' matches this call" + +[[rule]] +file = "@run" +must_contain = "No signature of function 'take_third' matches this call" + +[[rule]] +file = "@run" +must_contain = "No signature of function 'take_anon' matches this call" + +[[rule]] +file = "@run" +must_contain = "No signature of function 'take_anon_ty' matches this call" diff --git a/cases/0069-record-arg-not-first-unchecked/observed.txt b/cases/0069-record-arg-not-first-unchecked/observed.txt new file mode 100644 index 00000000..e1224888 --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/observed.txt @@ -0,0 +1,11 @@ +# exit=0 + +## stdout/stderr +Code verification successful! + + Parsing... done (8 ms) + Type checking... done (29 ms) + + +## R généré + diff --git a/cases/0069-record-arg-not-first-unchecked/repro/DESCRIPTION b/cases/0069-record-arg-not-first-unchecked/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0069-record-arg-not-first-unchecked/repro/NAMESPACE b/cases/0069-record-arg-not-first-unchecked/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0069-record-arg-not-first-unchecked/repro/R/.gitkeep b/cases/0069-record-arg-not-first-unchecked/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0069-record-arg-not-first-unchecked/repro/TypR/main.ty b/cases/0069-record-arg-not-first-unchecked/repro/TypR/main.ty new file mode 100644 index 00000000..c1a1940d --- /dev/null +++ b/cases/0069-record-arg-not-first-unchecked/repro/TypR/main.ty @@ -0,0 +1,26 @@ +# A record-typed argument that is not the first must be checked against the +# parameter type, like the first one and scalar ones are. Each call below is +# invalid and must be rejected. +type Pt <- list { x: int }; +type Qq <- list { z: int }; +type Rr <- list { x: char }; + +let take_other <- fn(a: int, p: Pt): int { a }; +let other <- Qq:{ z = 1 }; +let bad_other <- take_other(1, other); + +let take_field <- fn(a: int, p: Pt): int { a }; +let wrong_field <- Rr:{ x = "s" }; +let bad_field <- take_field(1, wrong_field); + +let take_third <- fn(a: int, b: int, p: Pt): int { a }; +let bad_third <- take_third(1, 2, other); + +# Anonymous records (no constructor) must be checked as well. +let take_anon <- fn(a: int, p: Pt): int { a }; +let anon_missing <- :{ z = 1 }; +let bad_anon_missing <- take_anon(1, anon_missing); + +let take_anon_ty <- fn(a: int, p: Pt): int { a }; +let anon_wrong <- :{ x = "s" }; +let bad_anon_wrong <- take_anon_ty(1, anon_wrong); diff --git a/cases/0070-integer-literal-overflow-panics/case.toml b/cases/0070-integer-literal-overflow-panics/case.toml new file mode 100644 index 00000000..72d742bc --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/case.toml @@ -0,0 +1,7 @@ +title = "an integer literal outside the i32 range panics the parser instead of typing as num" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-29" +origin = "perso" +checked = false diff --git a/cases/0070-integer-literal-overflow-panics/expect.md b/cases/0070-integer-literal-overflow-panics/expect.md new file mode 100644 index 00000000..418cde80 --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/expect.md @@ -0,0 +1,14 @@ +# integer-literal-overflow-panics + +## Ce qui devrait se passer + +Un littéral entier hors de la plage i32 (`2147483648`, `-2147483649`, `99999999999`) est un +double en R : il doit se typer `num`, pas faire tomber le compilateur. `99999999999.5` (déjà un +`num`) et `2147483647` (le plus grand `int`) doivent continuer à passer. + +## Anomalies + +`observed.txt` : `exit=101`, panic `ParseIntError { kind: PosOverflow }` à +`parsing/elements.rs:117` (`symbol.parse::().unwrap()` dans `integer_impl`). Même +`99999999999.5` panique, car `integer` est tenté avant `number` sur certains chemins du parseur. +Le panic emporte aussi le LSP. diff --git a/cases/0070-integer-literal-overflow-panics/expect.toml b/cases/0070-integer-literal-overflow-panics/expect.toml new file mode 100644 index 00000000..7a9b5461 --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/expect.toml @@ -0,0 +1,11 @@ +[[rule]] +file = "@run" +must_contain = "successful" + +[[rule]] +file = "@run" +must_not_contain = "panicked" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" diff --git a/cases/0070-integer-literal-overflow-panics/observed.txt b/cases/0070-integer-literal-overflow-panics/observed.txt new file mode 100644 index 00000000..d0dd3301 --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/observed.txt @@ -0,0 +1,12 @@ +# exit=101 + +## stdout/stderr + + Parsing... +thread 'main' (107795) panicked at crates/typr-core/src/processes/parsing/elements.rs:117:50: +called `Result::unwrap()` on an `Err` value: ParseIntError { kind: PosOverflow } +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +## R généré + diff --git a/cases/0070-integer-literal-overflow-panics/repro/DESCRIPTION b/cases/0070-integer-literal-overflow-panics/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0070-integer-literal-overflow-panics/repro/NAMESPACE b/cases/0070-integer-literal-overflow-panics/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0070-integer-literal-overflow-panics/repro/R/.gitkeep b/cases/0070-integer-literal-overflow-panics/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0070-integer-literal-overflow-panics/repro/TypR/main.ty b/cases/0070-integer-literal-overflow-panics/repro/TypR/main.ty new file mode 100644 index 00000000..30fe9ea7 --- /dev/null +++ b/cases/0070-integer-literal-overflow-panics/repro/TypR/main.ty @@ -0,0 +1,7 @@ +# An integer literal outside the 32-bit range is a double in R, so it must +# type as `num` instead of aborting the compiler. +let above_max: num <- 2147483648; +let below_min: num <- -2147483649; +let large: num <- 99999999999; +let large_fraction: num <- 99999999999.5; +let still_int: int <- 2147483647; diff --git a/cases/0071-type-level-integer-overflow/case.toml b/cases/0071-type-level-integer-overflow/case.toml new file mode 100644 index 00000000..4fc3e171 --- /dev/null +++ b/cases/0071-type-level-integer-overflow/case.toml @@ -0,0 +1,7 @@ +title = "a type-level integer outside the i32 range panics (or silently becomes 0)" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-29" +origin = "perso" +checked = false diff --git a/cases/0071-type-level-integer-overflow/expect.md b/cases/0071-type-level-integer-overflow/expect.md new file mode 100644 index 00000000..d24f6675 --- /dev/null +++ b/cases/0071-type-level-integer-overflow/expect.md @@ -0,0 +1,13 @@ +# type-level-integer-overflow + +## Ce qui devrait se passer + +Une dimension ou un entier singleton de type hors i32 (`[99999999999, int]`) est signalé comme +erreur de syntaxe. Il ne doit ni faire paniquer le compilateur ni être réinterprété en silence. + +## Anomalies + +`observed.txt` : `exit=101`, panic `ParseIntError` à `parsing/types.rs:159` +(`simple_index`). Le chemin voisin `integer_literal` (`types.rs:~557`) est pire : il fait +`parse().unwrap_or(0)`, donc `type Big <- 99999999999` devient en silence le singleton `0`. +`indexation.rs:42/50` (`parse_integer`, `parse_positive_integer`) ont le même `unwrap()`. diff --git a/cases/0071-type-level-integer-overflow/expect.toml b/cases/0071-type-level-integer-overflow/expect.toml new file mode 100644 index 00000000..c296f9eb --- /dev/null +++ b/cases/0071-type-level-integer-overflow/expect.toml @@ -0,0 +1,7 @@ +[[rule]] +file = "@run" +must_not_contain = "panicked" + +[[rule]] +file = "@run" +must_contain = "Syntax error" diff --git a/cases/0071-type-level-integer-overflow/observed.txt b/cases/0071-type-level-integer-overflow/observed.txt new file mode 100644 index 00000000..584e1c57 --- /dev/null +++ b/cases/0071-type-level-integer-overflow/observed.txt @@ -0,0 +1,12 @@ +# exit=101 + +## stdout/stderr + + Parsing... +thread 'main' (107798) panicked at crates/typr-core/src/processes/parsing/types.rs:159:63: +called `Result::unwrap()` on an `Err` value: ParseIntError { kind: PosOverflow } +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +## R généré + diff --git a/cases/0071-type-level-integer-overflow/repro/DESCRIPTION b/cases/0071-type-level-integer-overflow/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0071-type-level-integer-overflow/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0071-type-level-integer-overflow/repro/NAMESPACE b/cases/0071-type-level-integer-overflow/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0071-type-level-integer-overflow/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0071-type-level-integer-overflow/repro/R/.gitkeep b/cases/0071-type-level-integer-overflow/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0071-type-level-integer-overflow/repro/TypR/main.ty b/cases/0071-type-level-integer-overflow/repro/TypR/main.ty new file mode 100644 index 00000000..155c7c6f --- /dev/null +++ b/cases/0071-type-level-integer-overflow/repro/TypR/main.ty @@ -0,0 +1,3 @@ +# A type-level integer (array dimension, singleton type) outside the 32-bit +# range must be reported, never abort the compiler nor silently become 0. +let big_dim: [99999999999, int] <- [1]; diff --git a/cases/0072-union-subtype-always-true/case.toml b/cases/0072-union-subtype-always-true/case.toml new file mode 100644 index 00000000..06981c8e --- /dev/null +++ b/cases/0072-union-subtype-always-true/case.toml @@ -0,0 +1,7 @@ +title = "any union is accepted as a subtype of any other union" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-29" +origin = "perso" +checked = false diff --git a/cases/0072-union-subtype-always-true/expect.md b/cases/0072-union-subtype-always-true/expect.md new file mode 100644 index 00000000..f5991551 --- /dev/null +++ b/cases/0072-union-subtype-always-true/expect.md @@ -0,0 +1,16 @@ +# union-subtype-always-true + +## Ce qui devrait se passer + +`U1 <: U2` n'est vrai que si chaque membre de `U1` tient dans un membre de `U2` (même tag, charge +utile sous-type). Sur le repro, `Shape -> Unrelated` (tags disjoints), `Shape -> WrongPayload` +(mêmes tags, charge `char` au lieu de `int`) et `Shape -> Partial` (un tag de `Shape` absent) sont +rejetés ; l'élargissement `Shape -> Wider` et l'identité `Shape -> Shape` restent acceptés. + +## Anomalies + +`observed.txt` : aucune erreur. `is_subtype_raw` (`components/type/mod.rs`) avait un arm +`(Union, Union) => true //TODO: Fix this` placé avant les deux arms génériques (« tous les membres +gauches sous-types de la droite », « sous-type d'au moins un membre droit »), qui masquait la bonne +règle. Un `Shape` était donc utilisable là où un union sans aucun tag commun était attendu, et le +`match` du receveur pouvait alors rencontrer un tag qu'il ne connaît pas. diff --git a/cases/0072-union-subtype-always-true/expect.toml b/cases/0072-union-subtype-always-true/expect.toml new file mode 100644 index 00000000..3666d384 --- /dev/null +++ b/cases/0072-union-subtype-always-true/expect.toml @@ -0,0 +1,20 @@ +[[rule]] +file = "@run" +must_contain = "Expected: Unrelated" + +[[rule]] +file = "@run" +must_contain = "Expected: WrongPayload" + +[[rule]] +file = "@run" +must_contain = "Expected: Partial" + +# Valid conversions must keep passing. +[[rule]] +file = "@run" +must_not_contain = "Expected: Wider" + +[[rule]] +file = "@run" +must_not_contain = "Expected: Shape" diff --git a/cases/0072-union-subtype-always-true/observed.txt b/cases/0072-union-subtype-always-true/observed.txt new file mode 100644 index 00000000..21907ff1 --- /dev/null +++ b/cases/0072-union-subtype-always-true/observed.txt @@ -0,0 +1,11 @@ +# exit=0 + +## stdout/stderr +Code verification successful! + + Parsing... done (14 ms) + Type checking... done (50 ms) + + +## R généré + diff --git a/cases/0072-union-subtype-always-true/repro/DESCRIPTION b/cases/0072-union-subtype-always-true/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0072-union-subtype-always-true/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0072-union-subtype-always-true/repro/NAMESPACE b/cases/0072-union-subtype-always-true/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0072-union-subtype-always-true/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0072-union-subtype-always-true/repro/R/.gitkeep b/cases/0072-union-subtype-always-true/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0072-union-subtype-always-true/repro/TypR/main.ty b/cases/0072-union-subtype-always-true/repro/TypR/main.ty new file mode 100644 index 00000000..e84898b6 --- /dev/null +++ b/cases/0072-union-subtype-always-true/repro/TypR/main.ty @@ -0,0 +1,15 @@ +# A union is a subtype of another union only when every one of its members +# fits in one of the other's. Each `bad_*` conversion below is invalid. +type Shape <- .Circle(int) | .Square(int); +type Unrelated <- .Triangle(char) | .Hexagon(bool); +type WrongPayload <- .Circle(char) | .Square(char); +type Partial <- .Circle(int) | .Triangle(int); + +let bad_unrelated <- fn(s: Shape): Unrelated { s }; +let bad_payload <- fn(s: Shape): WrongPayload { s }; +let bad_partial <- fn(s: Shape): Partial { s }; + +# Valid widening: must keep type-checking. +type Wider <- .Circle(int) | .Square(int) | .Triangle(int); +let ok_wider <- fn(s: Shape): Wider { s }; +let ok_same <- fn(s: Shape): Shape { s }; diff --git a/cases/0072-union-subtype-always-true/repro/std.ty b/cases/0072-union-subtype-always-true/repro/std.ty new file mode 100644 index 00000000..02e434c2 --- /dev/null +++ b/cases/0072-union-subtype-always-true/repro/std.ty @@ -0,0 +1,204 @@ +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer sum +#! example: 1L + 2L # -> 3L +@`+`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric sum +#! example: 1.5 + 2.5 # -> 4 +@`+`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer difference +#! example: 5L - 3L # -> 2L +@`-`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric difference +#! example: 5.0 - 3.0 # -> 2 +@`-`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: integer quotient +#! example: 10L / 3L # -> 3L +@`/`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: numeric quotient +#! example: 10.0 / 3.0 # -> 3.333... +@`/`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer product +#! example: 3L * 4L # -> 12L +@`*`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric product +#! example: 3.0 * 4.0 # -> 12 +@`*`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: integer modulo +#! note: %% is the modulo operator (not remainder) +#! example: 7L %% 3L # -> 1L +@`%%`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: numeric modulo +#! example: 7.0 %% 3.0 # -> 1 +@`%%`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: logical AND +#! example: TRUE && FALSE # -> FALSE +@`&&`: (bool, bool) -> bool; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: logical OR +#! example: TRUE || FALSE # -> TRUE +@`||`: (bool, bool) -> bool; +#! pkg: base +#! tier: T1 +#! param a: left vector +#! param b: right vector +#! ret: element-wise sum of two vectors +#! coercion: vectors must be same length (recycling applies in R) +#! example: c(1,2) + c(3,4) # -> c(4,6) +@`+`: (Vec[#M, T], Vec[#M, T]) -> Vec[#M, T]; +#! pkg: base +#! tier: T1 +#! param a: vector to reduce +#! param f: binary combining function +#! ret: single value after left-fold +#! note: equivalent to base R Reduce() +#! example: reduce(c(1,2,3,4), add) # -> 10 +@reduce: ([#N, T], (T, T) -> T) -> T; +#! pkg: base +#! tier: T1 +#! param a: vector to fold +#! param init: initial accumulator value +#! param f: combining function (accumulator, element) -> accumulator +#! ret: final accumulator +#! note: equivalent to base R Reduce() with init +#! example: fold(c(1,2,3), 0, add) # -> 6 +@fold: ([#N, T], U, (U, T) -> U) -> U; +#! pkg: base +#! tier: T1 +#! param a: vector to extend +#! param b: element to append +#! ret: new vector with b appended +#! example: extend(c(1,2), 3) # -> c(1,2,3) +@extend: ([#N, T], T) -> [#N, T]; +#! pkg: base +#! tier: T3 +#! param desc: test description +#! param expr: test expression +#! ret: invisibly NULL +#! note: testing utility — not for production code +@test_that: (char, Any) -> Empty; +#! pkg: base +#! tier: T3 +#! param condition: expected condition +#! ret: invisibly NULL +#! note: testing utility +@expect_true: (bool) -> Empty; +#! pkg: base +#! tier: T3 +#! param actual: actual value +#! param expected: expected value +#! ret: invisibly NULL +#! note: testing utility +@expect_false: (T, T) -> Empty; +#! pkg: base +#! tier: T3 +#! param actual: actual value +#! param expected: expected value +#! ret: invisibly NULL +#! note: testing utility +@expect_equal: (T, T) -> Empty; +#! pkg: base +#! tier: T2 +#! param values: values to concatenate and print +#! ret: invisibly NULL +#! note: variadic, side-effectful; R's paste(..., sep="") + cat() +#! example: cat("hello", "world") +@cat: (...values: Any) -> Empty; +#! pkg: base +#! tier: T1 +#! param a: TypR value +#! ret: same value as native R +#! note: pass-through for interop boundaries +@to_native: (T) -> T; +#! pkg: base +#! tier: T2 +#! param a: native R value +#! param type: target TypR type name +#! ret: coerced value +#! note: runtime cast — type must match or error +@from_native: (Any, char) -> Any; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: integer representation +#! coercion: numeric/logical -> integer +#! example: from_int(3.7) # -> 3L +@from_int: (Any) -> int; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: numeric representation +#! coercion: integer/logical -> numeric +#! example: # noplayground: from_num(3L) # -> 3.0 +@from_num: (Any) -> num; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: character representation +#! coercion: any atomic -> character +#! example: from_char(42) # -> "42" +@from_char: (Any) -> char; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: logical representation +#! coercion: numeric -> logical (0 = FALSE, non-zero = TRUE) +#! example: from_bool(1) # -> TRUE +@from_bool: (Any) -> bool; +#! pkg: base +#! tier: T1 +#! param x: numeric vector +#! ret: sum over all elements +#! example: sum(c(1, 2, 3)) # -> 6 +@sum: ([#N, T]) -> T; +#! pkg: base +#! tier: T1 +#! param x: value to display +#! ret: prints to the console, returns nothing +#! example: print(sum(c(1, 2, 3))) +@print: (Any) -> Empty; diff --git a/cases/0072-union-subtype-always-true/std.ty b/cases/0072-union-subtype-always-true/std.ty new file mode 100644 index 00000000..02e434c2 --- /dev/null +++ b/cases/0072-union-subtype-always-true/std.ty @@ -0,0 +1,204 @@ +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer sum +#! example: 1L + 2L # -> 3L +@`+`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric sum +#! example: 1.5 + 2.5 # -> 4 +@`+`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer difference +#! example: 5L - 3L # -> 2L +@`-`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric difference +#! example: 5.0 - 3.0 # -> 2 +@`-`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: integer quotient +#! example: 10L / 3L # -> 3L +@`/`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: numeric quotient +#! example: 10.0 / 3.0 # -> 3.333... +@`/`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: integer product +#! example: 3L * 4L # -> 12L +@`*`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: numeric product +#! example: 3.0 * 4.0 # -> 12 +@`*`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: integer modulo +#! note: %% is the modulo operator (not remainder) +#! example: 7L %% 3L # -> 1L +@`%%`: (int, int) -> int; +#! pkg: base +#! tier: T1 +#! param a: dividend +#! param b: divisor +#! ret: numeric modulo +#! example: 7.0 %% 3.0 # -> 1 +@`%%`: (num, num) -> num; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: logical AND +#! example: TRUE && FALSE # -> FALSE +@`&&`: (bool, bool) -> bool; +#! pkg: base +#! tier: T1 +#! param a: left operand +#! param b: right operand +#! ret: logical OR +#! example: TRUE || FALSE # -> TRUE +@`||`: (bool, bool) -> bool; +#! pkg: base +#! tier: T1 +#! param a: left vector +#! param b: right vector +#! ret: element-wise sum of two vectors +#! coercion: vectors must be same length (recycling applies in R) +#! example: c(1,2) + c(3,4) # -> c(4,6) +@`+`: (Vec[#M, T], Vec[#M, T]) -> Vec[#M, T]; +#! pkg: base +#! tier: T1 +#! param a: vector to reduce +#! param f: binary combining function +#! ret: single value after left-fold +#! note: equivalent to base R Reduce() +#! example: reduce(c(1,2,3,4), add) # -> 10 +@reduce: ([#N, T], (T, T) -> T) -> T; +#! pkg: base +#! tier: T1 +#! param a: vector to fold +#! param init: initial accumulator value +#! param f: combining function (accumulator, element) -> accumulator +#! ret: final accumulator +#! note: equivalent to base R Reduce() with init +#! example: fold(c(1,2,3), 0, add) # -> 6 +@fold: ([#N, T], U, (U, T) -> U) -> U; +#! pkg: base +#! tier: T1 +#! param a: vector to extend +#! param b: element to append +#! ret: new vector with b appended +#! example: extend(c(1,2), 3) # -> c(1,2,3) +@extend: ([#N, T], T) -> [#N, T]; +#! pkg: base +#! tier: T3 +#! param desc: test description +#! param expr: test expression +#! ret: invisibly NULL +#! note: testing utility — not for production code +@test_that: (char, Any) -> Empty; +#! pkg: base +#! tier: T3 +#! param condition: expected condition +#! ret: invisibly NULL +#! note: testing utility +@expect_true: (bool) -> Empty; +#! pkg: base +#! tier: T3 +#! param actual: actual value +#! param expected: expected value +#! ret: invisibly NULL +#! note: testing utility +@expect_false: (T, T) -> Empty; +#! pkg: base +#! tier: T3 +#! param actual: actual value +#! param expected: expected value +#! ret: invisibly NULL +#! note: testing utility +@expect_equal: (T, T) -> Empty; +#! pkg: base +#! tier: T2 +#! param values: values to concatenate and print +#! ret: invisibly NULL +#! note: variadic, side-effectful; R's paste(..., sep="") + cat() +#! example: cat("hello", "world") +@cat: (...values: Any) -> Empty; +#! pkg: base +#! tier: T1 +#! param a: TypR value +#! ret: same value as native R +#! note: pass-through for interop boundaries +@to_native: (T) -> T; +#! pkg: base +#! tier: T2 +#! param a: native R value +#! param type: target TypR type name +#! ret: coerced value +#! note: runtime cast — type must match or error +@from_native: (Any, char) -> Any; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: integer representation +#! coercion: numeric/logical -> integer +#! example: from_int(3.7) # -> 3L +@from_int: (Any) -> int; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: numeric representation +#! coercion: integer/logical -> numeric +#! example: # noplayground: from_num(3L) # -> 3.0 +@from_num: (Any) -> num; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: character representation +#! coercion: any atomic -> character +#! example: from_char(42) # -> "42" +@from_char: (Any) -> char; +#! pkg: base +#! tier: T1 +#! param a: value to convert +#! ret: logical representation +#! coercion: numeric -> logical (0 = FALSE, non-zero = TRUE) +#! example: from_bool(1) # -> TRUE +@from_bool: (Any) -> bool; +#! pkg: base +#! tier: T1 +#! param x: numeric vector +#! ret: sum over all elements +#! example: sum(c(1, 2, 3)) # -> 6 +@sum: ([#N, T]) -> T; +#! pkg: base +#! tier: T1 +#! param x: value to display +#! ret: prints to the console, returns nothing +#! example: print(sum(c(1, 2, 3))) +@print: (Any) -> Empty; diff --git a/cases/0073-refined-type-property-panics/case.toml b/cases/0073-refined-type-property-panics/case.toml new file mode 100644 index 00000000..2ac66d87 --- /dev/null +++ b/cases/0073-refined-type-property-panics/case.toml @@ -0,0 +1,7 @@ +title = "`int & (> 0)` (a refined type) aborts the parser instead of type-checking" +cmd = "check" +layer = "parse" +status = "fixed" +created = "2026-09-29" +origin = "perso" +checked = false diff --git a/cases/0073-refined-type-property-panics/expect.md b/cases/0073-refined-type-property-panics/expect.md new file mode 100644 index 00000000..fceb797c --- /dev/null +++ b/cases/0073-refined-type-property-panics/expect.md @@ -0,0 +1,20 @@ +# refined-type-property-panics + +## Ce qui devrait se passer + +`int & (> 0)` est un type raffiné (`refined_types.md`, `refined_types_plan.md` Phase 2) : la base +`int` intersectée avec la propriété de valeur `(> 0)`. `let x: int & (> 0) <- 3;` doit passer +`typr check`, sans panic. + +## Anomalies + +`observed.txt` : `exit=101`. Deux causes empilées dans le parseur de types : + +1. `>` est lu comme un `Op` par `index_algebra`, et `compute_operators` finissait sur + `_ => panic!()` (`parsing/types.rs`). Corrigé : la fonction est maintenant faillible et + `index_chain` échoue proprement, ce qui laisse `alt` essayer autre chose. +2. Rien ne sait encore lire `(> 0)` comme propriété : `ltype` obtient une suite de jetons + invalide et `PriorityTokens::run_helper` lance `panic_any(TypeError::WrongExpression)` + (`operation_priority.rs`). C'est le canal général des erreurs de syntaxe de type + (`let x: int & ;` panique de la même façon) ; le cas se ferme quand la Phase 2 ajoute le + parseur `refinement_property`. diff --git a/cases/0073-refined-type-property-panics/expect.toml b/cases/0073-refined-type-property-panics/expect.toml new file mode 100644 index 00000000..618f4889 --- /dev/null +++ b/cases/0073-refined-type-property-panics/expect.toml @@ -0,0 +1,7 @@ +[[rule]] +file = "@run" +must_not_contain = "panicked" + +[[rule]] +file = "@run" +must_not_contain = "Type errors found" diff --git a/cases/0073-refined-type-property-panics/observed.txt b/cases/0073-refined-type-property-panics/observed.txt new file mode 100644 index 00000000..c0b28390 --- /dev/null +++ b/cases/0073-refined-type-property-panics/observed.txt @@ -0,0 +1,12 @@ +# exit=101 + +## stdout/stderr + + Parsing... +thread 'main' (268307) panicked at crates/typr-core/src/processes/parsing/types.rs:974:14: +explicit panic +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +## R généré + diff --git a/cases/0073-refined-type-property-panics/repro/DESCRIPTION b/cases/0073-refined-type-property-panics/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0073-refined-type-property-panics/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0073-refined-type-property-panics/repro/NAMESPACE b/cases/0073-refined-type-property-panics/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0073-refined-type-property-panics/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0073-refined-type-property-panics/repro/R/.gitkeep b/cases/0073-refined-type-property-panics/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0073-refined-type-property-panics/repro/TypR/main.ty b/cases/0073-refined-type-property-panics/repro/TypR/main.ty new file mode 100644 index 00000000..4848d3b5 --- /dev/null +++ b/cases/0073-refined-type-property-panics/repro/TypR/main.ty @@ -0,0 +1,3 @@ +# `int & (> 0)`: a base type refined by a value property (refined_types.md). +# Positive integers must type-check; the compiler must never abort on this. +let x: int & (> 0) <- 3; diff --git a/cases/0074-refined-let-runtime-obligation/case.toml b/cases/0074-refined-let-runtime-obligation/case.toml new file mode 100644 index 00000000..beb075f8 --- /dev/null +++ b/cases/0074-refined-let-runtime-obligation/case.toml @@ -0,0 +1,7 @@ +title = "Refined `let` annotation: unproven refinement becomes a runtime check, proven one emits none" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0074-refined-let-runtime-obligation/expect.md b/cases/0074-refined-let-runtime-obligation/expect.md new file mode 100644 index 00000000..0475ab49 --- /dev/null +++ b/cases/0074-refined-let-runtime-obligation/expect.md @@ -0,0 +1,8 @@ +# 0074 — refined `let`: boundary check (refined_types_plan.md, Phase 5/6) + +`let a: [3, int] <- v` with `v: [int]` used to be a static type error. `is_subtype` still says +`false` (it only answers `true` for a *proven* refinement), but `coerce_to` +(`type_checking/refinement_check.rs`) sees that the base types agree and the refinements are +compatible, so `let_expression` records an obligation in `Context::refinement_obligations` and +the transpiler wraps the initialiser in `typr_refine_length(...)` / `typr_refine_value(...)` +(prelude `std.R`). A literal or an already-refined value that proves the property gets no check. diff --git a/cases/0074-refined-let-runtime-obligation/expect.toml b/cases/0074-refined-let-runtime-obligation/expect.toml new file mode 100644 index 00000000..6c1dd382 --- /dev/null +++ b/cases/0074-refined-let-runtime-obligation/expect.toml @@ -0,0 +1,16 @@ +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(v, 3L" + +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_value(n, 0, TRUE, Inf, FALSE" + +# Proven by the type: a check here would be noise for the R reader. +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_value(3" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(c(" diff --git a/cases/0074-refined-let-runtime-obligation/repro/DESCRIPTION b/cases/0074-refined-let-runtime-obligation/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0074-refined-let-runtime-obligation/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0074-refined-let-runtime-obligation/repro/NAMESPACE b/cases/0074-refined-let-runtime-obligation/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0074-refined-let-runtime-obligation/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0074-refined-let-runtime-obligation/repro/R/.gitkeep b/cases/0074-refined-let-runtime-obligation/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0074-refined-let-runtime-obligation/repro/TypR/main.ty b/cases/0074-refined-let-runtime-obligation/repro/TypR/main.ty new file mode 100644 index 00000000..5d73d0ee --- /dev/null +++ b/cases/0074-refined-let-runtime-obligation/repro/TypR/main.ty @@ -0,0 +1,8 @@ +# `let x: T <- e` where `e`'s refinement cannot be proven: accepted, and checked at run time. +let v: [int] <- [1, 2, 3]; +let a: [3, int] <- v; +let n: int <- 5; +let p: int & (> 0) <- n; +# Proven statically: no check may be emitted for these. +let b: [3, int] <- [4, 5, 6]; +let q: int & (> 0) <- 3; diff --git a/cases/0075-refined-argument-runtime-obligation/case.toml b/cases/0075-refined-argument-runtime-obligation/case.toml new file mode 100644 index 00000000..6422581d --- /dev/null +++ b/cases/0075-refined-argument-runtime-obligation/case.toml @@ -0,0 +1,7 @@ +title = "Refined parameter: unproven argument checked at the call site, proven one is not" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0075-refined-argument-runtime-obligation/expect.md b/cases/0075-refined-argument-runtime-obligation/expect.md new file mode 100644 index 00000000..4eaada16 --- /dev/null +++ b/cases/0075-refined-argument-runtime-obligation/expect.md @@ -0,0 +1,6 @@ +# 0075 — refined argument: caller-side check (refined_types_plan.md, Phase 5, §37) + +No signature accepts `[num]` for `[2, num]` strictly, so `apply_from_variable_inner` retries with +`relax_refined_arguments`: an argument that only fails to *prove* a refinement stands in as the +parameter type, and the residual is recorded as an obligation on the argument expression. The +transpiler wraps that argument. Arguments proven by their type (literals) get no check. diff --git a/cases/0075-refined-argument-runtime-obligation/expect.toml b/cases/0075-refined-argument-runtime-obligation/expect.toml new file mode 100644 index 00000000..167a53e8 --- /dev/null +++ b/cases/0075-refined-argument-runtime-obligation/expect.toml @@ -0,0 +1,15 @@ +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(u, 2L" + +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_value(n, 0, TRUE, Inf, FALSE" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_value(3" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(c(" diff --git a/cases/0075-refined-argument-runtime-obligation/repro/DESCRIPTION b/cases/0075-refined-argument-runtime-obligation/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0075-refined-argument-runtime-obligation/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0075-refined-argument-runtime-obligation/repro/NAMESPACE b/cases/0075-refined-argument-runtime-obligation/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0075-refined-argument-runtime-obligation/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0075-refined-argument-runtime-obligation/repro/R/.gitkeep b/cases/0075-refined-argument-runtime-obligation/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0075-refined-argument-runtime-obligation/repro/TypR/main.ty b/cases/0075-refined-argument-runtime-obligation/repro/TypR/main.ty new file mode 100644 index 00000000..3da20a20 --- /dev/null +++ b/cases/0075-refined-argument-runtime-obligation/repro/TypR/main.ty @@ -0,0 +1,8 @@ +# Call arguments: an unproven refinement is checked by the caller, a proven one is not. +let dist <- fn(p: [2, num], q: [2, num]): num { 0.0 }; +let pos <- fn(x: int & (> 0)): int { x }; +let n: int <- 5; +let u: [num] <- [1.0, 2.0]; +dist(u, [3.0, 4.0]); +pos(n); +pos(3); diff --git a/cases/0076-refined-return-runtime-obligation/case.toml b/cases/0076-refined-return-runtime-obligation/case.toml new file mode 100644 index 00000000..9248e240 --- /dev/null +++ b/cases/0076-refined-return-runtime-obligation/case.toml @@ -0,0 +1,7 @@ +title = "Refined return type: unproven returned values checked, proven ones not" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0076-refined-return-runtime-obligation/expect.md b/cases/0076-refined-return-runtime-obligation/expect.md new file mode 100644 index 00000000..2a609af6 --- /dev/null +++ b/cases/0076-refined-return-runtime-obligation/expect.md @@ -0,0 +1,8 @@ +# 0076 — refined return type (refined_types_plan.md, Phase 5) + +`Context::return_position` marks the expressions whose value is the function's result: the +trailing expression, both `if` branches, the argument of `return`. `typing()` checks such a value +against `expected_return_type` with `coerce_to`; an unproven-but-compatible value records an +obligation on that expression, so an early `return` or one branch is checked on its own and a +proven branch is not. Obligations survive the sub-contexts of `if` and `function()` through +`Context::absorb_obligations`. diff --git a/cases/0076-refined-return-runtime-obligation/expect.toml b/cases/0076-refined-return-runtime-obligation/expect.toml new file mode 100644 index 00000000..87f06786 --- /dev/null +++ b/cases/0076-refined-return-runtime-obligation/expect.toml @@ -0,0 +1,29 @@ +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(v, 3L, \"TypR/main.ty:2\")" + +# early `return(v)` is a return position too +[[rule]] +file = "R/main.R" +must_contain = "return(typr_refine_length(v, 3L" + +# only the branch that is not proven is wrapped +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(v, 3L, \"TypR/main.ty:9\")" + +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_value(n, 0, TRUE, Inf, FALSE" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(c(" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(w" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_value(4" diff --git a/cases/0076-refined-return-runtime-obligation/repro/DESCRIPTION b/cases/0076-refined-return-runtime-obligation/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0076-refined-return-runtime-obligation/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0076-refined-return-runtime-obligation/repro/NAMESPACE b/cases/0076-refined-return-runtime-obligation/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0076-refined-return-runtime-obligation/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0076-refined-return-runtime-obligation/repro/R/.gitkeep b/cases/0076-refined-return-runtime-obligation/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0076-refined-return-runtime-obligation/repro/TypR/main.ty b/cases/0076-refined-return-runtime-obligation/repro/TypR/main.ty new file mode 100644 index 00000000..414d29e6 --- /dev/null +++ b/cases/0076-refined-return-runtime-obligation/repro/TypR/main.ty @@ -0,0 +1,12 @@ +# Return type: every value in return position (trailing expr, early return, if branch) is checked unless proven. +let mk <- fn(v: [int]): [3, int] { v }; +let pick <- fn(v: [int], c: bool): [3, int] { + if (c) { return v; }; + let w: [3, int] <- [1, 2, 3]; + w +}; +let branch <- fn(v: [int], c: bool): [3, int] { + if (c) { v } else { [7, 8, 9] } +}; +let sq <- fn(n: int): int & (> 0) { n }; +let ok <- fn(): int & (> 0) { 4 }; diff --git a/cases/0077-refined-propagation-vector-index/case.toml b/cases/0077-refined-propagation-vector-index/case.toml new file mode 100644 index 00000000..b15f1740 --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/case.toml @@ -0,0 +1,7 @@ +title = "Vector index, comparison and length propagate refined lengths" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0077-refined-propagation-vector-index/expect.md b/cases/0077-refined-propagation-vector-index/expect.md new file mode 100644 index 00000000..255ace28 --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/expect.md @@ -0,0 +1,10 @@ +# 0077 — propagation of lengths (refined_types_plan.md, Phase 7) + +Before: `a[1:3]` was rejected as a wrong index (a vector index on a vector) and, when accepted, +typed `int`; `a > 2` was rejected outright for a vector `a`; `length(a)` was `any`. + +Now `a:b` on a one-dimensional vector gives `[b-a+1, T]` when both bounds are literals, a +selection by mask gives `[T]` (length unknown), a comparison is element-wise (`[n, bool]`) and +`length(x)` with `x : [5, T]` is the literal type `5`. The witness is the generated R: the +proven `[3, int]` selections carry no `typr_refine_length`, the mask selection (`[int]` → +`[3, int]`) carries one. diff --git a/cases/0077-refined-propagation-vector-index/expect.toml b/cases/0077-refined-propagation-vector-index/expect.toml new file mode 100644 index 00000000..714bd85d --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/expect.toml @@ -0,0 +1,20 @@ +[[rule]] +file = "R/main.R" +must_contain = "a[a > 2L" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(a[[seq" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(length" + +# single bracket: `a[[1:3]]` would be a recursive index in R +[[rule]] +file = "R/main.R" +must_contain = "a[seq(2L" + +[[rule]] +file = "R/main.R" +must_not_contain = "a[[seq" diff --git a/cases/0077-refined-propagation-vector-index/golden/R/main.R b/cases/0077-refined-propagation-vector-index/golden/R/main.R new file mode 100644 index 00000000..fd955387 --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/golden/R/main.R @@ -0,0 +1,25 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +# Index/comparison/length results carry their length: a proven `[3, int]` needs no check. +`a` <- c( + 1L |> as.Integer(), + 2L |> as.Integer(), + 3L |> as.Integer(), + 4L |> as.Integer(), + 5L |> as.Integer() +) |> + identity() |> + identity() + +`head3` <- a[seq(1L |> as.Integer(), 3L |> as.Integer(), 1L |> as.Integer())] |> + identity() + +`mid` <- a[seq(2L |> as.Integer(), 4L |> as.Integer(), 1L |> as.Integer())] |> + identity() + +`big` <- typr_refine_length(a[a > 2L |> as.Integer()], 3L, "TypR/main.ty:5") |> + identity() + +`n` <- length(a) |> as.Integer() diff --git a/cases/0077-refined-propagation-vector-index/repro/DESCRIPTION b/cases/0077-refined-propagation-vector-index/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0077-refined-propagation-vector-index/repro/NAMESPACE b/cases/0077-refined-propagation-vector-index/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0077-refined-propagation-vector-index/repro/TypR/main.ty b/cases/0077-refined-propagation-vector-index/repro/TypR/main.ty new file mode 100644 index 00000000..f458e0e5 --- /dev/null +++ b/cases/0077-refined-propagation-vector-index/repro/TypR/main.ty @@ -0,0 +1,6 @@ +# Index/comparison/length results carry their length: a proven `[3, int]` needs no check. +let a: [5, int] <- [1, 2, 3, 4, 5]; +let head3: [3, int] <- a[1:3]; +let mid: [3, int] <- a[2:4]; +let big: [3, int] <- a[a > 2]; +let n: 5 <- length(a); diff --git a/cases/0078-refined-propagation-primitive-effects/case.toml b/cases/0078-refined-propagation-primitive-effects/case.toml new file mode 100644 index 00000000..f5f15935 --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/case.toml @@ -0,0 +1,7 @@ +title = "rev, head and tail carry the refined length of their argument" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0078-refined-propagation-primitive-effects/expect.md b/cases/0078-refined-propagation-primitive-effects/expect.md new file mode 100644 index 00000000..ecf2e6fe --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/expect.md @@ -0,0 +1,8 @@ +# 0078 — length effects of `rev`, `head`, `tail` (refined_types_plan.md, Phase 7) + +`rev`, `head` and `tail` come from `base.ty` (doc-only), so the compiler saw them as `any` and +every refined binding downstream needed a runtime check. Their effect on the length is now +hard-coded in `known_effect_call`: `rev(x)` keeps `x`'s type, `head/tail(x, k)` with a literal +`k` give `[min(N, k), T]` (`[max(N + k, 0), T]` for negative `k`). The witness is the generated +R: no `typr_refine_length` around them, while the call to an undeclared function `idf` still +carries one (default rule: no declared effect, no refinement). diff --git a/cases/0078-refined-propagation-primitive-effects/expect.toml b/cases/0078-refined-propagation-primitive-effects/expect.toml new file mode 100644 index 00000000..6a779596 --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/expect.toml @@ -0,0 +1,21 @@ +# proven by the declared effects: no runtime check +[[rule]] +file = "R/main.R" +must_contain = "head(a, 2L" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(rev" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(head" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(tail" + +# an undeclared function keeps its declared type: the boundary check stays +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(idf(a), 5L" diff --git a/cases/0078-refined-propagation-primitive-effects/golden/R/main.R b/cases/0078-refined-propagation-primitive-effects/golden/R/main.R new file mode 100644 index 00000000..3b6b502b --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/golden/R/main.R @@ -0,0 +1,39 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +# rev/head/tail: declared length effects. `head(a, 9)` clamps to 5; a call to an +# unrelated function gets no refinement, so its result is checked at the boundary. + +`a` <- c( + 1L |> as.Integer(), + 2L |> as.Integer(), + 3L |> as.Integer(), + 4L |> as.Integer(), + 5L |> as.Integer() +) |> + identity() |> + identity() + +`r` <- rev(a) |> identity() + +`h` <- head(a, 2L |> as.Integer()) |> identity() + +`t` <- tail(a, 3L |> as.Integer()) |> identity() + +`d` <- head(a, -2L |> as.Integer()) |> identity() + +`all` <- head(a, 9L |> as.Integer()) |> identity() + +`n` <- length(rev(a)) |> as.Integer() + +#' @method idf integer +`idf.integer` <- (function(x) { + { + x + } |> + identity() +}) |> + as.Generic() + +`k` <- typr_refine_length(idf(a), 5L, "TypR/main.ty:13") |> identity() diff --git a/cases/0078-refined-propagation-primitive-effects/repro/DESCRIPTION b/cases/0078-refined-propagation-primitive-effects/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0078-refined-propagation-primitive-effects/repro/NAMESPACE b/cases/0078-refined-propagation-primitive-effects/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0078-refined-propagation-primitive-effects/repro/TypR/main.ty b/cases/0078-refined-propagation-primitive-effects/repro/TypR/main.ty new file mode 100644 index 00000000..40dfc852 --- /dev/null +++ b/cases/0078-refined-propagation-primitive-effects/repro/TypR/main.ty @@ -0,0 +1,13 @@ +# rev/head/tail: declared length effects. `head(a, 9)` clamps to 5; a call to an +# unrelated function gets no refinement, so its result is checked at the boundary. +@head: (x: Any, n: int) -> Any; +@tail: (x: Any, n: int) -> Any; +let a: [5, int] <- [1, 2, 3, 4, 5]; +let r: [5, int] <- rev(a); +let h: [2, int] <- head(a, 2); +let t: [3, int] <- tail(a, 3); +let d: [3, int] <- head(a, -2); +let all: [5, int] <- head(a, 9); +let n: 5 <- length(rev(a)); +let idf <- fn(x: [int]): [int] { x }; +let k: [5, int] <- idf(a); diff --git a/cases/0079-refined-static-errors/case.toml b/cases/0079-refined-static-errors/case.toml new file mode 100644 index 00000000..d54bdfa6 --- /dev/null +++ b/cases/0079-refined-static-errors/case.toml @@ -0,0 +1,7 @@ +title = "contradictory or inapplicable refinements are rejected statically" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0079-refined-static-errors/expect.md b/cases/0079-refined-static-errors/expect.md new file mode 100644 index 00000000..e19b4d1d --- /dev/null +++ b/cases/0079-refined-static-errors/expect.md @@ -0,0 +1,12 @@ +# 0079 — refinement errors are static (refined_types_plan.md, Phase 3/8) + +Two families of errors, both raised while the `let` annotation is reduced +(`norm_refinement` / `apply_refinements` in `type_checking/type_arithmetic.rs`): + +- **`UnsatisfiableRefinement` (T046)**: the properties leave an empty interval + (`(> 10) & (< 5)`), or a second `length(n)` disagrees with the length already stored in the + `Vec` index (D4: `[int] & length(5)` is `[5, int]`). +- **`InvalidRefinement` (T045)**: the base lacks the capability — `length` on `int`, + `(> c)` on `chr`. + +Neither may panic, and neither may reach the transpiler. diff --git a/cases/0079-refined-static-errors/expect.toml b/cases/0079-refined-static-errors/expect.toml new file mode 100644 index 00000000..16457f92 --- /dev/null +++ b/cases/0079-refined-static-errors/expect.toml @@ -0,0 +1,22 @@ +# contradiction between two value bounds +[[rule]] +file = "@run" +must_contain = "`int & (> 10) & (< 5)` has no" + +# capability check: `length` needs a vector, `(> c)` needs int/num +[[rule]] +file = "@run" +must_contain = "`int` cannot be refined by `length`" + +[[rule]] +file = "@run" +must_contain = "`chr` cannot be refined by `value`" + +# `[int] & length(5)` is stored as `[5, int]`, so a second length contradicts it +[[rule]] +file = "@run" +must_contain = "contradicts" + +[[rule]] +file = "@run" +must_not_contain = "panicked" diff --git a/cases/0079-refined-static-errors/observed.txt b/cases/0079-refined-static-errors/observed.txt new file mode 100644 index 00000000..07b8db90 --- /dev/null +++ b/cases/0079-refined-static-errors/observed.txt @@ -0,0 +1 @@ +exit=0 (before Phase 3, these annotations were accepted or panicked) diff --git a/cases/0079-refined-static-errors/repro/DESCRIPTION b/cases/0079-refined-static-errors/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0079-refined-static-errors/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0079-refined-static-errors/repro/NAMESPACE b/cases/0079-refined-static-errors/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0079-refined-static-errors/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0079-refined-static-errors/repro/R/.gitkeep b/cases/0079-refined-static-errors/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0079-refined-static-errors/repro/TypR/main.ty b/cases/0079-refined-static-errors/repro/TypR/main.ty new file mode 100644 index 00000000..8dddd41e --- /dev/null +++ b/cases/0079-refined-static-errors/repro/TypR/main.ty @@ -0,0 +1,5 @@ +# Each of these refinements is rejected at compile time. +let a: int & (> 10) & (< 5) <- 7; +let b: int & length(5) <- 3; +let c: [int] & length(5) & length(10) <- [1, 2, 3, 4, 5]; +let d: chr & (> 0) <- "a"; diff --git a/cases/0080-refined-record-literal-fields/case.toml b/cases/0080-refined-record-literal-fields/case.toml new file mode 100644 index 00000000..f5710720 --- /dev/null +++ b/cases/0080-refined-record-literal-fields/case.toml @@ -0,0 +1,7 @@ +title = "record literal with refined fields: per-field runtime checks (let, argument, return)" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0080-refined-record-literal-fields/expect.md b/cases/0080-refined-record-literal-fields/expect.md new file mode 100644 index 00000000..bdcd3d76 --- /dev/null +++ b/cases/0080-refined-record-literal-fields/expect.md @@ -0,0 +1,11 @@ +# 0080 — refined record fields (refined_types_plan.md, Phase 5, record constructors) + +`let a: Account <- list { id: n, tags: v }` used to be a static type error: the literal's type +`list{id: int, tags: [int]}` is not a subtype of `Account` (whose fields are refined), and +`coerce_to` on the whole record only knows about refinements of the value itself. + +`field_obligations` (`type_checking/refinement_check.rs`) handles a spread-free record literal +against a record type: each field is a boundary of its own, so the obligation is recorded on the +*field expression* and the transpiler wraps just that value. Wired into `let_expression`, the +return position (`typing`) and `relax_refined_arguments`. A field that is provably wrong +(`id: -3`) is still a static error. diff --git a/cases/0080-refined-record-literal-fields/expect.toml b/cases/0080-refined-record-literal-fields/expect.toml new file mode 100644 index 00000000..63f0f36c --- /dev/null +++ b/cases/0080-refined-record-literal-fields/expect.toml @@ -0,0 +1,26 @@ +[[rule]] +file = "R/main.R" +must_contain = "id = typr_refine_value(n, 0, TRUE, Inf, FALSE, \"TypR/main.ty:6\")" + +[[rule]] +file = "R/main.R" +must_contain = "tags = typr_refine_length(v, 2L, \"TypR/main.ty:6\")" + +# argument position +[[rule]] +file = "R/main.R" +must_contain = "id = typr_refine_value(n, 0, TRUE, Inf, FALSE, \"TypR/main.ty:10\")" + +# return position +[[rule]] +file = "R/main.R" +must_contain = "id = typr_refine_value(k, 0, TRUE, Inf, FALSE, \"TypR/main.ty:11\")" + +# proven by literals: no noise +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_value(3" + +[[rule]] +file = "R/main.R" +must_not_contain = "typr_refine_length(c(" diff --git a/cases/0080-refined-record-literal-fields/observed.txt b/cases/0080-refined-record-literal-fields/observed.txt new file mode 100644 index 00000000..209c2e0c --- /dev/null +++ b/cases/0080-refined-record-literal-fields/observed.txt @@ -0,0 +1 @@ +type Account doesn't match type list{id: ...} (before per-field obligations) diff --git a/cases/0080-refined-record-literal-fields/repro/DESCRIPTION b/cases/0080-refined-record-literal-fields/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0080-refined-record-literal-fields/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0080-refined-record-literal-fields/repro/NAMESPACE b/cases/0080-refined-record-literal-fields/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0080-refined-record-literal-fields/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0080-refined-record-literal-fields/repro/R/.gitkeep b/cases/0080-refined-record-literal-fields/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0080-refined-record-literal-fields/repro/TypR/main.ty b/cases/0080-refined-record-literal-fields/repro/TypR/main.ty new file mode 100644 index 00000000..b0817c12 --- /dev/null +++ b/cases/0080-refined-record-literal-fields/repro/TypR/main.ty @@ -0,0 +1,11 @@ +# A record literal flowing into a record type with refined fields: the check goes on each +# field expression that needs one, at each boundary (let, argument, return). +type Account <- list { id: int & (> 0), tags: [int] & length(2) }; +let n: int <- 5; +let v: [int] <- [1, 2]; +let a: Account <- list { id: n, tags: v }; +# Proven by the literals: no check. +let b: Account <- list { id: 3, tags: [1, 2] }; +let describe <- fn(x: Account): int { 1 }; +let d <- describe(list { id: n, tags: [3, 4] }); +let make <- fn(k: int): Account { list { id: k, tags: [1, 2] } }; diff --git a/cases/0081-fixed-length-argument-mismatch/case.toml b/cases/0081-fixed-length-argument-mismatch/case.toml new file mode 100644 index 00000000..2e00a71f --- /dev/null +++ b/cases/0081-fixed-length-argument-mismatch/case.toml @@ -0,0 +1,7 @@ +title = "a fixed-length parameter is not lifted to a longer argument" +cmd = "check" +layer = "type" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0081-fixed-length-argument-mismatch/expect.md b/cases/0081-fixed-length-argument-mismatch/expect.md new file mode 100644 index 00000000..5c747590 --- /dev/null +++ b/cases/0081-fixed-length-argument-mismatch/expect.md @@ -0,0 +1,7 @@ +# 0081 — a fixed-length parameter is not lifted to the argument's length + +`Type::lift` (vectorised calls) used to rewrite the length of a `[2, num]` parameter to `3` whenever an +argument had 3 elements, so `dist(a)` with `a: [3, num]` type-checked. A parameter that already fixes +its length now keeps it (only scalars and unsized/generic-length vectors are lifted), so the call is +rejected statically like the equivalent `let` annotation. Found while working on refined types +(`refined_types_plan.md`, Phase 9 prerequisite). diff --git a/cases/0081-fixed-length-argument-mismatch/expect.toml b/cases/0081-fixed-length-argument-mismatch/expect.toml new file mode 100644 index 00000000..6e62ca47 --- /dev/null +++ b/cases/0081-fixed-length-argument-mismatch/expect.toml @@ -0,0 +1,8 @@ +# both 3-element calls are rejected; the 2-element one is not +[[rule]] +file = "@run" +must_contain = "No signature of function 'dist' matches this call." + +[[rule]] +file = "@run" +must_not_contain = "panicked" diff --git a/cases/0081-fixed-length-argument-mismatch/repro/DESCRIPTION b/cases/0081-fixed-length-argument-mismatch/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0081-fixed-length-argument-mismatch/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0081-fixed-length-argument-mismatch/repro/NAMESPACE b/cases/0081-fixed-length-argument-mismatch/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0081-fixed-length-argument-mismatch/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0081-fixed-length-argument-mismatch/repro/R/.gitkeep b/cases/0081-fixed-length-argument-mismatch/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0081-fixed-length-argument-mismatch/repro/TypR/main.ty b/cases/0081-fixed-length-argument-mismatch/repro/TypR/main.ty new file mode 100644 index 00000000..7faacd72 --- /dev/null +++ b/cases/0081-fixed-length-argument-mismatch/repro/TypR/main.ty @@ -0,0 +1,6 @@ +# A parameter with a fixed length must not be widened to fit a longer argument. +let dist <- fn(p: [2, num]): num { 0.0 }; +let a: [3, num] <- [1.0, 2.0, 3.0]; +dist(a); +dist([1.0, 2.0, 3.0]); +dist([1.0, 2.0]); diff --git a/cases/0082-narrowing-drops-runtime-check/case.toml b/cases/0082-narrowing-drops-runtime-check/case.toml new file mode 100644 index 00000000..7d547a84 --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/case.toml @@ -0,0 +1,7 @@ +title = "branch conditions narrow refined variables" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0082-narrowing-drops-runtime-check/expect.md b/cases/0082-narrowing-drops-runtime-check/expect.md new file mode 100644 index 00000000..6ad0a6ee --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/expect.md @@ -0,0 +1,6 @@ +# 0082 — a branch condition narrows the variable (refined_types_plan.md, Phase 9, §24) + +`if (length(v) == 2) { dist(v) }` needs no `typr_refine_length`: the condition is the check. The +facts read from the condition (`length(x) n`, `x c` on scalars, `&&`, `||` in the else +branch, `!`) are intersected with the variable's type inside the matching branch only +(`type_checking/narrowing.rs`). `!(n < 1)` is `n >= 1`, and `[1, +inf)` lies inside `(0, +inf)`, so that call is proven too. diff --git a/cases/0082-narrowing-drops-runtime-check/expect.toml b/cases/0082-narrowing-drops-runtime-check/expect.toml new file mode 100644 index 00000000..38121261 --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/expect.toml @@ -0,0 +1,25 @@ +# lines 5-11 are proven by their condition: no check there +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:5" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:6" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:7" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:8" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:9" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:11" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:10" +[[rule]] +file = "R/main.R" +must_contain = "dist(v)" diff --git a/cases/0082-narrowing-drops-runtime-check/golden/R/main.R b/cases/0082-narrowing-drops-runtime-check/golden/R/main.R new file mode 100644 index 00000000..a3783513 --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/golden/R/main.R @@ -0,0 +1,81 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +# Condition narrowing: inside the branch the condition proves the refinement. +#' @method dist numeric +`dist.numeric` <- (function(p) { + { + 0 |> as.Number() + } |> + as.Number() +}) |> + as.Generic() + +#' @method pos default +`pos.default` <- (function(x) { + { + x + } |> + as.Integer() +}) |> + as.Generic() + +#' @method f numeric +`f.numeric` <- (function(v, n) { + { + if (length(v) == 2L |> as.Integer()) { + dist(v) + } else { + 0 |> as.Number() + } + if (2L |> as.Integer() == length(v)) { + dist(v) + } else { + 0 |> as.Number() + } + if ( + { + length(v) > 1L |> as.Integer() + } && + { + length(v) < 3L |> as.Integer() + } + ) { + dist(v) + } else { + 0 |> as.Number() + } + if (n > 0L |> as.Integer()) { + pos(n) + } else { + 0L |> as.Integer() + } + if (n <= 0L |> as.Integer()) { + 0L |> as.Integer() + } else { + pos(n) + } + if ( + !{ + n < 1L |> as.Integer() + } + ) { + pos(n) + } else { + 0L |> as.Integer() + } + if (length(v) == 2L |> as.Integer()) { + if (n > 0L |> as.Integer()) { + dist(v) + } else { + 0 |> as.Number() + } + } else { + 0 |> as.Number() + } + 0 |> as.Number() + } |> + as.Number() +}) |> + as.Generic() diff --git a/cases/0082-narrowing-drops-runtime-check/repro/DESCRIPTION b/cases/0082-narrowing-drops-runtime-check/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0082-narrowing-drops-runtime-check/repro/NAMESPACE b/cases/0082-narrowing-drops-runtime-check/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0082-narrowing-drops-runtime-check/repro/R/.gitkeep b/cases/0082-narrowing-drops-runtime-check/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0082-narrowing-drops-runtime-check/repro/TypR/main.ty b/cases/0082-narrowing-drops-runtime-check/repro/TypR/main.ty new file mode 100644 index 00000000..39199040 --- /dev/null +++ b/cases/0082-narrowing-drops-runtime-check/repro/TypR/main.ty @@ -0,0 +1,13 @@ +# Condition narrowing: inside the branch the condition proves the refinement. +let dist <- fn(p: [2, num]): num { 0.0 }; +let pos <- fn(x: int & (> 0)): int { x }; +let f <- fn(v: [num], n: int): num { + if (length(v) == 2) { dist(v) } else { 0.0 }; + if (2 == length(v)) { dist(v) } else { 0.0 }; + if ((length(v) > 1) && (length(v) < 3)) { dist(v) } else { 0.0 }; + if (n > 0) { pos(n) } else { 0 }; + if (n <= 0) { 0 } else { pos(n) }; + if (!(n < 1)) { pos(n) } else { 0 }; + if (length(v) == 2) { if (n > 0) { dist(v) } else { 0.0 } } else { 0.0 }; + 0.0 +}; diff --git a/cases/0083-narrowing-stays-in-branch/case.toml b/cases/0083-narrowing-stays-in-branch/case.toml new file mode 100644 index 00000000..b1808462 --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/case.toml @@ -0,0 +1,7 @@ +title = "narrowing does not leak outside its branch" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0083-narrowing-stays-in-branch/expect.md b/cases/0083-narrowing-stays-in-branch/expect.md new file mode 100644 index 00000000..58927bf1 --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/expect.md @@ -0,0 +1,4 @@ +# 0083 — narrowing is scoped to the branch it proves + +The else-branch of `length(v) == 2`, the code after the `if`, and the then-branch of a `||` are not +narrowed: each `dist(v)` keeps its runtime length check. diff --git a/cases/0083-narrowing-stays-in-branch/expect.toml b/cases/0083-narrowing-stays-in-branch/expect.toml new file mode 100644 index 00000000..ff7f1a7b --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/expect.toml @@ -0,0 +1,9 @@ +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(v, 2L, \"TypR/main.ty:4\")" +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(v, 2L, \"TypR/main.ty:5\")" +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length(v, 2L, \"TypR/main.ty:6\")" diff --git a/cases/0083-narrowing-stays-in-branch/golden/R/main.R b/cases/0083-narrowing-stays-in-branch/golden/R/main.R new file mode 100644 index 00000000..7dd8c3c2 --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/golden/R/main.R @@ -0,0 +1,40 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +# Narrowing must not leak: the else-branch, code after the `if`, and `||` (then) prove nothing. +#' @method dist numeric +`dist.numeric` <- (function(p) { + { + 0 |> as.Number() + } |> + as.Number() +}) |> + as.Generic() + +#' @method f numeric +`f.numeric` <- (function(v) { + { + if (length(v) == 2L |> as.Integer()) { + 1 |> as.Number() + } else { + dist(typr_refine_length(v, 2L, "TypR/main.ty:4")) + } + dist(typr_refine_length(v, 2L, "TypR/main.ty:5")) + if ( + { + length(v) == 2L |> as.Integer() + } || + { + length(v) == 3L |> as.Integer() + } + ) { + dist(typr_refine_length(v, 2L, "TypR/main.ty:6")) + } else { + 0 |> as.Number() + } + 0 |> as.Number() + } |> + as.Number() +}) |> + as.Generic() diff --git a/cases/0083-narrowing-stays-in-branch/repro/DESCRIPTION b/cases/0083-narrowing-stays-in-branch/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0083-narrowing-stays-in-branch/repro/NAMESPACE b/cases/0083-narrowing-stays-in-branch/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0083-narrowing-stays-in-branch/repro/R/.gitkeep b/cases/0083-narrowing-stays-in-branch/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0083-narrowing-stays-in-branch/repro/TypR/main.ty b/cases/0083-narrowing-stays-in-branch/repro/TypR/main.ty new file mode 100644 index 00000000..c4ad9382 --- /dev/null +++ b/cases/0083-narrowing-stays-in-branch/repro/TypR/main.ty @@ -0,0 +1,8 @@ +# Narrowing must not leak: the else-branch, code after the `if`, and `||` (then) prove nothing. +let dist <- fn(p: [2, num]): num { 0.0 }; +let f <- fn(v: [num]): num { + if (length(v) == 2) { 1.0 } else { dist(v) }; + dist(v); + if ((length(v) == 2) || (length(v) == 3)) { dist(v) } else { 0.0 }; + 0.0 +}; diff --git a/cases/0084-refined-length-range/case.toml b/cases/0084-refined-length-range/case.toml new file mode 100644 index 00000000..af16ae68 --- /dev/null +++ b/cases/0084-refined-length-range/case.toml @@ -0,0 +1,7 @@ +title = "range bounds on lengths and values: length(> 0), (>= 0)" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0084-refined-length-range/expect.md b/cases/0084-refined-length-range/expect.md new file mode 100644 index 00000000..ae4e13a8 --- /dev/null +++ b/cases/0084-refined-length-range/expect.md @@ -0,0 +1,11 @@ +`[T] & length(> 0)` and `num & (>= 0)` parse, type check and are enforced like the point forms +(`length(5)`, `(> 0)`): the interval model already covered ranges, only the surface syntax and +`Vec` indexing through a `Refined` were missing. + +- `[int]` passed where `length(> 0)` is expected: runtime check (`typr_refine_length_range`). +- inside `if (length(v) > 0)`: proven by the condition, no check. +- `[3, int]`: implies the range, no check. `[0, int]` is rejected statically. +- `v[1]` on a `[int] & length(> 0)` parameter type-checks like on `[int]`. + +Code: `parsing/types.rs::refinement_property`, `type_arithmetic.rs::apply_refinements`, +`type_checking/mod.rs` (`Lang::ArrayIndexing`). diff --git a/cases/0084-refined-length-range/expect.toml b/cases/0084-refined-length-range/expect.toml new file mode 100644 index 00000000..a40a7354 --- /dev/null +++ b/cases/0084-refined-length-range/expect.toml @@ -0,0 +1,17 @@ +# unproven boundaries carry a range check +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_length_range(v, 0, TRUE, Inf, FALSE, \"TypR/main.ty:7\")" +[[rule]] +file = "R/main.R" +must_contain = "typr_refine_value(n, 0, FALSE, Inf, FALSE, \"TypR/main.ty:9\")" +# the branch condition proves it (line 5), and [3, int] implies it (line 8) +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:5" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:8" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:3\")" diff --git a/cases/0084-refined-length-range/golden/R/main.R b/cases/0084-refined-length-range/golden/R/main.R new file mode 100644 index 00000000..c06e9feb --- /dev/null +++ b/cases/0084-refined-length-range/golden/R/main.R @@ -0,0 +1,62 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +# Range bounds: `length(> 0)`, `(>= 0)`. Checked where unproven, absent where proven. +#' @method first integer +`first.integer` <- (function(v) { + { + v[[1L |> as.Integer()]] + } |> + as.Integer() +}) |> + as.Generic() + +#' @method nonneg numeric0 +`nonneg.numeric0` <- (function(x) { + { + x + } |> + as.Number() +}) |> + as.Generic() + +#' @method narrowed integer +`narrowed.integer` <- (function(v) { + { + if (length(v) > 0L |> as.Integer()) { + first(v) + } else { + 0L |> as.Integer() + } + } |> + as.Integer() +}) |> + as.Generic() + +#' @method unchecked integer +`unchecked.integer` <- (function(v) { + { + first(typr_refine_length_range(v, 0, TRUE, Inf, FALSE, "TypR/main.ty:7")) + } |> + as.Integer() +}) |> + as.Generic() + +#' @method sized integer +`sized.integer` <- (function(v) { + { + first(v) + } |> + as.Integer() +}) |> + as.Generic() + +#' @method scalar numeric +`scalar.numeric` <- (function(n) { + { + nonneg(typr_refine_value(n, 0, FALSE, Inf, FALSE, "TypR/main.ty:9")) + } |> + as.Number() +}) |> + as.Generic() diff --git a/cases/0084-refined-length-range/repro/DESCRIPTION b/cases/0084-refined-length-range/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0084-refined-length-range/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0084-refined-length-range/repro/NAMESPACE b/cases/0084-refined-length-range/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0084-refined-length-range/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0084-refined-length-range/repro/R/.gitkeep b/cases/0084-refined-length-range/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0084-refined-length-range/repro/TypR/main.ty b/cases/0084-refined-length-range/repro/TypR/main.ty new file mode 100644 index 00000000..0d7d48ea --- /dev/null +++ b/cases/0084-refined-length-range/repro/TypR/main.ty @@ -0,0 +1,9 @@ +# Range bounds: `length(> 0)`, `(>= 0)`. Checked where unproven, absent where proven. +let first <- fn(v: [int] & length(> 0)): int { v[1] }; +let nonneg <- fn(x: num & (>= 0)): num { x }; +let narrowed <- fn(v: [int]): int { + if (length(v) > 0) { first(v) } else { 0 } +}; +let unchecked <- fn(v: [int]): int { first(v) }; +let sized <- fn(v: [3, int]): int { first(v) }; +let scalar <- fn(n: num): num { nonneg(n) }; diff --git a/cases/0085-refined-generic-length-range/case.toml b/cases/0085-refined-generic-length-range/case.toml new file mode 100644 index 00000000..42aacd91 --- /dev/null +++ b/cases/0085-refined-generic-length-range/case.toml @@ -0,0 +1,7 @@ +title = "refinements of a generic base are checked at the call: [#N, T] & length(> 0)" +cmd = "build" +layer = "transpile" +status = "fixed" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0085-refined-generic-length-range/expect.md b/cases/0085-refined-generic-length-range/expect.md new file mode 100644 index 00000000..5af94264 --- /dev/null +++ b/cases/0085-refined-generic-length-range/expect.md @@ -0,0 +1,9 @@ +A generic base keeps its refinements at a call: `[#N, T] & length(> 0)` unifies `T` like `[#N, T]`, +and the refinement is then decided against the argument, since unification alone never looks at it. + +- `[int]` passed in: runtime check (`typr_refine_length_range`) on the argument. +- inside `if (length(v) > 0)`, or `[3, char]`: proven, no check. +- `[0, char]`: rejected statically (no signature matches). + +Code: `type_checking/mod.rs::get_gen_type` and `unification.rs` (`Refined` arms), +`function_application.rs::generic_refinement_obligations`, `type_arithmetic.rs::declared_refinements`. diff --git a/cases/0085-refined-generic-length-range/expect.toml b/cases/0085-refined-generic-length-range/expect.toml new file mode 100644 index 00000000..228e8b76 --- /dev/null +++ b/cases/0085-refined-generic-length-range/expect.toml @@ -0,0 +1,11 @@ +# the unproven boundary carries the range check on the argument +[[rule]] +file = "R/main.R" +must_contain = "first(typr_refine_length_range(v, 0, TRUE, Inf, FALSE, \"TypR/main.ty:6\"))" +# proven by the branch condition (line 4) or by the length 3 (line 7): no check +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:4" +[[rule]] +file = "R/main.R" +must_not_contain = "main.ty:7" diff --git a/cases/0085-refined-generic-length-range/golden/R/main.R b/cases/0085-refined-generic-length-range/golden/R/main.R new file mode 100644 index 00000000..62668f3b --- /dev/null +++ b/cases/0085-refined-generic-length-range/golden/R/main.R @@ -0,0 +1,44 @@ +#' @include std.R +#' @include generic_functions.R +#' @include types.R + +# A generic base keeps its refinements: `[#N, T] & length(> 0)` is checked like `[int] & length(> 0)`. +#' @method first Array0 +`first.Array0` <- (function(v) { + { + v[[1L |> as.Integer()]] + } |> + as.Generic() +}) |> + as.Generic() + +#' @method narrowed integer +`narrowed.integer` <- (function(v) { + { + if (length(v) > 0L |> as.Integer()) { + first(v) + } else { + 0L |> as.Integer() + } + } |> + as.Integer() +}) |> + as.Generic() + +#' @method unchecked integer +`unchecked.integer` <- (function(v) { + { + first(typr_refine_length_range(v, 0, TRUE, Inf, FALSE, "TypR/main.ty:6")) + } |> + as.Integer() +}) |> + as.Generic() + +#' @method sized character +`sized.character` <- (function(v) { + { + first(v) + } |> + as.Character() +}) |> + as.Generic() diff --git a/cases/0085-refined-generic-length-range/repro/DESCRIPTION b/cases/0085-refined-generic-length-range/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0085-refined-generic-length-range/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0085-refined-generic-length-range/repro/NAMESPACE b/cases/0085-refined-generic-length-range/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0085-refined-generic-length-range/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0085-refined-generic-length-range/repro/TypR/main.ty b/cases/0085-refined-generic-length-range/repro/TypR/main.ty new file mode 100644 index 00000000..49208648 --- /dev/null +++ b/cases/0085-refined-generic-length-range/repro/TypR/main.ty @@ -0,0 +1,7 @@ +# A generic base keeps its refinements: `[#N, T] & length(> 0)` is checked like `[int] & length(> 0)`. +let first <- fn(v: [#N, T] & length(> 0)): T { v[1] }; +let narrowed <- fn(v: [int]): int { + if (length(v) > 0) { first(v) } else { 0 } +}; +let unchecked <- fn(v: [int]): int { first(v) }; +let sized <- fn(v: [3, char]): char { first(v) }; diff --git a/cases/0086-interface-return-bound-to-first-arg/case.toml b/cases/0086-interface-return-bound-to-first-arg/case.toml new file mode 100644 index 00000000..2e16558e --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/case.toml @@ -0,0 +1,7 @@ +title = "a return typed Lovable@B is bound to the first argument, not to B" +cmd = "check" +layer = "type" +status = "open" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0086-interface-return-bound-to-first-arg/expect.md b/cases/0086-interface-return-bound-to-first-arg/expect.md new file mode 100644 index 00000000..122be36b --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/expect.md @@ -0,0 +1,5 @@ +# a return typed Lovable@B is bound to the first argument, not to B + +Avec `fn(a: Lovable@A, b: Lovable@B): Lovable@B`, `let s: Cat <- second(cat, dog)` doit être rejeté (le retour est un `Dog`). Aujourd'hui `@A`/`@B` sont avalés et le retour est lié au premier argument : le programme passe. + +Référence : `interface_generic_unification_plan.md` §0 (défaut 1), phases 3-4. diff --git a/cases/0086-interface-return-bound-to-first-arg/expect.toml b/cases/0086-interface-return-bound-to-first-arg/expect.toml new file mode 100644 index 00000000..36b85854 --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/expect.toml @@ -0,0 +1,3 @@ +[[rule]] +file = "@run" +must_contain = "Type errors found" diff --git a/cases/0086-interface-return-bound-to-first-arg/observed.txt b/cases/0086-interface-return-bound-to-first-arg/observed.txt new file mode 100644 index 00000000..627e23b8 --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/observed.txt @@ -0,0 +1,11 @@ +# exit=0 + +## stdout/stderr +Code verification successful! + + Parsing... done (7 ms) + Type checking... done (28 ms) + + +## R généré + diff --git a/cases/0086-interface-return-bound-to-first-arg/repro/DESCRIPTION b/cases/0086-interface-return-bound-to-first-arg/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0086-interface-return-bound-to-first-arg/repro/NAMESPACE b/cases/0086-interface-return-bound-to-first-arg/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0086-interface-return-bound-to-first-arg/repro/R/.gitkeep b/cases/0086-interface-return-bound-to-first-arg/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0086-interface-return-bound-to-first-arg/repro/TypR/main.ty b/cases/0086-interface-return-bound-to-first-arg/repro/TypR/main.ty new file mode 100644 index 00000000..f7d288e3 --- /dev/null +++ b/cases/0086-interface-return-bound-to-first-arg/repro/TypR/main.ty @@ -0,0 +1,9 @@ +type Lovable <- interface { love: (Self) -> int }; +type Cat <- list { name: char }; +type Dog <- list { age: int }; +let love <- fn(c: Cat): int { 1 }; +let love <- fn(d: Dog): int { 2 }; +let cat <- Cat:{ name = "tom" }; +let dog <- Dog:{ age = 3 }; +let second <- fn(a: Lovable@A, b: Lovable@B): Lovable@B { b }; +let s: Cat <- second(cat, dog); diff --git a/cases/0087-interface-shared-id-different-types/case.toml b/cases/0087-interface-shared-id-different-types/case.toml new file mode 100644 index 00000000..8f56e5dd --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/case.toml @@ -0,0 +1,7 @@ +title = "two parameters sharing the identifier X must receive the same type" +cmd = "check" +layer = "type" +status = "open" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0087-interface-shared-id-different-types/expect.md b/cases/0087-interface-shared-id-different-types/expect.md new file mode 100644 index 00000000..a7f505a8 --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/expect.md @@ -0,0 +1,5 @@ +# two parameters sharing the identifier X must receive the same type + +Avec `fn(a: Lovable@X, b: Lovable@X)`, `same_kind(cat, dog)` doit être rejeté (X lié à Cat puis Dog). Aujourd'hui `@X` est avalé silencieusement et l'appel passe. + +Référence : `interface_generic_unification_plan.md` §0 (défaut 2), phases 3-4. diff --git a/cases/0087-interface-shared-id-different-types/expect.toml b/cases/0087-interface-shared-id-different-types/expect.toml new file mode 100644 index 00000000..36b85854 --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/expect.toml @@ -0,0 +1,3 @@ +[[rule]] +file = "@run" +must_contain = "Type errors found" diff --git a/cases/0087-interface-shared-id-different-types/observed.txt b/cases/0087-interface-shared-id-different-types/observed.txt new file mode 100644 index 00000000..9a8e7720 --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/observed.txt @@ -0,0 +1,11 @@ +# exit=0 + +## stdout/stderr +Code verification successful! + + Parsing... done (6 ms) + Type checking... done (26 ms) + + +## R généré + diff --git a/cases/0087-interface-shared-id-different-types/repro/DESCRIPTION b/cases/0087-interface-shared-id-different-types/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0087-interface-shared-id-different-types/repro/NAMESPACE b/cases/0087-interface-shared-id-different-types/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0087-interface-shared-id-different-types/repro/R/.gitkeep b/cases/0087-interface-shared-id-different-types/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0087-interface-shared-id-different-types/repro/TypR/main.ty b/cases/0087-interface-shared-id-different-types/repro/TypR/main.ty new file mode 100644 index 00000000..9f7a1334 --- /dev/null +++ b/cases/0087-interface-shared-id-different-types/repro/TypR/main.ty @@ -0,0 +1,9 @@ +type Lovable <- interface { love: (Self) -> int }; +type Cat <- list { name: char }; +type Dog <- list { age: int }; +let love <- fn(c: Cat): int { 1 }; +let love <- fn(d: Dog): int { 2 }; +let cat <- Cat:{ name = "tom" }; +let dog <- Dog:{ age = 3 }; +let same_kind <- fn(a: Lovable@X, b: Lovable@X): int { a.love() }; +same_kind(cat, dog); diff --git a/cases/0088-interface-self-self-distinct-ids/case.toml b/cases/0088-interface-self-self-distinct-ids/case.toml new file mode 100644 index 00000000..8e27012f --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/case.toml @@ -0,0 +1,7 @@ +title = "a (Self, Self) method on two distinct rigid ids is rejected" +cmd = "check" +layer = "type" +status = "open" +created = "2026-09-30" +origin = "perso" +checked = false diff --git a/cases/0088-interface-self-self-distinct-ids/expect.md b/cases/0088-interface-self-self-distinct-ids/expect.md new file mode 100644 index 00000000..6d853721 --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/expect.md @@ -0,0 +1,5 @@ +# a (Self, Self) method on two distinct rigid ids is rejected + +Avec `fn(a: Same@A, b: Same@B): bool { a.same(b) }`, `b : B` ne satisfait pas le paramètre `A` : erreur attendue dans le corps. Aujourd'hui accepté (`@A`/`@B` avalés, et rigides distincts jugés compatibles). + +Référence : `interface_generic_unification_plan.md` §0 (défaut 3), phases 3-4. diff --git a/cases/0088-interface-self-self-distinct-ids/expect.toml b/cases/0088-interface-self-self-distinct-ids/expect.toml new file mode 100644 index 00000000..36b85854 --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/expect.toml @@ -0,0 +1,3 @@ +[[rule]] +file = "@run" +must_contain = "Type errors found" diff --git a/cases/0088-interface-self-self-distinct-ids/observed.txt b/cases/0088-interface-self-self-distinct-ids/observed.txt new file mode 100644 index 00000000..5a333539 --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/observed.txt @@ -0,0 +1,11 @@ +# exit=0 + +## stdout/stderr +Code verification successful! + + Parsing... done (8 ms) + Type checking... done (27 ms) + + +## R généré + diff --git a/cases/0088-interface-self-self-distinct-ids/repro/DESCRIPTION b/cases/0088-interface-self-self-distinct-ids/repro/DESCRIPTION new file mode 100644 index 00000000..b5d35be3 --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/repro/DESCRIPTION @@ -0,0 +1,5 @@ +Package: repro +Title: TypR repro case +Version: 0.0.0.9000 +Description: Minimal reproduction project for `typr case`. +Encoding: UTF-8 diff --git a/cases/0088-interface-self-self-distinct-ids/repro/NAMESPACE b/cases/0088-interface-self-self-distinct-ids/repro/NAMESPACE new file mode 100644 index 00000000..e651b944 --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/repro/NAMESPACE @@ -0,0 +1 @@ +# Generated by roxygen2: do not edit by hand diff --git a/cases/0088-interface-self-self-distinct-ids/repro/R/.gitkeep b/cases/0088-interface-self-self-distinct-ids/repro/R/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cases/0088-interface-self-self-distinct-ids/repro/TypR/main.ty b/cases/0088-interface-self-self-distinct-ids/repro/TypR/main.ty new file mode 100644 index 00000000..fc5b6ccb --- /dev/null +++ b/cases/0088-interface-self-self-distinct-ids/repro/TypR/main.ty @@ -0,0 +1,10 @@ +type Lovable <- interface { love: (Self) -> int }; +type Cat <- list { name: char }; +type Dog <- list { name: char, age: int }; +let love <- fn(c: Cat): int { 1 }; +let love <- fn(d: Dog): int { 2 }; +let cat <- Cat:{ name = "tom" }; +let dog <- Dog:{ name = "rex", age = 3 }; +type Same <- interface { same: (Self, Self) -> bool }; +let same <- fn(a: Cat, b: Cat): bool { true }; +let f <- fn(a: Same@A, b: Same@B): bool { a.same(b) }; diff --git a/cases/README.md b/cases/README.md index c8762946..d0a02f06 100644 --- a/cases/README.md +++ b/cases/README.md @@ -15,6 +15,7 @@ cases/0001-pub-typed-fn-in-module/ expect.md # ce qui DEVRAIT se passer (prose), + localisation dans le code observed.txt # l'erreur / le R fautif au moment du report golden/ # (cas fixed) copie des R/*.R ciblés → diff de non-régression (jamais "@run") + cache/ # optionnel : voir "Bundler un cache" ci-dessous ``` `case.toml` : @@ -66,6 +67,36 @@ particulier à gérer. `@run` n'est **jamais** diffé en golden (le texte captur timings de la barre de progression CLI, qui varient à chaque run) — reste du grep pur, golden/ continue de ne cibler que des fichiers `R/*.R` déterministes. +## Bundler un cache (`cache/`) + +Certaines fonctionnalités lisent un cache **hors** du répertoire du projet par construction — +par exemple `~/.cache/typr/types///` pour une Type Definition externe verrouillée +dans `typr.lock` (`typR/registry.md` §7.4). Un `repro/` copié dans un sandbox temporaire ne peut +donc pas transporter ce cache avec lui, et le cas échouerait toujours en pratique (paquet +"absent du cache") plutôt que d'exercer le vrai comportement. + +Un sous-dossier `cache/` optionnel, sibling de `repro/`, résout ça : `cases.rs::build_sandbox` +le copie dans le sandbox et pointe `$XDG_CACHE_HOME` dessus pour la durée de l'invocation. Il +doit donc être disposé exactement comme `$XDG_CACHE_HOME` le serait, par ex. : + +``` +cases/00NN-mon-cas/ + cache/ + typr/types/// + typr-def.toml + ty/core.ty + repro/ + typr.lock # digest ci-dessus, même package + TypR/main.ty +``` + +Le digest doit correspondre exactement à ce que `typr.lock` déclare — c'est une vérification +mécanique (`type_registry::compute_digest`, ré-exécutée à chaque `check`/`build`/`run`), pas une +convention : un `cache/` désynchronisé du `typr.lock` du cas se traduit par un cas qui échoue +avec « no longer matches typr.lock », pas par un chargement silencieusement raté. Voir +`cases/0066-external-type-definition-low-tier-degrades-to-any/` pour un exemple complet. Sans +`cache/`, le comportement de `build_sandbox` est inchangé. + ## Capturer un cas depuis un projet en cours de dev (`snapshot`) Quand tu rencontres un bug en développant un package TypR ailleurs, **depuis la racine de ce diff --git a/crates/typr-cli/Cargo.toml b/crates/typr-cli/Cargo.toml index 8ad1741d..8a5eb7fb 100644 --- a/crates/typr-cli/Cargo.toml +++ b/crates/typr-cli/Cargo.toml @@ -21,6 +21,7 @@ path = "src/lib.rs" # Use typr-core for core logic. `fuzz-gen` exposes utils::program_gen (Phase B # Stage 2's `typr fuzz`, soundness_transpilation.md) — see typr-core/Cargo.toml. typr-core = { workspace = true, features = ["fuzz-gen"] } +typr-graph.workspace = true # Pure dependencies (needed for parsing/type manipulation) nom.workspace = true @@ -44,3 +45,4 @@ typr-mcp.workspace = true rustyline.workspace = true syntect.workspace = true bincode.workspace = true +sha2.workspace = true diff --git a/crates/typr-cli/configs/src/introspect_pkg.R b/crates/typr-cli/configs/src/introspect_pkg.R index a7f795fb..ab1ecce6 100644 --- a/crates/typr-cli/configs/src/introspect_pkg.R +++ b/crates/typr-cli/configs/src/introspect_pkg.R @@ -18,7 +18,9 @@ # machine during a build and must not require any package to be installed. # # Vr_version +# Ppkgversion (package version, if determinable) # Nnamepkgs3_generics4_generichas_default +# Fnamehas_dotsparam1param2... (formals(), for `typr gen-types`) # Cs4_class_name # Epkgreason (package could not be loaded) @@ -60,6 +62,9 @@ for (pkg in args) { next } + ver <- tryCatch(as.character(utils::packageVersion(pkg)), error = function(e) NA_character_) + if (!is.na(ver) && tsv_safe(ver)) emit("P", pkg, ver) + exports <- tryCatch(getNamespaceExports(ns), error = function(e) character(0)) # S4 generics visible now that the namespace is loaded. `getGenerics()` is @@ -89,6 +94,19 @@ for (pkg in args) { if (n %in% s4_names) "1" else "0", if (has_default) "1" else "0" ) + + # formals() — arity and parameter names, for `typr gen-types` (registry.md + # §6). Primitives report `formals()` as NULL; `args(val)` recovers theirs. + formals_val <- tryCatch(formals(val), error = function(e) NULL) + if (is.null(formals_val)) { + formals_val <- tryCatch(formals(args(val)), error = function(e) NULL) + } + if (!is.null(formals_val)) { + param_names <- names(formals_val) + has_dots <- "..." %in% param_names + params <- Filter(tsv_safe, param_names[param_names != "..."]) + emit("F", n, if (has_dots) "1" else "0", params) + } } # NOTE: `getClasses()` reads its caller's environment when `where` is left to diff --git a/crates/typr-cli/configs/src/std.R b/crates/typr-cli/configs/src/std.R index 45f30abf..8cac50ae 100644 --- a/crates/typr-cli/configs/src/std.R +++ b/crates/typr-cli/configs/src/std.R @@ -81,6 +81,41 @@ typr_assert_type <- function(x, expected, loc, what) { x } +#' @title Vérifications des types raffinés (`[int] & length(3)`, `int & (> 0)`) +#' Émises par le compilateur seulement là où il n'a pas pu prouver le +#' raffinement. Elles renvoient `x` pour s'insérer dans n'importe quelle +#' expression ; `all()`/`isTRUE()` les rendent sûres face aux vecteurs et aux NA. +typr_refine_length <- function(x, n, loc) { + if (!isTRUE(length(x) == n)) { + stop(sprintf("Type refinement violation at %s: expected length %d, got %d", + loc, n, length(x)), call. = FALSE) + } + x +} + +typr_refine_length_range <- function(x, lo, lo_open, hi, hi_open, loc) { + n <- length(x) + ok <- (if (lo_open) n > lo else n >= lo) && (if (hi_open) n < hi else n <= hi) + if (!isTRUE(ok)) { + stop(sprintf("Type refinement violation at %s: length %d is out of bounds %s%s, %s%s", + loc, n, if (lo_open || is.infinite(lo)) "(" else "[", format(lo), format(hi), + if (hi_open || is.infinite(hi)) ")" else "]"), call. = FALSE) + } + x +} + +typr_refine_value <- function(x, lo, lo_open, hi, hi_open, loc) { + above <- if (lo_open) x > lo else x >= lo + below <- if (hi_open) x < hi else x <= hi + if (!isTRUE(all(above & below))) { + bad <- x[!(above & below)][1L] + stop(sprintf("Type refinement violation at %s: value %s is out of bounds %s%s, %s%s", + loc, format(bad), if (lo_open || is.infinite(lo)) "(" else "[", format(lo), format(hi), + if (hi_open || is.infinite(hi)) ")" else "]"), call. = FALSE) + } + x +} + # --- Génériques S3 --- get <- function(a, ...) UseMethod("get") diff --git a/crates/typr-cli/src/cases.rs b/crates/typr-cli/src/cases.rs index d9730d0d..ed214494 100644 --- a/crates/typr-cli/src/cases.rs +++ b/crates/typr-cli/src/cases.rs @@ -210,6 +210,15 @@ pub fn unique_tmp() -> PathBuf { } /// Copy `repro/` into a temp sandbox and run `typr ` (this same binary) inside it. +/// +/// If the case bundle also ships a `cache/` folder (sibling of `repro/`), it is copied into +/// the sandbox too and exposed as `$XDG_CACHE_HOME` for the invocation — the fix for +/// `registry.md` §13 J2's "cache portability" gap: `typr.lock` resolution reads from +/// `~/.cache/typr/types///` (`type_registry::cache_root`), which lives outside +/// `repro/` by construction (§7.4) and so was invisible to a case's sandboxed copy. Bundling a +/// `cache/` directory laid out exactly like `$XDG_CACHE_HOME` (i.e. `cache/typr/types/…`) lets +/// a case exercise real `typr.lock` resolution — tier/version degradation — without touching +/// the developer's real cache or the network. Cases with no `cache/` folder are unaffected. fn build_sandbox(case: &Path) -> Sandbox { let meta = read_meta(case); let repro = case.join("repro"); @@ -225,6 +234,15 @@ fn build_sandbox(case: &Path) -> Sandbox { .arg(&meta.cmd) .current_dir(&work) .env(crate::r_deps::SKIP_ENV_VAR, "1"); + let bundled_cache = case.join("cache"); + if bundled_cache.is_dir() { + let cache_work = tmp.join("cache"); + if let Err(e) = copy_dir(&bundled_cache, &cache_work) { + eprintln!("{RED}Impossible de copier le cache de {}: {e}{RESET}", case.display()); + std::process::exit(1); + } + command.env("XDG_CACHE_HOME", &cache_work); + } if meta.checked && (meta.cmd == "build" || meta.cmd == "run") { command.arg("--checked"); } @@ -453,7 +471,15 @@ pub fn add(slug: &str, from: Option, cmd: &str, layer: &str) { if !cases.exists() { let _ = std::fs::create_dir_all(&cases); } - let n = case_dirs().len() + 1; + // Next id from the highest existing numeric prefix, not the directory + // count: cases/ has gaps (deleted/renumbered entries), so `len() + 1` + // collides with an already-used id the moment count < max + 1. + let n = case_dirs() + .iter() + .filter_map(|p| basename(p).split('-').next().and_then(|s| s.parse::().ok())) + .max() + .unwrap_or(0) + + 1; let id = format!("{:04}", n); let dir = cases.join(format!("{id}-{slug}")); if let Err(e) = std::fs::create_dir_all(&dir) { diff --git a/crates/typr-cli/src/cli.rs b/crates/typr-cli/src/cli.rs index f949759a..ac1cd83c 100644 --- a/crates/typr-cli/src/cli.rs +++ b/crates/typr-cli/src/cli.rs @@ -45,6 +45,28 @@ enum Commands { #[arg(value_name = "FILE")] file: Option, }, + /// Emit the block-graph view of a file (`visualization_graph_v2.md`): the program broken + /// into typed blocks with their ports, wires, and cross-block `Ref`/`HasType`/`TypePosition` + /// relations — for code review and understanding, not for building. `typr graph diff` (see + /// `typr graph diff --help`) compares two versions of a program instead of viewing one. + Graph { + #[command(subcommand)] + graph_command: Option, + #[arg(value_name = "FILE")] + file: Option, + /// `json` (the full block map, spec §9) or `dot` (one level, rendered with Graphviz). + #[arg(long, value_name = "FORMAT", default_value = "json")] + format: String, + /// Restrict the view to one block and its direct children (a `BlockKey` like + /// `val:norm2`, printed by a previous `--format json` run). Required in practice for + /// `--format dot` on anything but the top level; defaults to the whole program there. + #[arg(long, value_name = "KEY")] + focus: Option, + /// `deps` (top-level blocks + flattened `Ref` edges) or `types` (`TypeDecl`/`Interface` + /// blocks + type relations). + #[arg(long, value_name = "PROJECTION")] + projection: Option, + }, Build { #[arg(value_name = "FILE")] file: Option, @@ -173,6 +195,124 @@ enum Commands { #[arg(long, short, value_name = "FILE")] output: Option, }, + /// Generate a `.ty` type definition for an *installed* R package by + /// introspecting its exports (arity, argument names, presence of `...`), + /// entirely at `#! tier: T3` — see `typR/registry.md` §6. Never fails a + /// build: a generated definition types every parameter and return value + /// as `Any`. + GenTypes { + package: String, + /// Directory to write `.generated.ty` into (default: `ty/`). + #[arg(long, short, value_name = "DIR")] + out: Option, + }, + /// Resolve, cache, and vendor external Type Definitions for R packages — + /// see `typR/registry.md` §7 and `rfcs/0031-external-type-definitions.md`. + Types { + #[command(subcommand)] + types_command: TypesCommands, + }, + /// List every Type Definition the `we-data-ch/registry` index has for a + /// package, ranked exactly as `typr types add`/`typr use` would pick + /// among them (registry.md §8.3, §13 J3) — a survey of the alternatives, + /// not a selection. Never fails a build: an unreachable registry or an + /// unlisted package are reported, not treated as errors. + Search { + package: String, + }, +} + +#[derive(Subcommand, Debug)] +enum TypesCommands { + /// Pin a definition for `` (fetch, verify its manifest and + /// capabilities, cache it by content digest, and record it in + /// `typr.lock`). + Add { + /// The package this definition describes (e.g. `shiny`). + package: String, + /// `github:owner/repo[@rev]`. Omit to resolve one automatically: an + /// explicit `typr.toml [types]` pin first, then a lookup in the + /// `we-data-ch/registry` (registry.md §13 J3). + repo: Option, + }, + /// Re-fetch and re-pin `typr.lock` for one package (or, with none given, + /// every resolved definition). + Update { package: Option }, + /// What's resolved in `typr.lock`, with tier and provenance. + List, + /// Copy every resolved definition's `.ty` files into the project tree + /// (default `ty/vendor//`), so the build stops depending on the + /// network or the upstream repository's continued existence. + Vendor { + #[arg(long, short, value_name = "DIR")] + out: Option, + }, + /// Run the mechanical checks of `typR/registry.md` §9 against a Type + /// Definition repository: manifest/capabilities, `.ty` parsing/type- + /// checking, `tests/smoke.ty`, exports and arity vs. `formals()` on the + /// locally installed package, and the T1 "no unconstrained `...`" + /// promotion gate. Exits 1 if any check fails (never on a `Skipped` one — + /// registry.md D2/D5). + Validate { + /// The package this definition describes (e.g. `shiny`). + package: String, + /// `github:owner/repo[@rev]`. Omit to resolve one automatically, the + /// same way `typr types add` does. + repo: Option, + }, + /// Run `Validate`'s checks against *every* definition the + /// `we-data-ch/registry` index lists, and report drift since the last run + /// — registry.md §13 J4, "revalidation périodique des définitions déjà + /// indexées (détection de dérive)". Meant to run centrally (a scheduled + /// CI job in `we-data-ch/registry` itself), not per-project. Exits 1 only + /// when something that was fine last run just broke (`--out`'s previous + /// contents vs. now) — a long-standing, already-known failure doesn't + /// keep failing the job forever. + Revalidate { + /// A local checkout of `we-data-ch/registry` to validate as-is (e.g. + /// a CI job's own working tree). Omit to sync the same local mirror + /// `typr search`/`typr types add` already use. + #[arg(long, value_name = "PATH")] + dir: Option, + /// Where the previous run's snapshot is read from and the new one is + /// written to. Defaults to `/status/validation.json`. + #[arg(long, short, value_name = "FILE")] + out: Option, + }, + /// Open a pull request against the community registry adding or updating + /// `packages/.json` for this definition — "Add to Registry" + /// from the CLI, no Store account needed. Runs the same checks as + /// `Validate` first and refuses to submit anything that fails them. + /// Needs the `gh` CLI, already authenticated (`gh auth login`): the PR + /// is opened as you, from your own fork of the registry. + Submit { + /// The package this definition describes (e.g. `shiny`). + package: String, + /// `github:owner/repo[/subdir][@rev]`. Omit to use whatever this + /// project already has pinned for `package` in `typr.lock` (i.e. + /// after `typr types add`). + repo: Option, + /// `owner/repo` of the registry to submit to. + #[arg(long, value_name = "OWNER/REPO", default_value = crate::registry_submit::DEFAULT_REGISTRY_REPO)] + registry: String, + }, +} + +#[derive(Subcommand, Debug)] +enum GraphCommands { + /// Diff the block graphs of two versions of a program, paired by `BlockKey` + /// (`visualization_graph_v2.md` §12 étape 6): blocks added, removed, or modified + /// (`type`/`interface`/`captures` changed). Both files are built through the same pipeline + /// as `typr graph`, independently — they need not be related by git history. + Diff { + #[arg(value_name = "OLD_FILE")] + old_file: PathBuf, + #[arg(value_name = "NEW_FILE")] + new_file: PathBuf, + /// `text` (human-readable, default) or `json` (the machine contract). + #[arg(long, value_name = "FORMAT", default_value = "text")] + format: String, + }, } #[derive(Subcommand, Debug)] @@ -304,6 +444,9 @@ fn skips_r_deps_check(command: &Option) -> bool { | Some(Commands::Std { .. }) | Some(Commands::Cache { .. }) | Some(Commands::Syntax { .. }) + | Some(Commands::GenTypes { .. }) + | Some(Commands::Types { .. }) + | Some(Commands::Search { .. }) ) } @@ -329,6 +472,30 @@ pub fn start() { Some(path) => check_file(&path), _ => check_project(), }, + Some(Commands::Graph { + graph_command: + Some(GraphCommands::Diff { + old_file, + new_file, + format, + }), + .. + }) => crate::graph::graph_diff(&old_file, &new_file, &format), + Some(Commands::Graph { + graph_command: None, + file: Some(file), + format, + focus, + projection, + }) => crate::graph::graph_file(&file, &format, focus.as_deref(), projection.as_deref()), + Some(Commands::Graph { + graph_command: None, + file: None, + .. + }) => { + eprintln!("typr graph requires a FILE, or use `typr graph diff `"); + std::process::exit(1); + } Some(Commands::Build { file, test, @@ -390,7 +557,10 @@ pub fn start() { }, Some(Commands::Document) => document(), Some(Commands::Pkgdown) => pkgdown(), - Some(Commands::Use { package_name }) => use_package(&package_name), + Some(Commands::Use { package_name }) => { + use_package(&package_name); + try_auto_resolve_type_definition(std::path::Path::new("."), &package_name); + } Some(Commands::Load) => load(), Some(Commands::Cran) => cran(), Some(Commands::Std { std_command }) => match std_command { @@ -430,6 +600,9 @@ pub fn start() { check, }) => run_syntax_command(json, target, output, write, check), Some(Commands::Spg { output }) => generate_spg(output), + Some(Commands::GenTypes { package, out }) => crate::gen_types::run(&package, out), + Some(Commands::Types { types_command }) => run_types_command(types_command), + Some(Commands::Search { package }) => run_search_command(&package), _ => { println!("Please specify a subcommand or file to execute"); std::process::exit(1); @@ -540,3 +713,194 @@ fn run_cache_command(command: CacheCommands) { } } } + +/// `typr types ` — see +/// `typR/registry.md` §7, §9, §13 J4 and `rfcs/0031-external-type-definitions.md`. +fn run_types_command(command: TypesCommands) { + use crate::type_registry; + + let root = std::path::Path::new("."); + + match command { + TypesCommands::Add { package, repo } => { + let spec = match repo { + Some(spec) => spec, + None => match type_registry::resolve_spec_for_package(root, &package) { + Some(spec) => spec, + None => { + eprintln!( + "error: no definition found for `{package}` — no `typr.toml [types]` pin \ + and nothing in the registry; pass an explicit `github:owner/repo[@rev]`" + ); + std::process::exit(1); + } + }, + }; + run_types_add(root, &package, &spec); + } + TypesCommands::Update { package } => match type_registry::update(root, package.as_deref()) { + Ok(updated) if updated.is_empty() => println!("nothing to update — typr.lock is empty."), + Ok(updated) => { + for locked in updated { + println!( + "{} — {} {} (tier {}, rev {})", + locked.package, + locked.repository, + locked.version, + locked.tier, + &locked.rev[..locked.rev.len().min(12)] + ); + } + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }, + TypesCommands::List => { + let definitions = type_registry::list(root); + if definitions.is_empty() { + println!("nothing resolved — see `typr types add`."); + } + for def in definitions { + println!( + "{:<12} {:<32} {:<10} tier {:<3} rev {}", + def.package, + def.repository, + def.version, + def.tier, + &def.rev[..def.rev.len().min(12)] + ); + } + } + TypesCommands::Vendor { out } => match type_registry::vendor(root, out.as_deref()) { + Ok(written) => println!("vendored {} file(s).", written.len()), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + }, + TypesCommands::Validate { package, repo } => { + let spec = match repo { + Some(spec) => spec, + None => match type_registry::resolve_spec_for_package(root, &package) { + Some(spec) => spec, + None => { + eprintln!( + "error: no definition found for `{package}` — no `typr.toml [types]` pin \ + and nothing in the registry; pass an explicit `github:owner/repo[@rev]`" + ); + std::process::exit(1); + } + }, + }; + let report = crate::registry_validate::validate(&package, &spec); + print!("{}", report.render()); + if !report.ok() { + std::process::exit(1); + } + } + TypesCommands::Submit { + package, + repo, + registry, + } => { + use crate::registry_submit::{self, SubmitOutcome}; + match registry_submit::submit(root, &package, repo.as_deref(), ®istry) { + Ok(SubmitOutcome::Opened(pr_url)) => println!("opened {pr_url}"), + Ok(SubmitOutcome::AlreadyUpToDate) => { + println!("`{package}` is already indexed identically in {registry} — nothing to submit.") + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } + } + TypesCommands::Revalidate { dir, out } => { + use crate::registry_revalidate; + match registry_revalidate::revalidate(dir.as_deref(), out.as_deref()) { + Ok((snapshots, drift, out_path)) => { + print!("{}", registry_revalidate::render(&snapshots, &drift)); + println!("\nwrote {}", out_path.display()); + if !drift.newly_failing.is_empty() { + std::process::exit(1); + } + } + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } + } + } +} + +/// `typr search ` — survey what `we-data-ch/registry` has for `pkg`, +/// ranked exactly as `typr types add`/`typr use` would pick among them +/// (registry.md §8.3, §13 J3's `typr search` item). +fn run_search_command(package: &str) { + use crate::type_registry; + + match type_registry::search(package) { + Ok(entries) if entries.is_empty() => { + println!("no definitions found for `{package}` in the registry."); + } + Ok(entries) => { + for entry in entries { + let repo = match entry.rev.as_deref() { + Some(rev) if !rev.is_empty() => format!("github:{}@{}", entry.repository, rev), + _ => format!("github:{}", entry.repository), + }; + println!("{repo:<48} tier {:<3} {}", entry.tier, entry.source); + } + } + Err(e) => { + eprintln!("error: could not reach the registry: {e}"); + std::process::exit(1); + } + } +} + +/// Shared by `run_types_command`'s `Add` arm and `try_auto_resolve_type_definition` +/// — fetch, cache, and lock a resolved spec for `package`, printing the same +/// confirmation line either way. +fn run_types_add(root: &std::path::Path, package: &str, spec: &str) { + use crate::type_registry; + match type_registry::add(root, package, spec) { + Ok(locked) => println!( + "{} — {} {} (tier {}, rev {}) → typr.lock", + locked.package, + locked.repository, + locked.version, + locked.tier, + &locked.rev[..locked.rev.len().min(12)] + ), + Err(e) => { + eprintln!("error: {e}"); + std::process::exit(1); + } + } +} + +/// After `typr use ` adds the R dependency, best-effort resolve and pin +/// a type definition too — the remaining steps of registry.md §7.3's flow, +/// now that J3 gives them something to search (`we-data-ch/registry`). +/// Unlike `run_types_add`, this never exits the process: the R dependency was +/// just added successfully, and a definition lookup finding nothing (or +/// failing to fetch) must not turn that into a command failure — D2 again, +/// one level up: a missing or unusable type signal only ever leaves the +/// package untyped, it never blocks anything else `typr use` already did. +fn try_auto_resolve_type_definition(root: &std::path::Path, package: &str) { + use crate::type_registry; + let Some(spec) = type_registry::resolve_spec_for_package(root, package) else { + return; + }; + match type_registry::add(root, package, &spec) { + Ok(locked) => println!( + "found a type definition for `{}`: {} {} (tier {}) → typr.lock", + locked.package, locked.repository, locked.version, locked.tier + ), + Err(e) => eprintln!("warning: found a type definition for `{package}` but could not resolve it ({e})"), + } +} diff --git a/crates/typr-cli/src/format_r.rs b/crates/typr-cli/src/format_r.rs new file mode 100644 index 00000000..11b62b54 --- /dev/null +++ b/crates/typr-cli/src/format_r.rs @@ -0,0 +1,165 @@ +//! Formats generated R code with `air` (the Rust-based R formatter/language +//! server, https://posit-dev.github.io/air/), so `R/*.R` files stay readable +//! when a user opens them to debug a transpilation. +//! +//! Formatting is best-effort: a missing `air` binary or a formatting failure +//! must never fail the build, so callers always get code back (formatted if +//! possible, the original otherwise). +//! +//! Only content that genuinely benefits from re-indentation goes through +//! `air` at all: `main.R` (the transpiled user program, with real nested +//! control flow) does, but `types.R`/`generic_functions.R` are one +//! definition per line by construction (simple `format!` templates in +//! `project.rs`) and never need it — callers should skip this module for +//! those. `air` is a single native binary (no R process to spawn/load, unlike +//! the `styler` package it replaced), but it's still a subprocess call per +//! file; Project builds should go through [`format_r_code_cached`] so +//! unchanged content (by hash, regardless of which file it ends up in) is +//! never re-formatted. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::OnceLock; + +/// Formats `code` with `air format` when available; returns `code` unchanged +/// otherwise (missing `air` binary, or a formatting error). +pub fn format_r_code(code: &str) -> String { + if !air_available() { + return code.to_string(); + } + match run_air(code) { + Ok(formatted) => formatted, + Err(e) => { + eprintln!("Warning: could not format R output with air ({e}); writing unformatted code"); + code.to_string() + } + } +} + +/// Same as [`format_r_code`], but content-addressed under +/// `/format/.R`: a `typr build` that regenerates the exact +/// same source for a file (the common case — most files in a project don't +/// change between builds) reuses the previously styled output instead of +/// spawning another `Rscript`. Keyed on the raw pre-format content, so it's +/// shared across files and across builds. +pub fn format_r_code_cached(code: &str, cache_dir: &Path) -> String { + let entry = format_cache_entry_path(cache_dir, code); + if let Ok(cached) = std::fs::read_to_string(&entry) { + return cached; + } + let formatted = format_r_code(code); + if let Some(parent) = entry.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&entry, &formatted); + formatted +} + +fn format_cache_entry_path(cache_dir: &Path, code: &str) -> PathBuf { + cache_dir + .join("format") + .join(format!("{:016x}.R", crate::cache::hash_str(code))) +} + +/// `air` availability rarely changes within a single run, and probing it +/// spawns a process — check once per process instead of once per file. +fn air_available() -> bool { + static AVAILABLE: OnceLock = OnceLock::new(); + *AVAILABLE.get_or_init(|| { + Command::new("air") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + }) +} + +fn run_air(code: &str) -> Result { + // `--stdin-file-path` doesn't need to point at a real file — `air` only + // uses it to walk up the directory tree looking for `air.toml`/exclude + // patterns to apply. `air` has no `--no-configuration` flag (removed + // upstream — the flag existed at design time but is gone as of 0.11.0), + // so the only way to keep generated code formatted the same way + // regardless of the target R project's own `air.toml` is to point this + // at a path outside the project entirely: the system temp dir, whose + // ancestry stops well before any project root. + let stdin_file_path = std::env::temp_dir().join("generated.R"); + let mut child = Command::new("air") + .arg("format") + .arg("--stdin-file-path") + .arg(&stdin_file_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("failed to spawn air: {e}"))?; + + child + .stdin + .take() + .expect("piped stdin") + .write_all(code.as_bytes()) + .map_err(|e| format!("failed to write code to air stdin: {e}"))?; + + let output = child + .wait_with_output() + .map_err(|e| format!("failed to wait on air: {e}"))?; + + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + + let formatted = String::from_utf8(output.stdout).map_err(|e| format!("air produced non-UTF-8 output: {e}"))?; + if formatted.trim().is_empty() && !code.trim().is_empty() { + return Err("air produced empty output".to_string()); + } + Ok(formatted) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn leaves_code_unchanged_when_air_is_unavailable() { + if air_available() { + return; // exercised by `styles_messy_code_when_available` instead + } + let messy = "f<-function(x){x+1}"; + assert_eq!(format_r_code(messy), messy); + } + + #[test] + fn styles_messy_code_when_available() { + if !air_available() { + return; // no `air` binary on this machine — nothing to check + } + let messy = "f<-function(x){\nx+1\n}"; + let formatted = format_r_code(messy); + assert_ne!(formatted, messy); + assert!(formatted.contains("f <- function(x) {")); + } + + #[test] + fn cached_format_reuses_disk_entry_without_reformatting() { + let dir = std::env::temp_dir().join(format!("typr_format_cache_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::create_dir_all(&dir); + + let code = "f<-function(x){x+1}"; + let first = format_r_code_cached(code, &dir); + + // Pre-seed a distinguishable value directly at the cache entry, so a + // cache hit (vs. a fresh, indistinguishable-from-`first` air run) is + // unambiguous. + let entry = format_cache_entry_path(&dir, code); + std::fs::write(&entry, "# from cache\n").unwrap(); + assert_eq!(format_r_code_cached(code, &dir), "# from cache\n"); + + let _ = std::fs::remove_dir_all(&dir); + let _ = first; // first run succeeded without panicking, cache dir was created + } +} diff --git a/crates/typr-cli/src/gen_types.rs b/crates/typr-cli/src/gen_types.rs new file mode 100644 index 00000000..f4304d50 --- /dev/null +++ b/crates/typr-cli/src/gen_types.rs @@ -0,0 +1,327 @@ +//! `typr gen-types ` — J1 of the type registry proposal (see +//! `typR/registry.md` §6, "Génération automatique — le chemin principal"). +//! +//! Nobody will hand-write signatures for a 400-function package like `shiny`. +//! This command introspects an *installed* R package (arity, argument names, +//! presence of `...`, version) and emits a `.ty` file where every entry is +//! `#! tier: T3`: every parameter and the return type are `Any`, so the +//! generated definition cannot make the type-checker reject correct code +//! (registry.md D2) — it is immediately useful to the LSP and MCP (argument +//! names, doc scaffolding) while being a no-op for soundness. Promotion to +//! T2/T1 is a manual, per-entry follow-up (registry.md §6, "Promotion"). +//! +//! Reuses `r_name_cache`'s `introspect_pkg.R`: that script's `F`/`P` lines +//! (formals, package version) exist for this command; its `N`/`V`/`C`/`E` +//! lines are what the R-name cache itself consumes. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::r_name_cache::INTROSPECT_R; + +/// One exported function, as recovered from `formals()`. +pub struct GeneratedFn { + pub name: String, + pub has_dots: bool, + pub params: Vec, +} + +pub struct Introspection { + pub r_version: String, + pub pkg_version: Option, + pub functions: Vec, + /// Human-readable notes about packages that failed to load. + pub errors: Vec, +} + +/// Whether a name can appear in `@importFrom pkg ;` and as a plain +/// (optionally backtick-quoted) `@name: ...;` signature — i.e. it is not an +/// operator or other non-identifier export (`%>%`, `[.foo`, `+.money`, ...), +/// which `gen-types` has no safe syntax to emit and simply skips. +fn is_generatable_name(name: &str) -> bool { + !name.is_empty() + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_') + && name.chars().any(|c| c.is_ascii_alphabetic()) +} + +pub fn rscript_available() -> bool { + Command::new("Rscript") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Spawn `Rscript introspect_pkg.R ` and return its stdout, exactly like +/// `r_name_cache::run_introspection` but for a single package at a time. +fn run_introspection(pkg: &str) -> Result { + let script = std::env::temp_dir().join(format!("typr_gen_types_{}.R", std::process::id())); + fs::write(&script, INTROSPECT_R).map_err(|e| format!("cannot write introspection script: {e}"))?; + let result = Command::new("Rscript").arg(&script).arg(pkg).output(); + let _ = fs::remove_file(&script); + let output = result.map_err(|e| format!("Rscript could not be run ({e})"))?; + if !output.status.success() { + return Err(format!( + "Rscript exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Introspect `pkg` (must be installed locally) and collect its exported +/// functions' arity and parameter names. +pub fn introspect(pkg: &str) -> Result { + let raw = run_introspection(pkg)?; + + let mut r_version = String::new(); + let mut pkg_version = None; + let mut functions = Vec::new(); + let mut errors = Vec::new(); + + for line in raw.lines() { + let mut fields = line.split('\t'); + match fields.next() { + Some("V") => r_version = fields.next().unwrap_or_default().to_string(), + Some("P") => { + let _pkg = fields.next(); + pkg_version = fields.next().map(str::to_string); + } + Some("F") => { + let (Some(name), Some(has_dots)) = (fields.next(), fields.next()) else { + continue; + }; + if !is_generatable_name(name) { + continue; + } + functions.push(GeneratedFn { + name: name.to_string(), + has_dots: has_dots == "1", + params: fields.map(str::to_string).collect(), + }); + } + Some("E") => { + if let Some(pkg) = fields.next() { + let reason = fields.next().unwrap_or("unknown reason"); + errors.push(format!("{pkg}: {reason}")); + } + } + _ => {} + } + } + + Ok(Introspection { + r_version, + pkg_version, + functions, + errors, + }) +} + +/// Render an `@name: (...) -> Any;` signature name, backtick-quoting it when +/// it contains a `.` — the convention already used throughout +/// `configs/std/*.ty` for names like `` `is.numeric` `` (a bare `.` inside a +/// TypR identifier would otherwise parse as field access). +fn signature_name(name: &str) -> String { + if name.contains('.') { + format!("`{name}`") + } else { + name.to_string() + } +} + +/// Emit the `.ty` source for every generatable function of `pkg`, all at +/// `#! tier: T3` (registry.md §5.4 / §6). +pub fn emit_ty(pkg: &str, info: &Introspection) -> String { + let mut out = String::new(); + let version = info.pkg_version.as_deref().unwrap_or("unknown"); + let generated_from = format!("{pkg} {version}, R {}", info.r_version); + + if info.functions.is_empty() { + return out; + } + + let names: Vec<&str> = info.functions.iter().map(|f| f.name.as_str()).collect(); + out.push_str(&format!("@importFrom {pkg} {};\n\n", names.join(" "))); + + for f in &info.functions { + out.push_str("#! pkg: "); + out.push_str(pkg); + out.push('\n'); + out.push_str("#! tier: T3\n"); + out.push_str(&format!("#! generated-from: {generated_from}\n")); + for p in &f.params { + out.push_str(&format!("#! param {p}:\n")); + } + if f.has_dots { + out.push_str("#! param ...:\n"); + } + + let mut arg_types: Vec<&str> = vec!["Any"; f.params.len()]; + if f.has_dots { + arg_types.push("...Any"); + } + out.push_str(&format!( + "@{}: ({}) -> Any;\n\n", + signature_name(&f.name), + arg_types.join(", ") + )); + } + + out +} + +/// `typr gen-types [--out DIR]` — see registry.md §6. +pub fn run(pkg: &str, out_dir: Option) { + if !rscript_available() { + eprintln!("error: `Rscript` is not on PATH — `typr gen-types` needs R to introspect `{pkg}`."); + std::process::exit(1); + } + + let info = match introspect(pkg) { + Ok(info) => info, + Err(e) => { + eprintln!("error: could not introspect `{pkg}`: {e}"); + std::process::exit(1); + } + }; + + for note in &info.errors { + eprintln!("warning: {note}"); + } + if info.functions.is_empty() { + eprintln!( + "error: `{pkg}` introspected but exports nothing gen-types can generate a signature for \ + (not installed, or every export is an operator/non-identifier form)." + ); + std::process::exit(1); + } + + let content = emit_ty(pkg, &info); + + let dir: PathBuf = out_dir.unwrap_or_else(|| Path::new("ty").to_path_buf()); + if let Err(e) = fs::create_dir_all(&dir) { + eprintln!("error: could not create `{}`: {e}", dir.display()); + std::process::exit(1); + } + let path = dir.join(format!("{pkg}.generated.ty")); + if let Err(e) = fs::write(&path, &content) { + eprintln!("error: could not write `{}`: {e}", path.display()); + std::process::exit(1); + } + + println!( + "{} — {} function(s), all T3 (generated, unverified) → {}", + pkg, + info.functions.len(), + path.display() + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Introspection { + Introspection { + r_version: "4.5.2".to_string(), + pkg_version: Some("1.1.4".to_string()), + functions: vec![ + GeneratedFn { + name: "filter".to_string(), + has_dots: true, + params: vec![".data".to_string()], + }, + GeneratedFn { + name: "is.numeric".to_string(), + has_dots: false, + params: vec!["x".to_string()], + }, + ], + errors: vec![], + } + } + + #[test] + fn skips_operator_and_non_identifier_exports() { + assert!(is_generatable_name("filter")); + assert!(is_generatable_name("is.numeric")); + assert!(!is_generatable_name("%>%")); + assert!(!is_generatable_name("[.foo")); + assert!(!is_generatable_name("+.money")); + assert!(!is_generatable_name("")); + assert!(!is_generatable_name("...")); + } + + #[test] + fn quotes_dotted_signature_names_only() { + assert_eq!(signature_name("filter"), "filter"); + assert_eq!(signature_name("is.numeric"), "`is.numeric`"); + } + + #[test] + fn emits_import_from_and_variadic_signature() { + let ty = emit_ty("dplyr", &sample()); + + assert!(ty.starts_with("@importFrom dplyr filter is.numeric;\n")); + assert!(ty.contains("#! tier: T3\n")); + assert!(ty.contains("#! generated-from: dplyr 1.1.4, R 4.5.2\n")); + assert!(ty.contains("#! param .data:\n")); + assert!(ty.contains("#! param ...:\n")); + assert!(ty.contains("@filter: (Any, ...Any) -> Any;\n")); + assert!(ty.contains("@`is.numeric`: (Any) -> Any;\n")); + } + + #[test] + fn empty_introspection_emits_nothing() { + let info = Introspection { + r_version: "4.5.2".to_string(), + pkg_version: None, + functions: vec![], + errors: vec![], + }; + assert!(emit_ty("empty", &info).is_empty()); + } + + /// Real end-to-end check against packages actually installed on this + /// machine (registry.md J1: "tests sur 3 packages contrastés"). Fails + /// open — like every other Rscript-dependent test in this crate — since + /// CI's `test` job runs with no R and none of these packages installed. + #[test] + fn generated_ty_type_checks_for_contrasting_packages() { + if !rscript_available() { + eprintln!("skipping: Rscript not on PATH"); + return; + } + for pkg in ["jsonlite", "httr2", "dplyr"] { + let info = match introspect(pkg) { + Ok(info) if !info.functions.is_empty() => info, + _ => { + eprintln!("skipping {pkg}: not installed on this machine"); + continue; + } + }; + let ty = emit_ty(pkg, &info); + assert!(!ty.is_empty(), "{pkg}: generated nothing"); + + let dir = std::env::temp_dir().join(format!("typr_gen_types_test_{pkg}_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + crate::engine::write_std_for_type_checking(&dir); + let file = dir.join(format!("{pkg}.generated.ty")); + fs::write(&file, &ty).unwrap(); + + let context = typr_core::components::context::Context::default() + .set_environment(typr_core::components::context::config::Environment::Project); + let (lang, syntax_errors) = crate::engine::parse_code(&file, context.get_environment()); + assert!(syntax_errors.is_empty(), "{pkg}: syntax errors: {syntax_errors:?}"); + let type_checker = + typr_core::processes::type_checking::type_checker::TypeChecker::new(context).typing_no_panic(&lang); + assert!(!type_checker.has_errors(), "{pkg}: generated .ty failed to type-check"); + + let _ = fs::remove_dir_all(&dir); + } + } +} diff --git a/crates/typr-cli/src/graph.rs b/crates/typr-cli/src/graph.rs new file mode 100644 index 00000000..898251e4 --- /dev/null +++ b/crates/typr-cli/src/graph.rs @@ -0,0 +1,114 @@ +//! `typr graph` — emits the block-graph view of a single TypR file +//! (`visualization_graph_v2.md` §12 étape 2). +//! +//! Deliberately mirrors `typr-graph`'s own tests (spec §7.1) rather than the project-wide +//! machinery `typr check`/`typr build` use: `parse_from_string` → `with_recording(|| +//! typing_with_errors(...))` → `typr_graph::build`. Multi-file projects (`module`/`use`/`import`) +//! are out of scope until étape 5 (spec §13 open question F). + +use std::path::Path; +use typr_core::components::context::Context; +use typr_core::processes::parsing::parse_from_string; +use typr_core::processes::type_checking::type_recorder::with_recording; +use typr_core::processes::type_checking::typing_with_errors; +use typr_graph::export::{dot, json}; +use typr_graph::project::{project, Projection}; +use typr_graph::{BlockGraph, BlockKey}; + +pub fn graph_file(path: &Path, format: &str, focus: Option<&str>, projection: Option<&str>) { + let mut graph = build_graph_or_exit(path); + + if let Some(name) = projection { + graph = match name { + "deps" | "dependencies" => project(&graph, Projection::Dependencies), + "types" => project(&graph, Projection::Types), + other => { + eprintln!("Unknown projection {:?} (expected \"deps\" or \"types\")", other); + std::process::exit(1); + } + }; + } + + match format { + "json" => print_json(&graph, focus), + "dot" => print_dot(&graph, focus), + other => { + eprintln!("Unknown format {:?} (expected \"json\" or \"dot\")", other); + std::process::exit(1); + } + } +} + +fn print_json(graph: &BlockGraph, focus: Option<&str>) { + let view = match focus { + Some(key) => match graph.one_level(&BlockKey::from_raw(key)) { + Some(g) => g, + None => { + eprintln!("No block with key {:?} in this graph", key); + std::process::exit(1); + } + }, + None => graph.clone(), + }; + println!( + "{}", + json::to_string_pretty(&view).expect("BlockGraph always serializes") + ); +} + +fn print_dot(graph: &BlockGraph, focus: Option<&str>) { + let focus_key = focus.map(BlockKey::from_raw).unwrap_or_else(BlockKey::program_root); + match dot::to_dot(graph, &focus_key) { + Some(rendered) => println!("{}", rendered), + None => { + eprintln!("No block with key {:?} in this graph", focus_key.as_str()); + std::process::exit(1); + } + } +} + +/// `typr graph diff ` (spec §12 étape 6): builds each file's block graph +/// independently through the same pipeline as `graph_file`, then pairs blocks up by `BlockKey`. +/// Unlike `typr diff`-style tools this never touches git — the two arguments are just two +/// source files (e.g. two working-tree revisions checked out to temp paths, or simply an +/// old/new copy of the same program), the same "two code excerpts" the spec's playground diff +/// view compares. +pub fn graph_diff(old_file: &Path, new_file: &Path, format: &str) { + let old_graph = build_graph_or_exit(old_file); + let new_graph = build_graph_or_exit(new_file); + let d = typr_graph::diff::diff(&old_graph, &new_graph); + + match format { + "text" => print!("{}", typr_graph::diff::to_text(&d)), + "json" => println!( + "{}", + serde_json::to_string_pretty(&d).expect("GraphDiff always serializes") + ), + other => { + eprintln!("Unknown format {:?} (expected \"text\" or \"json\")", other); + std::process::exit(1); + } + } +} + +fn build_graph_or_exit(path: &Path) -> BlockGraph { + let source = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(e) => { + eprintln!("Error reading file {:?}: {}", path, e); + std::process::exit(1); + } + }; + + let lang = parse_from_string(&source, &path.to_string_lossy()); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + if result.has_errors() { + eprintln!("Type errors found in {:?}:", path); + for err in result.display_errors() { + eprintln!(" - {}", err); + } + std::process::exit(1); + } + + typr_graph::build(&lang, &result.type_context.context, &table) +} diff --git a/crates/typr-cli/src/lib.rs b/crates/typr-cli/src/lib.rs index 009c3630..fdfdc8f1 100644 --- a/crates/typr-cli/src/lib.rs +++ b/crates/typr-cli/src/lib.rs @@ -43,7 +43,10 @@ pub mod cache; pub mod cases; pub mod cli; pub mod engine; +pub mod format_r; pub mod fuzz; +pub mod gen_types; +pub mod graph; pub mod io; pub mod md_renderer; pub mod metaprogramming; @@ -53,9 +56,14 @@ pub mod r_deps; pub mod r_name_cache; pub mod r_name_lint; pub mod rd_renderer; +pub mod registry_revalidate; +pub mod registry_submit; +pub mod registry_validate; pub mod repl; pub mod standard_library; pub mod syntax; +pub mod type_definition; +pub mod type_registry; pub mod vignette_renderer; // Re-export commonly used items diff --git a/crates/typr-cli/src/main.rs b/crates/typr-cli/src/main.rs index 9442ff81..ec750f14 100644 --- a/crates/typr-cli/src/main.rs +++ b/crates/typr-cli/src/main.rs @@ -6,7 +6,10 @@ mod cache; mod cases; mod cli; mod engine; +mod format_r; mod fuzz; +mod gen_types; +mod graph; mod io; mod md_renderer; mod metaprogramming; @@ -16,9 +19,14 @@ mod r_deps; mod r_name_cache; mod r_name_lint; mod rd_renderer; +mod registry_revalidate; +mod registry_submit; +mod registry_validate; mod repl; mod standard_library; mod syntax; +mod type_definition; +mod type_registry; mod vignette_renderer; fn main() { diff --git a/crates/typr-cli/src/md_renderer.rs b/crates/typr-cli/src/md_renderer.rs index 59ff4a2d..2399cc02 100644 --- a/crates/typr-cli/src/md_renderer.rs +++ b/crates/typr-cli/src/md_renderer.rs @@ -216,6 +216,8 @@ mod tests { examples: vec!["abs(-5) # -> 5".to_string()], seealso: vec!["sign".to_string()], pkg: Some(pkg.to_string()), + since: None, + until: None, }); node } diff --git a/crates/typr-cli/src/project.rs b/crates/typr-cli/src/project.rs index 423b9aab..5b38c630 100644 --- a/crates/typr-cli/src/project.rs +++ b/crates/typr-cli/src/project.rs @@ -599,6 +599,18 @@ pub fn write_to_r_lang(content: String, output_dir: &Path, file_name: &str, envi app.write_all(full_content.as_bytes()).unwrap(); } _ => { + // Project builds are a repeated dev-loop command (`typr build`), + // so cache the formatted output by content hash — most files are + // unchanged between builds and shouldn't pay for another `air` + // spawn. One-off StandAlone runs skip the cache: no project root + // to anchor `.typr_cache/` to, and no build loop to amortize it + // over. + let full_content = if environment.is_project() { + let cache_dir = Path::new(cache::CACHE_DIR); + crate::format_r::format_r_code_cached(&full_content, cache_dir) + } else { + crate::format_r::format_r_code(&full_content) + }; cache::write_if_changed(&app_path, &full_content).unwrap(); } } @@ -718,7 +730,10 @@ pub fn new(name: &str, renv: bool) { } pub fn check_project() { - let context = Context::default().set_environment(Environment::Project); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default().set_environment(Environment::Project), + ); let step = Step::new("Parsing"); let (lang, syntax_errors) = parse_code(&PathBuf::from("TypR/main.ty"), context.get_environment()); @@ -741,7 +756,10 @@ pub fn check_project() { } pub fn check_file(path: &PathBuf) { - let context = Context::default().set_environment(Environment::Project); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default().set_environment(Environment::Project), + ); let dir = PathBuf::from("."); write_std_for_type_checking(&dir); @@ -975,10 +993,13 @@ fn build_project_impl( } } - let context = Context::default() - .set_environment(Environment::Project) - .set_test_mode(test_mode) - .set_checked_mode(checked_mode); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default() + .set_environment(Environment::Project) + .set_test_mode(test_mode) + .set_checked_mode(checked_mode), + ); let step = Step::new("Parsing"); let (lang, mut expansion_info) = parse_code_with_info(&PathBuf::from("TypR/main.ty"), context.get_environment()); @@ -1085,9 +1106,12 @@ pub fn build_file(path: &Path, test_mode: bool, checked_mode: bool, strict_mode: } step.done(); - let context = Context::default() - .set_test_mode(test_mode) - .set_checked_mode(checked_mode); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default() + .set_test_mode(test_mode) + .set_checked_mode(checked_mode), + ); let step = Step::new("Type checking"); let type_checker = TypeChecker::new(context.clone()).typing_no_panic(&lang); @@ -1199,10 +1223,12 @@ fn strip_shebang(content: &str) -> &str { } } +// run the file normally pub fn run_file(path: &Path) { run_file_impl(path, false, false, false, false); } +// run the file while keeping the generated files pub fn run_file_keep(path: &Path, profile: bool, checked_mode: bool, strict_mode: bool) { run_file_impl(path, true, profile, checked_mode, strict_mode); } @@ -1247,7 +1273,10 @@ fn run_file_impl(path: &Path, keep_files: bool, profile: bool, checked_mode: boo let work_dir = get_working_directory(keep_files); let guard = TempDirGuard::new(if keep_files { None } else { Some(work_dir.clone()) }); write_std_for_type_checking(&work_dir); - let context = Context::default().set_checked_mode(checked_mode); + let context = crate::standard_library::load_project_type_definitions( + Path::new("."), + Context::default().set_checked_mode(checked_mode), + ); let type_checker = TypeChecker::new(context.clone()).typing_no_panic(&lang); if type_checker.has_errors() { step.fail(); diff --git a/crates/typr-cli/src/r_deps.rs b/crates/typr-cli/src/r_deps.rs index b25c3771..eb5eac95 100644 --- a/crates/typr-cli/src/r_deps.rs +++ b/crates/typr-cli/src/r_deps.rs @@ -88,7 +88,7 @@ fn now_secs() -> u64 { .unwrap_or(0) } -fn cache_home() -> Option { +pub(crate) fn cache_home() -> Option { for var in ["XDG_CACHE_HOME", "LOCALAPPDATA"] { if let Ok(dir) = std::env::var(var) { if !dir.is_empty() { diff --git a/crates/typr-cli/src/r_name_cache.rs b/crates/typr-cli/src/r_name_cache.rs index 85fb65a1..c34d928d 100644 --- a/crates/typr-cli/src/r_name_cache.rs +++ b/crates/typr-cli/src/r_name_cache.rs @@ -39,7 +39,10 @@ use crate::cache::CACHE_DIR; /// The committed base-R table, used to seed a fresh cache. const SEED_JSON: &str = include_str!("../configs/src/r_name_db.json"); /// The introspection script run against packages the seed does not cover. -const INTROSPECT_R: &str = include_str!("../configs/src/introspect_pkg.R"); +/// Also the backbone of `typr gen-types` (see `crate::gen_types`), which reads +/// this same script's `F`/`P` lines (formals, package version) alongside the +/// `N`/`V`/`C`/`E` lines this cache consumes. +pub(crate) const INTROSPECT_R: &str = include_str!("../configs/src/introspect_pkg.R"); pub const CACHE_FILE: &str = "r_names.json"; diff --git a/crates/typr-cli/src/registry_revalidate.rs b/crates/typr-cli/src/registry_revalidate.rs new file mode 100644 index 00000000..10b355a2 --- /dev/null +++ b/crates/typr-cli/src/registry_revalidate.rs @@ -0,0 +1,467 @@ +//! `typr types revalidate` — periodic revalidation of every Type Definition +//! indexed by `we-data-ch/registry`, and detection of drift since the last +//! run. `typR/registry.md` §13 J4, "revalidation périodique des définitions +//! déjà indexées (détection de dérive)" — the item `registry_validate.rs`'s +//! own doc comment named as still open when it landed the single-repository +//! checks of §9. +//! +//! `registry_validate::validate` answers "is this one definition healthy +//! *today*". This module answers two questions a single on-demand run +//! cannot: "is *everything* the registry lists healthy today" (by walking +//! `type_registry::list_registry_targets` instead of one repository), and +//! "did anything that was fine *stop* being fine" (by diffing against a +//! persisted snapshot of the previous run) — the actual failure mode this +//! guards against is a CRAN release silently breaking a definition nobody +//! touched (registry.md §9: "le mode de mort n° 1 d'un registre +//! communautaire"). +//! +//! This is meant to run centrally — a scheduled CI job in `we-data-ch/registry` +//! itself, not a command a consuming project would ever call — which is why +//! `revalidate` takes an optional `--dir` pointing at a *registry* checkout +//! (a CI job that has already checked one out) rather than a project root; +//! omitted, it falls back to the same synced mirror `typr search` already +//! uses (`type_registry::sync_registry_index`). + +use crate::registry_validate::{self, CheckStatus}; +use crate::type_registry; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// --------------------------------------------------------------------- +// Persisted snapshot shape +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SnapshotCheck { + pub name: String, + /// "ok" | "warning" | "FAILED" | "not checked" — `CheckStatus`'s own + /// display words (`ValidationReport::render`), kept as text rather than + /// re-deriving the enum so an older snapshot file with a status word this + /// build no longer emits still deserializes instead of breaking the diff. + pub status: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Snapshot { + pub package: String, + /// `github:owner/repo[@rev]` — doubles as the identity key alongside + /// `package` for diffing against the previous run (registry.md §8.3: one + /// package can resolve several definitions, so `package` alone isn't a + /// key). + pub repository: String, + pub definition_version: String, + /// `YYYY-MM-DD`, UTC — the "last verified" date registry.md §9's example + /// report shows next to each check. + pub checked_at: String, + pub ok: bool, + pub checks: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct SnapshotFile { + #[serde(default)] + definitions: Vec, +} + +// --------------------------------------------------------------------- +// Drift +// --------------------------------------------------------------------- + +/// What changed since the previous persisted run, keyed by `(package, +/// repository)`. Informational, not a build gate on its own (D2/D5) — a +/// caller (the CLI, a CI job) decides what to do with it; `typr types +/// revalidate` exits non-zero only when `newly_failing` is non-empty, so a +/// long-standing, already-known failure doesn't keep paging someone forever. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Drift { + /// `ok` last run (or never checked before) — `Fail` now. + pub newly_failing: Vec<(String, String)>, + /// `Fail` last run — `ok` now. + pub recovered: Vec<(String, String)>, + /// Checked last run, no longer listed by the registry at all (the + /// `packages/.json` entry, or the whole file, was removed). + pub removed: Vec<(String, String)>, +} + +impl Drift { + pub fn is_empty(&self) -> bool { + self.newly_failing.is_empty() && self.recovered.is_empty() && self.removed.is_empty() + } +} + +/// Compare `previous` against `current`, both keyed by `(package, +/// repository)`. An entry with no prior record counts as "newly failing" when +/// it fails now — first sight of a problem is still news — but never as +/// "recovered" or "removed", which need an actual prior record to compare +/// against. +fn diff(previous: &[Snapshot], current: &[Snapshot]) -> Drift { + let prev_by_key: HashMap<(&str, &str), &Snapshot> = previous + .iter() + .map(|s| ((s.package.as_str(), s.repository.as_str()), s)) + .collect(); + let curr_keys: HashSet<(&str, &str)> = current + .iter() + .map(|s| (s.package.as_str(), s.repository.as_str())) + .collect(); + + let mut drift = Drift::default(); + for snap in current { + let key = (snap.package.as_str(), snap.repository.as_str()); + match (prev_by_key.get(&key).map(|p| p.ok), snap.ok) { + (Some(true), false) | (None, false) => drift + .newly_failing + .push((snap.package.clone(), snap.repository.clone())), + (Some(false), true) => drift.recovered.push((snap.package.clone(), snap.repository.clone())), + _ => {} + } + } + for prev in previous { + let key = (prev.package.as_str(), prev.repository.as_str()); + if !curr_keys.contains(&key) { + drift.removed.push((prev.package.clone(), prev.repository.clone())); + } + } + drift.newly_failing.sort(); + drift.recovered.sort(); + drift.removed.sort(); + drift +} + +// --------------------------------------------------------------------- +// Running the checks +// --------------------------------------------------------------------- + +fn status_word(status: CheckStatus) -> &'static str { + match status { + CheckStatus::Pass => "ok", + CheckStatus::Warn => "warning", + CheckStatus::Fail => "FAILED", + CheckStatus::Skipped => "not checked", + } +} + +/// Run `registry_validate::validate` against every target `type_registry:: +/// list_registry_targets(registry_dir)` lists, stamping each result with +/// today's date. Real work per target (a `git clone`, a type-check, an +/// `Rscript` introspection) — this is the periodic/CI path, not an +/// interactive one, so it is expected to take as long as the registry is big. +fn run(registry_dir: &Path) -> Vec { + let checked_at = today(); + type_registry::list_registry_targets(registry_dir) + .into_iter() + .map(|target| { + let report = registry_validate::validate(&target.package, &target.spec); + Snapshot { + package: target.package, + repository: target.spec, + definition_version: report.definition_version.clone(), + checked_at: checked_at.clone(), + ok: report.ok(), + checks: report + .checks + .iter() + .map(|c| SnapshotCheck { + name: c.name.to_string(), + status: status_word(c.status).to_string(), + detail: c.detail.clone(), + }) + .collect(), + } + }) + .collect() +} + +/// A missing or unparsable snapshot file is simply "no previous run" — never +/// a hard error (D2), same contract as `type_registry::Lockfile::read`. +fn load_previous(path: &Path) -> Vec { + fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|f| f.definitions) + .unwrap_or_default() +} + +fn save(path: &Path, snapshots: &[Snapshot]) -> Result<(), String> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + } + let rendered = serde_json::to_string_pretty(&SnapshotFile { + definitions: snapshots.to_vec(), + }) + .map_err(|e| format!("could not serialize {}: {e}", path.display()))?; + fs::write(path, rendered).map_err(|e| format!("could not write {}: {e}", path.display())) +} + +// --------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------- + +/// `typr types revalidate [--dir PATH] [--out FILE]`. +/// +/// `registry_dir`: a local checkout of `we-data-ch/registry` to validate as-is +/// (e.g. a CI job's own working tree, so a PR can be checked before it merges) +/// — `None` syncs the same local mirror `typr search`/`typr types add` use. +/// +/// `out`: where the previous run's snapshot is read from and the new one is +/// written to. Defaults to `/status/validation.json`, so a CI +/// job that commits its checkout's working tree back naturally persists +/// history across runs without needing to know the path in advance. +/// +/// Fails only when the registry truly cannot be consulted at all (no `git`, +/// clone/pull failure, or a snapshot that cannot be written) — matches +/// `type_registry::search`'s Err semantics. A registry with nothing indexed +/// yet, or one where every check is `Skipped`/`Warn`, resolves fine (D2): an +/// absent or uncertain signal is never a reason to fail the job that +/// discovers it. +pub fn revalidate(registry_dir: Option<&Path>, out: Option<&Path>) -> Result<(Vec, Drift, PathBuf), String> { + let dir = match registry_dir { + Some(d) => d.to_path_buf(), + None => { + if !type_registry::git_available() { + return Err("`git` is not installed or not on PATH — cannot reach the registry".to_string()); + } + type_registry::sync_registry_index()? + } + }; + let out_path = out + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| dir.join("status").join("validation.json")); + + let previous = load_previous(&out_path); + let current = run(&dir); + let drift = diff(&previous, ¤t); + save(&out_path, ¤t)?; + + Ok((current, drift, out_path)) +} + +/// The nominative report of registry.md §9 — what was verified, named, with +/// the date it was last checked, never a single green badge — plus a "drift +/// since last run" section when `drift` has anything to say. +pub fn render(snapshots: &[Snapshot], drift: &Drift) -> String { + let mut out = String::new(); + if snapshots.is_empty() { + out.push_str("no definitions indexed in the registry — nothing to revalidate.\n"); + return out; + } + for s in snapshots { + out.push_str(&format!( + "{} — {} (definition v{}) — last verified {}\n", + s.package, s.repository, s.definition_version, s.checked_at + )); + for c in &s.checks { + out.push_str(&format!(" {:<26} {:<12} {}\n", c.name, c.status, c.detail)); + } + } + if !drift.is_empty() { + out.push_str("\ndrift since last run:\n"); + for (pkg, repo) in &drift.newly_failing { + out.push_str(&format!(" NEW FAILURE {pkg} — {repo}\n")); + } + for (pkg, repo) in &drift.recovered { + out.push_str(&format!(" recovered {pkg} — {repo}\n")); + } + for (pkg, repo) in &drift.removed { + out.push_str(&format!(" removed {pkg} — {repo}\n")); + } + } + out +} + +// --------------------------------------------------------------------- +// Dates — no `chrono` in this workspace (registry.md's own instinct +// elsewhere: shell out or hand-roll rather than add an HTTP/date dependency +// for one call site). Howard Hinnant's `civil_from_days` (public domain) is a +// small, well-known, dependency-free days-since-epoch → (y, m, d) conversion. +// --------------------------------------------------------------------- + +fn today() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let (y, m, d) = civil_from_days((secs / 86_400) as i64); + format!("{y:04}-{m:02}-{d:02}") +} + +fn civil_from_days(z: i64) -> (i64, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + // -- civil_from_days -------------------------------------------------- + + #[test] + fn civil_from_days_epoch_is_1970_01_01() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + } + + #[test] + fn civil_from_days_matches_a_known_date() { + // 2024-03-01 is 19783 days after the epoch (external reference: date -d @1709251200 -u). + assert_eq!(civil_from_days(19_783), (2024, 3, 1)); + } + + #[test] + fn civil_from_days_handles_a_leap_day() { + // 2024-02-29 is one day before the above. + assert_eq!(civil_from_days(19_782), (2024, 2, 29)); + } + + // -- diff --------------------------------------------------------------- + + fn snap(package: &str, repository: &str, ok: bool) -> Snapshot { + Snapshot { + package: package.to_string(), + repository: repository.to_string(), + definition_version: "0.1.0".to_string(), + checked_at: "2026-01-01".to_string(), + ok, + checks: Vec::new(), + } + } + + #[test] + fn ok_to_fail_is_newly_failing() { + let previous = vec![snap("shiny", "github:alice/typr-shiny", true)]; + let current = vec![snap("shiny", "github:alice/typr-shiny", false)]; + let drift = diff(&previous, ¤t); + assert_eq!( + drift.newly_failing, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + assert!(drift.recovered.is_empty()); + assert!(drift.removed.is_empty()); + } + + #[test] + fn fail_to_ok_is_recovered() { + let previous = vec![snap("shiny", "github:alice/typr-shiny", false)]; + let current = vec![snap("shiny", "github:alice/typr-shiny", true)]; + let drift = diff(&previous, ¤t); + assert_eq!( + drift.recovered, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + assert!(drift.newly_failing.is_empty()); + } + + #[test] + fn never_seen_before_and_failing_counts_as_newly_failing() { + let previous: Vec = Vec::new(); + let current = vec![snap("shiny", "github:alice/typr-shiny", false)]; + let drift = diff(&previous, ¤t); + assert_eq!( + drift.newly_failing, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + } + + #[test] + fn never_seen_before_and_ok_is_not_drift() { + let previous: Vec = Vec::new(); + let current = vec![snap("shiny", "github:alice/typr-shiny", true)]; + assert!(diff(&previous, ¤t).is_empty()); + } + + #[test] + fn still_failing_both_runs_is_not_drift() { + let previous = vec![snap("shiny", "github:alice/typr-shiny", false)]; + let current = vec![snap("shiny", "github:alice/typr-shiny", false)]; + assert!(diff(&previous, ¤t).is_empty()); + } + + #[test] + fn dropped_from_the_registry_is_removed() { + let previous = vec![snap("shiny", "github:alice/typr-shiny", true)]; + let current: Vec = Vec::new(); + let drift = diff(&previous, ¤t); + assert_eq!( + drift.removed, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + assert!(drift.newly_failing.is_empty()); + } + + #[test] + fn same_package_two_repositories_are_independent_keys() { + let previous = vec![ + snap("shiny", "github:alice/typr-shiny", true), + snap("shiny", "github:bob/typr-shiny", true), + ]; + let current = vec![ + snap("shiny", "github:alice/typr-shiny", false), + snap("shiny", "github:bob/typr-shiny", true), + ]; + let drift = diff(&previous, ¤t); + assert_eq!( + drift.newly_failing, + vec![("shiny".to_string(), "github:alice/typr-shiny".to_string())] + ); + } + + // -- load_previous / save round-trip ------------------------------------ + + #[test] + fn load_previous_missing_file_is_empty_not_an_error() { + let path = std::env::temp_dir().join(format!("typr_revalidate_missing_{}.json", std::process::id())); + let _ = fs::remove_file(&path); + assert!(load_previous(&path).is_empty()); + } + + #[test] + fn save_then_load_previous_round_trips() { + let path = std::env::temp_dir().join(format!("typr_revalidate_roundtrip_{}.json", std::process::id())); + let _ = fs::remove_file(&path); + let snapshots = vec![snap("shiny", "github:alice/typr-shiny", true)]; + + save(&path, &snapshots).unwrap(); + let loaded = load_previous(&path); + + let _ = fs::remove_file(&path); + assert_eq!(loaded, snapshots); + } + + // -- render --------------------------------------------------------------- + + #[test] + fn render_empty_registry_says_so() { + assert_eq!( + render(&[], &Drift::default()), + "no definitions indexed in the registry — nothing to revalidate.\n" + ); + } + + #[test] + fn render_includes_drift_section_only_when_non_empty() { + let clean = render(&[snap("shiny", "github:alice/typr-shiny", true)], &Drift::default()); + assert!(!clean.contains("drift since last run")); + + let mut drift = Drift::default(); + drift + .newly_failing + .push(("shiny".to_string(), "github:alice/typr-shiny".to_string())); + let dirty = render(&[snap("shiny", "github:alice/typr-shiny", false)], &drift); + assert!(dirty.contains("drift since last run")); + assert!(dirty.contains("NEW FAILURE shiny — github:alice/typr-shiny")); + } +} diff --git a/crates/typr-cli/src/registry_submit.rs b/crates/typr-cli/src/registry_submit.rs new file mode 100644 index 00000000..a1bd3621 --- /dev/null +++ b/crates/typr-cli/src/registry_submit.rs @@ -0,0 +1,543 @@ +//! `typr types submit [github:owner/repo[/subdir][@rev]]` — open a pull +//! request against `we-data-ch/registry` adding or updating +//! `packages/.json`. This is the "« Add to Registry » → PR automatique" +//! item of `typR/registry.md` §13 J6 (second half) — the one item that was +//! still unchecked once J0-J5 and J6's static Store page were done. +//! +//! Deliberately **not** what registry.md §12/D6 describes and defers as its +//! own project: a Store web form that generates a PR "on behalf of a user" +//! needs a GitHub App, stored tokens, a backend, and anti-spam moderation. +//! This is the CLI shape instead — it shells out to the caller's own, +//! already-authenticated `gh` (the GitHub CLI), the same "shell out rather +//! than add an in-process client" choice `type_registry.rs` already made for +//! `git` and `gen_types.rs` made for `Rscript`. There is no backend and no +//! app: the PR is opened as the actual signed-in `gh` user, from their own +//! fork, exactly as if they had typed the `gh repo fork`/`git push`/`gh pr +//! create` sequence by hand. +//! +//! Never opens a PR for a definition that hasn't been mechanically checked: +//! `submit` runs the same checks `typr types validate` runs +//! (`registry_validate::validate`) first and refuses to proceed if any of +//! them fails. It also refuses to open a no-op PR when the target +//! `packages/.json` already contains an identical entry for the same +//! repository. + +use crate::type_definition::ProviderType; +use crate::type_registry::{self, FetchedDefinition, LockedDefinition, Lockfile, RepoSpec, LOCKFILE_NAME}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The registry this build submits to by default — same one +/// `type_registry.rs`'s `REGISTRY_REPO_URL` points at, but as `owner/repo` +/// rather than a clone URL, since `gh repo fork`/`gh pr create` want it in +/// that form. +pub const DEFAULT_REGISTRY_REPO: &str = "we-data-ch/registry"; + +// --------------------------------------------------------------------- +// packages/.json — full read/write shape +// --------------------------------------------------------------------- +// +// `type_registry.rs`'s own `RegistryDefinitionEntry`/`RegistryPackageFile` +// are intentionally partial — "only the fields the selection logic needs" +// (its own doc comment). Submitting has to read and write the *whole* file +// (every field `schema/package.schema.json` requires) and must not lose an +// existing sibling entry for another repository, so this module keeps its +// own full round-trippable shape instead of widening that one. + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct CapabilitiesJson { + r_shims: bool, + extern_raw: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct DefinitionEntryJson { + /// `owner/repo` or `owner/repo/subdir...` — no `github:` scheme, matching + /// `schema/package.schema.json`'s `repository` pattern. + repository: String, + version: String, + rev: String, + since: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + until: Option, + /// `official` | `community` | `generated` | `local`. + source: String, + /// `T1` | `T2` | `T3`. + tier: String, + capabilities: CapabilitiesJson, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct PackageFileJson { + name: String, + #[serde(default)] + definitions: Vec, +} + +fn provider_type_str(kind: ProviderType) -> &'static str { + match kind { + ProviderType::Official => "official", + ProviderType::Community => "community", + ProviderType::Generated => "generated", + ProviderType::Local => "local", + } +} + +/// `spec.owner/spec.repo[/spec.subdir]` — the `repository` field's shape in +/// the registry (registry.md §8.1/§8.2), derived from the same `RepoSpec` a +/// user would otherwise write into `typr.toml [types]` or pass to `typr +/// types add`. +fn repository_field(spec: &RepoSpec) -> String { + match &spec.subdir { + Some(sub) => format!("{}/{}/{}", spec.owner, spec.repo, sub), + None => format!("{}/{}", spec.owner, spec.repo), + } +} + +fn build_registry_entry(spec: &RepoSpec, fetched: &FetchedDefinition) -> DefinitionEntryJson { + DefinitionEntryJson { + repository: repository_field(spec), + version: fetched.manifest.definition.version.clone(), + rev: fetched.rev.clone(), + since: fetched.manifest.package.since.clone(), + until: fetched.manifest.package.until.clone(), + source: provider_type_str(fetched.manifest.provider.kind).to_string(), + tier: fetched.manifest.definition.tier.clone(), + capabilities: CapabilitiesJson { + r_shims: fetched.manifest.capabilities.r_shims, + extern_raw: fetched.manifest.capabilities.extern_raw, + }, + } +} + +/// Insert or replace `entry` in `/packages/.json`, keyed +/// by `repository` (a package can list several definitions, registry.md +/// §8.3 — this only ever touches the one matching this submission's +/// repository, every sibling entry is preserved byte-for-byte apart from +/// JSON re-formatting). Returns `false` without touching the file when an +/// identical entry is already present, so the caller can skip opening a +/// no-op PR. +fn upsert_package_entry(work_dir: &Path, package: &str, entry: DefinitionEntryJson) -> Result { + let path = work_dir.join("packages").join(format!("{package}.json")); + let mut file = if path.is_file() { + let source = fs::read_to_string(&path).map_err(|e| format!("could not read {}: {e}", path.display()))?; + serde_json::from_str::(&source).map_err(|e| { + format!( + "{} does not match the registry's own schema — refusing to overwrite it: {e}", + path.display() + ) + })? + } else { + PackageFileJson { + name: package.to_string(), + definitions: Vec::new(), + } + }; + + let changed = match file.definitions.iter_mut().find(|d| d.repository == entry.repository) { + Some(existing) if *existing == entry => false, + Some(existing) => { + *existing = entry; + true + } + None => { + file.definitions.push(entry); + true + } + }; + if !changed { + return Ok(false); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + let rendered = + serde_json::to_string_pretty(&file).map_err(|e| format!("could not serialize {}: {e}", path.display()))?; + fs::write(&path, format!("{rendered}\n")).map_err(|e| format!("could not write {}: {e}", path.display()))?; + Ok(true) +} + +// --------------------------------------------------------------------- +// gh / git plumbing +// --------------------------------------------------------------------- + +fn gh_available() -> bool { + Command::new("gh") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn gh_authenticated() -> bool { + Command::new("gh") + .args(["auth", "status"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn now_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +/// Fork `registry_repo` into the signed-in `gh` user's account (a no-op if +/// they already have one) and clone that fork into a fresh temp directory, +/// with `origin` pointing at the fork and `upstream` at `registry_repo` — +/// exactly the layout `gh repo fork --clone --remote` sets up by hand, and +/// exactly what `gh pr create` below needs to infer the PR's head without +/// being told explicitly. +fn fork_and_clone(registry_repo: &str) -> Result { + let repo_name = registry_repo.rsplit('/').next().unwrap_or(registry_repo); + let parent = std::env::temp_dir().join(format!("typr_types_submit_{}_{}", std::process::id(), now_millis())); + fs::create_dir_all(&parent).map_err(|e| format!("could not create temp dir: {e}"))?; + + let fork = Command::new("gh") + .args(["repo", "fork", registry_repo, "--clone", "--remote"]) + .current_dir(&parent) + .output() + .map_err(|e| format!("could not run `gh repo fork`: {e}"))?; + if !fork.status.success() { + let _ = fs::remove_dir_all(&parent); + return Err(format!( + "`gh repo fork {registry_repo}` failed: {}", + String::from_utf8_lossy(&fork.stderr).trim() + )); + } + + let work_dir = parent.join(repo_name); + if !work_dir.is_dir() { + let _ = fs::remove_dir_all(&parent); + return Err(format!( + "`gh repo fork {registry_repo}` reported success but {} was not created", + work_dir.display() + )); + } + Ok(work_dir) +} + +fn run_git(work_dir: &Path, args: &[&str], what: &str) -> Result<(), String> { + let out = Command::new("git") + .arg("-C") + .arg(work_dir) + .args(args) + .output() + .map_err(|e| format!("could not run `git {}`: {e}", args.join(" ")))?; + if !out.status.success() { + return Err(format!( + "{what} failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(()) +} + +fn commit_and_push(work_dir: &Path, package: &str, branch: &str) -> Result<(), String> { + run_git(work_dir, &["checkout", "-b", branch], "`git checkout -b`")?; + run_git(work_dir, &["add", &format!("packages/{package}.json")], "`git add`")?; + let message = format!("Add/update packages/{package}.json via `typr types submit`"); + run_git(work_dir, &["commit", "--quiet", "-m", &message], "`git commit`")?; + run_git(work_dir, &["push", "--quiet", "-u", "origin", branch], "`git push`") +} + +/// `gh pr create`, run from inside the fork clone so `gh` infers the head +/// branch/owner from the checked-out branch and its `origin` remote — the +/// same thing a person would get running it by hand right after `git push`. +fn open_pr(work_dir: &Path, registry_repo: &str, package: &str, entry: &DefinitionEntryJson) -> Result { + let title = format!("Add/update {package}: {} (tier {})", entry.repository, entry.tier); + let body = format!( + "Adds or updates `packages/{package}.json` for `{}` at rev `{}` (tier `{}`, source `{}`).\n\n\ + Opened by `typr types submit` (`typR/registry.md` §13 J6). Passed `typr types \ + validate {package} github:{}@{}` locally before this PR was opened — see that command's \ + own output for exactly what was and was not mechanically checked (`registry.md` §9: \ + this PR is not a claim that the definition is correct beyond what was mechanically \ + verifiable).\n", + entry.repository, entry.rev, entry.tier, entry.source, entry.repository, entry.rev, + ); + let pr = Command::new("gh") + .current_dir(work_dir) + .args([ + "pr", + "create", + "--repo", + registry_repo, + "--title", + &title, + "--body", + &body, + ]) + .output() + .map_err(|e| format!("could not run `gh pr create`: {e}"))?; + if !pr.status.success() { + return Err(format!( + "`gh pr create` failed: {}", + String::from_utf8_lossy(&pr.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&pr.stdout).trim().to_string()) +} + +// --------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubmitOutcome { + /// `packages/.json` already had this exact entry — no PR opened. + AlreadyUpToDate, + Opened(String), +} + +/// A repository spec to submit for `package`: the one given explicitly, or — +/// mirroring `type_registry::update`'s own fallback — the one already +/// resolved in `typr.lock` (pinned rev included, so what gets submitted is +/// exactly what this project already trusts, not a possibly-moved `HEAD`). +fn resolve_spec_to_submit(project_root: &Path, package: &str, spec_override: Option<&str>) -> Result { + if let Some(spec) = spec_override { + return Ok(spec.to_string()); + } + let lockfile = Lockfile::read(&project_root.join(LOCKFILE_NAME)); + let locked: &LockedDefinition = lockfile.find(package).ok_or_else(|| { + format!( + "no repository given and `{package}` is not resolved in typr.lock — run `typr types \ + add {package} github:owner/repo` first, or pass one explicitly: `typr types submit \ + {package} github:owner/repo[@rev]`" + ) + })?; + Ok(format!("{}@{}", locked.repository, locked.rev)) +} + +/// `typr types submit [repo]` — resolve a spec, validate it +/// mechanically, and open (or update) a pull request against `registry_repo` +/// indexing it. Every temp directory this creates (the fetch's clone, the +/// fork's clone) is cleaned up before returning, success or failure. +pub fn submit( + project_root: &Path, + package: &str, + spec_override: Option<&str>, + registry_repo: &str, +) -> Result { + if !gh_available() { + return Err( + "`gh` (the GitHub CLI) is not on PATH — install it from https://cli.github.com, run \ + `gh auth login`, then retry; `typr types submit` opens the PR as you, from your own \ + fork, it never touches your GitHub credentials directly" + .to_string(), + ); + } + if !gh_authenticated() { + return Err("`gh` is not authenticated — run `gh auth login` first".to_string()); + } + + let spec_str = resolve_spec_to_submit(project_root, package, spec_override)?; + let repo_spec = RepoSpec::parse(&spec_str)?; + + let report = crate::registry_validate::validate(package, &spec_str); + if !report.ok() { + return Err(format!( + "`{package}` at {spec_str} fails mechanical validation — fix it before submitting \ + (rerun `typr types validate {package} {spec_str}` for details):\n{}", + report.render() + )); + } + + let (fetched, _warnings) = type_registry::fetch(&repo_spec)?; + let entry = build_registry_entry(&repo_spec, &fetched); + let _ = fs::remove_dir_all(&fetched.root); + + let work_dir = fork_and_clone(registry_repo)?; + let result = (|| { + if !upsert_package_entry(&work_dir, package, entry.clone())? { + return Ok(SubmitOutcome::AlreadyUpToDate); + } + let branch = format!("typr-types-submit-{package}-{}", &entry.rev[..entry.rev.len().min(12)]); + commit_and_push(&work_dir, package, &branch)?; + let pr_url = open_pr(&work_dir, registry_repo, package, &entry)?; + Ok(SubmitOutcome::Opened(pr_url)) + })(); + let _ = fs::remove_dir_all(work_dir.parent().unwrap_or(&work_dir)); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_entry(repository: &str) -> DefinitionEntryJson { + DefinitionEntryJson { + repository: repository.to_string(), + version: "0.1.0".to_string(), + rev: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string(), + since: "1.0.0".to_string(), + until: None, + source: "community".to_string(), + tier: "T2".to_string(), + capabilities: CapabilitiesJson { + r_shims: false, + extern_raw: false, + }, + } + } + + #[test] + fn repository_field_includes_subdir_when_present() { + let spec = RepoSpec::parse("github:we-data-ch/registry/definitions/dplyr").unwrap(); + assert_eq!(repository_field(&spec), "we-data-ch/registry/definitions/dplyr"); + + let spec = RepoSpec::parse("github:alice/typr-shiny").unwrap(); + assert_eq!(repository_field(&spec), "alice/typr-shiny"); + } + + #[test] + fn upsert_creates_a_new_file_when_none_exists() { + let dir = std::env::temp_dir().join(format!("typr_submit_new_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let changed = upsert_package_entry(&dir, "shiny", sample_entry("alice/typr-shiny")).unwrap(); + assert!(changed); + + let written = fs::read_to_string(dir.join("packages").join("shiny.json")).unwrap(); + let parsed: PackageFileJson = serde_json::from_str(&written).unwrap(); + assert_eq!(parsed.name, "shiny"); + assert_eq!(parsed.definitions.len(), 1); + assert_eq!(parsed.definitions[0].repository, "alice/typr-shiny"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_preserves_sibling_entries_for_other_repositories() { + let dir = std::env::temp_dir().join(format!("typr_submit_sibling_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + let existing = PackageFileJson { + name: "shiny".to_string(), + definitions: vec![sample_entry("bob/other-typr-shiny")], + }; + fs::write( + dir.join("packages").join("shiny.json"), + serde_json::to_string_pretty(&existing).unwrap(), + ) + .unwrap(); + + let changed = upsert_package_entry(&dir, "shiny", sample_entry("alice/typr-shiny")).unwrap(); + assert!(changed); + + let written = fs::read_to_string(dir.join("packages").join("shiny.json")).unwrap(); + let parsed: PackageFileJson = serde_json::from_str(&written).unwrap(); + assert_eq!(parsed.definitions.len(), 2); + assert!(parsed + .definitions + .iter() + .any(|d| d.repository == "bob/other-typr-shiny")); + assert!(parsed.definitions.iter().any(|d| d.repository == "alice/typr-shiny")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_replaces_the_matching_repository_entry_in_place() { + let dir = std::env::temp_dir().join(format!("typr_submit_replace_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + let mut first = sample_entry("alice/typr-shiny"); + first.tier = "T3".to_string(); + let existing = PackageFileJson { + name: "shiny".to_string(), + definitions: vec![first], + }; + fs::write( + dir.join("packages").join("shiny.json"), + serde_json::to_string_pretty(&existing).unwrap(), + ) + .unwrap(); + + let mut updated = sample_entry("alice/typr-shiny"); + updated.tier = "T1".to_string(); + let changed = upsert_package_entry(&dir, "shiny", updated).unwrap(); + assert!(changed); + + let written = fs::read_to_string(dir.join("packages").join("shiny.json")).unwrap(); + let parsed: PackageFileJson = serde_json::from_str(&written).unwrap(); + assert_eq!(parsed.definitions.len(), 1); + assert_eq!(parsed.definitions[0].tier, "T1"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_is_a_no_op_when_the_entry_is_already_identical() { + let dir = std::env::temp_dir().join(format!("typr_submit_noop_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + let entry = sample_entry("alice/typr-shiny"); + let existing = PackageFileJson { + name: "shiny".to_string(), + definitions: vec![entry.clone()], + }; + fs::write( + dir.join("packages").join("shiny.json"), + serde_json::to_string_pretty(&existing).unwrap(), + ) + .unwrap(); + + let changed = upsert_package_entry(&dir, "shiny", entry).unwrap(); + assert!(!changed); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn resolve_spec_prefers_explicit_override_over_typr_lock() { + let dir = std::env::temp_dir().join(format!("typr_submit_resolve_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let spec = resolve_spec_to_submit(&dir, "shiny", Some("github:alice/typr-shiny@abc123")).unwrap(); + assert_eq!(spec, "github:alice/typr-shiny@abc123"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn resolve_spec_falls_back_to_typr_lock_pinned_rev() { + let dir = std::env::temp_dir().join(format!("typr_submit_resolve_lock_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let mut lockfile = Lockfile::default(); + lockfile.upsert(LockedDefinition { + package: "shiny".to_string(), + repository: "github:alice/typr-shiny".to_string(), + version: "0.3.0".to_string(), + rev: "a1b2c3d4e5f6".to_string(), + digest: "sha256:deadbeef".to_string(), + tier: "T2".to_string(), + r_version_seen: None, + }); + lockfile.write(&dir.join(LOCKFILE_NAME)).unwrap(); + + let spec = resolve_spec_to_submit(&dir, "shiny", None).unwrap(); + assert_eq!(spec, "github:alice/typr-shiny@a1b2c3d4e5f6"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn resolve_spec_errors_when_nothing_is_locked_and_nothing_is_given() { + let dir = std::env::temp_dir().join(format!("typr_submit_resolve_missing_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + let err = resolve_spec_to_submit(&dir, "shiny", None).unwrap_err(); + assert!(err.contains("typr types add"), "unexpected error: {err}"); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/crates/typr-cli/src/registry_validate.rs b/crates/typr-cli/src/registry_validate.rs new file mode 100644 index 00000000..bff65495 --- /dev/null +++ b/crates/typr-cli/src/registry_validate.rs @@ -0,0 +1,912 @@ +//! `typr types validate [--repo SPEC]` — the mechanically-verifiable +//! checks of `typR/registry.md` §9, run against a single Type Definition +//! repository (registry.md §13 J4, "contrôles mécaniques de §9, dont le diff +//! `formals()` contre le package installé"). +//! +//! One check named in §9 is deliberately out of scope here and stays open in +//! the J4 checklist: "schéma JSON du registre valide" is a property of +//! `we-data-ch/registry`'s own `packages/*.json` files, not of one definition +//! repository (it already gets a mechanical check for free every time +//! `type_registry::lookup_in_registry_dir`/`search_in_registry_dir` parses one +//! into `RegistryPackageFile` — a malformed file simply resolves to no +//! candidates, per D2 — but there is no *dedicated* validator yet that flags +//! *which* file is malformed). "revalidation périodique" (drift re-detection +//! over time) — a place to run this validator centrally, on every registry +//! entry, on a schedule — is [`crate::registry_revalidate`], the next J4 item. +//! +//! Every other §9 line is a [`CheckResult`] here: +//! +//! - `format_version` known, repository accessible, rev pinned, digest — +//! mostly free: `type_registry::fetch` already enforces/computes these +//! before this module sees anything. +//! - capabilities coherent with the real content — same, via `fetch`'s +//! returned warnings (`type_registry::check_capabilities`). +//! - the `.ty` files parse and type-check, and `tests/smoke.ty` compiles — +//! reuses `standard_library::load_external_ty_definitions`, the exact loop +//! a consuming project runs. +//! - every declared name is really exported by the installed package, and its +//! arity/`...`-ness matches `formals()` — the new piece: diffs +//! `parse_declared_entries`'s view of the `.ty` sources against +//! `gen_types::introspect`'s view of the installed R package. +//! - no `T1` entry with an unconstrained `...` — the RFC-STDLIB-0001 §7 +//! promotion gate (registry.md §6, "Promotion T3 → T2 → T1"), applied here +//! for the first time to *external* definitions rather than the bundled +//! stdlib. +//! - the package exists on CRAN — shelled out to `Rscript`'s own +//! `available.packages()` against the public CRAN mirror, the same +//! shell-out-rather-than-add-an-HTTP-client choice `gen_types.rs`/ +//! `type_registry.rs` already made for R/`git`. +//! +//! Every introspection-dependent check (exports, arity, CRAN) fails *open* +//! when `Rscript` is unavailable or the package isn't installed locally — +//! `Skipped`, never `Fail` — the same contract every other Rscript-dependent +//! path in this crate already follows (`gen_types.rs`, `r_name_cache.rs`). +//! Only a check that could actually run and found something wrong reports +//! `Fail`; `typr types validate`'s exit code is 1 only in that case. + +use crate::gen_types; +use crate::standard_library; +use crate::type_registry::{self, FetchedDefinition, RepoSpec}; +use std::collections::HashMap; +use std::fs; +use std::process::Command; +use typr_core::components::context::Context; +use typr_core::processes::spg::stdlib_meta::parse_meta_from_source; + +// --------------------------------------------------------------------- +// Report shape +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CheckStatus { + Pass, + Warn, + Fail, + /// Could not run (missing `Rscript`/`git`, package not installed + /// locally, network unreachable, no `tests/smoke.ty` present, …) — never + /// a reason to fail the build (registry.md D2). + Skipped, +} + +#[derive(Debug, Clone)] +pub struct CheckResult { + pub name: &'static str, + pub status: CheckStatus, + pub detail: String, +} + +impl CheckResult { + fn pass(name: &'static str, detail: impl Into) -> Self { + CheckResult { + name, + status: CheckStatus::Pass, + detail: detail.into(), + } + } + fn warn(name: &'static str, detail: impl Into) -> Self { + CheckResult { + name, + status: CheckStatus::Warn, + detail: detail.into(), + } + } + fn fail(name: &'static str, detail: impl Into) -> Self { + CheckResult { + name, + status: CheckStatus::Fail, + detail: detail.into(), + } + } + fn skipped(name: &'static str, detail: impl Into) -> Self { + CheckResult { + name, + status: CheckStatus::Skipped, + detail: detail.into(), + } + } +} + +#[derive(Debug, Clone)] +pub struct ValidationReport { + pub package: String, + pub repository: String, + pub definition_version: String, + pub checks: Vec, +} + +impl ValidationReport { + /// `false` when any check is `Fail` — everything else (`Warn`, + /// `Skipped`) is, per D5, information to display, never a build-blocking + /// signal on its own. + pub fn ok(&self) -> bool { + !self.checks.iter().any(|c| c.status == CheckStatus::Fail) + } + + /// The nominative report of registry.md §9: what was verified, named, + /// never a single green badge. + pub fn render(&self) -> String { + let mut out = format!( + "{} — {} (definition v{})\n", + self.package, self.repository, self.definition_version + ); + for c in &self.checks { + let word = match c.status { + CheckStatus::Pass => "ok", + CheckStatus::Warn => "warning", + CheckStatus::Fail => "FAILED", + CheckStatus::Skipped => "not checked", + }; + out.push_str(&format!(" {:<26} {:<12} {}\n", c.name, word, c.detail)); + } + out + } +} + +// --------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------- + +/// Fetch `spec_str` (`github:owner/repo[@rev]`) and run every check below +/// against it. Always cleans up the temporary clone, success or failure. +pub fn validate(package: &str, spec_str: &str) -> ValidationReport { + let spec = match RepoSpec::parse(spec_str) { + Ok(s) => s, + Err(e) => { + return ValidationReport { + package: package.to_string(), + repository: spec_str.to_string(), + definition_version: "unknown".to_string(), + checks: vec![CheckResult::fail("repository spec", e)], + } + } + }; + + let (fetched, warnings) = match type_registry::fetch(&spec) { + Ok(ok) => ok, + Err(e) => { + return ValidationReport { + package: package.to_string(), + repository: spec.display(), + definition_version: "unknown".to_string(), + checks: vec![CheckResult::fail("fetch", e)], + } + } + }; + + let report = validate_fetched(package, &spec.display(), spec.rev.is_some(), &fetched, &warnings); + let _ = fs::remove_dir_all(&fetched.root); + report +} + +// --------------------------------------------------------------------- +// The checks themselves — pure given an already-fetched definition, so +// this is directly testable against a hand-built temp directory with no +// `git`/network involved (see `tests` below). +// --------------------------------------------------------------------- + +fn validate_fetched( + package: &str, + repository: &str, + rev_pinned: bool, + fetched: &FetchedDefinition, + capability_warnings: &[String], +) -> ValidationReport { + let mut checks = Vec::new(); + + checks.push(CheckResult::pass( + "format_version", + fetched.manifest.format_version.to_string(), + )); + + checks.push(CheckResult::pass( + "repository accessible", + format!("cloned @ {}", short_rev(&fetched.rev)), + )); + checks.push(if rev_pinned { + CheckResult::pass("rev pinned", &fetched.rev) + } else { + CheckResult::warn( + "rev pinned", + "resolved from the default branch's HEAD — pass @ to pin reproducibly (registry.md D4)", + ) + }); + checks.push(CheckResult::pass("digest", fetched.digest.clone())); + + if capability_warnings.is_empty() { + checks.push(CheckResult::pass( + "capabilities", + "no undeclared R; r_shims/extern_raw not declared", + )); + } else { + checks.push(CheckResult::warn("capabilities", capability_warnings.join(" "))); + } + + let all_files = type_registry::tracked_files(&fetched.dir).unwrap_or_default(); + // Declarations first, `tests/` last — a plain lexicographic walk would + // interleave them by accident ("tests/…" sorts before "ty/…") and + // type-check a smoke test against a context that doesn't have its own + // definitions loaded yet. + let mut declaration_files: Vec<&std::path::PathBuf> = Vec::new(); + let mut test_files: Vec<&std::path::PathBuf> = Vec::new(); + for rel in &all_files { + if rel.extension().and_then(|e| e.to_str()) != Some("ty") { + continue; + } + if rel.starts_with("tests") { + test_files.push(rel); + } else { + declaration_files.push(rel); + } + } + let mut ty_sources: Vec<(String, String)> = Vec::new(); + for rel in declaration_files.into_iter().chain(test_files) { + let content = fs::read_to_string(fetched.dir.join(rel)).unwrap_or_default(); + ty_sources.push((rel.to_string_lossy().replace('\\', "/"), content)); + } + + let sources_ref: Vec<(&str, &str)> = ty_sources.iter().map(|(f, s)| (f.as_str(), s.as_str())).collect(); + // trust = "T1" here has no bearing on this check: `load_external_ty_definitions` + // only *degrades* entries below trust after they've already + // parsed/type-checked, and `skipped` records failures from before that + // point — this just avoids implying a trust decision that isn't ours to + // make in a validator. + let (_context, skipped) = standard_library::load_external_ty_definitions( + Context::default(), + &sources_ref, + &fetched.manifest.definition.tier, + "T1", + ); + + if ty_sources.is_empty() { + checks.push(CheckResult::warn( + ".ty parse/type-check", + "no .ty file found in this repository", + )); + } else if skipped.is_empty() { + checks.push(CheckResult::pass( + ".ty parse/type-check", + format!("{} file(s) OK", ty_sources.len()), + )); + } else { + let names: Vec<&str> = skipped.iter().map(|(f, _)| f.as_str()).collect(); + checks.push(CheckResult::fail( + ".ty parse/type-check", + format!( + "{}/{} file(s) failed: {}", + skipped.len(), + ty_sources.len(), + names.join(", ") + ), + )); + } + + const SMOKE_PATH: &str = "tests/smoke.ty"; + match ty_sources.iter().any(|(f, _)| f == SMOKE_PATH) { + true => match skipped.iter().find(|(f, _)| f == SMOKE_PATH) { + Some((_, message)) => checks.push(CheckResult::fail("tests/smoke.ty", message.clone())), + None => checks.push(CheckResult::pass("tests/smoke.ty", "compiles")), + }, + false => checks.push(CheckResult::skipped("tests/smoke.ty", "not present in this repository")), + } + + // Declared names/arity/tier — deliberately excludes `tests/`, which + // exercises the API rather than declaring it (registry.md §5.1). + let declared_sources: Vec<(String, String)> = ty_sources + .iter() + .filter(|(f, _)| !f.starts_with("tests/")) + .cloned() + .collect(); + let declared = parse_declared_entries(&declared_sources, &fetched.manifest.definition.tier); + + let t1_entries: Vec<&DeclaredEntry> = declared.iter().filter(|e| e.tier.as_deref() == Some("T1")).collect(); + let t1_violations: Vec<&str> = t1_entries + .iter() + .filter(|e| e.params.iter().any(|p| p.is_variadic && p.type_text.trim() == "Any")) + .map(|e| e.name.as_str()) + .collect(); + if t1_violations.is_empty() { + checks.push(CheckResult::pass( + "T1 promotion gate", + format!("{} T1 entrie(s), none with an unconstrained `...`", t1_entries.len()), + )); + } else { + checks.push(CheckResult::fail( + "T1 promotion gate", + format!("unconstrained `...` at T1: {}", t1_violations.join(", ")), + )); + } + + if !gen_types::rscript_available() { + checks.push(CheckResult::skipped("exports vs formals()", "Rscript not on PATH")); + checks.push(CheckResult::skipped("arity vs formals()", "Rscript not on PATH")); + checks.push(CheckResult::skipped("package on CRAN", "Rscript not on PATH")); + } else { + match gen_types::introspect(package) { + Ok(info) if !info.functions.is_empty() => { + push_formals_diff(&mut checks, &declared, &info.functions); + } + _ => { + checks.push(CheckResult::skipped( + "exports vs formals()", + format!("`{package}` not installed locally"), + )); + checks.push(CheckResult::skipped( + "arity vs formals()", + format!("`{package}` not installed locally"), + )); + } + } + + match check_cran_availability(package) { + Some(true) => checks.push(CheckResult::pass("package on CRAN", "found on cloud.r-project.org")), + Some(false) => checks.push(CheckResult::warn( + "package on CRAN", + "not found on CRAN — may be R-universe/Bioconductor/GitHub-only", + )), + None => checks.push(CheckResult::skipped( + "package on CRAN", + "could not reach the CRAN mirror", + )), + } + } + + ValidationReport { + package: package.to_string(), + repository: repository.to_string(), + definition_version: fetched.manifest.definition.version.clone(), + checks, + } +} + +fn short_rev(rev: &str) -> &str { + &rev[..rev.len().min(12)] +} + +/// Diff `declared` (what the `.ty` sources say) against `installed` (what +/// `formals()` says on the real, locally installed package) — the "diff +/// `formals()` contre le package installé" registry.md §13 J4 names +/// explicitly. Pushes both the "exports vs formals()" and "arity vs +/// formals()" checks. +fn push_formals_diff( + checks: &mut Vec, + declared: &[DeclaredEntry], + installed_fns: &[gen_types::GeneratedFn], +) { + let installed: HashMap<&str, &gen_types::GeneratedFn> = + installed_fns.iter().map(|f| (f.name.as_str(), f)).collect(); + + let mut missing = Vec::new(); + let mut mismatches = Vec::new(); + for entry in declared { + match installed.get(entry.name.as_str()) { + None => missing.push(entry.name.clone()), + Some(f) => { + let declared_fixed = entry.params.iter().filter(|p| !p.is_variadic).count(); + let declared_variadic = entry.params.iter().any(|p| p.is_variadic); + if declared_fixed != f.params.len() || declared_variadic != f.has_dots { + mismatches.push(format!( + "{} (declared {} arg(s){}, formals() has {} arg(s){})", + entry.name, + declared_fixed, + if declared_variadic { "+..." } else { "" }, + f.params.len(), + if f.has_dots { "+..." } else { "" } + )); + } + } + } + } + + let total = declared.len(); + let found = total - missing.len(); + if missing.is_empty() { + checks.push(CheckResult::pass( + "exports vs formals()", + format!("{found}/{total} found"), + )); + } else { + checks.push(CheckResult::fail( + "exports vs formals()", + format!("{found}/{total} found — missing: {}", missing.join(", ")), + )); + } + if mismatches.is_empty() { + checks.push(CheckResult::pass( + "arity vs formals()", + format!("{total}/{total} match"), + )); + } else { + checks.push(CheckResult::fail( + "arity vs formals()", + format!("{} mismatch(es): {}", mismatches.len(), mismatches.join("; ")), + )); + } +} + +/// Best-effort: is `pkg` on CRAN, via `Rscript`'s own +/// `available.packages()` against the public mirror — the same +/// shell-out-to-`Rscript` choice `gen_types.rs` already made, rather than +/// adding an HTTP client dependency for one query. `None` (never a `Fail`) +/// when `Rscript` errors, the mirror is unreachable, or `pkg` isn't a plain +/// package-name-shaped string (never interpolated into R source otherwise — +/// same guard as `gen_types::is_generatable_name`). +fn check_cran_availability(pkg: &str) -> Option { + if pkg.is_empty() || !pkg.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_') { + return None; + } + let expr = format!( + "ap <- tryCatch(available.packages(repos = \"https://cloud.r-project.org\"), error = function(e) NULL); \ + if (is.null(ap)) cat(\"NETERR\") else if (\"{pkg}\" %in% rownames(ap)) cat(\"YES\") else cat(\"NO\")" + ); + let output = Command::new("Rscript").arg("-e").arg(&expr).output().ok()?; + if !output.status.success() { + return None; + } + match String::from_utf8_lossy(&output.stdout).trim() { + "YES" => Some(true), + "NO" => Some(false), + _ => None, + } +} + +// --------------------------------------------------------------------- +// Declared signature parsing — arity, parameter names and tier straight +// from the `.ty` source text, independent of the type-checker's own +// preprocessing (which strips parameter names before parsing, since the +// type grammar doesn't accept them — `standard_library::preprocess_ty_source`). +// --------------------------------------------------------------------- + +struct DeclaredParam { + is_variadic: bool, + type_text: String, +} + +struct DeclaredEntry { + /// Display name, backticks stripped. + name: String, + /// Effective tier: the entry's own `#! tier:`, or the manifest's + /// `[definition] tier` when absent. + tier: Option, + params: Vec, +} + +fn parse_declared_entries(ty_sources: &[(String, String)], default_tier: &str) -> Vec { + let mut out = Vec::new(); + for (_file, source) in ty_sources { + let meta_map = parse_meta_from_source(source); + for line in source.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('@') { + continue; + } + let Some(sig) = parse_signature_line(trimmed) else { + continue; + }; + let tier = meta_map + .get(&sig.raw_name) + .and_then(|m| m.tier.clone()) + .or_else(|| Some(default_tier.to_string())); + out.push(DeclaredEntry { + name: unwrap_backtick(&sig.raw_name), + tier, + params: sig.params, + }); + } + } + out +} + +struct ParsedSignature { + /// Exactly what `#! tier:`-map lookups need to match against — keeps + /// surrounding backticks, since `parse_meta_from_source` keys its map the + /// same way. + raw_name: String, + params: Vec, +} + +fn unwrap_backtick(name: &str) -> String { + name.trim_matches('`').to_string() +} + +/// Parse one `@name: (...) -> Ret;` (or `@extern pkg::name: ...;`) line into +/// its raw name and parameter list. `None` for a non-function declaration +/// (`type X <- Foreign;`, a bare `@x: int;` constant, …) — those don't +/// carry an arity to diff against `formals()`. +fn parse_signature_line(line: &str) -> Option { + let rest = line.strip_prefix('@')?; + let sep = find_unqualified_colon(rest)?; + let head = &rest[..sep]; + let raw_name = head + .strip_prefix("extern ") + .map(|n| n.rsplit("::").next().unwrap_or(n)) + .unwrap_or(head) + .trim(); + if raw_name.is_empty() { + return None; + } + let sig = rest[sep + 1..].trim(); + let sig = sig.strip_suffix(';').unwrap_or(sig).trim(); + if !sig.starts_with('(') { + return None; + } + let params = parse_param_list(sig)?; + Some(ParsedSignature { + raw_name: raw_name.to_string(), + params, + }) +} + +/// The first `:` in `text` that is not part of a `::` (which appears in +/// `@extern pkg::name`) — same rule stdlib_meta.rs's private +/// `extract_signature_name` uses, reimplemented here since that one isn't +/// exported across the crate boundary. +fn find_unqualified_colon(text: &str) -> Option { + let bytes = text.as_bytes(); + for (i, &b) in bytes.iter().enumerate() { + if b == b':' { + let prev = if i > 0 { bytes[i - 1] } else { 0 }; + let next = if i + 1 < bytes.len() { bytes[i + 1] } else { 0 }; + if prev != b':' && next != b':' { + return Some(i); + } + } + } + None +} + +/// `sig` starts with `(` — find its matching `)` (bracket-depth aware, so a +/// nested function-type or generic parameter doesn't confuse the split) and +/// parse the top-level comma-separated parameter list inside. +fn parse_param_list(sig: &str) -> Option> { + let chars: Vec = sig.chars().collect(); + let mut depth = 0i32; + let mut close_idx = None; + for (i, &c) in chars.iter().enumerate() { + match c { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => { + depth -= 1; + if depth == 0 && c == ')' { + close_idx = Some(i); + break; + } + } + _ => {} + } + } + let close_idx = close_idx?; + let inner: String = chars[1..close_idx].iter().collect(); + Some( + split_top_level(&inner) + .into_iter() + .map(|p| parse_one_param(&p)) + .collect(), + ) +} + +fn split_top_level(inner: &str) -> Vec { + let trimmed = inner.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + let mut parts = Vec::new(); + let mut depth = 0i32; + let mut current = String::new(); + for c in trimmed.chars() { + match c { + '(' | '[' | '{' => { + depth += 1; + current.push(c); + } + ')' | ']' | '}' => { + depth -= 1; + current.push(c); + } + ',' if depth == 0 => { + parts.push(current.trim().to_string()); + current.clear(); + } + _ => current.push(c), + } + } + if !current.trim().is_empty() { + parts.push(current.trim().to_string()); + } + parts +} + +fn parse_one_param(text: &str) -> DeclaredParam { + let (is_variadic, rest) = match text.strip_prefix("...") { + Some(r) => (true, r.trim()), + None => (false, text.trim()), + }; + if rest.is_empty() { + return DeclaredParam { + is_variadic, + type_text: "Any".to_string(), + }; + } + match find_unqualified_colon(rest) { + Some(colon_idx) => DeclaredParam { + is_variadic, + type_text: rest[colon_idx + 1..].trim().to_string(), + }, + None => DeclaredParam { + is_variadic, + type_text: rest.to_string(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::type_definition::{parse_manifest, DefinitionManifest}; + use std::path::{Path, PathBuf}; + + // -- Signature parsing -------------------------------------------- + + #[test] + fn parses_unnamed_generated_style_signature() { + let sig = parse_signature_line("@filter: (Any, ...Any) -> Any;").unwrap(); + assert_eq!(sig.raw_name, "filter"); + assert_eq!(sig.params.len(), 2); + assert!(!sig.params[0].is_variadic); + assert_eq!(sig.params[0].type_text, "Any"); + assert!(sig.params[1].is_variadic); + assert_eq!(sig.params[1].type_text, "Any"); + } + + #[test] + fn parses_named_hand_authored_signature() { + let sig = parse_signature_line("@cat: (...values: Any) -> Empty;").unwrap(); + assert_eq!(sig.raw_name, "cat"); + assert_eq!(sig.params.len(), 1); + assert!(sig.params[0].is_variadic); + assert_eq!(sig.params[0].type_text, "Any"); + } + + #[test] + fn parses_extern_pkg_double_colon_signature() { + let sig = parse_signature_line("@extern jsonlite::toJSON: (Any) -> char;").unwrap(); + assert_eq!(sig.raw_name, "toJSON"); + assert_eq!(sig.params.len(), 1); + } + + #[test] + fn parses_backtick_quoted_name() { + let sig = parse_signature_line("@`is.numeric`: (Any) -> bool;").unwrap(); + assert_eq!(sig.raw_name, "`is.numeric`"); + } + + #[test] + fn nested_higher_order_param_does_not_confuse_arity() { + let sig = parse_signature_line("@apply_fn: (f: (int) -> int, x: int) -> int;").unwrap(); + assert_eq!(sig.params.len(), 2); + assert_eq!(sig.params[0].type_text, "(int) -> int"); + assert_eq!(sig.params[1].type_text, "int"); + } + + #[test] + fn non_function_declaration_is_not_a_signature() { + assert!(parse_signature_line("type DataFrame <- Foreign;").is_none()); + } + + // -- Declared entries + tier fallback ------------------------------- + + #[test] + fn own_tier_annotation_wins_over_manifest_default() { + let src = "#! tier: T1\n@a: (Any) -> Any;\n\n@b: (Any) -> Any;\n"; + let declared = parse_declared_entries(&[("core.ty".to_string(), src.to_string())], "T3"); + let a = declared.iter().find(|e| e.name == "a").unwrap(); + let b = declared.iter().find(|e| e.name == "b").unwrap(); + assert_eq!(a.tier.as_deref(), Some("T1")); + assert_eq!(b.tier.as_deref(), Some("T3")); + } + + // -- T1 promotion gate + formals() diff, via `validate_fetched` ----- + + fn manifest(tier: &str) -> DefinitionManifest { + parse_manifest(&format!( + "format_version = 1\n\ + [package]\nname = \"shiny\"\nsince = \"1.11.0\"\n\ + [definition]\nversion = \"0.3.0\"\ntier = \"{tier}\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:alice/typr-shiny\"\n" + )) + .unwrap() + } + + fn write_fetched(subdir: &str, files: &[(&str, &str)]) -> FetchedDefinition { + let dir = std::env::temp_dir().join(format!("typr_registry_validate_{subdir}_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + for (rel, content) in files { + let path = dir.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, content).unwrap(); + } + FetchedDefinition { + manifest: manifest("T2"), + rev: "deadbeefcafef00d".to_string(), + digest: "sha256:test".to_string(), + dir: dir.clone(), + root: dir, + } + } + + fn cleanup(fetched: &FetchedDefinition) { + let _ = fs::remove_dir_all(&fetched.root); + } + + #[test] + fn clean_definition_passes_the_non_introspection_checks() { + let fetched = write_fetched( + "clean", + &[( + "ty/core.ty", + "#! tier: T2\n@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n", + )], + ); + let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + + let by_name = |n: &str| report.checks.iter().find(|c| c.name == n).unwrap(); + assert_eq!(by_name("format_version").status, CheckStatus::Pass); + assert_eq!(by_name("rev pinned").status, CheckStatus::Pass); + assert_eq!(by_name("capabilities").status, CheckStatus::Pass); + assert_eq!(by_name(".ty parse/type-check").status, CheckStatus::Pass); + assert_eq!(by_name("tests/smoke.ty").status, CheckStatus::Skipped); + assert_eq!(by_name("T1 promotion gate").status, CheckStatus::Pass); + } + + #[test] + fn unpinned_rev_warns() { + let fetched = write_fetched("unpinned", &[("ty/core.ty", "@f: (Any) -> Any;\n")]); + let report = validate_fetched("shiny", "github:alice/typr-shiny", false, &fetched, &[]); + cleanup(&fetched); + let rev_check = report.checks.iter().find(|c| c.name == "rev pinned").unwrap(); + assert_eq!(rev_check.status, CheckStatus::Warn); + } + + /// A package name no real registry/CRAN package will ever have — + /// guarantees `gen_types::introspect` finds nothing installed, so tests + /// that assert on `report.ok()` as a whole aren't at the mercy of what + /// happens to be installed on the machine running the suite (unlike + /// `formals_diff_flags_a_missing_export_and_an_arity_mismatch`, below, + /// which deliberately wants a real installed package). + const FAKE_PACKAGE: &str = "typr_registry_validate_fixture_zzz"; + + #[test] + fn declared_capability_warning_is_surfaced_not_failed() { + let fetched = write_fetched("capwarn", &[("ty/core.ty", "@f: (Any) -> Any;\n")]); + let report = validate_fetched( + FAKE_PACKAGE, + "github:alice/typr-shiny", + true, + &fetched, + &["ships R shims".to_string()], + ); + cleanup(&fetched); + let cap = report.checks.iter().find(|c| c.name == "capabilities").unwrap(); + assert_eq!(cap.status, CheckStatus::Warn); + assert!(report.ok(), "a declared capability must not fail validation"); + } + + #[test] + fn broken_ty_source_fails_the_parse_check() { + // `fn(x)` with no parameter type hits the dedicated + // `SyntaxError::FunctionWithoutType` panic in `parsing/elements.rs` + // (see `standard_library.rs::tests::broken_ty_source_is_reported_as_skipped_with_a_real_message`). + let fetched = write_fetched("broken", &[("ty/core.ty", "let f <- fn(x) { x };\n")]); + let report = validate_fetched(FAKE_PACKAGE, "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + let parse_check = report.checks.iter().find(|c| c.name == ".ty parse/type-check").unwrap(); + assert_eq!(parse_check.status, CheckStatus::Fail); + assert!(!report.ok()); + } + + #[test] + fn present_smoke_test_is_checked_and_passes_when_it_typechecks() { + let fetched = write_fetched( + "smoke", + &[ + ( + "ty/core.ty", + "@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n", + ), + ("tests/smoke.ty", "let x <- fluidPage(1);\n"), + ], + ); + let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + let smoke = report.checks.iter().find(|c| c.name == "tests/smoke.ty").unwrap(); + assert_eq!(smoke.status, CheckStatus::Pass); + } + + #[test] + fn t1_entry_with_unconstrained_variadic_fails_the_promotion_gate() { + let dir = std::env::temp_dir().join(format!("typr_registry_validate_t1gate_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let rel = Path::new("ty/core.ty"); + let path: PathBuf = dir.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "#! tier: T1\n@risky: (...values: Any) -> Any;\n").unwrap(); + let fetched = FetchedDefinition { + manifest: manifest("T2"), + rev: "abc123".to_string(), + digest: "sha256:test".to_string(), + dir: dir.clone(), + root: dir, + }; + + let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + + let gate = report.checks.iter().find(|c| c.name == "T1 promotion gate").unwrap(); + assert_eq!(gate.status, CheckStatus::Fail); + assert!(gate.detail.contains("risky"), "unexpected detail: {}", gate.detail); + assert!(!report.ok()); + } + + #[test] + fn t1_entry_with_constrained_variadic_passes_the_gate() { + let fetched = write_fetched( + "t1ok", + &[("ty/core.ty", "#! tier: T1\n@safe: (...values: int) -> Any;\n")], + ); + let report = validate_fetched("shiny", "github:alice/typr-shiny", true, &fetched, &[]); + cleanup(&fetched); + let gate = report.checks.iter().find(|c| c.name == "T1 promotion gate").unwrap(); + assert_eq!(gate.status, CheckStatus::Pass); + } + + // -- formals() diff, against a real installed package (fail-open like + // every other Rscript-dependent test in this crate: skipped, not + // failed, when R/the package isn't available on this machine — see + // `gen_types.rs::generated_ty_type_checks_for_contrasting_packages`) -- + + #[test] + fn formals_diff_flags_a_missing_export_and_an_arity_mismatch() { + if !gen_types::rscript_available() { + eprintln!("skipping: Rscript not on PATH"); + return; + } + let info = match gen_types::introspect("jsonlite") { + Ok(info) if !info.functions.is_empty() => info, + _ => { + eprintln!("skipping: jsonlite not installed on this machine"); + return; + } + }; + let real = info.functions.iter().find(|f| f.name == "toJSON"); + let Some(real) = real else { + eprintln!("skipping: jsonlite::toJSON not found by introspection"); + return; + }; + // `toJSON` declared with one extra fixed argument beyond its real + // `formals()` — a real, existing export with the wrong arity — plus + // a name that plain doesn't exist in the package at all. + let wrong_arity_args = std::iter::repeat("Any") + .take(real.params.len() + 1) + .collect::>() + .join(", "); + let src = format!( + "@extern jsonlite::toJSON: ({wrong_arity_args}) -> char;\n\ + @extern jsonlite::doesNotExist: (Any) -> char;\n" + ); + let fetched = write_fetched("formals", &[("ty/core.ty", &src)]); + let report = validate_fetched("jsonlite", "github:alice/typr-jsonlite", true, &fetched, &[]); + cleanup(&fetched); + + let exports = report.checks.iter().find(|c| c.name == "exports vs formals()").unwrap(); + assert_eq!(exports.status, CheckStatus::Fail); + assert!( + exports.detail.contains("doesNotExist"), + "unexpected detail: {}", + exports.detail + ); + + let arity = report.checks.iter().find(|c| c.name == "arity vs formals()").unwrap(); + assert_eq!(arity.status, CheckStatus::Fail); + assert!(arity.detail.contains("toJSON"), "unexpected detail: {}", arity.detail); + } +} diff --git a/crates/typr-cli/src/standard_library.rs b/crates/typr-cli/src/standard_library.rs index adcfd4f0..16b271f9 100644 --- a/crates/typr-cli/src/standard_library.rs +++ b/crates/typr-cli/src/standard_library.rs @@ -4,6 +4,7 @@ //! and prints the content of the standard library. use std::collections::HashMap; +use std::collections::HashSet; use std::path::PathBuf; use typr_core::components::context::vartype::VarType; use typr_core::components::context::Context; @@ -436,20 +437,26 @@ fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { } } -/// Build a VarType from typed standard library .ty source files. +/// Parse and type-check a sequence of `.ty` sources, threading a *starting* +/// context through them so that later files can reference types from earlier +/// ones (and from whatever `base_context` already carries). Signature lines +/// (`@`) are preprocessed to strip named parameters. /// -/// Parses and type-checks each .ty source file sequentially, threading the -/// context through so that later files can reference types from earlier ones. -/// Signature lines (`@`) are preprocessed to strip named parameters. +/// This is the shared loop behind both `build_typed_vartype` (bundled +/// stdlib, always starts from `Context::empty()`) and +/// `load_external_ty_definitions` (a third-party definition repository, +/// starts from the caller's own context so it can see the stdlib's types). /// -/// Returns the built `VarType` plus the list of `(filename, panic message)` -/// for every source file that was skipped because parsing/type-checking it -/// panicked. A skipped file's signatures are silently absent from the -/// resulting `VarType` — the caller MUST surface this loudly (see -/// `standard_library()`), never let it pass as a quiet informational line, -/// since it means real stdlib entries silently vanished from the compiler. -fn build_typed_vartype(ty_sources: &[(&str, &str)]) -> (VarType, Vec<(String, String)>) { - let mut context = Context::empty(); +/// Returns the resulting `Context` plus the list of `(filename, panic +/// message)` for every source file that was skipped because parsing/ +/// type-checking it panicked. A skipped file's signatures are silently +/// absent from the resulting context — the caller MUST surface this loudly, +/// never let it pass as a quiet informational line. +fn extend_context_with_ty_sources( + base_context: Context, + ty_sources: &[(&str, &str)], +) -> (Context, Vec<(String, String)>) { + let mut context = base_context; let mut skipped: Vec<(String, String)> = Vec::new(); // Silence the default panic hook while probing these files: a skip is an @@ -486,9 +493,288 @@ fn build_typed_vartype(ty_sources: &[(&str, &str)]) -> (VarType, Vec<(String, St std::panic::set_hook(previous_hook); + (context, skipped) +} + +/// Build a VarType from typed standard library .ty source files. +/// +/// Parses and type-checks each .ty source file sequentially, threading the +/// context through so that later files can reference types from earlier ones. +/// +/// Returns the built `VarType` plus the list of `(filename, panic message)` +/// for every source file that was skipped because parsing/type-checking it +/// panicked. A skipped file's signatures are silently absent from the +/// resulting `VarType` — the caller MUST surface this loudly (see +/// `standard_library()`), never let it pass as a quiet informational line, +/// since it means real stdlib entries silently vanished from the compiler. +fn build_typed_vartype(ty_sources: &[(&str, &str)]) -> (VarType, Vec<(String, String)>) { + let (context, skipped) = extend_context_with_ty_sources(Context::empty(), ty_sources); (context.get_vartype(), skipped) } +/// Ranking of the three tiers by trustworthiness, most trusted first (`T1` +/// = 3, `T2` = 2, `T3` = 1). `None` for anything else — including a tier +/// string a manifest or `#! tier:` annotation declares that this build +/// doesn't recognize, per `type_definition.rs::DefinitionSection::tier`'s +/// "an unrecognized future tier degrades instead of failing the whole +/// manifest to parse". +fn tier_rank(tier: &str) -> Option { + match tier { + "T1" => Some(3), + "T2" => Some(2), + "T3" => Some(1), + _ => None, + } +} + +/// Does `entry_tier` meet the project's `trust` threshold? +/// +/// `rfcs/0031-external-type-definitions.md`, "Loading external `.ty` into +/// the context": "entry tier ≥ project trust: loaded with its declared +/// signature […]; entry tier < project trust: loaded as +/// `Type::UnknownFunction`". An entry tier or a project `trust` this build +/// doesn't recognize never meets the threshold — D2 (`typR/registry.md` +/// §0/§5.4) requires an unreliable or unreadable trust signal to widen +/// towards `Any`, never to be silently treated as trusted. +fn meets_trust(entry_tier: &str, trust: &str) -> bool { + match (tier_rank(entry_tier), tier_rank(trust)) { + (Some(entry), Some(required)) => entry >= required, + _ => false, + } +} + +/// Every function name declared across `ty_sources` whose *effective* tier — +/// its own `#! tier:` annotation, falling back to `default_tier` (the +/// manifest's `[definition] tier`, RFC-0031) when absent — falls below +/// `trust`. These are exactly the names `load_external_ty_definitions` +/// degrades to `(Any, UnknownFunction)` after type-checking. +fn names_below_trust(ty_sources: &[(&str, &str)], default_tier: &str, trust: &str) -> HashSet { + let mut below = HashSet::new(); + for (_filename, source) in ty_sources { + let meta_map = parse_meta_from_source(source); + for line in source.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('@') { + continue; + } + let Some(raw_name) = extract_raw_signature_name(trimmed) else { + continue; + }; + let tier = meta_map + .get(&raw_name) + .and_then(|m| m.tier.as_deref()) + .unwrap_or(default_tier); + if !meets_trust(tier, trust) { + below.insert(unwrap_backtick_name(&raw_name)); + } + } + } + below +} + +/// Load externally-provided `.ty` definitions on top of an existing typing +/// context, using the exact same parse/type-check loop that builds the +/// bundled standard library from `R_T1_SOURCES` — so an external definition +/// can reference the stdlib's own types (`Foreign`, etc.) exactly the way +/// `std.ty` itself does — and then degrading every entry whose effective +/// tier falls below `trust` to `Type::UnknownFunction`. +/// +/// This is the "chargement d'un `.ty` externe dans le contexte" + +/// "seuil `trust` + règle de dégradation vers `Any`" items of +/// `typR/registry.md` §13 J2 (`rfcs/0031-external-type-definitions.md`, +/// "Loading external `.ty` into the context"). `ty_sources` is expected to +/// come from a single resolved definition repository, so a single +/// `default_tier` (its manifest's `[definition] tier`) applies to every +/// entry with no `#! tier:` of its own; `trust` is the consuming project's +/// own threshold (`typr.toml [types] trust`, not yet read from disk — the +/// machinery that resolves a `typr.lock` entry into these arguments is the +/// next checklist item). +/// +/// A degraded entry is never dropped or rejected: it is loaded exactly like +/// any other untyped R name (`(Any, UnknownFunction)`), keeping it callable +/// with arity/type checking simply skipped — this is D2 made real +/// (`typR/registry.md` §0/§5.4): a definition the project doesn't trust +/// enough can only make TypR check *less*, never break a build. +/// +/// `base_context` is typically `Context::default()` (or a project's own +/// context built on top of it) — starting from it, rather than +/// `Context::empty()`, is what lets a third-party `.ty` see the bundled +/// stdlib while it is being type-checked. +/// +/// Called from `load_project_type_definitions`, below, which resolves a +/// project's `typr.lock` into exactly the `(ty_sources, default_tier, +/// trust)` triples this function expects. +pub fn load_external_ty_definitions( + base_context: Context, + ty_sources: &[(&str, &str)], + default_tier: &str, + trust: &str, +) -> (Context, Vec<(String, String)>) { + let (mut context, skipped) = extend_context_with_ty_sources(base_context, ty_sources); + let degraded_names = names_below_trust(ty_sources, default_tier, trust); + context.typing_context = context.typing_context.clone().degrade_to_any(°raded_names); + (context, skipped) +} + +/// Every function name declared across `ty_sources`, regardless of tier — +/// what `degrade_if_version_out_of_range` widens to `Any` when the whole +/// definition is out of its declared version range. Unlike +/// `names_below_trust`, tier plays no role here: a version mismatch is a +/// property of the *definition*, not of any one entry's declared +/// trustworthiness. +fn all_declared_names(ty_sources: &[(&str, &str)]) -> HashSet { + let mut names = HashSet::new(); + for (_filename, source) in ty_sources { + for line in source.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with('@') { + continue; + } + if let Some(raw_name) = extract_raw_signature_name(trimmed) { + names.insert(unwrap_backtick_name(&raw_name)); + } + } + } + names +} + +/// Parse a dotted version string into numeric components, ignoring any +/// non-digit suffix on a component (`"1.11.0-beta"` -> `[1, 11, 0]`) and +/// treating an unparsable component as `0` — good enough for the floor/ +/// ceiling comparison below, never a reason to fail a build over a +/// malformed version string. +fn parse_version(v: &str) -> Vec { + v.split(['.', '-', '+']) + .map(|part| { + let digits: String = part.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse::().unwrap_or(0) + }) + .collect() +} + +/// Is `a` strictly less than `b`, comparing dotted version strings +/// component-wise (`"1.9"` < `"1.10"`, not string order)? Both are padded to +/// the same length first so `"1.2"` and `"1.2.0"` compare equal rather than +/// the shorter one spuriously losing. +fn version_less_than(a: &str, b: &str) -> bool { + let mut pa = parse_version(a); + let mut pb = parse_version(b); + while pa.len() < pb.len() { + pa.push(0); + } + while pb.len() < pa.len() { + pb.push(0); + } + pa < pb +} + +/// registry.md §7.2 "Compatibilité de versions : borne minimale, pas plage +/// fermée": when the R package version actually observed at resolution time +/// (`typr.lock`'s `r_version_seen`, populated by `typr types add`/`update`) +/// falls below the definition's declared `since` floor, or above its +/// optional `until` ceiling, every name the definition declares degrades to +/// `Any` — same D2 degrade-never-fail contract as the trust threshold +/// (§0/§5.4), just gated on a different signal, and applied on top of it +/// rather than instead of it. +/// +/// No comparison is made, and nothing degrades, when `r_version_seen` is +/// `None`: a version that was never observed (offline resolution, or R +/// unavailable when the definition was added/updated) is not the same as an +/// incompatible one, and D2 forbids treating an unreadable signal as +/// grounds for anything other than staying exactly as trusting as the tier +/// check already decided. +fn degrade_if_version_out_of_range( + context: Context, + ty_sources: &[(&str, &str)], + since: &str, + until: Option<&str>, + r_version_seen: Option<&str>, +) -> (Context, Option) { + let Some(observed) = r_version_seen else { + return (context, None); + }; + let below_floor = version_less_than(observed, since); + let above_ceiling = until.map(|u| version_less_than(u, observed)).unwrap_or(false); + if !below_floor && !above_ceiling { + return (context, None); + } + + let reason = if below_floor { + format!( + "observed R package version {observed} is older than this definition's declared floor (since = \"{since}\")" + ) + } else { + format!( + "observed R package version {observed} is newer than this definition's declared ceiling (until = \"{}\")", + until.unwrap_or_default() + ) + }; + + let names = all_declared_names(ty_sources); + let mut context = context; + context.typing_context = context.typing_context.clone().degrade_to_any(&names); + (context, Some(reason)) +} + +/// Load every package's resolved external Type Definition on top of +/// `base_context` — the "reading `typr.lock` at `check`/`build`/`run` time" +/// wiring that `type_registry.rs`'s module doc and `load_external_ty_definitions` +/// name as the last missing piece of `typR/registry.md` §13 J2. Called from +/// every `check`/`build`/`run` entry point in `project.rs`. +/// +/// A project with no `typr.lock` is unaffected (`resolve_locked_definitions` +/// returns nothing to load). A locked package whose cache is missing, stale, +/// or out of its declared version range degrades or is skipped with a +/// `warning:` line — never a hard error: this function cannot make a build +/// that passed before fail now (D2, registry.md §0/§5.4). +/// +/// `project_root` is the directory holding `typr.toml`/`typr.lock` — every +/// call site in `project.rs` passes `Path::new(".")`, since CLI commands +/// already run with the project root as the current directory (same +/// convention as `PathBuf::from("TypR/main.ty")` elsewhere in that module). +/// Taking it as a parameter, rather than hard-coding `"."` in here, is what +/// lets tests point it at a temporary project without touching the process's +/// current directory. +pub fn load_project_type_definitions(project_root: &std::path::Path, base_context: Context) -> Context { + let trust = crate::type_registry::TypesConfig::read(project_root) + .trust + .unwrap_or_else(|| "T2".to_string()); + let (resolved, warnings) = crate::type_registry::resolve_locked_definitions(project_root); + for w in &warnings { + eprintln!("warning: {w}"); + } + + let mut context = base_context; + for def in &resolved { + let sources: Vec<(&str, &str)> = def.ty_sources.iter().map(|(f, s)| (f.as_str(), s.as_str())).collect(); + + let (next_context, skipped) = load_external_ty_definitions(context, &sources, &def.default_tier, &trust); + for (filename, message) in &skipped { + eprintln!( + "warning: `{}` — {} could not be loaded ({message}); its declared names stay untyped", + def.package, filename + ); + } + + let (next_context, version_warning) = degrade_if_version_out_of_range( + next_context, + &sources, + &def.since, + def.until.as_deref(), + def.r_version_seen.as_deref(), + ); + if let Some(reason) = version_warning { + eprintln!( + "warning: `{}` — {reason}; its declared types are degraded to Any for this run (registry.md §7.2)", + def.package + ); + } + + context = next_context; + } + context +} + /// Build a documentation graph over a set of `.ty` sources. /// /// Shared by `build_stdlib_docs` (production) and the tests, so the @@ -774,6 +1060,256 @@ mod tests { assert!(skipped.is_empty()); } + /// `load_external_ty_definitions` is the registry.md §13 J2 "chargement + /// d'un `.ty` externe dans le contexte" mechanism: it must merge new + /// signatures on top of an existing context, exactly like an + /// `R_T1_SOURCES` file merges on top of the ones processed before it. + #[test] + fn load_external_ty_definitions_merges_new_signatures_into_base_context() { + let (base_context, base_skipped) = + extend_context_with_ty_sources(Context::empty(), &[("base.ty", "@base_fn: (int) -> int;")]); + assert!(base_skipped.is_empty()); + + let (context, skipped) = load_external_ty_definitions( + base_context, + &[("shiny.generated.ty", "@fluidPage: (Any) -> Any;")], + "T2", + "T2", + ); + + assert!(skipped.is_empty()); + let names: Vec = context + .get_vartype() + .variables + .iter() + .map(|(v, _)| v.get_name()) + .collect(); + assert!( + names.contains(&"base_fn".to_string()), + "base context signature must survive the merge" + ); + assert!( + names.contains(&"fluidPage".to_string()), + "external signature must be loaded" + ); + } + + /// An external definition can reference a type the bundled stdlib itself + /// declares (`Foreign`, `foreign.ty`) — proving the context is + /// threaded through from `base_context`, not type-checked in isolation. + /// This is what lets a third-party `.ty` use `Foreign` the way + /// `std.ty` itself does (RFC-0031, "Loading external `.ty` into the + /// context"). + #[test] + fn load_external_ty_definitions_sees_types_declared_in_base_context() { + let (base_context, base_skipped) = + extend_context_with_ty_sources(Context::empty(), &[("foreign.ty", FOREIGN_TY)]); + assert!(base_skipped.is_empty()); + + let (_context, skipped) = load_external_ty_definitions( + base_context, + &[( + "shiny.generated.ty", + "type UiObject <- Foreign;\n@fluidPage: (Any) -> UiObject;", + )], + "T2", + "T2", + ); + + assert!( + skipped.is_empty(), + "external definition referencing a base-context type must type-check: {:?}", + skipped + ); + } + + /// A broken external definition is reported as skipped — same contract as + /// a broken bundled stdlib file — and never corrupts the base context: + /// signatures already resolved before it stay intact. This is the D2 + /// "an unreliable definition widens to `Any`, it never fails the build" + /// principle at its narrowest: at minimum, a bad external file must not + /// take the rest of the project's own types down with it. + #[test] + fn load_external_ty_definitions_skips_a_broken_source_without_losing_the_base_context() { + let (base_context, base_skipped) = + extend_context_with_ty_sources(Context::empty(), &[("base.ty", "@base_fn: (int) -> int;")]); + assert!(base_skipped.is_empty()); + + let (context, skipped) = + load_external_ty_definitions(base_context, &[("broken.ty", "let f <- fn(x) { x };")], "T2", "T2"); + + assert_eq!(skipped.len(), 1); + assert_eq!(skipped[0].0, "broken.ty"); + let names: Vec = context + .get_vartype() + .variables + .iter() + .map(|(v, _)| v.get_name()) + .collect(); + assert!( + names.contains(&"base_fn".to_string()), + "base context must survive a skipped external source" + ); + } + + /// registry.md §13 J2 "seuil `trust` + règle de dégradation vers `Any`": + /// an entry whose own `#! tier:` is below the project's `trust` loads as + /// `(Any, UnknownFunction)` instead of its declared signature, while an + /// entry at or above `trust` keeps it — same source, same call, only the + /// tier differs. + #[test] + fn entries_below_trust_degrade_to_any_entries_at_or_above_keep_their_signature() { + let source = "\ +#! tier: T3 +@untrusted_fn: (int) -> int; + +#! tier: T1 +@trusted_fn: (int) -> int;"; + + let (context, skipped) = load_external_ty_definitions(Context::default(), &[("mixed.ty", source)], "T2", "T2"); + assert!(skipped.is_empty()); + + let untrusted_type = context + .get_type_from_variable(&Var::from_name("untrusted_fn")) + .expect("degraded entry must still be present, just untyped"); + assert!( + untrusted_type.is_unknown_function(), + "T3 entry under a T2 trust threshold must degrade to UnknownFunction, got {:?}", + untrusted_type + ); + + let trusted_type = context + .get_type_from_variable(&Var::from_name("trusted_fn")) + .expect("trusted entry must be present"); + assert!( + !trusted_type.is_unknown_function(), + "T1 entry under a T2 trust threshold must keep its declared signature, got {:?}", + trusted_type + ); + } + + /// An entry with no `#! tier:` of its own falls back to the manifest's + /// `[definition] tier` (`default_tier`) — a whole low-tier definition + /// with no per-entry annotations must degrade uniformly. + #[test] + fn entry_with_no_own_tier_falls_back_to_the_manifest_default_tier() { + let source = "@generated_fn: (int) -> int;"; + + let (context, skipped) = + load_external_ty_definitions(Context::default(), &[("generated.ty", source)], "T3", "T2"); + assert!(skipped.is_empty()); + + let typ = context + .get_type_from_variable(&Var::from_name("generated_fn")) + .expect("entry must still be present"); + assert!( + typ.is_unknown_function(), + "an entry with no #! tier must inherit the manifest's T3 default and degrade under T2 trust" + ); + } + + // -- version_less_than / degrade_if_version_out_of_range (registry.md §7.2) -- + + /// The whole reason `version_less_than` exists instead of a plain string + /// comparison: `"1.9" < "1.10"` numerically, but `"1.10" < "1.9"` + /// lexicographically. + #[test] + fn version_less_than_compares_components_numerically() { + assert!(version_less_than("1.9", "1.10")); + assert!(!version_less_than("1.10", "1.9")); + assert!(version_less_than("1.11.0", "2.0.0")); + assert!(!version_less_than("2.0.0", "1.11.0")); + } + + /// `"1.2"` and `"1.2.0"` must compare equal (neither less than the + /// other) rather than the shorter string spuriously losing to padding. + #[test] + fn version_less_than_treats_missing_trailing_components_as_zero() { + assert!(!version_less_than("1.2", "1.2.0")); + assert!(!version_less_than("1.2.0", "1.2")); + } + + /// An observed version below the definition's `since` floor degrades + /// every declared name to `Any` and names the floor in the reason. + #[test] + fn degrade_if_version_out_of_range_degrades_below_the_since_floor() { + let source = "@f: (int) -> int;"; + let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); + assert!(skipped.is_empty()); + + let (context, reason) = + degrade_if_version_out_of_range(context, &[("pkg.ty", source)], "1.11.0", None, Some("1.9.0")); + + let reason = reason.expect("an observed version below `since` must degrade"); + assert!( + reason.contains("older") && reason.contains("1.11.0"), + "unexpected reason: {reason}" + ); + let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); + assert!(typ.is_unknown_function()); + } + + /// An observed version above the definition's `until` ceiling degrades + /// every declared name to `Any` and names the ceiling in the reason. + #[test] + fn degrade_if_version_out_of_range_degrades_above_the_until_ceiling() { + let source = "@f: (int) -> int;"; + let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); + assert!(skipped.is_empty()); + + let (context, reason) = + degrade_if_version_out_of_range(context, &[("pkg.ty", source)], "1.0.0", Some("1.5.0"), Some("2.0.0")); + + let reason = reason.expect("an observed version above `until` must degrade"); + assert!( + reason.contains("newer") && reason.contains("1.5.0"), + "unexpected reason: {reason}" + ); + let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); + assert!(typ.is_unknown_function()); + } + + /// An observed version inside `[since, until]` is a no-op: the declared + /// signature survives untouched. + #[test] + fn degrade_if_version_out_of_range_is_a_no_op_within_range() { + let source = "@f: (int) -> int;"; + let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); + assert!(skipped.is_empty()); + + let (context, reason) = + degrade_if_version_out_of_range(context, &[("pkg.ty", source)], "1.0.0", Some("2.0.0"), Some("1.5.0")); + + assert!(reason.is_none()); + let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); + assert!(!typ.is_unknown_function()); + } + + /// D2: a version that was never observed (`r_version_seen == None`) must + /// never be treated as out of range — only an actually-observed + /// incompatible version may trigger the degradation. + #[test] + fn degrade_if_version_out_of_range_is_a_no_op_when_version_was_never_observed() { + let source = "@f: (int) -> int;"; + let (context, skipped) = extend_context_with_ty_sources(Context::default(), &[("pkg.ty", source)]); + assert!(skipped.is_empty()); + + let (context, reason) = degrade_if_version_out_of_range(context, &[("pkg.ty", source)], "1.11.0", None, None); + + assert!(reason.is_none()); + let typ = context.get_type_from_variable(&Var::from_name("f")).unwrap(); + assert!(!typ.is_unknown_function()); + } + + /// An unrecognized tier string — on the entry or on the project's own + /// `trust` setting — must never be silently treated as trusted (D2, + /// `typR/registry.md` §0/§5.4): it always degrades. + #[test] + fn unrecognized_tier_or_trust_never_meets_the_threshold() { + assert!(!meets_trust("T1", "not-a-tier")); + assert!(!meets_trust("not-a-tier", "T3")); + } + /// Phase 1: build_stdlib_docs parses #! annotations from .ty files and /// attaches them as `meta` on function nodes in the SPG. #[test] diff --git a/crates/typr-cli/src/type_definition.rs b/crates/typr-cli/src/type_definition.rs new file mode 100644 index 00000000..2f5799fb --- /dev/null +++ b/crates/typr-cli/src/type_definition.rs @@ -0,0 +1,223 @@ +//! `typr-def.toml` — the manifest of an external Type Definition repository. +//! +//! This is the "spec du format figée" item of `typR/registry.md` §13 J2: it +//! fixes the manifest's shape and enforces the `format_version` gate, turning +//! `typr/rfcs/0031-external-type-definitions.md`'s "Definition repository +//! layout and manifest" section into a real type instead of a TOML example in +//! prose. Nothing here fetches a repository, resolves `typr.lock`, or loads a +//! definition into the type checker — those are the RFC's remaining +//! checklist items (registry.md §13 J2: loading into `standard_library.rs`, +//! the `trust` threshold, `typr types add|update|list|vendor`, `cases/`). +//! +//! Example manifest this module parses (RFC §"Definition repository layout +//! and manifest"): +//! +//! ```toml +//! format_version = 1 +//! +//! [package] +//! name = "shiny" +//! since = "1.11.0" +//! # until = "2.0.0" +//! +//! [definition] +//! version = "0.3.0" +//! tier = "T2" +//! +//! [provider] +//! type = "community" +//! repository = "github:alice/typr-shiny" +//! +//! [capabilities] +//! r_shims = false +//! extern_raw = false +//! ``` + +#![allow(dead_code)] + +use serde::Deserialize; + +/// The only `format_version` this build of typr understands. A manifest +/// declaring anything else is refused outright rather than guessed at — see +/// rfcs/0031: "`format_version` is what keeps this survivable across N +/// repositories the project does not control: when the definition format +/// changes, the compiler reads old manifests it recognizes or refuses the +/// ones it doesn't — it never silently misparses one." +pub const CURRENT_FORMAT_VERSION: i64 = 1; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DefinitionManifest { + pub format_version: i64, + pub package: PackageSection, + pub definition: DefinitionSection, + pub provider: ProviderSection, + #[serde(default)] + pub capabilities: CapabilitiesSection, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct PackageSection { + pub name: String, + /// Minimum R package version this definition was written against — a + /// floor, never a closed range (`typR/registry.md` §7.2: `supports = + /// ["1.11.x"]` would depend on the resolving machine and rot unnoticed). + pub since: String, + /// Only set when a break is *known*, never speculative. + #[serde(default)] + pub until: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct DefinitionSection { + /// semver of the definition itself, independent of the R package's own + /// version. + pub version: String, + /// Default tier (`T1`/`T2`/`T3`) for entries with no `#! tier:` of their + /// own. Kept as a free-form string rather than an enum, matching + /// `FunctionMeta::tier` — an unrecognized future tier degrades instead of + /// failing the whole manifest to parse. + pub tier: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ProviderType { + Official, + Community, + Generated, + Local, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ProviderSection { + #[serde(rename = "type")] + pub kind: ProviderType, + pub repository: String, +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)] +pub struct CapabilitiesSection { + /// Ships executable R alongside the declarations (an `R/` shim + /// directory)? + #[serde(default)] + pub r_shims: bool, + /// Uses an `extern: (...) -> T r#"...R..."#` verbatim block anywhere? + #[serde(default)] + pub extern_raw: bool, +} + +/// Parse a `typr-def.toml` manifest. +/// +/// Checks `format_version` against a raw TOML value first, before attempting +/// to interpret the rest of the document against today's schema — so an +/// unsupported version is reported as exactly that, not as a confusing +/// "missing field" error from a future schema this build does not know about. +pub fn parse_manifest(source: &str) -> Result { + let raw: toml::Value = source.parse().map_err(|e| format!("not valid TOML: {e}"))?; + let found = raw + .get("format_version") + .ok_or_else(|| "missing required field `format_version`".to_string())? + .as_integer() + .ok_or_else(|| "`format_version` must be an integer".to_string())?; + if found != CURRENT_FORMAT_VERSION { + return Err(format!( + "unsupported format_version = {found} (this build of typr understands \ + format_version = {CURRENT_FORMAT_VERSION}); update typr, or ask this \ + definition's provider to publish one this build supports" + )); + } + toml::from_str(source).map_err(|e| format!("manifest does not match format_version {CURRENT_FORMAT_VERSION}: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHINY_MANIFEST: &str = r#" +format_version = 1 + +[package] +name = "shiny" +since = "1.11.0" + +[definition] +version = "0.3.0" +tier = "T2" + +[provider] +type = "community" +repository = "github:alice/typr-shiny" + +[capabilities] +r_shims = false +extern_raw = false +"#; + + #[test] + fn parses_the_rfc_example_manifest() { + let manifest = parse_manifest(SHINY_MANIFEST).unwrap(); + assert_eq!(manifest.format_version, 1); + assert_eq!(manifest.package.name, "shiny"); + assert_eq!(manifest.package.since, "1.11.0"); + assert_eq!(manifest.package.until, None); + assert_eq!(manifest.definition.version, "0.3.0"); + assert_eq!(manifest.definition.tier, "T2"); + assert_eq!(manifest.provider.kind, ProviderType::Community); + assert_eq!(manifest.provider.repository, "github:alice/typr-shiny"); + assert!(!manifest.capabilities.r_shims); + assert!(!manifest.capabilities.extern_raw); + } + + #[test] + fn until_is_optional() { + let manifest = parse_manifest(SHINY_MANIFEST).unwrap(); + assert!(manifest.package.until.is_none()); + + let with_until = + SHINY_MANIFEST.replacen("since = \"1.11.0\"", "since = \"1.11.0\"\nuntil = \"2.0.0\"", 1); + let manifest = parse_manifest(&with_until).unwrap(); + assert_eq!(manifest.package.until.as_deref(), Some("2.0.0")); + } + + #[test] + fn capabilities_default_to_false_when_section_is_absent() { + let without_capabilities = SHINY_MANIFEST + .lines() + .filter(|l| !l.contains("[capabilities]") && !l.contains("r_shims") && !l.contains("extern_raw")) + .collect::>() + .join("\n"); + let manifest = parse_manifest(&without_capabilities).unwrap(); + assert!(!manifest.capabilities.r_shims); + assert!(!manifest.capabilities.extern_raw); + } + + #[test] + fn missing_format_version_is_refused() { + let source = SHINY_MANIFEST.replacen("format_version = 1\n", "", 1); + let err = parse_manifest(&source).unwrap_err(); + assert!(err.contains("format_version"), "unexpected error: {err}"); + } + + #[test] + fn unknown_format_version_is_refused_not_misparsed() { + let source = SHINY_MANIFEST.replacen("format_version = 1", "format_version = 2", 1); + let err = parse_manifest(&source).unwrap_err(); + assert!( + err.contains("unsupported format_version = 2"), + "unexpected error: {err}" + ); + assert!(err.contains("format_version = 1"), "unexpected error: {err}"); + } + + #[test] + fn invalid_toml_is_refused() { + let err = parse_manifest("this is not { toml").unwrap_err(); + assert!(err.contains("not valid TOML"), "unexpected error: {err}"); + } + + #[test] + fn invalid_provider_type_is_refused() { + let source = SHINY_MANIFEST.replacen("type = \"community\"", "type = \"unofficial\"", 1); + assert!(parse_manifest(&source).is_err()); + } +} diff --git a/crates/typr-cli/src/type_registry.rs b/crates/typr-cli/src/type_registry.rs new file mode 100644 index 00000000..1ba920c5 --- /dev/null +++ b/crates/typr-cli/src/type_registry.rs @@ -0,0 +1,2062 @@ +//! Resolution, `typr.lock`, on-disk cache, and vendoring for external Type +//! Definitions — the `typr types add|update|list|vendor` item of +//! `typR/registry.md` §13 J2 +//! (`rfcs/0031-external-type-definitions.md`, "Resolution, `typr.lock`, +//! cache, vendoring"). +//! +//! `resolve_locked_definitions`, near the bottom of this file, is the +//! "reading `typr.lock` at `check`/`build`/`run` time" wiring step: it turns +//! a `typr.lock` entry back into `(filename, source)` `.ty` pairs from the +//! on-disk cache, re-verifying the digest first. Loading those into the +//! type-checking context is `standard_library::load_project_type_definitions`, +//! which calls this function and then `load_external_ty_definitions`. + +use crate::type_definition::{parse_manifest, DefinitionManifest}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub const LOCKFILE_NAME: &str = "typr.lock"; +pub const PROJECT_CONFIG_NAME: &str = "typr.toml"; +const MANIFEST_NAME: &str = "typr-def.toml"; + +// --------------------------------------------------------------------- +// Repository spec +// --------------------------------------------------------------------- + +/// `github:owner/repo[/subdir...][@rev]` — the only repository scheme this +/// build understands (registry.md §6/§7: GitHub is the primary host). The +/// optional `subdir` is what lets a `packages/.json` entry point *into* +/// a monorepo like `we-data-ch/registry`'s own `definitions//` +/// (registry.md §8.2, `definitions/README.md`) instead of only at a +/// repository's root — without it, two packages indexed against the same +/// monorepo would collide on "the one `typr-def.toml` at the repo root". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepoSpec { + pub owner: String, + pub repo: String, + /// Path *within* the repository to the definition's own root (the + /// directory holding its `typr-def.toml`), when it isn't the repository + /// root itself. `/`-separated, no leading or trailing slash. + pub subdir: Option, + /// A pinned commit/branch/tag, when the user gave one after `@`. `None` + /// means "resolve `HEAD`". + pub rev: Option, +} + +impl RepoSpec { + pub fn parse(spec: &str) -> Result { + let rest = spec.strip_prefix("github:").ok_or_else(|| { + format!("unsupported repository scheme in `{spec}` — only `github:owner/repo[/subdir][@rev]` is understood") + })?; + let (path, rev) = match rest.split_once('@') { + Some((path, rev)) => (path, Some(rev.to_string())), + None => (rest, None), + }; + let mut segments = path.split('/'); + let owner = segments.next().unwrap_or(""); + let repo = segments.next().unwrap_or(""); + if owner.is_empty() || repo.is_empty() { + return Err(format!( + "`{spec}` is not `github:owner/repo[/subdir][@rev]` — missing `owner/repo`" + )); + } + let rest_segments: Vec<&str> = segments.collect(); + let subdir = if rest_segments.is_empty() { + None + } else if rest_segments.iter().any(|s| s.is_empty()) { + return Err(format!( + "`{spec}` is not `github:owner/repo[/subdir][@rev]` — empty path segment in subdir" + )); + } else { + Some(rest_segments.join("/")) + }; + Ok(RepoSpec { + owner: owner.to_string(), + repo: repo.to_string(), + subdir, + rev, + }) + } + + pub fn clone_url(&self) -> String { + format!("https://github.com/{}/{}.git", self.owner, self.repo) + } + + /// The `repository` field as it is written into `typr.toml`/`typr.lock` — + /// always without the resolved rev, since that lives in `typr.lock`'s own + /// `rev` field (registry.md §7.1: one source of truth per question). + pub fn display(&self) -> String { + match &self.subdir { + Some(sub) => format!("github:{}/{}/{}", self.owner, self.repo, sub), + None => format!("github:{}/{}", self.owner, self.repo), + } + } +} + +// --------------------------------------------------------------------- +// typr.lock +// --------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LockedDefinition { + pub package: String, + pub repository: String, + pub version: String, + pub rev: String, + pub digest: String, + pub tier: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub r_version_seen: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Lockfile { + #[serde(default, rename = "definition")] + pub definitions: Vec, +} + +impl Lockfile { + /// A missing or unparsable lockfile is simply "nothing resolved yet" — + /// never a hard error, matching the fail-open convention the rest of the + /// CLI's caches use (`r_name_cache`, `cache::BuildManifest`). + pub fn read(path: &Path) -> Self { + fs::read_to_string(path) + .ok() + .and_then(|s| toml::from_str(&s).ok()) + .unwrap_or_default() + } + + pub fn write(&self, path: &Path) -> Result<(), String> { + let rendered = + toml::to_string_pretty(self).map_err(|e| format!("could not serialize {}: {e}", path.display()))?; + let header = "# typr.lock — generated by `typr types`, commit this file.\n\ + # See typR/registry.md §7.1 and rfcs/0031-external-type-definitions.md.\n\n"; + fs::write(path, format!("{header}{rendered}")).map_err(|e| format!("could not write {}: {e}", path.display())) + } + + pub fn find(&self, package: &str) -> Option<&LockedDefinition> { + self.definitions.iter().find(|d| d.package == package) + } + + /// Replace any existing entry for the same package (a package resolves to + /// exactly one definition, registry.md §8.3) and insert the new one. + pub fn upsert(&mut self, def: LockedDefinition) { + self.definitions.retain(|d| d.package != def.package); + self.definitions.push(def); + } +} + +// --------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------- + +/// `~/.cache/typr/types/` (or `$XDG_CACHE_HOME`/`$LOCALAPPDATA`), mirroring +/// `r_deps::cache_path`'s resolution rules. +pub fn cache_root() -> Option { + crate::r_deps::cache_home().map(|dir| dir.join("typr").join("types")) +} + +/// `~/.cache/typr/types///` — the RFC's +/// `~/.cache/typr/types///` (registry.md §7.4). +pub fn cache_dir_for(pkg: &str, digest: &str) -> Option { + cache_root().map(|root| root.join(pkg).join(digest_dirname(digest))) +} + +fn digest_dirname(digest: &str) -> &str { + digest.strip_prefix("sha256:").unwrap_or(digest) +} + +// --------------------------------------------------------------------- +// Fetch +// --------------------------------------------------------------------- + +/// A definition repository fetched to a local directory, before it is +/// admitted into the cache. Kept separate from `LockedDefinition` because +/// capability-gating (below) happens on these raw files, before anything is +/// written to `typr.lock`. +pub struct FetchedDefinition { + pub manifest: DefinitionManifest, + pub rev: String, + pub digest: String, + /// The definition's own root — where its `typr-def.toml` lives. Equal to + /// `root` unless `spec.subdir` was set, in which case it is `root` joined + /// with that subdir. This is what `tracked_files`/`compute_digest`/ + /// `admit_to_cache` walk, so a monorepo definition's digest and cache + /// copy only ever cover its own files, never its siblings. + pub dir: PathBuf, + /// The full clone — what must be removed to clean up the temp checkout + /// (`dir` alone isn't enough when it's a nested subdirectory of a + /// monorepo clone). + pub root: PathBuf, +} + +/// `pub(crate)`: also used by `registry_revalidate.rs` to fail open the same +/// way `lookup_in_registry`/`search` already do when `git` isn't on `PATH`. +pub(crate) fn git_available() -> bool { + Command::new("git") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Clone `url` (at `rev` when given, else the default branch's HEAD) into a +/// fresh temp directory and return it plus the resolved commit hash. +/// +/// Shells out to the `git` binary — the same pattern `gen_types.rs` uses for +/// `Rscript` — rather than adding a git-in-process dependency for a command +/// that already needs network access. +fn clone_repo(url: &str, rev: Option<&str>) -> Result<(PathBuf, String), String> { + // The counter keeps two clones started in the same millisecond (parallel + // tests, or a batch update) from sharing — and corrupting — one directory. + static CLONE_SEQ: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let seq = CLONE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "typr_types_fetch_{}_{}_{}", + std::process::id(), + now_millis(), + seq + )); + fs::create_dir_all(&dir).map_err(|e| format!("could not create temp dir: {e}"))?; + + let clone_status = Command::new("git") + .args(["clone", "--quiet"]) + .args(if rev.is_none() { vec!["--depth", "1"] } else { vec![] }) + .arg(url) + .arg(&dir) + .output() + .map_err(|e| format!("could not run `git clone`: {e}"))?; + if !clone_status.status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(format!( + "`git clone {url}` failed: {}", + String::from_utf8_lossy(&clone_status.stderr).trim() + )); + } + + if let Some(rev) = rev { + let checkout = Command::new("git") + .args(["-C"]) + .arg(&dir) + .args(["checkout", "--quiet", rev]) + .output() + .map_err(|e| format!("could not run `git checkout`: {e}"))?; + if !checkout.status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(format!( + "`git checkout {rev}` failed: {}", + String::from_utf8_lossy(&checkout.stderr).trim() + )); + } + } + + let head = Command::new("git") + .args(["-C"]) + .arg(&dir) + .args(["rev-parse", "HEAD"]) + .output() + .map_err(|e| format!("could not run `git rev-parse HEAD`: {e}"))?; + if !head.status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(format!( + "`git rev-parse HEAD` failed: {}", + String::from_utf8_lossy(&head.stderr).trim() + )); + } + let rev = String::from_utf8_lossy(&head.stdout).trim().to_string(); + + let git_dir = dir.join(".git"); + let _ = fs::remove_dir_all(&git_dir); + + Ok((dir, rev)) +} + +fn now_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +/// Every file under `dir` (relative paths, `/`-separated, sorted), skipping +/// `.git` — the exact set of bytes the content digest and the cache/vendor +/// copies are built from. `pub(crate)` so `registry_validate.rs` can walk a +/// freshly fetched definition the same way `resolve_one_locked_definition` +/// walks a cached one, rather than re-implementing directory traversal. +pub(crate) fn tracked_files(dir: &Path) -> Result, String> { + let mut out = BTreeSet::new(); + collect_files(dir, dir, &mut out)?; + Ok(out.into_iter().collect()) +} + +fn collect_files(root: &Path, current: &Path, out: &mut BTreeSet) -> Result<(), String> { + let entries = fs::read_dir(current).map_err(|e| format!("could not read {}: {e}", current.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("could not read entry in {}: {e}", current.display()))?; + let path = entry.path(); + if path.file_name().and_then(|n| n.to_str()) == Some(".git") { + continue; + } + let file_type = entry + .file_type() + .map_err(|e| format!("could not stat {}: {e}", path.display()))?; + if file_type.is_dir() { + collect_files(root, &path, out)?; + } else if file_type.is_file() { + let rel = path + .strip_prefix(root) + .map_err(|e| format!("could not relativize {}: {e}", path.display()))?; + out.insert(rel.to_path_buf()); + } + } + Ok(()) +} + +/// A deterministic content digest over every tracked file's path and bytes — +/// what `typr.lock`'s `digest` field pins, and what `typr check`/`build`/`run` +/// will re-verify a cached copy against before trusting it (registry.md +/// principle 5). +fn compute_digest(dir: &Path) -> Result { + let files = tracked_files(dir)?; + let mut hasher = Sha256::new(); + for rel in &files { + let content = fs::read(dir.join(rel)).map_err(|e| format!("could not read {}: {e}", rel.display()))?; + hasher.update(rel.to_string_lossy().replace('\\', "/").as_bytes()); + hasher.update([0u8]); + hasher.update((content.len() as u64).to_le_bytes()); + hasher.update(&content); + } + Ok(format!("sha256:{:x}", hasher.finalize())) +} + +/// Does `dir` ship an `R/` shim directory with at least one file in it? +fn has_r_shims(dir: &Path) -> bool { + let r_dir = dir.join("R"); + r_dir.is_dir() + && fs::read_dir(&r_dir) + .map(|mut entries| entries.any(|e| e.map(|e| e.path().is_file()).unwrap_or(false))) + .unwrap_or(false) +} + +/// Does any `.ty` file under `dir` use a raw `extern: (...) -> T r#"...R..."#` +/// verbatim block? Detected by the literal `r#"` token, the only place that +/// sequence appears in TypR source (`typR/ai_context/tuto_external_packages.md` +/// §"Niveau 3"). +fn has_extern_raw(dir: &Path) -> Result { + for rel in tracked_files(dir)? { + if rel.extension().and_then(|e| e.to_str()) != Some("ty") { + continue; + } + let content = fs::read_to_string(dir.join(&rel)).unwrap_or_default(); + if content.contains("r#\"") { + return Ok(true); + } + } + Ok(false) +} + +/// Enforce the manifest's `[capabilities]` gate against what was actually +/// fetched (rfcs/0031-external-type-definitions.md, "Capabilities and R +/// shims"): a definition that leaves `r_shims`/`extern_raw` at their default +/// `false` but ships either anyway is rejected outright, before a single +/// `.ty` file is loaded anywhere. A definition that truthfully declares +/// `true` is let through with a warning message for the caller to print. +fn check_capabilities(manifest: &DefinitionManifest, dir: &Path) -> Result, String> { + let mut warnings = Vec::new(); + let ships_r_shims = has_r_shims(dir); + let ships_extern_raw = has_extern_raw(dir)?; + + if ships_r_shims && !manifest.capabilities.r_shims { + return Err( + "rejected: this definition ships an `R/` shim directory but its manifest declares \ + `capabilities.r_shims = false` — undeclared executable R is refused at fetch time \ + (typR/registry.md §5.5)" + .to_string(), + ); + } + if ships_extern_raw && !manifest.capabilities.extern_raw { + return Err( + "rejected: this definition uses a raw `extern: (...) -> T r#\"...R...\"#` block but its \ + manifest declares `capabilities.extern_raw = false` — undeclared executable R is \ + refused at fetch time (typR/registry.md §5.5)" + .to_string(), + ); + } + if manifest.capabilities.r_shims { + warnings.push( + "this definition declares `capabilities.r_shims = true` — it ships executable R that \ + will run in this project's process (typR/registry.md §5.5)." + .to_string(), + ); + } + if manifest.capabilities.extern_raw { + warnings.push( + "this definition declares `capabilities.extern_raw = true` — it uses raw R blocks that \ + will run in this project's process (typR/registry.md §5.5)." + .to_string(), + ); + } + Ok(warnings) +} + +/// Fetch `spec`, validate its manifest and capabilities, and compute its +/// content digest — the shared core of `add` and `update`. Leaves the +/// fetched files at `FetchedDefinition::dir` for the caller to admit into the +/// cache (or discard, on any failure). +pub fn fetch(spec: &RepoSpec) -> Result<(FetchedDefinition, Vec), String> { + if !git_available() { + return Err( + "`git` is not on PATH — `typr types add`/`update` need it to fetch a definition repository".to_string(), + ); + } + let (root, rev) = clone_repo(&spec.clone_url(), spec.rev.as_deref())?; + + let result = (|| { + let dir = match &spec.subdir { + Some(sub) => root.join(sub), + None => root.clone(), + }; + let manifest_path = dir.join(MANIFEST_NAME); + let manifest_source = fs::read_to_string(&manifest_path).map_err(|_| match &spec.subdir { + Some(sub) => format!("no `{MANIFEST_NAME}` at `{sub}` in {}", spec.display()), + None => format!("no `{MANIFEST_NAME}` at the root of {}", spec.display()), + })?; + let manifest = parse_manifest(&manifest_source)?; + let warnings = check_capabilities(&manifest, &dir)?; + let digest = compute_digest(&dir)?; + Ok(( + FetchedDefinition { + manifest, + rev, + digest, + dir, + root: root.clone(), + }, + warnings, + )) + })(); + + if result.is_err() { + let _ = fs::remove_dir_all(&root); + } + result +} + +/// Best-effort: the version of `package` actually installed on this +/// machine, via the same `Rscript`-based introspection `typr gen-types` +/// already uses (`gen_types::introspect`, which itself parses the `P` line +/// of `introspect_pkg.R`'s output). This is the "version réellement +/// observée" registry.md §7.2 says `typr.lock`'s `r_version_seen` records. +/// +/// Fail-open (`None`) when `Rscript` is not on PATH or the package is not +/// installed locally: `typr types add`/`update` must still succeed without R +/// present (same contract as the rest of this module's git-only fetch path) +/// — it just leaves nothing for the version-floor check at `check`/`build`/ +/// `run` time to compare against later, per `degrade_if_version_out_of_range`'s +/// own "no comparison is made... when `r_version_seen` is `None`" rule. +fn observed_r_package_version(package: &str) -> Option { + crate::gen_types::introspect(package) + .ok() + .and_then(|info| info.pkg_version) +} + +/// Copy every tracked file of a fetched definition into the on-disk cache at +/// `~/.cache/typr/types///`, replacing whatever was there before +/// (the digest already identifies the content, so an existing directory with +/// the same digest is assumed identical — a corrupted one is exactly what +/// re-running `typr types add`/`update` is for). +fn admit_to_cache(fetched: &FetchedDefinition, pkg: &str) -> Result { + let cache_dir = cache_dir_for(pkg, &fetched.digest) + .ok_or_else(|| "could not determine a cache directory (no $HOME/$XDG_CACHE_HOME)".to_string())?; + let _ = fs::remove_dir_all(&cache_dir); + fs::create_dir_all(&cache_dir).map_err(|e| format!("could not create {}: {e}", cache_dir.display()))?; + for rel in tracked_files(&fetched.dir)? { + let from = fetched.dir.join(&rel); + let to = cache_dir.join(&rel); + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + fs::copy(&from, &to).map_err(|e| format!("could not copy {} to {}: {e}", from.display(), to.display()))?; + } + Ok(cache_dir) +} + +// --------------------------------------------------------------------- +// typr.toml [types] +// --------------------------------------------------------------------- + +/// The subset of a project's `typr.toml` this module reads: the `[types]` +/// table (registry.md §7.1 — never a dependency list, only trust + +/// package→definition pins). +/// +/// `trust` feeds `standard_library::load_project_type_definitions` — the +/// project's threshold below which an external definition's entries degrade +/// to `Any` (registry.md §5.4). A project with no `typr.toml`, or none with +/// a `[types] trust`, defaults to `"T2"` there — the same default the +/// manifest example in registry.md §5.4 documents. +#[derive(Debug, Clone, Default)] +pub struct TypesConfig { + pub trust: Option, + /// package name → explicit `github:owner/repo[@rev]` pin. + pub pins: std::collections::BTreeMap, +} + +impl TypesConfig { + pub fn read(project_root: &Path) -> Self { + let Ok(source) = fs::read_to_string(project_root.join(PROJECT_CONFIG_NAME)) else { + return Self::default(); + }; + let Ok(value) = source.parse::() else { + return Self::default(); + }; + let Some(types) = value.get("types").and_then(|t| t.as_table()) else { + return Self::default(); + }; + let trust = types.get("trust").and_then(|v| v.as_str()).map(str::to_string); + let pins = types + .iter() + .filter(|(k, _)| k.as_str() != "trust") + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect(); + TypesConfig { trust, pins } + } +} + +// --------------------------------------------------------------------- +// Registry lookup (registry.md §13 J3 — "résolution par le registre dans +// `typr add`, `typr types update`") +// --------------------------------------------------------------------- + +/// The one community registry this build knows how to query +/// (`we-data-ch/registry`, registry.md §8.1) — same one-host restriction as +/// `RepoSpec` only understanding `github:`. +const REGISTRY_REPO_URL: &str = "https://github.com/we-data-ch/registry.git"; + +/// Deserializes one entry of a `packages/.json` `definitions` array +/// (registry.md §8.1). Only the fields the selection logic below needs; +/// anything else in the file (e.g. `capabilities`) is not read here — the +/// real capability gate is enforced later, on the fetched repository itself, +/// by `check_capabilities`. +#[derive(Debug, Clone, Deserialize)] +struct RegistryDefinitionEntry { + /// `owner/repo` or `owner/repo/subdir...`, no `github:` scheme + /// (registry.md §8.1's example; the optional trailing segments are the + /// monorepo path of §8.2, e.g. `we-data-ch/registry/definitions/dplyr`). + repository: String, + #[serde(default)] + rev: Option, + /// `official` | `community` | `generated` | `local`. + #[serde(default)] + source: String, + /// `T1` | `T2` | `T3`. + #[serde(default)] + tier: String, +} + +#[derive(Debug, Clone, Deserialize, Default)] +struct RegistryPackageFile { + #[serde(default)] + definitions: Vec, +} + +/// registry.md §8.3's conflict order, restricted to what a registry file can +/// express — an explicit user pin and a locally generated definition are +/// resolved before this is ever consulted (`resolve_spec_for_package`, +/// below): official first, then community, then anything else. An +/// unrecognized `source` string ranks last rather than erroring — same D2 +/// instinct as `standard_library::tier_rank` for an unreadable tier: a signal +/// this build cannot read must never be trusted more than one it can. +fn provider_rank(source: &str) -> u8 { + match source { + "official" => 3, + "community" => 2, + "generated" => 1, + _ => 0, + } +} + +/// Same ranking as `standard_library::tier_rank`, duplicated locally rather +/// than shared: that function ranks a *loaded* entry's trust for the +/// degrade-to-`Any` decision, this one ranks *candidates* before anything is +/// fetched — different callers, same T1 > T2 > T3 intuition. +fn tier_rank_for_selection(tier: &str) -> u8 { + match tier { + "T1" => 3, + "T2" => 2, + "T3" => 1, + _ => 0, + } +} + +/// Highest-ranked entry in `file.definitions`, ties broken by whichever comes +/// first in the file (no editorial ordering beyond tier/provenance — Q3, +/// registry.md §14, is still open). `None` when the file lists nothing. +fn pick_best_entry(file: &RegistryPackageFile) -> Option<&RegistryDefinitionEntry> { + let mut best: Option<&RegistryDefinitionEntry> = None; + let mut best_rank = (0u8, 0u8); + for entry in &file.definitions { + let rank = (provider_rank(&entry.source), tier_rank_for_selection(&entry.tier)); + if best.is_none() || rank > best_rank { + best = Some(entry); + best_rank = rank; + } + } + best +} + +/// `~/.cache/typr/registry-index/` — a local mirror of `we-data-ch/registry`, +/// refreshed in place rather than re-cloned on every lookup. +fn registry_index_dir() -> Option { + crate::r_deps::cache_home().map(|dir| dir.join("typr").join("registry-index")) +} + +/// Clone (first use) or fast-forward `git pull` (subsequent uses) the local +/// registry mirror, returning its path. +/// +/// `pub(crate)`: `registry_revalidate.rs` calls this directly when `typr +/// types revalidate` isn't given an explicit `--dir` (e.g. a CI job that has +/// already checked out `we-data-ch/registry` itself and wants to validate +/// that working tree instead of a fresh mirror). +pub(crate) fn sync_registry_index() -> Result { + let dir = registry_index_dir() + .ok_or_else(|| "could not determine a cache directory (no $HOME/$XDG_CACHE_HOME)".to_string())?; + + if dir.join(".git").is_dir() { + let pull = Command::new("git") + .arg("-C") + .arg(&dir) + .args(["pull", "--quiet", "--ff-only"]) + .output() + .map_err(|e| format!("could not run `git pull`: {e}"))?; + if !pull.status.success() { + return Err(format!( + "`git pull` in {} failed: {}", + dir.display(), + String::from_utf8_lossy(&pull.stderr).trim() + )); + } + return Ok(dir); + } + + let _ = fs::remove_dir_all(&dir); + if let Some(parent) = dir.parent() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + let clone = Command::new("git") + .args(["clone", "--quiet", "--depth", "1"]) + .arg(REGISTRY_REPO_URL) + .arg(&dir) + .output() + .map_err(|e| format!("could not run `git clone`: {e}"))?; + if !clone.status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(format!( + "`git clone {REGISTRY_REPO_URL}` failed: {}", + String::from_utf8_lossy(&clone.stderr).trim() + )); + } + Ok(dir) +} + +/// Look up `packages/.json` in `we-data-ch/registry` and, when it +/// lists at least one usable entry, return a `github:owner/repo[@rev]` spec +/// string for the best one (`pick_best_entry`) — ready to hand straight to +/// `add`/`fetch`, exactly like a spec the user typed by hand. +/// +/// Fails open at every step: no `git`, an unreachable registry, no entry for +/// `package`, or an entry whose `repository` field is not `owner/repo` all +/// resolve to `None`, never an error the caller must special-case — "the +/// registry has nothing to say" is exactly as safe as "no definition at all" +/// (D2, registry.md §0/§5.4: an absent or unusable signal never blocks +/// anything, it just leaves the package untyped). +pub fn lookup_in_registry(package: &str) -> Option { + if !git_available() { + return None; + } + let dir = match sync_registry_index() { + Ok(dir) => dir, + Err(e) => { + eprintln!("warning: could not reach the registry ({e}) — skipping automatic type-definition lookup for `{package}`"); + return None; + } + }; + lookup_in_registry_dir(&dir, package) +} + +/// The JSON-reading and entry-selection half of `lookup_in_registry`, kept +/// separate from syncing the index so it can be unit-tested against a +/// hand-written `packages/` directory instead of a real clone of +/// `we-data-ch/registry`. +fn lookup_in_registry_dir(dir: &Path, package: &str) -> Option { + let path = dir.join("packages").join(format!("{package}.json")); + let source = fs::read_to_string(&path).ok()?; + let file: RegistryPackageFile = serde_json::from_str(&source).ok()?; + let entry = pick_best_entry(&file)?; + entry_spec(entry) +} + +/// `entry.repository`/`entry.rev` as a `github:owner/repo[/subdir][@rev]` +/// spec string, ready for `fetch`/`RepoSpec::parse` — `None` when +/// `repository` isn't at least `owner/repo` (extra `/`-separated segments +/// past the second are the monorepo subdir, registry.md §8.2 — e.g. +/// `we-data-ch/registry/definitions/dplyr`). Shared by +/// `lookup_in_registry_dir` (the single best entry) and +/// `list_registry_targets` (every entry). +fn entry_spec(entry: &RegistryDefinitionEntry) -> Option { + let segment_count = entry.repository.split('/').filter(|s| !s.is_empty()).count(); + if segment_count < 2 || entry.repository.contains("//") { + return None; + } + Some(match entry.rev.as_deref() { + Some(rev) if !rev.is_empty() => format!("github:{}@{}", entry.repository, rev), + _ => format!("github:{}", entry.repository), + }) +} + +/// One definition entry from `packages/.json`, resolved to a spec string +/// — `typr types revalidate`'s unit of work (registry.md §13 J4, "revalidation +/// périodique des définitions déjà indexées"). Unlike `lookup_in_registry` +/// (the single winner `typr types add` would pick) or `search` (every entry, +/// but not resolved to a fetchable spec), this is *every* entry across *every* +/// package file, each already a `github:owner/repo[@rev]` string ready to feed +/// straight to `registry_validate::validate`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistryTarget { + pub package: String, + pub spec: String, +} + +/// Walk `dir/packages/*.json` and resolve every listed definition entry to a +/// `RegistryTarget`. Sorted by filename then file order, so two runs over an +/// unchanged registry produce results in the same order — `typr types +/// revalidate` diffs successive runs, and a stable order keeps that diff +/// about content, not happenstance directory iteration. +/// +/// Fails open like everything else that reads registry files (D2): a missing +/// `packages/` directory, an unreadable file, invalid JSON, or a malformed +/// `repository` field all just drop that entry rather than aborting the walk. +pub fn list_registry_targets(dir: &Path) -> Vec { + let packages_dir = dir.join("packages"); + let Ok(read_dir) = fs::read_dir(&packages_dir) else { + return Vec::new(); + }; + let mut files: Vec = read_dir + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json")) + .collect(); + files.sort(); + + let mut out = Vec::new(); + for path in files { + let Some(package) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let Ok(source) = fs::read_to_string(&path) else { + continue; + }; + let Ok(file) = serde_json::from_str::(&source) else { + continue; + }; + for entry in &file.definitions { + if let Some(spec) = entry_spec(entry) { + out.push(RegistryTarget { + package: package.to_string(), + spec, + }); + } + } + } + out +} + +/// One entry of `packages/.json`'s `definitions` array, as `typr +/// search` reports it — every entry, not just the winner `pick_best_entry` +/// would choose, so the user can see what `typr types add` would pick among. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchEntry { + /// `owner/repo`, no `github:` scheme (matches the registry file's own field). + pub repository: String, + pub rev: Option, + /// `official` | `community` | `generated` | `local`, or anything else the + /// registry file happens to contain — not validated here. + pub source: String, + /// `T1` | `T2` | `T3`, same caveat. + pub tier: String, +} + +/// `typr search ` — list every definition the registry has for +/// `package`, ranked best (registry.md §8.3's provider-then-tier order) +/// first, ties broken by file order exactly like `pick_best_entry`. +/// +/// An empty result means "the registry has nothing for this package", which +/// is not an error (D2's instinct: an absent signal is safe, never fatal) — +/// `Ok(vec![])` covers a missing `packages/.json`, invalid JSON, and an +/// empty `definitions` array alike. `Err` is reserved for why the registry +/// itself could not be consulted at all (no `git`, clone/pull failure), so +/// the caller can show that reason instead of silently reporting zero hits. +pub fn search(package: &str) -> Result, String> { + if !git_available() { + return Err("`git` is not installed or not on PATH — cannot reach the registry".to_string()); + } + let dir = sync_registry_index()?; + Ok(search_in_registry_dir(&dir, package)) +} + +/// The JSON-reading half of `search`, kept separate so it can be unit-tested +/// against a hand-written `packages/` directory instead of a real clone — +/// same split as `lookup_in_registry`/`lookup_in_registry_dir`. +fn search_in_registry_dir(dir: &Path, package: &str) -> Vec { + let path = dir.join("packages").join(format!("{package}.json")); + let Ok(source) = fs::read_to_string(&path) else { + return Vec::new(); + }; + let Ok(file) = serde_json::from_str::(&source) else { + return Vec::new(); + }; + let mut entries: Vec = file + .definitions + .iter() + .map(|e| SearchEntry { + repository: e.repository.clone(), + rev: e.rev.clone(), + source: e.source.clone(), + tier: e.tier.clone(), + }) + .collect(); + entries.sort_by(|a, b| { + let rank_a = (provider_rank(&a.source), tier_rank_for_selection(&a.tier)); + let rank_b = (provider_rank(&b.source), tier_rank_for_selection(&b.tier)); + rank_b.cmp(&rank_a) + }); + entries +} + +/// Resolve a repository spec string for `package` when the caller has none +/// in hand yet — the two steps of registry.md §7.3's flow that precede a +/// fetch: an explicit `typr.toml [types]` pin always wins (§8.3 priority 1); +/// failing that, the registry (§8.3 priorities 2-3, as far as a registry +/// entry can distinguish them). `None` means neither had anything to say — +/// the caller decides what "nothing resolved" means for it (an error for an +/// explicit `typr types add` with no repo, a silent no-op for `typr add`'s +/// best-effort lookup). +pub fn resolve_spec_for_package(project_root: &Path, package: &str) -> Option { + let config = TypesConfig::read(project_root); + if let Some(pin) = config.pins.get(package) { + return Some(pin.clone()); + } + lookup_in_registry(package) +} + +// --------------------------------------------------------------------- +// Commands: add / update / list / vendor +// --------------------------------------------------------------------- + +/// `typr types add ` — resolve `spec` for `package`, fetch it, verify +/// it, cache it, and record it in `typr.lock`. +pub fn add(project_root: &Path, package: &str, spec_str: &str) -> Result { + let spec = RepoSpec::parse(spec_str)?; + let (fetched, warnings) = fetch(&spec)?; + for w in &warnings { + eprintln!("warning: {w}"); + } + admit_to_cache(&fetched, package)?; + + let locked = LockedDefinition { + package: package.to_string(), + repository: spec.display(), + version: fetched.manifest.definition.version.clone(), + rev: fetched.rev.clone(), + digest: fetched.digest.clone(), + tier: fetched.manifest.definition.tier.clone(), + r_version_seen: observed_r_package_version(package), + }; + + let lock_path = project_root.join(LOCKFILE_NAME); + let mut lockfile = Lockfile::read(&lock_path); + lockfile.upsert(locked.clone()); + lockfile.write(&lock_path)?; + + let _ = fs::remove_dir_all(&fetched.root); + Ok(locked) +} + +/// `typr types update [pkg]` — re-fetch and re-pin one (or, with `package == +/// None`, every) resolved definition. The repository spec comes from the +/// existing `typr.lock` entry; for a package not yet locked, it falls back to +/// `resolve_spec_for_package` — an explicit `typr.toml [types]` pin, or (J3) +/// the `we-data-ch/registry` — before giving up. +pub fn update(project_root: &Path, package: Option<&str>) -> Result, String> { + let lock_path = project_root.join(LOCKFILE_NAME); + let lockfile = Lockfile::read(&lock_path); + + let targets: Vec<(String, String)> = match package { + Some(pkg) => { + let spec = lockfile + .find(pkg) + .map(|d| d.repository.clone()) + .or_else(|| resolve_spec_for_package(project_root, pkg)) + .ok_or_else(|| { + format!( + "no resolved, pinned, or registry-listed definition for `{pkg}` — use \ + `typr types add {pkg} github:owner/repo` with an explicit repository" + ) + })?; + vec![(pkg.to_string(), spec)] + } + None => lockfile + .definitions + .iter() + .map(|d| (d.package.clone(), d.repository.clone())) + .collect(), + }; + + if targets.is_empty() { + return Ok(Vec::new()); + } + + let mut updated = Vec::new(); + for (pkg, spec) in targets { + updated.push(add(project_root, &pkg, &spec)?); + } + Ok(updated) +} + +/// `typr types list` — everything `typr.lock` currently resolves. +pub fn list(project_root: &Path) -> Vec { + Lockfile::read(&project_root.join(LOCKFILE_NAME)).definitions +} + +/// `typr types vendor [--out DIR]` — copy every resolved definition's cached +/// `.ty` files into the project tree (default `ty/vendor//`), so the +/// build stops depending on the network or the upstream repository's +/// continued existence (registry.md §7.4, rfcs/0031 "vendor"). +pub fn vendor(project_root: &Path, out_dir: Option<&Path>) -> Result, String> { + let lockfile = Lockfile::read(&project_root.join(LOCKFILE_NAME)); + if lockfile.definitions.is_empty() { + return Err("nothing resolved yet — `typr types add` a definition before vendoring".to_string()); + } + let base = out_dir + .map(|p| project_root.join(p)) + .unwrap_or_else(|| project_root.join("ty").join("vendor")); + + let mut written = Vec::new(); + for def in &lockfile.definitions { + let cache_dir = cache_dir_for(&def.package, &def.digest) + .ok_or_else(|| "could not determine a cache directory (no $HOME/$XDG_CACHE_HOME)".to_string())?; + if !cache_dir.is_dir() { + return Err(format!( + "`{}` is not in the local cache at {} — run `typr types update {}` first", + def.package, + cache_dir.display(), + def.package + )); + } + let actual_digest = compute_digest(&cache_dir)?; + if actual_digest != def.digest { + return Err(format!( + "digest mismatch for `{}`: typr.lock says {} but the cached copy hashes to {} — \ + the cache is corrupted; run `typr types update {}` to re-fetch", + def.package, def.digest, actual_digest, def.package + )); + } + + let dest = base.join(&def.package); + let _ = fs::remove_dir_all(&dest); + for rel in tracked_files(&cache_dir)? { + let from = cache_dir.join(&rel); + let to = dest.join(&rel); + if let Some(parent) = to.parent() { + fs::create_dir_all(parent).map_err(|e| format!("could not create {}: {e}", parent.display()))?; + } + fs::copy(&from, &to).map_err(|e| format!("could not copy {} to {}: {e}", from.display(), to.display()))?; + written.push(to); + } + } + Ok(written) +} + +// --------------------------------------------------------------------- +// Wiring into the type-checking context +// --------------------------------------------------------------------- + +/// A locked definition's `.ty` sources plus the facts +/// `standard_library::load_external_ty_definitions` and +/// `standard_library::degrade_if_version_out_of_range` need — the return +/// shape of `resolve_locked_definitions`, below. +pub struct ResolvedDefinition { + pub package: String, + /// `(relative path, file content)` for every `.ty` file the cached + /// definition ships, in the sorted order `tracked_files` already + /// produces (deterministic — later files in the same repository can + /// reference types declared by earlier ones, exactly like the bundled + /// stdlib's own `R_T1_SOURCES`). + pub ty_sources: Vec<(String, String)>, + /// The manifest's `[definition] tier` — the tier an entry with no + /// `#! tier:` of its own falls back to. + pub default_tier: String, + pub since: String, + pub until: Option, + /// `typr.lock`'s own `r_version_seen`, frozen at the last `typr types + /// add`/`update` — `None` when it was never observed (R unavailable at + /// resolution time). + pub r_version_seen: Option, +} + +/// Read `typr.lock` and, for every locked definition whose cached copy is +/// present and still matches its pinned digest, collect its `.ty` sources — +/// the "reading `typr.lock` at `check`/`build`/`run` time" step this +/// module's doc comment names. A locked package whose cache is missing, +/// stale, or corrupted is skipped, with a message for the caller to print as +/// a warning, rather than failing the build: the same fail-open contract +/// `vendor()` already applies, and D2 ("an unreliable signal only ever +/// removes checking, it never breaks a build", registry.md §0/§5.4) taken to +/// its logical end — a package `typr.lock` cannot currently resolve simply +/// falls back to being untyped R, exactly as if it had never been added. +pub fn resolve_locked_definitions(project_root: &Path) -> (Vec, Vec) { + let lockfile = Lockfile::read(&project_root.join(LOCKFILE_NAME)); + let mut resolved = Vec::new(); + let mut warnings = Vec::new(); + for def in &lockfile.definitions { + match resolve_one_locked_definition(def) { + Ok(r) => resolved.push(r), + Err(w) => warnings.push(w), + } + } + (resolved, warnings) +} + +fn resolve_one_locked_definition(def: &LockedDefinition) -> Result { + let cache_dir = cache_dir_for(&def.package, &def.digest).ok_or_else(|| { + format!( + "`{}`: no cache directory available (no $HOME/$XDG_CACHE_HOME) — its declared types are unavailable this run, names stay untyped", + def.package + ) + })?; + if !cache_dir.is_dir() { + return Err(format!( + "`{}`: not in the local cache at {} — run `typr types update {}`; its declared types are unavailable this run, names stay untyped", + def.package, + cache_dir.display(), + def.package + )); + } + let actual_digest = compute_digest(&cache_dir).map_err(|e| format!("`{}`: {e}", def.package))?; + if actual_digest != def.digest { + return Err(format!( + "`{}`: cached copy no longer matches typr.lock (expected {}, found {}) — run `typr types update {}`; \ + its declared types are unavailable this run, names stay untyped", + def.package, def.digest, actual_digest, def.package + )); + } + + let manifest_source = fs::read_to_string(cache_dir.join(MANIFEST_NAME)) + .map_err(|e| format!("`{}`: could not read {MANIFEST_NAME} from its cache: {e}", def.package))?; + let manifest = parse_manifest(&manifest_source).map_err(|e| format!("`{}`: {e}", def.package))?; + + let mut ty_sources = Vec::new(); + for rel in tracked_files(&cache_dir).map_err(|e| format!("`{}`: {e}", def.package))? { + if rel.extension().and_then(|e| e.to_str()) != Some("ty") { + continue; + } + let content = fs::read_to_string(cache_dir.join(&rel)) + .map_err(|e| format!("`{}`: could not read {}: {e}", def.package, rel.display()))?; + ty_sources.push((rel.to_string_lossy().replace('\\', "/"), content)); + } + + Ok(ResolvedDefinition { + package: def.package.clone(), + ty_sources, + default_tier: manifest.definition.tier, + since: manifest.package.since, + until: manifest.package.until, + r_version_seen: def.r_version_seen.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // -- RepoSpec ---------------------------------------------------- + + #[test] + fn parses_owner_repo_without_rev() { + let spec = RepoSpec::parse("github:alice/typr-shiny").unwrap(); + assert_eq!(spec.owner, "alice"); + assert_eq!(spec.repo, "typr-shiny"); + assert_eq!(spec.rev, None); + assert_eq!(spec.clone_url(), "https://github.com/alice/typr-shiny.git"); + assert_eq!(spec.display(), "github:alice/typr-shiny"); + } + + #[test] + fn parses_owner_repo_with_rev() { + let spec = RepoSpec::parse("github:alice/typr-shiny@a1b2c3d").unwrap(); + assert_eq!(spec.rev.as_deref(), Some("a1b2c3d")); + // the rev never leaks into the persisted `repository` string — + // typr.lock's own `rev` field is the one source of truth for it. + assert_eq!(spec.display(), "github:alice/typr-shiny"); + } + + #[test] + fn rejects_unsupported_scheme() { + let err = RepoSpec::parse("gitlab:alice/typr-shiny").unwrap_err(); + assert!(err.contains("github:"), "unexpected error: {err}"); + } + + #[test] + fn rejects_missing_owner_or_repo() { + assert!(RepoSpec::parse("github:typr-shiny").is_err()); + assert!(RepoSpec::parse("github:/typr-shiny").is_err()); + assert!(RepoSpec::parse("github:alice/").is_err()); + } + + #[test] + fn parses_a_single_segment_subdir() { + let spec = RepoSpec::parse("github:we-data-ch/registry/definitions/dplyr").unwrap(); + assert_eq!(spec.owner, "we-data-ch"); + assert_eq!(spec.repo, "registry"); + assert_eq!(spec.subdir.as_deref(), Some("definitions/dplyr")); + assert_eq!(spec.rev, None); + assert_eq!(spec.display(), "github:we-data-ch/registry/definitions/dplyr"); + } + + #[test] + fn parses_a_multi_segment_subdir_with_rev() { + let spec = RepoSpec::parse("github:we-data-ch/registry/definitions/dplyr/ty@abc123").unwrap(); + assert_eq!(spec.subdir.as_deref(), Some("definitions/dplyr/ty")); + assert_eq!(spec.rev.as_deref(), Some("abc123")); + // the rev never leaks into `display()`, same contract as the no-subdir case. + assert_eq!(spec.display(), "github:we-data-ch/registry/definitions/dplyr/ty"); + } + + #[test] + fn no_subdir_round_trips_to_none() { + let spec = RepoSpec::parse("github:alice/typr-shiny").unwrap(); + assert_eq!(spec.subdir, None); + } + + #[test] + fn rejects_empty_subdir_segment() { + assert!(RepoSpec::parse("github:alice/typr-shiny//").is_err()); + } + + // -- Lockfile ------------------------------------------------------ + + fn sample_locked(package: &str) -> LockedDefinition { + LockedDefinition { + package: package.to_string(), + repository: "github:alice/typr-shiny".to_string(), + version: "0.3.0".to_string(), + rev: "a1b2c3d4e5f6".to_string(), + digest: "sha256:deadbeef".to_string(), + tier: "T2".to_string(), + r_version_seen: Some("1.11.1".to_string()), + } + } + + #[test] + fn lockfile_round_trips_through_toml() { + let mut lockfile = Lockfile::default(); + lockfile.upsert(sample_locked("shiny")); + lockfile.upsert(sample_locked("dplyr")); + + let dir = std::env::temp_dir().join(format!("typr_lockfile_test_{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join(LOCKFILE_NAME); + lockfile.write(&path).unwrap(); + + let read_back = Lockfile::read(&path); + assert_eq!(read_back.definitions.len(), 2); + assert_eq!(read_back.find("shiny"), lockfile.find("shiny")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn upsert_replaces_existing_entry_for_same_package() { + let mut lockfile = Lockfile::default(); + lockfile.upsert(sample_locked("shiny")); + let mut updated = sample_locked("shiny"); + updated.digest = "sha256:newdigest".to_string(); + lockfile.upsert(updated); + + assert_eq!(lockfile.definitions.len(), 1); + assert_eq!(lockfile.find("shiny").unwrap().digest, "sha256:newdigest"); + } + + #[test] + fn missing_lockfile_reads_as_empty() { + let path = std::env::temp_dir().join("typr_lockfile_definitely_missing.lock"); + let _ = fs::remove_file(&path); + assert!(Lockfile::read(&path).definitions.is_empty()); + } + + // -- TypesConfig ----------------------------------------------------- + + #[test] + fn reads_trust_and_pins_from_typr_toml() { + let dir = std::env::temp_dir().join(format!("typr_types_config_test_{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join(PROJECT_CONFIG_NAME), + "[types]\ntrust = \"T2\"\nshiny = \"github:alice/typr-shiny\"\n", + ) + .unwrap(); + + let config = TypesConfig::read(&dir); + assert_eq!(config.trust.as_deref(), Some("T2")); + assert_eq!( + config.pins.get("shiny").map(String::as_str), + Some("github:alice/typr-shiny") + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn missing_typr_toml_reads_as_default() { + let dir = std::env::temp_dir().join("typr_types_config_definitely_missing"); + let _ = fs::remove_dir_all(&dir); + let config = TypesConfig::read(&dir); + assert!(config.trust.is_none()); + assert!(config.pins.is_empty()); + } + + // -- digest / capability gate ----------------------------------------- + + fn write_file(root: &Path, rel: &str, content: &str) { + let path = root.join(rel); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, content).unwrap(); + } + + #[test] + fn digest_is_deterministic_and_order_independent() { + let dir_a = std::env::temp_dir().join(format!("typr_digest_a_{}", std::process::id())); + let dir_b = std::env::temp_dir().join(format!("typr_digest_b_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir_a); + let _ = fs::remove_dir_all(&dir_b); + + write_file(&dir_a, "ty/core.ty", "@f: (int) -> int;"); + write_file(&dir_a, "typr-def.toml", "format_version = 1"); + // same content, written in the opposite order + write_file(&dir_b, "typr-def.toml", "format_version = 1"); + write_file(&dir_b, "ty/core.ty", "@f: (int) -> int;"); + + assert_eq!(compute_digest(&dir_a).unwrap(), compute_digest(&dir_b).unwrap()); + + write_file(&dir_b, "ty/core.ty", "@f: (int) -> char;"); + assert_ne!(compute_digest(&dir_a).unwrap(), compute_digest(&dir_b).unwrap()); + + let _ = fs::remove_dir_all(&dir_a); + let _ = fs::remove_dir_all(&dir_b); + } + + #[test] + fn digest_ignores_git_directory() { + let dir = std::env::temp_dir().join(format!("typr_digest_git_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "ty/core.ty", "@f: (int) -> int;"); + let before = compute_digest(&dir).unwrap(); + write_file(&dir, ".git/HEAD", "ref: refs/heads/main"); + let after = compute_digest(&dir).unwrap(); + assert_eq!(before, after); + let _ = fs::remove_dir_all(&dir); + } + + fn manifest_with(r_shims: bool, extern_raw: bool) -> DefinitionManifest { + let toml = format!( + "format_version = 1\n[package]\nname = \"shiny\"\nsince = \"1.11.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T2\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:alice/typr-shiny\"\n\ + [capabilities]\nr_shims = {r_shims}\nextern_raw = {extern_raw}\n" + ); + parse_manifest(&toml).unwrap() + } + + #[test] + fn undeclared_r_shims_are_rejected() { + let dir = std::env::temp_dir().join(format!("typr_caps_shims_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "R/shim.R", "f <- function(x) x"); + let err = check_capabilities(&manifest_with(false, false), &dir).unwrap_err(); + assert!(err.contains("r_shims"), "unexpected error: {err}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn declared_r_shims_pass_with_a_warning() { + let dir = std::env::temp_dir().join(format!("typr_caps_shims_ok_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "R/shim.R", "f <- function(x) x"); + let warnings = check_capabilities(&manifest_with(true, false), &dir).unwrap(); + assert!(warnings.iter().any(|w| w.contains("r_shims"))); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn undeclared_extern_raw_is_rejected() { + let dir = std::env::temp_dir().join(format!("typr_caps_extern_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file( + &dir, + "ty/core.ty", + "let f <- extern: (x: int) -> int r#\"\nfunction(x) x\n\"#;", + ); + let err = check_capabilities(&manifest_with(false, false), &dir).unwrap_err(); + assert!(err.contains("extern_raw"), "unexpected error: {err}"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn clean_definition_has_no_warnings() { + let dir = std::env::temp_dir().join(format!("typr_caps_clean_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_file(&dir, "ty/core.ty", "@f: (int) -> int;"); + let warnings = check_capabilities(&manifest_with(false, false), &dir).unwrap(); + assert!(warnings.is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + // -- End-to-end add/update/list/vendor against a local `file://` repo -- + + /// Build a minimal, valid definition repository under `dir` and commit it + /// with `git`, so `fetch`/`add` can clone it over a `file://` URL with no + /// network access — the same offline-friendly pattern + /// `gen_types.rs`/`r_name_cache.rs` use for their Rscript-dependent tests + /// (fail open when the external tool is missing). + fn make_definition_repo(dir: &Path) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + write_file( + dir, + MANIFEST_NAME, + "format_version = 1\n\ + [package]\nname = \"shiny\"\nsince = \"1.11.0\"\n\ + [definition]\nversion = \"0.3.0\"\ntier = \"T2\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:alice/typr-shiny\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n", + ); + write_file( + dir, + "ty/core.ty", + "#! pkg: shiny\n#! tier: T2\n@importFrom shiny fluidPage;\n@fluidPage: (Any) -> Any;\n", + ); + + let run = |args: &[&str]| -> Result<(), String> { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).into_owned()); + } + Ok(()) + }; + run(&["init", "--quiet"])?; + run(&["config", "user.email", "test@example.com"])?; + run(&["config", "user.name", "test"])?; + run(&["add", "."])?; + run(&["commit", "--quiet", "-m", "initial"])?; + Ok(()) + } + + fn file_url(dir: &Path) -> String { + format!("file://{}", dir.display()) + } + + /// Build a minimal *monorepo*-style repository under `dir`, with two + /// package definitions nested under `definitions//` (registry.md + /// §8.2's layout), and commit it — the fixture for exercising `fetch`'s + /// subdir handling the same way `make_definition_repo` exercises the + /// plain, single-package case. + fn make_monorepo(dir: &Path) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + write_file( + dir, + "definitions/dplyr/typr-def.toml", + "format_version = 1\n\ + [package]\nname = \"dplyr\"\nsince = \"1.1.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T3\"\n\ + [provider]\ntype = \"generated\"\nrepository = \"github:we-data-ch/registry\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n", + ); + write_file( + dir, + "definitions/dplyr/ty/core.ty", + "#! pkg: dplyr\n#! tier: T3\n@importFrom dplyr filter;\n@filter: (Any, Any) -> Any;\n", + ); + write_file( + dir, + "definitions/ggplot2/typr-def.toml", + "format_version = 1\n\ + [package]\nname = \"ggplot2\"\nsince = \"3.4.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T3\"\n\ + [provider]\ntype = \"generated\"\nrepository = \"github:we-data-ch/registry\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n", + ); + write_file( + dir, + "definitions/ggplot2/ty/core.ty", + "#! pkg: ggplot2\n#! tier: T3\n@importFrom ggplot2 ggplot;\n@ggplot: (Any) -> Any;\n", + ); + + let run = |args: &[&str]| -> Result<(), String> { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .map_err(|e| e.to_string())?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).into_owned()); + } + Ok(()) + }; + run(&["init", "--quiet"])?; + run(&["config", "user.email", "test@example.com"])?; + run(&["config", "user.name", "test"])?; + run(&["add", "."])?; + run(&["commit", "--quiet", "-m", "initial"])?; + Ok(()) + } + + #[test] + fn fetch_resolves_a_definition_nested_in_a_monorepo_subdir() { + if !git_available() { + eprintln!("skipping: git not on PATH"); + return; + } + let repo_dir = std::env::temp_dir().join(format!("typr_monorepo_{}", std::process::id())); + let _ = fs::remove_dir_all(&repo_dir); + if let Err(e) = make_monorepo(&repo_dir) { + eprintln!("skipping: could not set up local git fixture: {e}"); + let _ = fs::remove_dir_all(&repo_dir); + return; + } + + // Same "bypass RepoSpec::parse, drive the clone directly" pattern as + // `add_update_list_vendor_round_trip_against_a_local_repo`: only + // `github:` URLs are a supported host, so a `file://` fixture is + // cloned directly and `fetch`'s own subdir-joining logic is exercised + // by hand, against the two sibling package directories the fixture + // ships. + let (root, rev) = clone_repo(&file_url(&repo_dir), None).expect("clone should succeed"); + let base_dir = root.join("definitions").join("dplyr"); + let manifest = parse_manifest(&fs::read_to_string(base_dir.join(MANIFEST_NAME)).unwrap()).unwrap(); + assert_eq!(manifest.package.name, "dplyr"); + check_capabilities(&manifest, &base_dir).unwrap(); + let digest = compute_digest(&base_dir).unwrap(); + + // The digest and tracked-file set only cover `dplyr`'s own files — + // changing the sibling `ggplot2` definition must not move it, and no + // `ggplot2` path leaks into what would be admitted to the cache. + let files = tracked_files(&base_dir).unwrap(); + assert!(files.iter().all(|p| !p.to_string_lossy().contains("ggplot2"))); + write_file(&root, "definitions/ggplot2/ty/core.ty", "@ggplot: (int) -> int;\n"); + assert_eq!(compute_digest(&base_dir).unwrap(), digest); + + let fetched = FetchedDefinition { + manifest, + rev, + digest, + dir: base_dir, + root: root.clone(), + }; + admit_to_cache(&fetched, "dplyr").unwrap(); + let cache_dir = cache_dir_for("dplyr", &fetched.digest).unwrap(); + assert!(cache_dir.join("ty").join("core.ty").is_file()); + assert!(!cache_dir.join("definitions").exists()); + + let _ = fs::remove_dir_all(&repo_dir); + let _ = fs::remove_dir_all(&fetched.root); + let _ = fs::remove_dir_all(&cache_dir); + } + + #[test] + fn add_update_list_vendor_round_trip_against_a_local_repo() { + if !git_available() { + eprintln!("skipping: git not on PATH"); + return; + } + let repo_dir = std::env::temp_dir().join(format!("typr_types_repo_{}", std::process::id())); + let project_dir = std::env::temp_dir().join(format!("typr_types_project_{}", std::process::id())); + let _ = fs::remove_dir_all(&repo_dir); + let _ = fs::remove_dir_all(&project_dir); + fs::create_dir_all(&project_dir).unwrap(); + + if let Err(e) = make_definition_repo(&repo_dir) { + eprintln!("skipping: could not set up local git fixture: {e}"); + let _ = fs::remove_dir_all(&repo_dir); + let _ = fs::remove_dir_all(&project_dir); + return; + } + + // `fetch`/`add` only understand `github:owner/repo` — reach the local + // fixture through the real clone path by cloning the `file://` URL + // directly and driving the cache/lock plumbing at that level, since + // `RepoSpec` cannot express a local path (by design: only GitHub is a + // supported host today). + let (dir, rev) = clone_repo(&file_url(&repo_dir), None).expect("clone should succeed"); + let manifest = parse_manifest(&fs::read_to_string(dir.join(MANIFEST_NAME)).unwrap()).unwrap(); + let warnings = check_capabilities(&manifest, &dir).unwrap(); + assert!(warnings.is_empty()); + let digest = compute_digest(&dir).unwrap(); + let fetched = FetchedDefinition { + manifest, + rev, + digest, + dir: dir.clone(), + root: dir, + }; + + admit_to_cache(&fetched, "shiny").unwrap(); + let locked = LockedDefinition { + package: "shiny".to_string(), + repository: "github:alice/typr-shiny".to_string(), + version: fetched.manifest.definition.version.clone(), + rev: fetched.rev.clone(), + digest: fetched.digest.clone(), + tier: fetched.manifest.definition.tier.clone(), + r_version_seen: None, + }; + let mut lockfile = Lockfile::default(); + lockfile.upsert(locked); + lockfile.write(&project_dir.join(LOCKFILE_NAME)).unwrap(); + + // list() + let listed = list(&project_dir); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].package, "shiny"); + assert_eq!(listed[0].tier, "T2"); + + // vendor() + let written = vendor(&project_dir, None).unwrap(); + assert!(!written.is_empty()); + let vendored_ty = project_dir + .join("ty") + .join("vendor") + .join("shiny") + .join("ty") + .join("core.ty"); + assert!(vendored_ty.is_file(), "expected {}", vendored_ty.display()); + let content = fs::read_to_string(&vendored_ty).unwrap(); + assert!(content.contains("fluidPage")); + + let _ = fs::remove_dir_all(&repo_dir); + let _ = fs::remove_dir_all(&project_dir); + let _ = fs::remove_dir_all(&fetched.root); + if let Some(cache_dir) = cache_dir_for("shiny", &fetched.digest) { + let _ = fs::remove_dir_all(&cache_dir); + } + } + + #[test] + fn vendor_detects_cache_digest_mismatch() { + let project_dir = std::env::temp_dir().join(format!("typr_vendor_mismatch_{}", std::process::id())); + let _ = fs::remove_dir_all(&project_dir); + fs::create_dir_all(&project_dir).unwrap(); + + let mut lockfile = Lockfile::default(); + lockfile.upsert(LockedDefinition { + package: "ghost".to_string(), + repository: "github:alice/typr-ghost".to_string(), + version: "0.1.0".to_string(), + rev: "deadbeef".to_string(), + digest: "sha256:doesnotexistanywhereincache".to_string(), + tier: "T3".to_string(), + r_version_seen: None, + }); + lockfile.write(&project_dir.join(LOCKFILE_NAME)).unwrap(); + + let err = vendor(&project_dir, None).unwrap_err(); + assert!(err.contains("ghost"), "unexpected error: {err}"); + + let _ = fs::remove_dir_all(&project_dir); + } + + // -- End-to-end: typr.lock -> real cache -> type-checking context ------ + // + // These exercise `standard_library::load_project_type_definitions`, the + // "reading typr.lock at check/build/run time" wiring named as the last + // missing piece of registry.md §13 J2's `typr types add/update/list/ + // vendor` item. They admit a definition straight into the real on-disk + // cache (no git fixture, unlike `add_update_list_vendor_round_trip_ + // against_a_local_repo` above) since only the cache/lock -> context path + // is under test here, not fetching. + + fn lock_definition_in_real_cache( + project_dir: &Path, + package: &str, + manifest_toml: &str, + ty_source: &str, + r_version_seen: Option<&str>, + ) -> LockedDefinition { + let src_dir = std::env::temp_dir().join(format!("typr_lock_src_{package}_{}", std::process::id())); + let _ = fs::remove_dir_all(&src_dir); + write_file(&src_dir, MANIFEST_NAME, manifest_toml); + write_file(&src_dir, "ty/core.ty", ty_source); + + let manifest = parse_manifest(manifest_toml).unwrap(); + let digest = compute_digest(&src_dir).unwrap(); + let fetched = FetchedDefinition { + manifest, + rev: "0000000000000000000000000000000000000000".to_string(), + digest, + dir: src_dir.clone(), + root: src_dir.clone(), + }; + admit_to_cache(&fetched, package).unwrap(); + + let locked = LockedDefinition { + package: package.to_string(), + repository: format!("github:test/{package}"), + version: fetched.manifest.definition.version.clone(), + rev: fetched.rev.clone(), + digest: fetched.digest.clone(), + tier: fetched.manifest.definition.tier.clone(), + r_version_seen: r_version_seen.map(str::to_string), + }; + + let lock_path = project_dir.join(LOCKFILE_NAME); + let mut lockfile = Lockfile::read(&lock_path); + lockfile.upsert(locked.clone()); + lockfile.write(&lock_path).unwrap(); + + let _ = fs::remove_dir_all(&src_dir); + locked + } + + /// registry.md §5.4: a `T3` entry, locked and cached for real, degrades + /// to `Any` under the project's default `T2` trust once loaded through + /// the real `check`/`build`/`run` entry point + /// (`standard_library::load_project_type_definitions`) — not just + /// through the lower-level `load_external_ty_definitions` unit tests in + /// `standard_library.rs`. + #[test] + fn load_project_type_definitions_degrades_a_low_tier_locked_definition() { + let project_dir = std::env::temp_dir().join(format!("typr_wiring_tier_{}", std::process::id())); + let _ = fs::remove_dir_all(&project_dir); + fs::create_dir_all(&project_dir).unwrap(); + + let manifest_toml = "format_version = 1\n\ + [package]\nname = \"widget\"\nsince = \"1.0.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T3\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:test/typr-widget\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n"; + let locked = lock_definition_in_real_cache( + &project_dir, + "widget", + manifest_toml, + "@do_widget_thing: (int) -> int;", + None, + ); + + let context = crate::standard_library::load_project_type_definitions( + &project_dir, + typr_core::components::context::Context::default(), + ); + let typ = context + .get_type_from_variable(&typr_core::components::language::var::Var::from_name("do_widget_thing")) + .expect("locked definition's entry must be loaded into the context"); + assert!( + typ.is_unknown_function(), + "a T3 entry under the default T2 project trust must degrade to Any" + ); + + let _ = fs::remove_dir_all(&project_dir); + if let Some(cache_dir) = cache_dir_for("widget", &locked.digest) { + let _ = fs::remove_dir_all(&cache_dir); + } + } + + /// registry.md §7.2: a `typr.lock` entry whose recorded `r_version_seen` + /// is below the manifest's `since` floor degrades to `Any` once loaded + /// through the real `check`/`build`/`run` entry point, even though its + /// own tier (`T1`) is trusted outright. + #[test] + fn load_project_type_definitions_degrades_when_locked_version_is_below_since() { + let project_dir = std::env::temp_dir().join(format!("typr_wiring_version_{}", std::process::id())); + let _ = fs::remove_dir_all(&project_dir); + fs::create_dir_all(&project_dir).unwrap(); + + let manifest_toml = "format_version = 1\n\ + [package]\nname = \"widget2\"\nsince = \"2.0.0\"\n\ + [definition]\nversion = \"0.1.0\"\ntier = \"T1\"\n\ + [provider]\ntype = \"community\"\nrepository = \"github:test/typr-widget2\"\n\ + [capabilities]\nr_shims = false\nextern_raw = false\n"; + let locked = lock_definition_in_real_cache( + &project_dir, + "widget2", + manifest_toml, + "@do_widget2_thing: (int) -> int;", + Some("1.0.0"), + ); + + let context = crate::standard_library::load_project_type_definitions( + &project_dir, + typr_core::components::context::Context::default(), + ); + let typ = context + .get_type_from_variable(&typr_core::components::language::var::Var::from_name( + "do_widget2_thing", + )) + .expect("locked definition's entry must be loaded into the context"); + assert!( + typ.is_unknown_function(), + "a T1 entry whose observed version is below `since` must still degrade to Any (registry.md §7.2)" + ); + + let _ = fs::remove_dir_all(&project_dir); + if let Some(cache_dir) = cache_dir_for("widget2", &locked.digest) { + let _ = fs::remove_dir_all(&cache_dir); + } + } + + // -- Registry lookup (registry.md §13 J3) ---------------------------- + + fn write_registry_package(dir: &Path, package: &str, json: &str) { + write_file(dir, &format!("packages/{package}.json"), json); + } + + #[test] + fn picks_official_over_community_regardless_of_tier() { + let dir = std::env::temp_dir().join(format!("typr_registry_official_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "shiny", + r#"{ + "name": "shiny", + "definitions": [ + {"repository": "alice/typr-shiny", "rev": "aaa", "source": "community", "tier": "T1"}, + {"repository": "rstudio/typr-shiny-official", "rev": "bbb", "source": "official", "tier": "T3"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "shiny").unwrap(); + assert_eq!(spec, "github:rstudio/typr-shiny-official@bbb"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn picks_highest_tier_among_same_provenance() { + let dir = std::env::temp_dir().join(format!("typr_registry_tier_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "dplyr", + r#"{ + "name": "dplyr", + "definitions": [ + {"repository": "alice/typr-dplyr", "rev": "aaa", "source": "community", "tier": "T3"}, + {"repository": "bob/typr-dplyr", "rev": "bbb", "source": "community", "tier": "T1"}, + {"repository": "carol/typr-dplyr", "rev": "ccc", "source": "community", "tier": "T2"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "dplyr").unwrap(); + assert_eq!(spec, "github:bob/typr-dplyr@bbb"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn ties_keep_the_files_own_order() { + let dir = std::env::temp_dir().join(format!("typr_registry_tie_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "ggplot2", + r#"{ + "name": "ggplot2", + "definitions": [ + {"repository": "first/typr-ggplot2", "rev": "aaa", "source": "community", "tier": "T2"}, + {"repository": "second/typr-ggplot2", "rev": "bbb", "source": "community", "tier": "T2"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "ggplot2").unwrap(); + assert_eq!(spec, "github:first/typr-ggplot2@aaa"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn no_entry_for_package_resolves_to_none() { + let dir = std::env::temp_dir().join(format!("typr_registry_missing_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + + assert!(lookup_in_registry_dir(&dir, "sf").is_none()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn empty_definitions_array_resolves_to_none() { + let dir = std::env::temp_dir().join(format!("typr_registry_empty_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package(&dir, "sf", r#"{"name": "sf", "definitions": []}"#); + + assert!(lookup_in_registry_dir(&dir, "sf").is_none()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn malformed_json_resolves_to_none_rather_than_erroring() { + let dir = std::env::temp_dir().join(format!("typr_registry_bad_json_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package(&dir, "sf", "this is not { json"); + + assert!(lookup_in_registry_dir(&dir, "sf").is_none()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn monorepo_subdir_repository_resolves_with_subdir_preserved() { + let dir = std::env::temp_dir().join(format!("typr_registry_subdir_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "dplyr", + r#"{ + "name": "dplyr", + "definitions": [ + {"repository": "we-data-ch/registry/definitions/dplyr", "rev": "abc", "source": "generated", "tier": "T3"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "dplyr").unwrap(); + assert_eq!(spec, "github:we-data-ch/registry/definitions/dplyr@abc"); + // and it parses back into a RepoSpec with the subdir intact. + let parsed = RepoSpec::parse(&spec).unwrap(); + assert_eq!(parsed.owner, "we-data-ch"); + assert_eq!(parsed.repo, "registry"); + assert_eq!(parsed.subdir.as_deref(), Some("definitions/dplyr")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn entry_without_rev_resolves_to_head() { + let dir = std::env::temp_dir().join(format!("typr_registry_norev_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "httr2", + r#"{"name": "httr2", "definitions": [{"repository": "alice/typr-httr2", "source": "community", "tier": "T2"}]}"#, + ); + + let spec = lookup_in_registry_dir(&dir, "httr2").unwrap(); + assert_eq!(spec, "github:alice/typr-httr2"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn unrecognized_source_and_tier_never_outrank_a_recognized_one() { + let dir = std::env::temp_dir().join(format!("typr_registry_unknown_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "jsonlite", + r#"{ + "name": "jsonlite", + "definitions": [ + {"repository": "sketchy/typr-jsonlite", "rev": "zzz", "source": "totally-trustworthy", "tier": "super-good"}, + {"repository": "alice/typr-jsonlite", "rev": "aaa", "source": "generated", "tier": "T3"} + ] + }"#, + ); + + let spec = lookup_in_registry_dir(&dir, "jsonlite").unwrap(); + assert_eq!(spec, "github:alice/typr-jsonlite@aaa"); + + let _ = fs::remove_dir_all(&dir); + } + + // -- search: every entry, ranked, not just the winner ----------------- + + #[test] + fn search_lists_every_entry_ranked_best_first() { + let dir = std::env::temp_dir().join(format!("typr_search_ranked_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "shiny", + r#"{ + "name": "shiny", + "definitions": [ + {"repository": "alice/typr-shiny", "rev": "aaa", "source": "community", "tier": "T1"}, + {"repository": "rstudio/typr-shiny-official", "rev": "bbb", "source": "official", "tier": "T3"}, + {"repository": "carol/typr-shiny", "rev": "ccc", "source": "community", "tier": "T3"} + ] + }"#, + ); + + let entries = search_in_registry_dir(&dir, "shiny"); + let repos: Vec<&str> = entries.iter().map(|e| e.repository.as_str()).collect(); + // official (any tier) still outranks community, matching pick_best_entry. + assert_eq!( + repos, + vec!["rstudio/typr-shiny-official", "alice/typr-shiny", "carol/typr-shiny"] + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn search_ties_keep_the_files_own_order() { + let dir = std::env::temp_dir().join(format!("typr_search_tie_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "ggplot2", + r#"{ + "name": "ggplot2", + "definitions": [ + {"repository": "first/typr-ggplot2", "rev": "aaa", "source": "community", "tier": "T2"}, + {"repository": "second/typr-ggplot2", "rev": "bbb", "source": "community", "tier": "T2"} + ] + }"#, + ); + + let entries = search_in_registry_dir(&dir, "ggplot2"); + let repos: Vec<&str> = entries.iter().map(|e| e.repository.as_str()).collect(); + assert_eq!(repos, vec!["first/typr-ggplot2", "second/typr-ggplot2"]); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn search_no_entry_for_package_is_an_empty_list_not_an_error() { + let dir = std::env::temp_dir().join(format!("typr_search_missing_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(dir.join("packages")).unwrap(); + + assert!(search_in_registry_dir(&dir, "sf").is_empty()); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn search_malformed_json_is_an_empty_list_not_an_error() { + let dir = std::env::temp_dir().join(format!("typr_search_bad_json_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package(&dir, "sf", "this is not { json"); + + assert!(search_in_registry_dir(&dir, "sf").is_empty()); + + let _ = fs::remove_dir_all(&dir); + } + + // -- list_registry_targets (typr types revalidate, registry.md §13 J4) -- + + #[test] + fn list_registry_targets_covers_every_entry_across_every_package_file() { + let dir = std::env::temp_dir().join(format!("typr_list_targets_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "dplyr", + r#"{"name": "dplyr", "definitions": [ + {"repository": "alice/typr-dplyr", "rev": "aaa", "source": "community", "tier": "T2"}, + {"repository": "bob/typr-dplyr", "source": "community", "tier": "T3"} + ]}"#, + ); + write_registry_package( + &dir, + "shiny", + r#"{"name": "shiny", "definitions": [ + {"repository": "carol/typr-shiny", "rev": "ccc", "source": "official", "tier": "T1"} + ]}"#, + ); + + let targets = list_registry_targets(&dir); + let pairs: Vec<(String, String)> = targets.into_iter().map(|t| (t.package, t.spec)).collect(); + assert_eq!( + pairs, + vec![ + ("dplyr".to_string(), "github:alice/typr-dplyr@aaa".to_string()), + ("dplyr".to_string(), "github:bob/typr-dplyr".to_string()), + ("shiny".to_string(), "github:carol/typr-shiny@ccc".to_string()), + ] + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn list_registry_targets_skips_a_malformed_entry_without_dropping_the_rest() { + let dir = std::env::temp_dir().join(format!("typr_list_targets_bad_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + write_registry_package( + &dir, + "sf", + r#"{"name": "sf", "definitions": [ + {"repository": "not-a-valid-repo-field", "source": "community", "tier": "T2"}, + {"repository": "alice/typr-sf", "source": "community", "tier": "T2"} + ]}"#, + ); + + let targets = list_registry_targets(&dir); + assert_eq!( + targets, + vec![RegistryTarget { + package: "sf".to_string(), + spec: "github:alice/typr-sf".to_string() + }] + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn list_registry_targets_empty_when_packages_dir_is_absent() { + let dir = std::env::temp_dir().join(format!("typr_list_targets_empty_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + + assert!(list_registry_targets(&dir).is_empty()); + + let _ = fs::remove_dir_all(&dir); + } + + // -- resolve_spec_for_package: explicit pin beats the registry -------- + + #[test] + fn resolve_spec_for_package_prefers_explicit_pin_without_touching_the_registry() { + let dir = std::env::temp_dir().join(format!("typr_resolve_pin_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join(PROJECT_CONFIG_NAME), + "[types]\nshiny = \"github:alice/typr-shiny@pinned\"\n", + ) + .unwrap(); + + // No registry index is reachable/needed here: the pin must win before + // `lookup_in_registry` is ever consulted. + let spec = resolve_spec_for_package(&dir, "shiny"); + assert_eq!(spec, Some("github:alice/typr-shiny@pinned".to_string())); + + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/crates/typr-core/configs/bin/.std_js.bin b/crates/typr-core/configs/bin/.std_js.bin index b7235379..dc0ecc20 100644 Binary files a/crates/typr-core/configs/bin/.std_js.bin and b/crates/typr-core/configs/bin/.std_js.bin differ diff --git a/crates/typr-core/configs/bin/.std_js_typed.bin b/crates/typr-core/configs/bin/.std_js_typed.bin index 287f8223..e1dcbabb 100644 Binary files a/crates/typr-core/configs/bin/.std_js_typed.bin and b/crates/typr-core/configs/bin/.std_js_typed.bin differ diff --git a/crates/typr-core/configs/bin/.std_r.bin b/crates/typr-core/configs/bin/.std_r.bin index 064f9efe..27a72b8e 100644 Binary files a/crates/typr-core/configs/bin/.std_r.bin and b/crates/typr-core/configs/bin/.std_r.bin differ diff --git a/crates/typr-core/configs/bin/.std_r_typed.bin b/crates/typr-core/configs/bin/.std_r_typed.bin index abf18181..9e944fa1 100644 Binary files a/crates/typr-core/configs/bin/.std_r_typed.bin and b/crates/typr-core/configs/bin/.std_r_typed.bin differ diff --git a/crates/typr-core/src/components/context/fingerprint.rs b/crates/typr-core/src/components/context/fingerprint.rs index 9e38dafb..d733e8e2 100644 --- a/crates/typr-core/src/components/context/fingerprint.rs +++ b/crates/typr-core/src/components/context/fingerprint.rs @@ -64,10 +64,12 @@ pub fn canonical_type_debug(t: &Type) -> String { canonical_type_debug(ret), h ), - Type::Vec(vt, size, inner, h) => format!( + Type::Vec(vt, _, inner, h) => format!( "Vec({:?}, {:?}, {:?}, {:?})", vt, - canonical_type_debug(size), + t.vec_length() + .map(|size| canonical_type_debug(&size)) + .unwrap_or_default(), canonical_type_debug(inner), h ), diff --git a/crates/typr-core/src/components/context/mod.rs b/crates/typr-core/src/components/context/mod.rs index 2b98a41f..85e68cdd 100644 --- a/crates/typr-core/src/components/context/mod.rs +++ b/crates/typr-core/src/components/context/mod.rs @@ -15,6 +15,7 @@ use crate::components::language::var_function::VarFunction; use crate::components::language::Lang; use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::kind::Kind; +use crate::components::r#type::refinement::{RefinementObligation, RefinementSet}; use crate::components::r#type::type_system::TypeSystem; use crate::components::r#type::vector_type::ConstructorCategory; use crate::components::r#type::Type; @@ -89,6 +90,12 @@ pub struct Context { /// checked against in `function()`. `None` outside a function body. #[serde(skip)] pub expected_return_type: Option, + /// True while typing an expression whose value is the function's result + /// (trailing expression, `if` branches, `return` argument). Lets `typing()` + /// check that value against `expected_return_type` (refined types). Not + /// serialised and never part of a context's identity. + #[serde(skip)] + pub return_position: bool, /// Inner typing contexts computed while type-checking each `module M { ... }` /// body, keyed by module name. Populated during type-checking; consumed /// during transpilation to avoid re-running `typing()` on every module body. @@ -134,6 +141,10 @@ pub struct Context { /// apart by name alone). #[serde(default)] pub vectorizable_fns: Vec<(String, bool)>, + /// Refinement checks the type checker requires at a boundary the + /// transpiler reaches later (`refinement_check::coerce_to`). + #[serde(default)] + pub refinement_obligations: Vec, config: Config, } @@ -183,10 +194,12 @@ impl Default for Context { test_preamble: Vec::new(), self_type: None, expected_return_type: None, + return_position: false, extern_fns: Vec::new(), import_from_fns: Vec::new(), signature_fns: Vec::new(), vectorizable_fns: Vec::new(), + refinement_obligations: Vec::new(), module_inner_contexts: HashMap::new(), processed_modules: HashMap::new(), modules_in_progress: HashSet::new(), @@ -228,10 +241,12 @@ impl Context { test_preamble: Vec::new(), self_type: None, expected_return_type: None, + return_position: false, extern_fns: Vec::new(), import_from_fns: Vec::new(), signature_fns: Vec::new(), vectorizable_fns: Vec::new(), + refinement_obligations: Vec::new(), module_inner_contexts: HashMap::new(), processed_modules: HashMap::new(), modules_in_progress: HashSet::new(), @@ -277,6 +292,30 @@ impl Context { self } + /// Record that the expression at `h` must satisfy `set` at run time. + pub fn add_refinement_obligation(mut self, h: &HelpData, set: RefinementSet) -> Self { + let (file, start, end) = (h.get_file_name(), h.get_offset(), h.get_end()); + match self + .refinement_obligations + .iter_mut() + .find(|o| o.file == file && o.start == start && o.end == end) + { + Some(o) => o.set = set, + None => self + .refinement_obligations + .push(RefinementObligation { file, start, end, set }), + } + self + } + + pub fn refinement_obligation_at(&self, h: &HelpData) -> Option<&RefinementSet> { + let (file, start, end) = (h.get_file_name(), h.get_offset(), h.get_end()); + self.refinement_obligations + .iter() + .find(|o| o.file == file && o.start == start && o.end == end) + .map(|o| &o.set) + } + pub fn is_vectorizable_fn(&self, name: &str) -> bool { self.vectorizable_fns.iter().any(|(n, v)| n == name && *v) } @@ -336,6 +375,26 @@ impl Context { } } + pub fn set_return_position(mut self, on: bool) -> Context { + self.return_position = on; + self + } + + pub fn is_return_position(&self) -> bool { + self.return_position + } + + /// Keep the refinement obligations `inner` recorded (a sub-context built + /// for a nested scope is otherwise dropped by its parent). + pub fn absorb_obligations(mut self, inner: &Context) -> Context { + for entry in &inner.refinement_obligations { + if !self.refinement_obligations.contains(entry) { + self.refinement_obligations.push(entry.clone()); + } + } + self + } + pub fn get_expected_return_type(&self) -> Option { self.expected_return_type.clone() } @@ -685,6 +744,7 @@ impl Context { subtypes: new_subtypes, ..self } + .absorb_obligations(inner) } pub fn get_type_from_existing_variable(&self, var: Var) -> Type { @@ -737,6 +797,10 @@ impl Context { } pub fn get_class(&self, t: &Type) -> String { + // `Lovable@A` dispatches and casts as its bound; the id is type-checker only. + if let Type::Bounded(_, bound, _) = t { + return self.get_class(bound); + } // For a named alias whose underlying type is a record or array, return the alias // name directly. push_types may have also registered the same underlying type with // an auto-generated "Record0"/"Array0" name; searching aliases by type value would @@ -758,6 +822,10 @@ impl Context { } pub fn get_class_unquoted(&self, t: &Type) -> String { + // `Lovable@A` dispatches and casts as its bound; the id is type-checker only. + if let Type::Bounded(_, bound, _) = t { + return self.get_class_unquoted(bound); + } // Same rationale as get_class: bypass the record/array alias search for named aliases. if let Type::Alias(name, _, false, _) = t { if let Some((_, underlying)) = self.aliases().find(|(v, _)| v.get_name() == *name) { @@ -1384,6 +1452,12 @@ impl Add for Context { signature_fns.push(name); } } + let mut refinement_obligations = self.refinement_obligations; + for entry in other.refinement_obligations { + if !refinement_obligations.contains(&entry) { + refinement_obligations.push(entry); + } + } let mut vectorizable_fns = self.vectorizable_fns; for (name, is_vec) in other.vectorizable_fns { match vectorizable_fns.iter_mut().find(|(n, _)| n == &name) { @@ -1408,10 +1482,12 @@ impl Add for Context { test_preamble, self_type: None, expected_return_type: None, + return_position: false, extern_fns, import_from_fns, signature_fns, vectorizable_fns, + refinement_obligations, config: self.config, module_inner_contexts, processed_modules, diff --git a/crates/typr-core/src/components/context/vartype.rs b/crates/typr-core/src/components/context/vartype.rs index e74b6217..ae1a2f87 100644 --- a/crates/typr-core/src/components/context/vartype.rs +++ b/crates/typr-core/src/components/context/vartype.rs @@ -390,6 +390,8 @@ impl VarType { pub fn get_class(&self, t: &Type) -> String { let res = match t { + // `Lovable@A` is transpiled as its bound; the id is type-checker only. + Type::Bounded(_, bound, _) => return self.get_class(bound), Type::Integer(_, _) => "integer".to_string(), Type::Char(_, _) => "character".to_string(), Type::Boolean(_, _) => "logical".to_string(), @@ -495,6 +497,8 @@ impl VarType { pub fn get_type_anotation(&self, t: &Type) -> String { let res = match t { + // `Lovable@A` is transpiled as its bound; the id is type-checker only. + Type::Bounded(_, bound, _) => return self.get_type_anotation(bound), Type::Boolean(_, _) => "as.Boolean".to_string(), Type::Integer(_, _) => "as.Integer".to_string(), Type::Number(_, _) => "as.Number".to_string(), @@ -532,7 +536,10 @@ impl VarType { // function that was never defined, an R runtime "could not find // function as.FunctionN" error at the very first typed generic // function declared at project top level. - _ if t.has_generic() => "as.Generic".to_string(), + // An `I@Id` is a generic variable too, but `extract_generics` does not report it. + _ if t.has_generic() || crate::processes::type_checking::signature_normalization::has_bounded(t) => { + "as.Generic".to_string() + } _ => self .aliases .iter() @@ -545,6 +552,8 @@ impl VarType { pub fn get_type_anotation_no_parentheses(&self, t: &Type) -> String { match t { + // `Lovable@A` is transpiled as its bound; the id is type-checker only. + Type::Bounded(_, bound, _) => return self.get_type_anotation_no_parentheses(bound), Type::Boolean(_, _) => "logical".to_string(), Type::Integer(_, _) => "integer".to_string(), Type::Number(_, _) => "number".to_string(), @@ -561,6 +570,8 @@ impl VarType { pub fn get_class_unquoted(&self, t: &Type) -> String { match t { + // `Lovable@A` is transpiled as its bound; the id is type-checker only. + Type::Bounded(_, bound, _) => return self.get_class_unquoted(bound), Type::Integer(_, _) => "integer".to_string(), Type::Char(_, _) => "character".to_string(), Type::Boolean(_, _) => "logical".to_string(), @@ -698,6 +709,58 @@ impl VarType { vars.iter().fold(self, |acc, x| acc.remove_var(x)) } + /// Replace every entry named in `names` with the canonical `(Any, + /// UnknownFunction)` pair the compiler already uses everywhere for "a + /// real name, callable with any arguments, but not type-checked" (see + /// the `(Any, UnknownFunction)` preload for untyped R/JS builtins in + /// `build_function_list_vartype`, and the `UseSelector::Wildcard` comment + /// in `type_checking/mod.rs`). Used to degrade an external type + /// definition's entries that fall below the project's configured trust + /// threshold (`typR/registry.md` §5.4/D2, + /// `rfcs/0031-external-type-definitions.md` "Loading external `.ty` into + /// the context": entry tier < project trust -> load as + /// `Type::UnknownFunction` instead of its declared signature). + /// + /// A name absent from `self` is a no-op — degrading only ever removes + /// type information that would otherwise have been added, never invents + /// a new name. + pub fn degrade_to_any(self, names: &std::collections::HashSet) -> Self { + if names.is_empty() { + return self; + } + let VarType { + variables, + aliases, + std, + alias_counter, + .. + } = self; + let mut new_variables = Arc::unwrap_or_clone(variables); + let mut degraded: std::collections::HashSet = std::collections::HashSet::new(); + new_variables.retain(|(var, _)| { + let name = var.get_name(); + if names.contains(&name) { + degraded.insert(name); + false + } else { + true + } + }); + for name in degraded { + new_variables.insert(( + Var::from_name(&name).set_type(builder::any_type()), + builder::unknown_function_type(), + )); + } + Self { + variables: Arc::new(new_variables), + aliases, + std, + alias_counter, + name_index: Arc::new(OnceLock::new()), + } + } + pub fn remove_var(self, var: &Var) -> Self { let VarType { variables, @@ -903,6 +966,55 @@ mod tests { s.parse::().unwrap() } + #[test] + fn degrade_to_any_replaces_named_entries_with_any_unknown_function() { + let vt = VarType::new().push_var_type(&[ + ( + Var::from_name("keep_me").set_type(builder::integer_type_default()), + builder::integer_type_default(), + ), + ( + Var::from_name("degrade_me").set_type(builder::integer_type_default()), + builder::integer_type_default(), + ), + ]); + + let mut names = std::collections::HashSet::new(); + names.insert("degrade_me".to_string()); + let degraded = vt.degrade_to_any(&names); + + let (kept_var, kept_type) = degraded + .entries_named("keep_me") + .into_iter() + .next() + .expect("untouched entry must survive"); + assert_eq!(kept_type, builder::integer_type_default()); + assert_eq!(kept_var.get_type(), builder::integer_type_default()); + + let (degraded_var, degraded_type) = degraded + .entries_named("degrade_me") + .into_iter() + .next() + .expect("degraded entry must still be present"); + assert!(degraded_type.is_unknown_function()); + assert!(degraded_var.get_type().is_any()); + } + + #[test] + fn degrade_to_any_is_a_no_op_for_a_name_not_present() { + let vt = VarType::new().push_var_type(&[( + Var::from_name("keep_me").set_type(builder::integer_type_default()), + builder::integer_type_default(), + )]); + + let mut names = std::collections::HashSet::new(); + names.insert("never_declared".to_string()); + let degraded = vt.degrade_to_any(&names); + + assert_eq!(degraded.entries_named("keep_me").len(), 1); + assert!(degraded.entries_named("never_declared").is_empty()); + } + #[test] fn atomic_array_elem_primitive_and_vec_are_atomic() { let ctx = FluentParser::new().get_context(); diff --git a/crates/typr-core/src/components/error_message/help_data.rs b/crates/typr-core/src/components/error_message/help_data.rs index 86f9bc95..e1926cc4 100644 --- a/crates/typr-core/src/components/error_message/help_data.rs +++ b/crates/typr-core/src/components/error_message/help_data.rs @@ -35,6 +35,8 @@ fn get_cached_source(file_name: &str) -> Option { #[derive(Debug, PartialEq, Serialize, Deserialize, Eq, Clone, Hash, Default)] pub struct HelpData { offset: usize, + #[serde(default)] + end: usize, file_name: String, } @@ -43,6 +45,20 @@ impl HelpData { self.offset } + /// Offset just past the last byte of this node's source range. + /// Filled in by the parser once the node's production has fully consumed its input; + /// equal to `offset` for synthetic (non-source-derived) nodes. + pub fn get_end(&self) -> usize { + self.end + } + + /// Sets the end offset. The parser calls this once a production completes, using the + /// position of the remaining input right after this node's own text (see the `_impl`/ + /// wrapper split in `processes/parsing`). + pub fn set_end(&mut self, end: usize) { + self.end = end; + } + pub fn get_file_name(&self) -> String { self.file_name.clone() } @@ -84,6 +100,7 @@ impl HelpData { pub fn random() -> Self { HelpData { offset: 7_usize, + end: 7_usize, file_name: "asfdlwone".to_string(), } } @@ -91,8 +108,10 @@ impl HelpData { impl From> for HelpData { fn from(ls: LocatedSpan<&str, String>) -> Self { + let offset = ls.location_offset(); HelpData { - offset: ls.location_offset(), + offset, + end: offset, file_name: ls.extra, } } @@ -101,7 +120,13 @@ impl From> for HelpData { impl From> for HelpData { fn from(val: Vec) -> Self { if !val.is_empty() { - val[0].clone().into() + let first = val[0].get_help_data(); + let last = val[val.len() - 1].get_help_data(); + HelpData { + offset: first.offset, + end: last.end, + file_name: first.file_name, + } } else { HelpData::default() } diff --git a/crates/typr-core/src/components/error_message/syntax_error.rs b/crates/typr-core/src/components/error_message/syntax_error.rs index 3cebe237..69554375 100644 --- a/crates/typr-core/src/components/error_message/syntax_error.rs +++ b/crates/typr-core/src/components/error_message/syntax_error.rs @@ -45,6 +45,9 @@ pub enum SyntaxError { found: usize, help_data: HelpData, }, + /// `Lovable @A` / `Lovable@Self`: the `@Id` bound suffix must be one capital + /// letter touching the interface name. + DetachedBoundSuffix(HelpData), WithNode(Box, Box), } @@ -69,6 +72,7 @@ impl SyntaxError { SyntaxError::WrongCommentSyntax(h) => Some(h.clone()), SyntaxError::SingleEqualsComparison(h) => Some(h.clone()), SyntaxError::TupleDestructureArityMismatch { help_data, .. } => Some(help_data.clone()), + SyntaxError::DetachedBoundSuffix(h) => Some(h.clone()), SyntaxError::WithNode(_, inner) => inner.get_help_data(), } } @@ -97,6 +101,7 @@ impl SyntaxError { SyntaxError::WrongCommentSyntax(..) => "S015", SyntaxError::SingleEqualsComparison(..) => "S016", SyntaxError::TupleDestructureArityMismatch { .. } => "S017", + SyntaxError::DetachedBoundSuffix(..) => "S018", SyntaxError::WithNode(_, inner) => inner.code(), } } @@ -162,6 +167,9 @@ impl SyntaxError { SyntaxError::TupleDestructureArityMismatch { expected, found, .. } => { format!("Tuple destructuring expects {expected} element(s), but the source tuple has {found}") } + SyntaxError::DetachedBoundSuffix(_) => { + "The `@Id` suffix must be a single capital letter touching the interface name: write `Lovable@A`, not `Lovable @A` or `Lovable@Self`".to_string() + } SyntaxError::WithNode(_, inner) => inner.simple_message(), } } @@ -369,6 +377,16 @@ impl ErrorMsg for SyntaxError { }) .build() } + SyntaxError::DetachedBoundSuffix(help_data) => { + let (file_name, text) = help_data.get_file_data().unwrap_or_else(default_file_data); + SingleBuilder::new(file_name, text) + .kind("Syntax error") + .pos((help_data.get_offset(), 1)) + .text("Malformed `@Id` suffix") + .pos_text("Expected `@` + one capital letter, right after the name") + .help("Write `Lovable@A` (no space, one-letter id)") + .build() + } SyntaxError::WithNode(_, inner) => return inner.display(), }; msg.map_or_else(|e| format!("{:?}", e), |_| String::new()) diff --git a/crates/typr-core/src/components/error_message/type_error.rs b/crates/typr-core/src/components/error_message/type_error.rs index d691c5ef..ec7ef667 100644 --- a/crates/typr-core/src/components/error_message/type_error.rs +++ b/crates/typr-core/src/components/error_message/type_error.rs @@ -152,6 +152,25 @@ pub enum TypeError { /// `name.Suffix`" error in the generated R — /// `(function_name, forced_type, available_types, position)`. NoDispatchImplementation(String, Type, Vec, HelpData), + /// A call to an untyped R function (`Lang::RFunction`, RFC 0028) supplied + /// the wrong number of arguments. Distinguished from `NoMatchingSignature` + /// because the callee has exactly one signature and no argument types to + /// report — only arity was ever checked, and saying so avoids exposing + /// the internal `UnknownFunction` placeholder to someone who wrote plain + /// R — `(function_name, expected_arity, got_arity, position)`. + UntypedFunctionArity(String, usize, usize, HelpData), + /// `T & p` where `T` cannot carry the property `p` (`int & length(3)`). + /// `(message, position)` — the message comes from `apply_refinements`. + InvalidRefinement(String, HelpData), + /// A refined type no value can inhabit (`int & (> 5) & (< 3)`). + UnsatisfiableRefinement(String, HelpData), + /// `Lovable@A` and `Printable@A` in one signature: `(id, first_bound, other_bound, position)`. + ConflictingBound(String, String, String, HelpData), + /// `fn(a: T, b: Lovable@T)`: an `I@Id` id that is also a free generic. `(id, position)`. + BoundCollidesWithGeneric(String, HelpData), + /// A call binds one `I@Id` to two different types: + /// `(id, first_type, first_arg_position, second_type, second_arg_position)`. + IdBoundToTwoTypes(String, String, HelpData, String, HelpData), } impl TypeError { @@ -201,6 +220,12 @@ impl TypeError { TypeError::DataFrameColumnLengthMismatch(_, _, _, _, h) => Some(h.clone()), TypeError::NoMatchingSignature(_, _, _, h) => Some(h.clone()), TypeError::NoDispatchImplementation(_, _, _, h) => Some(h.clone()), + TypeError::UntypedFunctionArity(_, _, _, h) => Some(h.clone()), + TypeError::InvalidRefinement(_, h) => Some(h.clone()), + TypeError::UnsatisfiableRefinement(_, h) => Some(h.clone()), + TypeError::ConflictingBound(_, _, _, h) => Some(h.clone()), + TypeError::BoundCollidesWithGeneric(_, h) => Some(h.clone()), + TypeError::IdBoundToTwoTypes(_, _, _, _, h) => Some(h.clone()), } } @@ -252,6 +277,12 @@ impl TypeError { TypeError::UnknownUnionVariant(..) => "T041", TypeError::NoMatchingSignature(..) => "T042", TypeError::NoDispatchImplementation(..) => "T043", + TypeError::UntypedFunctionArity(..) => "T044", + TypeError::InvalidRefinement(..) => "T045", + TypeError::UnsatisfiableRefinement(..) => "T046", + TypeError::ConflictingBound(..) => "T047", + TypeError::BoundCollidesWithGeneric(..) => "T048", + TypeError::IdBoundToTwoTypes(..) => "T049", } } @@ -475,6 +506,28 @@ impl TypeError { available.iter().map(|t| t.pretty()).collect::>().join(", ") ) } + TypeError::UntypedFunctionArity(name, expected, got, _) => { + format!( + "'{}' is an untyped R function taking {} argument(s), called with {}.", + name, expected, got + ) + } + TypeError::InvalidRefinement(msg, _) | TypeError::UnsatisfiableRefinement(msg, _) => msg.clone(), + TypeError::ConflictingBound(id, b1, b2, _) => { + format!( + "'{}' is bounded by both '{}' and '{}' in the same signature.", + id, b1, b2 + ) + } + TypeError::BoundCollidesWithGeneric(id, _) => { + format!( + "'{}' is used both as a free generic and as a bound id (`I@{}`).", + id, id + ) + } + TypeError::IdBoundToTwoTypes(id, t1, _, t2, _) => { + format!("'{}' is bound to '{}' and then to '{}' in the same call.", id, t1, t2) + } } } } @@ -1065,6 +1118,71 @@ impl ErrorMsg for TypeError { )) .build() } + TypeError::UntypedFunctionArity(name, expected, got, help_data) => { + let (file_data, pos) = safe_file_pos(&help_data, name.len()); + SingleBuilder::new(file_data.0, file_data.1) + .pos(pos) + .text(format!( + "'{}' is an untyped R function taking {} argument(s), called with {}.", + name, expected, got + )) + .pos_text(format!("Called with {} argument(s) here", got)) + .help("Its body is not type-checked; only the number of arguments is.") + .build() + } + TypeError::InvalidRefinement(msg, help_data) => { + let (file_data, pos) = safe_file_pos(&help_data, 1); + SingleBuilder::new(file_data.0, file_data.1) + .pos(pos) + .text(msg) + .pos_text("This refinement does not apply to that type") + .help( + "`length(n)` refines vectors (`[T] & length(n)`); `(> c)` and `(< c)` refine `int` and `num`.", + ) + .build() + } + TypeError::UnsatisfiableRefinement(msg, help_data) => { + let (file_data, pos) = safe_file_pos(&help_data, 1); + SingleBuilder::new(file_data.0, file_data.1) + .pos(pos) + .text(msg) + .pos_text("No value can have this type") + .help("Its properties contradict each other; relax one of them.") + .build() + } + TypeError::ConflictingBound(id, b1, b2, help_data) => { + let (file_data, pos) = safe_file_pos(&help_data, 1); + SingleBuilder::new(file_data.0, file_data.1) + .pos(pos) + .text(format!("'{}' is bounded by both '{}' and '{}'", id, b1, b2)) + .pos_text(format!("'{}' already bounded by '{}'", id, b1)) + .help("One id names one type, so it can satisfy only one interface here. Use a different id for the second bound.") + .build() + } + TypeError::IdBoundToTwoTypes(id, t1, h1, t2, h2) => { + let ((file_name1, text1), pos1) = safe_file_pos(&h1, 0); + let ((file_name2, text2), pos2) = safe_file_pos(&h2, 1); + DoubleBuilder::new(file_name1, text1, file_name2, text2) + .pos1(pos1) + .pos2(pos2) + .text(format!("'{}' is bound to '{}' and then to '{}' in the same call", id, t1, t2)) + .pos_text1(format!("'{}' is '{}' here", id, t1)) + .pos_text2(format!("but '{}' here", t2)) + .help(format!( + "Every parameter declared `@{}` must receive the same type. Give the parameters different ids (`@A`, `@B`) if they may differ.", + id + )) + .build() + } + TypeError::BoundCollidesWithGeneric(id, help_data) => { + let (file_data, pos) = safe_file_pos(&help_data, 1); + SingleBuilder::new(file_data.0, file_data.1) + .pos(pos) + .text(format!("'{}' is both a free generic and a bound id", id)) + .pos_text("Bound id clashes with a generic of the same name") + .help("Rename one of them: generics and `I@Id` ids share one namespace within a signature.") + .build() + } TypeError::NoDispatchImplementation(name, forced_type, available, help_data) => { let (file_data, pos) = safe_file_pos(&help_data, name.len()); let available_pretty = available.iter().map(|t| t.pretty()).collect::>().join(", "); diff --git a/crates/typr-core/src/components/language/mod.rs b/crates/typr-core/src/components/language/mod.rs index 1bfad3bf..6bea4b59 100644 --- a/crates/typr-core/src/components/language/mod.rs +++ b/crates/typr-core/src/components/language/mod.rs @@ -1035,6 +1035,78 @@ impl Lang { .clone() } + /// Sets the end offset (§7.3 of `visualization_graph_v2.md`) of this node's `HelpData` in + /// place. Called by the parser's per-production wrappers (the `_impl`/public-name split in + /// `processes/parsing`) once a node's own production has fully consumed its input. + pub fn set_help_data_end(&mut self, end: usize) { + let h: &mut HelpData = match self { + Lang::Number { help_data: h, .. } => h, + Lang::Integer { help_data: h, .. } => h, + Lang::Char { help_data: h, .. } => h, + Lang::Bool { help_data: h, .. } => h, + Lang::Union(_, _, h) => h, + Lang::Scope { help_data: h, .. } => h, + Lang::Function { help_data: h, .. } => h, + Lang::Module { help_data: h, .. } => h, + Lang::Variable { help_data: h, .. } => h, + Lang::FunctionApp { help_data: h, .. } => h, + Lang::VecFunctionApp { help_data: h, .. } => h, + Lang::ArrayIndexing { help_data: h, .. } => h, + Lang::Let { help_data: h, .. } => h, + Lang::Array { help_data: h, .. } => h, + Lang::List { help_data: h, .. } => h, + Lang::DataFrame { help_data: h, .. } => h, + Lang::Alias { help_data: h, .. } => h, + Lang::Tag { help_data: h, .. } => h, + Lang::If { help_data: h, .. } => h, + Lang::Match { help_data: h, .. } => h, + Lang::Tuple { help_data: h, .. } => h, + Lang::Lines { help_data: h, .. } => h, + Lang::Assign { help_data: h, .. } => h, + Lang::Comment { help_data: h, .. } => h, + Lang::ModuleImport { help_data: h, .. } => h, + Lang::ImportFrom { help_data: h, .. } => h, + Lang::Import { help_data: h, .. } => h, + Lang::GenFunc { help_data: h, .. } => h, + Lang::Test { help_data: h, .. } => h, + Lang::Return { help_data: h, .. } => h, + Lang::VecBlock { help_data: h, .. } => h, + Lang::RBlock { help_data: h, .. } => h, + Lang::Lambda { help_data: h, .. } => h, + Lang::Library { help_data: h, .. } => h, + Lang::Exp { help_data: h, .. } => h, + Lang::Empty(h) => h, + Lang::Signature { help_data: h, .. } => h, + Lang::TypeConstructor { help_data: h, .. } => h, + Lang::ForLoop { help_data: h, .. } => h, + Lang::RFunction { help_data: h, .. } => h, + Lang::ExternBlock { help_data: h, .. } => h, + Lang::KeyValue { help_data: h, .. } => h, + Lang::Vector { help_data: h, .. } => h, + Lang::Not { help_data: h, .. } => h, + Lang::Sequence { help_data: h, .. } => h, + Lang::TestBlock { help_data: h, .. } => h, + Lang::JSBlock(_, _, h) => h, + Lang::Use { help_data: h, .. } => h, + Lang::WhileLoop { help_data: h, .. } => h, + Lang::Loop { help_data: h, .. } => h, + Lang::Break(h) => h, + Lang::Next(h) => h, + Lang::Operator { help_data: h, .. } => h, + Lang::TypePattern { help_data: h, .. } => h, + Lang::Null(h) => h, + Lang::NA(h) => h, + Lang::Dots(h) => h, + Lang::UseModule { help_data: h, .. } => h, + Lang::ConstructorCall { help_data: h, .. } => h, + Lang::UnionConstructor { help_data: h, .. } => h, + Lang::ArrayConstructorCall { help_data: h, .. } => h, + Lang::ValidatingCast { help_data: h, .. } => h, + Lang::PartialApp { help_data: h, .. } => h, + }; + h.set_end(end); + } + pub fn linearize_array(&self) -> Vec { match self { Lang::Array { value: v, .. } => v.iter().fold(Vec::::new(), |acc, x: &Lang| { diff --git a/crates/typr-core/src/components/language/operators.rs b/crates/typr-core/src/components/language/operators.rs index bba62975..756103de 100644 --- a/crates/typr-core/src/components/language/operators.rs +++ b/crates/typr-core/src/components/language/operators.rs @@ -73,12 +73,7 @@ fn lang_to_cast_type(lang: &Lang) -> Option { [idx, elem] => (lang_to_cast_type(idx)?, elem), _ => return None, }; - Some(Type::Vec( - VecType::S3, - Box::new(idx), - Box::new(lang_to_cast_type(elem)?), - help_data.clone(), - )) + Some(Type::vec(VecType::S3, idx, lang_to_cast_type(elem)?, help_data.clone())) } Lang::ArrayIndexing { identifier, @@ -98,12 +93,7 @@ fn lang_to_cast_type(lang: &Lang) -> Option { [idx, elem] => (lang_to_cast_type(idx)?, elem), _ => return None, }; - Some(Type::Vec( - VecType::S3, - Box::new(idx), - Box::new(lang_to_cast_type(elem)?), - help_data.clone(), - )) + Some(Type::vec(VecType::S3, idx, lang_to_cast_type(elem)?, help_data.clone())) } _ => None, } diff --git a/crates/typr-core/src/components/syntax/mod.rs b/crates/typr-core/src/components/syntax/mod.rs index 9415ec6d..131020d3 100644 --- a/crates/typr-core/src/components/syntax/mod.rs +++ b/crates/typr-core/src/components/syntax/mod.rs @@ -429,6 +429,16 @@ impl SyntaxManifest { names usable bare in a type position; the ones that only exist in front of \ a delimiter are in `types.constructor` and `keywords.block`.", ), + Rule::new( + "types.refinement", + "support.type.builtin.typr", + words_before(&["length"], "\\s*\\(\\s*[\\d<>]"), + ) + .with_note( + "`[int] & length(5)`, `[int] & length(> 0)` (`parsing/types.rs::refinement_property`). \ + `length` is also R's most common function, so it only counts glued to \ + `(` or `(`: `length(x)` stays a plain call.", + ), Rule::new( "types.builtin-indexed", "support.type.builtin.typr", diff --git a/crates/typr-core/src/components/type/argument_type.rs b/crates/typr-core/src/components/type/argument_type.rs index 17392c2b..6de9b44a 100644 --- a/crates/typr-core/src/components/type/argument_type.rs +++ b/crates/typr-core/src/components/type/argument_type.rs @@ -97,10 +97,10 @@ impl ArgumentType { pub fn body_type(&self) -> Type { if self.3 { let h = self.1.get_help_data(); - Type::Vec( + Type::vec( VecType::S3, - Box::new(Type::IndexGen("N".to_string(), h.clone())), - Box::new(self.1.clone()), + Type::IndexGen("N".to_string(), h.clone()), + self.1.clone(), h, ) } else { diff --git a/crates/typr-core/src/components/type/array_type.rs b/crates/typr-core/src/components/type/array_type.rs index cb68af59..c94d3315 100644 --- a/crates/typr-core/src/components/type/array_type.rs +++ b/crates/typr-core/src/components/type/array_type.rs @@ -35,8 +35,8 @@ impl TryFrom for ArrayType { fn try_from(value: Type) -> Result { match value { - Type::Vec(vtyp, t1, t2, h) => Ok(ArrayType { - index: *t1, + Type::Vec(_, length, t2, h) => Ok(ArrayType { + index: length.to_type(h.clone()), type_: *t2, help_data: h, }), diff --git a/crates/typr-core/src/components/type/function_type.rs b/crates/typr-core/src/components/type/function_type.rs index 2eed93d2..7e0fe311 100644 --- a/crates/typr-core/src/components/type/function_type.rs +++ b/crates/typr-core/src/components/type/function_type.rs @@ -246,8 +246,15 @@ impl FunctionType { self.help_data.clone() } + /// RFC 0028: an untyped R function's signature is exactly n `Any` + /// parameters (n possibly 0), non-variadic, returning `Any` — the shape + /// `Lang::RFunction`'s typing rule now produces. Used to pick the + /// arity-specific error message over the generic no-matching-signature + /// one when a call's argument count doesn't match. pub fn is_r_function(&self) -> bool { - (self.arguments == vec![]) && (self.return_type == builder::unknown_function_type()) + !self.is_variadic + && self.return_type == builder::any_type() + && self.arguments.iter().all(|t| *t == builder::any_type()) } pub fn get_first_param(&self) -> Option { @@ -284,7 +291,13 @@ impl TryFrom for FunctionType { fn try_from(value: Type) -> Result { match value { Type::Function(args, ret, h) => Ok(FunctionType::new(VecType::Empty, args, *ret, h)), - Type::UnknownFunction(h) => Ok(FunctionType::default().set_help_data(h.clone())), + // RFC 0028: same variadic-Any shape as `Type::to_function_type()`. + Type::UnknownFunction(h) => Ok(FunctionType::new( + VecType::Empty, + vec![ArgumentType::new("...", &builder::any_type()).set_variadic(true)], + builder::any_type(), + h.clone(), + )), _ => Err(format!("{} is a type not convertible to FunctionType", value)), } } diff --git a/crates/typr-core/src/components/type/mod.rs b/crates/typr-core/src/components/type/mod.rs index e6014e4b..57093791 100644 --- a/crates/typr-core/src/components/type/mod.rs +++ b/crates/typr-core/src/components/type/mod.rs @@ -8,6 +8,7 @@ pub mod index; pub mod intersection_type; pub mod kind; pub mod module_type; +pub mod refinement; pub mod tbool; pub mod tchar; pub mod tint; @@ -29,6 +30,7 @@ use crate::components::r#type::function_type::FunctionType; use crate::components::r#type::intersection_type::IntersectionType; use crate::components::r#type::kind::Kind; use crate::components::r#type::module_type::ModuleType; +use crate::components::r#type::refinement::{Interval, Length, Measure, Refinement, RefinementSet}; use crate::components::r#type::tbool::Tbool; use crate::components::r#type::tchar::Tchar; use crate::components::r#type::tint::Tint; @@ -60,6 +62,14 @@ thread_local! { RefCell::new(HashMap::new()); } +fn mentions_rigid(typ: &Type) -> bool { + let mut found = false; + crate::processes::type_checking::signature_normalization::visit(typ, &mut |t| { + found |= matches!(t, Type::Generic(name, _) if is_rigid_name(name)); + }); + found +} + pub fn clear_subtype_cache() { SUBTYPE_CACHE.with(|c| c.borrow_mut().clear()); } @@ -109,7 +119,7 @@ pub enum Type { Generic(String, HelpData), IndexGen(String, HelpData), LabelGen(String, HelpData), - Vec(VecType, Box, Box, HelpData), + Vec(VecType, Length, Box, HelpData), Record(HashSet, HelpData), Module(Vec, Vec, HelpData), Alias(String, Vec, bool, HelpData), //for opacity @@ -132,6 +142,18 @@ pub enum Type { Null(HelpData), NA(HelpData), KindedGen(Kind, String, HelpData), + // Refinement types. Appended last: the std `.bin` files serialise `Type` + // by variant index, so existing indices must not move. + /// `base & properties`, in normal form (see `refinement.rs`). + Refined(Box, RefinementSet, HelpData), + /// A bare property (`length(5)`, `(> 0)`). Parser output only: it must + /// be folded into a `Refined` by `norm_intersection` and never survive + /// `reduce_type`. + Property(Refinement, HelpData), + /// `Lovable@A`: an implicit generic `A` bounded by an interface (RFC + /// `interface_generic_unification`). Appended last for the `.bin` layout. + /// Only meaningful in signatures; the id `_` is anonymous. + Bounded(String, Box, HelpData), } /// Structural fallback for interface-method-set comparison: for every @@ -197,6 +219,13 @@ impl TypeSystem for Type { } fn is_subtype(&self, other: &Type, context: &Context) -> (bool, Option) { + // The cache key hashes every generic alike and `PartialEq` deems any + // two generics equal, so `__rigid_1 <: __rigid_0` would be answered by + // an unrelated earlier `T <: U`. Rigids are distinct unknowns: never + // cache a query that mentions one. + if mentions_rigid(self) || mentions_rigid(other) { + return (self.is_subtype_raw(other, context), None); + } let key = (self.clone(), other.clone()); let cached = SUBTYPE_CACHE.with(|c| c.borrow().get(&key).copied()); if let Some(result) = cached { @@ -213,13 +242,36 @@ impl TypeSystem for Type { fn is_subtype_raw(&self, other: &Type, context: &Context) -> bool { match (self, other) { (Type::Empty(_), _) => true, + // Rigid variables (`Lovable@A` in a body) are distinct unknowns: + // `PartialEq` deems any two generics equal, which must not make + // `__rigid_0` a subtype of `__rigid_1`. + (Type::Generic(a, _), Type::Generic(b, _)) if is_rigid_name(a) && is_rigid_name(b) => a == b, (typ1, typ2) if typ1 == typ2 => true, + // Refined types (plan §Phase 4). Only `Proven` answers `true`: an + // unproven refinement is never assumed, the boundary check of + // Phase 5 is what turns it into a runtime obligation. These arms + // come before the generic `Intersection` ones, which would + // otherwise ask `t1 <: typ || t2 <: typ` of a bare `Property`. + (Type::Refined(a, ps, _), Type::Refined(b, qs, _)) => { + a.is_subtype_raw(b, context) && a.refinements_of().meet(ps).implies(qs) + } + (Type::Refined(a, _, _), typ) => a.is_subtype_raw(typ, context), + (typ, Type::Refined(b, qs, _)) => typ.is_subtype_raw(b, context) && typ.refinements_of().implies(qs), (Type::Operator(TypeOperator::Intersection, t1, t2, _), typ) => { t1.is_subtype_raw(typ, context) || t2.is_subtype_raw(typ, context) } (_, Type::Any(_)) => true, - (Type::Vec(_, n1, t1, _), Type::Vec(_, n2, t2, _)) => { - n1.is_subtype_raw(n2, context) && t1.is_subtype_raw(t2, context) + (Type::Vec(_, n1, t1, h1), Type::Vec(_, n2, t2, h2)) => { + let length_ok = match (n1, n2) { + // Numeric ranges, or a symbolic length against a range: + // interval inclusion (`[#N + 1, T] <: [T] & length(> 0)`). + (_, Length::Range(_)) => n1.implies(n2), + // A proper range has no index form: it never meets an + // index expression. + (Length::Range(_), Length::Sym(_)) if n1.as_proper_range().is_some() => false, + _ => n1.to_type(h1.clone()).is_subtype_raw(&n2.to_type(h2.clone()), context), + }; + length_ok && t1.is_subtype_raw(t2, context) } // A function type is structural: only the arity, the parameter // *types* and the return type carry meaning. Parameter names are a @@ -267,9 +319,6 @@ impl TypeSystem for Type { p1.len() == p2.len() && p1.iter().zip(p2.iter()).all(|(t1, t2)| t1.is_subtype_raw(t2, context)) } (Type::RClass(set1, _), Type::RClass(set2, _)) => set1.is_subset(set2), - (Type::Operator(TypeOperator::Union, _t1, _t2, _), Type::Operator(TypeOperator::Union, _tp1, _tp2, _)) => { - true - } //TODO: Fix this // A union is a subtype of `T` when *every* member is. Without this // rule an `if`/`else` over literal-typed branches — which unions // their types — could never satisfy the base annotation: @@ -346,14 +395,77 @@ impl Type { } } + /// Builds a vector type. `length` is the length slot: `Integer(n)` for a + /// known length, `Any` for an unsized vector, or an index generic / + /// index arithmetic (`#N`, `#N+1`). Every construction and every read of + /// that slot goes through `vec` / `vec_length` so that where + /// the length lives (today the `Vec` index, eventually `Measure::Length` + /// in a `Refined`) is decided in one place. + pub fn vec(kind: VecType, length: Type, elem: Type, help: HelpData) -> Type { + Type::Vec(kind, Length::from_type(&length), Box::new(elem), help) + } + + /// The length of a `Vec`, `None` for any other type. Returned by value: + /// once the length lives in a `Refined` it has to be rebuilt on demand, + /// not borrowed. Cheap (an index type is a literal or a tiny tree). + pub fn vec_length(&self) -> Option { + match self { + Type::Vec(_, length, _, help) => Some(length.to_type(help.clone())), + _ => None, + } + } + + /// The element type of a `Vec`. + pub fn vec_elem(&self) -> Option<&Type> { + match self { + Type::Vec(_, _, elem, _) => Some(elem), + _ => None, + } + } + + /// `self` with its length slot replaced when it is a `Vec`; any other + /// type is returned unchanged. + pub fn with_vec_length(&self, length: Type) -> Type { + match self { + Type::Vec(kind, _, elem, help) => Type::vec(kind.clone(), length, (**elem).clone(), help.clone()), + t => t.clone(), + } + } + + /// A `Vec` whose length is a proper range (`length(> 0)`) reads as + /// unsized; anything else is returned unchanged. For code that reasons + /// on the shape of a vector, which a range does not change. + pub fn without_length_range(self) -> Type { + match &self { + Type::Vec(kind, l, elem, help) if l.as_proper_range().is_some() => { + Type::Vec(kind.clone(), Length::unknown(), elem.clone(), help.clone()) + } + _ => self, + } + } + + /// `self` with its element type replaced when it is a `Vec`. + pub fn with_vec_elem(&self, elem: Type) -> Type { + match self { + Type::Vec(kind, length, _, help) => Type::Vec(kind.clone(), length.clone(), Box::new(elem), help.clone()), + t => t.clone(), + } + } + pub fn lift(self, max_index: &(VecType, i32)) -> Type { match self.clone() { - Type::Vec(_, i, _, _) if i.equal(max_index.1) => self, - Type::Vec(v, _, t, h) => Type::Vec(v, Box::new(builder::integer_type(max_index.1)), t.clone(), h.clone()), - t => Type::Vec( + Type::Vec(_, i, _, _) if i.as_known() == Some(max_index.1) => self, + // A parameter that already fixes a length keeps it: lifting only + // widens scalars and unsized vectors, it must not turn `[2, num]` + // into `[3, num]` to make a `[3, num]` argument fit. + Type::Vec(_, i, _, _) if matches!(i, Length::Range(iv) if iv.as_point().is_some()) => self, + // `length(> 0)` and the like constrain the length: not widened. + Type::Vec(_, i, _, _) if i.as_proper_range().is_some() => self, + Type::Vec(v, _, t, h) => Type::Vec(v, Length::known(max_index.1), t.clone(), h.clone()), + t => Type::vec( max_index.0.clone(), - Box::new(builder::integer_type(max_index.1)), - Box::new(t.clone()), + builder::integer_type(max_index.1), + t.clone(), t.get_help_data(), ), } @@ -576,10 +688,10 @@ impl Type { .collect::>() .into_iter() .collect::>(), - Type::Vec(_, ind, typ, _) => typ + Type::Vec(_, ind, typ, h) => typ .extract_generics() .iter() - .chain(ind.extract_generics().iter()) + .chain(ind.to_type(h.clone()).extract_generics().iter()) .collect::>() .into_iter() .cloned() @@ -614,10 +726,10 @@ impl Type { a.index_calculation().mul_index(&b.index_calculation()) } Type::Operator(TypeOperator::Division, a, b, _) => a.index_calculation().div_index(&b.index_calculation()), - Type::Vec(vtype, ind, typ, h) => Type::Vec( + Type::Vec(vtype, ind, typ, h) => Type::vec( vtype.clone(), - Box::new(ind.index_calculation()), - Box::new(typ.index_calculation()), + ind.to_type(h.clone()).index_calculation(), + typ.index_calculation(), h.clone(), ), Type::Function(args, ret_typ, h) => { @@ -668,8 +780,8 @@ impl Type { } pub fn get_shape(&self) -> Option { - if let Type::Vec(_, i, t, _) = self { - match (*i.clone(), t.get_shape()) { + if let Type::Vec(_, i, t, h) = self { + match (i.to_type(h.clone()), t.get_shape()) { (Type::IndexGen(_, _), _) => Some("dim(===)".to_string()), (Type::Integer(j, _), Some(rest)) => Some(format!("{}, {}", j, rest)), (Type::Integer(j, _), None) => Some(format!("{}", j)), @@ -688,10 +800,13 @@ impl Type { (**ret_ty).clone(), h.clone(), )), + // RFC 0028: preloaded untyped builtins (`Position`, `t`, `Reduce`, + // …) carry no declared arity, so they accept any number of + // arguments — a single variadic `Any` parameter, returning `Any`. Type::UnknownFunction(h) => Some(FunctionType::new( VecType::Empty, - vec![], - builder::unknown_function_type(), + vec![ArgumentType::new("...", &builder::any_type()).set_variadic(true)], + builder::any_type(), h.clone(), )), _ => None, @@ -768,8 +883,45 @@ impl Type { } } + /// The refinements this type carries, read back from its structure: + /// the length index of a `Vec` (`[5, int]` is `[int] & length(5)`, D4), + /// the value of a literal, or the set of an explicit `Refined`. + pub fn refinements_of(&self) -> RefinementSet { + match self { + Type::Vec(_, length, _, _) => { + let iv = length.bounds(); + // A symbolic length only contributes what it provably has + // (`#N + 1` is at least 1); `>= 0` is true of every length. + let informative = match length { + Length::Range(_) => !iv.is_full(), + Length::Sym(_) => iv != Interval::at_least(0.0), + }; + if informative { + RefinementSet::empty().with(Measure::Length, iv) + } else { + RefinementSet::empty() + } + } + Type::Integer(Tint::Val(n), _) => RefinementSet::empty().with(Measure::Value, Interval::point(*n as f64)), + Type::Number(Tnum::Val(v), _) => RefinementSet::empty().with(Measure::Value, Interval::point(*v)), + Type::Refined(base, refs, _) => base.refinements_of().meet(refs), + _ => RefinementSet::empty(), + } + } + + /// `self` without its explicit refinements (`int & (> 0)` gives `int`). + /// The length index of a `Vec` is part of its structure and is kept. + pub fn unrefined(&self) -> &Type { + match self { + Type::Refined(base, _, _) => base.unrefined(), + t => t, + } + } + pub fn to_category(&self) -> TypeCategory { match self { + // A refined type dispatches like its base (`Refined <: base`). + Type::Refined(base, _, _) => base.to_category(), Type::Vec(_, _, _, _) => TypeCategory::Array, Type::Function(_, _, _) => TypeCategory::Function, Type::Record(_, _) => TypeCategory::Record, @@ -783,6 +935,7 @@ impl Type { Type::LabelGen(_, _) => TypeCategory::Generic, Type::IndexGen(_, _) => TypeCategory::GenericKinded(GKind::Number), Type::KindedGen(k, _, _) => TypeCategory::GenericKinded(GKind::from_kind(*k)), + Type::Bounded(_, bound, _) => bound.to_category(), Type::Integer(_, _) => TypeCategory::Integer, Type::Alias(_, _, false, _) => TypeCategory::Alias, Type::Alias(name, _, _, _) => TypeCategory::Opaque(name.clone()), @@ -865,6 +1018,9 @@ impl Type { Type::Null(h) => h.clone(), Type::NA(h) => h.clone(), Type::KindedGen(_, _, h) => h.clone(), + Type::Bounded(_, _, h) => h.clone(), + Type::Refined(_, _, h) => h.clone(), + Type::Property(_, h) => h.clone(), } } @@ -901,6 +1057,9 @@ impl Type { Type::Null(_) => Type::Null(h2), Type::NA(_) => Type::NA(h2), Type::KindedGen(k, a, _) => Type::KindedGen(k, a, h2), + Type::Bounded(id, b, _) => Type::Bounded(id, b, h2), + Type::Refined(b, r, _) => Type::Refined(b, r, h2), + Type::Property(p, _) => Type::Property(p, h2), } } @@ -918,7 +1077,7 @@ impl Type { pub fn to_array(&self) -> Option { match self { Type::Vec(_, t1, t2, h) => Some(Array { - index: (**t1).clone(), + index: t1.to_type(h.clone()), base_type: (**t2).clone(), help_data: h.clone(), }), @@ -1047,7 +1206,11 @@ impl Type { pub fn linearize(self) -> Vec { match self { - Type::Vec(_, t1, t2, _) => [*t1].iter().chain((*t2).linearize().iter()).cloned().collect(), + Type::Vec(_, t1, t2, h) => [t1.to_type(h)] + .iter() + .chain((*t2).linearize().iter()) + .cloned() + .collect(), other => vec![other], } } @@ -1060,9 +1223,10 @@ impl Type { pub fn from_linear(mut dims_and_base: Vec) -> Type { match dims_and_base.pop() { None => builder::any_type(), - Some(base) => dims_and_base.into_iter().rev().fold(base, |acc, dim| { - Type::Vec(VecType::S3, Box::new(dim), Box::new(acc), HelpData::default()) - }), + Some(base) => dims_and_base + .into_iter() + .rev() + .fold(base, |acc, dim| Type::vec(VecType::S3, dim, acc, HelpData::default())), } } @@ -1090,7 +1254,11 @@ impl Type { /// [3, T] -> rank: 3, vector type: Array, Type T pub fn get_size_type(&self) -> (i32, VecType, Type) { match self { - Type::Vec(v, i, t, _) => (i.get_index().unwrap_or(0) as i32, v.clone(), (**t).clone()), + Type::Vec(v, i, t, h) => ( + i.to_type(h.clone()).get_index().unwrap_or(0) as i32, + v.clone(), + (**t).clone(), + ), typ => (1, VecType::Empty, typ.clone()), } } @@ -1110,7 +1278,7 @@ impl Type { if let VecType::Named(name) = vtype { acc.push((name.clone(), h.clone())); } - idx.collect_named_constructors_into(acc); + idx.to_type(h.clone()).collect_named_constructors_into(acc); body.collect_named_constructors_into(acc); } Type::Function(args, ret, _) => { @@ -1231,6 +1399,9 @@ impl PartialEq for Type { ) => IntersectionType::try_from(self.clone()).ok() == IntersectionType::try_from(other.clone()).ok(), (Type::Operator(op1, a1, b1, _), Type::Operator(op2, a2, b2, _)) => op1 == op2 && a1 == a2 && b1 == b2, (Type::Module(a1, _, _), Type::Module(a2, _, _)) => a1 == a2, + (Type::Refined(b1, r1, _), Type::Refined(b2, r2, _)) => b1 == b2 && r1 == r2, + (Type::Property(p1, _), Type::Property(p2, _)) => p1 == p2, + (Type::Bounded(i1, b1, _), Type::Bounded(i2, b2, _)) => i1 == i2 && b1 == b2, _ => false, } } @@ -1260,8 +1431,9 @@ impl PartialOrd for Type { (typ1, typ2) if typ1 == typ2 => Some(Ordering::Equal), // Array subtyping (_, Type::Any(_)) => Some(Ordering::Less), - (Type::Vec(_, n1, t1, _), Type::Vec(_, n2, t2, _)) => { - (n1.partial_cmp(n2).is_some() && t1.partial_cmp(t2).is_some()).then_some(Ordering::Less) + (Type::Vec(_, n1, t1, h1), Type::Vec(_, n2, t2, h2)) => { + (n1.to_type(h1.clone()).partial_cmp(&n2.to_type(h2.clone())).is_some() && t1.partial_cmp(t2).is_some()) + .then_some(Ordering::Less) } (Type::Function(args1, ret_typ1, _), Type::Function(args2, ret_typ2, _)) => { let args1_types: Vec = args1.iter().map(|arg| arg.get_type()).collect(); @@ -1343,6 +1515,12 @@ impl Hash for Type { } Type::Char(_, _) => 3.hash(state), Type::Function(_, _, _) => 5.hash(state), + // Rigid names take part in the hash (though `PartialEq` ignores them) so + // the subtype cache keeps `(r0, r1)` apart from `(r0, r0)`. + Type::Generic(name, _) if is_rigid_name(name) => { + 6.hash(state); + name.hash(state); + } Type::Generic(_, _) => 6.hash(state), Type::IndexGen(_, _) => 7.hash(state), Type::LabelGen(_, _) => 8.hash(state), @@ -1372,6 +1550,20 @@ impl Hash for Type { 42.hash(state); k.hash(state); } + Type::Refined(base, refs, _) => { + 43.hash(state); + base.hash(state); + refs.hash(state); + } + Type::Property(p, _) => { + 44.hash(state); + p.hash(state); + } + Type::Bounded(id, bound, _) => { + 45.hash(state); + id.hash(state); + bound.hash(state); + } } } } @@ -1696,4 +1888,36 @@ mod tests { let ac = builder::union_type(&[a, c]); assert_ne!(ab, ac); } + + fn refined_int(set: RefinementSet) -> Type { + Type::Refined( + Box::new(Type::Integer(Tint::Unknown, HelpData::default())), + set, + HelpData::default(), + ) + } + fn gt(c: f64) -> RefinementSet { + RefinementSet::single(&Refinement::Gt(refinement::Num::new(c))) + } + + /// Phase 4: only a *proven* refinement makes a subtype. + #[test] + fn refined_subtyping_answers_true_only_when_proven() { + let ctx = Context::default(); + let int = Type::Integer(Tint::Unknown, HelpData::default()); + let lit = |n| Type::Integer(Tint::Val(n), HelpData::default()); + let (pos, big) = (refined_int(gt(0.0)), refined_int(gt(5.0))); + + assert!(lit(3).is_subtype(&pos, &ctx).0, "3 <: int & (> 0)"); + assert!(!lit(-3).is_subtype(&pos, &ctx).0, "-3 is refuted"); + assert!(!int.is_subtype(&pos, &ctx).0, "int is unknown, so not a subtype"); + assert!(pos.is_subtype(&int, &ctx).0, "Refined <: base"); + assert!(big.is_subtype(&pos, &ctx).0, "(> 5) implies (> 0)"); + assert!(!pos.is_subtype(&big, &ctx).0, "(> 0) does not imply (> 5)"); + } +} + +/// Name prefix given by `Context::fresh_rigid_name`. +pub(crate) fn is_rigid_name(name: &str) -> bool { + name.starts_with("__rigid_") } diff --git a/crates/typr-core/src/components/type/refinement.rs b/crates/typr-core/src/components/type/refinement.rs new file mode 100644 index 00000000..f87dd014 --- /dev/null +++ b/crates/typr-core/src/components/type/refinement.rs @@ -0,0 +1,720 @@ +//! Refinement algebra: properties normalised as *intervals over measures*. +//! +//! A refinement such as `length(5)` or `(> 0)` is not kept as a bare +//! predicate. It is folded into an [`Interval`] on a [`Measure`] (the length +//! of a vector, the value of a scalar), and a [`RefinementSet`] holds at most +//! one interval per measure. That gives, by construction: +//! +//! * commutativity / associativity / idempotence of `&` (interval meet), +//! * `contradicts` = empty interval, `implies` = interval inclusion, +//! * a canonical, deterministic `Hash`/`Eq` (the set is kept sorted), which +//! the subtype cache relies on. +//! +//! Nothing here knows about `Type`; see `type_arithmetic` for the wiring. + +use crate::components::error_message::help_data::HelpData; +use crate::components::r#type::tint::Tint; +use crate::components::r#type::Type; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::fmt; +use std::hash::{Hash, Hasher}; + +/// A finite `f64` with a total order and a canonical `Hash`/`Eq` +/// (so `-0.0 == 0.0`). Mirrors what `Tnum` does for its payload. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct Num(f64); + +impl Num { + pub fn new(v: f64) -> Self { + // Normalise -0.0 so that equality and hashing agree. + Num(if v == 0.0 { 0.0 } else { v }) + } + + pub fn get(&self) -> f64 { + self.0 + } +} + +impl PartialEq for Num { + fn eq(&self, other: &Self) -> bool { + self.0.total_cmp(&other.0) == Ordering::Equal + } +} +impl Eq for Num {} + +impl PartialOrd for Num { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Num { + fn cmp(&self, other: &Self) -> Ordering { + self.0.total_cmp(&other.0) + } +} +impl Hash for Num { + fn hash(&self, state: &mut H) { + self.0.to_bits().hash(state); + } +} + +impl fmt::Display for Num { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// What a property talks about. Extensible (`Nchar`, `Nrow`, `Ncol`, ...). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Measure { + /// The value itself (`(> 0)` on `int`/`num`; every element for a vector). + Value, + /// `length(x)`. + Length, +} + +impl fmt::Display for Measure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Measure::Value => write!(f, "value"), + Measure::Length => write!(f, "length"), + } + } +} + +/// One end of an interval. On the lower end `Open(c)` means `> c`; on the +/// upper end it means `< c`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Bound { + Unbounded, + Open(Num), + Closed(Num), +} + +impl Bound { + fn value(&self) -> Option { + match self { + Bound::Unbounded => None, + Bound::Open(n) | Bound::Closed(n) => Some(*n), + } + } + + /// The tighter of two *lower* bounds. + fn max_lower(self, other: Bound) -> Bound { + match (self, other) { + (Bound::Unbounded, b) | (b, Bound::Unbounded) => b, + (a, b) => match a.value().cmp(&b.value()) { + Ordering::Greater => a, + Ordering::Less => b, + // Same constant: the open one excludes more. + Ordering::Equal => match a { + Bound::Open(_) => a, + _ => b, + }, + }, + } + } + + /// The tighter of two *upper* bounds. + fn min_upper(self, other: Bound) -> Bound { + match (self, other) { + (Bound::Unbounded, b) | (b, Bound::Unbounded) => b, + (a, b) => match a.value().cmp(&b.value()) { + Ordering::Less => a, + Ordering::Greater => b, + Ordering::Equal => match a { + Bound::Open(_) => a, + _ => b, + }, + }, + } + } +} + +/// A (possibly unbounded) interval of reals. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Interval { + lo: Bound, + hi: Bound, +} + +impl Interval { + pub fn new(lo: Bound, hi: Bound) -> Self { + Interval { lo, hi } + } + + pub fn full() -> Self { + Interval::new(Bound::Unbounded, Bound::Unbounded) + } + + /// `[c, c]` + pub fn point(c: f64) -> Self { + let n = Num::new(c); + Interval::new(Bound::Closed(n), Bound::Closed(n)) + } + + /// `(c, +inf)` + pub fn greater_than(c: f64) -> Self { + Interval::new(Bound::Open(Num::new(c)), Bound::Unbounded) + } + + /// `[c, +inf)` + pub fn at_least(c: f64) -> Self { + Interval::new(Bound::Closed(Num::new(c)), Bound::Unbounded) + } + + /// `(-inf, c)` + pub fn less_than(c: f64) -> Self { + Interval::new(Bound::Unbounded, Bound::Open(Num::new(c))) + } + + /// `(-inf, c]` + pub fn at_most(c: f64) -> Self { + Interval::new(Bound::Unbounded, Bound::Closed(Num::new(c))) + } + + pub fn lo(&self) -> Bound { + self.lo + } + + pub fn hi(&self) -> Bound { + self.hi + } + + pub fn is_full(&self) -> bool { + *self == Interval::full() + } + + /// The single value of a degenerate `[c, c]` interval. + pub fn as_point(&self) -> Option { + match (self.lo, self.hi) { + (Bound::Closed(a), Bound::Closed(b)) if a == b => Some(a.get()), + _ => None, + } + } + + pub fn is_empty(&self) -> bool { + match (self.lo.value(), self.hi.value()) { + (Some(l), Some(h)) => match l.cmp(&h) { + Ordering::Greater => true, + Ordering::Equal => !(matches!(self.lo, Bound::Closed(_)) && matches!(self.hi, Bound::Closed(_))), + Ordering::Less => false, + }, + _ => false, + } + } + + /// Intersection (`&`). + pub fn meet(&self, other: &Interval) -> Interval { + Interval::new(self.lo.max_lower(other.lo), self.hi.min_upper(other.hi)) + } + + /// `self ⊆ other`: every value allowed by `self` is allowed by `other`. + /// An empty `self` implies everything. + pub fn implies(&self, other: &Interval) -> bool { + self.is_empty() || self.meet(other) == *self + } + + /// Some value satisfies both. + pub fn compatible(&self, other: &Interval) -> bool { + !self.meet(other).is_empty() + } + + /// No value satisfies both. + pub fn contradicts(&self, other: &Interval) -> bool { + !self.compatible(other) + } + + /// Tighten the bounds to integers: `(> 0)` becomes `[1, +inf)`, + /// `(< 1)` becomes `(-inf, 0]`. Used when the base type is `int`, so that + /// `int & (> 0) & (< 1)` is detected as empty. + pub fn to_integral(&self) -> Interval { + let lo = match self.lo { + Bound::Unbounded => Bound::Unbounded, + Bound::Closed(n) => Bound::Closed(Num::new(n.get().ceil())), + Bound::Open(n) => Bound::Closed(Num::new(n.get().floor() + 1.0)), + }; + let hi = match self.hi { + Bound::Unbounded => Bound::Unbounded, + Bound::Closed(n) => Bound::Closed(Num::new(n.get().floor())), + Bound::Open(n) => Bound::Closed(Num::new(n.get().ceil() - 1.0)), + }; + Interval::new(lo, hi) + } +} + +impl Measure { + /// Renders an interval on this measure in surface syntax, one property + /// per bound: `length(5)`, `(> 0) & (< 10)`. + pub fn display(&self, iv: &Interval) -> String { + if let Some(p) = iv.as_point() { + return match self { + Measure::Length => format!("length({})", p), + Measure::Value => format!("(== {})", Num::new(p)), + }; + } + let mut parts = Vec::new(); + let (lo_op, hi_op) = match self { + Measure::Value => (("(> ", "(>= "), ("(< ", "(<= ")), + Measure::Length => (("length(> ", "length(>= "), ("length(< ", "length(<= ")), + }; + match iv.lo { + Bound::Open(n) => parts.push(format!("{}{})", lo_op.0, n)), + Bound::Closed(n) => parts.push(format!("{}{})", lo_op.1, n)), + Bound::Unbounded => {} + } + match iv.hi { + Bound::Open(n) => parts.push(format!("{}{})", hi_op.0, n)), + Bound::Closed(n) => parts.push(format!("{}{})", hi_op.1, n)), + Bound::Unbounded => {} + } + parts.join(" & ") + } +} + +/// Surface form of a single property, as the parser produces it. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Refinement { + /// `length(5)` + Length(i32), + /// `(> c)` + Gt(Num), + /// `(< c)` + Lt(Num), + /// Every other bound: `(>= c)`, `(<= c)`, `length(> n)`, `length(<= n)`... + Range(Measure, Interval), +} + +impl Refinement { + pub fn measure(&self) -> Measure { + match self { + Refinement::Length(_) => Measure::Length, + Refinement::Gt(_) | Refinement::Lt(_) => Measure::Value, + Refinement::Range(m, _) => *m, + } + } + + pub fn interval(&self) -> Interval { + match self { + Refinement::Length(n) => Interval::point(*n as f64), + Refinement::Gt(c) => Interval::greater_than(c.get()), + Refinement::Lt(c) => Interval::less_than(c.get()), + Refinement::Range(_, iv) => *iv, + } + } +} + +impl fmt::Display for Refinement { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Refinement::Length(n) => write!(f, "length({})", n), + Refinement::Gt(c) => write!(f, "(> {})", c), + Refinement::Lt(c) => write!(f, "(< {})", c), + Refinement::Range(m, iv) => write!(f, "{}", m.display(iv)), + } + } +} + +/// The length of a vector, i.e. the [`Measure::Length`] value stored on +/// `Type::Vec`. A numeric length is an [`Interval`] (`[5, T]` is `[5, 5]`, +/// an unsized `[T]` is the full interval); a length that cannot be decided +/// numerically (`#N`, `#N + 1`) keeps its index expression symbolic. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Length { + Range(Interval), + Sym(Box), +} + +impl Length { + /// No known length (`[int]`). + pub fn unknown() -> Self { + Length::Range(Interval::full()) + } + + pub fn known(n: i32) -> Self { + Length::Range(Interval::point(n as f64)) + } + + /// Reads a length index as written in a type: an integer literal, `any` + /// (unsized), or an index expression kept as-is. + pub fn from_type(t: &Type) -> Self { + match t { + Type::Integer(Tint::Val(n), _) => Length::known(*n), + Type::Any(_) => Length::unknown(), + other => Length::Sym(Box::new(other.clone())), + } + } + + /// The length as an index type. A numeric range that is neither a point + /// nor unbounded has no index form and reads as unsized. + pub fn to_type(&self, help: HelpData) -> Type { + match self { + Length::Range(iv) => match iv.as_point() { + Some(n) => Type::Integer(Tint::Val(n as i32), help), + None => Type::Any(help), + }, + Length::Sym(t) => (**t).clone(), + } + } + + /// The length as an interval on [`Measure::Length`]; `None` when symbolic. + pub fn interval(&self) -> Option { + match self { + Length::Range(iv) => Some(*iv), + Length::Sym(_) => None, + } + } + + /// The exact length when it is a known literal. + pub fn as_known(&self) -> Option { + match self { + Length::Range(iv) => iv.as_point().map(|n| n as i32), + Length::Sym(_) => None, + } + } + + /// A numeric range that is neither a point nor unbounded (`length(> 0)`): + /// the only lengths with no index form, printed as a refinement. + pub fn as_proper_range(&self) -> Option { + match self { + Length::Range(iv) if iv.as_point().is_none() && !iv.is_full() => Some(*iv), + _ => None, + } + } + + /// The surface property for [`Length::as_proper_range`] (`length(> 0)`). + pub fn range_property(&self) -> Option { + self.as_proper_range().map(|iv| Measure::Length.display(&iv)) + } + + /// An interval every length denoted by `self` lies in. Exact for a + /// range; for a symbolic length it is a sound lower bound (a length is + /// never negative, `#N + 2` is at least 2) with no upper bound. + pub fn bounds(&self) -> Interval { + match self { + Length::Range(iv) => *iv, + Length::Sym(t) => Interval::at_least(sym_lower_bound(t).max(0.0)), + } + } + + /// `self ⊆ other`, decided on the bounds. A symbolic length only implies + /// another symbolic one when they are the same expression. + pub fn implies(&self, other: &Length) -> bool { + match (self, other) { + (Length::Sym(a), Length::Sym(b)) => a == b, + (_, Length::Sym(_)) => false, + (_, Length::Range(iv)) => self.bounds().implies(iv), + } + } + + /// No length can satisfy both. + pub fn contradicts(&self, other: &Length) -> bool { + match (self, other) { + (Length::Sym(_), Length::Sym(_)) => false, + _ => self.bounds().contradicts(&other.bounds()), + } + } + + /// Intersect with `iv`. `None` when the result has no representation: + /// a symbolic length narrowed by a range it does not already satisfy + /// stays a `Refined` property. `Some(Err)` is a contradiction. + pub fn meet_interval(&self, iv: &Interval) -> Option> { + match self { + Length::Range(cur) => { + // Kept as written (`> 0`, not `>= 1`): the bound is what the + // runtime check and the printer show. Emptiness is judged on + // integers, lengths being whole numbers. + let m = cur.meet(iv); + Some(if m.to_integral().is_empty() { + Err(()) + } else { + Ok(Length::Range(m)) + }) + } + Length::Sym(_) if self.bounds().implies(iv) => Some(Ok(self.clone())), + Length::Sym(_) if self.bounds().contradicts(iv) => Some(Err(())), + Length::Sym(_) => None, + } + } +} + +/// Lower bound of an index expression read as a length: literals and sums +/// are exact, an index generic is only known to be a length (>= 0). +fn sym_lower_bound(t: &Type) -> f64 { + use crate::components::r#type::type_operator::TypeOperator; + match t { + Type::Integer(Tint::Val(n), _) => *n as f64, + Type::Operator(TypeOperator::Addition, a, b, _) => sym_lower_bound(a).max(0.0) + sym_lower_bound(b).max(0.0), + Type::Operator(TypeOperator::Multiplication, a, b, _) => { + sym_lower_bound(a).max(0.0) * sym_lower_bound(b).max(0.0) + } + _ => 0.0, + } +} + +/// A conjunction of properties: at most one non-full interval per measure, +/// kept sorted by measure so that `Hash`/`Eq` are canonical. (A `Vec` rather +/// than a `BTreeMap` so it round-trips through non-string-keyed formats.) +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub struct RefinementSet(Vec<(Measure, Interval)>); + +impl RefinementSet { + pub fn empty() -> Self { + RefinementSet(Vec::new()) + } + + pub fn single(r: &Refinement) -> Self { + RefinementSet::empty().with(r.measure(), r.interval()) + } + + pub fn is_trivial(&self) -> bool { + self.0.is_empty() + } + + pub fn get(&self, m: Measure) -> Option<&Interval> { + self.0.iter().find(|(k, _)| *k == m).map(|(_, iv)| iv) + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } + + /// Add a constraint on `m`, intersecting with what is already there. + pub fn with(mut self, m: Measure, iv: Interval) -> Self { + match self.0.iter_mut().find(|(k, _)| *k == m) { + Some((_, cur)) => *cur = cur.meet(&iv), + None => { + if !iv.is_full() { + self.0.push((m, iv)); + self.0.sort_by_key(|(k, _)| *k); + } + } + } + self + } + + /// Intersection (`&`) of two sets. + pub fn meet(&self, other: &RefinementSet) -> RefinementSet { + other.0.iter().fold(self.clone(), |acc, (m, iv)| acc.with(*m, *iv)) + } + + /// Some measure has an empty interval: no value can satisfy the set. + /// `integral` rounds bounds to integers first (base type `int`, and + /// always for `Length`). + pub fn is_empty(&self, integral: bool) -> bool { + self.0.iter().any(|(m, iv)| { + let iv = if integral || *m == Measure::Length { + iv.to_integral() + } else { + *iv + }; + iv.is_empty() + }) + } + + /// `self ⊆ other`: every measure constrained by `other` is constrained at + /// least as tightly by `self`. + pub fn implies(&self, other: &RefinementSet) -> bool { + other.0.iter().all(|(m, want)| match self.get(*m) { + Some(have) => have.implies(want), + None => want.is_full(), + }) + } + + /// Some value satisfies both sets. + pub fn compatible(&self, integral: bool) -> bool { + !self.is_empty(integral) + } + + /// No value satisfies both `self` and `other`. + pub fn contradicts(&self, other: &RefinementSet, integral: bool) -> bool { + self.meet(other).is_empty(integral) + } +} + +impl fmt::Display for RefinementSet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let parts: Vec = self.0.iter().map(|(m, iv)| m.display(iv)).collect(); + write!(f, "{}", parts.join(" & ")) + } +} + +/// A runtime check the type checker decided is needed at one boundary +/// (Phase 5, D5). Keyed by the source span of the checked expression; the +/// transpiler only reads the table and never reasons about refinements. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RefinementObligation { + pub file: String, + pub start: usize, + pub end: usize, + pub set: RefinementSet, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gt(c: f64) -> RefinementSet { + RefinementSet::single(&Refinement::Gt(Num::new(c))) + } + fn lt(c: f64) -> RefinementSet { + RefinementSet::single(&Refinement::Lt(Num::new(c))) + } + fn len(n: i32) -> RefinementSet { + RefinementSet::single(&Refinement::Length(n)) + } + + #[test] + fn meet_is_commutative_associative_idempotent() { + let (a, b, c) = (gt(0.0), lt(10.0), gt(3.0)); + assert_eq!(a.meet(&b), b.meet(&a)); + assert_eq!(a.meet(&b).meet(&c), a.meet(&b.meet(&c))); + assert_eq!(a.meet(&a), a); + assert_eq!(len(5).meet(&len(5)), len(5)); + } + + #[test] + fn contradictions() { + assert!(gt(10.0).contradicts(<(5.0), false)); + assert!(len(5).contradicts(&len(10), false)); + assert!(!gt(0.0).contradicts(<(10.0), false)); + // (> 5) & (< 5) is empty even over the reals + assert!(gt(5.0).contradicts(<(5.0), false)); + } + + #[test] + fn integer_rounding_detects_gaps() { + // (> 0) & (< 1): fine over num, empty over int + let s = gt(0.0).meet(<(1.0)); + assert!(!s.is_empty(false)); + assert!(s.is_empty(true)); + } + + #[test] + fn implication_is_interval_inclusion() { + assert!(gt(5.0).implies(>(0.0))); + assert!(!gt(0.0).implies(>(5.0))); + assert!(gt(0.0).implies(&RefinementSet::empty())); + assert!(!RefinementSet::empty().implies(>(0.0))); + assert!(len(5).implies(&RefinementSet::empty())); + // (> 0) & (< 10) implies (< 20) + assert!(gt(0.0).meet(<(10.0)).implies(<(20.0))); + } + + #[test] + fn open_closed_at_same_constant() { + let open = Interval::greater_than(3.0); + let closed = Interval::at_least(3.0); + assert!(open.implies(&closed)); + assert!(!closed.implies(&open)); + assert_eq!(open.meet(&closed), open); + } + + #[test] + fn range_properties_share_the_interval_model() { + let len_pos = RefinementSet::single(&Refinement::Range(Measure::Length, Interval::greater_than(0.0))); + // a point length implies a range that contains it, and only that + assert!(len(5).implies(&len_pos)); + assert!(!len(0).implies(&len_pos)); + assert!(len(0).contradicts(&len_pos, true)); + assert!(!len_pos.implies(&len(5))); + // `length(> 0)` and `length(< 1)` leave no integer length + let none = len_pos.meet(&RefinementSet::single(&Refinement::Range( + Measure::Length, + Interval::less_than(1.0), + ))); + assert!(none.is_empty(true)); + assert_eq!(len_pos.to_string(), "length(> 0)"); + let ge = RefinementSet::single(&Refinement::Range(Measure::Value, Interval::at_least(0.0))); + assert_eq!(ge.to_string(), "(>= 0)"); + assert!(gt(0.0).implies(&ge) && !ge.implies(>(0.0))); + } + + #[test] + fn negative_zero_is_canonical() { + assert_eq!(Num::new(-0.0), Num::new(0.0)); + assert_eq!(gt(-0.0), gt(0.0)); + } + + #[test] + fn display_uses_surface_syntax() { + assert_eq!(len(5).to_string(), "length(5)"); + assert_eq!(gt(0.0).to_string(), "(> 0)"); + assert_eq!(gt(0.0).meet(<(10.0)).to_string(), "(> 0) & (< 10)"); + } +} + +#[cfg(test)] +mod type_tests { + use super::*; + use crate::components::error_message::help_data::HelpData; + use crate::components::r#type::tint::Tint; + use crate::components::r#type::type_system::TypeSystem; + use crate::components::r#type::vector_type::VecType; + use crate::components::r#type::Type; + use crate::utils::builder; + use std::collections::HashSet; + + fn refined(base: Type, rs: RefinementSet) -> Type { + Type::Refined(Box::new(base), rs, HelpData::default()) + } + fn int_lit(n: i32) -> Type { + Type::Integer(Tint::Val(n), HelpData::default()) + } + fn vec_of_len(n: Option) -> Type { + let index = match n { + Some(n) => int_lit(n), + None => builder::integer_type_default(), + }; + Type::vec(VecType::S3, index, builder::integer_type_default(), HelpData::default()) + } + + #[test] + fn refined_equality_and_hash_ignore_construction_order() { + let a = refined( + builder::integer_type_default(), + RefinementSet::single(&Refinement::Gt(Num::new(0.0))) + .meet(&RefinementSet::single(&Refinement::Lt(Num::new(9.0)))), + ); + let b = refined( + builder::integer_type_default(), + RefinementSet::single(&Refinement::Lt(Num::new(9.0))) + .meet(&RefinementSet::single(&Refinement::Gt(Num::new(0.0)))), + ); + assert_eq!(a, b); + let set: HashSet = [a, b].into_iter().collect(); + assert_eq!(set.len(), 1); + } + + #[test] + fn refinements_are_read_back_from_structure() { + assert_eq!( + vec_of_len(Some(5)).refinements_of(), + RefinementSet::single(&Refinement::Length(5)) + ); + assert!(vec_of_len(None).refinements_of().is_trivial()); + assert_eq!( + int_lit(3).refinements_of().get(Measure::Value), + Some(&Interval::point(3.0)) + ); + let r = refined( + vec_of_len(Some(2)), + RefinementSet::single(&Refinement::Gt(Num::new(0.0))), + ); + let got = r.refinements_of(); + assert_eq!(got.get(Measure::Length), Some(&Interval::point(2.0))); + assert_eq!(got.get(Measure::Value), Some(&Interval::greater_than(0.0))); + } + + #[test] + fn refined_prints_and_dispatches_like_its_base() { + let r = refined( + builder::integer_type_default(), + RefinementSet::single(&Refinement::Gt(Num::new(0.0))), + ); + assert_eq!(r.pretty(), "int & (> 0)"); + assert_eq!(r.to_category(), builder::integer_type_default().to_category()); + assert_eq!(r.unrefined(), &builder::integer_type_default()); + } +} diff --git a/crates/typr-core/src/components/type/type_printer.rs b/crates/typr-core/src/components/type/type_printer.rs index f29275f0..c9169557 100644 --- a/crates/typr-core/src/components/type/type_printer.rs +++ b/crates/typr-core/src/components/type/type_printer.rs @@ -1,4 +1,5 @@ use crate::components::error_message::help_data::HelpData; +use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::intersection_type::IntersectionType; use crate::components::r#type::pretty; use crate::components::r#type::tchar::Tchar; @@ -8,6 +9,19 @@ use crate::components::r#type::type_operator::TypeOperator; use crate::components::r#type::type_system::TypeSystem; use crate::components::r#type::vector_type::VecType; use crate::components::r#type::Type; +use std::collections::HashSet; + +/// A record's fields in a deterministic order for printing. `Type::Record` stores its fields in +/// a `HashSet`, whose iteration order is randomized per-process (std's default `RandomState`) — +/// printing them as found makes every `list{...}` string (and anything built from it: error +/// messages, `typr graph`'s recorded block types) flip field order from run to run even when the +/// type itself never changed. Sorting by field name here only changes what gets printed, not the +/// set itself or any equality/subtyping decision. +fn sorted_fields(fields: &HashSet) -> Vec<&ArgumentType> { + let mut v: Vec<&ArgumentType> = fields.iter().collect(); + v.sort_by(|a, b| a.get_argument_str().cmp(&b.get_argument_str())); + v +} fn simplify_for_dataframe(ty: &Type) -> String { let fmt = |t: &Type| format(t); @@ -17,8 +31,8 @@ fn simplify_for_dataframe(ty: &Type) -> String { Type::Vec(VecType::Array, _, inner, _) => simplify_for_dataframe(inner), Type::Vec(VecType::S3, _, inner, _) => simplify_for_dataframe(inner), Type::Record(fields, _) => { - let formatted_fields = fields - .iter() + let formatted_fields = sorted_fields(fields) + .into_iter() .map(|arg_typ| { format!( "{}: {}", @@ -43,13 +57,17 @@ pub fn format(ty: &Type) -> String { format!("{}<{}>", name, paras.join(", ")) } } - Type::Vec(vtype, dim, ty, _) => { + Type::Vec(vtype, length, ty, h) => { + let dim = &length.to_type(h.clone()); let inner = if matches!(vtype, VecType::DataFrame) { simplify_for_dataframe(ty) } else { verbose(ty) }; - format!("{}[{}, {}]", vtype, short(dim), inner) + match length.range_property() { + Some(prop) => format!("{}[{}] & {}", vtype, inner, prop), + None => format!("{}[{}, {}]", vtype, short(dim), inner), + } } Type::Function(params, ret_ty, _h) => { let formatted_params = params @@ -66,8 +84,8 @@ pub fn format(ty: &Type) -> String { } } Type::Record(fields, _) => { - let formatted_fields = fields - .iter() + let formatted_fields = sorted_fields(fields) + .into_iter() .map(|arg_typ| format!("{}: {}", arg_typ.get_argument_str(), format(&arg_typ.get_type()))) .collect::>(); format!("list{{{}}}", formatted_fields.join(", ")) @@ -96,6 +114,7 @@ pub fn format(ty: &Type) -> String { Type::IndexGen(i, _) => format!("#{}", i), Type::LabelGen(l, _) => format!("${}", l), Type::KindedGen(k, name, _) => format!("{}{}", k, name), + Type::Bounded(id, bound, _) => format!("{}@{}", format(bound), id), Type::Tuple(elements, _) => { let body = elements.iter().map(format).collect::>().join(", "); format!("tuple{{{}}}", body) @@ -135,6 +154,8 @@ pub fn format(ty: &Type) -> String { format!("({} {} {})", left.pretty(), op, right.pretty()) } Type::Variable(name, _) => name.to_string(), + Type::Refined(base, refs, _) => format!("{} & {}", format(base), refs), + Type::Property(p, _) => p.to_string(), t => format!("{:?}", t), } } @@ -160,17 +181,19 @@ pub fn verbose(t: &Type) -> String { Type::Null(_) => "null".to_string(), Type::Char(_tchar, _) => "char".to_string(), Type::Record(fields, _) => { - let formatted_fields = fields - .iter() + let formatted_fields = sorted_fields(fields) + .into_iter() .map(|arg_typ| format!("{}: {}", format(&arg_typ.get_argument()), format(&arg_typ.get_type()))) .collect::>(); format!("list{{{}}}", formatted_fields.join(", ")) } Type::IndexGen(idgen, _) => format!("#{}", idgen), Type::KindedGen(k, name, _) => format!("{}{}", k, name), - Type::Vec(vtype, i, t, _) => { - format!("{}[{}, {}]", vtype, i.pretty(), t.pretty2()) - } + Type::Bounded(id, bound, _) => format!("{}@{}", format(bound), id), + Type::Vec(vtype, length, t, h) => match length.range_property() { + Some(prop) => format!("{}[{}] & {}", vtype, t.pretty2(), prop), + None => format!("{}[{}, {}]", vtype, length.to_type(h.clone()).pretty(), t.pretty2()), + }, val if val.to_category() == TypeCategory::Template => val.pretty(), val => val.pretty(), //val => panic!("{:?} doesn't have a second format", val) } diff --git a/crates/typr-core/src/processes/parsing/elements.rs b/crates/typr-core/src/processes/parsing/elements.rs index ec807787..0b84f257 100644 --- a/crates/typr-core/src/processes/parsing/elements.rs +++ b/crates/typr-core/src/processes/parsing/elements.rs @@ -62,6 +62,12 @@ pub fn is_pascal_case(name: &str) -> bool { } fn number_helper(s: Span) -> IResult { + let (rest, mut lang) = number_helper_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn number_helper_impl(s: Span) -> IResult { let res = (opt(tag("-")), digit1, tag("."), digit1).parse(s); match res { Ok((s, (sign, d1, _dot, d2))) => { @@ -80,10 +86,22 @@ fn number_helper(s: Span) -> IResult { } pub fn number(s: Span) -> IResult { + let (rest, mut lang) = number_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn number_impl(s: Span) -> IResult { terminated(number_helper, multispace0).parse(s) } fn integer(s: Span) -> IResult { + let (rest, mut lang) = integer_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn integer_impl(s: Span) -> IResult { let res = terminated((opt(tag("-")), digit1), multispace0).parse(s); match res { Ok((s, (minus, d))) => { @@ -93,13 +111,28 @@ fn integer(s: Span) -> IResult { } .to_string() + d.as_ref(); - Ok(( - s, - Lang::Integer { - value: symbol.parse::().unwrap(), - help_data: d.into(), - }, - )) + match symbol.parse::() { + Ok(value) => Ok(( + s, + Lang::Integer { + value, + help_data: d.into(), + }, + )), + // Outside the 32-bit range. `99999999999.5` must be left to + // `number`; a bare `99999999999` is a double in R, so it types + // as `num` rather than aborting the compiler. + Err(_) if s.starts_with('.') && s.chars().nth(1).is_some_and(|c| c.is_ascii_digit()) => { + Err(nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Digit))) + } + Err(_) => Ok(( + s, + Lang::Number { + value: symbol.parse::().unwrap_or(f64::INFINITY), + help_data: d.into(), + }, + )), + } } Err(r) => Err(r), } @@ -120,6 +153,12 @@ fn get_value(l: LocatedSpan<&str, String>) -> Lang { } fn null_value(s: Span) -> IResult { + let (rest, mut lang) = null_value_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn null_value_impl(s: Span) -> IResult { let res = alt(( terminated(terminated(tag("NULL"), not(body_char)), multispace0), terminated(terminated(tag("null"), not(body_char)), multispace0), @@ -132,6 +171,12 @@ fn null_value(s: Span) -> IResult { } fn na_value(s: Span) -> IResult { + let (rest, mut lang) = na_value_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn na_value_impl(s: Span) -> IResult { let res = alt(( terminated(terminated(tag("NA"), not(body_char)), multispace0), terminated(terminated(tag("na"), not(body_char)), multispace0), @@ -144,6 +189,12 @@ fn na_value(s: Span) -> IResult { } fn boolean(s: Span) -> IResult { + let (rest, mut lang) = boolean_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn boolean_impl(s: Span) -> IResult { let res = alt(( terminated(terminated(tag("true"), not(body_char)), multispace0), terminated(terminated(tag("TRUE"), not(body_char)), multispace0), @@ -158,6 +209,12 @@ fn boolean(s: Span) -> IResult { } pub fn chars(s: Span) -> IResult { + let (rest, mut lang) = chars_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn chars_impl(s: Span) -> IResult { terminated(alt((double_quotes, single_quotes)), multispace0).parse(s) } @@ -191,6 +248,12 @@ pub fn decode_escapes(s: &str) -> String { } pub fn double_quotes(input: Span) -> IResult { + let (rest, mut lang) = double_quotes_impl(input)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn double_quotes_impl(input: Span) -> IResult { let res = delimited(char('"'), opt(escaped(is_not("\\\""), '\\', anychar)), char('"')).parse(input); match res { Ok((s, st)) => { @@ -209,6 +272,12 @@ pub fn double_quotes(input: Span) -> IResult { } pub fn single_quotes(input: Span) -> IResult { + let (rest, mut lang) = single_quotes_impl(input)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn single_quotes_impl(input: Span) -> IResult { let res = delimited(char('\''), opt(escaped(is_not("\\'"), '\\', anychar)), char('\'')).parse(input); match res { Ok((s, st)) => { @@ -380,6 +449,12 @@ fn parse_nested_braces(input: Span) -> IResult { } pub fn r_function(s: Span) -> IResult { + let (rest, mut lang) = r_function_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn r_function_impl(s: Span) -> IResult { let res = ( terminated(alt((tag("function"), tag("\\"))), multispace0), terminated(tag("("), multispace0), @@ -418,6 +493,12 @@ pub fn r_function(s: Span) -> IResult { /// straight through at transpile time with no wrapper/call — see /// `Lang::RBlock` in `transpiling/mod.rs`. pub fn r_block(s: Span) -> IResult { + let (rest, mut lang) = r_block_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn r_block_impl(s: Span) -> IResult { let res = (terminated(tag("R"), multispace0), terminated(parse_block, multispace0)).parse(s); match res { Ok((s, (kw, body))) => Ok(( @@ -446,6 +527,12 @@ fn raw_r_string(s: Span) -> IResult { // Return type must be a single type token (not a union/function type inline) — // use a type alias for complex return types. pub fn extern_block(s: Span) -> IResult { + let (rest, mut lang) = extern_block_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn extern_block_impl(s: Span) -> IResult { let res = ( terminated(tag("extern"), multispace1), terminated(tag("("), multispace0), @@ -471,6 +558,12 @@ pub fn extern_block(s: Span) -> IResult { } pub fn simple_function(s: Span) -> IResult { + let (rest, mut lang) = simple_function_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn simple_function_impl(s: Span) -> IResult { let res = ( terminated(tag("fn"), multispace0), terminated(tag("("), multispace0), @@ -511,10 +604,22 @@ pub fn simple_function(s: Span) -> IResult { } fn function(s: Span) -> IResult { + let (rest, mut lang) = function_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn function_impl(s: Span) -> IResult { simple_function.parse(s) } fn key_value(s: Span) -> IResult { + let (rest, mut lang) = key_value_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn key_value_impl(s: Span) -> IResult { let res = (recognize(variable), terminated(tag("="), multispace0), single_element).parse(s); match res { Ok((s, (v, _eq, el))) => Ok(( @@ -530,6 +635,13 @@ fn key_value(s: Span) -> IResult { } fn values(s: Span) -> IResult> { + let (rest, mut v) = values_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn values_impl(s: Span) -> IResult> { many0(terminated( alt((key_value, parse_elements)), terminated(opt(tag(",")), multispace0), @@ -538,6 +650,12 @@ fn values(s: Span) -> IResult> { } pub fn variable2(s: Span) -> IResult { + let (rest, mut lang) = variable2_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn variable2_impl(s: Span) -> IResult { let res = variable.parse(s); match res { Ok((s, (lang, _))) => Ok((s, lang)), @@ -546,6 +664,12 @@ pub fn variable2(s: Span) -> IResult { } fn array_indexing(s: Span) -> IResult { + let (rest, mut lang) = array_indexing_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn array_indexing_impl(s: Span) -> IResult { let res = (alt((scope, variable2)), array).parse(s); match res { @@ -562,6 +686,12 @@ fn array_indexing(s: Span) -> IResult { } fn dataframe_exp(s: Span) -> IResult { + let (rest, mut lang) = dataframe_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn dataframe_exp_impl(s: Span) -> IResult { let res = ( alt((tag("data__frame"), tag("data.frame"))), terminated(tag("("), multispace0), @@ -582,6 +712,12 @@ fn dataframe_exp(s: Span) -> IResult { } fn function_application(s: Span) -> IResult { + let (rest, mut lang) = function_application_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn function_application_impl(s: Span) -> IResult { let res = ( alt((scope, variable2)), terminated(tag("("), multispace0), @@ -603,6 +739,12 @@ fn function_application(s: Span) -> IResult { } fn array(s: Span) -> IResult { + let (rest, mut lang) = array_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn array_impl(s: Span) -> IResult { let res = ( terminated(tag("["), multispace0), values, @@ -622,6 +764,12 @@ fn array(s: Span) -> IResult { } pub fn vector(s: Span) -> IResult { + let (rest, mut lang) = vector_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn vector_impl(s: Span) -> IResult { let res = ( terminated(tag("c("), multispace0), values, @@ -641,6 +789,12 @@ pub fn vector(s: Span) -> IResult { } fn sequence(s: Span) -> IResult { + let (rest, mut lang) = sequence_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn sequence_impl(s: Span) -> IResult { let res = ( terminated(tag("seq["), multispace0), values, @@ -728,6 +882,12 @@ fn constructor_field(s: Span) -> IResult { } fn constructor_call(s: Span) -> IResult { + let (rest, mut lang) = constructor_call_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn constructor_call_impl(s: Span) -> IResult { let res = ( many0(terminated(variable_exp, tag("$"))), pascal_case, @@ -773,6 +933,12 @@ fn constructor_call(s: Span) -> IResult { } fn array_constructor_call(s: Span) -> IResult { + let (rest, mut lang) = array_constructor_call_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn array_constructor_call_impl(s: Span) -> IResult { let res = ( pascal_case, tag(":["), @@ -828,6 +994,12 @@ fn record_field(s: Span) -> IResult { } pub fn record(s: Span) -> IResult { + let (rest, mut lang) = record_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn record_impl(s: Span) -> IResult { let res = ( opt(terminated(record_identifier, multispace0)), terminated(alt((tag("{"), tag("("))), multispace0), @@ -880,6 +1052,12 @@ pub fn record(s: Span) -> IResult { /// heavily-used way to build positional tuples (see `tuple_exp`, which keeps handling it /// unchanged) and must not be affected. fn keyword_positional_record_exp(s: Span) -> IResult { + let (rest, mut lang) = keyword_positional_record_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn keyword_positional_record_exp_impl(s: Span) -> IResult { let res = ( terminated(alt((tag("list"), tag("record"), tag("object"))), multispace0), terminated(tag("{"), multispace0), @@ -921,6 +1099,12 @@ fn pascal_case(s: Span) -> IResult { } fn union_constructor(s: Span) -> IResult { + let (rest, mut lang) = union_constructor_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn union_constructor_impl(s: Span) -> IResult { let res = ( pascal_case, terminated(tag("."), multispace0), @@ -957,6 +1141,12 @@ fn union_constructor(s: Span) -> IResult { } fn parenthese_value(s: Span) -> IResult { + let (rest, mut lang) = parenthese_value_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn parenthese_value_impl(s: Span) -> IResult { delimited( terminated(tag("("), multispace0), parse_elements, @@ -966,6 +1156,12 @@ fn parenthese_value(s: Span) -> IResult { } pub fn tag_exp(s: Span) -> IResult { + let (rest, mut lang) = tag_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn tag_exp_impl(s: Span) -> IResult { let res = terminated((tag("."), pascal_case, opt(parenthese_value)), multispace0).parse(s); match res { Ok((s, (dot, (n, _h), None))) => Ok(( @@ -989,6 +1185,12 @@ pub fn tag_exp(s: Span) -> IResult { } fn dotdotdot(s: Span) -> IResult { + let (rest, mut lang) = dotdotdot_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn dotdotdot_impl(s: Span) -> IResult { let res = terminated(tag("..."), multispace0).parse(s); match res { Ok((s, d)) => Ok((s, Lang::Empty(d.into()))), @@ -997,6 +1199,12 @@ fn dotdotdot(s: Span) -> IResult { } fn else_exp(s: Span) -> IResult { + let (rest, mut lang) = else_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn else_exp_impl(s: Span) -> IResult { let res = ( terminated(tag("else"), multispace0), terminated(tag("{"), multispace0), @@ -1011,10 +1219,22 @@ fn else_exp(s: Span) -> IResult { } fn else_if_exp(s: Span) -> IResult { + let (rest, mut lang) = else_if_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn else_if_exp_impl(s: Span) -> IResult { preceded(terminated(tag("else"), multispace1), if_exp).parse(s) } fn if_exp(s: Span) -> IResult { + let (rest, mut lang) = if_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn if_exp_impl(s: Span) -> IResult { let res = ( terminated(tag("if"), multispace0), terminated(tag("("), multispace0), @@ -1042,6 +1262,12 @@ fn if_exp(s: Span) -> IResult { /// Parse a tag pattern with a variable binding in parentheses: `.Some(a)` fn tag_pattern_with_var(s: Span) -> IResult { + let (rest, mut lang) = tag_pattern_with_var_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn tag_pattern_with_var_impl(s: Span) -> IResult { let res = ( tag("."), pascal_case, @@ -1067,6 +1293,12 @@ fn tag_pattern_with_var(s: Span) -> IResult { /// Parse a tag pattern without binding: `.None` fn tag_pattern_no_var(s: Span) -> IResult { + let (rest, mut lang) = tag_pattern_no_var_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn tag_pattern_no_var_impl(s: Span) -> IResult { let res = (tag("."), pascal_case).parse(s); match res { Ok((s, (dot, (n, _h)))) => Ok(( @@ -1083,6 +1315,12 @@ fn tag_pattern_no_var(s: Span) -> IResult { /// Parse a wildcard pattern: `_` fn wildcard_pattern(s: Span) -> IResult { + let (rest, mut lang) = wildcard_pattern_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn wildcard_pattern_impl(s: Span) -> IResult { let res = terminated(tag("_"), multispace0).parse(s); match res { Ok((s, underscore)) => Ok(( @@ -1100,6 +1338,12 @@ fn wildcard_pattern(s: Span) -> IResult { /// Parse a type pattern with a variable binding: `x as int`, `y as bool`, etc. fn type_pattern(s: Span) -> IResult { + let (rest, mut lang) = type_pattern_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn type_pattern_impl(s: Span) -> IResult { let res = ( terminated(variable_exp, multispace0), terminated(tag("as"), multispace1), @@ -1121,6 +1365,12 @@ fn type_pattern(s: Span) -> IResult { /// Parse a match pattern: `.Some(a)`, `.None`, `x as int`, `:{nom: n}`, `:{a, b}`, `_`, or a variable fn match_pattern(s: Span) -> IResult { + let (rest, mut lang) = match_pattern_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn match_pattern_impl(s: Span) -> IResult { terminated( alt(( tag_pattern_with_var, @@ -1155,6 +1405,12 @@ fn pattern_branch(s: Span) -> IResult)> { /// Parse a match expression with pattern matching: /// `match expr { .Some(a) => a, .None => 0, _ => default }` fn match_exp(s: Span) -> IResult { + let (rest, mut lang) = match_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn match_exp_impl(s: Span) -> IResult { let res = ( terminated(tag("match"), multispace1), terminated(alt((scope, variable2)), multispace0), @@ -1177,6 +1433,12 @@ fn match_exp(s: Span) -> IResult { } pub fn tuple_exp(s: Span) -> IResult { + let (rest, mut lang) = tuple_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn tuple_exp_impl(s: Span) -> IResult { let res = ( terminated(alt((tag("list"), tag(":"))), multispace0), terminated(alt((tag("{"), tag("("))), multispace0), @@ -1197,6 +1459,12 @@ pub fn tuple_exp(s: Span) -> IResult { } fn int_or_var(s: Span) -> IResult { + let (rest, mut lang) = int_or_var_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn int_or_var_impl(s: Span) -> IResult { alt((integer, variable2)).parse(s) } @@ -1224,6 +1492,12 @@ fn create_range(params: &[Lang]) -> Lang { } fn range(s: Span) -> IResult { + let (rest, mut lang) = range_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn range_impl(s: Span) -> IResult { let res = (int_or_var, tag(":"), opt(terminated(int_or_var, tag(":"))), int_or_var).parse(s); //from_name().to_language() match res { @@ -1234,6 +1508,12 @@ fn range(s: Span) -> IResult { } fn function_application2(s: Span) -> IResult { + let (rest, mut lang) = function_application2_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn function_application2_impl(s: Span) -> IResult { let res = recognize(function_application).parse(s); match res { Ok((s, fun_app)) => Ok(( @@ -1248,6 +1528,12 @@ fn function_application2(s: Span) -> IResult { } fn dot_variable(s: Span) -> IResult { + let (rest, mut lang) = dot_variable_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn dot_variable_impl(s: Span) -> IResult { let res = preceded(tag("."), variable2).parse(s); match res { Ok(( @@ -1295,6 +1581,12 @@ fn element_operator2(s: Span) -> IResult { } fn vectorial_bloc(s: Span) -> IResult { + let (rest, mut lang) = vectorial_bloc_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn vectorial_bloc_impl(s: Span) -> IResult { let res = ( terminated(tag("@{"), multispace0), recognize(many1(element_operator2)), @@ -1319,6 +1611,12 @@ fn vectorial_bloc(s: Span) -> IResult { /// trying this combinator first and falling back to `lambda` on failure /// (via the `alt()` in `single_element`) disambiguates the two unambiguously. fn partial_application(s: Span) -> IResult { + let (rest, mut lang) = partial_application_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn partial_application_impl(s: Span) -> IResult { let res = ( tag("\\"), variable2, @@ -1351,6 +1649,12 @@ fn partial_application(s: Span) -> IResult { /// `argument_val`'s grammar (same as `constructor_call`), so `:` or `=` both /// work as the field separator. fn partial_constructor_application(s: Span) -> IResult { + let (rest, mut lang) = partial_constructor_application_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn partial_constructor_application_impl(s: Span) -> IResult { let res = ( tag("\\"), pascal_case, @@ -1389,6 +1693,12 @@ fn partial_constructor_application(s: Span) -> IResult { } fn lambda(s: Span) -> IResult { + let (rest, mut lang) = lambda_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn lambda_impl(s: Span) -> IResult { let res = ( tag("\\"), terminated(tag("("), multispace0), @@ -1411,6 +1721,12 @@ fn lambda(s: Span) -> IResult { } fn not_exp(s: Span) -> IResult { + let (rest, mut lang) = not_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn not_exp_impl(s: Span) -> IResult { let res = ( tag("!"), alt(( @@ -1451,10 +1767,22 @@ fn not_exp(s: Span) -> IResult { } fn array_variant(s: Span) -> IResult { + let (rest, mut lang) = array_variant_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn array_variant_impl(s: Span) -> IResult { alt((vector, sequence)).parse(s) } fn js_block(s: Span) -> IResult { + let (rest, mut lang) = js_block_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn js_block_impl(s: Span) -> IResult { let res = (terminated(tag("JS"), multispace0), scope).parse(s); match res { @@ -1464,10 +1792,22 @@ fn js_block(s: Span) -> IResult { } fn primitive(s: Span) -> IResult { + let (rest, mut lang) = primitive_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn primitive_impl(s: Span) -> IResult { alt((null_value, na_value, boolean, number, integer, chars)).parse(s) } pub fn return_exp(s: Span) -> IResult { + let (rest, mut lang) = return_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn return_exp_impl(s: Span) -> IResult { let res = terminated(delimited(tag("return "), parse_elements, tag(";")), multispace0).parse(s); match res { Ok((s, el)) => Ok(( @@ -1482,6 +1822,13 @@ pub fn return_exp(s: Span) -> IResult { } pub fn break_exp(s: Span) -> IResult> { + let (rest, mut v) = break_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn break_exp_impl(s: Span) -> IResult> { let res = tag("break;").parse(s); match res { Ok((s, el)) => Ok((s, vec![Lang::Break(el.into())])), @@ -1490,6 +1837,13 @@ pub fn break_exp(s: Span) -> IResult> { } pub fn next_exp(s: Span) -> IResult> { + let (rest, mut v) = next_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn next_exp_impl(s: Span) -> IResult> { let res = tag("next;").parse(s); match res { Ok((s, el)) => Ok((s, vec![Lang::Next(el.into())])), @@ -1499,6 +1853,12 @@ pub fn next_exp(s: Span) -> IResult> { // main pub fn single_element(s: Span) -> IResult { + let (rest, mut lang) = single_element_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn single_element_impl(s: Span) -> IResult { alt(( alt(( not_exp, @@ -1538,6 +1898,12 @@ pub fn single_element(s: Span) -> IResult { } pub fn scope(s: Span) -> IResult { + let (rest, mut lang) = scope_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn scope_impl(s: Span) -> IResult { let res = ( terminated(alt((tag("("), tag("{"))), multispace0), opt(base_parse), @@ -1641,6 +2007,12 @@ fn operator_like_token(s: Span) -> IResult { } pub fn elements(s: Span) -> IResult { + let (rest, mut lang) = elements_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn elements_impl(s: Span) -> IResult { let res = ( single_element_token, many0(pair(operator_like_token, single_element_token)), @@ -1665,6 +2037,12 @@ pub fn elements(s: Span) -> IResult { // main pub fn parse_elements(s: Span) -> IResult { + let (rest, mut lang) = parse_elements_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn parse_elements_impl(s: Span) -> IResult { alt((vectorial_bloc, elements)).parse(s) } diff --git a/crates/typr-core/src/processes/parsing/indexation.rs b/crates/typr-core/src/processes/parsing/indexation.rs index 357b0432..a94becd0 100644 --- a/crates/typr-core/src/processes/parsing/indexation.rs +++ b/crates/typr-core/src/processes/parsing/indexation.rs @@ -39,7 +39,10 @@ fn parse_integer(s: &str) -> IResult<&str, i32> { let res = recognize(pair(opt(char('-')), digit1)).parse(s); match res { - Ok((s, int)) => Ok((s, int.parse::().unwrap())), + Ok((s, int)) => int + .parse::() + .map(|n| (s, n)) + .map_err(|_| nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Digit))), Err(r) => Err(r), } } @@ -47,7 +50,10 @@ fn parse_integer(s: &str) -> IResult<&str, i32> { /// Parse un nombre positif fn parse_positive_integer(s: &str) -> IResult<&str, i32> { match digit1.parse(s) { - Ok((s, int)) => Ok((s, int.parse::().unwrap())), + Ok((s, int)) => int + .parse::() + .map(|n| (s, n)) + .map_err(|_| nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Digit))), Err(r) => Err(r), } } diff --git a/crates/typr-core/src/processes/parsing/mod.rs b/crates/typr-core/src/processes/parsing/mod.rs index b44d8889..5a757671 100644 --- a/crates/typr-core/src/processes/parsing/mod.rs +++ b/crates/typr-core/src/processes/parsing/mod.rs @@ -153,6 +153,12 @@ fn pattern_var(s: Span) -> IResult, Option)> { } fn single_parse(s: Span) -> IResult { + let (rest, mut lang) = single_parse_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn single_parse_impl(s: Span) -> IResult { let res = (parse_elements, opt(terminated(tag(";"), multispace0))).parse(s); match res { Ok((s, (exp, Some(_)))) => Ok((s, exp)), @@ -169,6 +175,13 @@ fn equality_operator(s: Span) -> IResult { } fn base_let_exp(s: Span) -> IResult> { + let (rest, mut v) = base_let_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn base_let_exp_impl(s: Span) -> IResult> { let res = ( terminated(tag("let"), multispace0), pattern_var, @@ -289,6 +302,13 @@ fn base_let_exp(s: Span) -> IResult> { } fn let_tuple_exp(s: Span) -> IResult> { + let (rest, mut v) = let_tuple_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn let_tuple_exp_impl(s: Span) -> IResult> { let res = ( terminated(tag("let"), multispace0), tuple_exp, @@ -377,6 +397,13 @@ fn let_tuple_exp(s: Span) -> IResult> { } fn let_exp(s: Span) -> IResult> { + let (rest, mut v) = let_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn let_exp_impl(s: Span) -> IResult> { let res = ( opt(terminated( alt((tag("@export"), tag("@pub"), tag("@testable"))), @@ -431,6 +458,13 @@ fn let_exp(s: Span) -> IResult> { /// `typeconstructor Tibble[N] record;` /// `typeconstructor Matrix[N, M, T] recursive;` fn typeconstructor_exp(s: Span) -> IResult> { + let (rest, mut v) = typeconstructor_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn typeconstructor_exp_impl(s: Span) -> IResult> { let res = ( terminated(tag("typeconstructor"), multispace0), pascal_case_no_space, @@ -473,6 +507,13 @@ fn typeconstructor_exp(s: Span) -> IResult> { /// lost from the AST, but flags it as a fatal `SingleLetterTypeName` error: single-letter /// alias names are forbidden outright (not legalized) to avoid colliding with generics. fn single_letter_type_name_exp(s: Span) -> IResult> { + let (rest, mut v) = single_letter_type_name_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn single_letter_type_name_exp_impl(s: Span) -> IResult> { let res = ( opt(terminated(alt((tag("@export"), tag("@pub"))), multispace0)), terminated(alt((tag("type"), tag("opaque"))), multispace0), @@ -514,6 +555,12 @@ fn single_letter_type_name_exp(s: Span) -> IResult> { } fn base_type_exp(s: Span) -> IResult { + let (rest, mut lang) = base_type_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn base_type_exp_impl(s: Span) -> IResult { let res = ( terminated(tag("type"), multispace0), type_alias, @@ -550,6 +597,13 @@ fn base_type_exp(s: Span) -> IResult { } fn type_exp(s: Span) -> IResult> { + let (rest, mut v) = type_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn type_exp_impl(s: Span) -> IResult> { let res = ( opt(terminated(alt((tag("@export"), tag("@pub"))), multispace0)), base_type_exp, @@ -618,6 +672,13 @@ fn type_exp(s: Span) -> IResult> { /// Detects `type <- ` which should be `let` instead. /// Parses the full expression and returns a `Lang::Let` with a push_parse_error. fn type_instead_of_let_exp(s: Span) -> IResult> { + let (rest, mut v) = type_instead_of_let_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn type_instead_of_let_exp_impl(s: Span) -> IResult> { let res = ( opt(terminated(tag("@pub"), multispace0)), terminated(tag("type"), multispace0), @@ -656,6 +717,12 @@ fn type_instead_of_let_exp(s: Span) -> IResult> { } fn base_opaque_exp(s: Span) -> IResult { + let (rest, mut lang) = base_opaque_exp_impl(s)?; + lang.set_help_data_end(rest.location_offset()); + Ok((rest, lang)) +} + +fn base_opaque_exp_impl(s: Span) -> IResult { let res = ( terminated(tag("opaque"), multispace0), type_alias, @@ -688,6 +755,13 @@ fn base_opaque_exp(s: Span) -> IResult { } fn opaque_exp(s: Span) -> IResult> { + let (rest, mut v) = opaque_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn opaque_exp_impl(s: Span) -> IResult> { let res = ( opt(terminated(alt((tag("@export"), tag("@pub"))), multispace0)), base_opaque_exp, @@ -759,6 +833,13 @@ fn opaque_exp(s: Span) -> IResult> { } pub fn module(s: Span) -> IResult> { + let (rest, mut v) = module_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn module_impl(s: Span) -> IResult> { let res = ( terminated(tag("module"), multispace0), terminated(variable_recognizer, multispace0), @@ -784,6 +865,13 @@ pub fn module(s: Span) -> IResult> { } fn import_module(s: Span) -> IResult> { + let (rest, mut v) = import_module_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn import_module_impl(s: Span) -> IResult> { let res = ( terminated(tag("import"), multispace0), terminated(variable_recognizer, multispace0), @@ -828,6 +916,13 @@ fn import_module(s: Span) -> IResult> { } fn import_from_exp(s: Span) -> IResult> { + let (rest, mut v) = import_from_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn import_from_exp_impl(s: Span) -> IResult> { let res = ( terminated(tag("@importFrom"), multispace1), terminated( @@ -855,6 +950,13 @@ fn import_from_exp(s: Span) -> IResult> { } fn assign(s: Span) -> IResult> { + let (rest, mut v) = assign_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn assign_impl(s: Span) -> IResult> { let res = ( variable, alt((terminated(tag("="), multispace0), terminated(tag("<-"), multispace0))), @@ -885,6 +987,13 @@ fn assign(s: Span) -> IResult> { } fn comment(s: Span) -> IResult> { + let (rest, mut v) = comment_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn comment_impl(s: Span) -> IResult> { let res = (tag("#"), not_line_ending, opt(line_ending), multispace0).parse(s); match res { Ok((s, (_hashtag, txt, _, _))) => Ok(( @@ -909,6 +1018,13 @@ fn comment(s: Span) -> IResult> { /// from the AST instead — this dedicated parser keeps the common typo both /// harmless (no code loss) and visible (a located warning). fn wrong_comment(s: Span) -> IResult> { + let (rest, mut v) = wrong_comment_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn wrong_comment_impl(s: Span) -> IResult> { let res = (tag("//"), not_line_ending, opt(line_ending), multispace0).parse(s); match res { Ok((s, (slashes, txt, _, _))) => { @@ -926,6 +1042,13 @@ fn wrong_comment(s: Span) -> IResult> { } pub fn simple_exp(s: Span) -> IResult> { + let (rest, mut v) = simple_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn simple_exp_impl(s: Span) -> IResult> { let res = (parse_elements, opt(terminated(tag(";"), multispace0))).parse(s); match res { Ok((s, (lang, Some(_)))) => Ok((s, vec![lang])), @@ -938,6 +1061,13 @@ pub fn simple_exp(s: Span) -> IResult> { } fn mod_imp(s: Span) -> IResult> { + let (rest, mut v) = mod_imp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn mod_imp_impl(s: Span) -> IResult> { let res = ( terminated(tag("mod"), multispace0), terminated(variable_exp, multispace0), @@ -957,6 +1087,13 @@ fn mod_imp(s: Span) -> IResult> { } fn import_var(s: Span) -> IResult> { + let (rest, mut v) = import_var_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn import_var_impl(s: Span) -> IResult> { let res = ( terminated(tag("use"), multispace0), variable, @@ -976,6 +1113,13 @@ fn import_var(s: Span) -> IResult> { } fn import_type(s: Span) -> IResult> { + let (rest, mut v) = import_type_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn import_type_impl(s: Span) -> IResult> { let res = ( terminated(tag("use"), multispace0), type_alias, @@ -996,6 +1140,13 @@ fn import_type(s: Span) -> IResult> { } fn tests(s: Span) -> IResult> { + let (rest, mut v) = tests_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn tests_impl(s: Span) -> IResult> { let res = (tag("Test"), delimited(tag("["), base_parse, tag("]"))).parse(s); match res { Ok((s, (_t, body))) => Ok(( @@ -1010,6 +1161,13 @@ fn tests(s: Span) -> IResult> { } fn library(s: Span) -> IResult> { + let (rest, mut v) = library_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn library_impl(s: Span) -> IResult> { let res = (tag("library("), variable_exp, tag(")"), opt(tag(";")), multispace0).parse(s); match res { @@ -1072,6 +1230,13 @@ fn use_selector_exp(s: Span) -> IResult IResult> { + let (rest, mut v) = use_module_directive_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn use_module_directive_impl(s: Span) -> IResult> { use crate::components::language::use_lang::UseSelector; // Parse: use FirstSeg :: ... @@ -1130,6 +1295,13 @@ fn use_module_directive(s: Span) -> IResult> { } fn use_exp(s: Span) -> IResult> { + let (rest, mut v) = use_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn use_exp_impl(s: Span) -> IResult> { let res = ( tag("use("), chars, @@ -1160,11 +1332,25 @@ fn custom_operators(s: Span) -> IResult { } fn return_stmt(s: Span) -> IResult> { + let (rest, mut v) = return_stmt_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn return_stmt_impl(s: Span) -> IResult> { let (s, e) = return_exp(s)?; Ok((s, vec![e])) } fn stmt_exp(s: Span) -> IResult> { + let (rest, mut v) = stmt_exp_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn stmt_exp_impl(s: Span) -> IResult> { let res = (parse_elements, terminated(tag(";"), multispace0)).parse(s); match res { Ok((s, (lang, _))) => Ok((s, vec![lang])), @@ -1185,6 +1371,13 @@ fn head_lang(lang: &Lang) -> Option { } fn implicit_mutate(s: Span) -> IResult> { + let (rest, mut v) = implicit_mutate_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn implicit_mutate_impl(s: Span) -> IResult> { let res = (parse_elements, terminated(tag("!;"), multispace0)).parse(s); match res { Ok((s, (expr, excl))) => match head_lang(&expr) { @@ -1206,6 +1399,13 @@ fn implicit_mutate(s: Span) -> IResult> { } fn signature_variable(s: Span) -> IResult> { + let (rest, mut v) = signature_variable_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn signature_variable_impl(s: Span) -> IResult> { let res = ( tag("@"), alt((variable_recognizer, custom_operators)), @@ -1233,6 +1433,13 @@ fn signature_variable(s: Span) -> IResult> { } fn signature_opaque(s: Span) -> IResult> { + let (rest, mut v) = signature_opaque_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn signature_opaque_impl(s: Span) -> IResult> { let res = ( tag("@"), type_alias, @@ -1261,6 +1468,13 @@ fn signature_opaque(s: Span) -> IResult> { } fn signature_extern(s: Span) -> IResult> { + let (rest, mut v) = signature_extern_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn signature_extern_impl(s: Span) -> IResult> { let res = ( tag("@extern"), multispace1, @@ -1294,10 +1508,24 @@ fn signature_extern(s: Span) -> IResult> { } pub fn signature(s: Span) -> IResult> { + let (rest, mut v) = signature_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn signature_impl(s: Span) -> IResult> { alt((signature_extern, signature_opaque, signature_variable)).parse(s) } fn for_loop(s: Span) -> IResult> { + let (rest, mut v) = for_loop_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn for_loop_impl(s: Span) -> IResult> { let res = ( terminated(tag("for"), multispace0), terminated(tag("("), multispace0), @@ -1324,6 +1552,13 @@ fn for_loop(s: Span) -> IResult> { } fn while_loop(s: Span) -> IResult> { + let (rest, mut v) = while_loop_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn while_loop_impl(s: Span) -> IResult> { let res = ( terminated(tag("while"), multispace0), terminated(tag("("), multispace0), @@ -1347,6 +1582,13 @@ fn while_loop(s: Span) -> IResult> { } fn loop_loop(s: Span) -> IResult> { + let (rest, mut v) = loop_loop_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn loop_loop_impl(s: Span) -> IResult> { let res = ( terminated(tag("loop"), multispace0), scope, @@ -1366,6 +1608,13 @@ fn loop_loop(s: Span) -> IResult> { } fn test_block(s: Span) -> IResult> { + let (rest, mut v) = test_block_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn test_block_impl(s: Span) -> IResult> { let res = (terminated(tag("Test"), multispace0), scope).parse(s); //parse_block).parse(s); @@ -1383,6 +1632,13 @@ fn test_block(s: Span) -> IResult> { // main pub fn base_parse(s: Span) -> IResult> { + let (rest, mut v) = base_parse_impl(s)?; + let end = rest.location_offset(); + v.iter_mut().for_each(|lang| lang.set_help_data_end(end)); + Ok((rest, v)) +} + +fn base_parse_impl(s: Span) -> IResult> { let res = ( opt(multispace0), many0(alt(( diff --git a/crates/typr-core/src/processes/parsing/types.rs b/crates/typr-core/src/processes/parsing/types.rs index 10a01473..94cd7d3a 100644 --- a/crates/typr-core/src/processes/parsing/types.rs +++ b/crates/typr-core/src/processes/parsing/types.rs @@ -4,6 +4,7 @@ use crate::components::language::operators::{op, Op}; use crate::components::language::Lang; use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::kind::Kind; +use crate::components::r#type::refinement::{Interval, Measure, Num, Refinement}; use crate::components::r#type::tbool::Tbool; use crate::components::r#type::tchar::Tchar; use crate::components::r#type::tint::Tint; @@ -27,12 +28,14 @@ use nom::character::complete::multispace0; use nom::character::complete::multispace1; use nom::character::complete::none_of; use nom::character::complete::one_of; +use nom::combinator::map; use nom::combinator::not; use nom::combinator::opt; use nom::combinator::recognize; use nom::multi::many0; use nom::multi::many1; use nom::sequence::delimited; +use nom::sequence::preceded; use nom::sequence::terminated; use nom::IResult; use nom::Parser; @@ -156,7 +159,11 @@ fn generic(s: Span) -> IResult { fn simple_index(s: Span) -> IResult { let res = terminated(digit1, multispace0).parse(s); match res { - Ok((s, fl)) => Ok((s, Type::Integer(fl.parse::().unwrap().into(), fl.into()))), + Ok((s, fl)) => match fl.parse::() { + Ok(value) => Ok((s, Type::Integer(value.into(), fl.into()))), + // A dimension beyond i32 is a syntax error, not a panic. + Err(_) => Err(nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Digit))), + }, Err(r) => Err(r), } } @@ -176,7 +183,7 @@ fn array_type_full(s: Span) -> IResult { .parse(s); match res { - Ok((s, (start, num, _, typ, _))) => Ok((s, Type::Vec(VecType::S3, Box::new(num), Box::new(typ), start.into()))), + Ok((s, (start, num, _, typ, _))) => Ok((s, Type::vec(VecType::S3, num, typ, start.into()))), Err(r) => Err(r), } } @@ -192,12 +199,7 @@ fn array_type_short(s: Span) -> IResult { match res { Ok((s, (start, typ, _))) => Ok(( s, - Type::Vec( - VecType::S3, - Box::new(Type::Any(start.clone().into())), - Box::new(typ), - start.into(), - ), + Type::vec(VecType::S3, Type::Any(start.clone().into()), typ, start.into()), )), Err(r) => Err(r), } @@ -224,9 +226,7 @@ fn named_array_type_full(s: Span) -> IResult { .parse(s); match res { - Ok((s, (start, num, _, typ, _))) => { - Ok((s, Type::Vec(VecType::Array, Box::new(num), Box::new(typ), start.into()))) - } + Ok((s, (start, num, _, typ, _))) => Ok((s, Type::vec(VecType::Array, num, typ, start.into()))), Err(r) => Err(r), } } @@ -242,12 +242,7 @@ fn named_array_type_short(s: Span) -> IResult { match res { Ok((s, (start, typ, _))) => Ok(( s, - Type::Vec( - VecType::Array, - Box::new(Type::Any(start.clone().into())), - Box::new(typ), - start.into(), - ), + Type::vec(VecType::Array, Type::Any(start.clone().into()), typ, start.into()), )), Err(r) => Err(r), } @@ -268,10 +263,7 @@ fn vector_type_full(s: Span) -> IResult { .parse(s); match res { - Ok((s, (start, num, _, typ, _))) => Ok(( - s, - Type::Vec(VecType::Vector, Box::new(num), Box::new(typ), start.into()), - )), + Ok((s, (start, num, _, typ, _))) => Ok((s, Type::vec(VecType::Vector, num, typ, start.into()))), Err(r) => Err(r), } } @@ -287,12 +279,7 @@ fn vector_type_short(s: Span) -> IResult { match res { Ok((s, (start, typ, _))) => Ok(( s, - Type::Vec( - VecType::Vector, - Box::new(Type::Any(start.clone().into())), - Box::new(typ), - start.into(), - ), + Type::vec(VecType::Vector, Type::Any(start.clone().into()), typ, start.into()), )), Err(r) => Err(r), } @@ -316,10 +303,10 @@ fn dataframe_type_full(s: Span) -> IResult { match res { Ok((s, (start, num, _, _, columns, _))) => Ok(( s, - Type::Vec( + Type::vec( VecType::DataFrame, - Box::new(num), - Box::new(Type::Record(columns.iter().cloned().collect(), start.clone().into())), + num, + Type::Record(columns.iter().cloned().collect(), start.clone().into()), start.into(), ), )), @@ -339,10 +326,10 @@ fn dataframe_type_short(s: Span) -> IResult { match res { Ok((s, (start, _, columns, _))) => Ok(( s, - Type::Vec( + Type::vec( VecType::DataFrame, - Box::new(Type::Any(start.clone().into())), - Box::new(Type::Record(columns.iter().cloned().collect(), start.clone().into())), + Type::Any(start.clone().into()), + Type::Record(columns.iter().cloned().collect(), start.clone().into()), start.into(), ), )), @@ -372,10 +359,10 @@ fn named_record_type(s: Span) -> IResult { match res { Ok((s, ((name, h), _, num, _, _, columns, _))) => Ok(( s, - Type::Vec( + Type::vec( VecType::Named(name), - Box::new(num), - Box::new(Type::Record(columns.iter().cloned().collect(), h.clone())), + num, + Type::Record(columns.iter().cloned().collect(), h.clone()), h, ), )), @@ -403,10 +390,10 @@ fn named_record_bad_index(s: Span) -> IResult { push_parse_error(SyntaxError::RecordConstructorIndex(h.clone())); Ok(( s, - Type::Vec( + Type::vec( VecType::Named(name), - Box::new(first), - Box::new(Type::Record(columns.iter().cloned().collect(), h.clone())), + first, + Type::Record(columns.iter().cloned().collect(), h.clone()), h, ), )) @@ -440,10 +427,10 @@ fn recursive_with_record_error(s: Span) -> IResult { push_parse_error(SyntaxError::RecordInRecursiveParams(h.clone())); Ok(( s, - Type::Vec( + Type::vec( VecType::S3, - Box::new(num), - Box::new(Type::Record(columns.iter().cloned().collect(), h.clone())), + num, + Type::Record(columns.iter().cloned().collect(), h.clone()), h, ), )) @@ -551,8 +538,12 @@ fn integer_literal(s: Span) -> IResult { let res = terminated(digit1, multispace0).parse(s); match res { Ok((s, span)) => { - let val: i32 = (*span).parse().unwrap_or(0); - Ok((s, Type::Integer(Tint::Val(val), span.into()))) + // Out of range is a parse failure: `unwrap_or(0)` used to turn + // `type Big <- 99999999999` into the singleton type `0`. + match (*span).parse::() { + Ok(val) => Ok((s, Type::Integer(Tint::Val(val), span.into()))), + Err(_) => Err(nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Digit))), + } } Err(r) => Err(r), } @@ -599,11 +590,48 @@ pub fn pascal_case_no_space(s: Span) -> IResult { } } +/// The `@Id` suffix of `Lovable@A`: an implicit generic `A` bounded by `Lovable`. +/// It must touch the alias name (no space), and `@_` is the anonymous id. +fn bound_suffix(s: Span) -> IResult { + preceded( + tag("@"), + alt(( + map(tag("_"), |_| "_".to_string()), + // One capital letter, not followed by more identifier characters: + // `@Self` or `@Abc` must fail rather than lose `elf` / `bc`. + map( + terminated( + one_of("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), + not(one_of( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_", + )), + ), + |c| c.to_string(), + ), + )), + ) + .parse(s) +} + pub fn type_alias(s: Span) -> IResult { - let res = (pascal_case_no_space, terminated(opt(type_params), multispace0)).parse(s); + let res = (pascal_case_no_space, opt(type_params), opt(bound_suffix), multispace0).parse(s); match res { - Ok((s, ((name, h), Some(v)))) => Ok((s, Type::Alias(name, v.clone(), false, h))), - Ok((s, ((name, h), None))) => Ok((s, Type::Alias(name, vec![], false, h))), + Ok((rest, ((name, h), params, None, ws))) + if (!ws.fragment().is_empty() && bound_suffix(rest.clone()).is_ok()) + || rest.fragment().starts_with('@') => + { + // `Lovable @A` or `Lovable@Self`: a malformed suffix. Left alone, the priority + // resolver would silently drop the stray `@...` type. + push_parse_error(SyntaxError::DetachedBoundSuffix(rest.clone().into())); + Ok((rest, Type::Alias(name, params.unwrap_or_default(), false, h))) + } + Ok((s, ((name, h), params, bound, _))) => { + let alias = Type::Alias(name, params.unwrap_or_default(), false, h.clone()); + match bound { + Some(id) => Ok((s, Type::Bounded(id, Box::new(alias), h))), + None => Ok((s, alias)), + } + } Err(r) => Err(r), } } @@ -640,6 +668,67 @@ pub fn single_letter_type_alias(s: Span) -> IResult { } } +/// A refinement property, only meaningful right of `&`: `length(5)`, `(> 0)`, +/// `(< 2.5)`. It becomes a `Type::Property`, which `norm_intersection` folds +/// into a `Refined` type. Tried before `parenthese_value` and `type_variable`, +/// which would otherwise read `(> 0)` / `length` as a group / a variable. +fn refinement_property(s: Span) -> IResult { + /// `>`, `>=`, `<`, `<=` + fn comparison(s: Span) -> IResult { + let (s, op) = one_of("<>").parse(s)?; + let (s, eq) = opt(tag("=")).parse(s)?; + let (s, _) = multispace0.parse(s)?; + Ok((s, (op, eq.is_some()))) + } + fn number(s: Span) -> IResult { + terminated(recognize((opt(tag("-")), digit1, opt((tag("."), digit1)))), multispace0).parse(s) + } + /// `length(5)` or `length(> 0)` + fn length_prop(s: Span) -> IResult, Span)> { + let (s, kw) = terminated(tag("length"), multispace0).parse(s)?; + let (s, _) = terminated(tag("("), multispace0).parse(s)?; + let (s, cmp) = opt(comparison).parse(s)?; + let (s, n) = terminated(digit1, multispace0).parse(s)?; + let (s, _) = tag(")").parse(s)?; + Ok((s, (kw, cmp, n))) + } + fn compare_prop(s: Span) -> IResult { + let (s, _) = terminated(tag("("), multispace0).parse(s)?; + let (s, cmp) = comparison(s)?; + let (s, n) = number(s)?; + let (s, _) = tag(")").parse(s)?; + Ok((s, (cmp, n))) + } + let help: HelpData = s.clone().into(); + if let Ok((rest, (kw, cmp, n))) = length_prop(s.clone()) { + let Ok(n) = (*n).parse::() else { + return Err(nom::Err::Error(nom::error::Error::new(s, nom::error::ErrorKind::Digit))); + }; + let prop = match cmp { + None => Refinement::Length(n), + Some(cmp) => Refinement::Range(Measure::Length, bound_interval(cmp, n as f64)), + }; + return Ok((rest, Type::Property(prop, kw.into()))); + } + let (rest, (cmp, num)) = compare_prop(s)?; + let c = (*num).parse().unwrap_or(0.0); + let prop = match cmp { + ('>', false) => Refinement::Gt(Num::new(c)), + ('<', false) => Refinement::Lt(Num::new(c)), + _ => Refinement::Range(Measure::Value, bound_interval(cmp, c)), + }; + Ok((rest, Type::Property(prop, help))) +} + +fn bound_interval((op, eq): (char, bool), c: f64) -> Interval { + match (op, eq) { + ('>', false) => Interval::greater_than(c), + ('>', true) => Interval::at_least(c), + ('<', false) => Interval::less_than(c), + _ => Interval::at_most(c), + } +} + fn parenthese_value(s: Span) -> IResult { delimited( terminated(tag("("), multispace0), @@ -928,43 +1017,21 @@ fn empty(s: Span) -> IResult { } } -fn compute_operators(v: &mut Vec<(Type, Op)>) -> Type { - // (params, op) - let first = v.pop().unwrap(); - match first { - (p, Op::Add(_)) => { - let res = compute_operators(v); - let pp = p; - Type::Operator(TypeOperator::Addition, Box::new(res.clone()), Box::new(pp), res.into()) - } - (p, Op::Minus(_)) => { - let res = compute_operators(v); - let pp = p; - Type::Operator( - TypeOperator::Substraction, - Box::new(res.clone()), - Box::new(pp), - res.into(), - ) - } - (p, Op::Mul(_)) => { - let res = compute_operators(v); - let pp = p; - Type::Operator( - TypeOperator::Multiplication, - Box::new(res.clone()), - Box::new(pp), - res.into(), - ) - } - (p, Op::Div(_)) => { - let res = compute_operators(v); - let pp = p; - Type::Operator(TypeOperator::Division, Box::new(res.clone()), Box::new(pp), res.into()) - } - (p, Op::Empty(_)) => p, - _ => panic!(), - } +fn compute_operators(v: &mut Vec<(Type, Op)>) -> Option { + // (params, op). `None` when the chain holds an operator that has no + // meaning at the type level (`>`, `<`, `==`, ...): the caller turns that + // into a parse failure instead of aborting. + let (p, op) = v.pop()?; + let arith = match op { + Op::Add(_) => TypeOperator::Addition, + Op::Minus(_) => TypeOperator::Substraction, + Op::Mul(_) => TypeOperator::Multiplication, + Op::Div(_) => TypeOperator::Division, + Op::Empty(_) => return Some(p), + _ => return None, + }; + let res = compute_operators(v)?; + Some(Type::Operator(arith, Box::new(res.clone()), Box::new(p), res.into())) } fn index_operator(s: Span) -> IResult { @@ -977,9 +1044,15 @@ fn index_operator(s: Span) -> IResult { } fn index_chain(s: Span) -> IResult { - let res = many1(index_operator).parse(s); + let res = many1(index_operator).parse(s.clone()); match res { - Ok((s, v)) => Ok((s, compute_operators(&mut v.clone()))), + Ok((rest, v)) => match compute_operators(&mut v.clone()) { + Some(t) => Ok((rest, t)), + None => Err(nom::Err::Error(nom::error::Error::new( + s, + nom::error::ErrorKind::Verify, + ))), + }, Err(r) => Err(r), } } @@ -1162,7 +1235,8 @@ pub fn single_type(s: Span) -> IResult { unknown_function, bracket_tuple_record, // Tuple[...] / Record[...] — before type_alias composite_vec_type, - list_types, // tuple{}/record{}/list{} brace forms + list_types, // tuple{}/record{}/list{} brace forms + refinement_property, // length(n), (> c), (< c) parenthese_value, tag_type, any, @@ -1197,6 +1271,119 @@ mod tests { use crate::components::r#type::type_system::TypeSystem; use crate::utils::builder; + fn bounded(src: &str) -> (String, String) { + match ltype(src.into()).unwrap().1 { + Type::Bounded(id, bound, _) => (id, bound.pretty()), + other => panic!("expected Bounded, got {:?}", other), + } + } + + #[test] + fn test_bounded_suffix_parses() { + assert_eq!(bounded("Lovable@A"), ("A".to_string(), "Lovable".to_string())); + assert_eq!(bounded("Lovable@_"), ("_".to_string(), "Lovable".to_string())); + assert_eq!(bounded("Eq@T"), ("T".to_string(), "Eq".to_string())); + } + + #[test] + fn test_bounded_suffix_rejects_long_ids() { + // `@Self` / `@Abc` must not parse as `@S` / `@A` with the tail swallowed. + assert!(bound_suffix("@Self".into()).is_err()); + assert!(bound_suffix("@Abc".into()).is_err()); + assert!(bound_suffix("@A".into()).is_ok()); + } + + #[test] + fn test_malformed_bound_suffix_is_an_error() { + for src in ["Lovable@Self", "Lovable@Abc"] { + let _ = crate::processes::parsing::take_parse_errors(); + let _ = ltype(src.into()); + let errors = crate::processes::parsing::take_parse_errors(); + assert!( + errors.iter().any(|e| matches!(e, SyntaxError::DetachedBoundSuffix(_))), + "{src}" + ); + } + } + + #[test] + fn test_bounded_suffix_nested() { + let t = ltype("[3, Lovable@A]".into()).unwrap().1; + assert!( + matches!(&t, Type::Vec(_, _, elem, _) if matches!(elem.as_ref(), Type::Bounded(id, _, _) if id == "A")) + ); + let t = ltype("tuple{Lovable@B, Lovable@A}".into()).unwrap().1; + assert!(matches!(&t, Type::Tuple(v, _) if v.len() == 2 && v.iter().all(|e| matches!(e, Type::Bounded(..))))); + let t = ltype("(Lovable@A) -> Lovable@A".into()).unwrap().1; + assert!(matches!(&t, Type::Function(args, ret, _) + if matches!(&args[0].get_type(), Type::Bounded(..)) && matches!(ret.as_ref(), Type::Bounded(..)))); + } + + #[test] + fn test_bounded_suffix_prints_back() { + let t = ltype("Lovable@A".into()).unwrap().1; + assert_eq!(t.pretty(), "Lovable@A"); + } + + #[test] + fn test_bare_alias_and_sigil_unchanged() { + assert!(matches!(ltype("Lovable".into()).unwrap().1, Type::Alias(..))); + assert!(matches!( + ltype("@A".into()).unwrap().1, + Type::KindedGen(Kind::Interface, ..) + )); + } + + #[test] + fn test_detached_bound_suffix_is_an_error() { + let _ = crate::processes::parsing::take_parse_errors(); + let _ = ltype("Lovable @A".into()); + let errors = crate::processes::parsing::take_parse_errors(); + assert!(errors.iter().any(|e| matches!(e, SyntaxError::DetachedBoundSuffix(_)))); + } + + #[test] + fn test_refinement_property_parses() { + let ok = |src: &str| refinement_property(src.into()).unwrap().1; + assert!(matches!(ok("length(5)"), Type::Property(Refinement::Length(5), _))); + assert!(matches!(ok("length( 5 )"), Type::Property(Refinement::Length(5), _))); + assert!(matches!(ok("(> 0)"), Type::Property(Refinement::Gt(_), _))); + assert!(matches!(ok("(< -2.5)"), Type::Property(Refinement::Lt(_), _))); + assert!(matches!( + ok("(>= 0)"), + Type::Property(Refinement::Range(Measure::Value, _), _) + )); + assert!(matches!( + ok("(<= 9.5)"), + Type::Property(Refinement::Range(Measure::Value, _), _) + )); + assert!(matches!( + ok("length(> 0)"), + Type::Property(Refinement::Range(Measure::Length, _), _) + )); + assert!(matches!( + ok("length(<= 10)"), + Type::Property(Refinement::Range(Measure::Length, _), _) + )); + } + + #[test] + fn test_refinement_property_rejects_lookalikes() { + assert!(refinement_property("length(x)".into()).is_err()); + assert!(refinement_property("length".into()).is_err()); + assert!(refinement_property("(int)".into()).is_err()); + assert!(refinement_property("(== 0)".into()).is_err()); + assert!(refinement_property("length(>)".into()).is_err()); + } + + #[test] + fn test_intersection_with_property_does_not_panic() { + let t = ltype("int & (> 0)".into()).unwrap().1; + assert!(matches!(t, Type::Operator(TypeOperator::Intersection, _, _, _))); + let v = ltype("[int] & length(5)".into()).unwrap().1; + assert!(matches!(v, Type::Operator(TypeOperator::Intersection, _, _, _))); + } + #[test] fn test_fabrice0() { let arr1 = ltype("[1, T]".into()).unwrap().1; @@ -1278,8 +1465,8 @@ mod tests { assert!(res.is_ok(), "dataframe type with numeric index should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::DataFrame, idx, body, _) => { - match idx.as_ref() { + Type::Vec(VecType::DataFrame, _, body, _) => { + match typ.vec_length().expect("vec length") { Type::Integer(_, _) => {} other => panic!("Expected Integer index, got {:?}", other), } @@ -1323,7 +1510,7 @@ mod tests { assert!(res.is_ok(), "dataframe with Any index should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::DataFrame, idx, _, _) => match idx.as_ref() { + Type::Vec(VecType::DataFrame, _, _, _) => match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), }, @@ -1337,7 +1524,7 @@ mod tests { assert!(res.is_ok(), "Vec with Any index should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::Vector, idx, _, _) => match idx.as_ref() { + Type::Vec(VecType::Vector, _, _, _) => match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), }, @@ -1351,7 +1538,7 @@ mod tests { assert!(res.is_ok(), "Array with Any index should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::S3, idx, _, _) => match idx.as_ref() { + Type::Vec(VecType::S3, _, _, _) => match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), }, @@ -1365,8 +1552,8 @@ mod tests { assert!(res.is_ok(), "dataframe without index should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::DataFrame, idx, body, _) => { - match idx.as_ref() { + Type::Vec(VecType::DataFrame, _, body, _) => { + match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), } @@ -1385,7 +1572,7 @@ mod tests { assert!(res.is_ok(), "df without index should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::DataFrame, idx, _, _) => match idx.as_ref() { + Type::Vec(VecType::DataFrame, _, _, _) => match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), }, @@ -1399,8 +1586,8 @@ mod tests { assert!(res.is_ok(), "Vec[type] short form should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::Vector, idx, body, _) => { - match idx.as_ref() { + Type::Vec(VecType::Vector, _, body, _) => { + match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), } @@ -1419,8 +1606,8 @@ mod tests { assert!(res.is_ok(), "[type] short form should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::S3, idx, body, _) => { - match idx.as_ref() { + Type::Vec(VecType::S3, _, body, _) => { + match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), } @@ -1439,8 +1626,8 @@ mod tests { assert!(res.is_ok(), "Array[index, type] should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::Array, idx, body, _) => { - match idx.as_ref() { + Type::Vec(VecType::Array, _, body, _) => { + match typ.vec_length().expect("vec length") { Type::Integer(_, _) => {} other => panic!("Expected Integer index, got {:?}", other), } @@ -1459,8 +1646,8 @@ mod tests { assert!(res.is_ok(), "Array[type] short form should parse"); let typ = res.unwrap().1; match &typ { - Type::Vec(VecType::Array, idx, body, _) => { - match idx.as_ref() { + Type::Vec(VecType::Array, _, body, _) => { + match typ.vec_length().expect("vec length") { Type::Any(_) => {} other => panic!("Expected Any index, got {:?}", other), } @@ -1527,9 +1714,9 @@ mod tests { fn test_named_record_constructor_parses() { let typ = ltype("Tibble[3]{ id: int, active: bool }".into()).unwrap().1; match &typ { - Type::Vec(VecType::Named(name), idx, body, _) => { + Type::Vec(VecType::Named(name), _, body, _) => { assert_eq!(name, "Tibble"); - assert!(matches!(idx.as_ref(), Type::Integer(_, _))); + assert!(matches!(typ.vec_length(), Some(Type::Integer(_, _)))); match body.as_ref() { Type::Record(fields, _) => assert_eq!(fields.len(), 2), other => panic!("Expected Record body, got {:?}", other), diff --git a/crates/typr-core/src/processes/spg/model.rs b/crates/typr-core/src/processes/spg/model.rs index caff81a7..dc0b5bcd 100644 --- a/crates/typr-core/src/processes/spg/model.rs +++ b/crates/typr-core/src/processes/spg/model.rs @@ -115,6 +115,14 @@ pub struct StdlibMeta { pub seealso: Vec, /// The R package of origin (e.g. "base", "stats"). pub pkg: Option, + /// Minimum R package version this entry was declared against (a floor, + /// never a closed range — `typR/registry.md` §7.2). External type + /// definitions only (rfcs/0031-external-type-definitions.md); unset for + /// the standard library. + pub since: Option, + /// Only set when a break is *known*, never speculative. Same scope as + /// `since`. + pub until: Option, } impl StdlibMeta { @@ -127,6 +135,8 @@ impl StdlibMeta { examples: Vec::new(), seealso: Vec::new(), pkg: None, + since: None, + until: None, } } @@ -139,6 +149,8 @@ impl StdlibMeta { || !self.examples.is_empty() || !self.seealso.is_empty() || self.pkg.is_some() + || self.since.is_some() + || self.until.is_some() } } diff --git a/crates/typr-core/src/processes/spg/stdlib_meta.rs b/crates/typr-core/src/processes/spg/stdlib_meta.rs index e6d23c90..017cb2f4 100644 --- a/crates/typr-core/src/processes/spg/stdlib_meta.rs +++ b/crates/typr-core/src/processes/spg/stdlib_meta.rs @@ -14,6 +14,14 @@ pub struct FunctionMeta { pub coercion_notes: Option, pub examples: Vec, pub seealso: Vec, + /// Minimum R package version this entry was declared against — a floor, + /// never a closed range (`typR/registry.md` §7.2). Meaningful only in an + /// external type definition (`rfcs/0031-external-type-definitions.md`); + /// absent from the standard library. + pub since: Option, + /// Only set when a break is *known*, never speculative. Same scope as + /// `since`. + pub until: Option, } impl FunctionMeta { @@ -27,6 +35,8 @@ impl FunctionMeta { examples: self.examples, seealso: self.seealso, pkg: self.pkg, + since: self.since, + until: self.until, } } @@ -39,6 +49,8 @@ impl FunctionMeta { || !self.examples.is_empty() || !self.seealso.is_empty() || self.pkg.is_some() + || self.since.is_some() + || self.until.is_some() } } @@ -99,6 +111,14 @@ pub fn parse_meta_from_source(source: &str) -> HashMap { let target = pending_meta.get_or_insert_with(FunctionMeta::default); target.ret_doc = Some(strip_leading_colon(value)); } + "since" => { + let target = pending_meta.get_or_insert_with(FunctionMeta::default); + target.since = Some(strip_leading_colon(value)); + } + "until" => { + let target = pending_meta.get_or_insert_with(FunctionMeta::default); + target.until = Some(strip_leading_colon(value)); + } "coercion" | "note" => { let target = pending_meta.get_or_insert_with(FunctionMeta::default); target.coercion_notes = Some(strip_leading_colon(value)); @@ -282,6 +302,33 @@ let x: int <- 5;"; assert!(!map.contains_key("stats::rnorm")); } + #[test] + fn since_and_until_are_parsed() { + let src = "\ +#! pkg: dplyr +#! tier: T3 +#! since: 1.1.0 +#! until: 2.0.0 +@filter: (Any, Any) -> Any;"; + + let map = parse_meta_from_source(src); + let meta = map.get("filter").unwrap(); + assert_eq!(meta.since.as_deref(), Some("1.1.0")); + assert_eq!(meta.until.as_deref(), Some("2.0.0")); + } + + #[test] + fn since_without_until_leaves_until_none() { + let src = "\ +#! since: 1.11.0 +@fluidPage: (Any) -> Any;"; + + let map = parse_meta_from_source(src); + let meta = map.get("fluidPage").unwrap(); + assert_eq!(meta.since.as_deref(), Some("1.11.0")); + assert!(meta.until.is_none()); + } + #[test] fn empty_tier_is_none() { let src = "\ diff --git a/crates/typr-core/src/processes/transpiling/checked_assertions.rs b/crates/typr-core/src/processes/transpiling/checked_assertions.rs index 4ef11086..1fda61aa 100644 --- a/crates/typr-core/src/processes/transpiling/checked_assertions.rs +++ b/crates/typr-core/src/processes/transpiling/checked_assertions.rs @@ -64,6 +64,9 @@ fn checked_descriptor(context: &Context, typ: &Type) -> Option { Type::Char(_, _) => Some("\"character\"".to_string()), Type::Boolean(_, _) => Some("\"logical\"".to_string()), Type::Function(_, _, _) => Some("\"function\"".to_string()), + // `Lovable@A` asserts like its bound `Lovable`: the id only matters + // to the type checker, never to the runtime value. + Type::Bounded(_, bound, _) => checked_descriptor(context, bound), Type::Any(_) | Type::Generic(_, _) | Type::IndexGen(_, _) diff --git a/crates/typr-core/src/processes/transpiling/mod.rs b/crates/typr-core/src/processes/transpiling/mod.rs index 387cd787..700e74de 100644 --- a/crates/typr-core/src/processes/transpiling/mod.rs +++ b/crates/typr-core/src/processes/transpiling/mod.rs @@ -1,4 +1,5 @@ pub mod checked_assertions; +pub mod refinement_checks; pub mod translatable; use crate::components::context::config::Environment; @@ -1236,7 +1237,17 @@ impl RTranslatable<(String, Context)> for Lang { } } else { let (val_str, _) = val.to_simple_r(cont); - format!("{}[[{}]]", exp_str, val_str) + // `v[1:3]` / `v[mask]` select a sub-vector: single bracket + // (`v[[1:3]]` would index recursively in R). + let selects_subvector = matches!(reduce_type(cont, &typing(cont, exp).value), Type::Vec(..)) + && matches!(val.get_members_if_array().as_deref(), Some([m]) + if !matches!(m, Lang::Integer { .. } | Lang::Array { .. } | Lang::Vector { .. }) + && matches!(reduce_type(cont, &typing(cont, m).value), Type::Vec(..))); + if selects_subvector { + format!("{}[{}]", exp_str, val_str) + } else { + format!("{}[[{}]]", exp_str, val_str) + } }; (res, cont.clone()) } @@ -1272,6 +1283,11 @@ impl RTranslatable<(String, Context)> for Lang { .map(|v| v.get_type()) .filter(|t| !matches!(t, Type::Empty(_) | Type::UnknownFunction(_))) .unwrap_or_else(|| typing(cont, expr).value); + // `Lovable@A` dispatches as its bound `Lovable`. + let related_type = match related_type { + Type::Bounded(_, bound, _) => *bound, + other => other, + }; let method = match cont.get_environment() { Environment::Project => format!( "#' @method {}\n", @@ -1760,7 +1776,7 @@ impl RTranslatable<(String, Context)> for Lang { // into a `data.frame` instead of a `list`, the class // chain carries `"data.frame"`, and a concrete size // index (`df[3]{...}`) additionally checks `nrow(x)`. - Type::Vec(VecType::DataFrame, size, fields_type, _) + Type::Vec(VecType::DataFrame, _, fields_type, _) if matches!(fields_type.as_ref(), Type::Record(_, _)) => { use crate::components::r#type::tint::Tint; @@ -1821,7 +1837,7 @@ impl RTranslatable<(String, Context)> for Lang { // Row-count check: only emitted when the size index is // a concrete literal (`df[3]{...}`); a generic (`#N`) // or unconstrained (`df{...}`) size imposes no check. - let size_check = if let Type::Integer(Tint::Val(n), _) = size.as_ref() { + let size_check = if let Some(Type::Integer(Tint::Val(n), _)) = typ_for_dispatch.vec_length() { format!( " if (nrow(x) != {n}) stop(paste0(\"Validation failed for type {name}: expected {n} rows, got \", nrow(x)))\n" ) @@ -1840,7 +1856,7 @@ impl RTranslatable<(String, Context)> for Lang { // constructor delegates straight to the validator — plus // an optional length check when the size index is a // concrete literal. - Type::Vec(VecType::Vector, size, elem_type, _) => { + Type::Vec(VecType::Vector, _, elem_type, _) => { use crate::components::r#type::tint::Tint; let constructor = format!("{name} <- function(x) {{\n validate_{name}(x)\n}}"); let elem_check = record_field_class(elem_type.as_ref(), cont).map(|cls| { @@ -1848,7 +1864,7 @@ impl RTranslatable<(String, Context)> for Lang { " if (!inherits(x, \"{cls}\")) stop(\"Validation failed for type {name}: expected vector of {cls}\")\n" ) }).unwrap_or_default(); - let size_check = if let Type::Integer(Tint::Val(n), _) = size.as_ref() { + let size_check = if let Some(Type::Integer(Tint::Val(n), _)) = typ_for_dispatch.vec_length() { format!( " if (length(x) != {n}) stop(paste0(\"Validation failed for type {name}: expected length {n}, got \", length(x)))\n" ) @@ -1875,7 +1891,7 @@ impl RTranslatable<(String, Context)> for Lang { // still appends the alias class (S3 dispatch for // alias-typed parameters relies on it); `c()`/subsetting // strip it, but TypR re-annotates from the static type. - Type::Vec(VecType::S3, size, elem_type, _) | Type::Vec(VecType::Array, size, elem_type, _) + Type::Vec(VecType::S3, _, elem_type, _) | Type::Vec(VecType::Array, _, elem_type, _) if cont.atomic_array_elem(typ).is_some() => { use crate::components::r#type::tint::Tint; @@ -1898,7 +1914,7 @@ impl RTranslatable<(String, Context)> for Lang { " if (!is.numeric(x)) stop(\"Validation failed for type {name}: expected a numeric vector\")\n" ), }; - let size_check = if let Type::Integer(Tint::Val(n), _) = size.as_ref() { + let size_check = if let Some(Type::Integer(Tint::Val(n), _)) = typ_for_dispatch.vec_length() { format!( " if (length(x) != {n}) stop(paste0(\"Validation failed for type {name}: expected length {n}, got \", length(x)))\n" ) @@ -1908,7 +1924,7 @@ impl RTranslatable<(String, Context)> for Lang { let validator = format!("validate_{name} <- function(x) {{\n{elem_check}{size_check} x\n}}"); (format!("{constructor}\n{annotator}\n{validator}"), cont.clone()) } - Type::Vec(VecType::S3, size, elem_type, _) | Type::Vec(VecType::Array, size, elem_type, _) => { + Type::Vec(VecType::S3, _, elem_type, _) | Type::Vec(VecType::Array, _, elem_type, _) => { use crate::components::r#type::tint::Tint; let constructor = format!( "{name} <- function(x) {{\n if (!inherits(x, \"typed_vec\")) x <- typed_vec(x)\n as.{name}(x)\n}}" @@ -1922,7 +1938,7 @@ impl RTranslatable<(String, Context)> for Lang { " if (!all(vapply(x$data, inherits, logical(1), \"{cls}\"))) stop(\"Validation failed for type {name}: expected elements of class {cls}\")\n" ) }).unwrap_or_default(); - let size_check = if let Type::Integer(Tint::Val(n), _) = size.as_ref() { + let size_check = if let Some(Type::Integer(Tint::Val(n), _)) = typ_for_dispatch.vec_length() { format!( " if (length(x) != {n}) stop(paste0(\"Validation failed for type {name}: expected length {n}, got \", length(x)))\n" ) @@ -2814,7 +2830,22 @@ impl RTranslatable<(String, Context)> for Lang { result }; - result + // Refinement the type checker could not prove at this boundary (`let` + // initialiser, call argument; `refinement_check::coerce_to`): the + // obligation is keyed by this expression's span. Single insertion + // point, so every boundary kind shares it. + let (r_code, r_cont) = result; + // `Lines`/`Scope`/`If`/`Return` share their span with the expression + // they hand the position to, which does the wrapping. + let r_code = if matches!( + self, + Lang::Lines { .. } | Lang::Scope { .. } | Lang::If { .. } | Lang::Return { .. } + ) { + r_code + } else { + refinement_checks::wrap_obligation(cont, r_code, &self.get_help_data()) + }; + (r_code, r_cont) } } @@ -2883,6 +2914,25 @@ mod tests { ); } + #[test] + fn test_bounded_id_does_not_change_generated_r() { + // `Lovable@A` is a type-checker-only id: the generated R (S3 method + // suffix, `.default` fallback, return cast) must match the bare form. + let header = [ + "type Lovable <- interface { love: (Self) -> int };", + "type Cat <- list { name: char };", + "let love <- fn(c: Cat): int { 1 };", + ]; + let with = |def: &'static str| { + let mut p = header.to_vec(); + p.push(def); + transpile_program(&p) + }; + let bare = with("let idf <- fn(a: Lovable): Lovable { a };"); + let ided = with("let idf <- fn(a: Lovable@A): Lovable@A { a };"); + assert_eq!(bare, ided, "@A must not leak into the generated R"); + } + #[test] fn test_any_first_param_default_name_backticks_wrap_whole_name() { // `Type::Any` is left unsuffixed by `display_type`, so `.default` diff --git a/crates/typr-core/src/processes/transpiling/refinement_checks.rs b/crates/typr-core/src/processes/transpiling/refinement_checks.rs new file mode 100644 index 00000000..a71939aa --- /dev/null +++ b/crates/typr-core/src/processes/transpiling/refinement_checks.rs @@ -0,0 +1,108 @@ +//! Runtime side of refined types (`refined_types_plan.md`, D5/D6). +//! +//! The type checker decides, at each boundary, which refinements it could not +//! prove and records them as obligations in the `Context` +//! (`refinement_check::coerce_to`). This module only reads that table and +//! wraps the transpiled expression in the prelude helpers of `std.R`: +//! +//! ```r +//! a <- typr_refine_length(read_coordinates(), 2L, "main.ty:12") +//! ``` +//! +//! The helpers return their argument, so the wrap works in any expression +//! position. Nothing is emitted for a boundary without an obligation. + +use crate::components::context::Context; +use crate::components::error_message::help_data::HelpData; +use crate::components::r#type::refinement::{Bound, Interval, Measure, RefinementSet}; +use crate::processes::transpiling::escape_r_string; + +/// Wrap `r_expr`, the transpilation of the expression at `expr_h`, in the +/// checks the type checker required there. `r_expr` is returned unchanged +/// when there is no obligation. +pub fn wrap_obligation(context: &Context, r_expr: String, expr_h: &HelpData) -> String { + match context.refinement_obligation_at(expr_h) { + Some(set) => wrap_set(r_expr, set, &format_loc(expr_h)), + None => r_expr, + } +} + +pub fn wrap_set(r_expr: String, set: &RefinementSet, loc: &str) -> String { + set.iter() + .fold(r_expr, |acc, (measure, iv)| wrap_measure(acc, *measure, iv, loc)) +} + +fn wrap_measure(x: String, measure: Measure, iv: &Interval, loc: &str) -> String { + let loc = escape_r_string(loc); + match (measure, iv.as_point()) { + (Measure::Length, Some(n)) => format!("typr_refine_length({}, {}L, {})", x, n as i64, loc), + // Only point lengths can be written today; a range would need its own + // helper, and dropping the check silently is not an option. + (Measure::Length, None) => format!("typr_refine_length_range({}, {}, {})", x, bound_args(iv), loc), + (Measure::Value, _) => format!("typr_refine_value({}, {}, {})", x, bound_args(iv), loc), + } +} + +/// `lo, lo_open, hi, hi_open` as R arguments (`-Inf`/`Inf` when unbounded). +fn bound_args(iv: &Interval) -> String { + let (lo, lo_open) = match iv.lo() { + Bound::Unbounded => ("-Inf".to_string(), false), + Bound::Open(c) => (r_number(c.get()), true), + Bound::Closed(c) => (r_number(c.get()), false), + }; + let (hi, hi_open) = match iv.hi() { + Bound::Unbounded => ("Inf".to_string(), false), + Bound::Open(c) => (r_number(c.get()), true), + Bound::Closed(c) => (r_number(c.get()), false), + }; + format!( + "{}, {}, {}, {}", + lo, + lo_open.to_string().to_uppercase(), + hi, + hi_open.to_string().to_uppercase() + ) +} + +fn r_number(v: f64) -> String { + if v.fract() == 0.0 && v.abs() < 1e9 { + format!("{}", v as i64) + } else { + format!("{}", v) + } +} + +fn format_loc(loc: &HelpData) -> String { + let file = loc.get_file_name(); + if file.is_empty() { + return "".to_string(); + } + let line = loc.get_file_data().and_then(|(_, content)| { + let offset = loc.get_offset().min(content.len()); + content.get(..offset).map(|prefix| prefix.matches('\n').count() + 1) + }); + match line { + Some(line) => format!("{}:{}", file, line), + None => format!("{}:offset {}", file, loc.get_offset()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::r#type::refinement::Refinement; + + #[test] + fn length_and_value_forms() { + let set = RefinementSet::single(&Refinement::Length(3)); + assert_eq!( + wrap_set("x".into(), &set, "m.ty:1"), + "typr_refine_length(x, 3L, \"m.ty:1\")" + ); + let set = RefinementSet::single(&Refinement::Gt(crate::components::r#type::refinement::Num::new(0.0))); + assert_eq!( + wrap_set("x".into(), &set, "m.ty:1"), + "typr_refine_value(x, 0, TRUE, Inf, FALSE, \"m.ty:1\")" + ); + } +} diff --git a/crates/typr-core/src/processes/type_checking/dollar_access.rs b/crates/typr-core/src/processes/type_checking/dollar_access.rs index 106db30d..a2b8a413 100644 --- a/crates/typr-core/src/processes/type_checking/dollar_access.rs +++ b/crates/typr-core/src/processes/type_checking/dollar_access.rs @@ -45,7 +45,8 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & let mut errors = tc1.errors; let ty1 = tc1.value; - match (ty1.reduce(context), e2.clone(), &op) { + let reduced1 = ty1.reduce(context); + match (reduced1.clone(), e2.clone(), &op) { (Type::Record(fields, _), Lang::Variable { name, help_data: h, .. }, _) => { field_access::find_field_or_push_error(&fields, &name, &ty1, h, &mut errors) .map(|ty| TypeContext::new(ty, expr.clone(), context.clone()).with_errors(errors.clone())) @@ -167,7 +168,7 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & } // DataFrame $ access: df$col -> [N, col_type] (VecType::Vector) ( - Type::Vec(VecType::DataFrame, n, ref body, h), + Type::Vec(VecType::DataFrame, _, ref body, h), Lang::Variable { name, help_data: ref vh, @@ -183,7 +184,12 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & other => other, }; TypeContext::new( - Type::Vec(VecType::Vector, n.clone(), Box::new(inner_type), h.clone()), + Type::vec( + VecType::Vector, + reduced1.vec_length().unwrap_or_else(builder::any_type), + inner_type, + h.clone(), + ), expr.clone(), context.clone(), ) @@ -199,7 +205,7 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & }, // DataFrame $ access with string literal: df$"col" ( - Type::Vec(VecType::DataFrame, n, ref body, h), + Type::Vec(VecType::DataFrame, _, ref body, h), Lang::Char { value: name, help_data: ref vh, @@ -214,7 +220,12 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & other => other, }; TypeContext::new( - Type::Vec(VecType::Vector, n, Box::new(inner_type), h.clone()), + Type::vec( + VecType::Vector, + reduced1.vec_length().unwrap_or_else(builder::any_type), + inner_type, + h.clone(), + ), expr.clone(), context.clone(), ) @@ -229,7 +240,7 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & } }, // DataFrame $ update with list: df$:{col: new_type} - (Type::Vec(VecType::DataFrame, n, ref body, h), Lang::List { value: fields2, .. }, _) => match body.as_ref() { + (Type::Vec(VecType::DataFrame, _, ref body, h), Lang::List { value: fields2, .. }, _) => match body.as_ref() { Type::Record(fields1, rh) => { let at = fields2[0].clone(); let fields3 = fields1 @@ -237,10 +248,10 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & .map(replace_fields_type_if_needed(context, at)) .collect::>(); TypeContext::new( - Type::Vec( + Type::vec( VecType::DataFrame, - n, - Box::new(Type::Record(fields3, rh.clone())), + reduced1.vec_length().unwrap_or_else(builder::any_type), + Type::Record(fields3, rh.clone()), h.clone(), ), expr.clone(), @@ -255,7 +266,7 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & }, // DataFrame $ function call: df$fn(args) ( - Type::Vec(VecType::DataFrame, _n, ref body, _h), + Type::Vec(VecType::DataFrame, _, ref body, _h), Lang::FunctionApp { identifier: exp, arguments: params, @@ -293,7 +304,7 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors) } }, - (Type::Vec(vtype, n, _, h), Lang::Variable { .. }, _) => match ArrayLang::try_from(e1.clone()) { + (Type::Vec(vtype, _, _, h), Lang::Variable { .. }, _) => match ArrayLang::try_from(e1.clone()) { Ok(arr_lang) => match arr_lang.get_first_argument() { Some(first_arg) => { let tc = typing( @@ -302,7 +313,12 @@ pub fn dollar_access(context: &Context, expr: &Lang, e1: &Lang, e2: &Lang, hd: & ); errors.extend(tc.errors); TypeContext::new( - Type::Vec(vtype, n, Box::new(tc.value), h.clone()), + Type::vec( + vtype, + reduced1.vec_length().unwrap_or_else(builder::any_type), + tc.value, + h.clone(), + ), tc.lang, context.clone(), ) diff --git a/crates/typr-core/src/processes/type_checking/function.rs b/crates/typr-core/src/processes/type_checking/function.rs index 2768feac..7dab0900 100644 --- a/crates/typr-core/src/processes/type_checking/function.rs +++ b/crates/typr-core/src/processes/type_checking/function.rs @@ -2,9 +2,11 @@ use crate::components::error_message::syntax_error::SyntaxError; use crate::components::error_message::type_error::TypeError; use crate::components::error_message::typr_error::TypRError; use crate::components::language::var::Var; +use crate::components::r#type::is_rigid_name; use crate::components::r#type::kind::Kind; use crate::components::r#type::type_system::TypeSystem; use crate::processes::type_checking::facets; +use crate::processes::type_checking::signature_normalization; use crate::processes::type_checking::type_comparison::reduce_type; use crate::processes::type_checking::ArgumentType; use crate::processes::type_checking::HelpData; @@ -53,8 +55,10 @@ fn collect_generic_kind_occurrences(typ: &Type, acc: &mut Vec<(String, ObservedK .for_each(|a| collect_generic_kind_occurrences(&a.get_type(), acc)); collect_generic_kind_occurrences(ret, acc); } - Type::Vec(_, idx, body, _) => { - collect_generic_kind_occurrences(idx, acc); + Type::Vec(_, _, body, _) => { + if let Some(length) = typ.vec_length() { + collect_generic_kind_occurrences(&length, acc); + } collect_generic_kind_occurrences(body, acc); } Type::Record(fields, _) | Type::Interface(fields, _) => fields @@ -190,8 +194,10 @@ fn collect_interface_leaf_types(context: &Context, typ: &Type, acc: &mut Vec { - collect_interface_leaf_types(context, idx, acc); + Type::Vec(_, _, body, _) => { + if let Some(length) = typ.vec_length() { + collect_interface_leaf_types(context, &length, acc); + } collect_interface_leaf_types(context, body, acc); } Type::Record(fields, _) => fields @@ -234,6 +240,63 @@ fn is_interface_return_only(params: &[ArgumentType], ret_ty: &Type, context: &Co !anchors.contains(&reduced_ret) } +/// The rigid standing for `id` in the body, created (with `bound` as its +/// interface constraint) the first time the id is met. +fn rigid_for_id( + ctx: &mut Context, + rigid_of_id: &mut std::collections::HashMap, + id: &str, + bound: Type, +) -> String { + if let Some(name) = rigid_of_id.get(id) { + return name.clone(); + } + let (name, new_ctx) = ctx.clone().fresh_rigid_name(); + *ctx = new_ctx.add_interface_constraint(name.clone(), reduce_type(ctx, &bound)); + rigid_of_id.insert(id.to_string(), name.clone()); + name +} + +/// Every `Bounded(id, bound)` in `typ` becomes the rigid of `id`. +fn replace_bounded_with_rigids( + ctx: &mut Context, + rigid_of_id: &mut std::collections::HashMap, + typ: &Type, + h: &HelpData, +) -> Type { + signature_normalization::rewrite(typ, &mut |t| match t { + Type::Bounded(id, bound, _) => { + let name = rigid_for_id(ctx, rigid_of_id, id, (**bound).clone()); + Some(Type::Generic(name, h.clone())) + } + _ => None, + }) +} + +/// Rewrites the rigids of `typ` back to what the user wrote (`Lovable@B`, or +/// plain `Lovable` for a bare interface) so an error never shows `__RIGID_1`. +fn name_rigids(typ: &Type, rigid_of_id: &std::collections::HashMap, params: &[ArgumentType]) -> Type { + let mut bounds: Vec<(String, Type)> = Vec::new(); + for p in params { + signature_normalization::visit(&p.get_type(), &mut |t| { + if let Type::Bounded(id, bound, _) = t { + bounds.push((id.clone(), (**bound).clone())); + } + }); + } + signature_normalization::rewrite(typ, &mut |t| { + let Type::Generic(name, h) = t else { return None }; + let (id, _) = rigid_of_id.iter().find(|(_, rigid)| *rigid == name)?; + let (_, bound) = bounds.iter().find(|(b, _)| b == id)?; + let bare = matches!(bound, Type::Alias(alias, ..) if alias == id); + Some(if bare { + bound.clone() + } else { + Type::Bounded(id.clone(), Box::new(bound.clone()), h.clone()) + }) + }) +} + /// Check if a type is a constrained rigid generic that satisfies the declared return type. /// This handles the case where the body returns a rigid variable `A` and the declared return /// is the interface `I` that constrains `A`. @@ -242,6 +305,13 @@ fn is_rigid_compatible(body_type: &Type, declared_ret: &Type, context: &Context) Type::Generic(name, _) => name, _ => return false, }; + // A declared rigid (`Lovable@B`) is matched by that very rigid only; the + // bound-based rule below would let any `Lovable` rigid through. + if let Type::Generic(declared, _) = declared_ret { + if is_rigid_name(declared) { + return declared == name; + } + } let Some(interface) = context.get_interface_constraint(name) else { return false; }; @@ -297,6 +367,11 @@ pub fn function( .with_errors(vec![TypRError::Type(TypeError::InterfaceReturnOnly(ret_ty.clone()))]); } + // RFC interface_generic_unification §7.3 — `I@Id` well-formedness. Only the + // errors are consumed for now; the desugared signature is used from phase 3. + let normalized = signature_normalization::normalize_signature(context, params, ret_ty); + let bound_errors = normalized.errors; + // RFC sigils.md §7 — intra-signature kind-consistency pass. let kind_consistency_errors = check_kind_consistency(params, ret_ty); let default_param_errors = check_default_params(params, context); @@ -319,23 +394,56 @@ pub fn function( // as the constraint, so `facets::record_facet` can still recover the // record side of a mixed intersection through the rigid generic. let mut sub_context = context.clone(); - for arg_typ in params { - let param_type = arg_typ.body_type(); + // D4: one rigid per `I@Id` identifier (a bare `I` being `I@I`), so two + // parameters sharing an id share their rigid, and distinct ids stay distinct. + let mut rigid_of_id: std::collections::HashMap = std::collections::HashMap::new(); + for (arg_typ, normalized_param) in params.iter().zip(normalized.params.iter()) { + // `Lovable@A` is typed through its bound; the id only decides sharing. + let param_type = match arg_typ.body_type() { + Type::Bounded(_, bound, _) => *bound, + other => other, + }; let reduced = reduce_type(&sub_context, ¶m_type); if facets::interface_facet(&sub_context, &reduced).is_some() { - let (rigid_name, new_ctx) = sub_context.clone().fresh_rigid_name(); - let rigid_type = Type::Generic(rigid_name.clone(), h.clone()); - sub_context = new_ctx.add_interface_constraint(rigid_name, reduced).push_var_type( + let id = match normalized_param.get_type() { + Type::Bounded(id, _, _) if !arg_typ.is_variadic() => Some(id), + _ => None, + }; + let rigid_name = match id { + Some(id) => rigid_for_id(&mut sub_context, &mut rigid_of_id, &id, reduced), + None => { + let (name, new_ctx) = sub_context.clone().fresh_rigid_name(); + sub_context = new_ctx.add_interface_constraint(name.clone(), reduced); + name + } + }; + let rigid_type = Type::Generic(rigid_name, h.clone()); + sub_context = sub_context.clone().push_var_type( Var::from_name(&arg_typ.get_argument_str()).set_type(rigid_type.clone()), rigid_type, &sub_context, ); + } else if !arg_typ.is_variadic() && signature_normalization::has_bounded(&normalized_param.get_type()) { + // `[#N, Lovable@A]`, `tuple{Lovable@A, T}`: the bound sits below the + // top, so the parameter keeps its shape with a rigid at each `@Id`. + let nested = + replace_bounded_with_rigids(&mut sub_context, &mut rigid_of_id, &normalized_param.get_type(), h); + let var = arg_typ.clone().set_type(nested.clone()).to_var(&sub_context); + sub_context = sub_context.clone().push_var_type(var, nested, &sub_context); } else { let var = arg_typ.clone().set_type(param_type.clone()).to_var(&sub_context); sub_context = sub_context.clone().push_var_type(var, param_type.clone(), &sub_context); } } + // A return type `Lovable@B` (or `tuple{Lovable@B, …}`) is held to the rigid + // of `B`, not to any rigid whose bound is `Lovable`. + let checked_ret = if signature_normalization::has_bounded(&normalized.ret) { + replace_bounded_with_rigids(&mut sub_context, &mut rigid_of_id, &normalized.ret, h) + } else { + ret_ty.clone() + }; + // `Self:{ ... }` (generic_constructor.md §4.1): bind `Self` to whatever // type the first parameter ended up with in `sub_context` (the declared // type, or a fresh rigid generic for an interface param), so it's only @@ -349,15 +457,21 @@ pub fn function( // C1 (audit_type_checking.md): thread the declared return type through so // every early `return` inside the body — not just the trailing expression // — gets checked against it (see `Lang::Return` in `type_checking/mod.rs`). - sub_context = sub_context.set_expected_return_type(Some(ret_ty.clone())); + sub_context = sub_context + .set_expected_return_type(Some(checked_ret.clone())) + .set_return_position(true); let body_type = body.typing(&sub_context); let mut errors = body_type.errors; + errors.extend(bound_errors); errors.extend(kind_consistency_errors); errors.extend(default_param_errors); errors.extend(undefined_alias_errors); - let is_compatible = is_compatible_return_type(&body_type.value, ret_ty, &sub_context); - (!is_compatible).then(|| errors.push(builder::unmatching_return_type(ret_ty, &body_type.value))); + let is_compatible = is_compatible_return_type(&body_type.value, &checked_ret, &sub_context); + (!is_compatible).then(|| { + let found = name_rigids(&body_type.value, &rigid_of_id, &normalized.params); + errors.push(builder::unmatching_return_type(ret_ty, &found)) + }); // Structural types registered on the fly while typing the body (e.g. the // `ArrayN` alias created by an inline `expr as! [T]` cast) must survive // into the outer context: the transpiler resolves them there to emit the @@ -1119,4 +1233,60 @@ mod tests { tc2.get_errors() ); } + + // ===================================================================== + // `I@Id` in the body (interface_generic_unification, phase 3) + // ===================================================================== + + /// Type-checks the interface declarations followed by `src` (one source, as + /// the CLI does); true when it is error-free. + fn body_is_ok(src: &str) -> bool { + use crate::components::context::Context; + use crate::processes::parsing::parse2; + use crate::processes::type_checking::type_checker::TypeChecker; + + let program = format!( + "type Lovable <- interface {{ love: (Self) -> char }};\n\ + type Same <- interface {{ same: (Self, Self) -> bool }};\n\ + type Cat <- list {{ name: char }};\n\ + let love <- fn(c: Cat): char {{ \"meow\" }};\n\ + let same <- fn(a: Cat, b: Cat): bool {{ true }};\n\ + let f <- {src};" + ); + let tc = TypeChecker::new(Context::default()) + .typing_no_panic(&crate::processes::parsing::parse_from_string(&program, "t.ty")); + !tc.has_errors() + } + + #[test] + fn test_bound_return_follows_its_own_id() { + assert!(body_is_ok("fn(a: Lovable@A, b: Lovable@B): Lovable@B { b }")); + assert!(body_is_ok("fn(a: Lovable@A, b: Lovable@B): Lovable@A { a }")); + } + + #[test] + fn test_bound_return_rejects_another_id() { + assert!(!body_is_ok("fn(a: Lovable@A, b: Lovable@B): Lovable@A { b }")); + assert!(!body_is_ok("fn(a: Lovable@A, b: Lovable@B): Lovable@B { a }")); + } + + #[test] + fn test_bound_methods_are_callable_on_distinct_ids() { + assert!(body_is_ok( + "fn(a: Lovable@A, b: Lovable@B): bool { a.love() == b.love() }" + )); + } + + #[test] + fn test_self_self_method_needs_a_shared_id() { + assert!(!body_is_ok("fn(a: Same@A, b: Same@B): bool { a.same(b) }")); + assert!(body_is_ok("fn(a: Same@A, b: Same@A): bool { a.same(b) }")); + // D3-A: two bare occurrences of an interface share one variable. + assert!(body_is_ok("fn(a: Same, b: Same): bool { a.same(b) }")); + } + + #[test] + fn test_bare_interface_return_is_tied_to_the_shared_variable() { + assert!(body_is_ok("fn(a: Lovable, b: Lovable): Lovable { b }")); + } } diff --git a/crates/typr-core/src/processes/type_checking/function_application.rs b/crates/typr-core/src/processes/type_checking/function_application.rs index 2e87e141..58a50106 100644 --- a/crates/typr-core/src/processes/type_checking/function_application.rs +++ b/crates/typr-core/src/processes/type_checking/function_application.rs @@ -4,10 +4,16 @@ use crate::components::error_message::typr_error::TypRError; use crate::components::language::set_related_type_if_variable; use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::function_type::FunctionType; +use crate::components::r#type::refinement::RefinementSet; use crate::components::r#type::type_system::TypeSystem; use crate::processes::type_checking::facets; use crate::processes::type_checking::interface_satisfaction; use crate::processes::type_checking::match_types_to_generic; +use crate::processes::type_checking::refinement_check::{ + coerce_to, field_obligations, residual, with_obligations, Coercion, +}; +use crate::processes::type_checking::signature_normalization::{self, CallInstance}; +use crate::processes::type_checking::type_arithmetic::declared_refinements; use crate::processes::type_checking::type_comparison::reduce_type; use crate::processes::type_checking::typing; use crate::processes::type_checking::Context; @@ -365,9 +371,11 @@ fn collect_named_generics(concrete: &Type, param: &Type, subs: &mut std::collect Type::IndexGen(name, _) => { subs.entry(name.clone()).or_insert_with(|| concrete.clone()); } - Type::Vec(_, size_param, elem_param, _) => { - if let Type::Vec(_, size_concrete, elem_concrete, _) = concrete { - collect_named_generics(size_concrete, size_param, subs); + Type::Vec(_, _, elem_param, _) => { + if let Type::Vec(_, _, elem_concrete, _) = concrete { + if let (Some(size_concrete), Some(size_param)) = (concrete.vec_length(), param.vec_length()) { + collect_named_generics(&size_concrete, &size_param, subs); + } collect_named_generics(elem_concrete, elem_param, subs); } } @@ -463,10 +471,10 @@ fn apply_named_generics(ty: &Type, subs: &std::collections::HashMap subs.get(name).cloned().unwrap_or_else(|| ty.clone()), - Type::Vec(vt, size, elem, h) => Type::Vec( + Type::Vec(vt, _, elem, h) => Type::vec( vt.clone(), - Box::new(apply_named_generics(size, subs)), - Box::new(apply_named_generics(elem, subs)), + apply_named_generics(&ty.vec_length().unwrap_or_else(builder::any_type), subs), + apply_named_generics(elem, subs), h.clone(), ), Type::Function(params, ret, h) => Type::Function( @@ -512,8 +520,10 @@ fn collect_interface_bindings(concrete: &Type, param: &Type, context: &Context, return; } match (concrete, param) { - (Type::Vec(_, size_c, elem_c, _), Type::Vec(_, size_p, elem_p, _)) => { - collect_interface_bindings(size_c, size_p, context, mapping); + (Type::Vec(_, _, elem_c, _), Type::Vec(_, _, elem_p, _)) => { + if let (Some(size_c), Some(size_p)) = (concrete.vec_length(), param.vec_length()) { + collect_interface_bindings(&size_c, &size_p, context, mapping); + } collect_interface_bindings(elem_c, elem_p, context, mapping); } (Type::Function(params_c, ret_c, _), Type::Function(params_p, ret_p, _)) => { @@ -531,10 +541,10 @@ fn substitute_interface_types(ty: &Type, mapping: &[(Type, Type)]) -> Type { return concrete.clone(); } match ty { - Type::Vec(vt, size, elem, h) => Type::Vec( + Type::Vec(vt, _, elem, h) => Type::vec( vt.clone(), - Box::new(substitute_interface_types(size, mapping)), - Box::new(substitute_interface_types(elem, mapping)), + substitute_interface_types(&ty.vec_length().unwrap_or_else(builder::any_type), mapping), + substitute_interface_types(elem, mapping), h.clone(), ), Type::Function(params, ret, h) => Type::Function( @@ -1099,6 +1109,53 @@ fn specialize_lambda(lambda_lang: &Lang, lambda_type: &Type, expected_type: &Typ (lambda_lang.clone(), lambda_type.clone()) } +/// Literal `n` of an index type `Integer(Val(n))`, if it is one. +fn literal_index(t: &Type) -> Option { + match t { + Type::Integer(crate::components::r#type::tint::Tint::Val(n), _) => Some(*n as i64), + _ => None, + } +} + +/// Declared effects of a few base-R primitives on a vector's length +/// (refined_types_plan.md, phase 7). These functions live in `base.ty`, which +/// is doc-only, so the compiler sees them as untyped (`Any`); the effect is +/// hard-coded here and applied only to a result that is otherwise `Any`. +/// Every other function keeps the conservative default: its declared return +/// type, with no refinement. +/// +/// - `length(x)`, `x : [N, T]` -> the literal `N` +/// - `rev(x)` -> `x`'s own type (same length, same elements) +/// - `head(x, k)` / `tail(x, k)`, literal `k`, `x : [N, T]` +/// -> `[min(N, k), T]` (`[max(N + k, 0), T]` for `k < 0`) +fn known_effect_call(var: &Var, context: &Context, parameters: &[Lang]) -> Option { + let name = var.get_name(); + let arity = match name.as_str() { + "length" | "rev" => 1, + "head" | "tail" => 2, + _ => return None, + }; + if parameters.len() != arity { + return None; + } + let arg = reduce_type(context, &typing(context, ¶meters[0]).value); + let Type::Vec(vt, _, elem, h) = &arg else { + return None; + }; + let n = arg.vec_length()?; + match name.as_str() { + "length" => literal_index(&n).map(|_| n.clone()), + "rev" => Some(arg.clone()), + _ => { + let len = literal_index(&n)?; + let k = literal_index(&reduce_type(context, &typing(context, ¶meters[1]).value))?; + let kept = if k >= 0 { len.min(k) } else { (len + k).max(0) }; + let index = Type::Integer(crate::components::r#type::tint::Tint::Val(kept as _), h.clone()); + Some(Type::vec(vt.clone(), index, (**elem).clone(), h.clone())) + } + } +} + pub fn apply_from_variable(var: Var, context: &Context, parameters: &[Lang], h: &HelpData) -> TypeContext { thread_local! { static DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; @@ -1113,8 +1170,13 @@ pub fn apply_from_variable(var: Var, context: &Context, parameters: &[Lang], h: return TypeContext::new(builder::any_type(), Lang::Empty(h.clone()), context.clone()) .with_errors(vec![TypRError::Type(TypeError::FunctionNotFound(var.clone()))]); } - let result = apply_from_variable_inner(var, context, parameters, h); + let mut result = apply_from_variable_inner(var.clone(), context, parameters, h); DEPTH.with(|d| d.set(d.get() - 1)); + if matches!(result.value, Type::Any(_)) { + if let Some(len) = known_effect_call(&var, context, parameters) { + result.value = len; + } + } result } @@ -1237,7 +1299,30 @@ fn apply_from_variable_inner(var: Var, context: &Context, parameters: &[Lang], h let (expanded_parameters, types, param_errors, arg_context) = get_expanded_parameters_with_their_types(context, parameters); let context = &arg_context; - let all_signatures = var.get_functions_from_name(context); + let declared_signatures = var.get_functions_from_name(context); + // `I@Id` variables (and repeated bare interfaces) are bound against the + // arguments up front; the regular filters then see concrete signatures. + let mut id_clashes: Vec = Vec::new(); + let all_signatures: Vec = declared_signatures + .iter() + .filter_map(|sig| { + match signature_normalization::instantiate_at_call( + context, + &sig.get_param_types(), + &sig.get_return_type(), + sig.is_variadic(), + &types, + ) { + CallInstance::NotBounded => Some(sig.clone()), + CallInstance::Rejected => None, + CallInstance::Clash(clash) => { + id_clashes.push(clash); + None + } + CallInstance::Instantiated(params, ret) => Some(sig.clone().set_params(params).set_return_type(ret)), + } + }) + .collect(); let filters: &[FilterStep] = &[ FilterStep { @@ -1282,22 +1367,49 @@ fn apply_from_variable_inner(var: Var, context: &Context, parameters: &[Lang], h }, ]; - for step in filters { - if let Some(fun_typ) = (step.filter)(&all_signatures, &types, &var, context) { - let (final_params, final_types) = specialize_lambdas(context, &expanded_parameters, &types, &fun_typ); - let final_fun_typ = match step.refine { - Some(refine) => refine(&all_signatures, &final_types, context).unwrap_or(fun_typ), - None => fun_typ, - }; - return build_success( - &var, - &final_fun_typ, - final_params, - &final_types, - param_errors, - context, - h, - ); + // Second attempt (refined types, plan Phase 5): when no signature accepts + // the arguments as typed, retry with each argument that only fails to + // *prove* a refinement (`[int]` given for `[3, int]`) standing in as the + // parameter type. The check that could not be proven statically becomes a + // runtime obligation on that argument, recorded in the context. + for attempt in 0..2 { + let (try_types, obligations): (Vec, Vec<(HelpData, RefinementSet)>) = if attempt == 0 { + (types.clone(), Vec::new()) + } else { + match relax_refined_arguments(&all_signatures, &types, &expanded_parameters, context) { + Some(relaxed) => relaxed, + None => break, + } + }; + let context = &obligations + .into_iter() + .fold(context.clone(), |ctx, (h, set)| ctx.add_refinement_obligation(&h, set)); + for step in filters { + if let Some(fun_typ) = (step.filter)(&all_signatures, &try_types, &var, context) { + let (final_params, final_types) = + specialize_lambdas(context, &expanded_parameters, &try_types, &fun_typ); + let final_fun_typ = match step.refine { + Some(refine) => refine(&all_signatures, &final_types, context).unwrap_or(fun_typ), + None => fun_typ, + }; + // A generic base (`[#N, T] & length(> 0)`) unifies whatever the + // refinements say: decide them here, against the arguments. + let Some(generic_obligations) = + generic_refinement_obligations(&final_fun_typ, &final_types, &expanded_parameters, context) + else { + continue; + }; + let context = &with_obligations(context.clone(), generic_obligations); + return build_success( + &var, + &final_fun_typ, + final_params, + &final_types, + param_errors, + context, + h, + ); + } } } @@ -1307,7 +1419,7 @@ fn apply_from_variable_inner(var: Var, context: &Context, parameters: &[Lang], h // empty) may mean it's declared in an in-scope module but never `use`d — // same idea as `VariableNotImported` for a bare variable reference, just // reached through function-call resolution instead of `Lang::Variable`. - let not_imported = all_signatures + let not_imported = declared_signatures .is_empty() .then(|| context.find_variable_source_module(&var.get_name())) .flatten(); @@ -1320,13 +1432,38 @@ fn apply_from_variable_inner(var: Var, context: &Context, parameters: &[Lang], h h.clone(), ))); } - None if !all_signatures.is_empty() => { + // RFC 0028: a name bound to exactly one signature shaped like an + // untyped R function (n `Any` params, `Any` return, non-variadic — + // `Lang::RFunction`'s typing rule) gets its own arity message instead + // of `NoMatchingSignature`, which would otherwise print the callee's + // own `Any` signature back at the caller as if it were informative. + None if declared_signatures.len() == 1 && declared_signatures[0].is_r_function() => { + errors.push(TypRError::Type(TypeError::UntypedFunctionArity( + var.get_name(), + declared_signatures[0].get_param_types().len(), + types.len(), + h.clone(), + ))); + } + // Every signature was turned down for the same reason: one `@Id` + // bound to two argument types. Say which, instead of listing signatures. + None if !id_clashes.is_empty() && id_clashes.len() == declared_signatures.len() => { + let c = &id_clashes[0]; + errors.push(TypRError::Type(TypeError::IdBoundToTwoTypes( + c.id.clone(), + c.first_type.pretty(), + expanded_parameters[c.first_arg].get_help_data(), + c.second_type.pretty(), + expanded_parameters[c.second_arg].get_help_data(), + ))); + } + None if !declared_signatures.is_empty() => { // The name IS bound to function signature(s) — the call just // doesn't match any of them (wrong arity or argument types). // Reporting `FunctionNotFound` here reads as "the variable // doesn't exist", which is wrong and misleading (e.g. `f()` on // a 1-parameter lambda looked like lambda-lets were unsupported). - let signatures = all_signatures + let signatures = declared_signatures .iter() .map(|sig| { format!( @@ -1356,6 +1493,73 @@ fn apply_from_variable_inner(var: Var, context: &Context, parameters: &[Lang], h TypeContext::new(builder::any_type(), Lang::Empty(h.clone()), context.clone()).with_errors(errors) } +/// The refinements of a matched signature's parameters against the argument +/// types: `None` when one provably fails (the signature does not match), else +/// the runtime checks still owed, one per argument. Parameters whose base is +/// concrete were already decided by the subtype test; what this adds is the +/// generic ones, which unification accepts without looking at refinements. +fn generic_refinement_obligations( + sig: &FunctionType, + types: &[Type], + args: &[Lang], + context: &Context, +) -> Option> { + let mut out = Vec::new(); + for (i, (arg, param)) in types.iter().zip(sig.get_param_types().iter()).enumerate() { + // not reduced: the signature is already substituted (`N ↦ 0`), and reducing + // `[0, char] & length(> 0)` would collapse it to an error + let Some((base, want)) = declared_refinements(param) else { + continue; + }; + let have = arg.reduce(context).refinements_of(); + if have.implies(&want) { + continue; + } + let integral = matches!(base, Type::Vec(..)); + if have.contradicts(&want, integral) { + return None; + } + out.push((args.get(i)?.get_help_data(), residual(&have, &want))); + } + Some(out) +} + +/// For the first signature of the right arity that every argument can flow +/// into (`coerce_to` is never `Reject`) with at least one argument that needs +/// a runtime check: the argument types with those arguments replaced by the +/// parameter type, and the residual to check at each replaced position. +fn relax_refined_arguments( + signatures: &[FunctionType], + types: &[Type], + args: &[Lang], + context: &Context, +) -> Option<(Vec, Vec<(HelpData, RefinementSet)>)> { + signatures.iter().find_map(|sig| { + let params = sig.get_param_types(); + if params.len() != types.len() { + return None; + } + let mut relaxed = Vec::with_capacity(types.len()); + let mut obligations = Vec::new(); + for (i, (arg, param)) in types.iter().zip(params.iter()).enumerate() { + match coerce_to(arg, param, context) { + Coercion::Static => relaxed.push(arg.clone()), + Coercion::Runtime(set) => { + relaxed.push(param.clone()); + obligations.push((args[i].get_help_data(), set)); + } + // a record literal argument: the checks go on its fields + Coercion::Reject => { + let fields = args.get(i).and_then(|a| field_obligations(a, arg, param, context))?; + relaxed.push(param.clone()); + obligations.extend(fields); + } + } + } + (!obligations.is_empty()).then_some((relaxed, obligations)) + }) +} + fn specialize_lambdas( context: &Context, params: &[Lang], diff --git a/crates/typr-core/src/processes/type_checking/let_expression.rs b/crates/typr-core/src/processes/type_checking/let_expression.rs index 371029a5..0a24cd60 100644 --- a/crates/typr-core/src/processes/type_checking/let_expression.rs +++ b/crates/typr-core/src/processes/type_checking/let_expression.rs @@ -1,7 +1,10 @@ use crate::components::error_message::type_error::TypeError; use crate::components::error_message::typr_error::TypRError; use crate::components::r#type::type_operator::TypeOperator; +use crate::components::r#type::type_system::TypeSystem; use crate::processes::type_checking::r#Type; +use crate::processes::type_checking::refinement_check::{coerce_to, field_obligations, with_obligations, Coercion}; +use crate::processes::type_checking::type_arithmetic::refinement_error; use crate::processes::type_checking::vectorizability::is_vectorizable_function; use crate::processes::type_checking::Context; use crate::processes::type_checking::HelpData; @@ -103,8 +106,10 @@ pub fn collect_undefined_aliases(context: &Context, ty: &Type) -> Vec errors.extend(collect_undefined_aliases(context, ret)); errors } - Type::Vec(_, ind, inner, _) => { - let mut errors = collect_undefined_aliases(context, ind); + Type::Vec(_, _, inner, _) => { + let mut errors = ty + .vec_length() + .map_or_else(Vec::new, |length| collect_undefined_aliases(context, &length)); errors.extend(collect_undefined_aliases(context, inner)); errors } @@ -155,6 +160,9 @@ pub fn let_expression( }; let mut alias_errors = collect_undefined_aliases(context, ty); + if let Some(e) = refinement_error(&ty.reduce(context)) { + alias_errors.push(TypRError::type_error(e)); + } // E-EMBED-003 (named type embedding, `embedding.rs`): an explicit function // can only collide with an inherited embedded method by being declared after @@ -176,8 +184,28 @@ pub fn let_expression( } } - let mut res = exp - .typing(&new_context) + // Refined annotation (plan Phase 5): what `is_subtype` cannot prove but + // does not refute (`let x: [3, int] <- some_vector`) is accepted here and + // becomes a runtime obligation on the initialiser, instead of a static + // error. The variable then carries the annotated type from this point on. + let mut typed = exp.typing(&new_context); + if !ty.is_empty() && !typed.has_errors() { + match coerce_to(&typed.value, ty, &typed.context) { + Coercion::Runtime(set) => { + typed.context = typed.context.add_refinement_obligation(&exp.get_help_data(), set); + typed.value = ty.reduce(&typed.context); + } + // a record literal: the check goes on each field that needs one + Coercion::Reject => { + if let Some(obs) = field_obligations(exp, &typed.value, ty, &typed.context) { + typed.context = with_obligations(typed.context.clone(), obs); + typed.value = ty.reduce(&typed.context); + } + } + Coercion::Static => {} + } + } + let mut res = typed .get_covariant_type(ty) .add_to_context(Var::try_from(name).unwrap()); diff --git a/crates/typr-core/src/processes/type_checking/mod.rs b/crates/typr-core/src/processes/type_checking/mod.rs index 251985e1..4bda0cb2 100644 --- a/crates/typr-core/src/processes/type_checking/mod.rs +++ b/crates/typr-core/src/processes/type_checking/mod.rs @@ -11,12 +11,16 @@ pub mod interface_satisfaction; pub mod let_expression; pub mod match_expression; pub mod module_cache; +pub mod narrowing; pub mod partial_application; +pub mod refinement_check; pub mod signature_expression; +pub mod signature_normalization; pub mod type_arithmetic; pub mod type_checker; pub mod type_comparison; pub mod type_context; +pub mod type_recorder; pub mod unification; pub mod unification_map; pub mod vectorizability; @@ -48,6 +52,7 @@ use crate::processes::type_checking::function::is_compatible_return_type; use crate::processes::type_checking::function_application::function_application; use crate::processes::type_checking::let_expression::let_expression; use crate::processes::type_checking::match_expression::match_expression; +use crate::processes::type_checking::refinement_check::{coerce_to, field_obligations, with_obligations, Coercion}; use crate::processes::type_checking::partial_application::partial_application; use crate::processes::type_checking::signature_expression::signature_expression; @@ -354,15 +359,29 @@ pub fn eval(context: &Context, expr: &Lang) -> TypeContext { let reduced_left_type = reduce_type(context, &left_type); let reduced_right_type = reduce_type(context, &right_type); - if reduced_right_type.is_subtype(&reduced_left_type, context).0 { - let Some(var) = Var::from_language((**left_expr).clone()).map(|v| v.set_type(right_type.clone())) + // A reassignment must not pin the variable to the exact literal just + // assigned (`x <- 10;` after unannotated `let x <- 0;` infers `x` as + // the singleton `0`), and must not keep narrowing it one literal at a + // time on every later assignment (`x <- 10;` then `x <- x + 1;`, + // cases/0065-reassign-widen-literal). Widen a literal singleton to its + // base kind before both the compatibility check and the type stored + // back into context — `Type::generalize()`, the same widening + // cases/0017-char-if-widening uses for `if` branches — leaving + // non-literal types (aliases, records, functions, unions...) + // untouched, so occurrence narrowing onto those still works. + let widened_left_type = reduced_left_type.clone().generalize(); + let widened_right_type = right_type.clone().generalize(); + + if reduced_right_type.is_subtype(&widened_left_type, context).0 { + let Some(var) = + Var::from_language((**left_expr).clone()).map(|v| v.set_type(widened_right_type.clone())) else { - return TypeContext::new(right_type, expr.clone(), context.clone()).with_errors(errors); + return TypeContext::new(widened_right_type, expr.clone(), context.clone()).with_errors(errors); }; TypeContext::new( - right_type.clone(), + widened_right_type.clone(), expr.clone(), - context.clone().push_var_type(var, right_type, context), + context.clone().push_var_type(var, widened_right_type, context), ) .with_errors(errors) } else { @@ -722,8 +741,13 @@ fn get_gen_type(type1: &Type, type2: &Type) -> Option> { } Some(res) } - (Type::Vec(_, ind1, typ1, _), Type::Vec(_, ind2, typ2, _)) => { - let gen1 = get_gen_type(ind1, ind2); + // `[#N, T] & length(> 0)`: generics live in the base, the refinements + // are decided at the boundary (`coerce_to`). + (Type::Refined(b1, _, _), Type::Refined(b2, _, _)) => get_gen_type(b1, b2), + (Type::Refined(b1, _, _), t2) => get_gen_type(b1, t2), + (t1, Type::Refined(b2, _, _)) => get_gen_type(t1, b2), + (Type::Vec(_, _, typ1, _), Type::Vec(_, _, typ2, _)) => { + let gen1 = get_gen_type(&type1.vec_length()?, &type2.vec_length()?); let gen2 = get_gen_type(typ1, typ2); match (gen1, gen2) { (None, _) | (_, None) => None, @@ -731,17 +755,32 @@ fn get_gen_type(type1: &Type, type2: &Type) -> Option> { } } (Type::Record(v1, _), Type::Record(v2, _)) => { - let res = v1 - .iter() - .zip(v2.iter()) - .flat_map(|(argt1, argt2)| { - let gen1 = get_gen_type(&argt1.get_argument(), &argt2.get_argument()).unwrap_or(vec![]); - let gen2 = get_gen_type(&argt1.get_type(), &argt2.get_type()).unwrap_or(vec![]); - gen1.iter().chain(gen2.iter()).cloned().collect::>() - }) - .collect::>() - .into_iter() - .collect::>(); + // Fields live in a `HashSet`: pair them by name, never by + // iteration order (a positional `zip` bound `T` to a random + // field's type, so the same program type-checked or not + // depending on the hasher's seed). + let mut res: HashSet<(Type, Type)> = HashSet::new(); + for argt2 in v2.iter() { + match v1 + .iter() + .find(|argt1| argt1.get_argument_str() == argt2.get_argument_str()) + { + Some(argt1) => { + let gen1 = get_gen_type(&argt1.get_argument(), &argt2.get_argument()).unwrap_or(vec![]); + // A shared field whose types cannot be matched makes + // the whole record a mismatch — it used to be + // silently ignored, so any record was accepted for + // any other record parameter. + let gen2 = get_gen_type(&argt1.get_type(), &argt2.get_type())?; + res.extend(gen1); + res.extend(gen2); + } + // The parameter requires a field the argument lacks. + None if matches!(argt2.get_argument(), Type::Char(_, _)) => return None, + None => {} + } + } + let res = res.into_iter().collect::>(); Some(res) } (Type::Tag(name1, inner1, _), Type::Tag(name2, inner2, _)) if name1 == name2 => { @@ -777,6 +816,11 @@ fn get_gen_type(type1: &Type, type2: &Type) -> Option> { _ => None, } } + // A transparent alias can't be judged without its definition: against + // an empty context it reduces to `Any`, which every type is a subtype + // of, so any argument would match any alias parameter. Let the caller + // retry on the reduced types (`match_types_to_generic`). + (_, Type::Alias(_, _, false, _)) => None, (t1, t2) if t1.is_subtype(t2, &Context::empty()).0 => Some(vec![]), _ => None, } @@ -925,25 +969,20 @@ fn try_concat_vectors(types: &[Type], h: &HelpData) -> Option { let mut elem: Option = None; for t in types { match t { - Type::Vec(vt, idx, el, _) if vt.is_vector() || vt.is_array() => { + Type::Vec(vt, _, el, _) if vt.is_vector() || vt.is_array() => { let el_gen = (**el).clone().generalize(); match &elem { None => elem = Some(el_gen), Some(e) if *e == el_gen => {} _ => return None, // heterogeneous element types } - indices.push((**idx).clone()); + indices.extend(t.vec_length()); } _ => return None, } } let elem = elem?; - Some(Type::Vec( - VecType::Vector, - Box::new(vec_index_sum(&indices, h)), - Box::new(elem), - h.clone(), - )) + Some(Type::vec(VecType::Vector, vec_index_sum(&indices, h), elem, h.clone())) } /// Rule 2 (records) — list concatenation: every argument is a record. Result is @@ -982,10 +1021,10 @@ fn try_homogeneous_scalars(types: &[Type], n: usize, h: &HelpData) -> Option Vec[0, Any] - Type::Vec( - VecType::Vector, - Box::new(builder::integer_type(0)), - Box::default(), - h.clone(), - ) + Type::vec(VecType::Vector, builder::integer_type(0), Type::default(), h.clone()) } else if let Some(t) = try_concat_vectors(&types, h) { t } else if let Some(t) = try_concat_records(&types, h) { @@ -1020,10 +1054,10 @@ fn typing_vector(context: &Context, expr: &Lang, exprs: &[Lang], h: &HelpData) - } else { // Rule 4 — no rule applies: incompatible arguments for c(...). errors.push(TypRError::Type(TypeError::WrongExpression(h.clone()))); - Type::Vec( + Type::vec( VecType::Vector, - Box::new(builder::integer_type(exprs.len() as i32)), - Box::default(), + builder::integer_type(exprs.len() as i32), + Type::default(), h.clone(), ) }; @@ -1120,6 +1154,45 @@ pub fn validate_forced_dispatch(context: &Context, var: &Var) -> Option TypeContext { + let at_return = context.is_return_position(); + // `Lines`/`Scope`/`If`/`Return` hand the return position on to the + // expressions they contain; anything else is itself the value returned, + // and its own operands are not in return position. + let delegates = matches!( + expr, + Lang::Lines { .. } | Lang::Scope { .. } | Lang::If { .. } | Lang::Return { .. } + ); + let mut result = if at_return && !delegates { + typing_impl(&context.clone().set_return_position(false), expr) + } else { + typing_impl(context, expr) + }; + if at_return && !delegates && !result.has_errors() { + // Refined return type (plan Phase 5): a value whose refinement is not + // proven but not refuted is accepted, and checked at run time. + if let Some(expected) = context.get_expected_return_type() { + match coerce_to(&result.value, &expected, &result.context) { + Coercion::Runtime(set) => { + result.context = result.context.add_refinement_obligation(&expr.get_help_data(), set); + result.value = expected.reduce(&result.context); + } + Coercion::Reject => { + if let Some(obs) = field_obligations(expr, &result.value, &expected, &result.context) { + result.context = with_obligations(result.context.clone(), obs); + result.value = expected.reduce(&result.context); + } + } + Coercion::Static => {} + } + } + } + // The flag describes where `expr` sits, not what its operands did to it. + result.context = result.context.set_return_position(at_return); + type_recorder::record(expr, &result.value); + result +} + +fn typing_impl(context: &Context, expr: &Lang) -> TypeContext { match expr { Lang::Number { value: n, help_data: h } => ( Type::Number(crate::components::r#type::tnumber::Tnum::Val(*n), h.clone()), @@ -1241,6 +1314,12 @@ pub fn typing(context: &Context, expr: &Lang) -> TypeContext { // (covers e.g. a record alias vs. its structural literal). let reduced1 = reduce_type(context, &tc1.value); let reduced2 = reduce_type(context, &tc2.value); + // R compares element-wise: `v > 2` is a vector of booleans as long + // as `v`, and a vector may be compared to a scalar of its element + // type (refined_types_plan.md, phase 7 — `v[v > 2]`). + if let Some(shape) = vectorized_comparison(context, &reduced1, &reduced2) { + return TypeContext::new(shape, expr.clone(), context.clone()).with_errors(errors); + } let both_numeric = matches!(reduced1, Type::Integer(_, _) | Type::Number(_, _)) && matches!(reduced2, Type::Integer(_, _) | Type::Number(_, _)); let comparable = reduced1 == reduced2 @@ -1330,12 +1409,13 @@ pub fn typing(context: &Context, expr: &Lang) -> TypeContext { // error it raised (found via audit_type_checking.md Phase 6 // diagnostic-wording snapshots: `.Blue`-variant match errors // came out twice for a two-statement program). - let new_context = exprs2.iter().fold(context2, |ctx, expr| { + let in_return_position = context2.is_return_position(); + let new_context = exprs2.iter().fold(context2.set_return_position(false), |ctx, expr| { let tc = typing(&ctx, expr); all_errors.extend(tc.errors); tc.context }); - let final_tc = typing(&new_context, &exp); + let final_tc = typing(&new_context.set_return_position(in_return_position), &exp); all_errors.extend(final_tc.errors); TypeContext::new(final_tc.value, final_tc.lang, final_tc.context).with_errors(all_errors) } @@ -1364,12 +1444,12 @@ pub fn typing(context: &Context, expr: &Lang) -> TypeContext { else_block: false_branch, help_data: _h, } => { - let cond_tc = typing(context, cond); + let cond_tc = typing(&context.clone().set_return_position(false), cond); let mut errors = cond_tc.errors; if reduce_type(context, &cond_tc.value).is_boolean() { - let true_tc = typing(context, true_branch); - let false_tc = typing(context, false_branch); + let true_tc = typing(&narrowing::narrow(context, cond, true), true_branch); + let false_tc = typing(&narrowing::narrow(context, cond, false), false_branch); errors.extend(true_tc.errors); errors.extend(false_tc.errors); @@ -1392,7 +1472,11 @@ pub fn typing(context: &Context, expr: &Lang) -> TypeContext { } else { builder::union_type(&[true_tc.value, false_tc.value]) }; - TypeContext::new(result_type, expr.clone(), context.clone()).with_errors(errors) + let context = context + .clone() + .absorb_obligations(&true_tc.context) + .absorb_obligations(&false_tc.context); + TypeContext::new(result_type, expr.clone(), context).with_errors(errors) } else { errors.push(TypRError::Type(TypeError::WrongExpression(cond.get_help_data()))); TypeContext::new(builder::any_type(), expr.clone(), context.clone()).with_errors(errors) @@ -1513,11 +1597,12 @@ pub fn typing(context: &Context, expr: &Lang) -> TypeContext { for (arg_val, tc) in fields.iter().zip(type_contexts.iter()) { let name = arg_val.get_argument(); match &tc.value { - Type::Vec(_, idx, _, _) => { + col @ Type::Vec(..) => { + let idx = col.vec_length().unwrap_or_else(builder::any_type); if fields.first().map(|f| f.get_argument()) == Some(name.clone()) { - index = (**idx).clone(); + index = idx.clone(); } - if let Type::Integer(Tint::Val(len), _) = idx.as_ref() { + if let Type::Integer(Tint::Val(len), _) = &idx { match &known_length { None => known_length = Some((name.clone(), *len)), Some((first_name, first_len)) if first_len != len => { @@ -1544,10 +1629,10 @@ pub fn typing(context: &Context, expr: &Lang) -> TypeContext { } TypeContext::new( - Type::Vec( + Type::vec( VecType::DataFrame, - Box::new(index), - Box::new(Type::Record(field_types, h.clone())), + index, + Type::Record(field_types, h.clone()), h.clone(), ), expr.clone(), @@ -1567,7 +1652,16 @@ pub fn typing(context: &Context, expr: &Lang) -> TypeContext { } => { let tc = typing(context, arr_exp); let mut errors = tc.errors; - let typ1 = tc.value; + // `[T] & length(> 0)` is indexed like `[T]`: a refinement only + // constrains the value, it does not change its shape. + let typ1 = match reduce_type(context, &tc.value) { + refined @ Type::Refined(..) => refined.unrefined().clone(), + // Same for a folded `length(> 0)`: a property, not a shape. + ranged @ Type::Vec(_, _, _, _) if matches!(&ranged, Type::Vec(_, l, _, _) if l.as_proper_range().is_some()) => { + ranged.without_length_range() + } + _ => tc.value, + }; // Scalar indexing on a Tuple: list(a, b, c)[2] → element type at position 2 if let Type::Tuple(types, _) = &typ1 { @@ -1620,6 +1714,37 @@ pub fn typing(context: &Context, expr: &Lang) -> TypeContext { return TypeContext::new(element_type, expr.clone(), context.clone()).with_errors(errors); } + // `v[1:3]` / `v[mask]` on a one-dimensional vector selects a + // sub-vector, not an element (refined_types_plan.md, phase 7). The + // result keeps the element type; its length is known only when the + // index is a literal range (`a:b`) — `mask` selections give `[int]`. + if let (Type::Vec(_, _, elem, _), Some([member])) = (&typ1, index.get_members_if_array().as_deref()) { + let dynamic = !matches!(member, Lang::Integer { .. } | Lang::Array { .. } | Lang::Vector { .. }); + if dynamic && !matches!(**elem, Type::Vec(..)) { + let idx_tc = typing(context, member); + let idx_type = reduce_type(context, &idx_tc.value); + if let (Type::Vec(_, _, ie, _), Some(n)) = (&idx_type, idx_type.vec_length()) { + let len = match reduce_type(context, ie) { + Type::Integer(..) => { + Some(literal_range_len(member).map_or_else(|| n.clone(), builder::integer_type)) + } + Type::Boolean(..) => Some(builder::any_type()), + _ => None, + }; + if let Some(len) = len { + let len = if matches!(len, Type::Integer(Tint::Val(_), _) | Type::Any(_)) { + len + } else { + builder::any_type() + }; + errors.extend(idx_tc.errors); + let typ2 = typ1.with_vec_length(len); + return TypeContext::new(typ2, expr.clone(), context.clone()).with_errors(errors); + } + } + } + } + let args_target = typ1.clone().linearize(); match index.get_members_if_array() { @@ -1741,8 +1866,22 @@ pub fn typing(context: &Context, expr: &Lang) -> TypeContext { } Lang::VecBlock { help_data: h, .. } => TypeContext::new(Type::Empty(h.clone()), expr.clone(), context.clone()), Lang::RBlock { help_data: h, .. } => TypeContext::new(Type::Empty(h.clone()), expr.clone(), context.clone()), - Lang::RFunction { help_data: h, .. } => { - TypeContext::new(Type::UnknownFunction(h.clone()), expr.clone(), context.clone()) + Lang::RFunction { + parameters, + help_data: h, + .. + } => { + // RFC 0028: an untyped R function is callable, checked on arity + // only. `parameters.len()` is the literal count of parsed bare + // names — `Lang::RFunction` has no default values and no `...` + // today, so there is nothing else to special-case here. + let params: Vec = parameters + .iter() + .enumerate() + .map(|(i, _)| ArgumentType::new(&format!("_{}", i), &builder::any_type())) + .collect(); + let func_type = Type::Function(params, Box::new(builder::any_type()), h.clone()); + TypeContext::new(func_type, expr.clone(), context.clone()) } Lang::ExternBlock { parameters: params, @@ -1873,7 +2012,7 @@ pub fn typing(context: &Context, expr: &Lang) -> TypeContext { Lang::Signature { .. } => eval(context, expr).with_lang(expr), Lang::TypeConstructor { .. } => eval(context, expr).with_lang(expr), Lang::Return { value: exp, .. } => { - let tc = typing(context, exp); + let tc = typing(&context.clone().set_return_position(true), exp); // C1 (audit_type_checking.md): an early `return` used to be typed // but never unified with the enclosing function's declared return // type — only the trailing expression was checked in `function()`. @@ -2739,10 +2878,10 @@ mod tests { fields.insert(ArgumentType::new("Pulse", &builder::integer_type_default())); let record_type = Type::Record(fields, HelpData::default()); - let df_type = Type::Vec( + let df_type = Type::vec( VecType::DataFrame, - Box::new(builder::integer_type(3)), - Box::new(record_type.clone()), + builder::integer_type(3), + record_type.clone(), HelpData::default(), ); @@ -2764,10 +2903,10 @@ mod tests { fields.insert(ArgumentType::new("Pulse", &builder::integer_type_default())); let record_type = Type::Record(fields, HelpData::default()); - let df_type = Type::Vec( + let df_type = Type::vec( VecType::DataFrame, - Box::new(builder::integer_type(3)), - Box::new(record_type.clone()), + builder::integer_type(3), + record_type.clone(), HelpData::default(), ); @@ -4742,7 +4881,21 @@ p"#; // paired with a pushed `TypeError` (AliasNotFound / // TagFieldConstructorNotSupported / UnknownUnionVariant) — never a // silent fallback, always an error-carrying result type. - const BASELINE: usize = 19; + // + // 19 -> 21 (RFC 0028): `Lang::RFunction` is now typed as a function + // of `n` `Any` parameters returning `Any`, instead of the old + // `Type::UnknownFunction` placeholder — this is the RFC's whole + // point (an untyped R function becomes callable, unchecked), not a + // silent degradation: the call site still gets a real `Type::Function` + // with the right arity, and the `Any` boundary is the documented, + // intentional cost of the escape hatch (see rfcs/0028). + // + // 21 -> 23 (refined_types_plan.md, phase 7): vector indexing/comparison + // results (`v[mask]`, `v[a:f(x)]`, `u > w` with lengths that differ) + // carry `Any` in the *length slot* of `Type::Vec` — that is TypR's + // "length unknown" marker (`[int]` is `Vec(_, Any, int)`), not a + // degraded element type. + const BASELINE: usize = 23; let count = production_source().matches("any_type()").count(); assert!( count <= BASELINE, @@ -4796,3 +4949,49 @@ p"#; ); } } + +/// Length of a literal range `a:b` / `a:s:b` (parsed as `seq(a, b, step)`), `None` for anything else. +fn literal_range_len(member: &Lang) -> Option { + let Lang::FunctionApp { + identifier, arguments, .. + } = member + else { + return None; + }; + match (&**identifier, arguments.as_slice()) { + ( + Lang::Variable { name, .. }, + [Lang::Integer { value: a, .. }, Lang::Integer { value: b, .. }, Lang::Integer { value: step, .. }], + ) if name == "seq" && *step != 0 && (b - a) / step >= 0 => Some((b - a) / step + 1), + _ => None, + } +} + +/// Result type of comparing `t1` with `t2` when at least one side is a +/// one-dimensional vector and the element types are comparable: a vector of +/// booleans whose length is that of the vector side(s). `None` otherwise. +fn vectorized_comparison(context: &Context, t1: &Type, t2: &Type) -> Option { + let split = |t: &Type| match t { + Type::Vec(vt, _, e, h) if !matches!(**e, Type::Vec(..)) => { + Some((vt.clone(), t.vec_length()?, (**e).clone(), h.clone())) + } + _ => None, + }; + let (s1, s2) = (split(t1), split(t2)); + let (e1, e2) = ( + s1.as_ref().map_or_else(|| t1.clone(), |s| reduce_type(context, &s.2)), + s2.as_ref().map_or_else(|| t2.clone(), |s| reduce_type(context, &s.2)), + ); + let numeric = |t: &Type| matches!(t, Type::Integer(..) | Type::Number(..)); + if s1.is_none() && s2.is_none() + || !(e1 == e2 || numeric(&e1) && numeric(&e2) || e1.is_subtype(&e2, context).0 || e2.is_subtype(&e1, context).0) + { + return None; + } + let (vt, n, _, h) = match (s1, s2) { + (Some((vt, n1, _, h)), Some((_, n2, _, _))) => (vt, if n1 == n2 { n1 } else { builder::any_type() }, (), h), + (Some((vt, n, _, h)), None) | (None, Some((vt, n, _, h))) => (vt, n, (), h), + (None, None) => return None, + }; + Some(Type::vec(vt, n, builder::boolean_type(), h)) +} diff --git a/crates/typr-core/src/processes/type_checking/narrowing.rs b/crates/typr-core/src/processes/type_checking/narrowing.rs new file mode 100644 index 00000000..395a3668 --- /dev/null +++ b/crates/typr-core/src/processes/type_checking/narrowing.rs @@ -0,0 +1,222 @@ +//! Condition narrowing (`refined_types_plan.md`, Phase 9, §24). +//! +//! Inside `if (length(x) == 5) { .. }` the checker knows more about `x` than +//! its declared type says: the branch is entered only when the condition +//! holds, so `x` can be treated as `[5, int]` there without any runtime +//! check (the condition *is* the check). A condition is read as a list of +//! facts `(variable, measure, interval)` and each fact is intersected with +//! what the variable already is, through the same `apply_refinements` as an +//! annotation. Nothing is narrowed when the intersection is empty (dead +//! branch) or the base does not support the measure (`x > 0` on a vector). +//! +//! Recognised, on either side of the comparison: +//! * `length(x) n` -> measure `Length` +//! * `x c` -> measure `Value` (scalars only) +//! * `a && b` (then-branch) and `a || b` (else-branch), `!c` +//! +//! The else-branch uses the negated comparison; `==` says nothing when +//! false, and neither does `!=` when true. + +use crate::components::context::Context; +use crate::components::language::operators::Op; +use crate::components::language::var::Var; +use crate::components::language::Lang; +use crate::components::r#type::refinement::{Interval, Measure, RefinementSet}; +use crate::components::r#type::Type; +use crate::processes::type_checking::type_arithmetic::apply_refinements; +use crate::processes::type_checking::type_comparison::reduce_type; + +type Fact = (String, Measure, Interval); + +#[derive(Clone, Copy)] +enum Cmp { + Eq, + NotEq, + Lt, + Le, + Gt, + Ge, +} + +impl Cmp { + fn of(op: &Op) -> Option { + Some(match op { + Op::Eq(_) => Cmp::Eq, + Op::NotEq(_) => Cmp::NotEq, + Op::LesserThan(_) => Cmp::Lt, + Op::LesserOrEqual(_) => Cmp::Le, + Op::GreaterThan(_) => Cmp::Gt, + Op::GreaterOrEqual(_) => Cmp::Ge, + _ => return None, + }) + } + + /// `c x` read as `x c`. + fn flip(self) -> Cmp { + match self { + Cmp::Lt => Cmp::Gt, + Cmp::Le => Cmp::Ge, + Cmp::Gt => Cmp::Lt, + Cmp::Ge => Cmp::Le, + c => c, + } + } + + fn negate(self) -> Cmp { + match self { + Cmp::Eq => Cmp::NotEq, + Cmp::NotEq => Cmp::Eq, + Cmp::Lt => Cmp::Ge, + Cmp::Le => Cmp::Gt, + Cmp::Gt => Cmp::Le, + Cmp::Ge => Cmp::Lt, + } + } + + /// What `x c` tells about `x`, if anything. + fn interval(self, c: f64) -> Option { + match self { + Cmp::Eq => Some(Interval::point(c)), + Cmp::NotEq => None, + Cmp::Lt => Some(Interval::less_than(c)), + Cmp::Le => Some(Interval::at_most(c)), + Cmp::Gt => Some(Interval::greater_than(c)), + Cmp::Ge => Some(Interval::at_least(c)), + } + } +} + +fn literal(l: &Lang) -> Option { + match l { + Lang::Integer { value, .. } => Some(*value as f64), + Lang::Number { value, .. } => Some(*value), + _ => None, + } +} + +/// `x` or `length(x)` with `x` a plain variable. +fn subject(l: &Lang) -> Option<(String, Measure)> { + match l { + Lang::Variable { name, .. } => Some((name.clone(), Measure::Value)), + Lang::FunctionApp { + identifier, arguments, .. + } if arguments.len() == 1 => match (&**identifier, &arguments[0]) { + (Lang::Variable { name: f, .. }, Lang::Variable { name, .. }) if f == "length" => { + Some((name.clone(), Measure::Length)) + } + _ => None, + }, + _ => None, + } +} + +/// Facts that hold when `cond` evaluates to `holds`. +fn facts(cond: &Lang, holds: bool, out: &mut Vec) { + match cond { + // `(c)` parses as a one-expression scope. + Lang::Scope { body, .. } if body.len() == 1 => facts(&body[0], holds, out), + Lang::Not { value, .. } => facts(value, !holds, out), + Lang::Operator { + operator: Op::And(_) | Op::And2(_), + lhs, + rhs, + .. + } if holds => { + facts(lhs, true, out); + facts(rhs, true, out); + } + Lang::Operator { + operator: Op::Or(_) | Op::Or2(_), + lhs, + rhs, + .. + } if !holds => { + facts(lhs, false, out); + facts(rhs, false, out); + } + // `&&` / `||` reach the checker as a call to the operator's function. + Lang::FunctionApp { + identifier, arguments, .. + } if arguments.len() == 2 => { + let Lang::Variable { name, .. } = &**identifier else { + return; + }; + let wanted = match name.trim_matches('`') { + "&&" | "&" if holds => true, + "||" | "|" if !holds => false, + _ => return, + }; + facts(&arguments[0], wanted, out); + facts(&arguments[1], wanted, out); + } + // `Lang::Operator` stores the *left* operand in `rhs`. + Lang::Operator { + operator, + lhs: right, + rhs: left, + .. + } => { + let Some(cmp) = Cmp::of(operator) else { return }; + let (lhs, rhs) = (left, right); + let (subj, c, cmp) = match (subject(lhs), literal(rhs), literal(lhs), subject(rhs)) { + (Some(s), Some(c), _, _) => (s, c, cmp), + (_, _, Some(c), Some(s)) => (s, c, cmp.flip()), + _ => return, + }; + let cmp = if holds { cmp } else { cmp.negate() }; + if let Some(iv) = cmp.interval(c) { + out.push((subj.0, subj.1, iv)); + } + } + _ => {} + } +} + +/// `context` as it is inside the branch entered when `cond` is `holds`. +pub fn narrow(context: &Context, cond: &Lang, holds: bool) -> Context { + let mut found = Vec::new(); + facts(cond, holds, &mut found); + // One narrowing per variable, from the meet of all its facts + // (`length(v) > 1 && length(v) < 3` is a single `length(v) == 2`). + let mut per_variable: Vec<(String, RefinementSet)> = Vec::new(); + for (name, measure, iv) in found { + let fact = RefinementSet::empty().with(measure, iv); + match per_variable.iter_mut().find(|(n, _)| *n == name) { + Some((_, set)) => *set = set.meet(&fact), + None => per_variable.push((name, fact)), + } + } + per_variable + .into_iter() + .fold(context.clone(), |ctx, (name, set)| narrow_variable(ctx, &name, set)) +} + +fn narrow_variable(context: Context, name: &str, facts: RefinementSet) -> Context { + // Only a variable with a single visible binding: with overloads there is + // no telling which one the condition talks about. + let mut entries = context.typing_context.entries_named(name); + if entries.len() != 1 { + return context; + } + let (var, declared) = entries.remove(0); + let reduced = reduce_type(&context, &declared); + if !matches!( + reduced.unrefined(), + Type::Vec(..) | Type::Integer(..) | Type::Number(..) + ) { + return context; + } + let mut set = reduced.refinements_of().meet(&facts); + // A length is an integer: `1 < length(x) < 3` means `length(x) == 2`. + if let Some(len) = set.get(Measure::Length).map(|iv| iv.to_integral()) { + set = set.with(Measure::Length, len); + } + let h = reduced.get_help_data(); + match apply_refinements(reduced.unrefined().clone(), set, h) { + Type::Failed(..) => context, + narrowed => { + let snapshot = context.clone(); + context.replace_or_push_var_type(var, narrowed, &snapshot) + } + } +} diff --git a/crates/typr-core/src/processes/type_checking/refinement_check.rs b/crates/typr-core/src/processes/type_checking/refinement_check.rs new file mode 100644 index 00000000..1f0fc6dc --- /dev/null +++ b/crates/typr-core/src/processes/type_checking/refinement_check.rs @@ -0,0 +1,234 @@ +//! Boundary check for refined types (`refined_types_plan.md`, Phase 5, D3/D5). +//! +//! `is_subtype` only ever answers `true` for a *proven* refinement. What it +//! cannot prove (`[int]` where `[3, int]` is expected, `int` where +//! `int & (> 0)` is expected) is neither accepted nor rejected there: the +//! boundary (`let` annotation, argument, return) asks [`coerce_to`], which +//! separates the three outcomes and returns, for the unproven case, the +//! residual the runtime has to check. + +use crate::components::context::Context; +use crate::components::error_message::help_data::HelpData; +use crate::components::language::Lang; +use crate::components::r#type::argument_type::ArgumentType; +use crate::components::r#type::refinement::RefinementSet; +use crate::components::r#type::tchar::Tchar; +use crate::components::r#type::type_system::TypeSystem; +use crate::components::r#type::Type; + +/// Outcome of comparing what a boundary found with what it expects. +#[derive(Debug, Clone, PartialEq)] +pub enum Coercion { + /// `found <: expected` is proven: nothing to check. + Static, + /// The base types agree and the refinements are compatible but not + /// proven. The set is the part of `expected` that `found` does not imply. + Runtime(RefinementSet), + /// A plain type mismatch, or a refinement that provably cannot hold. + Reject, +} + +/// `t` with every refinement removed, including the length index of a `Vec` +/// (`[3, int]` gives `[any, int]`): what is left is the type the refinements +/// are properties *of*. +fn base_of(t: &Type) -> Type { + match t { + Type::Refined(base, _, _) => base_of(base), + Type::Vec(_, _, _, h) => t.with_vec_length(Type::Any(h.clone())), + t => t.clone(), + } +} + +/// Decide how `found` may flow into `expected` (both reduced here). +pub fn coerce_to(found: &Type, expected: &Type, context: &Context) -> Coercion { + let found = found.reduce(context); + let expected = expected.reduce(context); + + if found.is_subtype(&expected, context).0 { + return Coercion::Static; + } + if !base_of(&found).is_subtype(&base_of(&expected), context).0 { + return Coercion::Reject; + } + + let have = found.refinements_of(); + let want = expected.refinements_of(); + // A vector's length is always integral; scalars follow their base type. + let integral = matches!(base_of(&expected).unrefined(), Type::Integer(..)); + if have.contradicts(&want, integral) { + return Coercion::Reject; + } + + let residual = residual(&have, &want); + if residual.is_trivial() { + // The refinements are not what blocked the subtype (an index that is + // not a literal, say): nothing runtime can repair that. + Coercion::Reject + } else { + Coercion::Runtime(residual) + } +} + +/// The name of a record field, when it is a plain literal. +fn field_name(arg: &ArgumentType) -> Option { + match &arg.0 { + Type::Char(Tchar::Val(name), _) => Some(name.to_string()), + _ => None, + } +} + +/// Runtime obligations for a record literal flowing into a record type +/// (`let a: Account <- list { id: n, .. }`), one per *field expression*: the +/// literal is built right there, so each field is a boundary of its own and +/// the check sits on the value that needs it, not on the whole record. +/// +/// `None` when `expr` is not a spread-free record literal, when the expected +/// type is not a record, when a field is missing or provably wrong, or when +/// no field needs a check (the caller then keeps its own verdict). +pub fn field_obligations( + expr: &Lang, + found: &Type, + expected: &Type, + context: &Context, +) -> Option> { + let Lang::List { + value: fields, spreads, .. + } = expr + else { + return None; + }; + if !spreads.is_empty() { + return None; + } + let (Type::Record(have, _), Type::Record(want, _)) = (found.reduce(context), expected.reduce(context)) else { + return None; + }; + let mut out = Vec::new(); + for w in want.iter() { + let name = field_name(w)?; + let value = fields.iter().find(|f| f.get_argument() == name)?.get_value(); + let h = have.iter().find(|a| field_name(a).as_deref() == Some(name.as_str()))?; + match coerce_to(&h.1, &w.1, context) { + Coercion::Static => {} + Coercion::Runtime(set) => out.push((value.get_help_data(), set)), + // a nested literal is a boundary in turn + Coercion::Reject => out.extend(field_obligations(&value, &h.1, &w.1, context)?), + } + } + (!out.is_empty()).then_some(out) +} + +/// `context` with each obligation of `field_obligations` recorded. +pub fn with_obligations(context: Context, obligations: Vec<(HelpData, RefinementSet)>) -> Context { + obligations + .into_iter() + .fold(context, |ctx, (h, set)| ctx.add_refinement_obligation(&h, set)) +} + +/// The measures of `want` that `have` does not already imply. +pub(crate) fn residual(have: &RefinementSet, want: &RefinementSet) -> RefinementSet { + want.iter() + .filter(|(m, iv)| !have.get(*m).map(|h| h.implies(iv)).unwrap_or(false)) + .fold(RefinementSet::empty(), |acc, (m, iv)| acc.with(*m, *iv)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::fluent_parser::FluentParser; + + fn ty(src: &str) -> (Type, Context) { + let ctx = FluentParser::new() + .push(&format!("type T <- {};", src)) + .run() + .context + .clone(); + let alias = Type::Alias("T".into(), vec![], false, HelpData::default()); + (alias.reduce(&ctx), ctx) + } + + #[test] + fn unproven_length_is_a_runtime_obligation() { + let (found, ctx) = ty("[int]"); + let (expected, _) = ty("[3, int]"); + match coerce_to(&found, &expected, &ctx) { + Coercion::Runtime(set) => assert_eq!(set.to_string(), "length(3)"), + other => panic!("expected Runtime, got {:?}", other), + } + } + + #[test] + fn proven_length_needs_no_check() { + let (found, ctx) = ty("[3, int]"); + let (expected, _) = ty("[int]"); + assert_eq!(coerce_to(&found, &expected, &ctx), Coercion::Static); + let (same, _) = ty("[3, int]"); + assert_eq!(coerce_to(&found, &same, &ctx), Coercion::Static); + } + + #[test] + fn contradicting_length_is_rejected() { + let (found, ctx) = ty("[3, int]"); + let (expected, _) = ty("[5, int]"); + assert_eq!(coerce_to(&found, &expected, &ctx), Coercion::Reject); + } + + #[test] + fn base_mismatch_is_rejected() { + let (found, ctx) = ty("[char]"); + let (expected, _) = ty("[3, int]"); + assert_eq!(coerce_to(&found, &expected, &ctx), Coercion::Reject); + } + + #[test] + fn scalar_needs_a_check_for_a_value_bound() { + let (found, ctx) = ty("int"); + let (expected, _) = ty("int & (> 0)"); + match coerce_to(&found, &expected, &ctx) { + Coercion::Runtime(set) => assert_eq!(set.to_string(), "(> 0)"), + other => panic!("expected Runtime, got {:?}", other), + } + } + + #[test] + fn refined_implies_weaker_bound() { + let (found, ctx) = ty("int & (> 5)"); + let (expected, _) = ty("int & (> 0)"); + assert_eq!(coerce_to(&found, &expected, &ctx), Coercion::Static); + let (tighter, _) = ty("int & (> 10)"); + assert!(matches!(coerce_to(&found, &tighter, &ctx), Coercion::Runtime(_))); + } + + fn errors_of(src: &str) -> usize { + use crate::processes::parsing::parse2; + use crate::processes::type_checking::typing_with_errors; + { + let r = typing_with_errors( + &Context::default(), + &crate::processes::parsing::parse_from_string(src, "test"), + ); + r.get_errors().len() + } + } + + #[test] + fn return_position_accepts_unproven_and_rejects_contradiction() { + assert_eq!(errors_of("let f <- fn(v: [int]): [3, int] { v };"), 0); + assert_eq!( + errors_of("let f <- fn(v: [int], c: bool): [3, int] { if (c) { return v; }; [1, 2, 3] };"), + 0 + ); + assert!(errors_of("let f <- fn(): [3, int] { [1, 2] };") > 0); + assert!(errors_of("let f <- fn(v: [char]): [3, int] { v };") > 0); + } + + #[test] + fn argument_position_accepts_unproven_and_rejects_contradiction() { + let decl = "let g <- fn(p: [2, num]): num { 1.0 };"; + assert_eq!( + errors_of(&format!("{} let u: [num] <- [1.0, 2.0]; let r <- g(u);", decl)), + 0 + ); + assert!(errors_of(&format!("{} let c: [bool] <- [true, false]; let r <- g(c);", decl)) > 0); + } +} diff --git a/crates/typr-core/src/processes/type_checking/signature_normalization.rs b/crates/typr-core/src/processes/type_checking/signature_normalization.rs new file mode 100644 index 00000000..31d83f8a --- /dev/null +++ b/crates/typr-core/src/processes/type_checking/signature_normalization.rs @@ -0,0 +1,498 @@ +//! Signature normalisation for implicit interface generics (`Lovable@A`). +//! +//! RFC `interface_generic_unification`, plan phase 2. One pass over a function +//! signature (`params` + return type) that: +//! +//! - gives every anonymous `I@_` a fresh id (`_0`, `_1`, …); +//! - desugars a bare interface alias `I` into `I@I` (decision D3-A: one +//! variable shared by every bare occurrence of the same interface); +//! - reports the three ill-formed signatures of RFC §7.3: two different bounds +//! for one id, an id colliding with a free generic of the same name, an id +//! that only appears in the return type. +//! +//! The desugared signature is *returned* but not yet consumed: the body +//! (phase 3) and the call site (phase 4) still read the raw signature, so +//! only the errors are wired into `function()` for now. + +use crate::components::error_message::type_error::TypeError; +use crate::components::error_message::typr_error::TypRError; +use crate::components::r#type::argument_type::ArgumentType; +use crate::components::r#type::type_system::TypeSystem; +use crate::processes::type_checking::facets; +use crate::processes::type_checking::type_comparison::reduce_type; +use crate::Context; +use crate::Type; + +/// Id of an anonymous `I@_` before it is renamed. +const ANONYMOUS_ID: &str = "_"; + +pub struct NormalizedSignature { + pub params: Vec, + pub ret: Type, + pub errors: Vec, +} + +/// Rebuilds `typ` top-down: `f` may replace a node (and then its children are +/// not visited) or return `None` to descend into it. Mirrors the recursion +/// shape of `collect_generic_kind_occurrences` in `function.rs`. +pub(crate) fn rewrite(typ: &Type, f: &mut dyn FnMut(&Type) -> Option) -> Type { + if let Some(replacement) = f(typ) { + return replacement; + } + match typ { + Type::Function(args, ret, h) => Type::Function( + args.iter() + .map(|a| a.clone().set_type(rewrite(&a.get_type(), f))) + .collect(), + Box::new(rewrite(ret, f)), + h.clone(), + ), + Type::Vec(vt, len, body, h) => Type::Vec(vt.clone(), len.clone(), Box::new(rewrite(body, f)), h.clone()), + Type::Record(fields, h) => Type::Record( + fields + .iter() + .map(|a| a.clone().set_type(rewrite(&a.get_type(), f))) + .collect(), + h.clone(), + ), + Type::Tag(name, inner, h) => Type::Tag(name.clone(), Box::new(rewrite(inner, f)), h.clone()), + Type::Multi(inner, h) => Type::Multi(Box::new(rewrite(inner, f)), h.clone()), + Type::Operator(op, a, b, h) => { + Type::Operator(op.clone(), Box::new(rewrite(a, f)), Box::new(rewrite(b, f)), h.clone()) + } + Type::Params(ts, h) => Type::Params(ts.iter().map(|t| rewrite(t, f)).collect(), h.clone()), + Type::Tuple(ts, h) => Type::Tuple(ts.iter().map(|t| rewrite(t, f)).collect(), h.clone()), + Type::Alias(name, params, opaque, h) => Type::Alias( + name.clone(), + params.iter().map(|t| rewrite(t, f)).collect(), + *opaque, + h.clone(), + ), + other => other.clone(), + } +} + +/// Read-only walk with the same traversal as `rewrite`. +pub(crate) fn visit(typ: &Type, f: &mut dyn FnMut(&Type)) { + rewrite(typ, &mut |t| { + f(t); + None + }); +} + +/// Whether an `I@Id` variable occurs anywhere in `typ`. +pub(crate) fn has_bounded(typ: &Type) -> bool { + let mut found = false; + visit(typ, &mut |t| found |= matches!(t, Type::Bounded(..))); + found +} + +/// `(id, bound, position)` for every `Bounded` reachable in `typ`, in order. +fn bounded_occurrences(typ: &Type) -> Vec<(String, Type, crate::components::error_message::help_data::HelpData)> { + let mut acc = Vec::new(); + visit(typ, &mut |t| { + if let Type::Bounded(id, bound, h) = t { + acc.push((id.clone(), (**bound).clone(), h.clone())); + } + }); + acc +} + +/// Names of the free generics (`T`, and index generics `#N`) reachable in `typ`. +/// `@A` (`KindedGen`) is left out on purpose: RFC D1 lets `@A` and `I@A` name +/// the same variable. +fn free_generic_names(typ: &Type) -> Vec { + let mut acc = Vec::new(); + visit(typ, &mut |t| match t { + Type::Generic(name, _) | Type::IndexGen(name, _) => acc.push(name.clone()), + _ => {} + }); + acc +} + +fn rename_anonymous(params: &[ArgumentType], ret: &Type) -> (Vec, Type) { + let mut counter = 0usize; + let mut fresh = |t: &Type| match t { + Type::Bounded(id, bound, h) if id == ANONYMOUS_ID => { + let name = format!("_{}", counter); + counter += 1; + Some(Type::Bounded(name, bound.clone(), h.clone())) + } + _ => None, + }; + let params = params + .iter() + .map(|p| p.clone().set_type(rewrite(&p.get_type(), &mut fresh))) + .collect(); + let ret = rewrite(ret, &mut fresh); + (params, ret) +} + +/// `I` → `I@I` for every bare alias that reduces to an interface. Inline +/// `interface { … }` types have no name to share and are left alone, as is +/// anything already `Bounded`. +fn desugar_bare_interfaces(context: &Context, typ: &Type) -> Type { + rewrite(typ, &mut |t| match t { + Type::Bounded(..) => Some(t.clone()), + Type::Alias(name, args, _, h) if args.is_empty() => facets::interface_facet(context, t) + .is_some() + .then(|| Type::Bounded(name.clone(), Box::new(t.clone()), h.clone())), + _ => None, + }) +} + +pub fn normalize_signature(context: &Context, params: &[ArgumentType], ret: &Type) -> NormalizedSignature { + let (params, ret) = rename_anonymous(params, ret); + let mut errors = Vec::new(); + + let param_occurrences: Vec<_> = params.iter().flat_map(|p| bounded_occurrences(&p.get_type())).collect(); + let ret_occurrences = bounded_occurrences(&ret); + + // Two different bounds for one id. Aliases are compared by name (after + // reduction, so `type L2 <- Lovable` isn't a spurious conflict). + let all: Vec<_> = param_occurrences.iter().chain(ret_occurrences.iter()).collect(); + let mut reported: Vec<&String> = Vec::new(); + for (i, (id, bound, _)) in all.iter().enumerate() { + if reported.contains(&&*id) { + continue; + } + let reduced = reduce_type(context, bound); + let clash = all[i + 1..] + .iter() + .find(|(other_id, other, _)| other_id == id && reduce_type(context, other) != reduced); + if let Some((_, other, h)) = clash { + reported.push(&*id); + errors.push(TypRError::Type(TypeError::ConflictingBound( + id.clone(), + bound.pretty(), + other.pretty(), + h.clone(), + ))); + } + } + + // An id sharing its name with a free generic (`fn(a: T, b: Lovable@T)`). + let mut free: Vec = params.iter().flat_map(|p| free_generic_names(&p.get_type())).collect(); + free.extend(free_generic_names(&ret)); + let mut collided: Vec<&String> = Vec::new(); + for (id, _, h) in &all { + if free.contains(id) && !collided.contains(&id) { + collided.push(id); + errors.push(TypRError::Type(TypeError::BoundCollidesWithGeneric( + id.clone(), + h.clone(), + ))); + } + } + + // An id that only appears in the return type has nothing to be inferred + // from: same existential problem as a bare interface returned alone. + let mut orphaned: Vec<&String> = Vec::new(); + for (id, bound, h) in &ret_occurrences { + let anchored = param_occurrences.iter().any(|(pid, _, _)| pid == id); + if !anchored && !orphaned.contains(&id) { + orphaned.push(id); + errors.push(TypRError::Type(TypeError::InterfaceReturnOnly(Type::Bounded( + id.clone(), + Box::new(bound.clone()), + h.clone(), + )))); + } + } + + let params = params + .iter() + .map(|p| p.clone().set_type(desugar_bare_interfaces(context, &p.get_type()))) + .collect(); + let ret = desugar_bare_interfaces(context, &ret); + NormalizedSignature { params, ret, errors } +} + +/// Outcome of matching a call's arguments against a signature that carries +/// `I@Id` variables (declared, or desugared from repeated bare interfaces). +pub enum CallInstance { + /// Nothing to do: the signature has no shared interface variable. + NotBounded, + /// One argument does not satisfy its bound (or the shapes disagree). + Rejected, + /// Two arguments disagree on an id. + Clash(IdClash), + /// The signature with every `Bounded(id, _)` replaced by the type `id` + /// was bound to. Generics left (`#N`, `T`) are for the usual filters. + Instantiated(Vec, Type), +} + +/// `id` was bound to the type of argument `first_arg`, then met `second_type` +/// at `second_arg` (0-based positions in the call). +pub struct IdClash { + pub id: String, + pub first_arg: usize, + pub first_type: Type, + pub second_arg: usize, + pub second_type: Type, +} + +/// Two arguments sharing an id must have the same type (RFC §7.2): mutual +/// subtyping, since `Type`'s `PartialEq` is too loose for generics. +fn same_type(a: &Type, b: &Type, context: &Context) -> bool { + a.is_subtype_raw(b, context) && b.is_subtype_raw(a, context) +} + +/// Binds every `Bounded` in `param` against the matching part of `concrete`. +/// Shapes that do not line up are left to the regular filters. +fn bind_ids( + concrete: &Type, + param: &Type, + arg: usize, + context: &Context, + binds: &mut Vec<(String, Type, usize)>, + clash: &mut Option, +) -> bool { + match param { + Type::Bounded(id, bound, _) => { + if !concrete.is_subtype_raw(&reduce_type(context, bound), context) { + return false; + } + match binds.iter().find(|(k, _, _)| k == id) { + Some((_, previous, first_arg)) => { + let same = same_type(previous, concrete, context); + if !same && clash.is_none() { + *clash = Some(IdClash { + id: id.clone(), + first_arg: *first_arg, + first_type: previous.clone(), + second_arg: arg, + second_type: concrete.clone(), + }); + } + same + } + None => { + binds.push((id.clone(), concrete.clone(), arg)); + true + } + } + } + Type::Vec(_, _, elem_p, _) => match reduce_type(context, concrete) { + Type::Vec(_, _, elem_c, _) => bind_ids(&elem_c, elem_p, arg, context, binds, clash), + _ => true, + }, + Type::Tuple(ps, _) => match reduce_type(context, concrete) { + Type::Tuple(cs, _) if cs.len() == ps.len() => cs + .iter() + .zip(ps.iter()) + .all(|(c, p)| bind_ids(c, p, arg, context, binds, clash)), + _ => true, + }, + Type::Function(ps, rp, _) => match reduce_type(context, concrete) { + Type::Function(cs, rc, _) if cs.len() == ps.len() => { + cs.iter() + .zip(ps.iter()) + .all(|(c, p)| bind_ids(&c.get_type(), &p.get_type(), arg, context, binds, clash)) + && bind_ids(&rc, rp, arg, context, binds, clash) + } + _ => true, + }, + _ => true, + } +} + +/// Plan phase 4 (D5). Normalises the signature, binds its ids against the +/// argument types, and substitutes them back. Variadic signatures are left to +/// the regular filters. +pub fn instantiate_at_call( + context: &Context, + params: &[Type], + ret: &Type, + variadic: bool, + arg_types: &[Type], +) -> CallInstance { + if variadic || params.len() != arg_types.len() { + return CallInstance::NotBounded; + } + // Cheap exit: a bound or a bare interface is always an alias/`Bounded` node. + let mut candidate = false; + for p in params { + visit(p, &mut |t| { + candidate |= matches!(t, Type::Alias(..) | Type::Bounded(..)) + }); + } + if !candidate { + return CallInstance::NotBounded; + } + let args: Vec = params.iter().map(|t| ArgumentType::new("_", t)).collect(); + let normalized = normalize_signature(context, &args, ret); + let norm_params: Vec = normalized.params.iter().map(|p| p.get_type()).collect(); + let declared = params.iter().any(|p| !bounded_occurrences(p).is_empty()); + let occurrences: Vec = norm_params + .iter() + .flat_map(|p| bounded_occurrences(p)) + .map(|(id, _, _)| id) + .collect(); + let shared = occurrences + .iter() + .any(|id| occurrences.iter().filter(|other| *other == id).count() > 1); + if !declared && !shared { + return CallInstance::NotBounded; + } + + let mut binds = Vec::new(); + let mut clash = None; + if !arg_types + .iter() + .zip(norm_params.iter()) + .enumerate() + .all(|(i, (arg, param))| bind_ids(arg, param, i, context, &mut binds, &mut clash)) + { + return match clash { + Some(c) => CallInstance::Clash(c), + None => CallInstance::Rejected, + }; + } + let mut subst = |t: &Type| match t { + Type::Bounded(id, bound, _) => Some( + binds + .iter() + .find(|(k, _, _)| k == id) + .map(|(_, concrete, _)| concrete.clone()) + .unwrap_or_else(|| (**bound).clone()), + ), + _ => None, + }; + let new_params = norm_params.iter().map(|p| rewrite(p, &mut subst)).collect(); + let new_ret = rewrite(&normalized.ret, &mut subst); + CallInstance::Instantiated(new_params, new_ret) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::processes::parsing::parse2; + use crate::processes::type_checking::type_checker::TypeChecker; + + const DECLS: &str = "type Lovable <- interface { love: (Self) -> char };\n\ + type Printable <- interface { show: (Self) -> char };"; + + /// Type-checks the declarations then `fn_src`, and returns the error codes. + fn codes(fn_src: &str) -> Vec<&'static str> { + let tc1 = TypeChecker::new(Context::empty()).typing_no_panic(&parse2(DECLS.into()).unwrap()); + let tc2 = tc1.typing_no_panic(&parse2(fn_src.into()).unwrap()); + tc2.get_errors().iter().map(|e| e.code()).collect() + } + + #[test] + fn well_formed_signatures_are_accepted() { + assert!(codes("fn(a: Lovable@A, b: Lovable@B): bool { true }").is_empty()); + // A shared id is well-formed; typing its body is phase 3. + assert!(codes("fn(a: Lovable@A, b: Lovable@A): bool { true }").is_empty()); + } + + #[test] + fn conflicting_bounds_are_rejected() { + assert!(codes("fn(a: Lovable@A, b: Printable@A): bool { true }").contains(&"T047")); + } + + #[test] + fn id_colliding_with_a_free_generic_is_rejected() { + assert!(codes("fn(a: T, b: Lovable@T): bool { true }").contains(&"T048")); + } + + #[test] + fn id_only_in_return_position_is_rejected() { + assert!(codes("fn(a: Lovable@A): Lovable@B { a }").contains(&"T017")); + } + + #[test] + fn anonymous_ids_are_fresh_and_never_conflict() { + assert!(codes("fn(a: Lovable@_, b: Printable@_): bool { true }").is_empty()); + } + + #[test] + fn bare_interfaces_desugar_to_a_shared_id() { + let tc = TypeChecker::new(Context::empty()).typing_no_panic(&parse2(DECLS.into()).unwrap()); + let ctx = tc.get_context(); + let h = crate::components::error_message::help_data::HelpData::default(); + let bare = ArgumentType::new("a", &Type::Alias("Lovable".into(), vec![], false, h.clone())); + let out = normalize_signature(&ctx, &[bare.clone(), bare], &Type::Any(h)); + assert!(out + .params + .iter() + .all(|p| matches!(p.get_type(), Type::Bounded(ref id, _, _) if id == "Lovable"))); + } + + const WORLD: &str = "type Cat <- list { name: char };\n\ + type Dog <- list { age: int };\n\ + let love <- fn(c: Cat): char { c.name };\n\ + let love <- fn(d: Dog): char { \"dog\" };\n\ + let cat <- Cat:{ name = \"tom\" };\n\ + let dog <- Dog:{ age = 3 };\n"; + + /// Error codes of the declarations, `fn_src` and `call_src` typed as one + /// program (`parse2` silently drops what the CLI rejects). + fn call_codes(fn_src: &str, call_src: &str) -> Vec<&'static str> { + let program = format!("{}\n{}\n{}\n{}", DECLS, WORLD, fn_src, call_src); + let tc = TypeChecker::new(Context::default()) + .typing_no_panic(&crate::processes::parsing::parse_from_string(&program, "t.ty")); + tc.get_errors().iter().map(|e| e.code()).collect() + } + + const TWO_IDS: &str = "let f <- fn(a: Lovable@A, b: Lovable@B): Lovable@B { b };"; + const ONE_ID: &str = "let g <- fn(a: Lovable@X, b: Lovable@X): char { a.love() };"; + const BARE: &str = "let h <- fn(a: Lovable, b: Lovable): char { a.love() };"; + + #[test] + fn distinct_ids_accept_different_types_and_return_the_right_one() { + assert!(call_codes(TWO_IDS, "let r: Dog <- f(cat, dog);").is_empty()); + assert!(!call_codes(TWO_IDS, "let r: Cat <- f(cat, dog);").is_empty()); + } + + #[test] + fn shared_id_rejects_different_types() { + assert!(call_codes(ONE_ID, "g(cat, cat);").is_empty()); + assert!(!call_codes(ONE_ID, "g(cat, dog);").is_empty()); + } + + #[test] + fn bare_interface_repeated_behaves_as_a_shared_id() { + assert!(call_codes(BARE, "h(cat, cat);").is_empty()); + assert!(!call_codes(BARE, "h(cat, dog);").is_empty()); + } + + #[test] + fn bounded_identity_chains() { + let id = "let id <- fn(a: Lovable@A): Lovable@A { a };"; + assert!(call_codes(id, "let r: Cat <- id(id(cat));").is_empty()); + } + + #[test] + fn id_clash_at_a_call_names_the_id_and_both_types() { + assert_eq!(call_codes(ONE_ID, "g(cat, dog);"), vec!["T049"]); + } + + #[test] + fn swap_returns_the_ids_in_swapped_positions() { + let swap = "let swap <- fn(a: Lovable@A, b: Lovable@B): tuple{Lovable@B, Lovable@A} { :{b, a} };"; + assert!(call_codes(swap, "let r: tuple{Dog, Cat} <- swap(cat, dog);").is_empty()); + assert!(!call_codes(swap, "let r: tuple{Cat, Dog} <- swap(cat, dog);").is_empty()); + } + + #[test] + fn nested_bound_in_an_array_is_typed_through_its_rigid() { + let first = "let first <- fn(xs: [#N, Lovable@A]): Lovable@A { xs[1] };\nlet cs <- [cat, cat];"; + assert!(call_codes(first, "let r: Cat <- first(cs);").is_empty()); + assert!(!call_codes(first, "let r: Dog <- first(cs);").is_empty()); + } + + #[test] + fn bounded_is_equal_to_itself() { + let h = crate::components::error_message::help_data::HelpData::default(); + let b = || { + Type::Bounded( + "A".into(), + Box::new(Type::Alias("L".into(), vec![], false, h.clone())), + h.clone(), + ) + }; + assert_eq!(b(), b()); + } +} diff --git a/crates/typr-core/src/processes/type_checking/type_arithmetic.rs b/crates/typr-core/src/processes/type_checking/type_arithmetic.rs index fb88e94a..6fb7a248 100644 --- a/crates/typr-core/src/processes/type_checking/type_arithmetic.rs +++ b/crates/typr-core/src/processes/type_checking/type_arithmetic.rs @@ -10,6 +10,7 @@ use crate::components::error_message::help_data::HelpData; use crate::components::error_message::type_error::TypeError; use crate::components::error_message::typr_error::TypRError; use crate::components::r#type::argument_type::ArgumentType; +use crate::components::r#type::refinement::{Interval, Measure, RefinementSet}; use crate::components::r#type::tint::Tint; use crate::components::r#type::tnumber::Tnum; use crate::components::r#type::type_category::GKind; @@ -131,6 +132,9 @@ pub fn norm_arithmetic(op: TypeOperator, t1: Type, t2: Type, h: HelpData) -> Typ /// nothing to merge structurally) — callers that need to see through it /// project the facet they need (see `facets.rs`). pub fn norm_intersection(t1: Type, t2: Type, h: HelpData) -> Type { + if matches!(t1, Type::Property(..) | Type::Refined(..)) || matches!(t2, Type::Property(..) | Type::Refined(..)) { + return norm_refinement(t1, t2, h); + } match (&t1, &t2) { (Type::Empty(_), _) | (_, Type::Empty(_)) => Type::Empty(h), (Type::Any(_), _) => t2, @@ -154,6 +158,154 @@ pub fn norm_intersection(t1: Type, t2: Type, h: HelpData) -> Type { } } +/// The `TypeError` for a `Failed` produced by `apply_refinements`, or `None` +/// for any other `Failed` (arithmetic, records, ...). +pub fn refinement_error(t: &Type) -> Option { + match t { + Type::Failed(msg, h) if msg.starts_with("invalid refinement") || msg.contains("is a property, not a type") => { + Some(TypeError::InvalidRefinement(msg.clone(), h.clone())) + } + Type::Refined(base, set, h) if matches!(**base, Type::Any(_)) => Some(TypeError::InvalidRefinement( + format!( + "`{}` is a property, not a type: refine a base type with `T & {}`", + set, set + ), + h.clone(), + )), + Type::Failed(msg, h) if msg.starts_with("unsatisfiable refinement") => { + Some(TypeError::UnsatisfiableRefinement(msg.clone(), h.clone())) + } + _ => None, + } +} + +/// Split an `&` operand into `(base, refinements)`. A bare `Property` has no +/// base (`int & (> 0)` is parsed as `Intersection(int, Property)`). +fn split_refinement(t: Type) -> (Option, RefinementSet) { + match t { + Type::Property(p, _) => (None, RefinementSet::single(&p)), + // `Refined(Any, ..)` is the base-less carrier made below for `p & q`. + Type::Refined(base, set, _) if matches!(*base, Type::Any(_)) => (None, set), + Type::Refined(base, set, _) => (Some(*base), set), + t => (Some(t), RefinementSet::empty()), + } +} + +/// The base and the refinements a declared type carries, read off its shape +/// without reducing it: `[#N, T] & length(> 0)` stays an unreduced +/// `Intersection` in a signature, and reducing it once `N` is substituted +/// (`[0, char] & length(> 0)`) would collapse it into an error. +pub(crate) fn declared_refinements(t: &Type) -> Option<(Type, RefinementSet)> { + fn walk(t: &Type) -> (Option, RefinementSet) { + match t { + Type::Operator(TypeOperator::Intersection, a, b, _) => { + let ((ba, sa), (bb, sb)) = (walk(a), walk(b)); + (ba.or(bb), sa.meet(&sb)) + } + t => split_refinement(t.clone()), + } + } + let (base, set) = walk(t); + base.filter(|_| !set.is_trivial()).map(|b| (b, set)) +} + +/// `&` where at least one operand is a `Property` or a `Refined` type +/// (refined_types_plan.md, Phase 3): merge the bases like any other +/// intersection, intersect the property sets, then check the result. +fn norm_refinement(t1: Type, t2: Type, h: HelpData) -> Type { + let ((b1, s1), (b2, s2)) = (split_refinement(t1), split_refinement(t2)); + let set = s1.meet(&s2); + let base = match (b1, b2) { + (Some(a), Some(b)) => match norm_intersection(a, b, h.clone()) { + failed @ Type::Failed(..) => return failed, + t => t, + }, + (Some(t), None) | (None, Some(t)) => t, + // `p & q` with no base yet (`int & ((> 0) & (< 10))`): keep the merged + // properties until a base shows up. `refinement_error` rejects one + // that stays alone. + (None, None) => return Type::Refined(Box::new(Type::Any(h.clone())), set, h), + }; + apply_refinements(base, set, h) +} + +/// Attach `set` to `base`, or explain why that is impossible: the base does +/// not support the measure (invalid refinement), or no value can satisfy the +/// set (unsatisfiable refinement). `[T] & length(n)` folds into the length +/// index of `Vec` (D4); a literal that satisfies the set stays a literal. +pub(crate) fn apply_refinements(base: Type, set: RefinementSet, h: HelpData) -> Type { + if set.is_trivial() { + return base; + } + let unsatisfiable = |why: &str| { + Type::Failed( + format!("unsatisfiable refinement: `{} & {}` {}", base.pretty(), set, why), + h.clone(), + ) + }; + let invalid = |m: Measure| { + Type::Failed( + format!("invalid refinement: `{}` cannot be refined by `{}`", base.pretty(), m), + h.clone(), + ) + }; + let full = Interval::full(); + match &base { + Type::Empty(_) => base, + Type::Vec(kind, length, elem, vh) => { + if set.get(Measure::Value).is_some() { + return invalid(Measure::Value); + } + if set.is_empty(true) { + return unsatisfiable("has no value"); + } + let wanted = set.get(Measure::Length).copied().unwrap_or(full); + match length.meet_interval(&wanted) { + // The length folds the range in (D4): `[T] & length(5)` is + // `[5, T]`, `[T] & length(> 0)` is a ranged length. + Some(Ok(l)) => Type::Vec(kind.clone(), l, elem.clone(), vh.clone()), + Some(Err(())) => match (length.as_known(), wanted.as_point()) { + (Some(m), Some(n)) => unsatisfiable(&format!("contradicts its length {} (asked for {})", m, n)), + (Some(m), None) => unsatisfiable(&format!("contradicts its length {}", m)), + _ => unsatisfiable("contradicts its length"), + }, + // Symbolic length (`#N`) narrowed by a range it does not + // already satisfy: keep the property. + None => Type::Refined(Box::new(base.clone()), set, h), + } + } + Type::Integer(tint, _) => { + if set.get(Measure::Length).is_some() { + return invalid(Measure::Length); + } + if set.is_empty(true) { + return unsatisfiable("has no integer value"); + } + match tint { + Tint::Val(v) if Interval::point(*v as f64).implies(set.get(Measure::Value).unwrap_or(&full)) => { + base.clone() + } + Tint::Val(_) => unsatisfiable("excludes this literal"), + Tint::Unknown => Type::Refined(Box::new(base.clone()), set, h), + } + } + Type::Number(tnum, _) => { + if set.get(Measure::Length).is_some() { + return invalid(Measure::Length); + } + if set.is_empty(false) { + return unsatisfiable("has no value"); + } + match tnum { + Tnum::Val(v) if Interval::point(*v).implies(set.get(Measure::Value).unwrap_or(&full)) => base.clone(), + Tnum::Val(_) => unsatisfiable("excludes this literal"), + Tnum::Unknown => Type::Refined(Box::new(base.clone()), set, h), + } + } + _ => invalid(set.iter().next().map(|(m, _)| *m).unwrap_or(Measure::Value)), + } +} + fn merge_record_fields(f1: &HashSet, f2: &HashSet, h: &HelpData) -> Type { let names1: HashMap = f1.iter().map(|a| (a.get_argument_str(), a.get_type())).collect(); let names2: HashMap = f2.iter().map(|a| (a.get_argument_str(), a.get_type())).collect(); @@ -240,8 +392,10 @@ fn collect_failed_types(typ: &Type, acc: &mut Vec<(String, HelpData)>) { Type::Record(fields, _) | Type::Interface(fields, _) => { fields.iter().for_each(|a| collect_failed_types(&a.get_type(), acc)) } - Type::Vec(_, idx, body, _) => { - collect_failed_types(idx, acc); + Type::Vec(_, _, body, _) => { + if let Some(idx) = typ.vec_length() { + collect_failed_types(&idx, acc); + } collect_failed_types(body, acc); } Type::Tag(_, inner, _) | Type::Multi(inner, _) => collect_failed_types(inner, acc), @@ -257,6 +411,118 @@ fn collect_failed_types(typ: &Type, acc: &mut Vec<(String, HelpData)>) { #[cfg(test)] mod tests { + use crate::components::r#type::refinement::{Num, Refinement}; + + fn prop(r: Refinement) -> Type { + Type::Property(r, HelpData::default()) + } + fn gt(c: f64) -> Type { + prop(Refinement::Gt(Num::new(c))) + } + fn lt(c: f64) -> Type { + prop(Refinement::Lt(Num::new(c))) + } + fn inter(a: Type, b: Type) -> Type { + norm_intersection(a, b, HelpData::default()) + } + + #[test] + fn test_refine_int_with_value_property() { + let res = inter(builder::integer_type_default(), gt(0.0)); + assert!(matches!(res, Type::Refined(..)), "{:?}", res); + } + + #[test] + fn test_refine_is_commutative_and_associative() { + let int = builder::integer_type_default; + let a = inter(inter(int(), gt(0.0)), lt(10.0)); + let c = inter(int(), inter(lt(10.0), gt(0.0))); + let d = inter(lt(10.0), inter(gt(0.0), int())); + assert_eq!(a, c); + assert_eq!(a, d); + } + + #[test] + fn test_refine_is_idempotent() { + let once = inter(builder::integer_type_default(), gt(0.0)); + let twice = inter(once.clone(), gt(0.0)); + assert_eq!(once, twice); + } + + #[test] + fn test_refine_contradiction_is_unsatisfiable() { + let res = inter(inter(builder::integer_type_default(), gt(10.0)), lt(5.0)); + assert!( + matches!(refinement_error(&res), Some(TypeError::UnsatisfiableRefinement(..))), + "{:?}", + res + ); + } + + #[test] + fn test_refine_integers_round_bounds() { + // (> 0) & (< 1) has real values but no integer. + let res = inter(inter(builder::integer_type_default(), gt(0.0)), lt(1.0)); + assert!(matches!( + refinement_error(&res), + Some(TypeError::UnsatisfiableRefinement(..)) + )); + let num = inter(inter(builder::number_type(), gt(0.0)), lt(1.0)); + assert!(matches!(num, Type::Refined(..))); + } + + #[test] + fn test_refine_capability_is_checked() { + let len = || prop(Refinement::Length(3)); + assert!(matches!( + refinement_error(&inter(builder::integer_type_default(), len())), + Some(TypeError::InvalidRefinement(..)) + )); + assert!(matches!( + refinement_error(&inter(builder::character_type_default(), gt(0.0))), + Some(TypeError::InvalidRefinement(..)) + )); + let vec = builder::array_type(builder::any_type(), builder::integer_type_default()); + assert!(matches!( + refinement_error(&inter(vec, gt(0.0))), + Some(TypeError::InvalidRefinement(..)) + )); + } + + #[test] + fn test_refine_length_folds_into_vec_index() { + let vec = builder::array_type(builder::any_type(), builder::integer_type_default()); + let res = inter(vec, prop(Refinement::Length(5))); + assert_eq!(res, builder::array_type2(5, builder::integer_type_default())); + // Same length again: no-op. Another length: contradiction. + assert_eq!(inter(res.clone(), prop(Refinement::Length(5))), res); + let clash = inter(res, prop(Refinement::Length(3))); + assert!(matches!( + refinement_error(&clash), + Some(TypeError::UnsatisfiableRefinement(..)) + )); + } + + #[test] + fn test_refine_literal_is_proven_or_refuted() { + assert_eq!(inter(builder::integer_type(3), gt(0.0)), builder::integer_type(3)); + let refuted = inter(builder::integer_type(-3), gt(0.0)); + assert!(matches!( + refinement_error(&refuted), + Some(TypeError::UnsatisfiableRefinement(..)) + )); + } + + #[test] + fn test_bare_properties_are_not_types() { + let res = inter(gt(0.0), lt(5.0)); + assert!( + matches!(refinement_error(&res), Some(TypeError::InvalidRefinement(..))), + "{:?}", + res + ); + } + use super::*; use crate::utils::builder; @@ -502,4 +768,116 @@ mod tests { let errors = validate_operator_kinds(&Context::default(), &typ); assert!(errors.is_empty()); } + + #[test] + fn length_range_on_vectors() { + use crate::components::r#type::refinement::{Interval, Measure}; + let range = || prop(Refinement::Range(Measure::Length, Interval::greater_than(0.0))); + let unknown = Type::vec( + crate::components::r#type::vector_type::VecType::S3, + Type::Any(HelpData::default()), + builder::integer_type_default(), + HelpData::default(), + ); + // unknown length: the range folds into the length itself + let folded = inter(unknown.clone(), range()); + assert!( + matches!(&folded, Type::Vec(_, l, _, _) if l.as_proper_range().is_some()), + "{:?}", + folded + ); + assert_eq!( + folded.refinements_of().get(Measure::Length), + Some(&Interval::greater_than(0.0)) + ); + // known length: satisfied range is redundant, violated one is a contradiction + let sized = |n| { + Type::vec( + crate::components::r#type::vector_type::VecType::S3, + builder::integer_type(n), + builder::integer_type_default(), + HelpData::default(), + ) + }; + assert_eq!(inter(sized(3), range()), sized(3)); + let res = inter(sized(0), range()); + assert!( + matches!(refinement_error(&res), Some(TypeError::UnsatisfiableRefinement(..))), + "{:?}", + res + ); + } + + #[test] + fn folded_length_ranges_meet_and_compare_by_interval() { + use crate::components::context::Context; + use crate::components::r#type::refinement::{Interval, Measure}; + use crate::components::r#type::vector_type::VecType; + let ctx = Context::default(); + let vec_of = |len: Type| Type::vec(VecType::S3, len, builder::integer_type_default(), HelpData::default()); + let any = || Type::Any(HelpData::default()); + let range = |iv: Interval| prop(Refinement::Range(Measure::Length, iv)); + let pos = inter(vec_of(any()), range(Interval::greater_than(0.0))); + let small = inter(pos.clone(), range(Interval::at_most(10.0))); + // meeting narrows the same length + assert!(matches!(&small, Type::Vec(..)), "{:?}", small); + // [1,10] <: [>0] <: [T]; the converse does not hold + assert!(small.is_subtype(&pos, &ctx).0); + assert!(pos.is_subtype(&vec_of(any()), &ctx).0); + assert!(!pos.is_subtype(&small, &ctx).0); + assert!(!vec_of(any()).is_subtype(&pos, &ctx).0); + // a literal length is proven against a range it satisfies, refuted otherwise + assert!(vec_of(builder::integer_type(3)).is_subtype(&pos, &ctx).0); + assert!(!vec_of(builder::integer_type(0)).is_subtype(&pos, &ctx).0); + // a contradiction is reported + let none = inter(pos.clone(), range(Interval::less_than(1.0))); + assert!( + matches!(refinement_error(&none), Some(TypeError::UnsatisfiableRefinement(..))), + "{:?}", + none + ); + // printed as a refinement + assert_eq!(pos.pretty(), "[int] & length(> 0)"); + } + + #[test] + fn symbolic_length_has_provable_bounds() { + use crate::components::context::Context; + use crate::components::r#type::refinement::{Interval, Measure}; + use crate::components::r#type::type_operator::TypeOperator; + use crate::components::r#type::vector_type::VecType; + let ctx = Context::default(); + let n = || Type::IndexGen("N".into(), HelpData::default()); + let plus1 = Type::Operator( + TypeOperator::Addition, + Box::new(n()), + Box::new(builder::integer_type(1)), + HelpData::default(), + ); + let vec_of = |len: Type| Type::vec(VecType::S3, len, builder::integer_type_default(), HelpData::default()); + let range = |iv: Interval| prop(Refinement::Range(Measure::Length, iv)); + // `#N + 1` is at least 1: it proves length(> 0), `#N` alone does not + let pos = inter( + vec_of(Type::Any(HelpData::default())), + range(Interval::greater_than(0.0)), + ); + assert!(vec_of(plus1.clone()).is_subtype(&pos, &ctx).0); + assert!(!vec_of(n()).is_subtype(&pos, &ctx).0); + // narrowing `#N + 1` by a satisfied range is a no-op; by an unproven one it stays a property + assert_eq!( + inter(vec_of(plus1.clone()), range(Interval::greater_than(0.0))), + vec_of(plus1) + ); + assert!(matches!( + inter(vec_of(n()), range(Interval::greater_than(0.0))), + Type::Refined(..) + )); + // a symbolic length is never negative + let neg = inter(vec_of(n()), range(Interval::less_than(0.0))); + assert!( + matches!(refinement_error(&neg), Some(TypeError::UnsatisfiableRefinement(..))), + "{:?}", + neg + ); + } } diff --git a/crates/typr-core/src/processes/type_checking/type_comparison.rs b/crates/typr-core/src/processes/type_checking/type_comparison.rs index adda3c51..9d853f49 100644 --- a/crates/typr-core/src/processes/type_checking/type_comparison.rs +++ b/crates/typr-core/src/processes/type_checking/type_comparison.rs @@ -98,12 +98,7 @@ pub fn reduce_type_helper(context: &Context, type_: &Type, memory: Vector Type::Vec( - vtype.clone(), - ind.clone(), - Box::new(reduce_type_helper(context, typ, memory.clone())), - h.clone(), - ), + Type::Vec(_, _, elem, _) => type_.with_vec_elem(reduce_type_helper(context, elem, memory.clone())), Type::Operator(TypeOperator::Union, t1, t2, h) => { // G4 (audit_type_checking.md): branches used to be compared // un-reduced, and — when neither subsumed the other — returned @@ -135,9 +130,20 @@ pub fn reduce_type_helper(context: &Context, type_: &Type, memory: Vector 0)`). + Type::Property(p, h) => Type::Failed( + format!("`{}` is a property, not a type: refine a base type with `T & {}`", p, p), + h.clone(), + ), Type::Operator(TypeOperator::Intersection, t1, t2, h) => { - let r1 = reduce_type_helper(context, t1, memory.clone()); - let r2 = reduce_type_helper(context, t2, memory.clone()); + // A `Property` operand is what `norm_intersection` folds into a + // `Refined`; reducing it alone would report it as a bare property. + let reduce_operand = |t: &Type| match t { + Type::Property(..) => t.clone(), + _ => reduce_type_helper(context, t, memory.clone()), + }; + let r1 = reduce_operand(t1); + let r2 = reduce_operand(t2); norm_intersection(r1, r2, h.clone()) } Type::Operator(TypeOperator::Access, t1, t2, h) => { diff --git a/crates/typr-core/src/processes/type_checking/type_context.rs b/crates/typr-core/src/processes/type_checking/type_context.rs index c662315f..0d43372e 100644 --- a/crates/typr-core/src/processes/type_checking/type_context.rs +++ b/crates/typr-core/src/processes/type_checking/type_context.rs @@ -1,6 +1,7 @@ use crate::components::error_message::type_error::TypeError; use crate::components::error_message::typr_error::TypRError; use crate::components::r#type::type_system::TypeSystem; +use crate::processes::type_checking::type_arithmetic::refinement_error; use crate::processes::type_checking::Context; use crate::processes::type_checking::Lang; use crate::processes::type_checking::Type; @@ -66,7 +67,11 @@ impl TypeContext { // But it also returns `Any` legitimately when the annotation IS `Any` (success), or when // there is no annotation (Empty) and the actual type is `Any`. Guard against both false // positives: only record an error when the annotation is an explicit non-Any, non-Empty type. - let annotation_is_constraining = !typ.is_empty() && !matches!(typ.reduce(&self.context), Type::Any(_)); + let reduced_annotation = typ.reduce(&self.context); + // A malformed refinement is reported on its own (T045/T046) by `let_expression`. + let annotation_is_constraining = !typ.is_empty() + && !matches!(reduced_annotation, Type::Any(_)) + && refinement_error(&reduced_annotation).is_none(); if annotation_is_constraining { if let crate::components::r#type::Type::Any(_) = new_type { errors.push(TypRError::type_error(TypeError::Let(typ.clone(), self.value.clone()))); diff --git a/crates/typr-core/src/processes/type_checking/type_recorder.rs b/crates/typr-core/src/processes/type_checking/type_recorder.rs new file mode 100644 index 00000000..3df1ee60 --- /dev/null +++ b/crates/typr-core/src/processes/type_checking/type_recorder.rs @@ -0,0 +1,184 @@ +//! Records the type computed for every expression `typing()` is called on, keyed by source span +//! (see `visualization_graph_v2.md` §7.2 — prerequisite for the block-graph builder and, longer +//! term, for LSP hover/inlay hints). Disabled by default: `typing()` still checks a thread-local +//! on every call, but records nothing unless a `with_recording` scope is active, so the cost when +//! off is one thread-local read and no allocation. +//! +//! WASM is single-threaded, so a `thread_local!` here carries no cross-thread risk. + +use crate::components::error_message::help_data::HelpData; +use crate::components::language::Lang; +use crate::components::r#type::Type; +use std::cell::RefCell; +use std::collections::HashMap; + +/// Identifies a node's source range for recording purposes: `(file, start, end)`. Two distinct +/// nodes at the same offset (there are none in a well-formed parse) would collide; see prereq D +/// (`HelpData.end`) for why `start` alone isn't enough — it's what previously made every prefix of +/// an expression share its parent's key. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SpanKey { + pub file: String, + pub start: usize, + pub end: usize, +} + +impl From<&HelpData> for SpanKey { + fn from(h: &HelpData) -> Self { + SpanKey { + file: h.get_file_name(), + start: h.get_offset(), + end: h.get_end(), + } + } +} + +/// A recorded conflict: `typing()` was called more than once on the same span and produced two +/// different, both non-`Failed`, types. Kept for analysis while validating the recording policy +/// on real cases (§7.2); not surfaced as an error. +#[derive(Debug, Clone)] +pub struct TypeConflict { + pub key: SpanKey, + pub previous: Type, + pub replacement: Type, +} + +#[derive(Debug, Clone, Default)] +pub struct TypeTable { + entries: HashMap, + conflicts: Vec, +} + +/// A type that carries no real information about the node it's attached to — the placeholder +/// `typing()` produces mid-way through a trial (dispatch candidate, forward reference, empty +/// block) rather than the node's actual type. Neither overwrites a firm value, nor counts as a +/// conflict when a firm value overwrites it: observed on the §14 walkthrough example, a function +/// body's `Lines` node is first typed as `UnknownFunction` while its own signature is still being +/// resolved, then re-typed to its real type once that trial completes (see §7.2's "last +/// non-`Failed` value" policy, broadened here after that observation). +fn is_placeholder(t: &Type) -> bool { + matches!(t, Type::Failed(..) | Type::UnknownFunction(..)) +} + +impl TypeTable { + pub fn new() -> Self { + Self::default() + } + + /// Inserts under the policy of §7.2, "last non-`Failed` value wins" (broadened to + /// [`is_placeholder`]): a placeholder result never overwrites a previously recorded firm + /// value, since it carries no information (typing() retries dispatch/unification candidates, + /// and re-types a node while its own signature is still being resolved). Two different firm + /// values for the same span are logged as a conflict but the later one still wins. + pub fn insert(&mut self, key: SpanKey, value: Type) { + match self.entries.get(&key) { + Some(previous) if is_placeholder(&value) && !is_placeholder(previous) => { + // Keep the earlier good value; a placeholder retry carries no information. + } + Some(previous) if *previous != value && !is_placeholder(previous) && !is_placeholder(&value) => { + self.conflicts.push(TypeConflict { + key: key.clone(), + previous: previous.clone(), + replacement: value.clone(), + }); + self.entries.insert(key, value); + } + _ => { + self.entries.insert(key, value); + } + } + } + + pub fn get(&self, key: &SpanKey) -> Option<&Type> { + self.entries.get(key) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn conflicts(&self) -> &[TypeConflict] { + &self.conflicts + } +} + +thread_local! { + static RECORDER: RefCell> = const { RefCell::new(None) }; +} + +/// Runs `f` with recording turned on and returns its result alongside the table filled in by every +/// `typing()` call made while `f` ran. Any recording already in progress on this thread (there is +/// none in current callers) is suspended for the duration and restored afterwards. +pub fn with_recording(f: impl FnOnce() -> R) -> (R, TypeTable) { + let previous = RECORDER.with(|cell| cell.replace(Some(TypeTable::new()))); + let result = f(); + let table = RECORDER.with(|cell| cell.replace(previous)); + (result, table.unwrap_or_default()) +} + +/// Called by `typing()` after computing `value` for `expr`. A no-op (one thread-local read, no +/// allocation) unless a `with_recording` scope is active. +pub fn record(expr: &Lang, value: &Type) { + RECORDER.with(|cell| { + if let Some(table) = cell.borrow_mut().as_mut() { + table.insert(SpanKey::from(&expr.get_help_data()), value.clone()); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::context::Context; + use crate::processes::parsing::parse_from_string; + use crate::processes::type_checking::typing_with_errors; + + // The walkthrough example from `visualization_graph_v2.md` §14, verified there with `typr check`. + const FIL_ROUGE: &str = r#" +type Printable <- interface { show: (Self) -> char }; +type Point <- list { x: int, y: int }; + +let sq <- fn(n: int): int { n * n }; + +let norm2 <- fn(p: Point): int { + let a <- sq(p$x); + a + sq(p$y) +}; + +let show <- fn(p: Point): char { "Point" }; + +let p <- Point:{ x = 3, y = 4 }; +let d <- Printable(p); +let total <- norm2(p) + 12 + 3; +"#; + + #[test] + fn recording_is_disabled_by_default() { + let lang = parse_from_string(FIL_ROUGE, "fil_rouge"); + let _ = typing_with_errors(&Context::default(), &lang); + // No with_recording scope was opened: nothing observable from outside, this just + // exercises the no-op path on the same program the next test records. + } + + #[test] + fn with_recording_captures_every_subexpression_of_the_walkthrough_example() { + let lang = parse_from_string(FIL_ROUGE, "fil_rouge"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + + assert!( + !result.has_errors(), + "walkthrough example should typecheck cleanly: {:?}", + result.display_errors() + ); + assert!(!table.is_empty(), "recording should have captured at least one span"); + assert!( + table.conflicts().is_empty(), + "no dispatch/unification conflicts expected on this example: {:?}", + table.conflicts() + ); + } +} diff --git a/crates/typr-core/src/processes/type_checking/unification.rs b/crates/typr-core/src/processes/type_checking/unification.rs index fad5f14d..e2ba25a8 100644 --- a/crates/typr-core/src/processes/type_checking/unification.rs +++ b/crates/typr-core/src/processes/type_checking/unification.rs @@ -3,6 +3,7 @@ use crate::components::r#type::argument_type::ArgumentType; use crate::components::r#type::type_operator::TypeOperator; use crate::components::r#type::Type; use crate::processes::type_checking::type_comparison; +use crate::utils::builder; use std::collections::HashSet; /// Compare two index/label keys by *name*. @@ -117,10 +118,10 @@ pub fn type_substitution(type_: &Type, substitutions: &[(Type, Type)]) -> Type { } // Array type substitution - Type::Vec(vtype, size, element_type, h) => Type::Vec( + Type::Vec(vtype, _, element_type, h) => Type::vec( vtype.clone(), - Box::new(type_substitution(size, substitutions)), - Box::new(type_substitution(element_type, substitutions)), + type_substitution(&type_.vec_length().unwrap_or_else(builder::any_type), substitutions), + type_substitution(element_type, substitutions), h.clone(), ), @@ -166,6 +167,11 @@ pub fn type_substitution(type_: &Type, substitutions: &[(Type, Type)]) -> Type { ), // Tag type substitution + // `[#N, T] & length(> 0)`: the refinements carry no generic, only the base does + Type::Refined(base, set, h) => { + Type::Refined(Box::new(type_substitution(base, substitutions)), set.clone(), h.clone()) + } + Type::Tag(name, inner_type, h) => Type::Tag( name.clone(), Box::new(type_substitution(inner_type, substitutions)), @@ -196,10 +202,13 @@ fn type_contains_generic(typ: &Type, name: &str) -> bool { Type::Function(params, ret, _) => { params.iter().any(|p| type_contains_generic(&p.get_type(), name)) || type_contains_generic(ret, name) } - Type::Vec(_, size, elem, _) => type_contains_generic(size, name) || type_contains_generic(elem, name), + Type::Vec(_, _, elem, _) => { + typ.vec_length().is_some_and(|size| type_contains_generic(&size, name)) || type_contains_generic(elem, name) + } Type::Record(fields, _) => fields.iter().any(|f| type_contains_generic(&f.get_type(), name)), Type::Alias(_, params, _, _) => params.iter().any(|p| type_contains_generic(p, name)), Type::Tag(_, inner, _) => type_contains_generic(inner, name), + Type::Refined(base, _, _) => type_contains_generic(base, name), Type::Interface(fields, _) => fields.iter().any(|f| type_contains_generic(&f.get_type(), name)), Type::Multi(inner, _) => type_contains_generic(inner, name), Type::Tuple(elems, _) => elems.iter().any(|e| type_contains_generic(e, name)), @@ -316,8 +325,15 @@ fn unification_helper(values: &[Type], type1: &Type, type2: &Type) -> Option { - let mut combined = unification_helper(values, size1, size2)?; + // Refinements are checked at the boundary (`coerce_to`): unification + // only binds the generics of the bases. + (Type::Refined(b1, _, _), Type::Refined(b2, _, _)) => unification_helper(values, b1, b2), + (Type::Refined(b, _, _), t) | (t, Type::Refined(b, _, _)) => unification_helper(values, b, t), + + (Type::Vec(_, _, elem1, _), Type::Vec(_, _, elem2, _)) => { + let size1 = type1.vec_length()?; + let size2 = type2.vec_length()?; + let mut combined = unification_helper(values, &size1, &size2)?; let elem_matches = unification_helper(values, elem1, elem2)?; if !merge_substitutions(&mut combined, elem_matches) { return None; diff --git a/crates/typr-core/src/utils/builder.rs b/crates/typr-core/src/utils/builder.rs index e59df7bf..3cb9050f 100644 --- a/crates/typr-core/src/utils/builder.rs +++ b/crates/typr-core/src/utils/builder.rs @@ -83,12 +83,12 @@ pub fn tuple_type(types: &[Type]) -> Type { } pub fn array_type(i: Type, t: Type) -> Type { - Type::Vec(VecType::S3, Box::new(i), Box::new(t), HelpData::default()) + Type::vec(VecType::S3, i, t, HelpData::default()) } pub fn array_type2(i: i32, t: Type) -> Type { let i2 = integer_type(i); - Type::Vec(VecType::S3, Box::new(i2), Box::new(t), HelpData::default()) + Type::vec(VecType::S3, i2, t, HelpData::default()) } pub fn dataframe_type(i: Type, columns: &[(String, Type)]) -> Type { @@ -96,10 +96,10 @@ pub fn dataframe_type(i: Type, columns: &[(String, Type)]) -> Type { .iter() .map(|param| ArgumentType::from(param.to_owned())) .collect::>(); - Type::Vec( + Type::vec( VecType::DataFrame, - Box::new(i), - Box::new(Type::Record(fields, HelpData::default())), + i, + Type::Record(fields, HelpData::default()), HelpData::default(), ) } diff --git a/crates/typr-core/src/utils/program_gen.rs b/crates/typr-core/src/utils/program_gen.rs index 9c191f9b..2f15c6be 100644 --- a/crates/typr-core/src/utils/program_gen.rs +++ b/crates/typr-core/src/utils/program_gen.rs @@ -197,9 +197,9 @@ fn gen_var(env: &GenEnv, goal: &Type) -> Option { /// since v1 never hands a `Vec` goal to anything else — function /// params/returns are primitive-only). Falls back to 2 otherwise. fn vec_len(goal: &Type) -> usize { - if let Type::Vec(_, idx, _, _) = goal { - if let Type::Integer(Tint::Val(n), _) = idx.as_ref() { - return (*n).max(1) as usize; + if let Some(idx) = goal.vec_length() { + if let Type::Integer(Tint::Val(n), _) = idx { + return n.max(1) as usize; } } 2 diff --git a/crates/typr-core/tests/snapshots/snapshot_tests__typing__function_type_inference.snap b/crates/typr-core/tests/snapshots/snapshot_tests__typing__function_type_inference.snap index 21bd62b3..e6a226e2 100644 --- a/crates/typr-core/tests/snapshots/snapshot_tests__typing__function_type_inference.snap +++ b/crates/typr-core/tests/snapshots/snapshot_tests__typing__function_type_inference.snap @@ -1,6 +1,6 @@ --- source: crates/typr-core/tests/snapshot_tests.rs -assertion_line: 283 +assertion_line: 287 expression: ty --- Function( @@ -12,6 +12,7 @@ Function( ), HelpData { offset: 15, + end: 15, file_name: "", }, ), @@ -19,6 +20,7 @@ Function( Unknown, HelpData { offset: 17, + end: 17, file_name: "", }, ), @@ -33,6 +35,7 @@ Function( ), HelpData { offset: 23, + end: 23, file_name: "", }, ), @@ -40,6 +43,7 @@ Function( Unknown, HelpData { offset: 25, + end: 25, file_name: "", }, ), @@ -52,11 +56,13 @@ Function( Unknown, HelpData { offset: 31, + end: 31, file_name: "", }, ), HelpData { offset: 0, + end: 45, file_name: "", }, ) diff --git a/crates/typr-core/tests/snapshots/snapshot_tests__typing__integer_type.snap b/crates/typr-core/tests/snapshots/snapshot_tests__typing__integer_type.snap index 5e094b95..6eeae3b3 100644 --- a/crates/typr-core/tests/snapshots/snapshot_tests__typing__integer_type.snap +++ b/crates/typr-core/tests/snapshots/snapshot_tests__typing__integer_type.snap @@ -1,5 +1,6 @@ --- source: crates/typr-core/tests/snapshot_tests.rs +assertion_line: 267 expression: ty --- Integer( @@ -8,6 +9,7 @@ Integer( ), HelpData { offset: 0, + end: 2, file_name: "", }, ) diff --git a/crates/typr-core/tests/snapshots/snapshot_tests__typing__let_introduces_variable.snap b/crates/typr-core/tests/snapshots/snapshot_tests__typing__let_introduces_variable.snap index 36653f01..a6904474 100644 --- a/crates/typr-core/tests/snapshots/snapshot_tests__typing__let_introduces_variable.snap +++ b/crates/typr-core/tests/snapshots/snapshot_tests__typing__let_introduces_variable.snap @@ -1,5 +1,6 @@ --- source: crates/typr-core/tests/snapshot_tests.rs +assertion_line: 278 expression: ty --- Alias( @@ -8,6 +9,7 @@ Alias( false, HelpData { offset: 8, + end: 8, file_name: "", }, ) diff --git a/crates/typr-core/tests/snapshots/snapshot_tests__typing__record_type.snap b/crates/typr-core/tests/snapshots/snapshot_tests__typing__record_type.snap index c6b16d69..fea5b66c 100644 --- a/crates/typr-core/tests/snapshots/snapshot_tests__typing__record_type.snap +++ b/crates/typr-core/tests/snapshots/snapshot_tests__typing__record_type.snap @@ -1,5 +1,6 @@ --- source: crates/typr-core/tests/snapshot_tests.rs +assertion_line: 298 expression: ty --- Alias( @@ -8,6 +9,7 @@ Alias( false, HelpData { offset: 5, + end: 22, file_name: "", }, ) diff --git a/crates/typr-core/tests/snapshots/snapshot_tests__typing__union_alias.snap b/crates/typr-core/tests/snapshots/snapshot_tests__typing__union_alias.snap index 47a7e9c0..e914c93f 100644 --- a/crates/typr-core/tests/snapshots/snapshot_tests__typing__union_alias.snap +++ b/crates/typr-core/tests/snapshots/snapshot_tests__typing__union_alias.snap @@ -1,5 +1,6 @@ --- source: crates/typr-core/tests/snapshot_tests.rs +assertion_line: 309 expression: ty --- Tag( @@ -10,11 +11,13 @@ Tag( ), HelpData { offset: 0, + end: 13, file_name: "", }, ), HelpData { offset: 0, + end: 13, file_name: "", }, ) diff --git a/crates/typr-graph/Cargo.toml b/crates/typr-graph/Cargo.toml new file mode 100644 index 00000000..908012a1 --- /dev/null +++ b/crates/typr-graph/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "typr-graph" +description = "Builds a hierarchical block-graph view of a TypR program (see visualization_graph_v2.md)" +readme = "../../README.md" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +typr-core.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +insta = { version = "1", features = ["yaml"] } diff --git a/crates/typr-graph/src/build/blocks.rs b/crates/typr-graph/src/build/blocks.rs new file mode 100644 index 00000000..9e30384e --- /dev/null +++ b/crates/typr-graph/src/build/blocks.rs @@ -0,0 +1,1330 @@ +//! Per-`Lang`-variant block construction (spec §4). [`Builder::build_expr`] is total: every +//! `Lang` variant not explicitly matched here — and every shape inside a matched variant that +//! doesn't fit the common case — falls back to a plain `Opaque` block rather than panicking, +//! which is the étape-1 acceptance criterion (spec §12). + +use super::{span_of, top_level_name, variable_name, Builder}; +use crate::key::{BlockKey, Namespace}; +use crate::model::*; +use std::collections::HashSet; +use typr_core::components::context::Context; +use typr_core::components::language::argument_value::ArgumentValue; +use typr_core::components::language::operators::Op; +use typr_core::components::language::var::Var; +use typr_core::components::language::Lang; +use typr_core::components::r#type::argument_type::ArgumentType; +use typr_core::components::r#type::tchar::Tchar; +use typr_core::components::r#type::type_system::TypeSystem; +use typr_core::components::r#type::Type; + +/// A parameter/field/method name, read the same way `ArgumentType::get_argument_str` does but +/// without its `panic!` on an unexpected shape (a generic or embedded parameter, say) — falls +/// back to `"_"` instead, since totality here matters more than a perfect label. +pub(super) fn safe_argument_name(arg: &ArgumentType) -> String { + match arg.get_argument() { + Type::Char(Tchar::Val(s), _) => s.to_string(), + Type::LabelGen(s, _) => s.to_uppercase(), + _ => "_".to_string(), + } +} + +pub(super) fn alias_name(ty: &Type) -> Option { + match ty { + Type::Alias(name, ..) => Some(name.clone()), + _ => None, + } +} + +/// A type's interface per Q4 ("l'interface d'un type = ses méthodes"): every top-level function +/// whose first parameter is `type_name`, discovered via `TypePosition{index: 0}` rather than a +/// declared `impl`. Shared between `build_type_decl` (its `TypeDecl` outputs) and the type +/// relations pass (étape 3's `Satisfies` evidence). +pub(super) fn discover_methods(context: &Context, type_name: &str) -> Vec<(String, String, BlockKey)> { + let mut methods: Vec<_> = context + .variables() + .filter_map(|(var, ty)| match ty { + Type::Function(params, _, _) => { + let p0 = params.first()?; + if alias_name(&p0.get_type()).as_deref() == Some(type_name) { + let method_name = var.get_name(); + Some(( + method_name.clone(), + ty.pretty(), + BlockKey::top_level(Namespace::Val, &method_name), + )) + } else { + None + } + } + _ => None, + }) + .collect(); + // `Context::variables()` has no stable order; the graph must. + methods.sort_by(|a, b| a.0.cmp(&b.0)); + methods +} + +/// Record fields and interface methods live in a `HashSet`, whose iteration order changes from one +/// run to the next. The graph (and its snapshots) must not: members are emitted by name. +fn sorted_members(members: &HashSet) -> Vec<&ArgumentType> { + let mut sorted: Vec<&ArgumentType> = members.iter().collect(); + sorted.sort_by_key(|m| safe_argument_name(m)); + sorted +} + +fn body_statements(body: &Lang) -> Vec<&Lang> { + match body { + Lang::Scope { body, .. } => body.iter().collect(), + other => vec![other], + } +} + +impl<'a> Builder<'a> { + pub(super) fn build_expr(&mut self, lang: &Lang, key: BlockKey, name_hint: Option<&str>) -> PortRef { + let name = name_hint.map(|s| s.to_string()); + match lang { + Lang::Number { .. } + | Lang::Integer { .. } + | Lang::Bool { .. } + | Lang::Char { .. } + | Lang::Null(_) + | Lang::NA(_) + | Lang::Empty(_) => { + let ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Literal, + name, + Vec::new(), + vec![Port::explicit("out", ty)], + Origin::User, + None, + ) + } + + // Reached only for a *named* position whose RHS is a bare rename (`let x <- y;`) — + // an unnamed use is short-circuited by `build_operand` before it calls `build_expr`. + // Modeled as a reference to the real definition rather than a copy of it. + Lang::Variable { name: var_name, .. } => { + if let Some((target, confidence)) = self.resolve_definition(var_name, None) { + self.graph + .relations + .push(Relation::r#ref(key.clone(), "value", target, confidence)); + } + let ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Opaque, + name, + Vec::new(), + vec![Port::explicit("out", ty)], + Origin::User, + None, + ) + } + + // `Lang::Operator`'s own field names are swapped from what they read as: `Op::combine` + // (operators.rs) builds `Operator{lhs: right_operand, rhs: left_operand}`, and every + // other constructor (e.g. `to_module_helper`, and the `dollar_access`/`dot_pipe_access` + // call sites in `processes/type_checking/mod.rs`) follows the same convention. So the + // *syntactic* first operand is `rhs`, the second is `lhs` — swapped here once, at the + // single place this `Lang` shape is taken apart, so every helper below can use + // `lhs`/`rhs` in their ordinary, non-swapped sense. + Lang::Operator { operator, lhs, rhs, .. } => self.build_operator_like(operator, rhs, lhs, lang, key, name), + + Lang::Not { value, .. } => { + let mut children = Vec::new(); + let mut wires = Vec::new(); + self.build_operand(value, key.role("value"), &key, "value", &mut children, &mut wires); + let inputs = vec![Port::explicit("value", self.pretty_type(value))]; + let out_ty = self.pretty_type(lang); + let name = name.or_else(|| Some("!".to_string())); + self.finish_block( + lang, + key, + BlockKind::Operator, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires }), + ) + } + + Lang::FunctionApp { + identifier, arguments, .. + } + | Lang::VecFunctionApp { + identifier, arguments, .. + } => self.build_apply(identifier, arguments, lang, key, name), + + Lang::Function { + parameters, + return_type, + body, + .. + } => self.build_function(parameters, return_type, body, lang, key, name), + Lang::Lambda { parameters, body, .. } => self.build_lambda(parameters, body, lang, key, name), + + Lang::List { value, .. } => self.build_record(value, lang, key, name), + Lang::ConstructorCall { fields, .. } => self.build_record(fields, lang, key, name), + + Lang::Alias { + identifier, + target_type, + .. + } => self.build_alias(identifier, target_type, lang, key, name), + + Lang::If { + condition, + if_block, + else_block, + .. + } => self.build_if(condition, if_block, else_block, lang, key, name), + + Lang::Tuple { value, .. } => self.build_sequence(BlockKind::Tuple, value, lang, key, name), + Lang::Array { value, .. } | Lang::Vector { value, .. } => { + self.build_sequence(BlockKind::Array, value, lang, key, name) + } + + Lang::Scope { body, .. } => { + self.scope.push_plain(); + let stmts = body_statements_from_vec(body); + let children = self.build_nested_body(&stmts, &key); + self.scope.pop(); + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Scope, + name, + Vec::new(), + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { + children, + wires: Vec::new(), + }), + ) + } + + Lang::Module { + name: module_name, + body, + .. + } => self.build_module(module_name, body, lang, key, name), + + Lang::ForLoop { + identifier, + expression, + body, + .. + } => self.build_for_loop(identifier, expression, body, lang, key, name), + Lang::WhileLoop { condition, body, .. } => self.build_while_loop(condition, body, lang, key, name), + Lang::Loop { body, .. } => self.build_bare_loop(body, lang, key, name), + + Lang::Match { target, branches, .. } => self.build_match(target, branches, lang, key, name), + + // Raw R (spec §4: "frontière opaque vers R") — the body text is never parsed as + // TypR, so unlike every other block above, no inputs/wires are derived from it. + Lang::RBlock { .. } | Lang::RFunction { .. } | Lang::ExternBlock { .. } => { + let ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::RCode, + name, + Vec::new(), + vec![Port::explicit("out", ty)], + Origin::User, + None, + ) + } + + // A `# …` line: its own kind, and the node's name is the comment text. No value, so + // no ports. + Lang::Comment { value, .. } => { + let text = value.trim().to_string(); + self.finish_block( + lang, + key, + BlockKind::Comment, + Some(text), + Vec::new(), + Vec::new(), + Origin::User, + None, + ) + } + + // Everything else — genuinely unhandled, or not worth a dedicated shape (`Assign` + // outside a body list, raw `Tag`/`Sequence`, …): falls back to a total, panic-free + // Opaque. + other => { + let ty = self.pretty_type(other); + self.finish_block( + other, + key, + BlockKind::Opaque, + name, + Vec::new(), + vec![Port::explicit("out", ty)], + Origin::User, + None, + ) + } + } + } + + fn build_operator_like( + &mut self, + op: &Op, + lhs: &Lang, + rhs: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + match op { + Op::Dollar(_) => self.build_access(lhs, rhs, lang, key, name), + Op::Dot(_) => match rhs { + Lang::FunctionApp { + identifier, arguments, .. + } + | Lang::VecFunctionApp { + identifier, arguments, .. + } => self.build_ufcs_apply(lhs, identifier, arguments, lang, key, name), + Lang::Variable { .. } => self.build_access(lhs, rhs, lang, key, name), + _ => self.build_binary_operator(op, lhs, rhs, lang, key, name), + }, + Op::Pipe(_) => match rhs { + Lang::FunctionApp { + identifier, arguments, .. + } + | Lang::VecFunctionApp { + identifier, arguments, .. + } => self.build_ufcs_apply(lhs, identifier, arguments, lang, key, name), + other => self.build_ufcs_apply(lhs, other, &[], lang, key, name), + }, + _ => self.build_binary_operator(op, lhs, rhs, lang, key, name), + } + } + + fn build_binary_operator( + &mut self, + op: &Op, + lhs: &Lang, + rhs: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + self.build_operand(lhs, key.role("lhs"), &key, "lhs", &mut children, &mut wires); + self.build_operand(rhs, key.role("rhs"), &key, "rhs", &mut children, &mut wires); + let inputs = vec![ + Port::explicit("lhs", self.pretty_type(lhs)), + Port::explicit("rhs", self.pretty_type(rhs)), + ]; + let out_ty = self.pretty_type(lang); + // An anonymous operator is titled by its symbol (`+`, `&&`…) rather than by its key. + let name = name.or_else(|| Some(op.to_string())); + self.finish_block( + lang, + key, + BlockKind::Operator, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires }), + ) + } + + fn build_access(&mut self, lhs: &Lang, rhs: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + self.build_operand(lhs, key.role("value"), &key, "value", &mut children, &mut wires); + let field_name = variable_name(rhs).unwrap_or_else(|| "field".to_string()); + let inputs = vec![Port::explicit("value", self.pretty_type(lhs))]; + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Access, + name, + inputs, + vec![Port::explicit(field_name, out_ty)], + Origin::User, + Some(Body { children, wires }), + ) + } + + /// `lhs.f(args)` / `lhs |> f(args)`: UFCS desugars to `f(lhs, args)` at this level already + /// (the parser keeps `.`/`|>` as plain operators, spec §2's note), so the graph builds it + /// directly as the `Apply` it becomes rather than as a nested `Access` + call. + fn build_ufcs_apply( + &mut self, + receiver: &Lang, + callee: &Lang, + rest: &[Lang], + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = vec![Port::explicit("callee", None)]; + match callee { + Lang::Variable { name: callee_name, .. } => { + let arg0_ty = self.recorded_type(receiver); + if let Some((target, confidence)) = self.resolve_definition(callee_name, arg0_ty.as_ref()) { + if self.callee_is_generic(callee_name, arg0_ty.as_ref()) && !target.as_str().starts_with("std:") { + self.graph.relations.push(Relation::instantiates( + key.clone(), + target.clone(), + confidence.clone(), + )); + } + self.graph + .relations + .push(Relation::r#ref(key.clone(), "callee", target, confidence)); + } + } + _ => self.build_operand(callee, key.role("callee"), &key, "callee", &mut children, &mut wires), + } + let all_args: Vec<&Lang> = std::iter::once(receiver).chain(rest.iter()).collect(); + for (i, arg) in all_args.iter().enumerate() { + let port = format!("arg{i}"); + inputs.push(Port::explicit(&port, self.pretty_type(arg))); + self.build_operand(arg, key.role(&port), &key, &port, &mut children, &mut wires); + } + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Apply, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires }), + ) + } + + fn build_apply( + &mut self, + identifier: &Lang, + arguments: &[Lang], + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = vec![Port::explicit("callee", None)]; + match identifier { + Lang::Variable { name: callee_name, .. } => { + let arg0_ty = arguments.first().and_then(|a| self.recorded_type(a)); + if let Some((target, confidence)) = self.resolve_definition(callee_name, arg0_ty.as_ref()) { + if self.callee_is_generic(callee_name, arg0_ty.as_ref()) && !target.as_str().starts_with("std:") { + self.graph.relations.push(Relation::instantiates( + key.clone(), + target.clone(), + confidence.clone(), + )); + } + self.graph + .relations + .push(Relation::r#ref(key.clone(), "callee", target, confidence)); + } + } + _ => self.build_operand( + identifier, + key.role("callee"), + &key, + "callee", + &mut children, + &mut wires, + ), + } + for (i, arg) in arguments.iter().enumerate() { + let port = format!("arg{i}"); + inputs.push(Port::explicit(&port, self.pretty_type(arg))); + self.build_operand(arg, key.role(&port), &key, &port, &mut children, &mut wires); + } + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Apply, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires }), + ) + } + + /// Whether `name` resolves (top-level bindings only — a local function isn't in the + /// `Context`) to a function type with type parameters. + fn callee_is_generic(&self, name: &str, arg0: Option<&Type>) -> bool { + matches!(super::refs::resolve_by_name(self.context, name, arg0), + Some((ty @ Type::Function(..), _)) if ty.has_generic()) + } + + fn build_function( + &mut self, + parameters: &[ArgumentType], + return_type: &Type, + body: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + self.scope.push_boundary(key.clone()); + let mut inputs = Vec::new(); + for p in parameters { + let pname = safe_argument_name(p); + self.scope.bind( + &pname, + PortRef { + block: key.clone(), + port: pname.clone(), + }, + ); + inputs.push(Port::explicit(pname, Some(p.get_type().pretty()))); + } + let stmts = body_statements(body); + let children = self.build_nested_body(&stmts, &key); + self.scope.pop(); + let (captured, refs) = self.take_captures(&key); + inputs.extend(captured); + self.graph.relations.extend(refs); + self.finish_block( + lang, + key, + BlockKind::Function, + name, + inputs, + vec![Port::explicit("out", Some(return_type.pretty()))], + Origin::User, + Some(Body { + children, + wires: Vec::new(), + }), + ) + } + + fn build_lambda( + &mut self, + parameters: &[Lang], + body: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + self.scope.push_boundary(key.clone()); + let mut inputs = Vec::new(); + for (i, p) in parameters.iter().enumerate() { + let pname = variable_name(p).unwrap_or_else(|| format!("arg{i}")); + self.scope.bind( + &pname, + PortRef { + block: key.clone(), + port: pname.clone(), + }, + ); + inputs.push(Port::explicit(pname, self.pretty_type(p))); + } + let stmts = body_statements(body); + let children = self.build_nested_body(&stmts, &key); + self.scope.pop(); + let (captured, refs) = self.take_captures(&key); + inputs.extend(captured); + self.graph.relations.extend(refs); + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Function, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { + children, + wires: Vec::new(), + }), + ) + } + + fn build_record(&mut self, fields: &[ArgumentValue], lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = Vec::new(); + let mut outputs = vec![Port::explicit("out", self.pretty_type(lang))]; + for field in fields { + let field_name = field.get_argument(); + let value = field.get_value(); + let ty = self.pretty_type(&value); + inputs.push(Port::explicit(field_name.clone(), ty.clone())); + outputs.push(Port::explicit(field_name.clone(), ty)); + self.build_operand( + &value, + key.named(&field_name), + &key, + &field_name, + &mut children, + &mut wires, + ); + } + self.finish_block( + lang, + key, + BlockKind::Record, + name, + inputs, + outputs, + Origin::User, + Some(Body { children, wires }), + ) + } + + fn build_alias( + &mut self, + _identifier: &Lang, + target_type: &Type, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + match target_type { + Type::Interface(methods, _) => self.build_interface(methods, lang, key, name), + other => self.build_type_decl(other, lang, key, name), + } + } + + fn build_interface( + &mut self, + methods: &HashSet, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + let mut children = Vec::new(); + for m in sorted_members(methods) { + children.push(self.insert_type_expr_leaf(&key, m)); + } + self.finish_block( + lang, + key, + BlockKind::Interface, + name, + Vec::new(), + Vec::new(), + Origin::User, + Some(Body { + children, + wires: Vec::new(), + }), + ) + } + + fn build_type_decl(&mut self, target_type: &Type, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + if let Type::Record(fields, _) = target_type { + for f in sorted_members(fields) { + children.push(self.insert_type_expr_leaf(&key, f)); + } + } + let mut outputs = vec![Port::explicit("out", self.pretty_type(lang))]; + if let Some(type_name) = &name { + // Q4: a TypeDecl's interface is its methods — functions taking it as their 1st + // parameter, discovered via `TypePosition{index: 0}` rather than a declared `impl`. + let methods = discover_methods(self.context, type_name); + for (method_name, pretty, method_key) in methods { + outputs.push(Port::explicit(method_name, Some(pretty))); + self.graph + .relations + .push(Relation::type_position(method_key, key.clone(), 0)); + } + } + self.finish_block( + lang, + key, + BlockKind::TypeDecl, + name, + Vec::new(), + outputs, + Origin::User, + Some(Body { + children, + wires: Vec::new(), + }), + ) + } + + fn insert_type_expr_leaf(&mut self, parent: &BlockKey, member: &ArgumentType) -> BlockKey { + let member_name = safe_argument_name(member); + let child_key = parent.named(&member_name); + let ty = member.get_type(); + self.graph.insert(Block { + key: child_key.clone(), + kind: BlockKind::TypeExpr, + name: Some(member_name), + span: None, + r#type: Some(ty.pretty()), + inputs: Vec::new(), + outputs: vec![Port::explicit("out", Some(ty.pretty()))], + origin: Origin::User, + body: None, + }); + child_key + } + + fn build_if( + &mut self, + condition: &Lang, + if_block: &Lang, + else_block: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + self.build_operand(condition, key.role("cond"), &key, "cond", &mut children, &mut wires); + // `then`/`else` are always materialized (never elided as a bare-variable read) so both + // branches stay independently explorable, per the catalog's "chacun explorable". + let then_key = key.role("then"); + self.build_expr(if_block, then_key.clone(), None); + children.push(then_key); + let else_key = key.role("else"); + self.build_expr(else_block, else_key.clone(), None); + children.push(else_key); + let inputs = vec![Port::explicit("cond", self.pretty_type(condition))]; + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::If, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires }), + ) + } + + fn build_sequence( + &mut self, + kind: BlockKind, + elements: &[Lang], + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = Vec::new(); + let mut outputs = vec![Port::explicit("out", self.pretty_type(lang))]; + for (i, el) in elements.iter().enumerate() { + let port = format!("elem{i}"); + let ty = self.pretty_type(el); + inputs.push(Port::explicit(&port, ty.clone())); + if kind == BlockKind::Tuple { + outputs.push(Port::explicit(&port, ty)); + } + self.build_operand(el, key.role(&port), &key, &port, &mut children, &mut wires); + } + self.finish_block( + lang, + key, + kind, + name, + inputs, + outputs, + Origin::User, + Some(Body { children, wires }), + ) + } + + /// `module Name { ... }` (spec §4, §4.1, étape 5). A capture boundary like `Function`, whose + /// members (`Let`/`Alias`, `@pub` or not) are pre-bound before any of them is built so they + /// can forward/mutually reference each other — the same two-pass shape as the program root + /// itself (spec §7.1 step 2). Every member is a body child (`is_public` or not — "le secret + /// se voit de l'intérieur"), but only `@pub` members get an output port: that's the only + /// place privacy is actually enforced, since a private member simply isn't reachable from + /// outside the module. + fn build_module( + &mut self, + _module_name: &str, + members: &[Lang], + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + self.scope.push_boundary(key.clone()); + + for item in members { + if let Some((_, member_name)) = top_level_name(item) { + self.scope.bind( + &member_name, + PortRef { + block: key.named(&member_name), + port: "out".to_string(), + }, + ); + } + } + + let mut children = Vec::new(); + let mut outputs = Vec::new(); + for (index, item) in members.iter().enumerate() { + match top_level_name(item) { + Some((_, member_name)) => { + let member_key = key.named(&member_name); + let expr = match item { + Lang::Let { expression, .. } => expression.as_ref(), + _ => item, + }; + if is_public_member(item) { + outputs.push(Port::explicit(member_name.clone(), self.pretty_type(expr))); + } + self.build_expr(expr, member_key.clone(), Some(&member_name)); + self.note_declared_type(&member_key, item); + children.push(member_key); + } + None => { + let member_key = key.anonymous(index); + self.build_expr(item, member_key.clone(), None); + children.push(member_key); + } + } + } + + self.scope.pop(); + let (captured, refs) = self.take_captures(&key); + self.graph.relations.extend(refs); + self.finish_block( + lang, + key, + BlockKind::Module, + name, + captured, + outputs, + Origin::User, + Some(Body { + children, + wires: Vec::new(), + }), + ) + } + + /// `for i in xs { ... }`: the iterable feeds a port named after the loop variable itself + /// (mirroring a `Function` parameter — the loop variable's port *is* its binding inside the + /// body), plus the state-port pair of every outer variable reassigned in the loop (spec + /// §4.1 Q3, `build_loop_state_and_body`). + fn build_for_loop( + &mut self, + identifier: &Var, + expression: &Lang, + body: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + let var_name = identifier.get_name(); + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = vec![Port::explicit(&var_name, self.pretty_type(expression))]; + self.build_operand( + expression, + key.role(&var_name), + &key, + &var_name, + &mut children, + &mut wires, + ); + + let outputs = self.build_loop_state_and_body( + body, + &key, + Some((&var_name, &var_name)), + &mut inputs, + &mut wires, + &mut children, + ); + self.finish_block( + lang, + key, + BlockKind::Loop, + name, + inputs, + outputs, + Origin::User, + Some(Body { children, wires }), + ) + } + + /// `while (cond) { ... }`: the condition feeds a `cond` input port (same convention as + /// `If`), re-read every iteration — no loop-variable binding, unlike `ForLoop`. + fn build_while_loop( + &mut self, + condition: &Lang, + body: &Lang, + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = vec![Port::explicit("cond", self.pretty_type(condition))]; + self.build_operand(condition, key.role("cond"), &key, "cond", &mut children, &mut wires); + + let outputs = self.build_loop_state_and_body(body, &key, None, &mut inputs, &mut wires, &mut children); + self.finish_block( + lang, + key, + BlockKind::Loop, + name, + inputs, + outputs, + Origin::User, + Some(Body { children, wires }), + ) + } + + /// `loop { ... }`: no condition or iterable at all, just the state-port pairs (the loop is + /// only ever left via `break`, not modeled as a port here — spec §7 doesn't ask for it). + fn build_bare_loop(&mut self, body: &Lang, lang: &Lang, key: BlockKey, name: Option) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + let mut inputs = Vec::new(); + let outputs = self.build_loop_state_and_body(body, &key, None, &mut inputs, &mut wires, &mut children); + self.finish_block( + lang, + key, + BlockKind::Loop, + name, + inputs, + outputs, + Origin::User, + Some(Body { children, wires }), + ) + } + + /// Shared tail of the three loop shapes (spec §4.1 Q3): every outer variable reassigned by a + /// bare `x <- expr;` directly among the loop's own top-level statements becomes a paired + /// `{name}_in`/`{name}_out` port — entering, it's what the body reads; leaving, it's + /// whatever the last reassignment inside the loop wrote (or unchanged, if the loop body + /// never ran — not distinguished here, same "no version label" stance as a plain + /// reassignment, spec §4.1). `lead_binding` is `ForLoop`'s extra local name (the loop + /// variable), bound in the same fresh frame as the state-entering ports before the body is + /// walked. + /// + /// Simplification, in the same spirit as the capture-boundary one (`scope.rs`): only a + /// direct `Assign` among the loop's immediate statements is seen, not one nested inside an + /// `if` inside the loop — revisit if a real case needs it. + fn build_loop_state_and_body( + &mut self, + body: &Lang, + key: &BlockKey, + lead_binding: Option<(&str, &str)>, + inputs: &mut Vec, + wires: &mut Vec, + children: &mut Vec, + ) -> Vec { + let stmts = body_statements(body); + let mut state_names = Vec::new(); + for stmt in &stmts { + if let Lang::Assign { identifier, .. } = stmt { + if let Some(n) = variable_name(identifier) { + if self.scope.resolve(&n).is_some() && !state_names.contains(&n) { + state_names.push(n); + } + } + } + } + + for n in &state_names { + let in_port = format!("{n}_in"); + let src = self.resolve_and_wire(n, None); + inputs.push(Port::implicit(in_port.clone(), None)); + wires.push(Wire { + from: src, + to: PortRef { + block: key.clone(), + port: in_port, + }, + }); + } + + self.scope.push_plain(); + if let Some((bind_name, port)) = lead_binding { + self.scope.bind( + bind_name, + PortRef { + block: key.clone(), + port: port.to_string(), + }, + ); + } + for n in &state_names { + self.scope.bind( + n, + PortRef { + block: key.clone(), + port: format!("{n}_in"), + }, + ); + } + children.extend(self.build_nested_body(&stmts, key)); + + let mut outputs = Vec::with_capacity(state_names.len()); + for n in &state_names { + let (final_port, _) = self + .scope + .resolve(n) + .expect("a name just bound in this frame resolves in it"); + outputs.push(Port::implicit(format!("{n}_out"), None)); + wires.push(Wire { + from: final_port, + to: PortRef { + block: key.clone(), + port: format!("{n}_out"), + }, + }); + } + self.scope.pop(); + outputs + } + + /// `match target { pattern => body, ... }` (spec §4, §13). Each branch is its own explorable + /// sub-block: whatever names its pattern binds become that sub-block's own input ports (the + /// open question in §13 — "un sous-bloc par bras, dont les liaisons du motif sont les + /// entrées" — resolved that way), with the sub-block's body wired up exactly like a + /// function's or scope's. The pattern itself isn't rendered as a block, only the names it + /// introduces. + fn build_match( + &mut self, + target: &Lang, + branches: &[(Lang, Box)], + lang: &Lang, + key: BlockKey, + name: Option, + ) -> PortRef { + let mut children = Vec::new(); + let mut wires = Vec::new(); + self.build_operand(target, key.role("target"), &key, "target", &mut children, &mut wires); + let inputs = vec![Port::explicit("target", self.pretty_type(target))]; + + for (index, (pattern, body)) in branches.iter().enumerate() { + let branch_key = key.role(&format!("branch{index}")); + let bindings = pattern_bindings(pattern); + + self.scope.push_plain(); + let mut branch_inputs = Vec::with_capacity(bindings.len()); + for n in &bindings { + self.scope.bind( + n, + PortRef { + block: branch_key.clone(), + port: n.clone(), + }, + ); + branch_inputs.push(Port::implicit(n.clone(), None)); + } + let stmts = body_statements(body); + let branch_children = self.build_nested_body(&stmts, &branch_key); + self.scope.pop(); + + let branch_out_ty = self.pretty_type(body); + self.graph.insert(Block { + key: branch_key.clone(), + kind: BlockKind::Scope, + name: None, + span: Some(span_of(body)), + r#type: branch_out_ty.clone(), + inputs: branch_inputs, + outputs: vec![Port::explicit("out", branch_out_ty)], + origin: Origin::User, + body: Some(Body { + children: branch_children, + wires: Vec::new(), + }), + }); + children.push(branch_key); + } + + let out_ty = self.pretty_type(lang); + self.finish_block( + lang, + key, + BlockKind::Match, + name, + inputs, + vec![Port::explicit("out", out_ty)], + Origin::User, + Some(Body { children, wires }), + ) + } +} + +/// Whether a module member's own `@pub` flag is set (spec §4.1: "un module n'expose que ses +/// membres `@pub`"). +fn is_public_member(item: &Lang) -> bool { + match item { + Lang::Let { is_public, .. } => *is_public, + Lang::Alias { is_public, .. } => *is_public, + _ => false, + } +} + +/// The names a match-branch pattern binds, mirroring `match_expression::build_match_branch_context` +/// (the type checker's own pattern-binding pass) but collecting names only — no types, no +/// errors: this pass runs after type-checking already accepted the program. +fn pattern_bindings(pattern: &Lang) -> Vec { + match pattern { + Lang::Tag { value: inner, .. } => match inner.as_ref() { + Lang::Variable { name, .. } if name != "_" => vec![name.clone()], + _ => Vec::new(), + }, + Lang::TypePattern { + variable_name: name, .. + } => vec![name.clone()], + Lang::Tuple { value: elements, .. } => elements + .iter() + .filter_map(|e| match e { + Lang::Variable { name, .. } if name != "_" => Some(name.clone()), + _ => None, + }) + .collect(), + Lang::List { value: fields, .. } => fields + .iter() + .filter_map(|f| match f.get_value() { + Lang::Variable { name, .. } if name != "_" => Some(name), + _ => None, + }) + .collect(), + Lang::Variable { name, .. } if name != "_" => vec![name.clone()], + _ => Vec::new(), + } +} + +fn body_statements_from_vec(body: &[Lang]) -> Vec<&Lang> { + body.iter().collect() +} + +#[cfg(test)] +mod tests { + use crate::key::Namespace; + use crate::model::{BlockGraph, BlockKind, RelationKind}; + use crate::BlockKey; + use typr_core::components::context::Context; + use typr_core::processes::parsing::parse_from_string; + use typr_core::processes::type_checking::type_recorder::with_recording; + use typr_core::processes::type_checking::typing_with_errors; + + fn build_graph(source: &str) -> BlockGraph { + let lang = parse_from_string(source, "blocks_test"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + assert!(!result.has_errors(), "{:?}", result.display_errors()); + crate::build(&lang, &result.type_context.context, &table) + } + + #[test] + fn calling_a_generic_function_emits_instantiates_but_a_concrete_one_does_not() { + let graph = build_graph( + r#" +let id <- fn(x: T): T { x }; +let inc <- fn(x: int): int { x + 1 }; +let a <- id(1); +let b <- inc(1); +"#, + ); + let id = BlockKey::top_level(Namespace::Val, "id"); + let inc = BlockKey::top_level(Namespace::Val, "inc"); + let inst = |to: &BlockKey| { + graph + .relations + .iter() + .any(|r| r.kind == RelationKind::Instantiates && r.to == *to) + }; + assert!(inst(&id), "{:#?}", graph.relations); + assert!(!inst(&inc)); + } + + // `while`'s own condition parser (`single_element`) can't take a bare comparison — this is a + // pre-existing `typr-core` parser quirk, unrelated to this crate, that double parens work + // around; not this crate's bug to fix. + #[test] + fn while_loop_gets_state_in_out_ports_for_reassigned_outer_variables() { + let graph = build_graph( + r#" +let total <- fn(seed: int): int { + let acc <- seed; + let i <- 0; + while ((i < 3)) { + acc <- acc + i; + i <- i + 1; + }; + acc +}; +"#, + ); + let loop_key = BlockKey::top_level(Namespace::Val, "total").role("#2"); + let block = graph + .blocks + .get(&loop_key) + .expect("the while loop is built as a top-level function's 3rd body statement"); + assert_eq!(block.kind, BlockKind::Loop); + let input_names: Vec<_> = block.inputs.iter().map(|p| p.name.as_str()).collect(); + assert!(input_names.contains(&"acc_in"), "{input_names:?}"); + assert!(input_names.contains(&"i_in"), "{input_names:?}"); + let output_names: Vec<_> = block.outputs.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(output_names, vec!["acc_out", "i_out"]); + } + + #[test] + fn for_loop_binds_its_variable_to_a_port_named_after_it() { + let graph = build_graph( + r#" +let total <- fn(seed: int): int { + let acc <- seed; + for (i in [1, 2, 3]) { + acc <- acc + i; + }; + acc +}; +"#, + ); + let loop_key = BlockKey::top_level(Namespace::Val, "total").role("#1"); + let block = graph + .blocks + .get(&loop_key) + .expect("the for loop is built as a top-level function's 2nd body statement"); + assert_eq!(block.kind, BlockKind::Loop); + let input_names: Vec<_> = block.inputs.iter().map(|p| p.name.as_str()).collect(); + assert!(input_names.contains(&"i"), "{input_names:?}"); + assert!(input_names.contains(&"acc_in"), "{input_names:?}"); + assert_eq!( + block.outputs.iter().map(|p| p.name.as_str()).collect::>(), + vec!["acc_out"] + ); + } + + #[test] + fn match_branch_gets_its_pattern_bindings_as_its_own_input_ports() { + let graph = build_graph( + r#" +type Shape <- .Circle(int) | .Square(int); +let area <- fn(s: Shape): int { + match s { + .Circle(r) => r * r, + .Square(side) => side * side, + } +}; +"#, + ); + let match_key = BlockKey::top_level(Namespace::Val, "area").role("#0"); + let block = graph + .blocks + .get(&match_key) + .expect("the match is the function's sole body statement"); + assert_eq!(block.kind, BlockKind::Match); + assert_eq!( + block.inputs.iter().map(|p| p.name.as_str()).collect::>(), + vec!["target"] + ); + + let branch0 = graph + .blocks + .get(&match_key.role("branch0")) + .expect("first branch sub-block"); + assert_eq!( + branch0.inputs.iter().map(|p| p.name.as_str()).collect::>(), + vec!["r"] + ); + let branch1 = graph + .blocks + .get(&match_key.role("branch1")) + .expect("second branch sub-block"); + assert_eq!( + branch1.inputs.iter().map(|p| p.name.as_str()).collect::>(), + vec!["side"] + ); + } + + #[test] + fn module_exposes_only_pub_members_as_outputs_but_keeps_all_of_them_in_its_body() { + let graph = build_graph( + r#" +module Reporter { + let helper <- fn(x: int): int { x + 1 }; + @pub let describe <- fn(x: int): int { helper(x) }; +}; +"#, + ); + let module_key = BlockKey::top_level(Namespace::Val, "Reporter"); + let block = graph + .blocks + .get(&module_key) + .expect("the module is built at the top level, named after itself"); + assert_eq!(block.kind, BlockKind::Module); + assert_eq!( + block.outputs.iter().map(|p| p.name.as_str()).collect::>(), + vec!["describe"] + ); + assert!(block + .body + .as_ref() + .unwrap() + .children + .contains(&module_key.named("helper"))); + assert!(block + .body + .as_ref() + .unwrap() + .children + .contains(&module_key.named("describe"))); + } + + #[test] + fn r_block_is_a_total_opaque_boundary_with_no_inputs() { + let graph = build_graph("let total <- R { sum(c(1, 2, 3)) };\n"); + let block = graph + .blocks + .get(&BlockKey::top_level(Namespace::Val, "total")) + .expect("top-level R block"); + assert_eq!(block.kind, BlockKind::RCode); + assert!(block.inputs.is_empty()); + assert!(block.body.is_none()); + } + + #[test] + fn comment_is_its_own_block_named_after_its_text() { + let graph = build_graph("# hello world\nlet a <- 1;\n# second\nlet b <- a;\n"); + let comments: Vec<_> = graph.blocks.values().filter(|b| b.kind == BlockKind::Comment).collect(); + let names: Vec<_> = comments.iter().filter_map(|b| b.name.as_deref()).collect(); + assert!(names.contains(&"hello world"), "{names:?}"); + assert!(names.contains(&"second"), "{names:?}"); + assert!(comments.iter().all(|b| b.outputs.is_empty() && b.inputs.is_empty())); + assert!(comments.iter().all(|b| b.r#type.is_none()), "a comment has no type"); + assert!( + !graph + .relations + .iter() + .any(|r| r.kind == RelationKind::HasType && comments.iter().any(|b| b.key == r.from)), + "a comment must not be linked to `char`" + ); + assert!(!graph + .blocks + .values() + .any(|b| b.kind == BlockKind::Opaque && b.name.as_deref() == Some("hello world"))); + } +} diff --git a/crates/typr-graph/src/build/mod.rs b/crates/typr-graph/src/build/mod.rs new file mode 100644 index 00000000..0c0d88b2 --- /dev/null +++ b/crates/typr-graph/src/build/mod.rs @@ -0,0 +1,476 @@ +//! Turns `Lang` + the final `Context` + a recorded `TypeTable` into a [`BlockGraph`] (spec §7.1). + +mod blocks; +mod refs; +mod scope; +mod type_relations; + +use crate::key::{BlockKey, Namespace}; +use crate::model::*; +use scope::ScopeStack; +use std::collections::HashMap; +use typr_core::components::context::Context; +use typr_core::components::language::Lang; +use typr_core::components::r#type::type_system::TypeSystem; +use typr_core::components::r#type::Type; +use typr_core::processes::type_checking::type_recorder::{SpanKey, TypeTable}; + +pub fn build(lang: &Lang, context: &Context, types: &TypeTable) -> BlockGraph { + let root_key = BlockKey::program_root(); + let mut b = Builder { + context, + types, + graph: BlockGraph::new(root_key.clone()), + scope: ScopeStack::new(), + pending_captures: HashMap::new(), + reference_blocks: HashMap::new(), + type_notes: Vec::new(), + }; + b.scope.push_boundary(root_key.clone()); + + let items = top_level_items(lang); + + // Pre-pass: every top-level declaration's name is registered before any body is built, so a + // forward or mutual reference still resolves (spec §7.1 step 2). + for item in &items { + if let Some((namespace, name)) = top_level_name(item) { + let key = BlockKey::top_level(namespace, &name); + b.scope.bind( + &name, + PortRef { + block: key, + port: "out".to_string(), + }, + ); + } + } + + let mut children = Vec::new(); + for (index, item) in items.iter().enumerate() { + b.build_top_level_item(item, index, &mut children); + } + + type_relations::build(&mut b.graph, b.context, &items); + let primitives = type_relations::has_type_links(&mut b.graph, &b.type_notes); + children.extend(primitives); + + b.scope.pop(); + let (inputs, refs) = b.take_captures(&root_key); + b.graph.relations.extend(refs); + b.graph.insert(Block { + key: root_key.clone(), + kind: BlockKind::Program, + name: None, + span: None, + r#type: None, + inputs, + outputs: Vec::new(), + origin: Origin::User, + body: Some(Body { + children, + wires: Vec::new(), + }), + }); + b.graph +} + +fn top_level_items(lang: &Lang) -> Vec<&Lang> { + match lang { + Lang::Lines { value, .. } => value.iter().collect(), + other => vec![other], + } +} + +fn top_level_name(item: &Lang) -> Option<(Namespace, String)> { + match item { + Lang::Let { variable, .. } => variable_name(variable).map(|n| (Namespace::Val, n)), + Lang::Alias { identifier, .. } => variable_name(identifier).map(|n| (Namespace::Type, n)), + // `module Name { ... }` (spec §4, étape 5) parses straight to `Lang::Module`, never + // wrapped in a `Let` — it names itself directly, in the value namespace (it's accessed + // like a record, via `$`/`use`). + Lang::Module { name, .. } => Some((Namespace::Val, name.clone())), + _ => None, + } +} + +pub(super) fn variable_name(lang: &Lang) -> Option { + match lang { + Lang::Variable { name, .. } => Some(name.clone()), + Lang::TypeConstructor { name, .. } => Some(name.clone()), + _ => None, + } +} + +pub(super) fn span_of(lang: &Lang) -> Span { + let h = lang.get_help_data(); + Span { + file: h.get_file_name(), + start: h.get_offset(), + end: h.get_end(), + } +} + +/// The outcome of resolving a free name (spec §5.3): where it's really defined, how confident +/// that resolution is, and which boundary (if any) it crosses to get from there to here. +struct Resolved { + target: PortRef, + confidence: Confidence, + crosses: Option, +} + +struct Builder<'a> { + context: &'a Context, + types: &'a TypeTable, + graph: BlockGraph, + scope: ScopeStack, + /// Captures discovered so far for a boundary block that hasn't been finalized yet (it's + /// still being walked) — folded into its `inputs`/`Ref` relations by [`Builder::take_captures`] + /// once its own body is fully built. + pending_captures: HashMap)>>, + /// Memoizes synthesized leaf blocks for names resolved outside the program (stdlib, R + /// packages) so the same name doesn't get a fresh block every time it's referenced. + reference_blocks: HashMap, + /// Type texts a block is tied to that its own `type` field doesn't show: the annotation of a + /// `let` (the recorded type is the value's, e.g. the literal `"hi"`, not the declared `char`) + /// and the primitive behind a literal. Scanned by `type_relations::has_type_links`. + type_notes: Vec<(BlockKey, String)>, +} + +impl<'a> Builder<'a> { + fn recorded_type(&self, lang: &Lang) -> Option { + self.types.get(&SpanKey::from(&lang.get_help_data())).cloned() + } + + /// Remembers a `let`'s written annotation (`let m: char <- …`) so the block is tied to it. + fn note_declared_type(&mut self, key: &BlockKey, item: &Lang) { + if let Lang::Let { r#type, .. } = item { + if !matches!(r#type, Type::Empty(_)) { + self.type_notes.push((key.clone(), r#type.pretty())); + } + } + } + + fn pretty_type(&self, lang: &Lang) -> Option { + self.recorded_type(lang).map(|t| t.pretty()) + } + + fn build_top_level_item(&mut self, item: &Lang, index: usize, children: &mut Vec) { + // `x <- expr;` outside a loop (spec §4.1's "Assign hors boucle"): not a fresh + // declaration, so it gets an anonymous positional key like any unnamed statement, but + // rebinds `x` going forward — later top-level reads of `x` wire from this block, not + // from whatever defined it before. + if let Lang::Assign { + identifier, expression, .. + } = item + { + if let Some(reassigned) = variable_name(identifier) { + let key = BlockKey::top_level(Namespace::Val, &format!("#{index}")); + let port = self.build_expr(expression, key.clone(), Some(&reassigned)); + self.scope.bind(&reassigned, port); + children.push(key); + return; + } + } + match top_level_name(item) { + Some((namespace, name)) => { + let key = BlockKey::top_level(namespace, &name); + let expr = match item { + Lang::Let { expression, .. } => expression.as_ref(), + _ => item, + }; + self.build_expr(expr, key.clone(), Some(&name)); + self.note_declared_type(&key, item); + children.push(key); + } + None => { + let key = BlockKey::top_level(Namespace::Val, &format!("#{index}")); + self.build_expr(item, key.clone(), None); + children.push(key); + } + } + } + + /// A statement list belonging to some enclosing block `owner` (a function body, a bare + /// `{ ... }` scope): named lets are keyed and bound under `owner`, everything else gets an + /// anonymous positional key (spec §6). + fn build_nested_body(&mut self, stmts: &[&Lang], owner: &BlockKey) -> Vec { + let mut children = Vec::new(); + for (index, stmt) in stmts.iter().enumerate() { + match stmt { + Lang::Let { + variable, expression, .. + } => match variable_name(variable) { + Some(name) => { + let key = owner.named(&name); + let port = self.build_expr(expression, key.clone(), Some(&name)); + self.note_declared_type(&key, stmt); + self.scope.bind(&name, port); + children.push(key); + } + None => { + // Pattern-destructuring let: not modeled yet (étape 1 keeps bindings + // simple); built anonymously so nothing is lost, just unnamed. + let key = owner.anonymous(index); + self.build_expr(expression, key.clone(), None); + children.push(key); + } + }, + // `x <- expr;` (spec §4.1's "Assign hors boucle", also the mechanism behind a + // `Loop`'s state ports, étape 5): rebinds `x` going forward, same as a `Let`, + // but keyed anonymously since the name is already claimed by its original + // declaration. + Lang::Assign { + identifier, expression, .. + } => { + let key = owner.anonymous(index); + match variable_name(identifier) { + Some(reassigned) => { + let port = self.build_expr(expression, key.clone(), Some(&reassigned)); + self.scope.bind(&reassigned, port); + } + None => { + self.build_expr(expression, key.clone(), None); + } + } + children.push(key); + } + other => { + let key = owner.anonymous(index); + self.build_expr(other, key.clone(), None); + children.push(key); + } + } + } + children + } + + /// Builds `lang` as an operand feeding `owner`'s `port` input. A bare name reference doesn't + /// get its own block (spec §3.2's "un fil... à l'intérieur d'un même bloc parent" collapses a + /// pure variable read into a direct wire from its definition); anything else recurses into a + /// real child block first. + fn build_operand( + &mut self, + lang: &Lang, + key: BlockKey, + owner: &BlockKey, + port: &str, + children: &mut Vec, + wires: &mut Vec, + ) { + let src = match lang { + Lang::Variable { name, .. } => self.resolve_and_wire(name, None), + _ => { + let src = self.build_expr(lang, key.clone(), None); + children.push(key); + src + } + }; + wires.push(Wire { + from: src, + to: PortRef { + block: owner.clone(), + port: port.to_string(), + }, + }); + } + + fn resolve_name(&mut self, name: &str, arg0: Option<&Type>) -> Option { + if let Some((port, is_local)) = self.scope.resolve(name) { + let crosses = if is_local { None } else { self.scope.nearest_boundary() }; + return Some(Resolved { + target: port, + confidence: Confidence::Exact, + crosses, + }); + } + let (ty, confidence) = refs::resolve_by_name(self.context, name, arg0)?; + let target_key = self.ensure_reference_block(name, &ty); + Some(Resolved { + target: PortRef { + block: target_key, + port: "out".to_string(), + }, + confidence, + crosses: self.scope.nearest_boundary(), + }) + } + + /// Resolves a name for a **reference** (e.g. `Apply.callee`, spec Q2): the `Ref` returned + /// always points straight at the real definition, never at a capture port — a call always + /// "goes to the definition". But the name is still genuinely free in the enclosing boundary's + /// interior, so per Q1 that boundary still gets an honest implicit port for it (with its own + /// separate boundary-level `Ref`) even though nothing wires through it for a call. + fn resolve_definition(&mut self, name: &str, arg0: Option<&Type>) -> Option<(BlockKey, Confidence)> { + let resolved = self.resolve_name(name, arg0)?; + if let Some(boundary) = resolved.crosses.clone() { + self.ensure_capture_port( + boundary, + name, + resolved.target.clone(), + resolved.confidence.clone(), + None, + ); + } + Some((resolved.target.block, resolved.confidence)) + } + + /// Resolves a name for a **value read** (a bare variable used as data): if it crosses a + /// boundary, wires through that boundary's capture port instead of reaching past it directly + /// (spec §3.3). + fn resolve_and_wire(&mut self, name: &str, arg0: Option<&Type>) -> PortRef { + match self.resolve_name(name, arg0) { + Some(Resolved { + target, + confidence, + crosses: Some(boundary), + }) => { + self.ensure_capture_port(boundary.clone(), name, target, confidence, None); + PortRef { + block: boundary, + port: name.to_string(), + } + } + Some(Resolved { + target, crosses: None, .. + }) => target, + None => { + let key = self.ensure_unresolved_block(name); + PortRef { + block: key, + port: "out".to_string(), + } + } + } + } + + fn ensure_reference_block(&mut self, name: &str, ty: &Type) -> BlockKey { + let cache_key = format!("std:{name}"); + if let Some(key) = self.reference_blocks.get(&cache_key) { + return key.clone(); + } + let key = BlockKey::top_level(Namespace::Std, name); + self.graph.insert(Block { + key: key.clone(), + kind: BlockKind::Opaque, + name: Some(name.to_string()), + span: None, + r#type: Some(ty.pretty()), + inputs: Vec::new(), + outputs: vec![Port::explicit("out", Some(ty.pretty()))], + origin: Origin::Std, + body: None, + }); + self.reference_blocks.insert(cache_key, key.clone()); + key + } + + /// A name that type-checking accepted but this best-effort by-name pass couldn't find at all + /// (should not happen on a program that passed `typr check` — kept only so the builder stays + /// total rather than panicking on a gap in this pass). + fn ensure_unresolved_block(&mut self, name: &str) -> BlockKey { + let cache_key = format!("unresolved:{name}"); + if let Some(key) = self.reference_blocks.get(&cache_key) { + return key.clone(); + } + let key = BlockKey::top_level(Namespace::Std, &format!("@unresolved-{name}")); + self.graph.insert(Block { + key: key.clone(), + kind: BlockKind::Opaque, + name: Some(name.to_string()), + span: None, + r#type: None, + inputs: Vec::new(), + outputs: vec![Port::explicit("out", None)], + origin: Origin::User, + body: None, + }); + self.reference_blocks.insert(cache_key, key.clone()); + key + } + + fn ensure_capture_port( + &mut self, + boundary: BlockKey, + name: &str, + target: PortRef, + confidence: Confidence, + ty: Option, + ) { + let entry = self.pending_captures.entry(boundary).or_default(); + if entry.iter().any(|(n, ..)| n == name) { + return; + } + entry.push((name.to_string(), target, confidence, ty)); + } + + fn take_captures(&mut self, boundary: &BlockKey) -> (Vec, Vec) { + let entries = self.pending_captures.remove(boundary).unwrap_or_default(); + let mut inputs = Vec::with_capacity(entries.len()); + let mut relations = Vec::with_capacity(entries.len()); + for (name, target, confidence, ty) in entries { + inputs.push(Port::implicit(name.clone(), ty)); + relations.push(Relation::r#ref(boundary.clone(), &name, target.block, confidence)); + } + (inputs, relations) + } + + /// Common tail of every `build_*` arm: records the recorded type, links a named-alias result + /// to its `TypeDecl`/`Interface` via `HasType`, inserts the block, and returns a `PortRef` to + /// its default value port (`outputs[0]`, `"out"` by convention — except `Access`, whose sole + /// output is the field itself). + fn finish_block( + &mut self, + lang: &Lang, + key: BlockKey, + kind: BlockKind, + name: Option, + inputs: Vec, + outputs: Vec, + origin: Origin, + body: Option, + ) -> PortRef { + // A comment has no type: the parser models it as a `char` value, which must neither be + // printed on the block nor linked (`HasType`) to the `char` primitive. + let ty = if kind == BlockKind::Comment { + None + } else { + self.recorded_type(lang) + }; + // A literal's recorded type is the value (`"hi"`, `3`), which names no type block. + let primitive = match &ty { + Some(Type::Char(..)) => Some("char"), + Some(Type::Integer(..)) => Some("int"), + Some(Type::Number(..)) => Some("num"), + Some(Type::Boolean(..)) => Some("bool"), + _ => None, + }; + if let Some(p) = primitive { + self.type_notes.push((key.clone(), p.to_string())); + } + if let Some(Type::Alias(alias_name, _, _, _)) = &ty { + self.graph.relations.push(Relation::has_type( + key.clone(), + BlockKey::top_level(Namespace::Type, alias_name), + )); + } + let default_port = outputs + .first() + .map(|p| p.name.clone()) + .unwrap_or_else(|| "out".to_string()); + self.graph.insert(Block { + key: key.clone(), + kind, + name, + span: Some(span_of(lang)), + r#type: ty.map(|t| t.pretty()), + inputs, + outputs, + origin, + body, + }); + PortRef { + block: key, + port: default_port, + } + } +} diff --git a/crates/typr-graph/src/build/refs.rs b/crates/typr-graph/src/build/refs.rs new file mode 100644 index 00000000..14c08ba8 --- /dev/null +++ b/crates/typr-graph/src/build/refs.rs @@ -0,0 +1,54 @@ +//! By-name resolution against the final `Context`, for names not bound anywhere in the current +//! `Lang` walk (spec §5.3) — stdlib functions, R package functions, anything not reachable +//! through the scope stack. There is no name-resolution pass in `typr-core` to delegate to (the +//! real dispatch machinery, `Context::get_type_from_variable`/`Var::match_with`, picks a single +//! winner without reporting how many candidates it had to choose from), so this replicates just +//! enough of it to produce a `Confidence`. + +use crate::model::Confidence; +use typr_core::components::context::Context; +use typr_core::components::r#type::type_system::TypeSystem; +use typr_core::components::r#type::Type; + +fn first_param_type(ty: &Type) -> Option { + match ty { + Type::Function(params, _, _) => params.first().map(|p| p.get_type()), + _ => None, + } +} + +/// Resolves `name` against every top-level binding in `context` (stdlib included). `arg0` is the +/// type of the call's first argument, if any, used to narrow an overload set the same way +/// `Context::get_type_from_variable` does (via `is_subtype`), but without silently picking a +/// winner when it can't: that case is reported as `Ambiguous`. +pub fn resolve_by_name(context: &Context, name: &str, arg0: Option<&Type>) -> Option<(Type, Confidence)> { + let candidates: Vec<&Type> = context + .variables() + .filter(|(v, _)| v.get_name() == name) + .map(|(_, t)| t) + .collect(); + + match candidates.as_slice() { + [] => None, + [only] => Some(((*only).clone(), Confidence::Exact)), + many => { + let Some(arg0) = arg0 else { + return Some((many[0].clone(), Confidence::ByName)); + }; + let matching: Vec<&Type> = many + .iter() + .filter(|t| { + first_param_type(t) + .map(|p| arg0.is_subtype(&p, context).0 || p.is_subtype(arg0, context).0) + .unwrap_or(false) + }) + .copied() + .collect(); + match matching.as_slice() { + [only] => Some(((*only).clone(), Confidence::Exact)), + [] => Some((many[0].clone(), Confidence::ByName)), + _ => Some((many[0].clone(), Confidence::Ambiguous { candidates: Vec::new() })), + } + } + } +} diff --git a/crates/typr-graph/src/build/scope.rs b/crates/typr-graph/src/build/scope.rs new file mode 100644 index 00000000..2e158930 --- /dev/null +++ b/crates/typr-graph/src/build/scope.rs @@ -0,0 +1,80 @@ +//! Lexical scope tracking during the `Lang` walk (spec §3.3, §7.1 step 2). A "frame" is pushed +//! for every name-binding position (the `Program` root, each `Function`); a name found in the +//! current boundary's own frames is local, one found further out is a capture crossing that +//! boundary. +//! +//! Simplification (documented, not yet needed by any real case): a capture only ever attaches to +//! the *nearest* enclosing boundary, even when the definition lives further out still (e.g. a +//! `Function` nested inside another `Function`, itself referencing something from `Program`). +//! Full bubbling through every crossed boundary (spec §3.3's "les dépendances remontent +//! naturellement") is deferred until a real nested case needs it. + +use crate::model::PortRef; +use std::collections::HashMap; + +use crate::key::BlockKey; + +struct Frame { + bindings: HashMap, + /// `Some(key)` if this frame is a capture boundary (the block `key` owns captures crossing + /// into it); `None` for a plain nested scope (e.g. an `if` branch) that shares its parent's + /// boundary. + boundary: Option, +} + +#[derive(Default)] +pub struct ScopeStack { + frames: Vec, +} + +impl ScopeStack { + pub fn new() -> Self { + ScopeStack { frames: Vec::new() } + } + + pub fn push_boundary(&mut self, key: BlockKey) { + self.frames.push(Frame { + bindings: HashMap::new(), + boundary: Some(key), + }); + } + + pub fn push_plain(&mut self) { + self.frames.push(Frame { + bindings: HashMap::new(), + boundary: None, + }); + } + + pub fn pop(&mut self) { + self.frames.pop(); + } + + pub fn bind(&mut self, name: &str, port: PortRef) { + if let Some(frame) = self.frames.last_mut() { + frame.bindings.insert(name.to_string(), port); + } + } + + fn nearest_boundary_index(&self) -> Option { + self.frames.iter().rposition(|f| f.boundary.is_some()) + } + + pub fn nearest_boundary(&self) -> Option { + self.frames.iter().rev().find_map(|f| f.boundary.clone()) + } + + /// Looks a name up from the innermost frame outward. Returns the bound `PortRef` and whether + /// it was found without crossing the nearest boundary (`true`) or beyond it (`false`, a + /// capture). + pub fn resolve(&self, name: &str) -> Option<(PortRef, bool)> { + let boundary_idx = self.nearest_boundary_index(); + for (idx, frame) in self.frames.iter().enumerate().rev() { + if let Some(port) = frame.bindings.get(name) { + let is_local = boundary_idx.is_none_or(|b| idx >= b); + return Some((port.clone(), is_local)); + } + } + None + } +} diff --git a/crates/typr-graph/src/build/type_relations.rs b/crates/typr-graph/src/build/type_relations.rs new file mode 100644 index 00000000..9a47601b --- /dev/null +++ b/crates/typr-graph/src/build/type_relations.rs @@ -0,0 +1,363 @@ +//! Type relations pass (spec §5.1, étape 3: `Satisfies`, `DeclaredAs`, `Subtype`). +//! +//! A post-pass over the program's top-level `Lang::Alias` declarations, run once every +//! `TypeDecl`/`Interface` block already exists (spec §7.1 step 4). Not folded into `build_expr`'s +//! per-node recursion because these relations compare *pairs* of top-level declarations against +//! each other, not a single node against its children. + +use super::blocks::{alias_name, discover_methods, safe_argument_name}; +use super::variable_name; +use crate::key::{BlockKey, Namespace}; +use crate::model::{Block, BlockGraph, BlockKind, Evidence, Origin, Port, Relation, RelationKind}; +use std::collections::{BTreeSet, HashSet}; +use typr_core::components::context::Context; +use typr_core::components::language::Lang; +use typr_core::components::r#type::argument_type::ArgumentType; +use typr_core::components::r#type::type_operator::TypeOperator; +use typr_core::components::r#type::type_system::TypeSystem; +use typr_core::components::r#type::Type; +use typr_core::processes::type_checking::interface_satisfaction::check_interface_satisfaction; + +struct TypeAlias { + name: String, + key: BlockKey, + /// The type exactly as written in `type <- ;` — not reduced, so an + /// intersection member's own alias name is still visible for `DeclaredAs`. + target_type: Type, + /// `Interface`'s required methods, once reduced — `None` for every other kind of alias. + interface_methods: Option>, +} + +pub(super) fn build(graph: &mut BlockGraph, context: &Context, items: &[&Lang]) { + let aliases = collect_type_aliases(context, items); + let (interfaces, type_decls): (Vec<&TypeAlias>, Vec<&TypeAlias>) = + aliases.iter().partition(|a| a.interface_methods.is_some()); + + for decl in &type_decls { + declared_as(graph, context, decl); + for iface in &interfaces { + satisfies(graph, context, decl, iface); + } + for other in &type_decls { + if other.key != decl.key && decl.target_type.is_subtype(&other.target_type, context).0 { + graph + .relations + .push(Relation::subtype(decl.key.clone(), other.key.clone())); + } + } + } +} + +/// The primitive types, which have no `TypeDecl` but are types all the same: a block typed `char` +/// gets a synthesized `type:char` block to point at. +const PRIMITIVES: [&str; 4] = ["int", "num", "char", "bool"]; + +/// `HasType` for every block, whether its type was written or inferred, and however deep the +/// named type sits. `finish_block` only links a block whose recorded type *is* an alias +/// (`p: Point`), which misses an inferred `fn(v: Point) -> Point`, `[Point]`, `Option`, and +/// every primitive (`char` has no declaration to point at). Runs after every `TypeDecl`/`Interface` +/// block exists and scans each block's printed type — plus `notes`, the type texts the printed type +/// doesn't carry (a `let`'s annotation, the primitive behind a literal) — for type names. +/// Printed rather than walked: `Type::pretty` already renders every variant, so this stays correct +/// as variants are added. +/// +/// Returns the keys of the primitive blocks it synthesized (only the ones used), for the caller to +/// list among the program's children so they appear in the top-level view. +pub(super) fn has_type_links(graph: &mut BlockGraph, notes: &[(BlockKey, String)]) -> Vec { + let mut type_keys: Vec<(String, BlockKey)> = graph + .blocks + .values() + .filter(|b| matches!(b.kind, BlockKind::TypeDecl | BlockKind::Interface) && b.key.is_top_level()) + .filter_map(|b| Some((b.key.as_str().strip_prefix("type:")?.to_string(), b.key.clone()))) + .collect(); + // A user `type int <- …` would shadow the primitive; the declared block wins. + for p in PRIMITIVES { + if !type_keys.iter().any(|(n, _)| n == p) { + type_keys.push((p.to_string(), BlockKey::top_level(Namespace::Type, p))); + } + } + + let mut known: HashSet<(BlockKey, BlockKey)> = graph + .relations + .iter() + .filter(|r| r.kind == RelationKind::HasType) + .map(|r| (r.from.clone(), r.to.clone())) + .collect(); + + let mut texts: Vec<(BlockKey, String)> = Vec::new(); + for block in graph.blocks.values() { + // A declaration's own type is its definition, not something it "has". + if matches!(block.kind, BlockKind::TypeDecl | BlockKind::Interface) { + continue; + } + if let Some(ty) = &block.r#type { + texts.push((block.key.clone(), ty.clone())); + } + } + texts.extend(notes.iter().cloned()); + + let mut added = Vec::new(); + let mut used_primitives = BTreeSet::new(); + for (from, text) in &texts { + let words = type_identifiers(text); + for (name, key) in &type_keys { + if !words.contains(name.as_str()) || !known.insert((from.clone(), key.clone())) { + continue; + } + if !graph.blocks.contains_key(key) { + used_primitives.insert(name.clone()); + } + added.push(Relation::has_type(from.clone(), key.clone())); + } + } + graph.relations.extend(added); + + used_primitives + .into_iter() + .map(|name| { + let key = BlockKey::top_level(Namespace::Type, &name); + graph.insert(Block { + key: key.clone(), + kind: BlockKind::TypeExpr, + name: Some(name.clone()), + span: None, + r#type: None, + inputs: Vec::new(), + outputs: vec![Port::explicit("out", Some(name))], + origin: Origin::Std, + body: None, + }); + key + }) + .collect() +} + +/// Identifiers in a printed type that name a type: outside string literals, and not a field or +/// parameter name (`x` in `list{x: int}`, `v` in `fn(v: Point)`) — those are followed by `:`. +fn type_identifiers(ty: &str) -> BTreeSet<&str> { + let mut words = BTreeSet::new(); + let bytes = ty.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if c == b'"' { + i += 1; + while i < bytes.len() && bytes[i] != b'"' { + i += 1; + } + i += 1; + } else if c.is_ascii_alphabetic() || c == b'_' { + let start = i; + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' || bytes[i] == b'.') { + i += 1; + } + let mut j = i; + while j < bytes.len() && bytes[j] == b' ' { + j += 1; + } + if bytes.get(j) != Some(&b':') { + words.insert(&ty[start..i]); + } + } else { + i += 1; + } + } + words +} + +fn collect_type_aliases(context: &Context, items: &[&Lang]) -> Vec { + items + .iter() + .filter_map(|item| match item { + Lang::Alias { + identifier, + target_type, + .. + } => { + let name = variable_name(identifier)?; + let key = BlockKey::top_level(Namespace::Type, &name); + let interface_methods = match target_type.reduce(context) { + Type::Interface(methods, _) => Some(methods), + _ => None, + }; + Some(TypeAlias { + name, + key, + target_type: target_type.clone(), + interface_methods, + }) + } + _ => None, + }) + .collect() +} + +/// `type Foo <- Record & Printable;`: the intersection's interface member is named directly in +/// the declaration (`type_arithmetic::norm_intersection` keeps a `Record & Interface` mix +/// symbolic rather than merging it), so `DeclaredAs` doesn't need structural satisfaction — the +/// declaration itself names the interface it commits to. +fn declared_as(graph: &mut BlockGraph, context: &Context, decl: &TypeAlias) { + let Type::Operator(TypeOperator::Intersection, a, b, _) = &decl.target_type else { + return; + }; + for member in [a.as_ref(), b.as_ref()] { + if let Some(member_name) = alias_name(member) { + if matches!(member.reduce(context), Type::Interface(..)) { + let iface_key = BlockKey::top_level(Namespace::Type, &member_name); + graph.relations.push(Relation::declared_as(decl.key.clone(), iface_key)); + } + } + } +} + +/// Structural satisfaction (spec §5.1 `Satisfies`), computed with the same primitive the type +/// checker itself uses for `Record & Interface` declarations and `I(x)` interface-constructor +/// calls (`interface_satisfaction::check_interface_satisfaction`) — this pass doesn't reimplement +/// the check, only renders its result plus a per-method evidence trail (spec §5.2). Evidence +/// falls back to the `TypeDecl` itself (rather than a method block) when a requirement is met +/// structurally — e.g. a record field read as a trivial getter — instead of by a discovered +/// free-function block. +fn satisfies(graph: &mut BlockGraph, context: &Context, decl: &TypeAlias, iface: &TypeAlias) { + let Some(required) = &iface.interface_methods else { + return; + }; + // Nominal reference, matching how a parameter declared `p: ` would be typed — see + // `discover_methods`'s own by-name match and the doc comment on + // `interface_satisfaction::candidate_methods` (`typr-core`). + let concrete = Type::Alias(decl.name.clone(), Vec::new(), false, Default::default()); + if check_interface_satisfaction(context, &concrete, required).is_err() { + return; + } + let provided = discover_methods(context, &decl.name); + let mut evidence: Vec = required + .iter() + .map(|req| { + let name = safe_argument_name(req); + let provided_by = provided + .iter() + .find(|(method_name, ..)| *method_name == name) + .map(|(_, _, key)| key.clone()) + .unwrap_or_else(|| decl.key.clone()); + Evidence { + requires: format!("{name}: {}", req.get_type().pretty()), + provided_by, + } + }) + .collect(); + evidence.sort_by(|a, b| a.requires.cmp(&b.requires)); + graph + .relations + .push(Relation::satisfies(decl.key.clone(), iface.key.clone(), evidence)); +} + +#[cfg(test)] +mod tests { + use crate::key::Namespace; + use crate::model::{BlockGraph, RelationKind}; + use crate::BlockKey; + use typr_core::components::context::Context; + use typr_core::processes::parsing::parse_from_string; + use typr_core::processes::type_checking::type_recorder::with_recording; + use typr_core::processes::type_checking::typing_with_errors; + + fn build_graph(source: &str) -> BlockGraph { + let lang = parse_from_string(source, "type_relations_test"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + assert!(!result.has_errors(), "{:?}", result.display_errors()); + crate::build(&lang, &result.type_context.context, &table) + } + + #[test] + fn has_type_reaches_a_named_type_nested_in_an_inferred_function_type() { + let graph = build_graph( + r#" +type Point <- list { x: int, y: int }; +let m <- fn(v: Point): Point { v }; +let n <- list(name = "Point"); +"#, + ); + let point = BlockKey::top_level(Namespace::Type, "Point"); + let has_type = |from: &str| { + graph.relations.iter().any(|r| { + r.kind == RelationKind::HasType && r.from == BlockKey::top_level(Namespace::Val, from) && r.to == point + }) + }; + assert!(has_type("m"), "{:#?}", graph.relations); + assert!(!has_type("n"), "a string literal isn't a type reference"); + } + + #[test] + fn a_primitive_type_gets_a_block_and_a_let_annotation_is_linked() { + let graph = build_graph("let message: char <- \"Hello, TypR!\";\n\nmessage\n"); + let char_key = BlockKey::top_level(Namespace::Type, "char"); + assert!(graph.blocks.contains_key(&char_key), "{:#?}", graph.blocks.keys()); + assert!(graph.blocks[&graph.root] + .body + .as_ref() + .unwrap() + .children + .contains(&char_key)); + let linked = |from: &str| { + graph + .relations + .iter() + .any(|r| r.kind == RelationKind::HasType && r.from == BlockKey::from_raw(from) && r.to == char_key) + }; + assert!(linked("val:message"), "{:#?}", graph.relations); + assert!(linked("val:#1")); + } + + #[test] + fn satisfies_links_a_type_to_the_interface_its_method_satisfies() { + let graph = build_graph( + r#" +type Printable <- interface { show: (Self) -> char }; +type Point <- list { x: int, y: int }; +let show <- fn(p: Point): char { "Point" }; +"#, + ); + let point = BlockKey::top_level(Namespace::Type, "Point"); + let printable = BlockKey::top_level(Namespace::Type, "Printable"); + let rel = graph + .relations + .iter() + .find(|r| r.kind == RelationKind::Satisfies && r.from == point && r.to == printable) + .expect("expected a Satisfies relation from Point to Printable"); + let evidence = rel.evidence.as_ref().expect("Satisfies carries evidence"); + assert_eq!(evidence.len(), 1); + assert_eq!(evidence[0].provided_by, BlockKey::top_level(Namespace::Val, "show")); + } + + #[test] + fn declared_as_links_an_intersection_alias_to_its_named_interface_member() { + let graph = build_graph( + r#" +type Printable <- interface { show: (Self) -> char }; +type Point <- list { x: int, y: int }; +type Combined <- Point & Printable; +"#, + ); + let combined = BlockKey::top_level(Namespace::Type, "Combined"); + let printable = BlockKey::top_level(Namespace::Type, "Printable"); + assert!(graph + .relations + .iter() + .any(|r| r.kind == RelationKind::DeclaredAs && r.from == combined && r.to == printable)); + } + + #[test] + fn subtype_links_a_wider_record_to_a_structurally_narrower_one() { + let graph = build_graph( + r#" +type Named <- list { name: char }; +type Person <- list { name: char, age: int }; +"#, + ); + let named = BlockKey::top_level(Namespace::Type, "Named"); + let person = BlockKey::top_level(Namespace::Type, "Person"); + assert!(graph + .relations + .iter() + .any(|r| r.kind == RelationKind::Subtype && r.from == person && r.to == named)); + } +} diff --git a/crates/typr-graph/src/diff.rs b/crates/typr-graph/src/diff.rs new file mode 100644 index 00000000..35ae1b32 --- /dev/null +++ b/crates/typr-graph/src/diff.rs @@ -0,0 +1,379 @@ +//! `diff(old, new)` (spec §12 étape 6): two block-graphs of the *same program at two points in +//! time*, paired up by [`BlockKey`] — the whole point of a stable semantic identity (§6) is that +//! it survives an edit, so a block that didn't move keeps its key and a block that did shows up +//! as removed+added rather than silently "modified" into something unrelated. +//! +//! Scope is deliberately block-level, matching the spec's own list: added/removed blocks, plus +//! for a block present in both, whether its `type`, `captures` (implicit input ports, §3.3) or +//! `interface` (`Satisfies`/`DeclaredAs` targets, for a `TypeDecl`) changed. Wire-level and +//! `Ref`-level diffing is out of scope here — the spec's own wording is `type, interface, +//! captures`, not "every relation". + +use crate::key::BlockKey; +use crate::model::{BlockGraph, BlockKind, Port, RelationKind}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CaptureChange { + pub added: Vec, + pub removed: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct InterfaceChange { + /// `Satisfies`/`DeclaredAs` targets (interfaces) gained or lost — the block's declared + /// contract, not its full relation list. + pub added: Vec, + pub removed: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlockDiff { + pub key: BlockKey, + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option<(BlockKind, BlockKind)>, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<(Option, Option)>, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option<(Option, Option)>, + #[serde(skip_serializing_if = "Option::is_none")] + pub captures: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub interface: Option, +} + +impl BlockDiff { + fn is_empty(&self) -> bool { + self.kind.is_none() + && self.name.is_none() + && self.r#type.is_none() + && self.captures.is_none() + && self.interface.is_none() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct GraphDiff { + pub added: Vec, + pub removed: Vec, + pub modified: Vec, +} + +/// Pairs `old` and `new` by [`BlockKey`] and reports what changed (spec §12 étape 6). Both +/// graphs are expected to come from the same builder (`typr_graph::build`) run on two versions +/// of the same source — nothing here assumes that, but a `BlockKey` computed by a different +/// scheme would just never match and everything would show up as removed+added. +pub fn diff(old: &BlockGraph, new: &BlockGraph) -> GraphDiff { + let mut out = GraphDiff::default(); + + for key in old.blocks.keys() { + if !new.blocks.contains_key(key) { + out.removed.push(key.clone()); + } + } + for key in new.blocks.keys() { + if !old.blocks.contains_key(key) { + out.added.push(key.clone()); + } + } + + for (key, old_block) in &old.blocks { + let Some(new_block) = new.blocks.get(key) else { + continue; + }; + + let mut d = BlockDiff { + key: key.clone(), + kind: None, + name: None, + r#type: None, + captures: None, + interface: None, + }; + + if old_block.kind != new_block.kind { + d.kind = Some((old_block.kind, new_block.kind)); + } + if old_block.name != new_block.name { + d.name = Some((old_block.name.clone(), new_block.name.clone())); + } + if old_block.r#type != new_block.r#type { + d.r#type = Some((old_block.r#type.clone(), new_block.r#type.clone())); + } + + let old_captures: Vec<&Port> = old_block.inputs.iter().filter(|p| p.implicit).collect(); + let new_captures: Vec<&Port> = new_block.inputs.iter().filter(|p| p.implicit).collect(); + let captures_added: Vec = new_captures + .iter() + .filter(|p| !old_captures.contains(p)) + .map(|p| (*p).clone()) + .collect(); + let captures_removed: Vec = old_captures + .iter() + .filter(|p| !new_captures.contains(p)) + .map(|p| (*p).clone()) + .collect(); + if !captures_added.is_empty() || !captures_removed.is_empty() { + d.captures = Some(CaptureChange { + added: captures_added, + removed: captures_removed, + }); + } + + let old_interface = interface_targets(old, key); + let new_interface = interface_targets(new, key); + let interface_added: Vec = new_interface.difference(&old_interface).cloned().collect(); + let interface_removed: Vec = old_interface.difference(&new_interface).cloned().collect(); + if !interface_added.is_empty() || !interface_removed.is_empty() { + d.interface = Some(InterfaceChange { + added: interface_added, + removed: interface_removed, + }); + } + + if !d.is_empty() { + out.modified.push(d); + } + } + + out.added.sort(); + out.removed.sort(); + out.modified.sort_by(|a, b| a.key.cmp(&b.key)); + out +} + +/// The `Satisfies`/`DeclaredAs` targets of `key` in `graph` — its declared type contract. +fn interface_targets(graph: &BlockGraph, key: &BlockKey) -> BTreeSet { + graph + .relations + .iter() + .filter(|r| r.from == *key && matches!(r.kind, RelationKind::Satisfies | RelationKind::DeclaredAs)) + .map(|r| r.to.clone()) + .collect() +} + +/// A compact human-readable rendering of a [`GraphDiff`], one line per block +/// (`+`/`-`/`~` prefix, like a source diff's file-level summary) with indented sub-lines for +/// what changed on a modified block. `--format json` is the machine contract (spec §9-shaped); +/// this is the CLI's `--format text` and the playground diff view's textual fallback. +pub fn to_text(diff: &GraphDiff) -> String { + let mut out = String::new(); + for key in &diff.added { + out.push_str(&format!("+ {}\n", key.as_str())); + } + for key in &diff.removed { + out.push_str(&format!("- {}\n", key.as_str())); + } + for m in &diff.modified { + out.push_str(&format!("~ {}\n", m.key.as_str())); + if let Some((before, after)) = &m.kind { + out.push_str(&format!(" kind: {:?} -> {:?}\n", before, after)); + } + if let Some((before, after)) = &m.name { + out.push_str(&format!( + " name: {} -> {}\n", + before.as_deref().unwrap_or("_"), + after.as_deref().unwrap_or("_") + )); + } + if let Some((before, after)) = &m.r#type { + out.push_str(&format!( + " type: {} -> {}\n", + before.as_deref().unwrap_or("_"), + after.as_deref().unwrap_or("_") + )); + } + if let Some(c) = &m.captures { + out.push_str(&format!(" captures: {}\n", format_port_delta(&c.added, &c.removed))); + } + if let Some(i) = &m.interface { + out.push_str(&format!(" interface: {}\n", format_key_delta(&i.added, &i.removed))); + } + } + out +} + +fn format_port_delta(added: &[Port], removed: &[Port]) -> String { + let plus = added.iter().map(|p| format!("+{}", p.name)); + let minus = removed.iter().map(|p| format!("-{}", p.name)); + plus.chain(minus).collect::>().join(" ") +} + +fn format_key_delta(added: &[BlockKey], removed: &[BlockKey]) -> String { + let plus = added.iter().map(|k| format!("+{}", k.as_str())); + let minus = removed.iter().map(|k| format!("-{}", k.as_str())); + plus.chain(minus).collect::>().join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Block, Body, Origin}; + + fn block(key: &str, kind: BlockKind) -> Block { + Block { + key: BlockKey::from_raw(key), + kind, + name: None, + span: None, + r#type: None, + inputs: Vec::new(), + outputs: Vec::new(), + origin: Origin::User, + body: None, + } + } + + #[test] + fn detects_added_and_removed_blocks() { + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + old.insert(block("val:@program", BlockKind::Program)); + old.insert(block("val:a", BlockKind::Function)); + + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + new.insert(block("val:@program", BlockKind::Program)); + new.insert(block("val:b", BlockKind::Function)); + + let d = diff(&old, &new); + assert_eq!(d.added, vec![BlockKey::from_raw("val:b")]); + assert_eq!(d.removed, vec![BlockKey::from_raw("val:a")]); + assert!(d.modified.is_empty()); + } + + #[test] + fn detects_type_change() { + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a = block("val:a", BlockKind::Function); + a.r#type = Some("int".to_string()); + old.insert(a); + + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a2 = block("val:a", BlockKind::Function); + a2.r#type = Some("char".to_string()); + new.insert(a2); + + let d = diff(&old, &new); + assert_eq!(d.modified.len(), 1); + assert_eq!( + d.modified[0].r#type, + Some((Some("int".to_string()), Some("char".to_string()))) + ); + assert!(d.modified[0].captures.is_none()); + assert!(d.modified[0].interface.is_none()); + } + + #[test] + fn detects_capture_change() { + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a = block("val:a", BlockKind::Function); + a.inputs = vec![Port::implicit("sq", None)]; + old.insert(a); + + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a2 = block("val:a", BlockKind::Function); + a2.inputs = vec![Port::implicit("norm", None)]; + new.insert(a2); + + let d = diff(&old, &new); + assert_eq!(d.modified.len(), 1); + let captures = d.modified[0].captures.as_ref().unwrap(); + assert_eq!(captures.added, vec![Port::implicit("norm", None)]); + assert_eq!(captures.removed, vec![Port::implicit("sq", None)]); + } + + #[test] + fn ignores_explicit_input_changes_as_captures() { + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a = block("val:a", BlockKind::Function); + a.inputs = vec![Port::explicit("n", Some("int".to_string()))]; + old.insert(a); + + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a2 = block("val:a", BlockKind::Function); + a2.inputs = vec![Port::explicit("m", Some("int".to_string()))]; + new.insert(a2); + + let d = diff(&old, &new); + assert!(d.modified.is_empty(), "explicit params aren't captures"); + } + + #[test] + fn detects_interface_change() { + use crate::model::Relation; + + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + old.insert(block("type:Point", BlockKind::TypeDecl)); + old.insert(block("type:Printable", BlockKind::Interface)); + old.relations.push(Relation::satisfies( + BlockKey::from_raw("type:Point"), + BlockKey::from_raw("type:Printable"), + Vec::new(), + )); + + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + new.insert(block("type:Point", BlockKind::TypeDecl)); + new.insert(block("type:Printable", BlockKind::Interface)); + // Printable satisfaction dropped in the new version. + + let d = diff(&old, &new); + let point_diff = d + .modified + .iter() + .find(|m| m.key == BlockKey::from_raw("type:Point")) + .expect("Point should be reported modified"); + let interface = point_diff.interface.as_ref().unwrap(); + assert_eq!(interface.removed, vec![BlockKey::from_raw("type:Printable")]); + assert!(interface.added.is_empty()); + } + + #[test] + fn unchanged_block_is_not_reported() { + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + old.insert(block("val:a", BlockKind::Function)); + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + new.insert(block("val:a", BlockKind::Function)); + + let d = diff(&old, &new); + assert!(d.added.is_empty()); + assert!(d.removed.is_empty()); + assert!(d.modified.is_empty()); + } + + #[test] + fn text_rendering_is_stable_and_readable() { + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + old.insert(block("val:a", BlockKind::Function)); + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + new.insert(block("val:b", BlockKind::Function)); + + let d = diff(&old, &new); + let text = to_text(&d); + assert_eq!(text, "+ val:b\n- val:a\n"); + } + + #[test] + fn body_only_changes_are_not_flagged_at_this_key() { + // A body's children/wires changing (e.g. a new statement inside a Scope) isn't one of + // the spec's tracked attributes (type/interface/captures) — it shows up as the *inner* + // blocks being added/removed instead, not as a change on the outer block itself. + let mut old = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a = block("val:a", BlockKind::Scope); + a.body = Some(Body { + children: vec![BlockKey::from_raw("val:a/#0")], + wires: Vec::new(), + }); + old.insert(a); + + let mut new = BlockGraph::new(BlockKey::from_raw("val:@program")); + let mut a2 = block("val:a", BlockKind::Scope); + a2.body = Some(Body { + children: vec![BlockKey::from_raw("val:a/#0"), BlockKey::from_raw("val:a/#1")], + wires: Vec::new(), + }); + new.insert(a2); + + let d = diff(&old, &new); + assert!(d.modified.is_empty()); + } +} diff --git a/crates/typr-graph/src/export/dot.rs b/crates/typr-graph/src/export/dot.rs new file mode 100644 index 00000000..c7e5fa91 --- /dev/null +++ b/crates/typr-graph/src/export/dot.rs @@ -0,0 +1,120 @@ +//! DOT export — one level at a time (spec §12 étape 2: "un niveau (le bloc `--focus`) avec ses +//! ports"). Renders [`BlockGraph::one_level`]: the focus block's direct children as record nodes +//! with their ports as fields, plus solid wire edges and dashed `Ref` edges. +//! +//! A wire's `to` is always some port inside the block that was being built when the builder +//! wired it in (`build_operand`'s `owner`) — sometimes that's the focus block's own input feeding +//! straight into a child (e.g. an `Apply`'s own body wires an argument in), sometimes it's a +//! child's own body wiring one of *its* ports from an ancestor's port or a sibling's output +//! (spec §7.1, see `typr-graph` builder notes). So a one-level view has to collect wires from +//! every block in the level, not just the focus block's own `body.wires`. + +use crate::key::BlockKey; +use crate::model::{Block, RelationKind}; +use crate::BlockGraph; +use std::fmt::Write as _; + +/// `None` if `focus` isn't a block of `graph`. +pub fn to_dot(graph: &BlockGraph, focus: &BlockKey) -> Option { + let level = graph.one_level(focus)?; + let mut out = String::new(); + let _ = writeln!(out, "digraph \"{}\" {{", escape(focus.as_str())); + let _ = writeln!(out, " rankdir=LR;"); + let _ = writeln!(out, " node [shape=record, fontsize=10];"); + + for (key, block) in &level.blocks { + write_node(&mut out, key, block); + } + + for block in level.blocks.values() { + let Some(body) = &block.body else { continue }; + for wire in &body.wires { + let _ = writeln!( + out, + " \"{}\":\"{}\" -> \"{}\":\"{}\";", + escape(wire.from.block.as_str()), + escape(&wire.from.port), + escape(wire.to.block.as_str()), + escape(&wire.to.port), + ); + } + } + + for rel in &level.relations { + if rel.kind != RelationKind::Ref { + continue; + } + let label = rel.port.as_deref().unwrap_or(""); + let _ = writeln!( + out, + " \"{}\" -> \"{}\" [style=dashed, label=\"{}\"];", + escape(rel.from.as_str()), + escape(rel.to.as_str()), + escape(label), + ); + } + + let _ = writeln!(out, "}}"); + Some(out) +} + +fn write_node(out: &mut String, key: &BlockKey, block: &Block) { + let name = block.name.clone().unwrap_or_else(|| format!("{:?}", block.kind)); + let inputs = ports_field(block.inputs.iter().map(|p| p.name.as_str())); + let outputs = ports_field(block.outputs.iter().map(|p| p.name.as_str())); + let label = match (inputs.is_empty(), outputs.is_empty()) { + (true, true) => escape(&name), + (false, true) => format!("{{{}}}|{}", inputs, escape(&name)), + (true, false) => format!("{}|{{{}}}", escape(&name), outputs), + (false, false) => format!("{{{}}}|{}|{{{}}}", inputs, escape(&name), outputs), + }; + let _ = writeln!(out, " \"{}\" [label=\"{}\"];", escape(key.as_str()), label); +} + +/// A `{label|label|...}` record field group. +fn ports_field<'a>(ports: impl Iterator) -> String { + ports + .map(|p| format!("<{}> {}", escape(p), escape(p))) + .collect::>() + .join("|") +} + +fn escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::key::Namespace; + use typr_core::components::context::Context; + use typr_core::processes::parsing::parse_from_string; + use typr_core::processes::type_checking::type_recorder::with_recording; + use typr_core::processes::type_checking::typing_with_errors; + + #[test] + fn one_level_dot_view_has_child_nodes_and_wires() { + let source = "let sq <- fn(n: int): int { n * n };"; + let lang = parse_from_string(source, "dot_test"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + assert!(!result.has_errors(), "{:?}", result.display_errors()); + let graph = crate::build(&lang, &result.type_context.context, &table); + + let sq = BlockKey::top_level(Namespace::Val, "sq"); + let dot = to_dot(&graph, &sq).expect("sq is a block of this graph"); + assert!(dot.starts_with("digraph")); + assert!(dot.contains(&format!("\"{}\"", sq.as_str()))); + assert!(dot.contains(" -> "), "expected at least one edge in:\n{dot}"); + } + + #[test] + fn missing_focus_returns_none() { + let source = "let x <- 1;"; + let lang = parse_from_string(source, "dot_missing_test"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + assert!(!result.has_errors()); + let graph = crate::build(&lang, &result.type_context.context, &table); + + assert!(to_dot(&graph, &BlockKey::from_raw("val:does-not-exist")).is_none()); + } +} diff --git a/crates/typr-graph/src/export/json.rs b/crates/typr-graph/src/export/json.rs new file mode 100644 index 00000000..39cf0f43 --- /dev/null +++ b/crates/typr-graph/src/export/json.rs @@ -0,0 +1,7 @@ +//! JSON export — the external contract of spec §9, verbatim: `serde_json` over [`BlockGraph`]. + +use crate::model::BlockGraph; + +pub fn to_string_pretty(graph: &BlockGraph) -> serde_json::Result { + serde_json::to_string_pretty(graph) +} diff --git a/crates/typr-graph/src/export/mod.rs b/crates/typr-graph/src/export/mod.rs new file mode 100644 index 00000000..127a3d28 --- /dev/null +++ b/crates/typr-graph/src/export/mod.rs @@ -0,0 +1,5 @@ +//! Serializations of a [`crate::model::BlockGraph`] (spec §10): `json` for the external contract +//! (§9), `dot` for a one-level Graphviz view (§12 étape 2). + +pub mod dot; +pub mod json; diff --git a/crates/typr-graph/src/key.rs b/crates/typr-graph/src/key.rs new file mode 100644 index 00000000..ba74499c --- /dev/null +++ b/crates/typr-graph/src/key.rs @@ -0,0 +1,105 @@ +//! `BlockKey`: the stable semantic identity of a block (spec §6). Grown one segment at a time +//! while the builder walks down into `Lang`. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// `val:` / `type:` / `std:` / `r:` — TypR keeps values and aliases in separate namespaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Namespace { + Val, + Type, + Std, + R, +} + +impl fmt::Display for Namespace { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Namespace::Val => "val", + Namespace::Type => "type", + Namespace::Std => "std", + Namespace::R => "r", + }; + write!(f, "{s}") + } +} + +/// A block's stable identity, e.g. `val:norm2/a`, `val:norm2/#1/rhs/arg0`, `type:Point`. +/// Opaque on purpose (a plain `String` newtype): callers build one path segment at a time via +/// the methods below rather than constructing the string by hand. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct BlockKey(String); + +impl BlockKey { + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Reconstructs a key from its printed form (`"val:norm2/a"`) — for reading one back (CLI + /// `--focus`, a key round-tripped through JSON), never for synthesizing a fresh one; use the + /// segment-by-segment constructors below for that. + pub fn from_raw(raw: impl Into) -> Self { + BlockKey(raw.into()) + } + + /// A top-level declaration's own key (`val:norm2`, `type:Point`) — top-level names live + /// directly under their namespace, not nested under the synthetic `Program` block. + pub fn top_level(namespace: Namespace, name: &str) -> Self { + BlockKey(format!("{namespace}:{name}")) + } + + /// Whether this key names a top-level declaration directly (no `/` segment) — used by the + /// "dependencies" projection (spec §8) to pick which blocks survive flattening. + pub fn is_top_level(&self) -> bool { + !self.0.contains('/') + } + + /// The top-level declaration a nested key lives under (`val:norm2/a` → `val:norm2`); a + /// top-level key is its own ancestor. Used by the "dependencies" projection to collapse a + /// capture chain down to an edge between two top-level blocks (spec §8). + pub fn top_level_ancestor(&self) -> Self { + match self.0.split_once('/') { + Some((head, _)) => BlockKey(head.to_string()), + None => self.clone(), + } + } + + /// The synthetic root block representing the whole program. `@` can't start a TypR + /// identifier, so this can never collide with a real top-level name. + pub fn program_root() -> Self { + BlockKey("val:@program".to_string()) + } + + fn extended(&self, segment: &str) -> Self { + BlockKey(format!("{}/{}", self.0, segment)) + } + + /// A name introduced by a declaration (`let`, parameter, pattern binding). + pub fn named(&self, name: &str) -> Self { + self.extended(name) + } + + /// A fixed structural role (`then`, `else`, `cond`, `lhs`, `rhs`, `arg0`, `value`…). + pub fn role(&self, role: &str) -> Self { + self.extended(role) + } + + /// An anonymous child's position among its siblings. + pub fn anonymous(&self, index: usize) -> Self { + self.extended(&format!("#{index}")) + } + + /// An overloaded name, disambiguated by the type of its first parameter + /// (`show(Point)`), spec §6. + pub fn overload(&self, name: &str, first_param_type: &str) -> Self { + self.extended(&format!("{name}({first_param_type})")) + } +} + +impl fmt::Display for BlockKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/crates/typr-graph/src/lib.rs b/crates/typr-graph/src/lib.rs new file mode 100644 index 00000000..8e3d958a --- /dev/null +++ b/crates/typr-graph/src/lib.rs @@ -0,0 +1,23 @@ +//! Builds a hierarchical block-graph view of a TypR program (`visualization_graph_v2.md`). +//! +//! Entry point: [`build`], which turns `Lang` + the `Context` produced by type-checking it + a +//! recorded [`TypeTable`] into a [`model::BlockGraph`] — a JSON-serializable, versioned external +//! contract (spec §9). Pure logic, no I/O, WASM-safe. + +mod build; +pub mod diff; +pub mod export; +pub mod key; +pub mod model; +pub mod project; + +pub use key::{BlockKey, Namespace}; +pub use model::BlockGraph; + +use typr_core::components::context::Context; +use typr_core::components::language::Lang; +use typr_core::processes::type_checking::type_recorder::TypeTable; + +pub fn build(lang: &Lang, context: &Context, types: &TypeTable) -> BlockGraph { + build::build(lang, context, types) +} diff --git a/crates/typr-graph/src/model.rs b/crates/typr-graph/src/model.rs new file mode 100644 index 00000000..9f0e1873 --- /dev/null +++ b/crates/typr-graph/src/model.rs @@ -0,0 +1,315 @@ +//! The block-graph data model (spec §3.2, §4, §5, §9). This is the external JSON contract +//! (CLI, WASM, playground, docs): field names mirror the spec's example verbatim, and any +//! incompatible change must bump [`FORMAT_VERSION`]. + +use crate::key::BlockKey; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +pub const FORMAT: &str = "typr-block-graph"; +pub const FORMAT_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Span { + pub file: String, + pub start: usize, + pub end: usize, +} + +/// Every block kind of the catalogue (spec §4). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BlockKind { + Program, + Literal, + Operator, + Apply, + Scope, + Function, + Record, + Access, + TypeDecl, + TypeExpr, + Interface, + If, + Tuple, + Array, + Opaque, + Loop, + Match, + Module, + RCode, + /// A `# …` line kept as its own node; the node's `name` is the comment text. + Comment, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum Origin { + User, + Std, + RPackage(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Visibility { + Public, + Private, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Port { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + pub implicit: bool, + pub visibility: Visibility, +} + +impl Port { + pub fn explicit(name: impl Into, r#type: Option) -> Self { + Port { + name: name.into(), + r#type, + implicit: false, + visibility: Visibility::Public, + } + } + + pub fn implicit(name: impl Into, r#type: Option) -> Self { + Port { + name: name.into(), + r#type, + implicit: true, + visibility: Visibility::Public, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PortRef { + pub block: BlockKey, + pub port: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Wire { + pub from: PortRef, + pub to: PortRef, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct Body { + pub children: Vec, + pub wires: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Block { + pub key: BlockKey, + pub kind: BlockKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub span: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + pub inputs: Vec, + pub outputs: Vec, + pub origin: Origin, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +/// Confidence of a by-name reference (spec §5.3) — a stand-in until a real name-resolution pass +/// exists (étape 7). `Exact` unifies "only one candidate" and "several candidates, but the type +/// of the first argument at the call site narrows it to one." +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "confidence", rename_all = "snake_case")] +pub enum Confidence { + Exact, + ByName, + Ambiguous { candidates: Vec }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RelationKind { + Ref, + HasType, + TypePosition, + Satisfies, + DeclaredAs, + Subtype, + Instantiates, +} + +/// Why a `Satisfies`/`DeclaredAs` relation holds (spec §5.2) — the pedagogical payload: not just +/// *that* a type satisfies an interface, but *through which method*. `provided_by` is the block +/// supplying the required method: a free function's block when one was found by name, or the +/// `TypeDecl` itself when the requirement is met structurally (e.g. a record field read as a +/// trivial getter) rather than through a discovered function block. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Evidence { + pub requires: String, + pub provided_by: BlockKey, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Relation { + pub kind: RelationKind, + pub from: BlockKey, + pub to: BlockKey, + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub evidence: Option>, +} + +impl Relation { + pub fn r#ref(from: BlockKey, port: &str, to: BlockKey, confidence: Confidence) -> Self { + Relation { + kind: RelationKind::Ref, + from, + to, + port: Some(port.to_string()), + index: None, + confidence: Some(confidence), + evidence: None, + } + } + + pub fn has_type(from: BlockKey, to: BlockKey) -> Self { + Relation { + kind: RelationKind::HasType, + from, + to, + port: None, + index: None, + confidence: None, + evidence: None, + } + } + + pub fn type_position(from: BlockKey, to: BlockKey, index: usize) -> Self { + Relation { + kind: RelationKind::TypePosition, + from, + to, + port: None, + index: Some(index), + confidence: None, + evidence: None, + } + } + + /// `TypeDecl` → `Interface`, structurally computed (spec §5.1), with the evidence backing it. + pub fn satisfies(from: BlockKey, to: BlockKey, evidence: Vec) -> Self { + Relation { + kind: RelationKind::Satisfies, + from, + to, + port: None, + index: None, + confidence: None, + evidence: Some(evidence), + } + } + + /// `TypeDecl` → `Interface`, for a `Record & Interface` intersection alias: the interface + /// member named directly in the declaration, checked at declaration time (spec §5.1). + pub fn declared_as(from: BlockKey, to: BlockKey) -> Self { + Relation { + kind: RelationKind::DeclaredAs, + from, + to, + port: None, + index: None, + confidence: None, + evidence: None, + } + } + + /// type → supertype (spec §5.1), from the structural subtyping relation between two declared + /// type aliases. + pub fn subtype(from: BlockKey, to: BlockKey) -> Self { + Relation { + kind: RelationKind::Subtype, + from, + to, + port: None, + index: None, + confidence: None, + evidence: None, + } + } + + /// `Apply` → generic `Function` (spec §5.1): the call site instantiates the generic + /// definition it resolves to. Complements the `Ref` on the `callee` port, which says *what* + /// is called; this says the call binds the callee's type parameters. + pub fn instantiates(from: BlockKey, to: BlockKey, confidence: Confidence) -> Self { + Relation { + kind: RelationKind::Instantiates, + from, + to, + port: Some("callee".to_string()), + index: None, + confidence: Some(confidence), + evidence: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlockGraph { + pub format: String, + pub version: u32, + pub root: BlockKey, + pub blocks: BTreeMap, + pub relations: Vec, +} + +impl BlockGraph { + pub fn new(root: BlockKey) -> Self { + BlockGraph { + format: FORMAT.to_string(), + version: FORMAT_VERSION, + root, + blocks: BTreeMap::new(), + relations: Vec::new(), + } + } + + pub fn insert(&mut self, block: Block) { + self.blocks.insert(block.key.clone(), block); + } + + /// The one-level view centered on `focus` (spec §11's "un niveau rendu à la fois", spec §12 + /// étape 2's DOT export): `focus` itself plus its direct children, and every relation with at + /// least one endpoint in that set. `None` if `focus` isn't a block of this graph. + pub fn one_level(&self, focus: &BlockKey) -> Option { + let block = self.blocks.get(focus)?; + let mut out = BlockGraph::new(focus.clone()); + out.blocks.insert(focus.clone(), block.clone()); + + let mut scope: BTreeSet = BTreeSet::new(); + scope.insert(focus.clone()); + if let Some(body) = &block.body { + for child in &body.children { + if let Some(child_block) = self.blocks.get(child) { + out.blocks.insert(child.clone(), child_block.clone()); + scope.insert(child.clone()); + } + } + } + + out.relations = self + .relations + .iter() + .filter(|r| scope.contains(&r.from) || scope.contains(&r.to)) + .cloned() + .collect(); + Some(out) + } +} diff --git a/crates/typr-graph/src/project.rs b/crates/typr-graph/src/project.rs new file mode 100644 index 00000000..15c8c9ab --- /dev/null +++ b/crates/typr-graph/src/project.rs @@ -0,0 +1,194 @@ +//! Projections (spec §8): flatten the block hierarchy and filter by relation/block kind. A +//! projection always drops nested bodies — it's a flat map of the blocks it keeps, plus the +//! relations whose kind it keeps, each re-pointed at whichever kept block is its nearest match +//! ("on remplace chaque chaîne capture → `Ref` par une arête directe", spec §8). + +use crate::key::BlockKey; +use crate::model::{BlockGraph, BlockKind, Relation, RelationKind}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Projection { + /// Top-level named blocks + flattened `Ref` edges (étape 2). + Dependencies, + /// `TypeDecl`/`Interface` blocks plus the type-relation kinds that touch them. `Satisfies`, + /// `DeclaredAs` and `Subtype` don't exist in the graph yet (étape 3): until then this + /// projection only ever has `HasType`/`TypePosition` edges to show. + Types, +} + +pub fn project(graph: &BlockGraph, projection: Projection) -> BlockGraph { + match projection { + Projection::Dependencies => dependencies(graph), + Projection::Types => types(graph), + } +} + +fn dependencies(graph: &BlockGraph) -> BlockGraph { + let mut out = BlockGraph::new(graph.root.clone()); + for (key, block) in &graph.blocks { + // The synthetic Program root is top-level-shaped (no `/`) but isn't itself a "named" + // declaration (spec §8) — left out so the projection is purely the named blocks. + if key.is_top_level() && *key != graph.root { + let mut flat = block.clone(); + flat.body = None; + out.blocks.insert(key.clone(), flat); + } + } + + let mut seen: BTreeSet<(BlockKey, BlockKey)> = BTreeSet::new(); + for rel in &graph.relations { + if rel.kind != RelationKind::Ref { + continue; + } + let from = rel.from.top_level_ancestor(); + let to = rel.to.top_level_ancestor(); + if from == to || !out.blocks.contains_key(&from) || !out.blocks.contains_key(&to) { + continue; + } + if !seen.insert((from.clone(), to.clone())) { + continue; + } + out.relations.push(Relation { + kind: RelationKind::Ref, + from, + to, + port: None, + index: None, + confidence: rel.confidence.clone(), + evidence: None, + }); + } + out +} + +fn types(graph: &BlockGraph) -> BlockGraph { + let mut out = BlockGraph::new(graph.root.clone()); + + let kept: Vec<&Relation> = graph + .relations + .iter() + .filter(|r| { + matches!( + r.kind, + RelationKind::HasType + | RelationKind::TypePosition + | RelationKind::Satisfies + | RelationKind::DeclaredAs + | RelationKind::Subtype + ) + }) + .collect(); + + // Every TypeDecl/Interface, plus whichever value blocks a kept relation points from (e.g. a + // `HasType` edge's `from` is the value block that has the type, not a type block itself) — + // otherwise those relations would dangle at a block this projection dropped. + let mut keys: BTreeSet = graph + .blocks + .iter() + .filter(|(_, b)| matches!(b.kind, BlockKind::TypeDecl | BlockKind::Interface)) + .map(|(k, _)| k.clone()) + .collect(); + for rel in &kept { + keys.insert(rel.from.clone()); + keys.insert(rel.to.clone()); + } + + for key in keys { + if let Some(block) = graph.blocks.get(&key) { + let mut flat = block.clone(); + flat.body = None; + out.blocks.insert(key, flat); + } + } + out.relations = kept.into_iter().cloned().collect(); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::key::Namespace; + use typr_core::components::context::Context; + use typr_core::processes::parsing::parse_from_string; + use typr_core::processes::type_checking::type_recorder::with_recording; + use typr_core::processes::type_checking::typing_with_errors; + + const SOURCE: &str = r#" +type Point <- list { x: int, y: int }; + +let sq <- fn(n: int): int { n * n }; + +let norm2 <- fn(p: Point): int { + let a <- sq(p$x); + a + sq(p$y) +}; + +let total <- norm2(Point:{ x = 3, y = 4 }); +"#; + + fn build_graph() -> BlockGraph { + let lang = parse_from_string(SOURCE, "project_test"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + assert!(!result.has_errors(), "{:?}", result.display_errors()); + crate::build(&lang, &result.type_context.context, &table) + } + + #[test] + fn dependencies_flattens_captures_into_top_level_edges() { + let graph = build_graph(); + let deps = project(&graph, Projection::Dependencies); + + // Every kept block is top-level and the synthetic root is gone. + assert!(deps.blocks.keys().all(|k| k.is_top_level())); + assert!(!deps.blocks.contains_key(&graph.root)); + + // `norm2`'s body calls `sq` twice, nested two levels deep (`norm2/a` and `norm2/#1/lhs` + // or similar) — the projection must still surface a single top-level `norm2` -> `sq` + // edge, not one per call site and not one that still points at the nested key. + let norm2 = BlockKey::top_level(Namespace::Val, "norm2"); + let sq = BlockKey::top_level(Namespace::Val, "sq"); + let norm2_to_sq: Vec<_> = deps + .relations + .iter() + .filter(|r| r.kind == RelationKind::Ref && r.from == norm2 && r.to == sq) + .collect(); + assert_eq!( + norm2_to_sq.len(), + 1, + "expected exactly one flattened norm2 -> sq edge, got {:?}", + norm2_to_sq + ); + + // `total` calls `norm2`. + let total = BlockKey::top_level(Namespace::Val, "total"); + assert!(deps + .relations + .iter() + .any(|r| r.kind == RelationKind::Ref && r.from == total && r.to == norm2)); + + // Flattening never introduces a self-loop. + assert!(deps.relations.iter().all(|r| r.from != r.to)); + } + + #[test] + fn types_keeps_type_decls_and_has_type_edges() { + let graph = build_graph(); + let types_view = project(&graph, Projection::Types); + + let point = BlockKey::top_level(Namespace::Type, "Point"); + assert!(types_view.blocks.contains_key(&point)); + assert!(types_view + .relations + .iter() + .any(|r| r.kind == RelationKind::HasType && r.to == point)); + assert!(types_view.relations.iter().all(|r| matches!( + r.kind, + RelationKind::HasType + | RelationKind::TypePosition + | RelationKind::Satisfies + | RelationKind::DeclaredAs + | RelationKind::Subtype + ))); + } +} diff --git a/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap b/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap new file mode 100644 index 00000000..f0b1cec5 --- /dev/null +++ b/crates/typr-graph/tests/snapshots/walkthrough__walkthrough_example_builds_without_panicking.snap @@ -0,0 +1,1171 @@ +--- +source: crates/typr-graph/tests/walkthrough.rs +assertion_line: 53 +expression: json +--- +{ + "format": "typr-block-graph", + "version": 1, + "root": "val:@program", + "blocks": { + "type:Point": { + "key": "type:Point", + "kind": "TypeDecl", + "name": "Point", + "span": { + "file": "fil_rouge", + "start": 61, + "end": 341 + }, + "type": "UnknownFunction", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "UnknownFunction", + "implicit": false, + "visibility": "Public" + }, + { + "name": "norm2", + "type": "fn(p: Point) -> int", + "implicit": false, + "visibility": "Public" + }, + { + "name": "show", + "type": "fn(p: Point) -> char", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "type:Point/x", + "type:Point/y" + ], + "wires": [] + } + }, + "type:Point/x": { + "key": "type:Point/x", + "kind": "TypeExpr", + "name": "x", + "type": "int", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "type:Point/y": { + "key": "type:Point/y", + "kind": "TypeExpr", + "name": "y", + "type": "int", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "type:Printable": { + "key": "type:Printable", + "kind": "Interface", + "name": "Printable", + "span": { + "file": "fil_rouge", + "start": 7, + "end": 341 + }, + "type": "int", + "inputs": [], + "outputs": [], + "origin": "User", + "body": { + "children": [ + "type:Printable/show" + ], + "wires": [] + } + }, + "type:Printable/show": { + "key": "type:Printable/show", + "kind": "TypeExpr", + "name": "show", + "type": "fn(a: SELF) -> char", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "fn(a: SELF) -> char", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "type:char": { + "key": "type:char", + "kind": "TypeExpr", + "name": "char", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "char", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "Std" + }, + "type:int": { + "key": "type:int", + "kind": "TypeExpr", + "name": "int", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "Std" + }, + "val:@program": { + "key": "val:@program", + "kind": "Program", + "inputs": [], + "outputs": [], + "origin": "User", + "body": { + "children": [ + "type:Printable", + "type:Point", + "val:sq", + "val:norm2", + "val:show", + "val:p", + "val:d", + "val:total", + "type:char", + "type:int" + ], + "wires": [] + } + }, + "val:d": { + "key": "val:d", + "kind": "Apply", + "name": "d", + "span": { + "file": "fil_rouge", + "start": 304, + "end": 309 + }, + "type": "Point", + "inputs": [ + { + "name": "callee", + "implicit": false, + "visibility": "Public" + }, + { + "name": "arg0", + "type": "Point", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "Point", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [], + "wires": [ + { + "from": { + "block": "val:p", + "port": "out" + }, + "to": { + "block": "val:d", + "port": "arg0" + } + } + ] + } + }, + "val:norm2": { + "key": "val:norm2", + "kind": "Function", + "name": "norm2", + "span": { + "file": "", + "start": 0, + "end": 208 + }, + "type": "fn(p: Point) -> int", + "inputs": [ + { + "name": "p", + "type": "Point", + "implicit": false, + "visibility": "Public" + }, + { + "name": "sq", + "implicit": true, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:norm2/a", + "val:norm2/#1" + ], + "wires": [] + } + }, + "val:norm2/#1": { + "key": "val:norm2/#1", + "kind": "Operator", + "name": "+", + "span": { + "file": "fil_rouge", + "start": 193, + "end": 204 + }, + "type": "int", + "inputs": [ + { + "name": "lhs", + "type": "int", + "implicit": false, + "visibility": "Public" + }, + { + "name": "rhs", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:norm2/#1/rhs" + ], + "wires": [ + { + "from": { + "block": "val:norm2/a", + "port": "out" + }, + "to": { + "block": "val:norm2/#1", + "port": "lhs" + } + }, + { + "from": { + "block": "val:norm2/#1/rhs", + "port": "out" + }, + "to": { + "block": "val:norm2/#1", + "port": "rhs" + } + } + ] + } + }, + "val:norm2/#1/rhs": { + "key": "val:norm2/#1/rhs", + "kind": "Apply", + "span": { + "file": "fil_rouge", + "start": 197, + "end": 204 + }, + "type": "int", + "inputs": [ + { + "name": "callee", + "implicit": false, + "visibility": "Public" + }, + { + "name": "arg0", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:norm2/#1/rhs/arg0" + ], + "wires": [ + { + "from": { + "block": "val:norm2/#1/rhs/arg0", + "port": "y" + }, + "to": { + "block": "val:norm2/#1/rhs", + "port": "arg0" + } + } + ] + } + }, + "val:norm2/#1/rhs/arg0": { + "key": "val:norm2/#1/rhs/arg0", + "kind": "Access", + "span": { + "file": "fil_rouge", + "start": 200, + "end": 202 + }, + "type": "int", + "inputs": [ + { + "name": "value", + "type": "Point", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "y", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [], + "wires": [ + { + "from": { + "block": "val:norm2", + "port": "p" + }, + "to": { + "block": "val:norm2/#1/rhs/arg0", + "port": "value" + } + } + ] + } + }, + "val:norm2/a": { + "key": "val:norm2/a", + "kind": "Apply", + "name": "a", + "span": { + "file": "fil_rouge", + "start": 180, + "end": 192 + }, + "type": "int", + "inputs": [ + { + "name": "callee", + "implicit": false, + "visibility": "Public" + }, + { + "name": "arg0", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:norm2/a/arg0" + ], + "wires": [ + { + "from": { + "block": "val:norm2/a/arg0", + "port": "x" + }, + "to": { + "block": "val:norm2/a", + "port": "arg0" + } + } + ] + } + }, + "val:norm2/a/arg0": { + "key": "val:norm2/a/arg0", + "kind": "Access", + "span": { + "file": "fil_rouge", + "start": 183, + "end": 185 + }, + "type": "int", + "inputs": [ + { + "name": "value", + "type": "Point", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "x", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [], + "wires": [ + { + "from": { + "block": "val:norm2", + "port": "p" + }, + "to": { + "block": "val:norm2/a/arg0", + "port": "value" + } + } + ] + } + }, + "val:p": { + "key": "val:p", + "kind": "Record", + "name": "p", + "span": { + "file": "fil_rouge", + "start": 267, + "end": 286 + }, + "type": "Point", + "inputs": [ + { + "name": "x", + "type": "3", + "implicit": false, + "visibility": "Public" + }, + { + "name": "y", + "type": "4", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "Point", + "implicit": false, + "visibility": "Public" + }, + { + "name": "x", + "type": "3", + "implicit": false, + "visibility": "Public" + }, + { + "name": "y", + "type": "4", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:p/x", + "val:p/y" + ], + "wires": [ + { + "from": { + "block": "val:p/x", + "port": "out" + }, + "to": { + "block": "val:p", + "port": "x" + } + }, + { + "from": { + "block": "val:p/y", + "port": "out" + }, + "to": { + "block": "val:p", + "port": "y" + } + } + ] + } + }, + "val:p/x": { + "key": "val:p/x", + "kind": "Literal", + "span": { + "file": "fil_rouge", + "start": 274, + "end": 275 + }, + "type": "3", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "3", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "val:p/y": { + "key": "val:p/y", + "kind": "Literal", + "span": { + "file": "fil_rouge", + "start": 281, + "end": 283 + }, + "type": "4", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "4", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "val:show": { + "key": "val:show", + "kind": "Function", + "name": "show", + "span": { + "file": "", + "start": 0, + "end": 253 + }, + "type": "fn(p: Point) -> char", + "inputs": [ + { + "name": "p", + "type": "Point", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "char", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:show/#0" + ], + "wires": [] + } + }, + "val:show/#0": { + "key": "val:show/#0", + "kind": "Literal", + "span": { + "file": "fil_rouge", + "start": 242, + "end": 249 + }, + "type": "\"Point\"", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "\"Point\"", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "val:sq": { + "key": "val:sq", + "kind": "Function", + "name": "sq", + "span": { + "file": "", + "start": 0, + "end": 133 + }, + "type": "fn(n: int) -> int", + "inputs": [ + { + "name": "n", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:sq/#0" + ], + "wires": [] + } + }, + "val:sq/#0": { + "key": "val:sq/#0", + "kind": "Operator", + "name": "*", + "span": { + "file": "fil_rouge", + "start": 124, + "end": 129 + }, + "type": "int", + "inputs": [ + { + "name": "lhs", + "type": "int", + "implicit": false, + "visibility": "Public" + }, + { + "name": "rhs", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [], + "wires": [ + { + "from": { + "block": "val:sq", + "port": "n" + }, + "to": { + "block": "val:sq/#0", + "port": "lhs" + } + }, + { + "from": { + "block": "val:sq", + "port": "n" + }, + "to": { + "block": "val:sq/#0", + "port": "rhs" + } + } + ] + } + }, + "val:total": { + "key": "val:total", + "kind": "Operator", + "name": "total", + "span": { + "file": "fil_rouge", + "start": 323, + "end": 341 + }, + "type": "int", + "inputs": [ + { + "name": "lhs", + "type": "int", + "implicit": false, + "visibility": "Public" + }, + { + "name": "rhs", + "type": "3", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:total/lhs", + "val:total/rhs" + ], + "wires": [ + { + "from": { + "block": "val:total/lhs", + "port": "out" + }, + "to": { + "block": "val:total", + "port": "lhs" + } + }, + { + "from": { + "block": "val:total/rhs", + "port": "out" + }, + "to": { + "block": "val:total", + "port": "rhs" + } + } + ] + } + }, + "val:total/lhs": { + "key": "val:total/lhs", + "kind": "Operator", + "name": "+", + "span": { + "file": "fil_rouge", + "start": 323, + "end": 331 + }, + "type": "int", + "inputs": [ + { + "name": "lhs", + "type": "int", + "implicit": false, + "visibility": "Public" + }, + { + "name": "rhs", + "type": "12", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [ + "val:total/lhs/lhs", + "val:total/lhs/rhs" + ], + "wires": [ + { + "from": { + "block": "val:total/lhs/lhs", + "port": "out" + }, + "to": { + "block": "val:total/lhs", + "port": "lhs" + } + }, + { + "from": { + "block": "val:total/lhs/rhs", + "port": "out" + }, + "to": { + "block": "val:total/lhs", + "port": "rhs" + } + } + ] + } + }, + "val:total/lhs/lhs": { + "key": "val:total/lhs/lhs", + "kind": "Apply", + "span": { + "file": "fil_rouge", + "start": 323, + "end": 331 + }, + "type": "int", + "inputs": [ + { + "name": "callee", + "implicit": false, + "visibility": "Public" + }, + { + "name": "arg0", + "type": "Point", + "implicit": false, + "visibility": "Public" + } + ], + "outputs": [ + { + "name": "out", + "type": "int", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User", + "body": { + "children": [], + "wires": [ + { + "from": { + "block": "val:p", + "port": "out" + }, + "to": { + "block": "val:total/lhs/lhs", + "port": "arg0" + } + } + ] + } + }, + "val:total/lhs/rhs": { + "key": "val:total/lhs/rhs", + "kind": "Literal", + "span": { + "file": "fil_rouge", + "start": 333, + "end": 336 + }, + "type": "12", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "12", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + }, + "val:total/rhs": { + "key": "val:total/rhs", + "kind": "Literal", + "span": { + "file": "fil_rouge", + "start": 338, + "end": 339 + }, + "type": "3", + "inputs": [], + "outputs": [ + { + "name": "out", + "type": "3", + "implicit": false, + "visibility": "Public" + } + ], + "origin": "User" + } + }, + "relations": [ + { + "kind": "TypePosition", + "from": "val:norm2", + "to": "type:Point", + "index": 0 + }, + { + "kind": "TypePosition", + "from": "val:show", + "to": "type:Point", + "index": 0 + }, + { + "kind": "Ref", + "from": "val:norm2/a", + "to": "val:sq", + "port": "callee", + "confidence": { + "confidence": "exact" + } + }, + { + "kind": "Ref", + "from": "val:norm2/#1/rhs", + "to": "val:sq", + "port": "callee", + "confidence": { + "confidence": "exact" + } + }, + { + "kind": "Ref", + "from": "val:norm2", + "to": "val:sq", + "port": "sq", + "confidence": { + "confidence": "exact" + } + }, + { + "kind": "HasType", + "from": "val:p", + "to": "type:Point" + }, + { + "kind": "Ref", + "from": "val:d", + "to": "type:Printable", + "port": "callee", + "confidence": { + "confidence": "exact" + } + }, + { + "kind": "HasType", + "from": "val:d", + "to": "type:Point" + }, + { + "kind": "Ref", + "from": "val:total/lhs/lhs", + "to": "val:norm2", + "port": "callee", + "confidence": { + "confidence": "exact" + } + }, + { + "kind": "Satisfies", + "from": "type:Point", + "to": "type:Printable", + "evidence": [ + { + "requires": "show: fn(a: SELF) -> char", + "provided_by": "val:show" + } + ] + }, + { + "kind": "HasType", + "from": "type:Point/x", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "type:Point/y", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "type:Printable/show", + "to": "type:char" + }, + { + "kind": "HasType", + "from": "val:norm2", + "to": "type:Point" + }, + { + "kind": "HasType", + "from": "val:norm2", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:norm2/#1", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:norm2/#1/rhs", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:norm2/#1/rhs/arg0", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:norm2/a", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:norm2/a/arg0", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:show", + "to": "type:Point" + }, + { + "kind": "HasType", + "from": "val:show", + "to": "type:char" + }, + { + "kind": "HasType", + "from": "val:sq", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:sq/#0", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:total", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:total/lhs", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:total/lhs/lhs", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "type:Printable", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:show/#0", + "to": "type:char" + }, + { + "kind": "HasType", + "from": "val:p/x", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:p/y", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:total/lhs/rhs", + "to": "type:int" + }, + { + "kind": "HasType", + "from": "val:total/rhs", + "to": "type:int" + } + ] +} diff --git a/crates/typr-graph/tests/totality.rs b/crates/typr-graph/tests/totality.rs new file mode 100644 index 00000000..37a6fec4 --- /dev/null +++ b/crates/typr-graph/tests/totality.rs @@ -0,0 +1,58 @@ +//! Étape 1 acceptance criterion (spec §12): "tout programme qui passe `typr check` produit un +//! graphe sans panique." Replays every `cases/*/repro/TypR/main.ty` — parsed standalone, so a +//! multi-file case that only type-checks as part of its whole project is simply skipped (not a +//! false negative: we only claim totality over programs that *do* pass type-checking here). + +use std::fs; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use typr_core::components::context::Context; +use typr_core::processes::parsing::parse_from_string; +use typr_core::processes::type_checking::type_recorder::with_recording; +use typr_core::processes::type_checking::typing_with_errors; + +fn cases_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../cases") +} + +#[test] +fn builds_every_typechecking_case_without_panicking() { + let cases_dir = cases_dir(); + let mut checked = 0usize; + let mut skipped = 0usize; + + for entry in fs::read_dir(&cases_dir).expect("cases/ directory should exist at the workspace root") { + let entry = entry.expect("readable cases/ entry"); + let main_ty = entry.path().join("repro/TypR/main.ty"); + if !main_ty.is_file() { + continue; + } + let source = fs::read_to_string(&main_ty).expect("readable main.ty"); + let file_name = main_ty.to_string_lossy().to_string(); + + let lang = catch_unwind(AssertUnwindSafe(|| parse_from_string(&source, &file_name))); + let Ok(lang) = lang else { + skipped += 1; + continue; + }; + + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + if result.has_errors() { + skipped += 1; + continue; + } + + // Built from the originally parsed tree, not `result.type_context.lang` — `typing()` on + // a `Lines` node returns only its last statement's rewritten form, not the whole program. + let outcome = catch_unwind(AssertUnwindSafe(|| { + typr_graph::build(&lang, &result.type_context.context, &table) + })); + assert!(outcome.is_ok(), "typr-graph panicked building {}", main_ty.display()); + checked += 1; + } + + assert!( + checked > 0, + "expected at least one cases/ repro to typecheck standalone and be built" + ); + eprintln!("totality: built {checked} case(s) without panicking, skipped {skipped} (parse/typecheck failure or multi-file dependency)"); +} diff --git a/crates/typr-graph/tests/walkthrough.rs b/crates/typr-graph/tests/walkthrough.rs new file mode 100644 index 00000000..aaf06bd4 --- /dev/null +++ b/crates/typr-graph/tests/walkthrough.rs @@ -0,0 +1,54 @@ +//! Snapshot test on the walkthrough example (`visualization_graph_v2.md` §14), the same program +//! already used in `typr-core`'s `type_recorder` tests to validate recording. Run `cargo insta +//! review` after a deliberate change to the builder to re-freeze the snapshot. + +use typr_core::components::context::Context; +use typr_core::processes::parsing::parse_from_string; +use typr_core::processes::type_checking::type_recorder::with_recording; +use typr_core::processes::type_checking::typing_with_errors; + +const FIL_ROUGE: &str = r#" +type Printable <- interface { show: (Self) -> char }; +type Point <- list { x: int, y: int }; + +let sq <- fn(n: int): int { n * n }; + +let norm2 <- fn(p: Point): int { + let a <- sq(p$x); + a + sq(p$y) +}; + +let show <- fn(p: Point): char { "Point" }; + +let p <- Point:{ x = 3, y = 4 }; +let d <- Printable(p); +let total <- norm2(p) + 12 + 3; +"#; + +#[test] +fn walkthrough_example_builds_without_panicking() { + let lang = parse_from_string(FIL_ROUGE, "fil_rouge"); + let (result, table) = with_recording(|| typing_with_errors(&Context::default(), &lang)); + assert!( + !result.has_errors(), + "walkthrough example should typecheck cleanly: {:?}", + result.display_errors() + ); + + // `typing()` on a `Lines` node returns the *last statement's* rewritten `Lang` in + // `type_context.lang` (mirroring a `Scope`'s "value is its last expression"), not the whole + // program — so the graph is built from the originally parsed tree, whose spans are still + // what the `TypeTable` was recorded against (spec §7.1's `build(&Lang, &Context, &TypeTable)` + // takes the parsed AST, not `typing()`'s return value). + let graph = typr_graph::build(&lang, &result.type_context.context, &table); + + assert!(graph + .blocks + .contains_key(&typr_graph::BlockKey::top_level(typr_graph::Namespace::Val, "norm2"))); + assert!(graph + .blocks + .contains_key(&typr_graph::BlockKey::top_level(typr_graph::Namespace::Type, "Point"))); + + let json = serde_json::to_string_pretty(&graph).unwrap(); + insta::assert_snapshot!(json); +} diff --git a/crates/typr-lsp/src/handlers/semantic_tokens.rs b/crates/typr-lsp/src/handlers/semantic_tokens.rs index e5c5a8e8..ce48a108 100644 --- a/crates/typr-lsp/src/handlers/semantic_tokens.rs +++ b/crates/typr-lsp/src/handlers/semantic_tokens.rs @@ -221,9 +221,11 @@ fn collect_type_tokens(typ: &Type, tokens: &mut Vec) { } collect_type_tokens(ret, tokens); } - Type::Vec(_, a, b, _) => { - collect_type_tokens(a, tokens); - collect_type_tokens(b, tokens); + Type::Vec(_, _, elem, _) => { + if let Some(length) = typ.vec_length() { + collect_type_tokens(&length, tokens); + } + collect_type_tokens(elem, tokens); } Type::Record(fields, _) | Type::Interface(fields, _) => { for f in fields { diff --git a/crates/typr-mcp/data/std.R b/crates/typr-mcp/data/std.R index 45f30abf..8cac50ae 100644 --- a/crates/typr-mcp/data/std.R +++ b/crates/typr-mcp/data/std.R @@ -81,6 +81,41 @@ typr_assert_type <- function(x, expected, loc, what) { x } +#' @title Vérifications des types raffinés (`[int] & length(3)`, `int & (> 0)`) +#' Émises par le compilateur seulement là où il n'a pas pu prouver le +#' raffinement. Elles renvoient `x` pour s'insérer dans n'importe quelle +#' expression ; `all()`/`isTRUE()` les rendent sûres face aux vecteurs et aux NA. +typr_refine_length <- function(x, n, loc) { + if (!isTRUE(length(x) == n)) { + stop(sprintf("Type refinement violation at %s: expected length %d, got %d", + loc, n, length(x)), call. = FALSE) + } + x +} + +typr_refine_length_range <- function(x, lo, lo_open, hi, hi_open, loc) { + n <- length(x) + ok <- (if (lo_open) n > lo else n >= lo) && (if (hi_open) n < hi else n <= hi) + if (!isTRUE(ok)) { + stop(sprintf("Type refinement violation at %s: length %d is out of bounds %s%s, %s%s", + loc, n, if (lo_open || is.infinite(lo)) "(" else "[", format(lo), format(hi), + if (hi_open || is.infinite(hi)) ")" else "]"), call. = FALSE) + } + x +} + +typr_refine_value <- function(x, lo, lo_open, hi, hi_open, loc) { + above <- if (lo_open) x > lo else x >= lo + below <- if (hi_open) x < hi else x <= hi + if (!isTRUE(all(above & below))) { + bad <- x[!(above & below)][1L] + stop(sprintf("Type refinement violation at %s: value %s is out of bounds %s%s, %s%s", + loc, format(bad), if (lo_open || is.infinite(lo)) "(" else "[", format(lo), format(hi), + if (hi_open || is.infinite(hi)) ")" else "]"), call. = FALSE) + } + x +} + # --- Génériques S3 --- get <- function(a, ...) UseMethod("get") diff --git a/crates/typr-mcp/src/explain.rs b/crates/typr-mcp/src/explain.rs index 8a4519e9..a128f8d9 100644 --- a/crates/typr-mcp/src/explain.rs +++ b/crates/typr-mcp/src/explain.rs @@ -185,6 +185,16 @@ pub const ENTRIES: &[ExplainEntry] = &[ bad: "let :{a, b} <- :{1, 2, 3};", good: "let :{a, b, c} <- :{1, 2, 3};", }, + ExplainEntry { + code: "S018", + title: "Malformed `@Id` bound suffix", + explanation: "In `Lovable@A`, the `@A` suffix names an implicit generic bounded by the \ + interface `Lovable`. It must be one capital letter touching the interface name: with a \ + space, or a longer id like `@Self`, the tail would be read as a separate type and \ + silently dropped.", + bad: "let f <- fn(a: Lovable @A): bool { true };", + good: "let f <- fn(a: Lovable@A): bool { true };", + }, ExplainEntry { code: "T001", title: "Type mismatch on a 'let' binding", @@ -571,6 +581,31 @@ pub const ENTRIES: &[ExplainEntry] = &[ bad: "type Personne <- list{ name: char };\ntype Voiture <- list{ marque: char };\nlet greet <- fn(x: Personne, y: Personne): Personne { x };\nlet p1 <- Personne:{ name = \"a\" };\nlet p2 <- Personne:{ name = \"b\" };\ngreet(p1, p2);", good: "type Personne <- list{ name: char };\ntype Voiture <- list{ marque: char };\nlet greet <- fn(x: Personne, y: Personne): Personne { x };\nlet p1 <- Personne:{ name = \"a\" };\nlet p2 <- Personne:{ name = \"b\" };\ngreet(p1, p2);", }, + ExplainEntry { + code: "T047", + title: "Two different bounds for one `@Id`", + explanation: "An `@Id` names one type, so `Lovable@A` and `Printable@A` in the same \ + signature would ask that type to be both interfaces at once. Give the second bound \ + its own id.", + bad: "type Lovable <- interface { love: (Self) -> char };\ntype Printable <- interface { show: (Self) -> char };\nlet f <- fn(a: Lovable@A, b: Printable@A): bool { true };", + good: "type Lovable <- interface { love: (Self) -> char };\ntype Printable <- interface { show: (Self) -> char };\nlet f <- fn(a: Lovable@A, b: Printable@B): bool { true };", + }, + ExplainEntry { + code: "T048", + title: "Bound id clashes with a free generic", + explanation: "Free generics (`T`) and `I@Id` ids share one namespace inside a signature; \ + using `T` for both would make it unclear whether `T` is unconstrained or bounded.", + bad: "type Lovable <- interface { love: (Self) -> char };\nlet f <- fn(a: T, b: Lovable@T): bool { true };", + good: "type Lovable <- interface { love: (Self) -> char };\nlet f <- fn(a: T, b: Lovable@A): bool { true };", + }, + ExplainEntry { + code: "T049", + title: "One `@Id` bound to two different types in a call", + explanation: "Parameters that share an id (`Lovable@A`, or a bare `Lovable` used twice) must \ + receive the same type. Give them different ids when they may differ.", + bad: "type Lovable <- interface { love: (Self) -> char };\ntype Cat <- list { name: char };\ntype Dog <- list { age: int };\nlet love <- fn(c: Cat): char { c.name };\nlet love <- fn(d: Dog): char { \"dog\" };\nlet cat <- Cat:{ name = \"tom\" };\nlet dog <- Dog:{ age = 3 };\nlet f <- fn(a: Lovable@X, b: Lovable@X): char { a.love() };\nf(cat, dog);", + good: "type Lovable <- interface { love: (Self) -> char };\ntype Cat <- list { name: char };\ntype Dog <- list { age: int };\nlet love <- fn(c: Cat): char { c.name };\nlet love <- fn(d: Dog): char { \"dog\" };\nlet cat <- Cat:{ name = \"tom\" };\nlet dog <- Dog:{ age = 3 };\nlet f <- fn(a: Lovable@X, b: Lovable@Y): char { a.love() };\nf(cat, dog);", + }, ]; pub fn find(code: &str) -> Option<&'static ExplainEntry> { diff --git a/crates/typr-wasm/Cargo.toml b/crates/typr-wasm/Cargo.toml index e4f6f946..208a9aab 100644 --- a/crates/typr-wasm/Cargo.toml +++ b/crates/typr-wasm/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["cdylib"] [dependencies] typr-core = { workspace = true, features = ["wasm"] } +typr-graph.workspace = true wasm-bindgen = "0.2" serde.workspace = true serde_json.workspace = true diff --git a/crates/typr-wasm/src/lib.rs b/crates/typr-wasm/src/lib.rs index fb8a1ef1..8804a185 100644 --- a/crates/typr-wasm/src/lib.rs +++ b/crates/typr-wasm/src/lib.rs @@ -18,6 +18,7 @@ use typr_core::processes::transpiling::clear_generated_files; use typr_core::processes::type_checking::type_checker::TypeChecker; +use typr_core::processes::type_checking::type_recorder::with_recording; use typr_core::{Compiler, InMemorySourceProvider}; use wasm_bindgen::prelude::*; @@ -262,6 +263,157 @@ pub fn compile_multiple(files_json: &str) -> Result { }) } +/// Build the block-graph view of TypR source code (`visualization_graph_v2.md`) +/// +/// The graph is only built for source that type-checks: totality of the builder (no panic) is +/// only guaranteed for a program that passes `typr check` (spec §12 étape 1). When there are +/// type errors, `has_errors` is set and `graph_json` is left empty, matching `typeCheck()`. +#[wasm_bindgen(js_name = semanticGraph)] +pub fn semantic_graph(source: &str) -> Result { + let mut sources = InMemorySourceProvider::new(); + sources.add_source("main.ty", source); + + let compiler = Compiler::new_wasm(sources); + let ast = compiler + .parse("main.ty") + .map_err(|e| JsValue::from_str(&format!("{}", e)))?; + + let (result, table) = with_recording(|| compiler.type_check(&ast)); + + if result.has_errors() { + return Ok(GraphResult { + graph_json: String::new(), + has_errors: true, + errors: result + .get_errors() + .iter() + .map(|e| e.clone().display()) + .collect::>() + .join("\n\n"), + }); + } + + let graph = typr_graph::build(&ast, &result.type_context.context, &table); + let graph_json = + typr_graph::export::json::to_string_pretty(&graph).map_err(|e| JsValue::from_str(&e.to_string()))?; + + Ok(GraphResult { + graph_json, + has_errors: false, + errors: String::new(), + }) +} + +/// Build the block-graph views of two versions of TypR source and diff them +/// (`visualization_graph_v2.md` §12 étape 6), for the playground's diff view. +/// +/// Each side is built independently through the same pipeline as `semanticGraph`: if either +/// side has type errors, the graph on that side is not built and the diff is not computed +/// (`has_errors`/`errors` describe whichever side(s) failed — both sides are still type-checked +/// so a caller sees every error, not just the first). Both `old_graph_json` and `new_graph_json` +/// are included alongside `diff_json` because the diff itself only carries keys, not full block +/// data — the playground renders the diff overlaid on the *new* graph's layout (matching `typr +/// graph diff`'s framing of "how did the new version change relative to the old one"), but still +/// needs the *old* graph to show what a removed block actually was (kind, name) since a removed +/// key has no entry in the new graph to look it up in. +#[wasm_bindgen(js_name = semanticGraphDiff)] +pub fn semantic_graph_diff(old_source: &str, new_source: &str) -> Result { + let build_one = |source: &str| -> Result, JsValue> { + let mut sources = InMemorySourceProvider::new(); + sources.add_source("main.ty", source); + let compiler = Compiler::new_wasm(sources); + let ast = compiler + .parse("main.ty") + .map_err(|e| JsValue::from_str(&format!("{}", e)))?; + + let (result, table) = with_recording(|| compiler.type_check(&ast)); + if result.has_errors() { + let errors = result + .get_errors() + .iter() + .map(|e| e.clone().display()) + .collect::>() + .join("\n\n"); + return Ok(Err(errors)); + } + + Ok(Ok(typr_graph::build(&ast, &result.type_context.context, &table))) + }; + + let old_result = build_one(old_source)?; + let new_result = build_one(new_source)?; + + let mut errors = Vec::new(); + if let Err(e) = &old_result { + errors.push(format!("(old) {}", e)); + } + if let Err(e) = &new_result { + errors.push(format!("(new) {}", e)); + } + if !errors.is_empty() { + return Ok(GraphDiffResult { + diff_json: String::new(), + old_graph_json: String::new(), + new_graph_json: String::new(), + has_errors: true, + errors: errors.join("\n\n"), + }); + } + + let old_graph = old_result.expect("checked Ok above"); + let new_graph = new_result.expect("checked Ok above"); + let diff = typr_graph::diff::diff(&old_graph, &new_graph); + + let diff_json = serde_json::to_string_pretty(&diff).map_err(|e| JsValue::from_str(&e.to_string()))?; + let old_graph_json = + typr_graph::export::json::to_string_pretty(&old_graph).map_err(|e| JsValue::from_str(&e.to_string()))?; + let new_graph_json = + typr_graph::export::json::to_string_pretty(&new_graph).map_err(|e| JsValue::from_str(&e.to_string()))?; + + Ok(GraphDiffResult { + diff_json, + old_graph_json, + new_graph_json, + has_errors: false, + errors: String::new(), + }) +} + +/// Result of building and diffing the block-graph views of two source versions +#[wasm_bindgen] +pub struct GraphDiffResult { + /// Pretty-printed JSON of the `GraphDiff` (added/removed/modified block keys), empty when + /// `has_errors` is set + #[wasm_bindgen(getter_with_clone)] + pub diff_json: String, + /// Pretty-printed JSON of the *old* version's `BlockGraph` — only its `blocks` map is + /// actually needed (to look up a removed key's kind/name), not its layout + #[wasm_bindgen(getter_with_clone)] + pub old_graph_json: String, + /// Pretty-printed JSON of the *new* version's `BlockGraph`, so the playground can render the + /// diff overlaid on a real layout without a second round-trip + #[wasm_bindgen(getter_with_clone)] + pub new_graph_json: String, + /// Whether type errors on either side prevented building the diff + pub has_errors: bool, + /// Formatted type error messages, prefixed with `(old)`/`(new)` per side (empty if none) + #[wasm_bindgen(getter_with_clone)] + pub errors: String, +} + +/// Result of building the block-graph view +#[wasm_bindgen] +pub struct GraphResult { + /// Pretty-printed JSON of the `BlockGraph` (spec §9), empty when `has_errors` is set + #[wasm_bindgen(getter_with_clone)] + pub graph_json: String, + /// Whether type errors prevented building the graph + pub has_errors: bool, + /// Formatted type error messages (empty string if no errors) + #[wasm_bindgen(getter_with_clone)] + pub errors: String, +} + /// Result of compilation #[wasm_bindgen] pub struct CompileResult { diff --git a/editors/rstudio/DESCRIPTION b/editors/rstudio/DESCRIPTION index 0698d6c6..257e3898 100644 --- a/editors/rstudio/DESCRIPTION +++ b/editors/rstudio/DESCRIPTION @@ -1,6 +1,6 @@ Package: typr.runner Title: Run TypR from RStudio and Positron -Version: 0.5.12 +Version: 0.6.0 Authors@R: person("Fabrice", "Hategekimana", email = "fab.hatege15@gmail.com", role = c("aut", "cre")) Description: RStudio addins to create, check, build, test and run TypR projects without leaving the IDE. Wraps the TypR compiler binary, which is bundled in diff --git a/editors/vim/syntax/typr.vim b/editors/vim/syntax/typr.vim index 2f6f32a7..edc89e9c 100644 --- a/editors/vim/syntax/typr.vim +++ b/editors/vim/syntax/typr.vim @@ -43,6 +43,7 @@ syn match typrTypesVariant "\v\.[A-Z][A-Za-z0-9_]*>" syn match typrKeywordsBlock "\v<%(Test|JS|R)%(\s*[\[{])@=" syn match typrTypesConstructor "\v<%(library|Class|seq|c)%(\s*[\[(])@=" syn match typrTypesBuiltinIndexed "\v<%(df)%(\s*\[)@=" +syn match typrTypesRefinement "\v<%(length)%(\s*\(\s*[\d<>])@=" syn match typrTypesBuiltin "\v<%(UnknownFunction|data__frame|data\.frame|dataframe|Record|Array|Tuple|tuple|list|Vec)[A-Za-z0-9_]@!" syn keyword typrTypesPrimitive int num char bool logic Any Empty Self syn keyword typrConstants true TRUE false FALSE null NULL na NA @@ -75,6 +76,7 @@ hi def link typrKeywordsOperatorWord Keyword hi def link typrConstants Constant hi def link typrTypesPrimitive Type hi def link typrTypesBuiltin Type +hi def link typrTypesRefinement Type hi def link typrTypesBuiltinIndexed Type hi def link typrTypesConstructor Function hi def link typrKeywordsBlock Keyword diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index 44fcdc3b..7f61dc3d 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "typr-language", - "version": "0.5.12", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "typr-language", - "version": "0.5.12", + "version": "0.6.0", "license": "Apache-2.0", "devDependencies": { "@types/node": "^25.2.3", diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 8f026939..53237b89 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "typr-language", "displayName": "TypR Tooling and Language Support", "description": "Language support for typR - A typed version of R with TypeScript-like typing and Rust-like syntax", - "version": "0.5.12", + "version": "0.6.0", "publisher": "wedata-ch", "license": "Apache-2.0", "engines": { diff --git a/editors/vscode/syntaxes/typr.tmLanguage.json b/editors/vscode/syntaxes/typr.tmLanguage.json index 26c59504..0b882236 100644 --- a/editors/vscode/syntaxes/typr.tmLanguage.json +++ b/editors/vscode/syntaxes/typr.tmLanguage.json @@ -54,6 +54,9 @@ { "include": "#types.builtin" }, + { + "include": "#types.refinement" + }, { "include": "#types.builtin-indexed" }, @@ -473,6 +476,15 @@ } ] }, + "types.refinement": { + "comment": "`[int] & length(5)`, `[int] & length(> 0)` (`parsing/types.rs::refinement_property`). `length` is also R's most common function, so it only counts glued to `(` or `(`: `length(x)` stays a plain call.", + "patterns": [ + { + "match": "(?])", + "name": "support.type.builtin.typr" + } + ] + }, "types.sigil-generic": { "comment": "Sigil + generic. A generic name is a *single* uppercase letter or `Self` (`parsing/types.rs::upper_case_generic`), so the rule stops there instead of running on into a PascalCase alias.", "patterns": [ diff --git a/install/README.md b/install/README.md new file mode 100644 index 00000000..9c920e8a --- /dev/null +++ b/install/README.md @@ -0,0 +1,124 @@ +# Installation + +TypR s'installe en une commande. Le script vérifie l'empreinte SHA-256 de ce +qu'il télécharge avant de l'exécuter, et n'écrit rien en dehors de votre dossier +utilisateur. + +## Linux, macOS + +```sh +curl -fsSL https://we-data-ch.github.io/typr.github.io/install/install.sh | sh +``` + +Le script est un `sh` POSIX : ni bash, ni `jq`, ni `python`. Il s'exécute +n'importe où, y compris sur une Alpine sans glibc. + +Sur Linux, il installe un binaire **musl**, lié statiquement, qui démarre sur +Rocky 9, Debian 12, Ubuntu 22.04 et Alpine alike. Pour un binaire **GNU** +(lié dynamiquement à glibc), ajoutez `--gnu`. + +## Windows + +PowerShell 5.1 est présent sur toute machine Windows 10 ou plus récente : + +```powershell +irm https://we-data-ch.github.io/typr.github.io/install/install.ps1 | iex +``` + +Le script pose le binaire dans `%LOCALAPPDATA%\Programs\typr`, ajoute ce +dossier au PATH utilisateur, et ne demande aucun privilège administrateur. Il +n'est pas signé : PowerShell affichera *« Unrecognized script from +typr-install »*, avec son nom et son origine. Vérifiez que la ligne correspond +à ce dépôt avant de taper `O`. + +Le PATH ne prend effet qu'au prochain terminal ; le script met aussi à jour la +session courante, donc `typr --version` fonctionne immédiatement. + +## Options + +Les deux scripts partagent la même ligne de commande. + +| Option | Effet | +|---|---| +| `--version vX.Y.Z` | installe ce tag précis au lieu du dernier | +| `--channel beta` | installe la dernière prerelease | +| `--dry-run` | affiche ce qui serait fait, ne télécharge rien | +| `--help` | l'aide du script | +| `--gnu` | Unix seulement : cible glibc au lieu de musl | + +Sur PowerShell, les options s'écrivent `-Version`, `-Channel`, `-DryRun`, `-Gnu`. +Sur la ligne `irm … | iex`, elles ne sont pas transmissibles : `iex` n'accepte +aucun argument. Utilisez alors les variables d'environnement, ou téléchargez le +script dans un fichier et lancez-le. + +| Variable | Effet | +|---|---| +| `TYPR_INSTALL_DIR` | dossier d'installation | +| `TYPR_INSTALL_VERIFY=0` | saute la vérification SHA-256 (dépannage réseau) | + +## Ce qui est écrit + +| Système | Emplacement | +|---|---| +| Linux, macOS | `$HOME/.local/bin` | +| Windows | `%LOCALAPPDATA%\Programs\typr`, ajouté au PATH utilisateur par registre | + +Sur Unix, le script **n'écrit dans aucun fichier de configuration** : si +`$HOME/.local/bin` n'est pas déjà dans votre PATH, il affiche la ligne exacte à +ajouter à `~/.profile` ou `~/.zshrc`. Le laisser modifier votre profil à votre +place serait une surprise, et un `.profile` réécrit à tort peut empêcher votre +shell de démarrer. + +Sur Windows en revanche, le PATH est mis à jour dans le registre — c'est le +seul endroit où Windows le lit. Le changement ne s'applique qu'au prochain +terminal, et le script le dit. + +Dans les deux cas, rien d'autre n'est écrit : pas de fichier système, aucun +privilège administrateur, et le dossier temporaire de téléchargement est +supprimé en cas d'échec comme de succès. + +## Vérifier l'empreinte soi-même + +Si vous ne faites confiance ni au script ni à TLS : + +```sh +curl -fsSLO https://github.com/we-data-ch/typr/releases/latest/download/checksums.txt +grep "x86_64-unknown-linux-musl.tar.gz" checksums.txt +``` + +La ligne attendue commence par une empreinte SHA-256 de 64 caractères +hexadécimaux. C'est exactement la ligne que le script cherche, et il refuse de +continuer si elle est absente ou malformée — y compris lorsque le fichier existe +et contient bien le nom de l'archive. Un `checksums.txt` tronqué ou corrompu est +donc un échec, pas une installation sans vérification. + +## Codes de sortie + +| Code | Signification | +|---|---| +| `0` | installation réussie | +| `1` | environnement ou vérification : réseau coupé, release absente, SHA invalide | +| `2` | ligne de commande : option inconnue, argument manquant, tag illisible | + +La distinction 1 / 2 est volontaire : un appelant peut réagir différemment à une +mauvaise commande et à une release cassée. + +## Dépannage + +**`GLIBC_2.39 not found`** — le binaire GNU exige une glibc plus récente que +celle de votre système. C'est ce qui se passait par défaut avant que musl ne +devienne le choix par défaut. Rejouez sans `--gnu`. + +**`typr: command not found`** — le dossier `$HOME/.local/bin` n'est pas dans le +PATH de ce terminal. Ouvrez-en un nouveau, ou lancez `$HOME/.local/bin/typr`. + +**Le téléchargement échoue alors que le réseau fonctionne** + +`TYPR_INSTALL_VERIFY=0` désactive la vérification SHA-256. À n'utiliser que pour +confirmer qu'un problème de réseau est bien la cause : cela retire la seule +protection contre une archive altérée en transit. + +**Alerte de sécurité PowerShell** — le script n'est pas signé. `irm … | iex` +exécute du code téléchargé ; c'est le compromis habituel du one-liner, contre la +signature Authenticode qui exigerait un certificat de signature de code à jour +pour chaque binaire publié. \ No newline at end of file diff --git a/install/install.ps1 b/install/install.ps1 new file mode 100644 index 00000000..77eccf8a --- /dev/null +++ b/install/install.ps1 @@ -0,0 +1,505 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + TypR — installation en une commande (Windows). + +.DESCRIPTION + powershell -c "irm https://we-data-ch.github.io/typr.github.io/install/install.ps1 | iex" + + Le format est .zip et non .tar.gz : Expand-Archive est le seul outil + d'extraction présent sur un Windows sans rien installer, alors qu'il + faudrait tar.exe (livré à partir de Windows 10 1803, absent de Windows 7/8.1 + et de Windows Server 2012). + + Cible %LOCALAPPDATA%\Programs\typr : convention utilisateur moderne, aucun + droit administrateur. Le PATH utilisateur est mis à jour par registre, donc + il ne prend effet qu'au prochain terminal — le script le dit, et met + également à jour $env:Path de la session courante pour que `typr --version` + fonctionne immédiatement. + + Le binaire n'est pas signé. Un fichier posé par Expand-Archive ne porte pas + l'attribut Mark-of-the-Web (seuls les navigateurs et les zip téléchargés en + le posent) : pas de SmartScreen, contrairement à un exécutable téléchargé + puis double-cliqué. + +.PARAMETER Version + Tag à installer (ex. v0.5.12). Par défaut, la dernière version stable. + +.PARAMETER Channel + stable (défaut) ou beta. + +.PARAMETER DryRun + Affiche ce qui serait fait, ne télécharge rien. + +.PARAMETER Gnu + Sans effet sur Windows ; accepté pour que la même ligne de commande + fonctionne sur les deux scripts. + +.NOTES + Rien n'est écrit hors de %LOCALAPPDATA% et aucun privilège administrateur + n'est requis. +#> + +[CmdletBinding()] +param( + [string] $Version, + [ValidateSet('stable', 'beta')] + [string] $Channel = 'stable', + [switch] $DryRun, + [switch] $Gnu +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Configuration +# +# Les variables d'environnement ci-dessous sont surchargées par les tests, qui +# servent une release locale : sans cela, tester le refus d'un SHA falsifié +# supposerait de falsifier une vraie release publiée. +# --------------------------------------------------------------------------- +$Repo = if ($env:TYPR_INSTALL_REPO) { $env:TYPR_INSTALL_REPO } else { 'we-data-ch/typr' } +$Origin = if ($env:TYPR_INSTALL_ORIGIN) { $env:TYPR_INSTALL_ORIGIN } else { 'https://github.com' } +$Docs = if ($env:TYPR_INSTALL_DOCS) { $env:TYPR_INSTALL_DOCS } else { 'https://we-data-ch.github.io/typr.github.io' } + +$DownloadBase = "$Origin/$Repo/releases/download" +$LatestUrl = "$Origin/$Repo/releases/latest" +$ReleasesFeed = "$Origin/$Repo/releases.atom" + +$Verify = if ($env:TYPR_INSTALL_VERIFY) { $env:TYPR_INSTALL_VERIFY } else { '1' } +if ($Verify -notin @('0', '1')) { + Write-Error "TYPR_INSTALL_VERIFY doit valoir 0 ou 1 (reçu : '$Verify')." + exit 2 +} + +$script:TempDir = $null + +# `exit` ne passe pas par le `trap`, qui ne voit que les erreurs : le dossier +# temporaire est donc nettoyé explicitement ici. Sans cela, chaque échec laissait +# une archive à moitié téléchargée dans %TEMP%. +function Stop-Install { + param([string] $Message, [int] $Code = 1) + Remove-TempDir + Write-Host "typr-install : $Message" -ForegroundColor Red + exit $Code +} + +function Step { param([string] $Message) Write-Host "→ $Message" } +function Note { param([string] $Message) Write-Host " $Message" } +function Warn { + param([string] $Message) + Write-Host "typr-install : attention : $Message" -ForegroundColor Yellow +} + +function Remove-TempDir { + if ($script:TempDir -and (Test-Path -LiteralPath $script:TempDir)) { + Remove-Item -LiteralPath $script:TempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# Nettoyage du dossier temporaire en cas d'erreur, avec un code de retour non +# nul : sous `irm | iex`, un `exit 0` ferait croire à une installation réussie. +trap { + Remove-TempDir + Write-Error $_ + exit 1 +} + +# --------------------------------------------------------------------------- +# HTTP +# +# `HttpClient` plutôt que `Invoke-WebRequest` : la classe .NET Framework est +# présente à l'identique dans Windows PowerShell 5.1 et PowerShell 7, alors que +# les paramètres des cmdlets ont divergé entre les deux — `-UseBasicParsing` +# n'a pas le même sens partout, et `-MaximumRedirection 0` se comporte +# différemment, ou n'existe pas. Ici il n'y a aucun drapeau à se tromper. +function New-Client { + param([bool] $AllowRedirect = $true) + $handler = [System.Net.Http.HttpClientHandler]::new() + $handler.AllowAutoRedirect = $AllowRedirect + $client = [System.Net.Http.HttpClient]::new($handler) + $client.DefaultRequestHeaders.UserAgent.ParseAdd('typr-install') + return $client +} + +function Get-RedirectLocation { + param([string] $Url) + + # On veut l'en-tête de redirection, pas la destination : c'est lui qui porte + # le tag, et `/releases/latest` exclut les prereleases — exactement la + # sémantique du canal stable, sans dépendre de jq ni de l'API GitHub. + $client = New-Client -AllowRedirect $false + try { + $request = [System.Net.Http.HttpRequestMessage]::new( + [System.Net.Http.HttpMethod]::Head, $Url) + $response = $client.SendAsync($request).GetAwaiter().GetResult() + if ($null -ne $response.Headers.Location) { + return $response.Headers.Location.ToString() + } + return $null + } + catch { + return $null + } + finally { + $client.Dispose() + } +} + +function Get-String { + param([string] $Url) + + $client = New-Client + try { + $response = $client.GetAsync($Url).GetAwaiter().GetResult() + $response.EnsureSuccessStatusCode() | Out-Null + return $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + } + finally { + $client.Dispose() + } +} + +# Renvoie $false au lieu d'appeler Stop-Install : à chaque appelant de dire ce +# que l'absence du fichier signifie. +function Try-Save-File { + param([string] $Url, [string] $Path) + + # On écrit dans un fichier temporaire puis on renomme : si le téléchargement + # est interrompu, il ne reste pas un fichier à moitié écrit sous le nom + # définitif — l'archive est vérifiée avant d'être installée de toute façon. + $partial = "$Path.part" + $client = New-Client + try { + $response = $client.GetAsync($Url).GetAwaiter().GetResult() + $response.EnsureSuccessStatusCode() | Out-Null + + # `$source` et non `$input` : ce dernier est une variable automatique + # de PowerShell (l'ancien `$input` du pipeline), qui peut être réécrite + # par du code tiers chargé avant nous — ou par nous, ce qui ferait lire + # un $null à CopyTo et masquerait le téléchargement en « échec ». + $source = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $target = [System.IO.File]::Create($partial) + try { $source.CopyTo($target) } + finally { $target.Dispose(); $source.Dispose() } + + if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Force } + Move-Item -LiteralPath $partial -Destination $Path -Force + + # Explicite, et non implicite : une fonction PowerShell renvoie ce que + # son `try` a émis, or un téléchargement réussi n'émet rien. Sans ce + # `return`, Try-Save-File rendrait $null — donc falsy — et l'appelant + # lirait un succès comme un échec. + return $true + } + catch { + if (Test-Path -LiteralPath $partial) { + Remove-Item -LiteralPath $partial -Force -ErrorAction SilentlyContinue + } + return $false + } + finally { + $client.Dispose() + } +} + +function Save-File { + param([string] $Url, [string] $Path) + + if (-not (Try-Save-File $Url $Path)) { + Stop-Install "téléchargement échoué : $Url`n Vérifier le réseau, ou réessayer avec -Version vX.Y.Z." + } +} + +# --------------------------------------------------------------------------- +# Version +# --------------------------------------------------------------------------- +function Resolve-Version { + if ($Version) { + $tag = $Version -replace '^v', '' + return "v$tag" + } + + if ($Channel -eq 'stable') { + $location = Get-RedirectLocation $LatestUrl + if ($location -and $location -match '/tag/(v[^/?#]+)') { return $Matches[1] } + Stop-Install "impossible de déterminer la dernière version stable.`n URL interrogée : $LatestUrl`n Pour épingler une version : -Version vX.Y.Z" + } + + # Le canal beta doit lister les releases et filtrer. Le flux Atom en liste + # les dix dernières, prereleases comprises, et se parse sans dépendance. + # Les suffixes sont ceux que release.yml marque `prerelease`. + $xml = Get-String $ReleasesFeed + $tags = [regex]::Matches($xml, 'Repository/\d+/(v[^<]+)') | + ForEach-Object { $_.Groups[1].Value } + $prerelease = $tags | Where-Object { $_ -match '-(alpha|beta|rc)[.0-9]' } | Select-Object -First 1 + if (-not $prerelease) { + Stop-Install "aucune prerelease trouvée sur $ReleasesFeed.`n Le flux Atom ne liste que les dix dernières releases : si la dernière`n prerelease est plus ancienne, épinglez-la avec -Channel beta -Version vX.Y.Z" + } + return $prerelease +} + +function Assert-Tag { + param([string] $Tag) + if ($Tag -notmatch '^v[0-9]') { + Stop-Install "tag illisible : '$Tag' (attendu : vX.Y.Z)." 2 + } +} + +function Resolve-Target { + # Surcharge de test. `PROCESSOR_ARCHITECTURE` n'existe que sous Windows, donc + # sans cette variable le chemin download → SHA → extraction → PATH ne peut + # être exercé que sur un runner Windows. install.sh n'a pas le même besoin : + # `uname -m` existe partout où le script Unix tourne. + if ($env:TYPR_INSTALL_TARGET) { return $env:TYPR_INSTALL_TARGET } + + # Sous WOW64 (PowerShell 32 bits sur Windows 64), PROCESSOR_ARCHITECTURE + # vaut x86 et PROCESSOR_ARCHITEW6432 porte le vrai. Sur ARM64, on prend le + # binaire ARM : Windows l'émule de toute façon s'il ne l'exécute pas. + $arch = $env:PROCESSOR_ARCHITECTURE + if ($arch -eq 'x86' -and $env:PROCESSOR_ARCHITEW6432) { $arch = $env:PROCESSOR_ARCHITEW6432 } + + switch ($arch) { + 'AMD64' { return 'x86_64-pc-windows-msvc' } + 'ARM64' { return 'aarch64-pc-windows-msvc' } + 'x86' { + Stop-Install "TypR ne publie pas de binaire 32 bits (x86).`n Un Windows 64 bits a normalement PROCESSOR_ARCHITECTURE=AMD64 : si ce`n n'est pas le cas, le PowerShell ouvert est 32 bits — fermez-le et rouvrez-le." + } + default { + Stop-Install "architecture non supportée : '$arch'.`n TypR publie des binaires x86_64 et aarch64 pour Windows." + } + } +} + +function Get-OsName { + if ($env:OS) { return $env:OS } + # `$env:OS` n'existe que sous Windows ; sous PowerShell 7 sur Linux/macOS + # (où le script est testé), on retombe sur la description .NET. + if ([System.Runtime.InteropServices.RuntimeInformation]) { + return [System.Runtime.InteropServices.RuntimeInformation]::OSDescription + } + return 'Windows' +} + +function Resolve-InstallDir { + if ($env:TYPR_INSTALL_DIR) { return $env:TYPR_INSTALL_DIR } + if ($env:LOCALAPPDATA) { return (Join-Path $env:LOCALAPPDATA 'Programs\typr') } + Stop-Install "LOCALAPPDATA n'est pas défini : impossible de savoir où installer.`n Définissez TYPR_INSTALL_DIR." +} + +# --------------------------------------------------------------------------- +# SHA-256 +# --------------------------------------------------------------------------- +# `Get-FileHash` est présent depuis PowerShell 4.0 : pas de module à charger, +# contrairement à `certutil -hashfile` qui n'est pas garanti sur une image +# serveur minimale. +# +# On isole la seule ligne de checksums.txt qui concerne notre archive, puis on +# compare les empreintes. `sha256sum -c` échouerait ici — l'équivalent Windows +# essaierait de vérifier les sept autres archives de la release, non +# téléchargées, en sortant un code d'erreur alors que la nôtre est valide. +function Test-Checksum { + param([string] $Path, [string] $Name) + + Step 'Vérification du SHA-256' + + $sumsPath = Join-Path $script:TempDir 'checksums.txt' + $sumsUrl = "$DownloadBase/$script:Tag/checksums.txt" + # `Save-File` dirait « téléchargement échoué », ce qui ne dit pas *ce qui* + # manque. Ici la réponse est toujours la même — checksums.txt est introuvable + # ou le réseau est coupé — et l'utilisateur a besoin de la seconde phrase + # pour distinguer une release mal publiée d'un pare-feu. + if (-not (Try-Save-File $sumsUrl $sumsPath)) { + Stop-Install "checksums.txt introuvable pour $($script:Tag).`n URL : $sumsUrl`n Le fichier liste les huit archives de la release ; son absence signifie`n que la release est incomplète. Vérifier aussi le réseau." + } + + # On filtre sur le NOM, pas sur le couple SHA + nom. Filtrer sur les deux + # confondrait deux diagnostics très différents : « ce fichier ne parle pas + # de notre archive » et « la ligne existe mais son empreinte est illisible ». + # Le second est un fichier corrompu, le premier une release mal publiée. + # install.sh procède dans le même ordre, pour que les deux scripts nomment la + # même cause de la même façon. + $line = Get-Content -LiteralPath $sumsPath | + Where-Object { $_ -match ('\s+\*?' + [regex]::Escape($Name) + '$') } | + Select-Object -First 1 + + if (-not $line) { + $received = (Get-Content -LiteralPath $sumsPath) -join "`n" + Stop-Install "checksums.txt de $($script:Tag) ne contient aucune ligne pour $Name.`n Fichier reçu :`n$received" + } + + $expected = ($line.Trim() -split '\s+')[0] + if ($expected -notmatch '^[0-9a-f]{64}$') { + Stop-Install "ligne malformée dans checksums.txt pour $Name :`n $line`n Un SHA-256 fait 64 caractères hexadécimaux ; trouvé '$expected'." + } + + $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($expected -ne $actual) { + Stop-Install "SHA-256 falsifié pour $Name.`n attendu $expected`n obtenu $actual`n L'archive a été supprimée ; rien n'est installé." + } + + Note "SHA-256 correct ($actual)" +} + +# --------------------------------------------------------------------------- +# PATH +# --------------------------------------------------------------------------- +function Test-OnPath { + param([string] $Dir) + + $full = [System.IO.Path]::GetFullPath($Dir).TrimEnd('\') + $separator = [System.IO.Path]::PathSeparator + foreach ($entry in ($env:Path -split [regex]::Escape($separator))) { + if (-not $entry) { continue } + $normalized = [System.IO.Path]::GetFullPath($entry.Trim('"')).TrimEnd('\') + if ($normalized -ieq $full) { return $true } + } + return $false +} + +# Le PATH utilisateur est écrit dans le registre : il ne sera pris en compte +# qu'au prochain démarrage de terminal, ce qu'il faut dire explicitement. On +# met aussi à jour $env:Path de la session courante pour que `typr --version` +# fonctionne sans attendre. +function Add-ToUserPath { + param([string] $Dir) + + $current = [Environment]::GetEnvironmentVariable('Path', 'User') + if ($null -eq $current) { $current = '' } + $separator = [System.IO.Path]::PathSeparator + + foreach ($entry in ($current -split [regex]::Escape($separator))) { + if (-not $entry) { continue } + if ([System.IO.Path]::GetFullPath($entry.Trim('"')).TrimEnd('\') -ieq $Dir.TrimEnd('\')) { + return $false + } + } + + $new = if ([string]::IsNullOrWhiteSpace($current)) { $Dir } else { "$current$separator$Dir" } + try { + [Environment]::SetEnvironmentVariable('Path', $new, 'User') + } + catch { + # Un PATH verrouillé par une stratégie de groupe refuse l'écriture. Ce n'est pas + # fatal : le binaire est installé, seul son accès automatisé manque. + Warn "le PATH utilisateur n'a pas pu être écrit : $($_.Exception.Message)" + Warn "ajoutez manuellement : `$env:Path += ';' + '$Dir'" + return $false + } + return $true +} + +function Test-BinaryStart { + param([string] $Path) + + Step 'Vérification de l''installation' + $output = & $Path '--version' 2>&1 + if ($LASTEXITCODE -eq 0) { + $output | ForEach-Object { Note "$_" } + return + } + + # Le cas mesuré en septembre 2026 : l'installation se termine sans erreur, + # puis `typr --version` échoue. Le dire ici vaut mieux qu'un ticket. + Write-Host '' + Warn 'le binaire est installé mais ne démarre pas.' + Note "Message du binaire : $output" + Note 'Si celui-ci évoque une DLL introuvable (VCRUNTIME140.dll, ucrtbase.dll),' + Note "l'artefact n'a pas été lié statiquement : lire $Docs/install pour le dépannage." + exit 1 +} + +# --------------------------------------------------------------------------- +# Déroulement +# --------------------------------------------------------------------------- +$script:Tag = Resolve-Version +Assert-Tag $script:Tag +$target = Resolve-Target +$installDir = Resolve-InstallDir + +$artifact = "typr-$($script:Tag)-$target.zip" +$url = "$DownloadBase/$($script:Tag)/$artifact" + +Step "TypR $($script:Tag)" +Note "système : $(Get-OsName) ($env:PROCESSOR_ARCHITECTURE)" +Note "cible : $target" +Note "dossier : $installDir" + +if ($Verify -eq '0') { Warn 'vérification SHA-256 désactivée (TYPR_INSTALL_VERIFY=0).' } + +if ($DryRun) { + Step 'Simulation — rien ne sera téléchargé ni écrit' + Note "artefact : $artifact" + Note "url : $url" + Note "sha256 : $DownloadBase/$($script:Tag)/checksums.txt" + exit 0 +} + +if (-not (Test-Path -LiteralPath $installDir)) { + New-Item -ItemType Directory -Path $installDir -Force | Out-Null +} +if (-not (Test-Path -LiteralPath $installDir)) { + Stop-Install "impossible de créer $installDir." +} + +$script:TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("typr-install-" + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $script:TempDir -Force | Out-Null + +Step 'Téléchargement' +$archivePath = Join-Path $script:TempDir $artifact +Save-File $url $archivePath + +if ($Verify -ne '0') { + Test-Checksum $archivePath $artifact +} +else { + Step 'Vérification du SHA-256' + Note 'ignorée.' +} + +Step 'Extraction' +$extractDir = Join-Path $script:TempDir 'extract' +New-Item -ItemType Directory -Path $extractDir -Force | Out-Null +try { + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractDir -Force +} +catch { + Stop-Install "archive illisible : $artifact`n $($_.Exception.Message)" +} + +$exePath = Join-Path $extractDir 'typr.exe' +if (-not (Test-Path -LiteralPath $exePath)) { + Stop-Install "l'archive $artifact ne contient aucun binaire 'typr.exe'." +} + +$targetExe = Join-Path $installDir 'typr.exe' +# Remove-Item puis Copy-Item plutôt que Move-Item : un binaire en cours +# d'exécution sous Windows ne peut pas être écrasé, et l'échec serait +# confondu avec un problème d'écriture. L'opération reste idempotente. +if (Test-Path -LiteralPath $targetExe) { + Remove-Item -LiteralPath $targetExe -Force +} +Copy-Item -LiteralPath $exePath -Destination $targetExe -Force + +Write-Host '' +Step "TypR $($script:Tag) installé dans $installDir" + +if (Test-OnPath $installDir) { + Test-BinaryStart $targetExe +} +else { + if (Add-ToUserPath $installDir) { + $env:Path = "$installDir$([System.IO.Path]::PathSeparator)$env:Path" + Warn "$installDir a été ajouté au PATH utilisateur." + Note 'Il ne sera pris en compte qu''au prochain démarrage d''un terminal.' + Note 'Celui-ci est à jour : `typr --version` fonctionne dès maintenant.' + } + Write-Host '' + Note "Essayez d'abord : $targetExe --version" +} + +Remove-TempDir + +Write-Host '' +Note "Documentation : $Docs" \ No newline at end of file diff --git a/install/install.sh b/install/install.sh new file mode 100755 index 00000000..e59d5d7d --- /dev/null +++ b/install/install.sh @@ -0,0 +1,423 @@ +#!/bin/sh +# TypR — installation en une commande (Linux, macOS). +# +# curl -fsSL https://we-data-ch.github.io/typr.github.io/install/install.sh | sh +# +# POSIX sh de bout en bout : ni bash, ni zsh, ni jq, ni python. Un script +# d'installation qui exige un interpréteur que la machine n'a pas transforme le +# canal principal en ticket. +# +# Sur Linux le script vise les binaires musl, liés statiquement : aucune +# contrainte de glibc, donc le binaire démarre sur Rocky 9 (2.34), Debian 12 +# (2.36), Ubuntu 22.04 (2.35) *et* Alpine. Les binaires GNU ne sont choisis que +# sur demande explicite (--gnu), jamais par défaut — c'était le défaut mesuré sur +# v0.5.12 (`GLIBC_2.39 not found` sur les trois premières distributions). + +set -eu + +# --------------------------------------------------------------------------- +# Configuration +# +# Les trois variables ci-dessous existent pour être surchargées par les tests +# CI, qui servent une arborescence de release locale : sans cela, vérifier le +# refus d'un SHA falsifié exigerait de falsifier une vraie release. +# --------------------------------------------------------------------------- +REPO="${TYPR_INSTALL_REPO:-we-data-ch/typr}" +ORIGIN="${TYPR_INSTALL_ORIGIN:-https://github.com}" +DOCS="${TYPR_INSTALL_DOCS:-https://we-data-ch.github.io/typr.github.io}" + +DOWNLOAD_BASE="$ORIGIN/$REPO/releases/download" +LATEST_URL="$ORIGIN/$REPO/releases/latest" +RELEASES_FEED="$ORIGIN/$REPO/releases.atom" + +CHANNEL="stable" +VERSION="" +LIBC="musl" +VERIFY="${TYPR_INSTALL_VERIFY:-1}" +DRY_RUN="0" +INSTALL_DIR="" + +TMP="" + +# --------------------------------------------------------------------------- +# Sortie +# --------------------------------------------------------------------------- +# Erreur d'environnement ou de vérification : le monde est en cause (1). +die() { + printf 'typr-install : %s\n' "$1" >&2 + exit 1 +} + +# Erreur de saisie : la ligne de commande est en cause (2). La distinction +# permet à un appelant de réagir différemment — et à un test d'affirmer que le +# refus vient bien du script. +usage_die() { + printf 'typr-install : %s\n' "$1" >&2 + printf '\n' >&2 + usage >&2 + exit 2 +} + +step() { printf '→ %s\n' "$*"; } +note() { printf ' %s\n' "$*"; } +warn() { printf 'typr-install : attention : %s\n' "$*" >&2; } + +cleanup() { + if [ -n "$TMP" ] && [ -d "$TMP" ]; then + rm -rf "$TMP" + fi +} + +usage() { + cat <<'EOF' +TypR — installeur Unix (Linux, macOS) + +Usage : + install.sh [options] + +Options : + --version installe ce tag précis (ex. v0.5.12) au lieu du dernier + --channel stable (défaut) ou beta + --gnu Linux : cible glibc dynamique au lieu de musl + --dry-run affiche ce qui serait fait, ne télécharge rien + --help cette aide + +Environnement : + TYPR_INSTALL_DIR dossier d'installation (défaut : $HOME/.local/bin) + TYPR_INSTALL_VERIFY mettre à 0 pour sauter la vérification SHA-256. + Réservé au dépannage réseau ; jamais le mode par défaut. + +Rien n'est écrit hors de $HOME et aucun privilège administrateur n'est requis. +EOF +} + +# --------------------------------------------------------------------------- +# Arguments +# --------------------------------------------------------------------------- +while [ $# -gt 0 ]; do + case "$1" in + --version) + [ $# -ge 2 ] || usage_die "--version attend un tag (ex. --version v0.5.12)." + VERSION="$2" + shift 2 + ;; + --version=*) + VERSION="${1#--version=}" + shift + ;; + --channel) + [ $# -ge 2 ] || usage_die "--channel attend stable ou beta." + CHANNEL="$2" + shift 2 + ;; + --channel=*) + CHANNEL="${1#--channel=}" + shift + ;; + --gnu) + LIBC="gnu" + shift + ;; + --dry-run) + DRY_RUN="1" + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + usage_die "argument inconnu : $1" + ;; + esac +done + +case "$CHANNEL" in + stable|beta) ;; + *) usage_die "canal inconnu : '$CHANNEL' (stable ou beta)." ;; +esac + +case "$VERIFY" in + 0|1) ;; + *) usage_die "TYPR_INSTALL_VERIFY doit valoir 0 ou 1 (reçu : '$VERIFY')." ;; +esac + +command -v curl >/dev/null 2>&1 || die "curl est requis et n'est pas installé." +command -v tar >/dev/null 2>&1 || die "tar est requis et n'est pas installé." + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +# --------------------------------------------------------------------------- +# Cible : uname -s / uname -m vers le triple Rust +# --------------------------------------------------------------------------- +case "$(uname -m)" in + x86_64|amd64) ARCH="x86_64" ;; + arm64|aarch64) ARCH="aarch64" ;; + *) + die "architecture non supportée : $(uname -m). + TypR publie des binaires x86_64 et aarch64." + ;; +esac + +case "$(uname -s)" in + Linux) + if [ "$LIBC" = "gnu" ]; then + TARGET="$ARCH-unknown-linux-gnu" + else + TARGET="$ARCH-unknown-linux-musl" + fi + FORMAT="tar.gz" + ;; + Darwin) + TARGET="$ARCH-apple-darwin" + FORMAT="tar.gz" + ;; + *) + die "système non supporté : $(uname -s). + Windows : powershell -c \"irm https://$DOCS/install/install.ps1 | iex\"" + ;; +esac + +# Sous Rosetta, `uname -m` renvoie x86_64 sur une machine Apple Silicon et le +# binaire Intel y fonctionne : aucun cas particulier n'est nécessaire. + +# --------------------------------------------------------------------------- +# Version +# --------------------------------------------------------------------------- +validate_tag() { + case "$1" in + v[0-9]*) return 0 ;; + *) usage_die "tag illisible : '$1' (attendu : vX.Y.Z)." ;; + esac +} + +resolve_version() { + if [ -n "$VERSION" ]; then + case "$VERSION" in + v*) TAG="$VERSION" ;; + *) TAG="v$VERSION" ;; + esac + validate_tag "$TAG" + return 0 + fi + + if [ "$CHANNEL" = "stable" ]; then + # `/releases/latest` redirige vers `/tag/` et exclut les prereleases — + # exactement la sémantique de la branche stable. Lire l'en-tête Location + # évite d'avoir à supposer `jq` ou `python` sur la machine. + LOC=$(curl -fsSI "$LATEST_URL" \ + | tr -d '\r' \ + | grep -i '^location:' \ + | sed 's|.*/tag/||' \ + | head -1 || true) + [ -n "$LOC" ] || die "impossible de déterminer la dernière version stable. + URL interrogée : $LATEST_URL + Pour épingler une version : --version vX.Y.Z" + TAG="$LOC" + else + # Le canal beta doit lister les releases et filtrer. Le flux Atom en liste + # les dix dernières, prereleases comprises, et se parse avec grep et sed — + # même contrainte qu'au-dessus. Les suffixes sont ceux que release.yml + # marque `prerelease`. + TAG=$(curl -fsSL "$RELEASES_FEED" \ + | grep -oE 'Repository/[0-9]+/v[^<]+' \ + | sed 's|.*/||' \ + | grep -E -- '-(alpha|beta|rc)[.0-9]' \ + | head -1 || true) + [ -n "$TAG" ] || die "aucune prerelease trouvée sur $RELEASES_FEED. + Le flux Atom ne liste que les dix dernières releases : si la dernière + prerelease est plus ancienne, épinglez-la avec --channel beta --version vX.Y.Z" + fi + + validate_tag "$TAG" +} + +# --------------------------------------------------------------------------- +# SHA-256 +# --------------------------------------------------------------------------- +# GNU Coreutils expose `sha256sum` ; macOS livre `shasum -a 256`. Les deux +# doivent être tentés, sinon le canal principal échoue sur un tiers des machines. +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | cut -d' ' -f1 + else + die "ni sha256sum ni shasum n'est disponible : le SHA-256 ne peut pas être + vérifié. Relancer avec TYPR_INSTALL_VERIFY=0 pour installer sans vérifier." + fi +} + +verify_download() { + file="$1" + name="$2" + + step "Vérification du SHA-256" + + if ! curl -fsSL "$DOWNLOAD_BASE/$TAG/checksums.txt" -o "$TMP/checksums.txt"; then + die "checksums.txt introuvable pour $TAG. + URL : $DOWNLOAD_BASE/$TAG/checksums.txt" + fi + + # `sha256sum -c checksums.txt` échouerait ici : le fichier liste les huit + # archives de la release, et l'outil tenterait de vérifier les sept que + # nous n'avons pas téléchargées, en sortant un code non nul même quand la + # nôtre est valide. On isole donc la seule ligne qui la concerne, et on + # compare les empreintes nous-mêmes — sans dépendre du format de `-c`. + # + # Les points du nom de fichier sont échappés : sans cela, le motif matche + # n'importe quel caractère à leur place. + PATTERN=$(printf '%s' "$name" | sed 's/[.[\*^$]/\\&/g') + LINE=$(grep -E "[[:space:]]\*?${PATTERN}\$" "$TMP/checksums.txt" || true) + + [ -n "$LINE" ] || die "checksums.txt de $TAG ne contient aucune ligne pour $name. + Fichier reçu : +$(cat "$TMP/checksums.txt")" + + EXPECTED=$(printf '%s\n' "$LINE" | cut -d' ' -f1) + + # Un SHA-256 fait 64 caractères hexadécimaux. Sans ce contrôle, une ligne + # tronquée ou malformée produirait un « SHA-256 falsifié » qui accuse + # l'archive à tort : le message désignerait le mauvais fichier. + printf '%s\n' "$EXPECTED" | grep -Eq '^[0-9a-f]{64}$' \ + || die "ligne malformée dans checksums.txt pour $name : + $LINE + Un SHA-256 fait 64 caractères hexadécimaux." + + ACTUAL=$(sha256_of "$file") + [ "$EXPECTED" = "$ACTUAL" ] || die "SHA-256 falsifié pour $name. + attendu $EXPECTED + obtenu $ACTUAL + L'archive a été supprimée ; rien n'est installé." + + note "SHA-256 correct ($ACTUAL)" +} + +# --------------------------------------------------------------------------- +# Installation +# --------------------------------------------------------------------------- +resolve_install_dir() { + if [ -n "$INSTALL_DIR" ]; then + return 0 + fi + if [ -n "${TYPR_INSTALL_DIR:-}" ]; then + INSTALL_DIR="$TYPR_INSTALL_DIR" + return 0 + fi + [ -n "${HOME:-}" ] || die "HOME n'est pas défini et TYPR_INSTALL_DIR ne l'est pas + non plus : impossible de savoir où installer." + INSTALL_DIR="$HOME/.local/bin" +} + +path_contains() { + # Les entrées vides de PATH (`:$PATH`) ignorées, et un eventual `/` final + # toléré : `~/.local/bin/` et `~/.local/bin` désignent le même dossier. + RESOLVED=$(cd "$1" 2>/dev/null && pwd -P) || return 1 + OLDIFS="$IFS" + IFS=":" + for entry in $PATH; do + [ -n "$entry" ] || continue + NORMALIZED=$(cd "$entry" 2>/dev/null && pwd -P) || continue + if [ "$NORMALIZED" = "$RESOLVED" ]; then + IFS="$OLDIFS" + return 0 + fi + done + IFS="$OLDIFS" + return 1 +} + +warn_about_path() { + warn "$INSTALL_DIR n'est pas dans votre PATH." + note "Ajoutez ceci à ~/.profile (ou ~/.zshrc) :" + note " export PATH=\"$INSTALL_DIR:\$PATH\"" + note "Puis ouvrez un nouveau terminal." +} + +check_binary_starts() { + step "Vérification de l'installation" + if "$INSTALL_DIR/typr" --version; then + return 0 + fi + + # Le cas mesuré en septembre 2026 : l'installation se termine sans erreur, + # puis `typr --version` échoue. Le dire ici vaut mieux qu'un ticket. + printf '\n' >&2 + warn "le binaire est installé mais ne démarre pas." + note "Si le message ci-dessus évoque une version de glibc manquante, le" + note "binaire a été compilé pour une glibc plus récente que celle de cette" + note "machine. Relancer sans --gnu, ou lire $DOCS/install pour le dépannage." + exit 1 +} + +# --------------------------------------------------------------------------- +# Déroulement +# --------------------------------------------------------------------------- +resolve_version +resolve_install_dir + +ARTIFACT="typr-$TAG-$TARGET.$FORMAT" +URL="$DOWNLOAD_BASE/$TAG/$ARTIFACT" + +step "TypR $TAG" +note "système : $(uname -s) $(uname -m)" +note "cible : $TARGET" +note "dossier : $INSTALL_DIR" + +if [ "$VERIFY" = "0" ]; then + warn "vérification SHA-256 désactivée (TYPR_INSTALL_VERIFY=0)." +fi + +if [ "$DRY_RUN" = "1" ]; then + step "Simulation — rien ne sera téléchargé ni écrit" + note "artefact : $ARTIFACT" + note "url : $URL" + note "sha256 : $DOWNLOAD_BASE/$TAG/checksums.txt" + exit 0 +fi + +mkdir -p "$INSTALL_DIR" || die "impossible de créer $INSTALL_DIR. + Vérifier les droits, ou choisir un autre dossier avec TYPR_INSTALL_DIR." + +TMP=$(mktemp -d "${TMPDIR:-/tmp}/typr-install.XXXXXX") \ + || die "impossible de créer un dossier temporaire." + +step "Téléchargement" +curl -fL --progress-bar "$URL" -o "$TMP/$ARTIFACT" \ + || die "téléchargement échoué : $URL + Vérifier le réseau ou réessayer avec --version vX.Y.Z." + +if [ "$VERIFY" != "0" ]; then + verify_download "$TMP/$ARTIFACT" "$ARTIFACT" +else + step "Vérification du SHA-256" + note "ignorée." +fi + +step "Extraction" +mkdir -p "$TMP/extract" +tar -xzf "$TMP/$ARTIFACT" -C "$TMP/extract" \ + || die "archive illisible : $ARTIFACT" +[ -f "$TMP/extract/typr" ] \ + || die "l'archive $ARTIFACT ne contient aucun binaire 'typr'." + +# mv -f rend l'opération idempotente : relancer l'installeur remplace le +# binaire au lieu d'échouer dessus. +mv -f "$TMP/extract/typr" "$INSTALL_DIR/typr" +chmod 755 "$INSTALL_DIR/typr" + +printf '\n' +step "TypR $TAG installé dans $INSTALL_DIR" + +if path_contains "$INSTALL_DIR"; then + check_binary_starts +else + warn_about_path + printf '\n' + note "Essayez d'abord : $INSTALL_DIR/typr --version" +fi + +printf '\n' +note "Documentation : $DOCS" \ No newline at end of file diff --git a/install/tests/Dockerfile b/install/tests/Dockerfile new file mode 100644 index 00000000..2c09e6f5 --- /dev/null +++ b/install/tests/Dockerfile @@ -0,0 +1,30 @@ +# Image de test pour install.ps1. +# +# Le dépôt ne fournit pas de runner Windows, et `pwsh` sous Linux ne suffit +# pas : Expand-Archive, le registre utilisateur et les chemins %LOCALAPPDATA% +# sont des chemins de code Windows. On teste donc ce qui est testable ici — la +# logique, les diagnostics, les codes de retour — et la CI conserve une matrice +# Windows réelle pour la validation d bout en bout. +# +# Le tag `7.4-ubuntu-22.04` est épinglé, pas `latest` : une mise à jour de +# base ne doit pas pouvoir faire échouer la CI sur un changement de runtime. +FROM mcr.microsoft.com/powershell:7.4-ubuntu-22.04 + +RUN apt-get update \ + && apt-get install --no-install-recommends -y python3 \ + && rm -rf /var/lib/apt/lists/* + +# PSScriptAnalyzer est le équivalent PowerShell de shellcheck : il signale ce +# que l'analyse syntaxique laisse passer (variables inutilisées, pièges de +# comparaison, cmdlets obsolètes). Installé ici pour que la suite puisse le +# lancer sans téléchargement à chaque exécution. +RUN pwsh -NoProfile -Command \ + 'Install-Module PSScriptAnalyzer -Scope CurrentUser -Force -AllowClobber -Repository PSGallery' + +# shellcheck : le même outil est utilisé par la suite Unix, pour que les deux +# halves de la suite d'installation soient vérifiées pareil. +RUN apt-get update \ + && apt-get install --no-install-recommends -y shellcheck \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /w \ No newline at end of file diff --git a/install/tests/PSScriptAnalyzerSettings.psd1 b/install/tests/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 00000000..37ad881d --- /dev/null +++ b/install/tests/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,63 @@ +# Règles PSScriptAnalyzer écartées pour install.ps1, et pourquoi. +# +# Ce fichier n'est pas lu automatiquement par PSScriptAnalyzer — voir la note en +# bas. Il documente les exclusions, et run-tests.ps1 en tire la liste exacte. +# +# Une règle écartée sans raison écrite est pire qu'une règle active : elle crée +# un sentiment de couverture qui n'existe pas. + +@{ + # `Try-Save-File` renvoie un booléen. Le verbe approprié serait `Test-`, + # qui dirait « tester si on peut sauvegarder » sans le faire. Le préfixe + # `Try-` est la convention .NET pour « essayer, sinon renvoyer false ». + # + # $Version et $Channel sont déclarés dans le bloc `param` et lus plus bas, + # dans `Resolve-Version`. PSScriptAnalyzer ne résout pas la portée : depuis + # une fonction, il voit le $Version de la fonction, pas celui du script. + # $Gnu est volontairement sans effet sur Windows — il existe pour que la + # même ligne de commande fonctionne sur les deux scripts. + # + # Ces trois paramètres sont couverts par les tests : `-Version` épinglé et + # `-Channel beta` ont chacun leurs propres scénarios. + # + # Stop-Install, Remove-TempDir et New-Client ne sont pas des cmdlets et + # n'ont pas à implémenter -WhatIf. La règle vise les modules distribués, où + # l'appelant doit pouvoir voir l'effet avant de le subir. Ici + # l'installateur est le programme : « installer sans installer » n'a pas de + # sens, et un -WhatIf trompeur serait pire que son absence. + # + # Write-Host est le bon choix, et le seul qui convienne. L'installateur + # écrit son rapport sur le flux de l'hôte, pas dans le pipeline : sous + # `irm … | iex`, un Write-Output de plus se retrouverait dans la valeur de + # retour de l'utilisateur, dans un `$x =`, ou dans une boucle. Write-Host + # va vers le flux d'information, que `2>&1` capture et que `iex` ne pollue + # pas. L'échec passe par Write-Host puis `exit`, jamais par Write-Error : + # sous `iex`, une erreur non terminante est écrite sans rien arrêter. + # La couleur est voulue : c'est un outil de terminal, pas une bibliothèque. + + Excluded = @( + 'PSUseApprovedVerbs' + 'PSReviewUnusedParameter' + 'PSUseShouldProcessForStateChangingFunctions' + 'PSAvoidUsingWriteHost' + ) + + # Règles volontairement NON écartées, malgré leur pénalité : elles ont + # attrapé de vrais défauts dans ce fichier. + # + # PSUseBOMForUnicodeEncodedFile : a signalé l'absence de BOM, donc que le + # fichier aurait été lu en ANSI par PowerShell 5.1 — chaque accent cassé + # sur un Windows français ou allemand. Le BOM est posé depuis, et un test + # explicite le vérifie. + # PSAvoidAssignmentToAutomaticVariable : a signalé `$input`, qui est une + # variable automatique. Réécrite en `$source`. + # PSUseSingularNouns : a signalé Test-BinaryStarts, renommé + # Test-BinaryStart. + # + # Note d'implémentation : `-Settings` avec un dictionnaire est accepté par + # l'API mais silencieusement ignoré dans PSScriptAnalyzer 1.25 — les + # constatations changent d'un appel à l'autre. `-ExcludeRule` est le + # mécanisme vérifié, et c'est lui que la suite utilise. Ce fichier reste la + # source de vérité pour les raisons ; la liste effective y est lue par + # run-tests.ps1. +} \ No newline at end of file diff --git a/install/tests/fixture-server.py b/install/tests/fixture-server.py new file mode 100755 index 00000000..9552832e --- /dev/null +++ b/install/tests/fixture-server.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Sert une release GitHub factice, pour tester install.sh sans réseau. + +L'installeur ne connaît que deux points d'entrée côté release : l'URL +`/releases/latest`, dont il lit l'en-tête `Location`, et `/releases.atom`, qu'il +parse pour le canal beta. Les deux sont servis ici depuis l'arborescence racine +en argument, avec une seule route réélée : + + //releases/LATEST contient le tag vers lequel rediriger + //releases/latest absent : c'est la redirection + //releases.atom le flux + //releases/download//… les artefacts + +Sans ce serveur, vérifier que l'installeur *refuse* un SHA falsifié +supposerait de falsifier une vraie release publiée. + +Usage : + fixture-server.py + → écrit « PORT » sur stdout, puis sert jusqu'à l'arrêt. +""" + +import sys +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + +class Handler(BaseHTTPRequestHandler): + root = "." + + def log_message(self, *_args): + pass # le test lit la sortie du script, pas les accès + + def resolve(self): + """Renvoie (chemin, est_une_redirection) ou None si 404.""" + path = self.path.split("?", 1)[0].split("#", 1)[0] + while "//" in path: + path = path.replace("//", "/") + parts = [p for p in path.split("/") if p and p != ".."] + + if parts and parts[-1] == "latest": + tag_file = os.path.join(self.root, *(parts[:-1] + ["LATEST"])) + if os.path.isfile(tag_file): + with open(tag_file, encoding="utf-8") as handle: + tag = handle.read().strip() + return ("/".join(parts + ["tag", tag]), True) + + target = os.path.normpath(os.path.join(self.root, *parts)) + if not os.path.abspath(target).startswith(os.path.abspath(self.root)): + return None + if os.path.isfile(target): + return (target, False) + return None + + def do_HEAD(self): + self.respond(body=False) + + def do_GET(self): + self.respond(body=True) + + def respond(self, body): + found = self.resolve() + if found is None: + self.send_error(404, "Not Found") + return + + target, redirect = found + if redirect: + self.send_response(302) + self.send_header("Location", target) + self.send_header("Content-Length", "0") + self.end_headers() + return + + with open(target, "rb") as handle: + payload = handle.read() + self.send_response(200) + self.send_header("Content-Length", str(len(payload))) + self.send_header("Content-Type", "application/octet-stream") + self.end_headers() + if body: + self.wfile.write(payload) + + +def main(): + if len(sys.argv) != 2: + print(__doc__, file=sys.stderr) + return 2 + Handler.root = os.path.abspath(sys.argv[1]) + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + print(f"PORT {server.server_port}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/install/tests/run-tests.ps1 b/install/tests/run-tests.ps1 new file mode 100755 index 00000000..2e78190c --- /dev/null +++ b/install/tests/run-tests.ps1 @@ -0,0 +1,495 @@ +#!/usr/bin/env pwsh +# Tests de install/install.ps1, sans réseau. +# +# Le script ne parle qu'à une release : redirection de `/releases/latest`, flux +# Atom, archives .zip et `checksums.txt`. Le serveur de fixtures (voir +# fixture-server.py) reproduit ces quatre points, ce qui permet de tester le +# chemin heureux *et* les refus — notamment le SHA falsifié, qu'on ne peut pas +# produire sur une vraie release sans la compromettre. +# +# pwsh install/tests/run-tests.ps1 +# +# Le même script est exécuté par le job `install` de ci.yml. + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$Here = Split-Path -Parent $MyInvocation.MyCommand.Path +$AnalyzerSettings = Join-Path $PSScriptRoot 'PSScriptAnalyzerSettings.psd1' +$InstallPs1 = Join-Path (Split-Path -Parent $Here) 'install.ps1' +$RepoPath = 'we-data-ch/typr' +$StableTag = 'v9.9.9' +$BetaTag = 'v9.9.9-beta.1' + +$script:Passed = 0 +$script:Failed = 0 +$script:Root = $null +$script:Server = $null + +# --------------------------------------------------------------------------- + +function Ok { + param([string] $Message) + $script:Passed++ + Write-Host " $([char]0x2713) $Message" -ForegroundColor Green +} + +function Ko { + param([string] $Message, [string] $Detail) + $script:Failed++ + Write-Host " $([char]0x2717) $Message" -ForegroundColor Red + if ($Detail) { Write-Host " $Detail" -ForegroundColor Red } +} + +function Check-Rc { + param([int] $Expected, [int] $Actual, [string] $Message) + if ($Expected -eq $Actual) { Ok $Message } + else { Ko $Message "code de retour $Actual, attendu $Expected" } +} + +function Check-Contains { + param([string] $Output, [string] $Needle, [string] $Message) + if ($Output -and $Output.Contains($Needle)) { Ok $Message } + else { Ko $Message "« $Needle » absent de la sortie : $Output" } +} + +function Check-NotContains { + param([string] $Output, [string] $Needle, [string] $Message) + if ($Output -and $Output.Contains($Needle)) { + Ko $Message "« $Needle » présent alors qu'il ne devait pas l'être" + } + else { Ok $Message } +} + +function Check-Path { + param([string] $Path, [bool] $ShouldExist, [string] $Message) + if ([bool](Test-Path -LiteralPath $Path) -eq $ShouldExist) { Ok $Message } + else { Ko $Message "$Path : existence inattendue" } +} + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +# Un « binaire » de trois lignes : le script l'extrait, le copie et l'exécute +# avec `--version`. Assez pour couvrir le chemin complet sans télécharger 11 Mo +# ni dépendre d'une release existante. +function Get-Targets { + # Une archive par cible conceivable : le script résout sa cible à partir de + # l'architecture de la machine, que les fixtures ne peuvent pas faire varier. + return @( + 'x86_64-pc-windows-msvc', 'aarch64-pc-windows-msvc', + 'x86_64-unknown-linux-musl', 'x86_64-apple-darwin' + ) +} + +function New-StubArchive { + param([string] $Dir, [string] $Tag, [string] $Target) + + $name = "typr-$Tag-$Target.zip" + $stage = Join-Path $Dir 'stage' + if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } + New-Item -ItemType Directory -Path $stage -Force | Out-Null + + # Un script bat nommé typr.exe : c'est le seul format que PowerShell peut + # exécuter sous Linux, où la suite tourne. Le script testé s'en fiche — il + # copie et invoque le fichier sans regarder ce qu'il contient. + Set-Content -LiteralPath (Join-Path $stage 'typr.exe') -Encoding Ascii -Value @( + '@echo off', + 'if not "%1"=="--version" exit /b 2', + 'echo typr-cli 0.0.0-fixture' + ) + + # On zippe à la main : Compress-Archive n'est pas disponible sous Linux, et + # System.IO.CompressionZipFile l'est partout. + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zipPath = Join-Path $Dir $name + if (Test-Path $zipPath) { Remove-Item $zipPath -Force } + [System.IO.Compression.ZipFile]::CreateFromDirectory($stage, $zipPath) + + $sha = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + Add-Content -LiteralPath (Join-Path $Dir 'checksums.txt') -Value "$sha $name" + Remove-Item $stage -Recurse -Force +} + +function Set-FakeChecksum { + param([string] $Target) + $dir = Join-Path $script:Root "$RepoPath/releases/download/$StableTag" + $sums = Join-Path $dir 'checksums.txt' + $out = @() + foreach ($line in (Get-Content -LiteralPath $sums)) { + if ($line -match "\s+typr-$StableTag-$([regex]::Escape($Target))\.zip$") { + $out += "0000000000000000000000000000000000000000000000000000000000000000 $($line -split '\s+' | Select-Object -Last 1)" + } + else { + $out += $line + } + } + Set-Content -LiteralPath $sums -Value $out +} + +function Reset-Checksums { + param([string] $Tag, [string] $Target) + $dir = Join-Path $script:Root "$RepoPath/releases/download/$Tag" + Remove-Item (Join-Path $dir 'checksums.txt') -Force -ErrorAction SilentlyContinue + New-StubArchive $dir $Tag $Target +} + +function New-FixtureTree { + $script:Root = Join-Path ([System.IO.Path]::GetTempPath()) ("typr-ps-tests-" + [Guid]::NewGuid().ToString('N')) + $rel = Join-Path $script:Root "$RepoPath/releases" + New-Item -ItemType Directory -Path $rel -Force | Out-Null + Set-Content -LiteralPath (Join-Path $rel 'LATEST') -Value $StableTag + + foreach ($t in (Get-Targets)) { + New-StubArchive (Join-Path $rel "download/$StableTag") $StableTag $t + New-StubArchive (Join-Path $rel "download/$BetaTag") $BetaTag $t + } + + Set-Content -LiteralPath (Join-Path $script:Root "$RepoPath/releases.atom") -Encoding Ascii -Value @( + '', + '', + ' Release notes from typr', + " tag:github.com,2008:https://github.com/$RepoPath/releases", + " tag:github.com,2008:Repository/1/$StableTag", + " tag:github.com,2008:Repository/1/$BetaTag", + ' tag:github.com,2008:Repository/1/v0.1.0', + '' + ) +} + +function Start-FixtureServer { + param([string] $Python) + + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $Python + $psi.ArgumentList.Add((Join-Path $Here 'fixture-server.py')) + $psi.ArgumentList.Add($script:Root) + $psi.RedirectStandardOutput = $true + $psi.UseShellExecute = $false + return [System.Diagnostics.Process]::Start($psi) +} + +# --------------------------------------------------------------------------- +# Lancement de l'installeur +# +# `pwsh -File` dans un processus séparé, et non `& $InstallPs1` : le script +# appelle `exit`, qui dans un appel in-process ne ferait que terminer le script +# appelé — le code de retour se perdrait, et « refus d'un SHA falsifié » et +# « refus d'un tag illisible » rendraient tous deux 0. +function Invoke-Install { + param([string] $InstallDir, [string] $ExtraEnv = '', [string[]] $Options = @('-DryRun')) + + $envArgs = @( + "`$env:TYPR_INSTALL_DIR='$InstallDir'", + "`$env:TYPR_INSTALL_TARGET='x86_64-pc-windows-msvc'", + "`$env:TYPR_INSTALL_ORIGIN='$($script:Origin)'" + ) + if ($ExtraEnv) { $envArgs += $ExtraEnv } + + # `exit $LASTEXITCODE` explicite : `& script.ps1` ne propage pas le code de + # sortie du script appelé vers le processus — sans cette ligne, un refus + # 2 (usage) serait indiscernable d'un refus 1 (environnement). + $command = ($envArgs -join '; ') + "; & '$InstallPs1' " + ($Options -join ' ') + " 2>&1; exit `$LASTEXITCODE" + + $out = & pwsh -NoProfile -Command $command 2>&1 | Out-String + return @($out, $LASTEXITCODE) +} + +# Le chemin réel de l'utilisateur est `irm … | iex`, où le script arrive comme +# texte et non comme fichier. Ce n'est pas la même exécution que -File : on +# vérifie que le texte survit à ce mode — notamment que son bloc `param` est +# accepté par `iex` et que l'échec s'y propère toujours. +# +# `iex` ne prend pas d'argument : les options ne sont pas transmissibles par +# cette voie, ce qui est aussi ce que verra l'utilisateur. Les variables +# d'environnement, elles, le sont. +function Invoke-InstallViaIex { + param([string] $InstallDir, [string] $Origin) + + if (-not $Origin) { $Origin = $script:Origin } + + $command = @( + "`$env:TYPR_INSTALL_DIR='$InstallDir'", + "`$env:TYPR_INSTALL_TARGET='x86_64-pc-windows-msvc'", + "`$env:TYPR_INSTALL_ORIGIN='$Origin'", + "`$src = Get-Content -Raw -LiteralPath '$InstallPs1'", + 'iex $src 2>&1' + ) -join '; ' + + $out = & pwsh -NoProfile -Command $command 2>&1 | Out-String + return @($out, $LASTEXITCODE) +} + +function Work { + param([string] $Name) + $dir = Join-Path $script:Root "work-$Name" + New-Item -ItemType Directory -Path $dir -Force | Out-Null + return $dir +} + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +function Test-Syntax { + Write-Host 'Analyse statique' + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $InstallPs1, [ref]$null, [ref]$errors) | Out-Null + if ($errors -and $errors.Count -gt 0) { + Ko 'install.ps1 — analyse syntaxique' ($errors | ForEach-Object { $_.Message } | Out-String) + } + else { + Ok 'install.ps1 — analyse syntaxique' + } + + # Le BOM n'est pas une coquetterie : PowerShell 5.1 lit un .ps1 sans BOM + # comme de l'ANSI, avec la page de code de la machine. Sur un Windows + # français ou allemand — les deux cibles principales du projet — chaque + # accent de ce fichier deviendrait un caractère illisible. Le BOM est donc + # testé explicitement, sinon un reformatage l'effacerait sans bruit. + $head = [System.IO.File]::ReadAllBytes($InstallPs1)[0..2] + if ($head[0] -eq 0xEF -and $head[1] -eq 0xBB -and $head[2] -eq 0xBF) { + Ok 'install.ps1 — BOM UTF-8 présent' + } + else { + Ko 'install.ps1 — BOM UTF-8 présent' ( + 'sans BOM, PowerShell 5.1 lit le fichier en ANSI et les accents cassent. ' + + "Octets : $($head -join ' ')") + } + + if (-not (Get-Module -ListAvailable PSScriptAnalyzer)) { + Ko 'PSScriptAnalyzer est disponible' 'module absent de l image de test' + return + } +# `-Settings` avec un dictionnaire est accepté par l'API mais + # silencieusement ignoré dans PSScriptAnalyzer 1.25 : le nombre de + # constatations ne change pas, que la liste soit vide ou complète. + # `-ExcludeRule` est le mécanisme vérifié — un fichier jetable contenant + # `Write-Host` passe de 1 constatation à 0 avec, et reste à 1 sans. + # + # La liste vient du fichier de configuration, pour que les raisons soient + # écrites à un seul endroit. + $excluded = (Import-PowerShellDataFile -Path $AnalyzerSettings).Excluded + $findings = Invoke-ScriptAnalyzer -Path $InstallPs1 -ExcludeRule $excluded + if ($findings) { + Ko 'install.ps1 — PSScriptAnalyzer' ( + ($findings | ForEach-Object { + "ligne $($_.Line) [$($_.RuleName)] $($_.Message)" + }) -join "`n") + } + else { + Ok 'install.ps1 — PSScriptAnalyzer' + } +} + +function Test-Iex { + Write-Host 'Mode irm | iex' + # `irm … | iex` est la forme documentée pour Windows : le texte arrive dans + # la portée de l'appelant, pas comme fichier. Deux différences réelles avec + # `pwsh -File` sont vérifiées ici : + # + # — `iex` ne transmet aucun argument. Les options (-DryRun, -Version…) sont + # inaccessibles par cette voie ; seules les variables d'environnement + # passent. Le test ne peut donc pas être « juste une simulation ». + # — un `exit` y termine le processus appelant. C'est voulu pour un + # échec, mais cela signifie que la session interactive est perdue si le + # script échoue. D'où un test d'échec : il doit bien rendre 1, et non + # laisser croire à un succès. + $dir = Work 'iex' + $r = Invoke-InstallViaIex $dir + Check-Rc 0 $r[1] 'iex installe et sort en 0' + Check-Contains $r[0] 'installé dans' 'iex installe réellement' + Check-Path (Join-Path $dir 'typr.exe') $true 'iex pose le binaire' + + $badDir = Work 'iex-bad' + $r = Invoke-InstallViaIex $badDir "$($script:Origin)/inexistant" + Check-Rc 1 $r[1] 'iex propage un échec en 1' + Check-Path (Join-Path $badDir 'typr.exe') $false 'iex en échec n installe rien' +} + +function Test-DryRun { + Write-Host ' -DryRun' + $dir = Work 'dryrun' + $r = Invoke-Install $dir + Check-Rc 0 $r[1] '-DryRun sort en 0' + Check-Contains $r[0] 'Simulation' '-DryRun annonce la simulation' + Check-Contains $r[0] 'windows-msvc' '-DryRun résout une cible Windows' + Check-Contains $r[0] $StableTag '-DryRun résout la dernière version stable' + Check-NotContains $r[0] 'Extraction' '-DryRun n''extrait rien' + $leftover = Get-ChildItem -LiteralPath $dir -ErrorAction SilentlyContinue + if (-not $leftover) { Ok '-DryRun n''écrit rien' } else { Ko '-DryRun n''écrit rien' ($leftover.Name) } +} + +function Test-HappyPath { + Write-Host 'Installation réelle' + $dir = Work 'default' + $r = Invoke-Install $dir @() @() + Check-Rc 0 $r[1] 'installation par défaut' + Check-Contains $r[0] 'SHA-256 correct' 'le SHA-256 est vérifié' + Check-Contains $r[0] $StableTag 'la version installée est annoncée' + if (Test-Path (Join-Path $dir 'typr.exe')) { Ok 'le binaire est installé' } + else { Ko 'le binaire est installé' $r[0] } + + # Idempotence : relancer ne doit ni échouer ni laisser de doublon. + $r = Invoke-Install $dir @() @() + Check-Rc 0 $r[1] 'seconde installation (idempotence)' + if (Test-Path (Join-Path $dir 'typr.exe')) { Ok 'le binaire survit à la réinstallation' } + else { Ko 'le binaire survit à la réinstallation' } +} + +function Test-PinnedVersion { + Write-Host 'Version épinglée' + $r = Invoke-Install (Work 'pinned') @() @('-DryRun', '-Version', $StableTag) + Check-Rc 0 $r[1] '-Version sort en 0' + Check-Contains $r[0] $StableTag '-Version est respecté' + + # Sans le « v » initial, comme dans le README. + $r = Invoke-Install (Work 'pinned2') @() @('-DryRun', '-Version', $StableTag.TrimStart('v')) + Check-Contains $r[0] $StableTag '-Version sans le v initial est normalisé' + + $r = Invoke-Install (Work 'pinned3') @() @('-DryRun', '-Version', 'pas-un-tag') + Check-Rc 2 $r[1] 'un tag illisible sort en 2' + Check-Contains $r[0] 'tag illisible' 'le tag illisible est nommé' +} + +function Test-BetaChannel { + Write-Host 'Canal beta' + $r = Invoke-Install (Work 'beta') @() @('-DryRun', '-Channel', 'beta') + Check-Rc 0 $r[1] '-Channel beta résout une prerelease' + Check-Contains $r[0] $BetaTag '-Channel beta choisit la prerelease, pas la stable' + + $r = Invoke-Install (Work 'beta2') @() @('-Channel', 'beta') + Check-Rc 0 $r[1] 'installation depuis le canal beta' + if (Test-Path (Join-Path $script:Root 'work-beta2/typr.exe')) { Ok 'le binaire beta est installé' } + else { Ko 'le binaire beta est installé' $r[0] } +} + +function Test-BadChecksum { + Write-Host 'SHA-256 falsifié' + Set-FakeChecksum 'x86_64-pc-windows-msvc' + $dir = Work 'badsha' + $r = Invoke-Install $dir @() @() + Check-Rc 1 $r[1] 'le script refuse un SHA falsifié' + Check-Contains $r[0] 'SHA-256 falsifié' 'le message nomme la cause' + Check-Contains $r[0] "rien n'est installé" 'le message dit que rien n''est installé' + if (-not (Test-Path (Join-Path $dir 'typr.exe'))) { Ok 'aucun binaire n''est laissé sur le disque' } + else { Ko 'aucun binaire n''est laissé sur le disque' } + + Reset-Checksums $StableTag 'x86_64-pc-windows-msvc' +} + +function Test-BadChecksumBypass { + Write-Host "TYPR_INSTALL_VERIFY=0" + Set-FakeChecksum 'x86_64-pc-windows-msvc' + $dir = Work 'nosha' + $r = Invoke-Install $dir "`$env:TYPR_INSTALL_VERIFY='0'" @() + Check-Rc 0 $r[1] "TYPR_INSTALL_VERIFY=0 installe sans vérifier" + Check-Contains $r[0] 'désactivée' "TYPR_INSTALL_VERIFY=0 est annoncé" + Reset-Checksums $StableTag 'x86_64-pc-windows-msvc' +} + +function Test-MissingChecksums { + Write-Host 'checksums.txt absent ou malformé' + $dir = Join-Path $script:Root "$RepoPath/releases/download/$StableTag" + + # Absent + $saved = Get-Content -LiteralPath (Join-Path $dir 'checksums.txt') + Remove-Item (Join-Path $dir 'checksums.txt') -Force + $inst = Work 'nosum' + $r = Invoke-Install $inst @() @() + Check-Rc 1 $r[1] 'checksums.txt absent → échec' + Check-Contains $r[0] 'checksums.txt introuvable' "l'absence est nommée explicitement" + if (-not (Test-Path (Join-Path $inst 'typr.exe'))) { Ok "rien n'est installé sans checksums.txt" } + else { Ko "rien n'est installé sans checksums.txt" } + Set-Content -LiteralPath (Join-Path $dir 'checksums.txt') -Value $saved + + # Présent mais sans la ligne de cet artefact : c'est le cas de + # `sha256sum -c`, qui tente de vérifier les sept autres archives. + $out = @($saved | Where-Object { $_ -notmatch 'windows-msvc\.zip$' }) + $out += "1111111111111111111111111111111111111111111111111111111111111111 typr-$StableTag-x86_64-pc-windows-msvc.zip.exe" + Set-Content -LiteralPath (Join-Path $dir 'checksums.txt') -Value $out + $inst = Work 'nosum2' + $r = Invoke-Install $inst @() @() + Check-Rc 1 $r[1] 'checksums.txt sans la ligne attendue → échec' + Check-Contains $r[0] 'ne contient aucune ligne pour' 'la ligne manquante est nommée' + if (-not (Test-Path (Join-Path $inst 'typr.exe'))) { Ok "rien n'est installé sans ligne correspondante" } + else { Ko "rien n'est installé sans ligne correspondante" } + + # Malformé : la ligne existe, le SHA est illisible. + Set-Content -LiteralPath (Join-Path $dir 'checksums.txt') ` + -Value "pas-un-sha typr-$StableTag-x86_64-pc-windows-msvc.zip" + $inst = Work 'badline' + $r = Invoke-Install $inst @() @() + Check-Rc 1 $r[1] 'checksums.txt malformé → échec' + Check-Contains $r[0] 'malform' 'la malformation est nommée' + if (-not (Test-Path (Join-Path $inst 'typr.exe'))) { Ok "rien n'est installé sur une ligne malformée" } + else { Ko "rien n'est installé sur une ligne malformée" } + + Reset-Checksums $StableTag 'x86_64-pc-windows-msvc' +} + +function Test-MissingRelease { + Write-Host 'Release absente' + $r = Invoke-Install (Work 'ghost') @() @('-Version', 'v0.0.1-does-not-exist') + Check-Rc 1 $r[1] 'un tag inexistant échoue' + Check-Contains $r[0] 'téléchargement échoué' "l'échec de téléchargement est explicite" +} + +function Test-ContainedWrites { + Write-Host 'Écritures contenues' + $dir = Work 'contained' + $r = Invoke-Install $dir @() @() + Check-Rc 0 $r[1] 'installation avec un dossier explicite' + $stray = Get-ChildItem -LiteralPath $dir | Where-Object { $_.Name -ne 'typr.exe' } + if (-not $stray) { Ok "le dossier d'installation ne contient que le binaire" } + else { Ko "le dossier d'installation ne contient que le binaire" ($stray.Name -join ', ') } + + $leftovers = Get-ChildItem -LiteralPath ([System.IO.Path]::GetTempPath()) -Filter 'typr-install-*' -ErrorAction SilentlyContinue + if (-not $leftovers) { Ok 'aucun dossier temporaire laissé derrière' } + else { Ko 'aucun dossier temporaire laissé derrière' ($leftovers.Name -join ', ') } +} + +# --------------------------------------------------------------------------- + +function Main { + Write-Host 'install.ps1 — tests' + Write-Host '' + + $python = (Get-Command python3 -ErrorAction SilentlyContinue) + if (-not $python) { Write-Error 'python3 est requis pour le serveur de fixtures.'; exit 1 } + +Test-Syntax + + # Le port est choisi par le serveur ; on le récupère en lisant sa sortie. + New-FixtureTree + $script:Server = Start-FixtureServer $python.Source + + $portLine = $script:Server.StandardOutput.ReadLine() + if ($portLine -notmatch 'PORT (\d+)') { Write-Error "le serveur n'a pas démarré : $portLine"; exit 1 } + $script:Origin = "http://127.0.0.1:$($Matches[1])" + + try { + Test-Iex + Test-DryRun + Test-HappyPath + Test-PinnedVersion + Test-BetaChannel + Test-BadChecksum + Test-BadChecksumBypass + Test-MissingChecksums + Test-MissingRelease + Test-ContainedWrites + } + finally { + if ($script:Server -and -not $script:Server.HasExited) { $script:Server.Kill() } + Remove-Item -LiteralPath $script:Root -Recurse -Force -ErrorAction SilentlyContinue + } + + Write-Host '' + Write-Host "$($script:Passed) réussis, $($script:Failed) échoués" + if ($script:Failed -gt 0) { exit 1 } +} + +Main \ No newline at end of file diff --git a/install/tests/run-tests.sh b/install/tests/run-tests.sh new file mode 100755 index 00000000..f3ce5934 --- /dev/null +++ b/install/tests/run-tests.sh @@ -0,0 +1,451 @@ +#!/usr/bin/env bash +# Tests de install/install.sh, sans réseau. +# +# L'installeur ne parle qu'à une release : URL de redirection `/releases/latest`, +# flux Atom, artefacts et `checksums.txt`. Le serveur de fixtures reproduit ces +# quatre points, ce qui permet de tester le chemin heureux *et* les refus — +# notamment le SHA falsifié, qu'on ne peut pas produire sur une vraie release +# sans la compromettre. +# +# install/tests/run-tests.sh +# +# Le même script est exécuté par le job `install` de ci.yml. + +set -uo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +INSTALL_SH="$HERE/../install.sh" +REPO_PATH="we-data-ch/typr" +STABLE_TAG="v9.9.9" +BETA_TAG="v9.9.9-beta.1" + +PASSED=0 +FAILED=0 +ROOT="" +SERVER_PID="" + +# --------------------------------------------------------------------------- + +cleanup() { + [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null + [ -n "$ROOT" ] && rm -rf "$ROOT" + return 0 +} +trap cleanup EXIT + +ok() { PASSED=$((PASSED + 1)); printf ' \033[32m✓\033[0m %s\n' "$1"; } +ko() { FAILED=$((FAILED + 1)); printf ' \033[31m✗\033[0m %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; return 0; } + +check_rc() { # check_rc + if [ "$1" -eq "$2" ]; then ok "$3"; else ko "$3" "code de retour $2, attendu $1"; fi +} + +check_contains() { # check_contains + case "$1" in + *"$2"*) ok "$3" ;; + *) ko "$3" "« $2 » absent de la sortie : $1" ;; + esac +} + +check_not_contains() { + case "$1" in + *"$2"*) ko "$3" "« $2 » présent alors qu'il ne devait pas l'être" ;; + *) ok "$3" ;; + esac +} + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- +# Un « binaire » de trois lignes : l'installeur l'extrait, le rend exécutable et +# l'exécute avec `--version`. Assez pour couvrir le chemin complet sans +# télécharger 11 Mo ni dépendre d'une release existante. +build_binary() { + cat > "$1" <<'EOF' +#!/bin/sh +[ "${1:-}" = "--version" ] || exit 2 +echo "typr-cli 0.0.0-fixture" +EOF + chmod 755 "$1" +} + +# Construit une archive et *ajoute* sa ligne à checksums.txt sans l'écraser : +# une vraie release liste les huit archives, et c'est précisément ce qui fait +# échouer `sha256sum -c` quand on n'en a téléchargé qu'une. +make_release() { # make_release [bad] + local tag="$1" target="$2" bad="${3:-}" + local dir="$ROOT/$REPO_PATH/releases/download/$tag" + local name="typr-$tag-$target.tar.gz" + mkdir -p "$dir" "$ROOT/stage" + + build_binary "$ROOT/stage/typr" + tar -czf "$dir/$name" -C "$ROOT/stage" typr + + local sha + sha=$(sha256sum "$dir/$name" | cut -d' ' -f1) + [ "$bad" = "bad" ] && sha="0000000000000000000000000000000000000000000000000000000000000000" + printf '%s %s\n' "$sha" "$name" >> "$dir/checksums.txt" +} + +# Réécrit checksums.txt depuis les archives déjà présentes, en falsifiant +# éventuellement l'empreinte d'une seule. Les archives ne bougent pas entre deux +# appels, donc leur SHA est stable. +rewrite_checksums() { # rewrite_checksums [cible-à-falsifier] + local rel="$ROOT/$REPO_PATH/releases/download/$STABLE_TAG" + local bad="${1:-}" name sha + : > "$rel/checksums.txt" + for name in "$rel"/*.tar.gz; do + sha=$(sha256sum "$name" | cut -d' ' -f1) + if [ -n "$bad" ] && [ "${name##*/}" = "typr-$STABLE_TAG-$bad.tar.gz" ]; then + sha="0000000000000000000000000000000000000000000000000000000000000000" + fi + printf '%s %s\n' "$sha" "${name##*/}" >> "$rel/checksums.txt" + done +} + +make_origin() { + ROOT=$(mktemp -d) + local rel="$ROOT/$REPO_PATH/releases" + + mkdir -p "$rel" + printf '%s\n' "$STABLE_TAG" > "$rel/LATEST" + + for target in \ + x86_64-unknown-linux-musl aarch64-unknown-linux-musl \ + x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu \ + x86_64-apple-darwin aarch64-apple-darwin; do + make_release "$STABLE_TAG" "$target" + done + + # Release beta : plus récente que la stable dans le flux, donc celle que + # `--channel beta` doit choisir. + make_release "$BETA_TAG" "x86_64-unknown-linux-musl" + make_release "$BETA_TAG" "x86_64-unknown-linux-gnu" + + cat > "$ROOT/$REPO_PATH/releases.atom" < + + Release notes from typr + tag:github.com,2008:https://github.com/$REPO_PATH/releases + + tag:github.com,2008:Repository/1/$STABLE_TAG + $STABLE_TAG + + + tag:github.com,2008:Repository/1/$BETA_TAG + $BETA_TAG + + + tag:github.com,2008:Repository/1/v0.1.0 + v0.1.0 + + +EOF + + python3 "$HERE/fixture-server.py" "$ROOT" > "$ROOT/.port" & + SERVER_PID=$! + + local port="" + for _ in $(seq 1 50); do + port=$(grep -oE 'PORT [0-9]+' "$ROOT/.port" 2>/dev/null | cut -d' ' -f2) + [ -n "$port" ] && break + sleep 0.1 + done + [ -n "$port" ] || { echo "le serveur de fixtures n'a pas démarré" >&2; exit 1; } + + export TYPR_INSTALL_ORIGIN="http://127.0.0.1:$port" +} + +# --------------------------------------------------------------------------- +# Lancement de l'installeur +# --------------------------------------------------------------------------- +run() { # run + local dir="$1"; shift + TYPR_INSTALL_DIR="$dir" "$INSTALL_SH" "$@" 2>&1 +} + +# Un dossier de travail sous $ROOT, donc nettoyé avec le reste. +work() { + local dir + dir="$ROOT/work-$(printf '%s' "$1" | tr -c 'a-zA-Z0-9' -)" + mkdir -p "$dir" + printf '%s' "$dir" +} + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- +test_static_analysis() { + echo "Analyse statique" + + # `ok` et `ko` sortent toujours en 0, donc le `||` ne se déclenche jamais à + # tort : SC2015 signalerait ici un if-then-else qui n'existe pas. + # shellcheck disable=SC2015 + sh -n "$INSTALL_SH" 2>&1 && ok "sh -n" || ko "sh -n" + # shellcheck disable=SC2015 + bash -n "$INSTALL_SH" 2>&1 && ok "bash -n" || ko "bash -n" + + if command -v dash >/dev/null 2>&1; then + # shellcheck disable=SC2015 + dash -n "$INSTALL_SH" 2>&1 && ok "dash -n (POSIX strict)" || ko "dash -n" + else + printf ' – dash absent, POSIX strict non vérifié\n' + fi + + if command -v shellcheck >/dev/null 2>&1; then + # Aucune exclusion : install.sh ne se sert ni de `local` (non POSIX) ni + # d'aucun attribut bash. Le dialecte `sh` est vérifié, pas seulement la + # syntaxe. + if out=$(shellcheck -s sh "$INSTALL_SH" 2>&1); then + ok "shellcheck" + else + ko "shellcheck" "$out" + fi + else + printf ' – shellcheck absent, non exécuté\n' + fi + + if command -v pwsh >/dev/null 2>&1; then + if out=$(pwsh -NoProfile -Command " + \$e = \$null + [System.Management.Automation.Language.Parser]::ParseFile( + '$HERE/../install.ps1', [ref]\$null, [ref]\$e) + if (\$e.Count -gt 0) { \$e | ForEach-Object { \$_.Message }; exit 1 }"); then + ok "install.ps1 — analyse syntaxique PowerShell" + else + ko "install.ps1 — analyse syntaxique PowerShell" "$out" + fi + else + printf ' – pwsh absent, install.ps1 non analysé\n' + fi +} + +test_help() { + echo " --help et arguments" + local out + out=$(run "$(work help)" --help) + check_rc 0 $? "--help sort en 0" + check_contains "$out" "Usage" "--help affiche l'usage" + check_contains "$out" "--dry-run" "--help documente --dry-run" + + out=$(run "$(work noarg)" --bogus) + check_rc 2 $? "un argument inconnu sort en 2" + check_contains "$out" "argument inconnu" "un argument inconnu est nommé" +} + +test_dry_run() { + echo " --dry-run" + local dir out + dir=$(work dryrun) + out=$(run "$dir" --dry-run) + check_rc 0 $? "--dry-run sort en 0" + check_contains "$out" "Simulation" "--dry-run annonce la simulation" + check_contains "$out" "musl" "--dry-run vise musl par défaut sur Linux" + check_contains "$out" "$STABLE_TAG" "--dry-run résout la dernière version stable" + check_not_contains "$out" "Téléchargement" "--dry-run ne télécharge rien" + if [ -z "$(ls -A "$dir")" ]; then ok "--dry-run n'écrit rien" + else ko "--dry-run n'écrit rien" "contenu : $(ls -A "$dir")"; fi +} + +test_happy_path() { + echo "Installation réelle" + local dir out + dir="$ROOT/inst-default" + out=$(run "$dir") + check_rc 0 $? "installation par défaut" + check_contains "$out" "SHA-256 correct" "le SHA-256 est vérifié" + check_contains "$out" "$STABLE_TAG" "la version installée est annoncée" + check_contains "$out" "n'est pas dans votre PATH" "un dossier hors PATH avertit sans échouer" + if [ -x "$dir/typr" ]; then ok "le binaire est installé et exécutable" + else ko "le binaire est installé et exécutable" "$dir/typr absent ou non exécutable"; fi + + out=$("$dir/typr" --version 2>&1) + check_rc 0 $? "typr --version répond après installation" + + # Idempotence : relancer ne doit ni échouer ni laisser de fichier temporaire. + out=$(run "$dir") + check_rc 0 $? "seconde installation (idempotence)" + if [ -x "$dir/typr" ]; then ok "le binaire survit à la réinstallation" + else ko "le binaire survit à la réinstallation"; fi + + # --gnu : la cible glibc dynamique, que le script ne doit choisir que sur + # demande explicite. + dir="$ROOT/inst-gnu" + out=$(run "$dir" --gnu) + check_rc 0 $? "installation --gnu" + check_contains "$out" "linux-gnu" "--gnu cible bien unknown-linux-gnu" + if [ -x "$dir/typr" ]; then ok "--gnu installe le binaire" + else ko "--gnu installe le binaire"; fi + + # Dossier d'installation imbriqué absent : il doit être créé. + dir="$ROOT/inst-nested/bin" + out=$(run "$dir") + check_rc 0 $? "dossier imbriqué absent créé" + if [ -x "$dir/typr" ]; then ok "le dossier imbriqué contient le binaire" + else ko "le dossier imbriqué contient le binaire"; fi +} + +test_pinned_version() { + echo "Version épinglée" + local dir out + dir="$ROOT/inst-pinned" + out=$(run "$dir" --dry-run --version "$STABLE_TAG") + check_rc 0 $? "--version sort en 0" + check_contains "$out" "$STABLE_TAG" "--version est respecté" + + # Sans le « v », l'installeur doit le remettre : c'est la forme que la + # documentation affiche. + out=$(run "$(work pinned)" --dry-run --version "${STABLE_TAG#v}") + check_contains "$out" "$STABLE_TAG" "--version sans le v initial est normalisé" +} + +test_beta_channel() { + echo "Canal beta" + local dir out + dir="$ROOT/inst-beta" + out=$(run "$dir" --dry-run --channel beta) + check_rc 0 $? "--channel beta résout une prerelease" + check_contains "$out" "$BETA_TAG" "--channel beta choisit la prerelease, pas la stable" + + out=$(run "$dir" --channel beta) + check_rc 0 $? "installation depuis le canal beta" + if [ -x "$dir/typr" ]; then ok "le binaire beta est installé" + else ko "le binaire beta est installé"; fi +} + +test_bad_checksum() { + echo "SHA-256 falsifié" + local dir out target + rewrite_checksums x86_64-unknown-linux-musl + + dir="$ROOT/inst-badsha" + out=$(run "$dir") + check_rc 1 $? "le script refuse un SHA falsifié" + check_contains "$out" "SHA-256 falsifié" "le message nomme la cause" + check_contains "$out" "rien n'est installé" "le message dit que rien n'est installé" + if [ ! -e "$dir/typr" ]; then ok "aucun binaire n'est laissé sur le disque" + else ko "aucun binaire n'est laissé sur le disque" "$dir/typr présent"; fi + + # La variable d'échappement doit exister, sans être le mode par défaut. + out=$(TYPR_INSTALL_VERIFY=0 run "$dir") + check_rc 0 $? "TYPR_INSTALL_VERIFY=0 installe sans vérifier" + check_contains "$out" "désactivée" "TYPR_INSTALL_VERIFY=0 est annoncé" + rm -rf "$dir" + + rewrite_checksums +} + +test_missing_checksums() { + echo "checksums.txt absent ou malformé" + local dir out rel + rel="$ROOT/$REPO_PATH/releases/download" + + # Absent + rm -f "$rel/$STABLE_TAG/checksums.txt" + dir="$ROOT/inst-nosum" + out=$(run "$dir") + check_rc 1 $? "checksums.txt absent → échec" + check_contains "$out" "checksums.txt introuvable" "l'absence est nommée explicitement" + check_not_contains "$out" "FAILED open or read" "le message n'est pas celui de sha256sum -c" + if [ ! -e "$dir/typr" ]; then ok "rien n'est installé sans checksums.txt" + else ko "rien n'est installé sans checksums.txt"; fi + + # Présent mais sans la ligne de cet artefact : c'est le cas de `sha256sum -c`, + # qui tente de vérifier les sept autres archives et sort en erreur alors que + # la nôtre est valide. + cat > "$rel/$STABLE_TAG/checksums.txt" <<'EOF' +1111111111111111111111111111111111111111111111111111111111111111 typr-v9.9.9-aarch64-apple-darwin.tar.gz +2222222222222222222222222222222222222222222222222222222222222222 typr-v9.9.9-x86_64-pc-windows-msvc.zip +EOF + dir="$ROOT/inst-nosum2" + out=$(run "$dir") + check_rc 1 $? "checksums.txt sans la ligne attendue → échec" + check_contains "$out" "ne contient aucune ligne pour" "la ligne manquante est nommée" + if [ ! -e "$dir/typr" ]; then ok "rien n'est installé sans ligne correspondante" + else ko "rien n'est installé sans ligne correspondante"; fi + + # Malformé : la ligne existe mais le SHA est illisible. + printf 'pas-un-sha typr-v9.9.9-x86_64-unknown-linux-musl.tar.gz\n' \ + > "$rel/$STABLE_TAG/checksums.txt" + dir="$ROOT/inst-badline" + out=$(run "$dir") + check_rc 1 $? "checksums.txt malformé → échec" + check_contains "$out" "malform" "la malformation est nommée" + if [ ! -e "$dir/typr" ]; then ok "rien n'est installé sur une ligne malformée" + else ko "rien n'est installé sur une ligne malformée"; fi + + # Restitué pour les tests suivants. + rewrite_checksums +} + +test_missing_release() { + echo "Release absente" + local out + out=$(run "$(work ghost)" --version v0.0.1-does-not-exist) + check_rc 1 $? "un tag inexistant échoue" + check_contains "$out" "téléchargement échoué" "l'echec de téléchargement est explicite" +} + +test_path_present() { + echo "Dossier déjà dans le PATH" + local dir out + dir="$ROOT/inst-onpath" + out=$(PATH="$dir:$PATH" run "$dir") + check_rc 0 $? "installation avec le dossier dans le PATH" + check_not_contains "$out" "n'est pas dans votre PATH" "aucun avertissement PATH inutile" + check_contains "$out" "Vérification de l'installation" "le binaire est exécuté dans la foulée" +} + +# §3.8 du plan : le job `coherence` interdit les binaires commités. Le script +# ne doit écrire que dans TYPR_INSTALL_DIR — jamais dans l'arbre du dépôt, et +# jamais ailleurs que dans le dossier qu'on lui a donné. +test_contained_writes() { + echo "Écritures contenues" + local dir out stray + dir="$(work contained)" + + # HOME redirigé, TYPR_INSTALL_DIR absent : le dossier par défaut doit être + # `$HOME/.local/bin`, et rien d'autre ne doit être écrit. + out=$(env -u TYPR_INSTALL_DIR HOME="$ROOT/fakehome" PATH="$dir:$PATH" "$INSTALL_SH" 2>&1) + check_rc 0 $? "installation avec HOME redirigé et TYPR_INSTALL_DIR absent" + if [ -x "$ROOT/fakehome/.local/bin/typr" ]; then + ok "le dossier par défaut est \$HOME/.local/bin" + else + ko "le dossier par défaut est \$HOME/.local/bin" "$out" + fi + + stray=$(find "$ROOT/fakehome" -type f ! -path "$ROOT/fakehome/.local/bin/typr" 2>/dev/null) + if [ -z "$stray" ]; then ok "aucun fichier écrit hors du dossier d'installation" + else ko "aucun fichier écrit hors du dossier d'installation" "$stray"; fi + + # Le dossier temporaire doit être nettoyé : un binaire partiellement + # téléchargé qui traînerait là n'est pas installé, mais traîne. + local leftovers + leftovers=$(find "${TMPDIR:-/tmp}" -maxdepth 1 -name 'typr-install.*' 2>/dev/null) + if [ -z "$leftovers" ]; then ok "aucun dossier temporaire laissé derrière" + else ko "aucun dossier temporaire laissé derrière" "$leftovers"; fi +} + +# --------------------------------------------------------------------------- + +main() { + printf 'install.sh — tests\n\n' + make_origin + + test_static_analysis + test_help + test_dry_run + test_happy_path + test_pinned_version + test_beta_channel + test_bad_checksum + test_missing_checksums + test_missing_release + test_path_present + test_contained_writes + + printf '\n%d réussis, %d échoués\n' "$PASSED" "$FAILED" + [ "$FAILED" -eq 0 ] +} + +main "$@" \ No newline at end of file diff --git a/install_plan.md b/install_plan.md new file mode 100644 index 00000000..b24dcdd4 --- /dev/null +++ b/install_plan.md @@ -0,0 +1,915 @@ +# Plan — installation de TypR en une commande + +Objectif : qu'un utilisateur choisisse **un seul canal** parmi quatre, sans +compter sur Cargo, Docker ou un téléchargement manuel depuis la page des +releases. + +Ce plan part de l'existant : les 6 binaires, `checksums.txt` et la CI de release +sont déjà en place. Il ne s'agit donc que d'ajouter la couche de distribution +par-dessus, pas de refaire ce qui marche. + +--- + +## 0. Avancement + +| Lot | État | +|---|---| +| 0a — cibles musl dans la matrice | **fait** | +| 0b — `+crt-static` sur `*-msvc` | **fait** (liaison vérifiée en CI, pas en local) | +| 0c — binaire musl dans Rocky 9 / Debian 12 / Alpine | **fait** | +| 1 — scripts d'installation + hébergement + CI | **fait** (voir ci-dessous) | +| 2 — tests CI sur ces scripts | **couvert par le lot 1** — les jobs `install`, `install-windows` et `install-hosting` sont ceux-là | +| 3 — Homebrew | **fait** (voir ci-dessous) | +| 4 — WinGet et Scoop | **fait** (voir ci-dessous) | +| 5 → 7 | à faire | + +### Ce que le lot 0 a changé + +`.github/workflows/release.yml` est passé de 6 à 8 cibles. + +- `x86_64-unknown-linux-musl` et `aarch64-unknown-linux-musl` sont entrés dans + la matrice, construits par `cross` comme `aarch64-unknown-linux-gnu` l'était + déjà. Plus de cible `*-sys` : le binaire est `static-pie linked`, sans + `PT_INTERP`, et `objdump -T | grep -c GLIBC_` renvoie **0**. +- Les deux cibles GNU sont sorties de `ubuntu-latest` pour `ubuntu-22.04`. Le + plancher de glibc de la voie de repli est ainsi figé à 2.35 au lieu de monter + tout seul à ~2.42 en novembre 2026 avec le runner. +- Les deux cibles `*-msvc` reçoivent `-C target-feature=+crt-static` par + `RUSTFLAGS`. Comme `--target` est toujours passé, cargo n'applique pas la + variable aux build scripts ; le crate C embarqué (oniguruma, via `syntect`) + est donc compilé en `/MT` et non en `/MD` — `cc` lit `crt-static` dans + `CARGO_CFG_TARGET_FEATURE` pour choisir. Sans cela, le runtime du + VC++ Redistributable revenait par la porte du code C. + +### Mesures (30 septembre 2026) + +Binaire `x86_64-unknown-linux-musl` construit par `cross`, profil `release` : + +| Système | Avant (GNU) | Après (musl) | +|---|---|---| +| Rocky Linux 9 (glibc 2.34) | `GLIBC_2.39 not found` | `typr-cli 0.5.12` | +| Debian 12 (glibc 2.36) | `GLIBC_2.39 not found` | `typr-cli 0.5.12` | +| Ubuntu 22.04 (glibc 2.35) | `GLIBC_2.39 not found` | `typr-cli 0.5.12` | +| Alpine 3.20 (musl) | non exécutable | `typr-cli 0.5.12` | +| Debian 13, Ubuntu 24.04 | `typr-cli 0.5.12` | `typr-cli 0.5.12` | + +`typr check` produit des diagnostics identiques à ceux du binaire glibc sur le +même fichier : le comportement n'a pas bougé, seul le lien a changé. + +### Deux garde-fous dans la CI + +La propriété qui fait marcher ces binaires — la liaison statique — est +invisible dans le code : rien ne la casse quand elle disparaît, et c'est +précisément le mode de défaillance qu'on cherche à supprimer. Deux étapes +l'interdisent désormais. + +- `Runtime C statique (Windows)` lit la table d'imports PE avec `dumpbin` et + échoue si `VCRUNTIME140`, `MSVCP140` ou un `api-ms-win-crt-*` réapparaît. +- `Binaire musl statique et exécutable` échoue si le binaire musl déclare un + `PT_INTERP` — un binaire musl qui trahirait son interpréteur ne tournerait + que sur Alpine — puis lance `typr --version` dans `rockylinux:9`, + `debian:12` et `alpine:3.20`. + +La seconde remplace la checklist de la section 4 : le test qui compte n'a pas +lieu d'être fait à la main une fois, mais à chaque release. + +### Reste à confirmer en CI + +La liaison `+crt-static` sur `x86_64-pc-windows-msvc` reste **non vérifiée +localement** : ni le linker MSVC ni le SDK ne sont disponibles ici. `rustc` +accepte le flag pour les deux cibles `*-msvc` (vérifié jusqu'à l'émission des +métadonnées), et le garde-fou `dumpbin` tranchera à la prochaine release. + +### Ce que le lot 1 a livré + +Sept fichiers sous `install/`, tous du texte, aucun binaire. + +| Fichier | Contenu | +|---|---| +| `install/install.sh` | `sh` POSIX : musl par défaut, GNU sur `--gnu`, SHA-256, stable/beta | +| `install/install.ps1` | PowerShell 5.1 et 7 : `.zip`, SHA-256, PATH par registre | +| `install/README.md` | usage, options, codes de sortie, dépannage | +| `install/tests/run-tests.sh` | 59 assertions contre un serveur de fixtures | +| `install/tests/run-tests.ps1` | 49 assertions, plus PSScriptAnalyzer | +| `install/tests/fixture-server.py` | `/releases/latest`, `releases.atom`, archives, `checksums.txt` | +| `install/tests/Dockerfile` | image PowerShell 7.4 + PSScriptAnalyzer + shellcheck | + +Trois jobs de CI : `install` (shellcheck, `dash -n`, les deux suites), +`install-windows` (le script sous Windows PowerShell 5.1 réel), et +`install-hosting` (copie vers `typr.github.io/static/install/`). + +**59 + 49 assertions passent** ; shellcheck est propre sur les deux scripts +shell. Le vrai test d'installation a réussi en local : +`TYPR_INSTALL_DIR=$(mktemp -d) ./install/install.sh --gnu --version v0.5.12` +puis `typr-cli 0.5.12`. + +Cinq défauts réels sont sortis des tests, tous invisibles à la lecture : + +- `Try-Save-File` rendait `$null` — donc falsy — sur un téléchargement réussi, + parce qu'une fonction PowerShell renvoie ce que son `try` a émis, et un + succès n'émet rien. Chaque installation Windows échouait. +- `$input` est une variable automatique PowerShell. Y écrire pouvait faire lire + un `$null` à `CopyTo` et rendre le téléchargement silencieusement infructueux. +- Le fichier n'avait pas de BOM UTF-8. PowerShell 5.1 lit un `.ps1` sans BOM + en ANSI avec la page de code de la machine : sur un Windows français ou + allemand, chaque accent de ce fichier devenait illisible. Le BOM est + maintenant testé explicitement, sinon un reformatage l'effacerait en silence. +- Une ligne `checksums.txt` au SHA malformé était signalée « aucune ligne pour + cette archive » au lieu de « ligne malformée ». Deux diagnostics très + différents — release mal publiée contre fichier corrompu — se confondaient. + `install.sh` et `install.ps1` nomment désormais la même cause. +- Le harnais de test perdait les codes de retour : `& script.ps1` ne propage + pas le `exit` du script appelé au processus. Le refus d'un tag illisible (2) + était indiscernable d'un refus d'environnement (1). + +Deux points restent **non vérifiés localement**, et la CI les couvre sans +pretendre le contraire : la liaison `+crt-static` (ci-dessus) et le démarrage +d'un binaire Windows réel — le runner n'a pas de VC++ Redistributable, et la +fixture n'est de toute façon pas un exécutable Windows. + +### Ce que le lot 3 a livré + +La formule, ses deux variantes et son générateur — sous `packaging/homebrew/`, +toujours aucun binaire dans le dépôt. + +| Fichier | Contenu | +|---|---| +| `Formula/typr.rb.in` | `desc`, `homepage`, `license`, `livecheck`, `on_macos` | +| `parts/on_linux.rb.in` | le bloc `on_linux` musl, inséré seulement s'il existe | +| `parts/on_linux_absent.rb.in` | le commentaire qui le remplace sinon | +| `README.md.in` | le README du tap ; sa description et son URL sont **relues dans la formule rendue**, pas recopiées | +| `render.sh` | `sh` POSIX : tag + `checksums.txt` → formule + README | +| `tests/run-tests.sh` | 81 assertions, sans réseau ni brew | +| `README.md` | le mode d'emploi du canal | + +`render.sh` ne fait qu'une chose, et c'est le cœur du lot : **lire les +empreintes dans le `checksums.txt` de la release**. La formule est donc +impossible à publier avec une empreinte recopiée — la seule façon de l'obtenir +est de modifier le générateur, qui est relu et testé à chaque PR. + +Le tag est pris avec ou sans `v` initial ; `--checksums` accepte une URL ou un +fichier local ; **rien n'est écrit** dans `--out` si le rendu échoue en cours de +route, pour qu'une formule tronquée ne puisse pas être commitée par erreur. + +Deux jobs de CI. `packaging` tourne sous Linux : shellcheck, `dash -n`, +`bash -n`, la suite. `packaging-audit` tourne sous macOS et fait passer la +formule rendue à `brew style` et `brew audit --strict` — sur une PR, donc, et +non à la release. C'est le seul endroit où le DSL Homebrew de la formule peut +être jugé, aucun runner Linux n'ayant Homebrew. + +Le job `brew` de `release.yml` reprend le même chemin sur le `checksums.txt` +réellement publié, **télécharge une archive et compare son empreinte à celle +inscrite dans la formule** avant de commiter vers `we-data-ch/homebrew-typr` — +là où une erreur de sélection se verrait enfin. Sans `HOMEBREW_TAP_TOKEN`, la +validation a lieu quand même et seule la publication est sautée, avec un +avertissement ; un refus de Homebrew arrête le job. + +**81 assertions passent.** Cinq mutations du générateur ont été essayées pour +vérifier que la suite a vraiment du mordant — c'est elle qui l'affirme, pas la +relecture du gabarit : + +| Mutation | Détectée par | +|---|---| +| les deux `sha256` macOS intervertis | les deux blocs `url`+`sha256`, vérifiés côte à côte | +| garde-fou des marqueurs `@@` supprimé | 18 assertions | +| cible GNU choisie pour Linux | 12 assertions | +| bloc `on_linux` désindentation | l'imbrication `on_linux` / `on_intel` | +| garde-fou du retour à la ligne final supprimé | 2 assertions | + +La troisième a été trouvée en écrivant la suite : la version précédente +vérifiait que les quatre empreintes *apparaissaient* dans le fichier, ce que +satisfait aussi deux cibles interverties. La désindentation, elle, venait de la +normalisation du gabarit — invisible pour tout test qui ne compte pas les +espaces, et rattrapée par `brew style` dix minutes plus tard si l'on n'y prenait +garde. + +**Ce que le lot n'a pas pu faire, et le dit.** Aucune version publiée ne contient +de binaire musl : `v0.5.12`, la dernière, n'a que six artefacts, tous GNU ou +Apple. Le générateur produit donc, pour elle, une formule **macOS seule**, et le +README du tap l'annonce au lieu de promettre Linux. Aucun repli GNU n'a été +ajouté : ce serait réintroduire, par la porte de Homebrew, le défaut +`GLIBC_2.39 not found` que le lot 0 vient de corriger partout ailleurs. Le bloc +`on_linux` réapparaîtra seul, à la première release qui publiera du musl. + +Reste une seule chose à faire, et elle n'est pas dans ce dépôt : +`HOMEBREW_TAP_TOKEN` (PAT GitHub, scope `repo` sur `we-data-ch/homebrew-typr`) +dans les secrets de la CI. Tant qu'il manque, le job `brew` valide la formule et +saute la publication, avec un avertissement. + +Le dépôt `we-data-ch/homebrew-typr` a été créé le 30 septembre 2026, vide puis +amorcé avec la formule v0.5.12 rendue depuis le `checksums.txt` de cette release +— `brew install we-data-ch/typr/typr` fonctionne donc dès aujourd'hui sur macOS, +et la prochaine release remplacera ce fichier par le sien, ce qui exercera le +chemin complet `release.yml` → tap. + +### Ce que le lot 4 a livré + +Les deux canaux sont rendus par **un seul script**, `packaging/render.sh`, au +niveau `packaging/` — et non deux sous `packaging/winget/` et `packaging/scoop/` +comme le plan les posait. La raison est celle qui vaut partout ailleurs dans ce +dépôt : les deux canaux lisent le **même** `checksums.txt` et doivent donc +pouvoir être cassés ensemble. Un générateur par canal laisserait passer le jour +où l'un des deux est régénéré et pas l'autre — et les deux canaux +désinstalleront la même version, avec des empreintes différentes. + +```sh +packaging/render.sh --tag v0.5.12 --out /tmp/manifests # 6 fichiers +``` + +| Fichier | Contenu | +|---|---| +| `packaging/winget/*.yaml.in` | version + installateurs (`x64`, `arm64`) + locale `en-US` | +| `packaging/scoop/typr.json.in` | manifeste Scoop | +| `packaging/scoop/README.md.in` | README du bucket, relu dans le manifeste | +| `packaging/tests/run-tests.sh` | 122 assertions, dont la validation par les schémas officiels | +| `packaging/tests/validate-schemas.py` | schémas WinGet et Scoop, en cache, `--offline` disponible | + +Le job `packaging-win` de `ci.yml` rend depuis une fixture et fait valider par les +schémas officiels. Les jobs `winget` et `scoop` de `release.yml` rendent depuis le +`checksums.txt` de la release, **vérifient les deux archives téléchargées**, puis +publient : PR `winget-pkgs` via `wingetcreate submit` d'un côté, commit dans le +bucket de l'autre. Le job `scoop` va plus loin et **installe** : `scoop install` +sur le manifeste local, puis `typr --version` doit annoncer la version de la +release. + +Trois défauts ont été trouvés en faisant, que rien ne montrait à la relecture. +Les trois sont couverts par la suite, et détaillés dans `packaging/README.md` : + +- **`ReleaseDate` sans guillemets** — lu par un analyseur YAML 1.1, `2026-01-02` + est une *date* ; le schéma WinGet exige une *chaîne*. 23 tests cassent si le + guillemet disparaît. +- **Une clé `_comment_urls` dans le manifeste Scoop** — le schéma de Scoop refuse + toute clé inconnue et n'accorde que `_comment` ; le bucket n'aurait pas + installé. +- **`url` et `hash` réordonnés** — les deux tableaux sont alignés par index chez + Scoop ; c'est un défaut qui ne se voit qu'à l'installation, sur une + architecture ou pas l'autre. + +**Deux affirmations de ce plan étaient fausses.** Elles sont corrigées en §6 : + +- `InstallLocation` n'est pas un dossier de destination, c'est un *argument* + passé à l'installeur — et un zip n'a pas d'installeur. WinGet installe sous + `%LOCALAPPDATA%\Microsoft\WinGet\Packages\…`. La case « même dossier que + `install.ps1` » de la vérification est donc **abandonnée**, pas ratée : + `irm | iex` reste le canal qui promet `%LOCALAPPDATA%`. +- « `.installer.yaml` **si signature** » : ce fichier est obligatoire, signé ou + non — c'est lui qui décrit les installateurs. `SignatureSha256` n'a pas sa + place ici : les archives ne sont pas signées, et WinGet n'en exige pas pour + publier. + +Sur ARM64, WinGet associe `arm64` au binaire natif. Scoop, lui, cherche la +chaîne littérale `arm64` dans le manifeste et ne trouve pas `aarch64` : le +binaire x86_64 y tourne par émulation, ce que Windows 11 sait faire. Le plan +annonçait une sélection native par architecture ; le bucket fournit **les deux** +URL et laisse Scoop choisir son miroir, ce qui est déjà le natif quand il le prend. +Ajouter un bloc `architecture.arm64` décoratif aurait coûté une illusion — et un +risque de régression sur les vraies installations ARM64, ce qui vaut mieux éviter. + +Reste à faire, et ce n'est pas dans ce dépôt : créer +[`we-data-ch/scoop-bucket`](https://github.com/we-data-ch/scoop-bucket) (le job +échoue bruyamment s'il n'existe pas) et définir les secrets +`WINGET_CREATE_GITHUB_TOKEN` (scope `public_repo`) et `SCOOP_BUCKET_TOKEN`. Tant +qu'ils manquent, les deux jobs rendent, valident, téléchargent et vérifient les +archives, puis sautent la publication avec un avertissement. + +--- + +## 1. État actuel + +`.github/workflows/release.yml` produit, à partir d'un unique tag `vX.Y.Z` : + +| Cible | Artefact | +|---|---| +| `x86_64-unknown-linux-gnu` | `typr-vX.Y.Z-x86_64-unknown-linux-gnu.tar.gz` | +| `aarch64-unknown-linux-gnu` | `typr-vX.Y.Z-aarch64-unknown-linux-gnu.tar.gz` | +| `x86_64-pc-windows-msvc` | `typr-vX.Y.Z-x86_64-pc-windows-msvc.zip` | +| `aarch64-pc-windows-msvc` | `typr-vX.Y.Z-aarch64-pc-windows-msvc.zip` | +| `x86_64-apple-darwin` | `typr-vX.Y.Z-x86_64-apple-darwin.tar.gz` | +| `aarch64-apple-darwin` | `typr-vX.Y.Z-aarch64-apple-darwin.tar.gz` | + +Plus `checksums.txt` au format `sha256sum` (`release.yml:157`), et une version +stable (`X.Y.Z`) et une version instable (`X.Y.Z-alpha.N`) qui est marquée +`prerelease` (`release.yml:169`). + +Ce qui manque est uniquement l'agent qui transforme ces fichiers en une +commande. Le reste du plan est de la distribution, pas de la compilation. + +--- + +## 2. Décisions + +### Ce qu'on fait + +| Canal | Commande | OS | +|---|---|---| +| Script Unix | `curl -fsSL …/install.sh \| sh` | macOS, Linux | +| Script PowerShell | `irm …/install.ps1 \| iex` | Windows | +| Homebrew | `brew install we-data-ch/typr/typr` | macOS, Linux | +| WinGet | `winget install we-data-ch.TypR` | Windows | +| Scoop | `scoop install typr` | Windows | + +### Ce qu'on ne fait pas, et pourquoi + +- **`.msi` / `.pkg` (installeur graphique)** — `.pkg` macOS non notarisé produit + « TypR est endommagé » pour tout le monde ; la notarisation exige un compte + Apple Developer payant. Un binaire posé par `curl | sh` **n'a pas** l'attribut + `com.apple.quarantine` (seuls les navigateurs le posent) et passe sans + friction. Un `.msi` non signé se heurte au même mur avec SmartScreen. Le seul + cas qui le justifierait est un déploiement d'entreprise par GPO — à rouvrir + si ce cas se présente. +- **Code signing / notarization des binaires** — même raison, hors de portée + d'un projet open source sans certificat payant. +- **Wrapper npm** (`npm i -g typr`) — la technique esbuild/biome fonctionne + partout, mais impose Node à l'utilisateur. L'audience TypR (R, data) n'a pas + Node acquis. À réexaminer plus tard, pas comme canal principal. + +### Deux décisions à confirmer + +Les questions n'ont pas encore reçu de réponse ; le plan part sur les valeurs +par défaut ci-dessous. + +1. **Emplacement** — `~/.local/bin` (défaut retenu). Zéro `sudo`, aucun + privilège. L'installateur affiche un avertissement si le dossier n'est pas + dans le `PATH` au lieu d'échouer. Homebrew reste le canal « propre » pour qui + veut une installation système. + *Alternative :* `/usr/local/bin` avec `sudo`, plus proche des conventions + mais qui casse les installations en CI et dans les conteneurs. + +2. **Hébergement des scripts** — GitHub Pages sur le dépôt de documentation + existant (`we-data-ch.github.io/typr.github.io/install/install.sh`), ce qui ne + coûte aucune infrastructure. Un vrai domaine (`typr.sh`) est plus joli mais + demande DNS et redirection ; à faire plus tard si le besoin se fait sentir. + +--- + +## 3. Points de vigilance avant de commencer + +### 3.1 Le glibc des binaires Linux — défaut confirmé, à corriger avant tout + +Le diagnostic a été exécuté sur le binaire **publié** en `v0.5.12`. Le défaut +est réel, et il bloque l'installation sur une large part des machines. + +`objdump -T` sur `typr-v0.5.12-x86_64-unknown-linux-gnu` : + +``` +GLIBC_2.39 pidfd_spawnp (faible) +GLIBC_2.39 pidfd_getpid (faible) +``` + +Exécution réelle dans des conteneurs qui n'ont rien de TypR : + +| Système | glibc / libc | Résultat | +|---|---|---| +| Ubuntu 24.04 (le runner) | 2.39 | fonctionne | +| Debian 13 (trixie) | 2.41 | fonctionne — `typr-cli 0.5.12` | +| Ubuntu 22.04 | 2.35 | **`GLIBC_2.39 not found`** | +| Debian 12 (bookworm) | 2.36 | **`GLIBC_2.39 not found`** | +| Rocky Linux 9 | 2.34 | **`GLIBC_2.39 not found`** | +| Alpine 3.20 | musl | non exécutable — aucun build musl | + +Deux enseignements. + +**Les symboles faibles ne sauvent rien.** `pidfd_spawnp` et `pidfd_getpid` +(venus de `std::process::Command`) sont marqués faibles : on pourrait croire que +le chargeur les résoudra à `NULL` et que le repli prendra le relais. Testé : le +chargeur refuse quand même, parce que c'est la **référence versionnée** qui est +absente, pas seulement le symbole. Il n'y a donc aucun contournement à tenter — +seule une chaîne de compilation avec un plancher plus bas règle le problème. + +**Rocky 9 échoue.** C'est le point le plus douloureux : RHEL / Rocky / AlmaLinux +9 est une base très répandue chez les utilisateurs R professionnels, en +particulier en entreprise. Debian 12 et Ubuntu 22.04 ne sont pas rares non plus. + +**Et le plancher va monter tout seul.** `ubuntu-latest` passe à **Ubuntu 26.04 +en novembre 2026** (annonce de `actions/runner-images#14748`). Sans action, la +glibc requise passera de 2.39 à ~2.42 et le terrain cassera davantage, à chaque +release, sans qu'une ligne de code soit modifiée. C'est un défaut qui s'aggrave +silencieusement — la raison de plus pour traiter le fond, pas seulement le +symptôme. + +**Décision recommandée : ajouter les deux cibles musl.** Elles sont liées +stiquement, donc sans contrainte de glibc du tout — elles fonctionnent sur +Rocky 9, Debian 12, Ubuntu 22.04 **et** Alpine. Cela règle le problème de glibc +et le cas Alpine d'un seul geste, pour deux cibles de plus dans la matrice. + +`cross` est déjà utilisé par la matrice pour `aarch64-unknown-linux-gnu` +(`release.yml:110`), et son image musl est disponible : rien de nouveau à +installer, seulement deux entrées à ajouter. + +| Cible | Rôle | Priorité | +|---|---|---| +| `x86_64-unknown-linux-musl` | remplaçante de la version GNU sur x86_64 | 1 | +| `aarch64-unknown-linux-musl` | remplaçante sur ARM | 1 | + +Deux variantes possibles si l'on veut rester sur des binaires glibc dynamiques, +par ordre de préférence : + +- `cargo-zigbuild` en visant `glibc = "2.28"` — plancher très bas, couvre + RHEL 8. Mais ne couvre pas Alpine, et ajoute une dépendance d'outillage. +- compiler la cible GNU sur `ubuntu-22.04` au lieu de `ubuntu-latest` — plancher + 2.35, couvre Debian 12 et Ubuntu 22.04 mais **pas** Rocky 9 (2.34). Un + correctif partiel. + +Dans tous les cas, sortir la cible GNU de `latest` une fois musl disponible : +le script doit viser musl par défaut et ne retomber sur GNU que si l'utilisateur +le demande explicitement. + +### 3.2 macOS — aucun problème + +`LC_VERSION_MIN_MACOSX` du binaire `v0.5.12-x86_64-apple-darwin` annonce un +minimum de **10.12.0** (Sierra), compilé avec un SDK 26.5. Toutes les versions +de macOS réellement en usage sont couvertes. Rien à faire. + +### 3.3 Windows — le runtime MSVC manque sur les machines minimalistes + +Les DLL importées par `typr.exe` : + +``` +kernel32.dll, USER32.dll, advapi32.dll, ntdll.dll, bcryptprimitives.dll +VCRUNTIME140.dll +api-ms-win-crt-runtime / -string / -math / -stdio / -locale / -heap +``` + +`VCRUNTIME140.dll` et les `api-ms-win-crt-*` proviennent du **VC++ Redistributable +de Microsoft**. Ils sont présents sur un poste de développement ou une machine +qui a déjà installé beaucoup de choses, mais **absents d'un Windows 10/11 +frais**. Le scénario est exactement celui qu'on veut éviter : l'utilisateur copie +la commande, l'installation se termine sans erreur, puis `typr --version` échoue +avec une `DLL introuvable` — une faute d'orthographe dans un message de +chargeur. + +**Correction : `-C target-feature=+crt-static`** sur les cibles `*-msvc`, ce qui +lie statiquement le runtime C et supprime la dépendance. rustc accepte le flag +pour `x86_64-pc-windows-msvc` ; **la liaison n'a pas pu être vérifiée localement** +(pas de linker MSVC dans cet environnement). À valider en CI en comparant la +liste des DLL importées du binaire produit. + +Ce correctif est peu coûteux et supprime une classe entière de tickets, donc il +fait partie du lot 0. + +### 3.4 `aarch64-pc-windows-msvc` — à réévaluer + +Cette cible ne fonctionne que sur Windows sur ARM (Snapdragon X). Elle occupe un +créneau de la matrice pour une audience quasi inexistante. Ce n'est pas un +problème de fonctionnement, seulement de temps de CI. À garder pour la +complétude, à retirer si la matrice devient coûteuse. + +### 3.5 Résoudre « latest » sans dépendance + +Les scripts ne doivent pas supposer `jq` ni `python` sur la machine. + +L'astuce retenue : l'API GitHub demande `jq`, mais l'URL +`https://github.com//releases/latest` **redirige** vers `/tag/vX.Y.Z`. +Il suffit de lire l'en-tête `Location` : + +```bash +curl -fsSI https://github.com/we-data-ch/typr/releases/latest \ + | grep -i '^location:' | sed 's|.*/tag/||' | tr -d '\r\n' +``` + +Deux conséquences utiles : + +- `/releases/latest` **exclut** les prereleases — exactement la sémantique de la + branche stable, alignée sur `prerelease` dans `release.yml:169`. Le canal + beta devra, lui, lister `/releases` et filtrer. +- Aucune dépendance à un outil de parsing. + +### 3.6 Vérifier le SHA-256 — ne pas utiliser `sha256sum -c` tel quel + +Testé sur `v0.5.12` : `checksums.txt` contient bien les 6 lignes attendues, au +format `sha256sum`. Mais `sha256sum -c checksums.txt` **échoue**, parce qu'il +tente de vérifier les 5 archives qui n'ont pas été téléchargées : + +``` +typr-v0.5.12-x86_64-unknown-linux-gnu.tar.gz: OK +typr-v0.5.12-aarch64-apple-darwin.tar.gz: FAILED open or read +… (4 autres) +``` + +Le code de sortie est non nul même quand le fichier nous intéresse est valide. +Le script doit donc **extraire la seule ligne qui correspond**, puis la +vérifier : + +```bash +grep " $ARTIFACT\$" checksums.txt | sha256sum -c - +``` + +Testé : cette variante sort `OK` avec un code de retour nul. Elle reste le seul +endroit où une erreur de formatage passerait inaperçue, donc à couvrir par un +test qui fournit volontairement un SHA falsifié. + +`checksums.txt` ne contient que les 6 archives de binaires — ni le `.vsix`, ni +le tarball RStudio, ni le plugin Vim, ni le WASM, qui sont joints plus tard par +d'autres jobs. C'est cohérent avec l'usage du script d'installation, et le plan +n'a pas à en tenir compte autrement. + +L'option `TYPR_INSTALL_VERIFY=0` permet de sauter la vérification pour un +dépannage réseau, sans jamais être le mode par défaut. + +### 3.7 Récupérer le binaire après un échec + +`install.sh` télécharge dans un fichier temporaire, vérifie le SHA, puis déplace. +En cas d'échec le fichier temporaire est nettoyé : on n'installe jamais un binaire +partiellement téléchargé. + +### 3.8 Le job `coherence` interdit les binaires commités + +Le job de CI échoue si un binaire réapparaît dans l'index (`RELEASING.md:157`). +Les scripts ne doivent installer que dans `$HOME`, jamais dans le dépôt, et le +nouveau dossier `install/` ne doit contenir aucun artefact — uniquement du texte. + +--- + +## 4. Phase 1 — Scripts d'installation + +Socle : les trois canaux suivants ne font que résoudre une URL et lire un SHA. +Tout ce qui est commun à Unix et Windows (résolution de version, choix du +canal, message de fin) vit dans les scripts, pas dans la CI. + +### Livrables + +``` +install/install.sh # POSIX sh, Linux + macOS +install/install.ps1 # PowerShell, Windows +install/README.md # usage et dépannage, destiné aux contributeurs +``` + +### `install/install.sh` + +- `#!/bin/sh`, `set -eu`, aucune dépendance hors `curl`/`tar`/`sha256sum` + (macOS fournit `shasum -a 256`, pas `sha256sum` : les deux doivent être + Tentés). +- Résolution de la cible par `uname -s` et `uname -m`, avec correspondance + explicite `uname -m` → triple Rust. Sous Rosetta, `uname -m` renvoie `x86_64` + sur une machine Apple Silicon, et le binaire Intel y fonctionne — aucun cas + particulier n'est nécessaire. +- Support d'arguments : `--version ` pour épingler, `--channel `, + `--dry-run`, `--help`. +- Détection du dossier cible : `$TYPR_INSTALL_DIR`, sinon `$HOME/.local/bin`. +- Si le dossier cible n'est pas dans le `PATH`, afficher la ligne à ajouter et le + `~/.profile`, sans échouer. +- Message final indiquant `typr --version` et le lien vers la documentation. + +### `install/install.ps1` + +- Cible `%LOCALAPPDATA%\Programs\typr` — convention utilisateur moderne, aucun + droit administrateur. Surchargeable par `$env:TYPR_INSTALL_DIR`. +- `irm … | iex` :PowerShell impose de rester sur une seule ligne dans le + message du README, et d'afficher l'URL avant exécution. +- Extraction via `Expand-Archive`, donc format `.zip` et non `.tar.gz`. +- Ajout au `PATH` utilisateur par registre + (`[Environment]::SetEnvironmentVariable('Path', …, 'User')`) — qui ne prend + effet qu'au prochain démarrage du terminal. Le script doit le dire + explicitement, **et** mettre à jour `$env:Path` de la session courante pour + que `typr --version` fonctionne tout de suite dans le même terminal. + +### Vérification + +Tester au minimum, pour chaque OS et chaque architecture disponible : + +```bash +# Unix +TYPR_INSTALL_DIR=$(mktemp -d) ./install/install.sh --dry-run +TYPR_INSTALL_DIR=$(mktemp -d) ./install/install.sh +$TYPR_INSTALL_DIR/typr --version +``` + +Le `typr --version` de la dernière ligne est le test qui compte : c'est +précisément l'étape qui échoue aujourd'hui sur d'anciennes distributions. +Ajouter un contrôle explicite dans la CI, sur des images Docker sans rien +d'autre : + +```bash +# ce test a échoué sur les 3 premières distributions en septembre 2026 +docker run --rm -v "$BIN:/w/typr" rockylinux:9 sh -c '/w/typr --version' +docker run --rm -v "$BIN:/w/typr" debian:12 sh -c '/w/typr --version' +docker run --rm -v "$BIN:/w/typr" ubuntu:22.04 sh -c '/w/typr --version' +docker run --rm -v "$BIN:/w/typr" alpine:3.20 sh -c '/w/typr --version' +``` + +- [ ] Les 4 conteneurs ci-dessus répondent une version +- [ ] Linux x86_64 et aarch64, macOS x86_64 et aarch64 +- [ ] Windows x86_64 via `irm | iex`, sur une image sans VC++ Redistributable +- [ ] `--version ` installe bien l'ancienne version +- [ ] SHA volontairement falsifié → le script refuse d'installer et le dit +- [ ] `checksums.txt` absent ou malformé → erreur explicite, pas un `FAILED open or read` +- [ ] `PATH` non modifiable → message clair, pas d'erreur opaque +- [ ] Idempotence : deux exécutions successives ne cassent rien + +--- + +## 5. Phase 2 — Homebrew + +Le canal le plus attendu sur macOS, et il couvre Linux au passage. + +### Livrables + +Dépôt `we-data-ch/homebrew-typr`, créé à blanc puis tenu par la CI : + +``` +README.md # généré à partir de la formule +Formula/typr.rb # généré depuis checksums.txt +LICENSE # copié depuis ce dépôt +``` + +Le générateur et ses tests restent ici, pas dans le tap : le tap ne contient que +ce qu'il faut pour installer, et sa seule source est `release.yml`. + +### Contenu de la formule + +Le plan prévoyait un `url` unique plus un `if Hardware::CPU.arm?`. C'est +insuffisant : avec Linux dans le canal, **les deux** systèmes ont deux +architectures, et un seul `url` au niveau racine ne peut pas désigner le bon +binaire. Le gabarit construit utilise donc les deux variantes du DSL moderne : + +```ruby +class Typr < Formula + desc "A typed superset of R — transpiler and type checker" + homepage "https://we-data-ch.github.io/typr.github.io/" + license "Apache-2.0" + version "X.Y.Z" + + livecheck do + url :stable + strategy :github_latest + end + + on_macos do + on_intel do + url "…/typr-vX.Y.Z-x86_64-apple-darwin.tar.gz" + sha256 "" + end + + on_arm do + url "…/typr-vX.Y.Z-aarch64-apple-darwin.tar.gz" + sha256 "" + end + end + + # on_linux : idem, en musl. Ce bloc n'existe que si la release en publie. + + def install + bin.install "typr" + end + + test do + assert_match version.to_s, shell_output("#{bin}/typr --version") + end + + def caveats + "typR is pre-1.0: version 0.x releases can change CLI surface." + end +end +``` + +- Homebrew gère le `PATH`, les mises à jour (`brew upgrade`) et le + désinstallateur : le script Unix n'a plus à s'en préoccuper pour ce canal. +- **Le `sha256` doit être rempli automatiquement**, jamais écrit à la main. + Extrapoler `release.yml` avec un job `brew` qui lit `checksums.txt` — le + fichier contient déjà la ligne correspondant à l'artefact macOS, il n'y a rien + à recalculer — et commite la formule mise à jour. +- Avec `livecheck`, Homebrew suit le tag et propose la mise à jour tout seul. + +### Vérification + +- [ ] `brew install --formula we-data-ch/typr/typr` depuis un Mac clean — humain +- [ ] Fonctionne sur Apple Silicon **et** Intel — humain +- [ ] `brew upgrade typr` passe à la bonne version — humain +- [x] `brew audit --strict` ne signale rien — `packaging-audit` et `brew` +- [x] Le SHA publié correspond à `checksums.txt` de la release — job `brew`, qui + télécharge l'archive et compare +- [ ] `brew install we-data-ch/typr/typr` résout le tap — le dépôt existe + (30 septembre 2026) ; la ligne n'est cochée qu'après un essai sur un Mac + +Ce que la CI ne peut pas faire, et qui reste à faire à la main une fois le tap +créé : ouvrir un Mac vierge et taper `brew install`. `brew audit --strict` juge la +formule, pas l'installation. + +--- + +## 6. Phase 3 — WinGet et Scoop + +Deux manifests, aucun binaire. Ils puisent dans les `.zip` déjà publiés. + +> **Ce qui suit a été corrigé après implémentation.** Les deux affirmations +> fausses du plan initial sont signalées en place ; voir « Ce que le lot 4 a +> livré » pour le reste. + +### Livrables + +``` +packaging/render.sh le générateur des deux canaux +packaging/winget/we-data-ch.TypR.yaml.in version +packaging/winget/we-data-ch.TypR.installer.yaml.in installateurs x64 + arm64 +packaging/winget/we-data-ch.TypR.locale.en-US.yaml.in locale +packaging/scoop/typr.json.in manifeste Scoop +packaging/scoop/README.md.in README du bucket +packaging/tests/ suite + validation des schémas +``` + +Le `.installer.yaml` n'est pas conditionné à une signature : WinGet l'exige pour +décrire les installateurs, signés ou non. + +### WinGet + +- `PackageIdentifier: we-data-ch.TypR` +- `PackageVersion` sans le `v` initial — WinGet l'exige. +- `InstallerType: zip`, `InstallerSwitches: Silent` et `SilentWithProgress` avec + `/S` pour `7z`… **à ne pas inventer** : le type `zip` de WinGet extrait sans + copie dans un dossier. +- ~~Le chemin `InstallLocation` doit être `%LOCALAPPDATA%\Programs\typr`, pour + coïncider avec ce que fait `install.ps1`.~~ **Faux.** `InstallLocation` est un + *argument passé à l'installeur*, pas un dossier de destination ; un zip n'a pas + d'installeur, et le champ n'aurait rien fait. WinGet extrait sous + `%LOCALAPPDATA%\Microsoft\WinGet\Packages\…` et y met l'alias `typr` sur le + PATH — ce qui garde l'installation sans droits administrateur, le seul point + qui comptait dans cette case. +- Le fichier est publié dans `microsoft/winget-pkgs` par une pull request + ouverte automatiquement depuis la CI. `wingetcreate` n'a pas de commande + `validate` autonome : c'est `wingetcreate submit` qui valide avec l'outil + officiel, puis calcule lui-même le chemin de destination et ouvre la PR. + +### Scoop + +Le manifest est plus court que celui de WinGet et ne demande pas de PR +maintenue manuellement. `scoop install typr` après +`scoop bucket add typr https://github.com/we-data-ch/scoop-bucket`. + +`url` et `hash` sont des **tableaux alignés par index** : les deux URL sont +fournies, Scoop les propose en miroirs et vérifie l'empreinte du fichier +réellement téléchargé. Voir la note ARM64 dans « Ce que le lot 4 a livré ». + +Dans les deux cas, la version publiée doit venir de la même source que le reste — +`needs.verify.outputs.version` — et non d'un fichier écrit à la main. C'est +fait, et par construction : `render.sh` refuse de produire quoi que ce soit +depuis un `checksums.txt` qui ne contiendrait pas les deux empreintes Windows. + +### Vérification + +- [x] Les manifestes rendus passent les **schémas officiels** WinGet et Scoop — + 122 assertions, dont six mutations vérifiées détectées à la main. +- [ ] `winget install we-data-ch.TypR` sur Windows 10 et 11 — **impossible en + local** : c'est fait par le job `winget` sur `windows-latest`, avec + `wingetcreate submit`. +- [x] L'installation **sans droits administrateur** est acquise : WinGet comme + Scoop écrivent dans le profil utilisateur. C'est vérifié par construction + (aucun `InstallerSwitches`, aucun `RequireExplicitUpgrade`, aucun + installeur MSI), pas par une exécution. +- [ ] `scoop install typr` puis `scoop update typr` — **impossible en local** : + c'est fait par le job `scoop`, qui installe le manifeste rendu avant de + commiter. +- [x] Les deux canaux installent **le même binaire** : la même archive + `typr-vX.Y.Z-x86_64-pc-windows-msvc.zip`, lue dans le même + `checksums.txt`, et le même `typr.exe` à sa racine. La suite le vérifie. +- ~~Les deux installers aboutissent au même dossier et au même binaire que + `install.ps1`~~ **Abandonnée**, voir §6 WinGet : le dossier ne peut pas être + choisi pour un zip portable. Ce qui est vérifié, c'est le binaire — pas le + chemin. + +--- + +## 7. Phase 4 — Documentation + +### README + +Remplacer le tableau `Install` (README.md:62-69) : + +```markdown +## Install + +| Plateforme | Commande | +|---|---| +| **Toutes** | `curl -fsSL https://we-data-ch.github.io/typr.github.io/install/install.sh \| sh` | +| **Windows** | `irm https://we-data-ch.github.io/typr.github.io/install/install.ps1 \| iex` | +| macOS / Linux (Homebrew) | `brew install we-data-ch/typr/typr` | +| Windows (WinGet) | `winget install we-data-ch.TypR` | +| Windows (Scoop) | `scoop install typr` | +| Cargo | `cargo install typr` | +| Docker | `docker run --rm -it fabricehategekimana/typr:latest` | +| RStudio / Positron | `typr.runner_*.tar.gz` — release | +| VS Code / Positron | extension **TypR** dans le Marketplace | +| Vim / Neovim | `typr-vim-*.tar.gz` — release, ou un plugin manager | +``` + +Faire figurer en tête les deux lignes universelles : ce sont elles que l'on veut +que la personne copie. + +### Page d'installation du site + +Créer `docs/install.md` (ou l'équivalent dans le dépôt `typr.github.io`) avec, +dans cet ordre : la commande unique, le choix du canal, la vérification du +SHA-256, la mise à jour, le dépannage (PATH non modifié, Gatekeeper, +SmartScreen, architecture non supportée). + +Préciser explicitement que l'installation **n'exige pas `sudo`** et +**n'écrit rien hors de `$HOME`** — c'est ce qui lève la principale réticence. + +### Vérification + +- [ ] Les commandes du README sont copiées et exécutées telles quelles +- [ ] `RELEASING.md` mentionne les nouveaux canaux dans le diagramme +- [ ] `publish.nu check` compare bien les quatre canaux (voir ci-dessous) + +--- + +## 8. Phase 5 — `typr upgrade` (optionnelle) + +À ne faire que si les releases sont fréquentes ; sinon le script et Homebrew +suffisent. + +Le binaire serait `typr upgrade` : même logique de résolution de version et de +vérification de SHA que les scripts, mais à l'intérieur du programme — donc testée +par `cargo test` plutôt que par un script shell. + +Le risque est la duplication entre le code Rust et les scripts. Le traiter en +conséquence : soit le script devient le client d'un sous-commande `typr install`, +soit la logique est factorisée dans une petite bibliothèque partagée. + +--- + +## 9. CI + +### Étapes dans `release.yml` + +``` +tag vX.Y.Z + │ + ├─ verify … verify … release … + │ ├─ brew ……… commit de la formule mise à jour + │ ├─ winget … PR vers microsoft/winget-pkgs + │ └─ scoop … commit du manifest + └─ … +``` + +Ces jobs restent **indépendants** des autres canaux, comme le sont déjà +`vscode` et `vim` : un échec de publication Homebrew ne doit pas empêcher les +binaires d'arriver. C'est la règle que `RELEASING.md:108` énonce déjà. + +### Étapes dans `ci.yml` + +- Job `install`, sur une PR : `bash -n install/install.sh`, `sh -n`, et + exécution réelle avec `--dry-run`. +- Analyse avec `shellcheck` pour `install.sh`, `PSScriptAnalyzer` pour + `install.ps1`. +- Pour WinGet et Scoop, valider le YAML/JSON contre leur schéma. +- Vérifier que `install/` ne contient aucun binaire — le job `coherence` + l'interdit déjà pour l'ensemble du dépôt. + +### `publish.nu` + +Étendre `check` pour interroger l'API Homebrew et WinGet et confirmer que la +version publiée est bien celle du tag. Sans cela, un canal en retard devient +invisible : les autres canaux sont mis à jour au tag, lui seul dériverait en +silence. + +--- + +## 10. Ordre d'exécution + +| # | Lot | Dépend de | Effort | +|---|---|---|---| +| 0a | **Cibles musl** dans la matrice de `release.yml` | — | 2 h | +| 0b | **`+crt-static`** sur les cibles `*-msvc` | — | 30 min | +| 0c | Vérifier que le binaire musl démarre dans Rocky 9, Debian 12, Alpine | 0a | 1 h | +| 1 | `install.sh` + `install.ps1` + hébergement | 0 | 1 j | +| 2 | Tests CI sur les scripts | 1 | 3 h | +| 3 | Homebrew tap + job CI | 1 | 1 j | +| 4 | WinGet + Scoop + PR automatique | 1 | 1 j | +| 5 | README, page d'installation, `RELEASING.md` | 1-4 | 3 h | +| 6 | `publish.nu check` étendu | 1-4 | 2 h | +| 7 | `typr upgrade` (optionnel) | — | plus tard | + +Le lot 0 est **précisément-identified et non négociable** : le diagnostic a +montré que le binaire actuel ne démarre ni sur Rocky 9, ni sur Debian 12, ni sur +Ubuntu 22.04. Publier un script d'installation qui lance `typr --version` et +échoue dessus convertirait le canal principal en source de tickets — c'est-à-dire +exactement l'expérience que ce plan cherche à supprimer. + +Les lots 3 et 4 sont indépendants de 2 et parallélisables. + +--- + +## 11. Risques + +| Risque | Impact | Réponse | +|---|---|---| +| glibc des binaires Linux trop récent | **Confirmé** — l'installation échoue sur Rocky 9, Debian 12, Ubuntu 22.04, Alpine | Lot 0a : cibles musl, liées statiquement | +| Le plancher glibc remontera seul en novembre 2026 | Nouvelle rupture, sans changement de code | Lot 0a : musl supprime la dépendance à la glibc du runner | +| `VCRUNTIME140.dll` absent sur Windows minimal | Installation réussie puis `typr --version` échoue | Lot 0b : `+crt-static` | +| SmartScreen bloque `typr.exe` | Avertissement, pas un blocage | Un binaire posé par `install.ps1` ne porte pas de MOTW | +| Gatekeeper sur macOS | Rare — plancher à macOS 10.12, et `curl \| sh` ne pose pas la quarantaine | Documenter `xattr -d com.apple.quarantine` pour qui télécharge d'abord par navigateur | +| Un canal dérive en silence | Le README promet une version absente d'un canal | `publish.nu check` étendu (lot 6) | +| Le script d'installation casse les gens | Mauvaise expérience sur le canal principal | `--dry-run`, `--version` épinglé, idempotence, tests sur 6 cibles en CI | + +--- + +## 12. hors périmètre + +- Signature et notarification (coût, hors budget d'un projet open source). +- Canal `apt` / `dnf` : la distribution Linux est trop fragmentée pour que le + gain justifie l'effort. Le script Unix — et musl — couvrent ces systèmes sans + coût supplémentaire. +- Mise à jour automatique silencieuse, qui crée plus de problèmes qu'elle n'en + résout (mise à jour pendant une session LSP en cours, binaire remplacé sous + les pieds du processus). `typr upgrade` explicite suffit. +- Paquet `apt` / `dnf` : sans objet, la ligne ci-dessus couvre le sujet. \ No newline at end of file diff --git a/interface_generic_unification2.md b/interface_generic_unification2.md new file mode 100644 index 00000000..1e034804 --- /dev/null +++ b/interface_generic_unification2.md @@ -0,0 +1,337 @@ +# RFC TypR – Génériques implicites basés sur les interfaces + +> **Objet :** permettre plusieurs paramètres génériques distincts partageant la même interface, +> sans introduire de `T`, `U` explicites, ni casser le modèle “interface = type”. + +--- + +## 1. Contexte et objectifs + +### 1.1. Contexte + +Dans TypR : + +- **Les interfaces sont des types à part entière**, définis via des alias : + ```typr + type Printable <- interface { + print: (Self) -> char + }; + ``` +- Une interface sert **à la fois** : + - de **type structurel** (annotation classique), + - de **contrainte générique** (on peut écrire `fn(xs: Iterable): Iterable`). + +Ce modèle est volontairement simple pour des développeurs venant de R : +pas de `T`, `U`, pas de syntaxe de type paramétrique explicite. + +### 1.2. Problème + +Avec ce modèle, une fonction : + +```typr +let compare <- fn(a: Lovable, b: Lovable): bool { + ... +}; +``` + +ne peut pas distinguer deux **types concrets différents** pour `a` et `b` : + +- `Lovable` est un **type structurel unique**, +- donc `a` et `b` sont du **même type structurel**. + +On ne peut pas exprimer : +“`a` est un type qui satisfait `Lovable`, `b` est un autre type (potentiellement différent) qui satisfait aussi `Lovable`”. + +### 1.3. Objectif + +Introduire un mécanisme de **génériques implicites** qui : + +- **sépare les paramètres** (`a` et `b` peuvent être de types différents), +- **reste structurel** (pas de nominalisme), +- **reste simple** pour des devs R (pas de `T`, `U`), +- **respecte** le modèle mental : *interface = type = contrainte*. + +--- + +## 2. Design proposé – paramètres implicites `@Id` + +### 2.1. Idée centrale + +On ajoute une **annotation légère** sur les paramètres : + +```typr +let compare <- fn(a: Lovable@A, b: Lovable@B): bool { + ... +}; +``` + +- `Lovable` : interface/type structurel. +- `@A`, `@B` : **identifiants de paramètre générique**, *non typés*. +- Chaque identifiant (`A`, `B`) représente un **paramètre générique distinct**. + +### 2.2. Règle intuitive + +- Deux annotations `Lovable@A` et `Lovable@B` : + - partagent la **même interface** (`Lovable`), + - mais sont **deux paramètres génériques différents**. +- L’inférence peut donc déduire : + - `A = Cat`, + - `B = Dog`, + - tant que `Cat` et `Dog` satisfont `Lovable`. + +--- + +## 3. Syntaxe formelle + +### 3.1. Définition d’interface + +Syntaxe inchangée : + +```typr +type Lovable <- interface { + love: (Self) -> char +}; +``` + +### 3.2. Annotation de paramètre + +#### 3.2.1. Forme générale + +Pour un paramètre : + +```typr +name: Interface@Id +``` + +- **`name`** : identifiant du paramètre (variable). +- **`Interface`** : alias de type pointant vers une interface. +- **`Id`** : identifiant de paramètre générique (nom court, style `A`, `B`, `Item`, etc.). + +#### 3.2.2. Forme minimale + +Si aucun `@Id` n’est fourni : + +```typr +name: Interface +``` + +- Comportement **actuel** : + tous les paramètres annotés avec `Interface` **partagent le même type structurel**. +- Ce mode reste valide et est le **mode par défaut**. + +#### 3.2.3. Exemple + +```typr +let pair <- fn(a: Lovable@Left, b: Lovable@Right): (Lovable@Left, Lovable@Right) { + (a, b) +}; +``` + +--- + +## 4. Sémantique – modèle de types + +### 4.1. Interfaces comme types structurels + +On conserve : + +- Une interface `I` est un **type structurel**. +- Un alias : + ```typr + type I <- interface { ... }; + ``` + introduit un **type nommé** `I` avec une **structure requise**. + +### 4.2. Paramètres génériques implicites + +Pour chaque identifiant `Id` utilisé dans `Interface@Id` : + +- Le compilateur introduit un **paramètre de type implicite** \(\alpha_{Id}\). +- La contrainte associée est : + \[ + \alpha_{Id} : Interface + \] + c’est‑à‑dire : + “\(\alpha_{Id}\) doit satisfaire la structure de `Interface`”. + +### 4.3. Distinction des paramètres + +Deux annotations : + +```typr +a: Lovable@A +b: Lovable@B +``` + +induisent : + +- deux paramètres de type distincts \(\alpha_A\) et \(\alpha_B\), +- chacun contraint par `Lovable`. + +Il n’y a **aucune relation** imposée entre \(\alpha_A\) et \(\alpha_B\) +(sauf si le corps de la fonction impose des contraintes supplémentaires). + +--- + +## 5. Règles d’inférence + +### 5.1. Collecte des paramètres + +Pour une fonction : + +```typr +let f <- fn(a: Lovable@A, b: Lovable@B): bool { + ... +}; +``` + +Le compilateur : + +1. **Collecte** les identifiants `A`, `B`. +2. Crée un ensemble de paramètres de type : + \[ + \{\alpha_A, \alpha_B\} + \] +3. Associe les contraintes : + \[ + \alpha_A : Lovable,\quad \alpha_B : Lovable + \] + +### 5.2. Résolution à l’appel + +À l’appel : + +```typr +f(cat, dog); +``` + +Le compilateur : + +- infère le type de `cat` : `Cat`, +- infère le type de `dog` : `Dog`, +- vérifie : + - `Cat` satisfait `Lovable`, + - `Dog` satisfait `Lovable`, +- unifie : + - \(\alpha_A = Cat\), + - \(\alpha_B = Dog\). + +### 5.3. Cas sans `@Id` + +Pour : + +```typr +let f <- fn(a: Lovable, b: Lovable): bool { + ... +}; +``` + +Le compilateur : + +- introduit **un seul** paramètre \(\alpha\), +- avec contrainte : + \[ + \alpha : Lovable + \] +- à l’appel, `a` et `b` doivent être du **même type** \(\alpha\). + +--- + +## 6. Exemples d’usage + +### 6.1. Comparaison de deux Lovable distincts + +```typr +type Lovable <- interface { + love: (Self) -> char +}; + +let compare <- fn(a: Lovable@A, b: Lovable@B): bool { + a.love() == b.love() +}; +``` + +- `A` et `B` peuvent être deux types différents. +- On compare leur “score d’amour”. + +### 6.2. Fonction symétrique + +```typr +let swap <- fn(a: Lovable@A, b: Lovable@B): (Lovable@B, Lovable@A) { + (b, a) +}; +``` + +- Le type de retour encode la permutation des paramètres génériques. + +### 6.3. Interface générique + paramètres implicites + +```typr +type Box <- interface { + value: T +}; + +let merge <- fn(a: Box@A, b: Box@B): (Box@A, Box@B) { + (a, b) +}; +``` + +- `Box@A` et `Box@B` peuvent encapsuler des types différents. +- L’inférence doit gérer : + - le paramètre implicite `A`, + - le paramètre explicite `T` de `Box`. + +--- + +## 7. Points ouverts et variantes + +### 7.1. Identifiants optionnels + +On peut autoriser : + +- `Lovable@A` (nom explicite), +- `Lovable@_` (paramètre anonyme), +- `Lovable#` (syntaxe alternative plus légère). + +À discuter selon la **lisibilité** et la **culture R**. + +### 7.2. Réutilisation d’un identifiant + +Si on écrit : + +```typr +let pair <- fn(a: Lovable@X, b: Lovable@X): (Lovable@X, Lovable@X) { + (a, b) +}; +``` + +- `a` et `b` partagent le **même paramètre générique** \(\alpha_X\), +- donc doivent être du **même type**. + +C’est l’équivalent de `T` réutilisé dans les langages classiques, +mais sans introduire `T` comme type nominal. + +### 7.3. Erreurs de typage + +Cas à définir clairement : + +- utilisation de `Lovable@A` dans le retour sans paramètre correspondant, +- identifiant `@A` utilisé mais jamais contraint, +- conflit entre deux interfaces incompatibles sur le même `@A`. + +--- + +## 8. Résumé conceptuel + +- **Interfaces** restent : + - des **types structurels**, + - des **contrats**, + - des **espaces de fonctions**. +- Les **génériques implicites** sont : + - des **identifiants de contrainte** (`@A`, `@B`), + - non typés, + - résolus par **inférence**. + +> On ne demande jamais au développeur R de “penser en `T`, `U`”. +> On lui dit juste : +> “si tu veux que deux paramètres soient indépendants, donne‑leur chacun un petit tag `@A`, `@B`”. diff --git a/interface_generic_unification_plan.md b/interface_generic_unification_plan.md new file mode 100644 index 00000000..bf35bd14 --- /dev/null +++ b/interface_generic_unification_plan.md @@ -0,0 +1,370 @@ +# Génériques implicites `Interface@Id` — plan d'implémentation + +Plan pour `interface_generic_unification2.md` (RFC), écrit après lecture de `typr-core` et sondes +contre le binaire (`target/debug/typr`, branche `develop`, état au 2026-09-30). + +--- + +## 0. Ce qui existe déjà (et ce que les sondes révèlent) + +| Élément du RFC | État actuel dans le code | +|---|---| +| Interface en paramètre → variable rigide contrainte | **Déjà en place dans le corps** : `type_checking/function.rs:326-340` crée un `Type::Generic("__rigid_N")` + `add_interface_constraint` **par paramètre**. Même mécanique dans `specialize_lambda` (`function_application.rs:1061`). | +| Élimination `e.m() : T[Self ↦ A]` | En place : `try_constrained_variable_match` (`function_application.rs:29`, FILTERING 0). | +| Instanciation au site d'appel | `try_interface_subtype_match` (`function_application.rs:624`) : table `interface_to_concrete` **indexée par l'interface réduite**, et c'est le premier argument qui gagne. | +| Retour interface sans paramètre (§7.3a) | Déjà une erreur : `TypeError::InterfaceReturnOnly` (`function.rs`, `is_interface_return_only`). | +| Sigil `@` en position de type | **Déjà pris** en préfixe : `@A` → `Type::KindedGen(Kind::Interface, "A")` (`parsing/types.rs:917`, RFC `sigils.md`). | +| Génériques explicites `T`, `U` | Fonctionnent correctement : `fn(a: T, b: U): U` appelé avec `(cat, dog)` rend `Dog`. | +| Interfaces paramétrées `interface` (RFC §6.3) | **N'existent pas** (aucune occurrence dans le parseur). | + +Les sondes (fichiers `*.ty` avec `Lovable`, `Cat`, `Dog`) montrent que **le corps et le site d'appel +appliquent deux sémantiques différentes** pour `fn(a: Lovable, b: Lovable)` : + +- **Dans le corps, les variables sont distinctes** : `a : __rigid_0`, `b : __rigid_1`. +- **Au site d'appel, une seule variable est partagée**, et c'est le premier argument qui la fixe. + +Défauts constatés, à capturer en `cases/` avant toute modification : + +1. **Défaut de soundness** : `let second <- fn(a: Lovable, b: Lovable): Lovable { b }` puis + `let r: Cat <- second(cat, dog)` **passe**, et `let r: Dog <- second(cat, dog)` est **rejeté** + (« Received Cat »). Le retour est lié au premier argument, et `b: Dog` n'est jamais confronté + à `a: Cat`. +2. **`Lovable@A` est silencieusement avalé** : `fn(a: Lovable@A, b: Lovable@B)` passe `typr check`, + la signature s'affiche `fn(a: Lovable, b: Lovable) -> bool` et le R généré ne garde aucune trace + de `@A`. Il faudra trouver quel parseur consomme `@A` sans le reporter. +3. **Méthode `(Self, Self)` sur deux rigides distincts acceptée** : + `type Same <- interface { same: (Self, Self) -> bool }; fn(a: Same, b: Same): bool { a.same(b) }` + passe, alors qu'avec des rigides distincts, `b : B` ne devrait pas satisfaire le paramètre `A`. + À expliquer en phase 0 : soit `is_subtype_raw` entre deux rigides est trop permissif, soit un + filtre de repli accepte l'appel. +4. `is_rigid_compatible` (`function.rs:244`) accepte **n'importe quel** rigide dont la borne est + `Lovable` comme retour `Lovable`. C'est pour cela que `{ b }` et `{ a }` passent tous les deux. + +--- + +## 1. Décisions de conception + +### D1. Syntaxe : `I@Id` en suffixe, cohérent avec le sigil préfixe existant + +- `Lovable@A` : variable générique `A`, bornée par `Lovable`. Le `@` suffixe ne se lit qu'**immédiatement + après un nom d'alias**, sans espace. Il ne peut donc pas être confondu avec `@export`, `@pub` ou + `@name: …;`, qui sont au niveau des déclarations. +- `@A` reste ce qu'il est (générique de *kind* interface, sans borne précise). Lecture unifiée : + `@A` ≈ `interface{}@A`. Un même `A` dans `@A` et `Lovable@A` désigne **la même variable**, dont la + borne est `Lovable`. +- `Lovable@_` (RFC §7.1) : identifiant anonyme, donc variable fraîche à chaque occurrence. C'est peu coûteux + à ajouter, à inclure. +- `Lovable#` (variante §7.1) : **rejeté**, parce que `#` est déjà le sigil d'`IndexGen`. +- Les identifiants partagent **l'espace de noms des génériques de la signature**. `fn(a: T, b: Lovable@T)` est + une erreur (`T` est à la fois libre et borné). + +### D2. Représentation : un nouveau variant `Bounded` + +```rust +// components/type/mod.rs — À LA FIN de l'enum (sérialisation .bin, cf. Risques) +Bounded(String, Box, HelpData), // Lovable@A → Bounded("A", Alias("Lovable"), h) +``` + +On le préfère à une extension de `KindedGen`, dont la clé est un `Kind` (4 valeurs) et pas un type. +On le préfère aussi à une table de bornes à côté de la signature : la borne doit voyager avec le type +à travers `Function`, `Vec`, `Tuple` et la substitution. + +`Bounded` n'existe **que dans les signatures**. Dans le corps, il devient un rigide (D4). Au site +d'appel, il est lié comme un générique (D5). + +### D3. Sémantique d'un `I` nu : **option A retenue (2026-09-30) — changement de comportement assumé** + +> **Décision :** un `I` nu ≡ `I@I` (une variable partagée par interface, RFC §5.3). L'option B ci-dessous est écartée ; elle est conservée pour mémoire. + +| Option | `fn(a: Lovable, b: Lovable)` | `cmp(cat, dog)` | Retour `Lovable` | +|---|---|---|---| +| **A (RFC §5.3)** : `I` nu ≡ `I@I`, une variable partagée par interface | `a` et `b` du **même** type | **rejeté** (aujourd'hui accepté) | non ambigu | +| **B** : `I` nu ≡ `I@_`, variable fraîche par occurrence | types indépendants | accepté | **ambigu** s'il y a ≥ 2 paramètres `I` : erreur « ajoutez `@Id` » | + +**Choix : A**, pour trois raisons : + +- c'est le RFC ; +- c'est la seule option sûre pour les interfaces `(Self, Self)` (`Eq`, `Ord`, `Same`), où `a.eq(b)` + n'a de sens que si `a` et `b` ont le même type ; +- elle aligne le corps sur ce que le site d'appel fait déjà à moitié. + +Coût de A : du code qui compile aujourd'hui (`cmp(cat, dog)` avec des `Lovable` nus) sera rejeté, avec +un message qui propose `Lovable@A, Lovable@B`. Le stdlib n'est pas touché (`unique` et `sort` +n'ont qu'un paramètre interface). Il reste à mesurer l'impact sur les tests et la doc en phase 0. + +### D4. Corps : un rigide **par identifiant**, pas par paramètre + +La boucle de `function.rs:326` passe d'un rigide par paramètre à une table `id → rigide`. Deux +paramètres `Lovable@X` partagent `__rigid_k`. Avec D3-A, deux `Lovable` nus partagent aussi le leur. +`is_rigid_compatible` compare le rigide du corps **au rigide de l'identifiant déclaré en retour**, +et non plus seulement à la borne. + +### D5. Site d'appel : `Bounded` passe par l'unification générale + +On ajoute un bras à `unification_helper` (`unification.rs:239`), sur le modèle du bras `KindedGen` +déjà présent : +`(concrete, Bounded(id, bound))` lie `Generic(id) ↦ concrete` **si** `concrete ⊨ bound` +(`interface_satisfaction::check_interface_satisfaction`), et échoue sinon. Deux occurrences du +même `id` passent alors par `UnificationMap::try_new`, qui rejette déjà les liaisons contradictoires. +C'est exactement la règle « même identifiant ⇒ même type » (RFC §7.2). + +`try_interface_subtype_match` ne sert plus qu'aux interfaces nues **après désucrage**. Avec D3-A, +elles deviennent des `Bounded` et la fonction peut disparaître. Égalité exigée pour un même `id` : +on reprend le comportement actuel d'un `T` répété (`fn(a: T, b: T)`), pour ne pas créer deux règles. + +### D6. Hors périmètre : interfaces paramétrées `interface` (RFC §6.3) + +Elles ne sont pas parsées aujourd'hui. `Box@A` avec `Box <- interface` relève d'un RFC distinct. +`Bounded` est conçu pour les accueillir (borne = `Alias("Box", [T])`) sans changement de représentation. + +--- + +## 2. Phases + +### Phase 0 — Filet et mesure (aucun changement de sémantique) + +- `typr case add` pour les défauts 1, 2 et 3 du §0 (`expect.toml` : substrings d'erreur attendues). +- Expliquer le défaut 3 (`a.same(b)` accepté) : tracer `is_subtype_raw(Generic(r1), Generic(r0))`. +- Mesurer D3-A : forcer temporairement un rigide partagé par interface dans `function.rs`, puis lancer + `cargo test --workspace`, `typr case run` et, côté doc, `npm run check:examples`. Lister les échecs. + C'est cette liste qui chiffre le changement de comportement. + +**Statut : FAITE (2026-09-30).** + +- Cas ouverts (échouent tant que le plan n'est pas implémenté ; oracle : `Type errors found`) : + `0086-interface-return-bound-to-first-arg` (défaut 1), `0087-interface-shared-id-different-types` + (défaut 2 / D5), `0088-interface-self-self-distinct-ids` (défaut 3). `typr case run` : ils sont `OPEN`. + Les 4 `REGRESS` restants (0004, 0005, 0006, 0008 : diff golden de `R/main.R`) préexistent et n'ont + aucun rapport. +- **Cause du défaut 3** : `impl PartialEq for Type` (`components/type/mod.rs:1334`) déclare + `(Generic(_), Generic(_)) => true` — deux génériques quelconques sont « égaux ». Or `is_subtype_raw` + commence par `(typ1, typ2) if typ1 == typ2 => true` (`mod.rs:226`) : deux rigides distincts + `__rigid_0` / `__rigid_1` passent donc pour sous-types l'un de l'autre. Ce n'est pas un filtre de + repli. **Conséquence pour la phase 3** : ne pas toucher au `PartialEq` global (il sert à l'unification + et à `UnificationMap`) ; ajouter dans `is_subtype_raw`, avant la garde d'égalité, un bras + `(Generic(a), Generic(b))` qui compare les **noms** quand les deux sont des rigides (`__rigid_`). + Même cause probable pour le défaut 4 (`{ a }` et `{ b }` passent tous deux). +- **Mesure D3-A** (rigide partagé par interface dans le corps, `function.rs`, expérience temporaire + revertie) : `cargo test --workspace` **entièrement vert** (711 + 161 + … tests), `typr case run` + inchangé (mêmes 4 REGRESS préexistants). Un scan de `typr.github.io/docs`, `blog`, `lab/`, + `cases/*/repro` et du stdlib ne trouve **aucune** fonction à deux paramètres de même alias + d'interface. Limite : l'expérience ne touche que le corps ; le rejet de `cmp(cat, dog)` au site + d'appel (D5) n'est pas mesuré, mais le scan statique suggère un impact nul sur le corpus existant. + `npm run check:examples` non lancé (aucun exemple concerné d'après le scan). + +### Phase 1 — Parsing et affichage + +- `parsing/types.rs` : suffixe `opt(preceded(tag("@"), alt((generic_name, tag("_")))))` après un alias + (y compris imbriqué : `[#N, Eq@A]`, `tuple{Lovable@A}`, `(Lovable@A) -> …`). Produit `Type::Bounded`. +- Trouver et supprimer le chemin qui avale `@A` aujourd'hui (défaut 2). Un `@` suffixe mal formé doit + donner une erreur de syntaxe, jamais être ignoré. +- `Type::Bounded` : `Hash`/`Eq`, `type_printer` (`Lovable@A`), `type_category`, `fingerprint`, et tous les + `match` exhaustifs (le compilateur les listera). +- Manifeste de syntaxe `components/syntax/mod.rs` : déclarer le `@` suffixe de type. Puis + `typr syntax --write` et `--check`, et les deux tests `components::syntax::tests`. + +**Statut : FAITE (2026-09-30, non commitée).** + +- `Type::Bounded(id, bound, h)` ajouté en **fin** d'enum ; `Hash` (tag 45), `to_category` (celle de la + borne), `get_help_data`/`set_help_data`, `type_printer` (`format` et `verbose` : `Lovable@A`). + Les autres `match` ont des jokers : le variant ne traverse pas encore `reduce_type`, l'unification + ni `checked_assertions` (phases 2-5). +- Parseur (`type_alias`, `bound_suffix`) : `@` collé à l'alias, suivi de **une seule majuscule** ou de `_`. + `Self` n'est pas un id valable (un générique est une lettre ; `Self` est réservé aux interfaces). +- **Défaut 2 (`@A` avalé)** : la cause est le résolveur de priorité des types, qui écarte un type + résiduel. Un `@` suffixe mal formé (`Lovable @A`, `Lovable@Self`, `Lovable@Abc`) pousse + maintenant `SyntaxError::DetachedBoundSuffix` (**S018**, + entrée `typr-mcp/src/explain.rs`). + `Lovable@` et `Lovable@a` échouaient déjà (point-virgule manquant). +- Manifeste de syntaxe : **rien à changer**, `@` y est déjà (`live("@", "Interface", …)`) ; + `typr syntax --check` passe. +- Tests : 5 nouveaux dans `parsing/types.rs`. `cargo test --workspace` vert ; `typr case run` : mêmes 4 + REGRESS préexistants. +- Limite : `Bounded` est parsé mais **pas encore interprété**. `fn(a: Lovable@A, b: Lovable@B)` passe + `typr check` avec la sémantique d'avant. Le défaut 2 n'est donc corrigé qu'au niveau syntaxe ; le cas + 0087 reste OPEN jusqu'à la phase 4. + +### Phase 2 — Normalisation de la signature + +Un seul passage, appelé depuis `function()` et pour les signatures `@name: …;` : + +- collecter `id → borne` sur les paramètres et le retour ; +- appliquer le désucrage D3 aux `I` nus : `I` devient `Bounded(I, I)` (option A) ; + `@_` donne un id frais ; +- erreurs (RFC §7.3), une variante `TypeError` chacune, codes d'erreur stables : + - un `id` avec deux bornes différentes (`Lovable@A`, `Printable@A`), sauf si l'une est `@A` sans borne ; + - un `id` présent seulement dans le retour : on généralise `InterfaceReturnOnly` ; + - un `id` qui entre en collision avec un générique libre du même nom ; + +**Statut : FAITE (2026-09-30, non commitée).** + +- Nouveau module `type_checking/signature_normalization.rs` : `normalize_signature(context, params, ret)` + renvoie `{ params, ret, errors }`. Il (1) renomme chaque `I@_` en `_0`, `_1`…, (2) désucre un alias + d'interface nu `I` en `Bounded("I", I)` (D3-A ; les `interface { … }` en ligne sont laissés tels quels), + (3) produit les erreurs du §7.3. +- Erreurs : `ConflictingBound` **T047** (deux bornes pour un id ; comparées après `reduce_type`), + `BoundCollidesWithGeneric` **T048** (id égal à un générique libre `T`/`#N` ; `@A` n'est pas compté, D1), + et `InterfaceReturnOnly` **T017** réutilisée pour un id présent seulement au retour. Entrées `explain` + T047/T048 dans `typr-mcp`. +- **Seules les erreurs sont branchées** dans `function()`. La signature désucrée est calculée mais pas + consommée : corps et site d'appel lisent encore la signature brute, donc le comportement des programmes + valides est inchangé (les I nus ne deviennent pas `Bounded` avant les phases 3-4). +- Limite connue : `fn(a: Lovable@A): Lovable@A { a }` est encore rejeté (`Bounded` n'est pas interprété + dans le corps) — c'est l'objet de la phase 3. +- Tests : 6 nouveaux. `RUST_MIN_STACK=8388608 cargo test --workspace` vert, sauf + `typr-graph --test walkthrough` (ordre `Point/x`/`Point/y` d'un `HashSet`, flaky, sans lien). + +### Phase 3 — Typage du corps + +- `function.rs:326` : table `id → rigide` (D4). `add_interface_constraint(rigide, borne)` une seule fois + par id. +- `specialize_lambda` (`function_application.rs:1061`) : même traitement, et `rigid_to_concrete` indexé + par id. +- `is_rigid_compatible` : le retour `Lovable@B` n'accepte que le rigide de `B`. +- Défaut 3 : entre deux rigides distincts, `is_subtype_raw` doit valoir `false`. Donc `a.same(b)` sera + rejeté avec `Same@A, Same@B`, et accepté avec `Same@A, Same@A` ou avec `Same` nu (D3). + +**Statut : FAITE pour `function()` (2026-09-30, non commitée).** + +- `function.rs` : table `id → rigide` (un rigide par id, D3-A pour les `I` nus via la signature + normalisée). Un paramètre `Lovable@A` est typé par sa borne ; la table sert aussi au retour : un retour + `Lovable@B` est vérifié contre le **rigide de `B`** (`checked_ret`, aussi passé à + `set_expected_return_type` pour les `return` anticipés). `is_rigid_compatible` : si le retour déclaré est + un rigide, seul ce rigide passe (le défaut 4 venait de `interface <: Generic(_)`). +- Défaut 3 : bras `(Generic, Generic)` rigides dans `is_subtype_raw` (comparaison par nom). Piège : + `Hash` ignorait le nom d'un `Generic`, donc le cache de sous-typage confondait `(r0, r1)` et `(r0, r0)` ; + les noms de rigides entrent maintenant dans le hash (`is_rigid_name`, `type/mod.rs`). +- Tests : 5 dans `function.rs` (`test_bound_*`, `test_self_self_*`, `test_bare_interface_*`). Piège de test : + `parse2` ne se comporte pas comme le CLI (les rejets passaient) ; le helper utilise `parse_from_string`. +- `typr case run` : 0088 passe à READY (rejeté dans le corps). 0086/0087 restent en attente de la phase 4 + (READY pour de mauvaises raisons : l'appel d'une fonction `Lovable@A` échoue tant que `Bounded` n'est pas + unifié). `cargo test --workspace` vert ; mêmes 4 REGRESS préexistants. +- **Non fait** : `specialize_lambda` (les paramètres de lambda n'ont pas de `Bounded` déclaré ; à traiter en + phase 4 avec `rigid_to_concrete` par id). Rigides imbriqués (`[#N, Lovable@A]`) : toujours sans rigide. +- À corriger : le message de retour affiche `Found: __RIGID_1` ; il faudrait nommer l'id (`B`). + +### Phase 4 — Site d'appel + +- `unification.rs` : bras `Bounded` (D5), avec un occurs check comme pour `Generic`. +- `apply_unification_to_return_type` : la substitution de `Generic(id)` couvre `Bounded(id, _)` dans le + retour, y compris sous `Tuple`, `Vec` et `Function` (le `swap` du RFC §6.2 rend `tuple{Dog, Cat}`). +- `try_named_generic_match` / `collect_interface_bindings` : les signatures mixtes + (`fn(f: (T) -> U, x: Lovable@A)`) et les éléments de tableau (`[#N, Eq@A]`). +- Supprimer `try_interface_subtype_match`, ou le réduire au strict nécessaire (D5). +- Messages d'erreur au site d'appel : pour « `A` lié à `Cat` puis à `Dog` », nommer l'identifiant et les + deux arguments. + +**Statut : FAITE, avec écarts au plan (2026-09-30, non commitée).** + +- **Écart D5** : pas de bras `Bounded` dans `unification_helper`. `signature_normalization::instantiate_at_call` + normalise la signature, lie chaque id à l'argument (`bind_ids` : la borne doit être satisfaite, un même id + exige le même type par sous-typage mutuel, car le `PartialEq` de `Type` est trop lâche pour les génériques), + puis **substitue** `Bounded(id)` par le type lié dans les paramètres et le retour. Les filtres existants + voient ensuite une signature concrète (les `#N`, `T` restants sont à eux). Plus simple qu'un bras + d'unification, et le retour est substitué sous `Vec`/`Tuple`/`Function` par le même `rewrite`. +- Branché au début de `apply_from_variable_inner` : signature rejetée → retirée des candidats ; une signature + est concernée seulement si elle a un `Bounded` déclaré ou un même id ≥ 2 fois après désucrage (un `I` nu + unique garde le chemin historique). Le message d'erreur liste les signatures *déclarées*. +- **`try_interface_subtype_match` conservé** (il sert encore au `I` nu unique, `unique`/`sort`). À réduire plus tard. +- Cas de test : `cmp(cat, dog)` avec `Lovable@A, Lovable@B` OK ; `Lovable@X` répété avec `(cat, dog)` rejeté ; + `Lovable` nu répété avec `(cat, dog)` rejeté (D3-A) ; `id(id(cat))` OK ; retour `Lovable@B` = `Dog`, pas `Cat`. + 4 tests dans `signature_normalization.rs`. Cas 0086/0087/0088 : READY. `cargo test --workspace` vert + (hors `typr-graph walkthrough`, flaky connu) ; mêmes 4 REGRESS préexistants. +- Piège : le cas 0086 utilisait `Dog` ⊃ `Cat` (largeur de record), donc `Dog <: Cat` et le retour `Dog` passait + pour `Cat`. `Dog` n'a plus que `age`. +- **Non fait** : `swap` avec `tuple{…}` (syntaxe littérale non vérifiée), éléments `[#N, Eq@A]` (code prévu via + `Vec` mais sans test), `specialize_lambda` par id, message dédié « `A` lié à `Cat` puis `Dog` » (aujourd'hui : + `NoMatchingSignature`). + +### Phase 5 — Transpilation, `--checked`, LSP + +- **Le R généré ne doit pas changer** (propriété à vérifier, pas une option) : la dispatch S3 + `cmp.Lovable` / `cmp.default` utilise le nom de la borne, pas l'id. +- `transpiling/checked_assertions.rs` : un `Bounded` s'asserte comme sa borne. +- LSP : le survol affiche `Lovable@A` dans la signature et `Cat` au site d'appel. + +**Statut : FAITE (2026-09-30, non commitée). LSP vérifié par lecture seulement.** + +- **La propriété « R inchangé » était fausse** : `fn(a: Lovable@A): Lovable@A` produisait `idf.default` avec + un cast `as.Generic()`, au lieu de `idf.Lovable` / `as.Lovable()` + alias `.default`. Cause : `get_class`, + `get_class_unquoted`, `get_type_anotation` et `get_type_anotation_no_parentheses` (`context/vartype.rs`, + `context/mod.rs`) ne connaissaient pas `Bounded`, et la branche `Lang::Let` de `transpiling/mod.rs` lisait + le type du paramètre de dispatch brut (facettes interface/record). Correctif : `Bounded` est déballé en sa + borne à ces cinq endroits. +- `checked_assertions.rs` : `Bounded` s'asserte comme sa borne (`checked_descriptor`). +- Vérifié : R généré **identique** (diff vide) entre `Lovable` et `Lovable@A`, avec et sans `--checked`. + Test : `test_bounded_id_does_not_change_generated_r` (`transpiling/mod.rs`). +- LSP : aucun changement de code. Le survol passe par `type_printer`, qui affiche déjà `Lovable@A` (phase 1). + Non testé dans le crate `typr-lsp`. +- `cargo test -p typr-core` vert ; `typr case run` : mêmes 4 REGRESS préexistants, 0086-0088 READY. Sous + `cargo test --workspace`, `registry_validate::formals_diff_flags_…` a échoué une fois et passe seul (instable + en parallèle, sans lien). + +### Phase 6 — Documentation et RFC + +- Déplacer le RFC dans `rfcs/` (numéro suivant, format `0000-template.md`), avec les décisions D1-D6. +- `typr.github.io` : page de référence sur les interfaces, `syntaxe.md` (**les deux copies**), glose + du `@` suffixe dans `scripts/syntax-glossary.mjs` (sinon `check:syntax` casse), et exemples vérifiés + par `typr check` (un `compile_fail` pour `cmp(cat, dog)` avec des `Lovable` nus ). +- `ai_context/interface_as_constrained_generic.md` §4.2 et §8.4 : à mettre à jour. Cette spec disait + « paramètres distincts ⇒ variables distinctes » et proposait `where A: I`, que `I@A` remplace. + + +**Statut : FAITE (2026-09-30, non commitée).** + +- RFC : `rfcs/0000-interface-bound-generics.md` (numéro à attribuer au merge de la PR). +- `typr.github.io` : section « `I@Id` » dans `docs/reference/interfaces.md` (4 blocs : 1 `compile_fail` + pour `cmp(cat, dog)` avec `Lovable` nus, 3 vérifiés), et `syntaxe.md` §11. Il n'existe **qu'une** copie de + `syntaxe.md` (`typr.github.io`) ; `typR/typr/syntaxe.md` n'existe pas, contrairement à ce que dit le CLAUDE.md. +- Glossaire `syntax-glossary.mjs` : **inchangé**, car le manifeste n'a pas bougé (`@` y est déjà) ; + `npm run check:syntax` passe. +- `ai_context/interface_as_constrained_generic.md` : §4.2, §8.4 et la note §10 réécrits. +- Piège : `check:examples` prend le `typr` du PATH (installé, ancien). Il faut + `TYPR_BIN=…/target/debug/typr`, sinon les blocs `@Id` échouent à tort. Avec lui : 199 blocs + 7 `compile_fail` OK. +### Suivi — points « non fait » des phases 3-4 (2026-09-30, non commité) + +- **Message de retour** : `name_rigids` (`function.rs`) réécrit les rigides en `Lovable@B` (ou `Lovable` nu) : plus de `__RIGID_1`. +- **Site d'appel** : `CallInstance::Clash(IdClash)` + `TypeError::IdBoundToTwoTypes` **T049** (deux spans : « `X` est `Cat` ici / mais `Dog` ici »). + Émis quand toutes les signatures déclarées sont rejetées par un clash d'id ; sinon `NoMatchingSignature`. Entrée `explain` T049. +- **Rigides imbriqués** : `[#N, Lovable@A]`, `tuple{Lovable@B, Lovable@A}` en paramètre *et* en retour passent par `replace_bounded_with_rigids` + (`function.rs`). `swap` (tuple) et `first` (tableau) sont vérifiés, positif et négatif. +- **Bug trouvé** : `PartialEq for Type` n'avait pas de bras `Bounded` (`_ => false`) : un `Bounded` n'était jamais égal à lui-même, le graphe de types + de `hoist_aliases` se dupliquait à chaque appel, et le vérificateur bouclait (deux fonctions `Lovable@A` appelées avec des lambdas sans type). + Corrigé. Effet de bord : `get_type_anotation` trouvait un alias `FunctionN` au lieu de `as.Generic()` ; `has_bounded` compte désormais comme générique + (R généré à nouveau identique à la forme nue). +- **Hors périmètre, préexistant** : une lambda sans annotation passée en argument (`apply(fn(c) { … }, cat)`) échoue déjà avec `T` (« fn not defined »). + Donc `specialize_lambda` par id n'est pas atteignable tant que ce n'est pas résolu ; lambdas *typées* avec `@A`/`@B` : OK. +- **Reste** : `try_interface_subtype_match` (I nu unique), `[#N, Eq@A]` avec une interface `(Self, Self)` non sondé. +- Tests : 4 nouveaux dans `signature_normalization.rs`. `cargo test --workspace` vert ; mêmes 4 REGRESS. + +--- + +## 3. Tests à écrire (au fil des phases) + +| Programme | Attendu | +|---|---| +| `fn(a: Lovable@A, b: Lovable@B): bool { a.love() == b.love() }`, `cmp(cat, dog)` | OK | +| `fn(a: Lovable@A, b: Lovable@B): Lovable@B { b }`, `let r: Dog <- f(cat, dog)` | OK (défaut 1 corrigé) | +| idem, `let r: Cat <- f(cat, dog)` | erreur | +| `fn(a: Lovable@A, b: Lovable@B): Lovable@A { b }` | erreur dans le corps | +| `fn(a: Lovable@X, b: Lovable@X)` appelé avec `(cat, dog)` | erreur « X lié à Cat puis Dog » | +| `swap` : `tuple{Lovable@B, Lovable@A}` | `tuple{Dog, Cat}` | +| `fn(a: Same@A, b: Same@B): bool { a.same(b) }` | erreur (défaut 3) | +| `fn(a: Same, b: Same): bool { a.same(b) }`, appel `(1, 2)` | OK (D3) | +| `fn(a: Lovable, b: Lovable)` appelé avec `(cat, dog)` | erreur + suggestion `@A/@B` (D3) | +| `Lovable@A` et `Printable@A` dans la même signature | erreur de borne conflictuelle | +| `fn(): Lovable@A` | erreur (retour seul) | +| `double(3)`, `double(double(3))`, `unique`/`sort` sur `[#N, Point]` | inchangés (régression) | +| `Lovable @A` (espace), `Lovable@` | erreur de syntaxe, pas de perte silencieuse | +| R généré de `cmp` | identique à avant | + +--- + +## 4. Risques + +- **Changement de comportement (D3)** : c'est le principal. La phase 0 le chiffre avant qu'on + s'engage. +- **Sérialisation `.bin`** : `Bounded` va à la fin de l'enum `Type` (même contrainte que `Refined`). +- **Deux chemins d'appel** (unification générale et `try_interface_subtype_match`) : risque d'incohérence + s'ils coexistent. D5 les fusionne. +- **Couplage inter-dépôts** : le manifeste de syntaxe et le glossaire de `typr.github.io` doivent + changer ensemble, sinon le build de la doc casse. diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 00000000..4efe94e9 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,238 @@ +# Manifestes WinGet et Scoop — TypR + +Les canaux `winget install we-data-ch.TypR` et `scoop install typr` ne publient +aucun binaire. Le dépôt [`we-data-ch/scoop-bucket`](https://github.com/we-data-ch/scoop-bucket) +ne contient que des URL de release et leurs SHA-256 ; la pull request ouverte sur +[`microsoft/winget-pkgs`](https://github.com/microsoft/winget-pkgs) n'en contient +pas davantage. + +Ces valeurs sont **générées**, jamais écrites à la main. Un SHA recopié à la main +finit toujours par désigner un binaire qui n'est plus celui de la release, et +l'erreur n'apparaît qu'à l'installation, chez l'utilisateur. Ici elles sont lues +dans le `checksums.txt` que la release publie, puis comparées aux archives +réellement téléchargées avant d'être publiées. + +Les deux canaux lisent le **même** fichier, ce qui les empêche de diverger entre +eux : une release ne peut pas être installé par WinGet et refusée par Scoop sous +prétexte qu'une empreinte a été recopiée. + +## Les fichiers d'ici + +| Fichier | Rôle | +|---|---| +| `render.sh` | le générateur : tag + `checksums.txt` → les six fichiers des deux canaux | +| `winget/*.yaml.in` | les trois manifestes WinGet : version, installation, locale `en-US` | +| `scoop/typr.json.in` | le manifeste Scoop, et le même fichier versé dans l'historique du bucket | +| `scoop/README.md.in` | le README du bucket, dont la description et l'URL sont relues dans le manifeste | +| `tests/` | la suite de tests et le validateur de schémas, exécutés par le job `packaging-win` de `ci.yml` | + +Le générateur est commun aux deux canaux — `render.sh`, à la racine de +`packaging/`, rend tout. Il est POSIX `sh` de bout en bout : ni bash, ni ruby, ni +python. La validation par WinGet et par Scoop ne peut pas tourner ici ; elle est +faite par la suite de tests (schémas officiels) et par la CI (installation +réelle). + +## Rendre les manifestes + +```sh +# depuis le `checksums.txt` d'une release publiée +packaging/render.sh --tag v0.5.12 --out /tmp/manifests + +# ou depuis une copie locale du fichier +packaging/render.sh --tag v9.9.9 --checksums ./checksums.txt --out /tmp/manifests +``` + +Six fichiers sont écrits, rien d'autre : + +``` +winget/we-data-ch.TypR.yaml version → PR winget-pkgs +winget/we-data-ch.TypR.installer.yaml installateurs (x64, arm64) +winget/we-data-ch.TypR.locale.en-US.yaml locale +scoop/bucket/typr.json version courante du bucket +scoop/bin/typr/.json historique +scoop/README.md README du bucket +``` + +Sans `--checksums`, l'URL est déduite du tag — +`https://github.com/we-data-ch/typr/releases/download//checksums.txt` — ce +qui est exactement ce que font les jobs `winget` et `scoop`. + +| Option | Effet | +|---|---| +| `--tag ` | version à publier ; le `v` initial est optionnel, `-alpha.N` et `-beta.N` sont acceptés | +| `--checksums ` | `checksums.txt` : URL (défaut) ou fichier local | +| `--out ` | où écrire `winget/` et `scoop/` (défaut : le répertoire courant) | +| `--release-date ` | `ReleaseDate` WinGet ; par défaut la date du jour, en UTC | +| `--help` | l'aide | + +## Tester + +Sans réseau, sous n'importe quel Unix : + +```sh +packaging/tests/run-tests.sh +``` + +La suite couvre ce qui compte et ne se voit pas à la relecture : + +- que le générateur prenne **la** bonne ligne parmi les huit de `checksums.txt` — + les deux archives Windows ne diffèrent que par un mot, et les six autres + cibles ne doivent jamais apparaître dans un canal Windows ; +- que l'URL d'une architecture ne soit jamais jumelée à l'empreinte de l'autre ; + les deux cibles sont vérifiées comme un bloc, architecture comprise ; +- que `url` et `hash` du manifeste Scoop restent alignés par index, comme Scoop + les lit ; +- que l'alias WinGet (`typr`) et l'exécutable Scoop (`typr.exe`) désignent le + même programme ; +- que rien ne soit écrit quand une vérification échoue. + +Les schémas officiels sont validés en plus, quand le réseau est là : + +```sh +packaging/render.sh --tag v9.9.9 \ + --checksums packaging/tests/fixtures/checksums-v9.9.9.txt --out /tmp/manifests +packaging/tests/validate-schemas.py /tmp/manifests +``` + +Ils sont téléchargés depuis `winget-pkgs` et `Scoop`, pas recopiés dans ce dépôt : +`winget-pkgs` n'accepte que son propre schéma, et une copie locale validerait +moins que ce qui sera exigé dès la version suivante. + +## Ce que la CI en fait + +Le job `packaging-win` de `ci.yml` rend les manifestes depuis une fixture, sans +réseau ni release, et les fait valider par les schémas officiels. C'est ce qui +arrête une pull request. + +Ce que la fixture ne peut pas juger a lieu au moment de la release : + +Le job `winget` de `release.yml`, sur `windows-latest` : + +1. récupère le `checksums.txt` de la release ; +2. rend les manifestes ; +3. télécharge les deux archives Windows et vérifie que leurs empreintes + figurent bien dans les manifestes rendus ; +4. passe le dossier rendu à `wingetcreate submit`, qui le valide avec le + validateur officiel de WinGet avant d'ouvrir la pull request. + +Le job `scoop` de `release.yml`, sur `windows-latest` : + +1. récupère le `checksums.txt` de la release ; +2. rend les manifestes ; +3. installe le binaire **pour de vrai**, depuis le manifeste local : + `scoop install ` télécharge l'archive publiée, en vérifie + l'empreinte, l'extrait, crée le shim `typr` — puis `typr --version` doit + annoncer la version de la release ; +4. ne commite vers le bucket qu'après cela, en purgeant l'historique au-delà des + dix dernières versions. + +Sans les secrets, les étapes 1 à 3 ont lieu quand même et seule la publication est +sautée, avec un avertissement ; un refus de WinGet ou de Scoop arrête le job. + +## Deux points où le plan avait tort + +Le plan de distribution annonçait `InstallLocation` dans le manifeste WinGet, en +prétendant obtenir `%LOCALAPPDATA%\Programs\typr`. Ce champ n'est pas un dossier +de destination : c'est un *argument passé à l'installeur*, et un zip n'a pas +d'installeur. WinGet installe donc dans son propre dossier de paquet, sous +`%LOCALAPPDATA%\Microsoft\WinGet\Packages\…`, et y met `typr` sur le PATH — sans +droits administrateur, ce qui est le point qui compte. Le chemin exact n'est pas +contractuel et changera ; `irm | iex` reste le canal qui promet `%LOCALAPPDATA%`. + +Le plan annonçait aussi un « `.installer.yaml` de signature ». Ce fichier est +nécessaire quel que soit le statut de signature : c'est lui qui décrit les +installateurs. La signature, elle, se déclare par `SignatureSha256`, qui n'a pas +sa place ici : les archives ne sont pas signées, et WinGet n'en exige pas pour +publier. + +## Trois pièges de validation, et comment ils ont été trouvés + +Ce sont des défauts que rien n'affiche à la relecture. Ils sont tous couverts par +la suite de tests. + +**`ReleaseDate` sans guillemets.** Lu par un analyseur YAML 1.1 — PyYAML, et +tout ce qui l'imite — `2026-01-02` est une *date*, pas une chaîne, et le schéma +de `winget-pkgs` exige une chaîne. Le gabarit écrit donc `ReleaseDate: "…"`. +Guillemeté, le fichier est un `string` partout, sans dépendre de la version de la +norme qu'applique l'outil de validation. + +**Une clé de confort dans le manifeste Scoop.** Le schéma de Scoop refuse toute +clé inconnue (`additionalProperties: false`) et n'accorde que `_comment` parmi les +clés préfixées par un tiret bas. Une clé `_comment_urls` expliquant l'ordre des +URLs aurait été rejetée à l'installation : l'explication est dans ce fichier, dans +le gabarit, et dans les tests. + +**`url` et `hash` réordonnés.** Les deux tableaux sont alignés par index, et +Scoop vérifie l'empreinte du fichier qu'il a réellement téléchargé. Un `hash` +réordonné seul ne serait pas faux *par construction* — seulement faux à +l'installation, chez l'utilisateur, sur une architecture ou pas l'autre. La suite +compare donc les deux tableaux au `checksums.txt` de la fixture, index par index. + +## ARM64 + +Les deux archives Windows sont publiées et annoncées : `x86_64-pc-windows-msvc` et +`aarch64-pc-windows-msvc`. WinGet les associe à `x64` et `arm64` respectivement. + +Scoop, lui, choisit son architecture en cherchant la chaîne littérale `arm64` +dans le manifeste — un nom de fichier `aarch64-…` ne suffit pas. Un manifeste qui +ne contient pas ce mot est donc traité comme x64 sur une machine ARM64, et le +binaire x86_64 y tourne par émulation, ce que Windows 11 sait faire. C'est +acceptable, et c'est pourquoi les deux URL sont fournies : Scoop les propose en +miroirs et vérifie l'empreinte du fichier réellement téléchargé. Ajouter un bloc +`architecture.arm64` pour faire croire à une sélection native ne changerait rien +— `arch_specific` retomberait sur le tableau `url` — et coûterait une illusion. + +## Prérequis de publication + +- **Le dépôt [`we-data-ch/scoop-bucket`](https://github.com/we-data-ch/scoop-bucket) doit exister.** + C'est un dépôt ordinaire : il suffit de l'initialiser avec un `README.md` et un + `LICENSE`. Tant qu'il n'existe pas, le job `scoop` échoue bruyamment. +- **`SCOOP_BUCKET_TOKEN`** — PAT GitHub avec permission d'écriture sur ce dépôt. +- **`WINGET_CREATE_GITHUB_TOKEN`** — PAT GitHub avec le scope `public_repo` : + `winget-pkgs` est public, et il faut pouvoir y ouvrir une pull request. + `wingetcreate` le lit dans la variable d'environnement + `WINGET_CREATE_GITHUB_TOKEN`, jamais dans `--token`, qui l'afficherait dans la + liste des processus du runner. + +## La première soumission WinGet + +`wingetcreate submit` sert à la première publication d'un paquet ; `update` +exigerait qu'il existe déjà dans `winget-pkgs`. Le chemin de destination +(`manifests/w/we-data-ch/typpr//`) est calculé par l'outil d'après le +contenu des manifestes : le dossier rendu n'a pas besoin d'être pré-dispositionné. + +WinGet ne propose une version prerelease que sur demande explicite : +`winget install --pre`. Le paquet reste donc installable normalement, et le +rendu des manifestes n'a pas à distinguer les deux cas — sauf que le premier +`winget install` que quelqu'un tentera pour un paquet encore inconnu de +`winget-pkgs` risque de proposer `-alpha.1` à la place de la version stable. +Précéder la première publication d'une version stable est donc la prudence, pas +une règle que le générateur puisse appliquer : il rend fidèlement ce que la +release publie. + +Ce n'est pas au générateur de trancher non plus sur la date : `ReleaseDate` +décrit la date de la version WinGet, pas celle du commit ni celle de la +publication. Par défaut c'est le jour de la génération, ce qui est le jour où la +pull request est ouverte. + +Enfin, `wingetcreate` réécrit les manifestes avec son propre sérialiseur : les +commentaires de nos gabarits ne se retrouvent pas dans la pull request. Ce qui +compte — les champs — est exactement ce que l'outil a validé. + +## Ce que le générateur refuse + +| Cause | Message | +|---|---| +| tag illisible | `tag illisible : …` (code 2) | +| `--release-date` illisible | `release-date illisible : …` (code 2) | +| `checksums.txt` absent | `checksums.txt introuvable` | +| ligne absente pour un artefact | `ne contient aucune ligne pour …` | +| deux lignes pour le même artefact | `contient 2 lignes pour …` | +| SHA malformé | `ligne malformée dans checksums.txt` | +| gabarit modifié | `contient encore un marqueur non substitué` | +| rendu sans retour à la ligne final | `ne se termine pas par un retour à la ligne` | +| gabarit amputé d'une cible | `le manifeste d'installation déclare N empreintes, attendu 2` | + +Dans tous les cas **rien n'est écrit** dans `--out` : une erreur au milieu du +rendu ne doit pas laisser un manifeste tronqué que la CI proposerait à +`winget-pkgs`, ou commiterait dans le bucket. diff --git a/packaging/homebrew/Formula/typr.rb.in b/packaging/homebrew/Formula/typr.rb.in new file mode 100644 index 00000000..33df9067 --- /dev/null +++ b/packaging/homebrew/Formula/typr.rb.in @@ -0,0 +1,48 @@ +# TypR — un sur-ensemble typé de R : transpileur et vérificateur de types. +# +# FICHIER GÉNÉRÉ — ne pas l'éditer à la main. +# +# Gabarit : packaging/homebrew/Formula/typr.rb.in (dépôt we-data-ch/typr) +# Généré par : packaging/homebrew/render.sh --tag vX.Y.Z +# Version : @@VERSION@@ (déduite de l'URL par Homebrew, sans `version`) +# +# La version et les empreintes viennent de `checksums.txt` de la release : elles +# ne peuvent donc pas dériver du binaire réellement publié, ce qui arrive dès +# qu'un SHA est recopié à la main. Pour installer le tap ou corriger ce +# fichier : packaging/homebrew/README.md +class Typr < Formula + desc "Typed superset of R — transpiler and type checker" + homepage "https://we-data-ch.github.io/typr.github.io/" + license "Apache-2.0" + + livecheck do + url :stable + strategy :github_latest + end + + on_macos do + on_arm do + url "@@URL_AARCH64_APPLE@@" + sha256 "@@SHA256_AARCH64_APPLE@@" + end + + on_intel do + url "@@URL_X86_64_APPLE@@" + sha256 "@@SHA256_X86_64_APPLE@@" + end + end + +@@LINUX@@ + + def install + bin.install "typr" + end + + def caveats + "TypR is pre-1.0: 0.x releases can change the CLI surface." + end + + test do + assert_match version.to_s, shell_output("#{bin}/typr --version") + end +end diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md new file mode 100644 index 00000000..2a068519 --- /dev/null +++ b/packaging/homebrew/README.md @@ -0,0 +1,124 @@ +# Tap Homebrew — TypR + +Le canal `brew install we-data-ch/typr/typr` ne publie aucun binaire. Le dépôt +[`we-data-ch/homebrew-typr`](https://github.com/we-data-ch/homebrew-typr) ne +contient qu'une formule : une URL de release et son SHA-256. + +Ces deux valeurs sont **générées**, jamais écrites à la main. Un SHA recopié à la +main finit toujours par désigner un binaire qui n'est plus celui de la release, +et l'erreur n'apparaît qu'à l'installation, chez l'utilisateur. Ici elles sont +lues dans le `checksums.txt` que la release publie, puis comparées à l'archive +réellement téléchargée avant d'être commitées. + +## Les fichiers d'ici + +| Fichier | Rôle | +|---|---| +| `render.sh` | le générateur : tag + `checksums.txt` → `Formula/typr.rb` et `README.md` | +| `Formula/typr.rb.in` | le gabarit de la formule | +| `parts/on_linux.rb.in` | le bloc `on_linux`, inséré seulement si la release publie un binaire musl | +| `parts/on_linux_absent.rb.in` | le commentaire mis à sa place sinon | +| `README.md.in` | le README du tap, dont la description et l'URL sont relues dans la formule | +| `tests/` | la suite de tests, exécutée par le job `packaging` de `ci.yml` | + +## Rendre une formule + +```sh +# depuis le `checksums.txt` d'une release publiée +packaging/homebrew/render.sh --tag v0.5.12 --out /tmp/tap + +# ou depuis une copie locale du fichier +packaging/homebrew/render.sh --tag v9.9.9 --checksums ./checksums.txt --out /tmp/tap +``` + +Deux fichiers sont écrits, rien d'autre : `Formula/typr.rb` et `README.md`. Sans +`--checksums`, l'URL est déduite du tag — +`https://github.com/we-data-ch/typr/releases/download//checksums.txt` — ce +qui est exactement ce que fait le job `brew`. + +| Option | Effet | +|---|---| +| `--tag ` | version à publier ; le `v` initial est optionnel | +| `--checksums ` | `checksums.txt` : URL (défaut) ou fichier local | +| `--out ` | où écrire (défaut : le répertoire courant) | +| `--help` | l'aide | + +## Tester + +Sans Homebrew ni réseau, sous n'importe quel Unix : + +```sh +packaging/homebrew/tests/run-tests.sh +``` + +La suite couvre ce qui compte et ne se voit pas à la relecture : que le +générateur prenne **la** bonne ligne parmi les huit de `checksums.txt` — les +bâtiments GNU et musl ne diffèrent que par un mot — et qu'il refuse de produire +une formule à moitié rendue. + +Sur macOS, ajouter ce que Linux ne peut pas juger : + +```sh +packaging/homebrew/render.sh --tag v9.9.9 \ + --checksums packaging/homebrew/tests/fixtures/checksums-v9.9.9.txt --out /tmp/tap + +TAP_DIR="$(brew --repository)/Library/Taps/we-data-ch/homebrew-typr" +mkdir -p "$TAP_DIR/Formula" +cp /tmp/tap/Formula/typr.rb "$TAP_DIR/Formula/typr.rb" + +brew style "$TAP_DIR/Formula/typr.rb" +brew audit --strict --formula="$TAP_DIR/Formula/typr.rb" + +# installation réelle, sur l'archive publiée +brew install we-data-ch/typr/typr +``` + +## Ce que la CI en fait + +Le job `brew` de `release.yml`, sur `macos-latest` : + +1. récupère le `checksums.txt` de la release ; +2. rend la formule ; +3. télécharge `typr--aarch64-apple-darwin.tar.gz` et vérifie que son + empreinte figure bien dans la formule rendue ; +4. passe la formule à `brew style` et `brew audit --strict` ; +5. ne commite vers le tap qu'après tout cela. + +Sans le secret `HOMEBREW_TAP_TOKEN`, les étapes 1 à 4 ont lieu quand même et +seule la publication est sautée, avec un avertissement ; un refus de Homebrew +arrête le job. Mieux vaut un job rouge qu'un tap que personne ne peut installer. + +Le dépôt `we-data-ch/homebrew-typr` existe et contient déjà la formule de +v0.5.12, rendue depuis le `checksums.txt` de cette release. Il reste à définir +le secret `HOMEBREW_TAP_TOKEN` (PAT GitHub, scope `repo` sur ce dépôt) dans les +secrets de la CI ; la prochaine release exercera alors le chemin complet +`release.yml` → tap. + +## Pourquoi Linux peut manquer + +Le bloc `on_linux` n'est inséré que si la release publie +`typr--{x86_64,aarch64}-unknown-linux-musl.tar.gz`. **Aucune version +publiée ne le fait** : les cibles musl sont entrées dans la matrice après +v0.5.12, et le prochain tag les produira. Le rendu produit alors une formule +macOS seule, et le README du tap l'annonce au lieu de promettre Linux. + +Le repli n'est pas le binaire GNU. Ce serait réintroduire, par la porte du canal +Homebrew, le défaut que le lot 0 du plan vient de corriger partout ailleurs : +`GLIBC_2.39 not found` sur Rocky 9, Debian 12 et Ubuntu 22.04, après une +installation qui a pourtant réussi. Le bloc `on_linux` réapparaîtra seul à la +première release qui en publiera — sans que quiconque touche à ce fichier. + +## Ce que le générateur refuse + +| Cause | Message | +|---|---| +| tag illisible | `tag illisible : …` (code 2) | +| `checksums.txt` absent | `checksums.txt introuvable` | +| ligne absente pour un artefact | `ne contient aucune ligne pour …` | +| deux lignes pour le même artefact | `contient 2 lignes pour …` | +| SHA malformé | `ligne malformée dans checksums.txt` | +| gabarit modifié | `contient encore un marqueur non substitué` | +| rendu sans retour à la ligne final | `ne se termine pas par un retour à la ligne` | + +Dans tous les cas **rien n'est écrit** dans `--out` : une erreur au milieu du +rendu ne doit pas laisser une formule tronquée que la CI pousserait dans le tap. \ No newline at end of file diff --git a/packaging/homebrew/README.md.in b/packaging/homebrew/README.md.in new file mode 100644 index 00000000..db8bef92 --- /dev/null +++ b/packaging/homebrew/README.md.in @@ -0,0 +1,42 @@ +# TypR + +@@DESC@@ + +## Installation + +```bash +brew install we-data-ch/typr/typr +``` + +@@LINUX@@ + +## Utilisation + +```bash +typr check fichier.ty +typr build fichier.ty +typr --version +``` + +Mise à jour : + +```bash +brew update && brew upgrade typr +``` + +Désinstallation : + +```bash +brew uninstall typr +``` + +## Versions + +Ce dépôt ne publie pas de binaire : la formule ne contient que l'URL de la +release et son SHA-256, lus dans le `checksums.txt` de cette release. Un tag +`vX.Y.Z` de [we-data-ch/typr](https://github.com/we-data-ch/typr) produit le +commit suivant ici — voir `packaging/homebrew/README.md` dans le dépôt source. + +Documentation : @@HOMEPAGE@@ + +Licence : Apache-2.0 — voir `LICENSE`. diff --git a/packaging/homebrew/parts/on_linux.rb.in b/packaging/homebrew/parts/on_linux.rb.in new file mode 100644 index 00000000..95a13667 --- /dev/null +++ b/packaging/homebrew/parts/on_linux.rb.in @@ -0,0 +1,18 @@ + # Linux : binaire musl, lié statiquement. C'est le même choix que + # l'installeur `install.sh`, et pour la même raison — le binaire GNU exige une + # glibc plus récente que celle de Rocky 9 (2.34), Debian 12 (2.36) ou Ubuntu + # 22.04 (2.35) : l'installation y réussit, puis `typr --version` échoue sur + # « GLIBC_2.39 not found ». Aucun repli GNU n'est proposé ici, pour la même + # raison : ce serait réintroduire le défaut que le canal principal vient de + # supprimer. + on_linux do + on_intel do + url "@@URL_X86_64_LINUX@@" + sha256 "@@SHA256_X86_64_LINUX@@" + end + + on_arm do + url "@@URL_AARCH64_LINUX@@" + sha256 "@@SHA256_AARCH64_LINUX@@" + end + end diff --git a/packaging/homebrew/parts/on_linux_absent.rb.in b/packaging/homebrew/parts/on_linux_absent.rb.in new file mode 100644 index 00000000..5eb46572 --- /dev/null +++ b/packaging/homebrew/parts/on_linux_absent.rb.in @@ -0,0 +1,5 @@ + # Linux : cette version ne publie pas encore de binaire musl — les cibles + # `*-unknown-linux-musl` sont entrées dans la matrice de release après + # v0.5.12. Le bloc `on_linux` reviendra tout seul à la prochaine release qui en + # publiera, sans que ce fichier soit touché : c'est le job `brew` de + # `release.yml` qui régénère la formule au tag. diff --git a/packaging/homebrew/render.sh b/packaging/homebrew/render.sh new file mode 100755 index 00000000..14dd740b --- /dev/null +++ b/packaging/homebrew/render.sh @@ -0,0 +1,363 @@ +#!/bin/sh +# TypR — génération de la formule Homebrew à partir d'un tag. +# +# packaging/homebrew/render.sh --tag v0.5.12 +# +# Produit les deux fichiers du tap `we-data-ch/homebrew-typr` : +# +# Formula/typr.rb la formule — version et SHA-256 lus dans checksums.txt +# README.md le README du tap, dérivé de la formule +# +# Rien n'est écrit à la main, et surtout pas un SHA-256 : recopié à la main, il +# finit forcément par désigner un binaire qui n'est plus celui de la release. Ici +# les empreintes viennent du fichier que la release a publié, donc la formule ne +# peut pas en diverger. +# +# POSIX sh de bout en bout : ni bash, ni ruby. La validation par Homebrew +# (`brew audit --strict`) ne peut pas tourner ici ; elle est faite par la CI sur +# un runner macOS, avant le commit vers le tap. + +set -eu + +# --------------------------------------------------------------------------- +# Configuration +# +# Les trois variables existent pour être surchargées par les tests, qui servent +# une arborescence de release locale. +# --------------------------------------------------------------------------- +REPO="${TYPR_REPO:-we-data-ch/typr}" +ORIGIN="${TYPR_ORIGIN:-https://github.com}" + +HERE=$(cd "$(dirname "$0")" && pwd) +FORMULA_IN="$HERE/Formula/typr.rb.in" +LINUX_IN="$HERE/parts/on_linux.rb.in" +LINUX_ABSENT_IN="$HERE/parts/on_linux_absent.rb.in" +README_IN="$HERE/README.md.in" + +TAG="" +CHECKSUMS="" +OUT="." + +TMP="" + +# --------------------------------------------------------------------------- +# Sortie +# --------------------------------------------------------------------------- +# Erreur d'environnement ou de vérification : le monde est en cause (1). +die() { + printf 'typr-brew-render : %s\n' "$1" >&2 + exit 1 +} + +# Erreur de saisie : la ligne de commande est en cause (2). Même convention que +# install.sh et install.ps1, pour qu'un appelant puisse réagir pareil. +usage_die() { + printf 'typr-brew-render : %s\n' "$1" >&2 + printf '\n' >&2 + usage >&2 + exit 2 +} + +step() { printf '→ %s\n' "$*"; } +note() { printf ' %s\n' "$*"; } +warn() { printf 'typr-brew-render : attention : %s\n' "$*" >&2; } + +cleanup() { + if [ -n "$TMP" ] && [ -d "$TMP" ]; then + rm -rf "$TMP" + fi +} + +usage() { + cat <<'EOF' +TypR — générateur de la formule Homebrew + +Usage : + render.sh --tag [options] + +Options : + --tag version à publier (ex. v0.5.12 ; le v initial est optionnel) + --checksums checksums.txt de la release : URL (défaut) ou fichier local + --out où écrire Formula/typr.rb et README.md (défaut : .) + --help cette aide + +Écrit Formula/typr.rb et README.md dans le dossier du tap, et rien d'autre. +Les deux fichiers sont entièrement générés : ne jamais les éditer à la main. +EOF +} + +# --------------------------------------------------------------------------- +# Arguments +# --------------------------------------------------------------------------- +while [ $# -gt 0 ]; do + case "$1" in + --tag) + [ $# -ge 2 ] || usage_die "--tag attend une version (ex. --tag v0.5.12)." + TAG="$2" + shift 2 + ;; + --tag=*) + TAG="${1#--tag=}" + shift + ;; + --checksums) + [ $# -ge 2 ] || usage_die "--checksums attend une URL ou un chemin." + CHECKSUMS="$2" + shift 2 + ;; + --checksums=*) + CHECKSUMS="${1#--checksums=}" + shift + ;; + --out) + [ $# -ge 2 ] || usage_die "--out attend un dossier." + OUT="$2" + shift 2 + ;; + --out=*) + OUT="${1#--out=}" + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + usage_die "argument inconnu : $1" + ;; + esac +done + +[ -n "$TAG" ] || usage_die "--tag est obligatoire." + +case "$TAG" in + v*) ;; + *) TAG="v$TAG" ;; +esac + +# La forme exacte est vérifiée, pas seulement le préfixe `v` : le tag entre dans +# une URL et dans une substitution sed, donc un `v../../x` n'a rien à faire +# ici. `-alpha.1` et `-beta.2` sont acceptés — release.yml marque ces versions +# prerelease, et Homebrew les lit sans problème. +printf '%s' "$TAG" \ + | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$' \ + || usage_die "tag illisible : '$TAG' (attendu : vX.Y.Z, ou vX.Y.Z-alpha.N)." + +VERSION="${TAG#v}" + +for required in "$FORMULA_IN" "$LINUX_IN" "$LINUX_ABSENT_IN" "$README_IN"; do + [ -f "$required" ] || die "gabarit manquant : $required" +done + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +TMP=$(mktemp -d "${TMPDIR:-/tmp}/typr-brew-render.XXXXXX") \ + || die "impossible de créer un dossier temporaire." + +# --------------------------------------------------------------------------- +# checksums.txt +# --------------------------------------------------------------------------- +command -v curl >/dev/null 2>&1 \ + || die "curl est requis et n'est pas installé." + +CHECKSUMS_FILE="$TMP/checksums.txt" + +case "$CHECKSUMS" in + ''|*://*) + URL="$CHECKSUMS" + [ -n "$URL" ] \ + || URL="$ORIGIN/$REPO/releases/download/$TAG/checksums.txt" + step "Téléchargement de $URL" + curl -fsSL "$URL" -o "$CHECKSUMS_FILE" \ + || die "checksums.txt introuvable pour $TAG. + URL : $URL" + ;; + *) + [ -f "$CHECKSUMS" ] || die "checksums.txt introuvable : $CHECKSUMS" + cp "$CHECKSUMS" "$CHECKSUMS_FILE" + ;; +esac + +# --------------------------------------------------------------------------- +# SHA-256 +# +# Une ligne par artefact, au format `sha256sum`. On isole la ligne qui concerne +# l'artefact cherché — `sha256sum -c` ne conviendrait pas, il tenterait de +# vérifier les sept autres archives et sortirait en erreur alors que la nôtre est +# valide. Les points du nom sont échappés : sans cela, le motif matche +# n'importe quel caractère à leur place, et le binaire GNU se ferait prendre pour +# le binaire musl. +# --------------------------------------------------------------------------- +lookup_sha() { # lookup_sha — vide si absent, exit 1 si malformé + artifact="$1" + pattern=$(printf '%s' "$artifact" | sed 's/[.[\*^$]/\\&/g') + lines=$(grep -E "[[:space:]]\*?${pattern}\$" "$CHECKSUMS_FILE" || true) + count=$(printf '%s' "$lines" | grep -c . || true) + + [ "$count" -gt 1 ] && die "checksums.txt contient $count lignes pour $artifact : +$lines +Deux lignes pour le même nom : la release est incohérente, et choisir l'une +des deux reviendrait à publier un SHA arbitraire." + + [ "$count" -eq 1 ] || return 0 + + sha=$(printf '%s\n' "$lines" | cut -d' ' -f1) + printf '%s\n' "$sha" | grep -Eq '^[0-9a-f]{64}$' \ + || die "ligne malformée dans checksums.txt pour $artifact : + $lines + Un SHA-256 fait 64 caractères hexadécimaux." + + printf '%s\n' "$sha" +} + +require_sha() { # require_sha — die si absent + sha=$(lookup_sha "$1") || exit 1 + [ -n "$sha" ] \ + || die "checksums.txt de $TAG ne contient aucune ligne pour $1. + Fichier reçu : +$(cat "$CHECKSUMS_FILE")" + printf '%s' "$sha" +} + +# --------------------------------------------------------------------------- +# Rendu +# --------------------------------------------------------------------------- +A_APPLE_ARM="aarch64-apple-darwin" +A_APPLE_X86="x86_64-apple-darwin" +A_LINUX_ARM="aarch64-unknown-linux-musl" +A_LINUX_X86="x86_64-unknown-linux-musl" + +SHA_APPLE_ARM=$(require_sha "typr-$TAG-$A_APPLE_ARM.tar.gz") || exit 1 +SHA_APPLE_X86=$(require_sha "typr-$TAG-$A_APPLE_X86.tar.gz") || exit 1 + +# Linux est facultatif, contrairement à macOS. Les binaires musl sont entrés +# dans la matrice après v0.5.12 : aucune version publiée n'en contient encore, et +# exiger les quatre artefacts rendrait le tap impossible à amorcer. Le GNU n'est +# pas repris à la place — ce serait réintroduire le défaut `GLIBC_2.39 not +# found` que le lot 0 du plan vient de corriger sur le canal principal. Le bloc +# `on_linux` réapparaîtra seul dès la première release qui en publiera. +SHA_LINUX_ARM=$(lookup_sha "typr-$TAG-$A_LINUX_ARM.tar.gz") || exit 1 +SHA_LINUX_X86=$(lookup_sha "typr-$TAG-$A_LINUX_X86.tar.gz") || exit 1 + +if [ -n "$SHA_LINUX_ARM" ] && [ -n "$SHA_LINUX_X86" ]; then + LINUX_PART="$LINUX_IN" + EXPECTED_SHAS=4 +else + LINUX_PART="$LINUX_ABSENT_IN" + EXPECTED_SHAS=2 + for missing in "$A_LINUX_ARM" "$A_LINUX_X86"; do + grep -q "$TAG-$missing" "$CHECKSUMS_FILE" \ + || warn "$TAG ne publie pas de binaire $missing : le bloc on_linux est omis de la formule." + done +fi + +step "TypR $VERSION" +note "tag : $TAG" +note "sortie : $OUT" + +# Les valeurs substituées sont des URL et des empreintes : aucun caractère `&` +# ni `|`, qui seraient des métacaractères de sed. La seule exception possible +# viendrait du tag, validé plus haut. +BASE_URL="$ORIGIN/$REPO/releases/download/$TAG" + +sed \ + -e "s|@@VERSION@@|$VERSION|g" \ + -e "s|@@URL_AARCH64_APPLE@@|$BASE_URL/typr-$TAG-$A_APPLE_ARM.tar.gz|g" \ + -e "s|@@SHA256_AARCH64_APPLE@@|$SHA_APPLE_ARM|g" \ + -e "s|@@URL_X86_64_APPLE@@|$BASE_URL/typr-$TAG-$A_APPLE_X86.tar.gz|g" \ + -e "s|@@SHA256_X86_64_APPLE@@|$SHA_APPLE_X86|g" \ + "$FORMULA_IN" > "$TMP/formula-1.rb" + +# `r file` insère le fichier après la ligne trouvée, `d` supprime cette ligne — +# la paire remplace donc le marqueur par son contenu, en portable (BSD sed +# compris, contrairement à un `s` multi-lignes). +sed -e "/^@@LINUX@@\$/{ + r $LINUX_PART + d +}" "$TMP/formula-1.rb" > "$TMP/formula-2.rb" + +if [ "$LINUX_PART" = "$LINUX_IN" ]; then + sed \ + -e "s|@@URL_X86_64_LINUX@@|$BASE_URL/typr-$TAG-$A_LINUX_X86.tar.gz|g" \ + -e "s|@@SHA256_X86_64_LINUX@@|$SHA_LINUX_X86|g" \ + -e "s|@@URL_AARCH64_LINUX@@|$BASE_URL/typr-$TAG-$A_LINUX_ARM.tar.gz|g" \ + -e "s|@@SHA256_AARCH64_LINUX@@|$SHA_LINUX_ARM|g" \ + "$TMP/formula-2.rb" > "$TMP/typr.rb" +else + cp "$TMP/formula-2.rb" "$TMP/typr.rb" +fi + +# Un gabarit dont le nom d'un marqueur a changé produirait une formule servie +# telle quelle, avec `@@…@@` dedans — le défaut le plus discret de cette +# famille d'outils, parce qu'il ne se voit qu'à l'installation. +grep -q '@@' "$TMP/typr.rb" \ + && die "la formule rendue contient encore un marqueur non substitué : +$(grep -n '@@' "$TMP/typr.rb")" + +# Un gabarit écrit sans retour à la ligne final donne une formule que `brew style` +# refuse (Style/FinalNewline), et le retrait se voit seulement à l'installation. +# Aucune des deux anomalies ne se voit dans le diff du tap, d'où le contrôle. +[ -z "$(tail -c1 "$TMP/typr.rb")" ] \ + || die "la formule rendue ne se termine pas par un retour à la ligne." + +shas=$(grep -cE '^[[:space:]]+sha256 "[0-9a-f]{64}"$' "$TMP/typr.rb" || true) +[ "$shas" -eq "$EXPECTED_SHAS" ] \ + || die "la formule rendue déclare $shas empreintes, attendu $EXPECTED_SHAS. +Le gabarit et le rendu ne correspondent plus." + +# Pas de `version` explicite : `brew audit` la juge redondante, Homebrew la +# déduit de l'URL. On vérifie donc que l'URL porte bien la version attendue. +grep -Fq "/download/v$VERSION/" "$TMP/typr.rb" \ + || die "la formule rendue ne pointe pas vers la release v$VERSION." + +# Le README du tap est dérivé de la formule, pas d'une deuxième source : desc et +# homepage sont relus dans ce qui vient d'être écrit, donc les deux fichiers ne +# peuvent pas diverger. +DESC=$(sed -n 's/^[[:space:]]*desc "\(.*\)"$/\1/p' "$TMP/typr.rb" | head -1) +HOMEPAGE=$(sed -n 's/^[[:space:]]*homepage "\(.*\)"$/\1/p' "$TMP/typr.rb" | head -1) + +[ -n "$DESC" ] || die "aucun desc lisible dans la formule rendue." +[ -n "$HOMEPAGE" ] || die "aucun homepage lisible dans la formule rendue." + +if [ "$LINUX_PART" = "$LINUX_IN" ]; then + LINUX_NOTE='- **Linux** : couvert — binaire musl lié statiquement, x86_64 et aarch64, aucune contrainte de glibc.' +else + LINUX_NOTE="- **Linux** : pas encore pour cette version — aucun binaire musl n'est publié. macOS est couvert. Le bloc Linux réapparaîtra à la première release qui en publiera." +fi + +sed \ + -e "s|@@DESC@@|$DESC|g" \ + -e "s|@@HOMEPAGE@@|$HOMEPAGE|g" \ + -e "s|@@VERSION@@|$VERSION|g" \ + -e "s|@@LINUX@@|$LINUX_NOTE|g" \ + "$README_IN" > "$TMP/README.md" + +grep -q '@@' "$TMP/README.md" \ + && die "le README rendu contient encore un marqueur non substitué : +$(grep -n '@@' "$TMP/README.md")" + +# Rien n'est écrit dans le dossier de sortie tant que le rendu n'est pas +# complet : un échec au milieu ne doit pas laisser une formule tronquée que la +# CI pousserait dans le tap. +mkdir -p "$OUT/Formula" || die "impossible de créer $OUT/Formula." + +cp "$TMP/typr.rb" "$OUT/Formula/typr.rb" +cp "$TMP/README.md" "$OUT/README.md" + +note "sha256 : $SHA_APPLE_X86 (x86_64-apple-darwin)" +note "sha256 : $SHA_APPLE_ARM (aarch64-apple-darwin)" +# Les deux lignes Linux sont conditionnelles : leur absence est normale pour +# toutes les versions publiées à ce jour (cf. plus haut). Un `if` explicite +# plutôt qu'un `&&` — le statut de retour de la liste entière est alors celui de +# `note`, ce qui évite de dépendre du traitement de `set -e` par l'interpréteur. +if [ -n "$SHA_LINUX_X86" ]; then + note "sha256 : $SHA_LINUX_X86 (x86_64-unknown-linux-musl)" +fi +if [ -n "$SHA_LINUX_ARM" ]; then + note "sha256 : $SHA_LINUX_ARM (aarch64-unknown-linux-musl)" +fi + +printf '\n' +step "Écrit : $OUT/Formula/typr.rb et $OUT/README.md" \ No newline at end of file diff --git a/packaging/homebrew/tests/fixtures/checksums-v0.5.12-legacy.txt b/packaging/homebrew/tests/fixtures/checksums-v0.5.12-legacy.txt new file mode 100644 index 00000000..a300faed --- /dev/null +++ b/packaging/homebrew/tests/fixtures/checksums-v0.5.12-legacy.txt @@ -0,0 +1,11 @@ +# checksums.txt factice — la forme exacte de la release v0.5.12, dont +# checksums.txt a été recopié tel quel : six archives, aucune cible musl +# (les cibles `*-unknown-linux-musl` sont entrées dans la matrice après +# cette version). Sert à vérifier que le rendu fonctionne quand Linux +# n'est pas encore disponible — voir packaging/homebrew/tests/run-tests.sh. +d06235ea40a816abafc22a02f42306709e1ca9c23c1c4a01ac1f003e5a5313ce typr-v0.5.12-aarch64-apple-darwin.tar.gz +10187a6f8df2c778b50210bbf8594c5dcb185da39aacaf22fa250e7f011bdc9d typr-v0.5.12-aarch64-pc-windows-msvc.zip +146a2e37a80d9847803da2216947bf66e1cc9cbb962ba8da5f7838fe4e4b5e4b typr-v0.5.12-aarch64-unknown-linux-gnu.tar.gz +57ba2a134ba48be5d9df81300b67b99a1144e085ce013b072580bbcf5f1d0091 typr-v0.5.12-x86_64-apple-darwin.tar.gz +b06c470b6a5642754f6289818928f2cd40aa62a40dd9a6fece340e2517ebf784 typr-v0.5.12-x86_64-pc-windows-msvc.zip +fdf7cff81660575fdaa40a4a7a578a97a18e381d52075eaec4eef78fd0287acb typr-v0.5.12-x86_64-unknown-linux-gnu.tar.gz diff --git a/packaging/homebrew/tests/fixtures/checksums-v9.9.9.txt b/packaging/homebrew/tests/fixtures/checksums-v9.9.9.txt new file mode 100644 index 00000000..7a4f6f98 --- /dev/null +++ b/packaging/homebrew/tests/fixtures/checksums-v9.9.9.txt @@ -0,0 +1,13 @@ +# checksums.txt factice — la même forme que celui produit par le job +# `release` de .github/workflows/release.yml (sha256sum, huit archives), +# pour la release de test v9.9.9. Utilisé par packaging/homebrew/tests +# et par le job `packaging-audit` de ci.yml, qui rend la formule sans +# toucher au réseau. Aucune de ces empreintes ne correspond à un fichier. +8146ea8ef59c8c5570f35961c1218a7d1cffb35366fbd9de2746662bf5d4b564 typr-v9.9.9-x86_64-unknown-linux-musl.tar.gz +58aa5132a479b35cb66b6e8e8ab9574991708c77020da12543d6d0f073984919 typr-v9.9.9-aarch64-unknown-linux-musl.tar.gz +981bde1973356ba60175f3831aa6edeef538bcf014f4df7530f9f9851581269d typr-v9.9.9-x86_64-unknown-linux-gnu.tar.gz +96a6c0fa324a1009b470761cb34cf3549a160e11bab9b696aef9f6451e8d2ac5 typr-v9.9.9-aarch64-unknown-linux-gnu.tar.gz +34f8df27e7d4fe1f1553043a1fbe305215ac44b6be57ad28e3aa43595d540f1e typr-v9.9.9-x86_64-apple-darwin.tar.gz +ee13ff3cf174801164d73935b91e878845bbe16f7e37030adc938ab1fe853c81 typr-v9.9.9-aarch64-apple-darwin.tar.gz +ad27d2ab79bff970f475d29693c0da803f97f35ba86174a5a0914dcb04ac7e1d typr-v9.9.9-x86_64-pc-windows-msvc.zip +53f96c50c5c740366100a369a84cd1009bfd85edf402d7763f61f8240d25bbd2 typr-v9.9.9-aarch64-pc-windows-msvc.zip diff --git a/packaging/homebrew/tests/run-tests.sh b/packaging/homebrew/tests/run-tests.sh new file mode 100755 index 00000000..980bebf0 --- /dev/null +++ b/packaging/homebrew/tests/run-tests.sh @@ -0,0 +1,509 @@ +#!/usr/bin/env bash +# Tests de packaging/homebrew/render.sh, sans réseau. +# +# Le générateur ne lit qu'une chose : le `checksums.txt` d'une release. Toute sa +# valeur tient donc à deux propriétés qu'aucune relecture ne montre — +# +# 1. il prend **la** bonne ligne (le fichier liste huit archives, dont les +# deux GNU que le bloc `on_linux` ne doit jamais pointer) ; +# 2. il refuse de produire une formule à moitié rendue. +# +# C'est ce que ces tests couvrent, plus la dérive entre les gabarits et le +# générateur — le défaut qui n'apparaît qu'à la prochaine release, chez +# l'utilisateur. +# +# packaging/homebrew/tests/run-tests.sh +# +# Le même script est exécuté par le job `packaging` de ci.yml. + +set -uo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +ROOT_DIR=$(cd "$HERE/.." && pwd) +RENDER="$ROOT_DIR/render.sh" +FIXTURE="$HERE/fixtures/checksums-v9.9.9.txt" + +# La release sans binaire musl, c'est-à-dire l'état réel de toutes les versions +# publiées jusqu'ici : les cibles `*-unknown-linux-musl` sont entrées dans la +# matrice après v0.5.12. +LEGACY_TAG="v0.5.12" +LEGACY_FIXTURE="$HERE/fixtures/checksums-v0.5.12-legacy.txt" + +TAG="v9.9.9" + +PASSED=0 +FAILED=0 +ROOT="" +SERVER_PID="" + +cleanup() { + [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null + [ -n "$ROOT" ] && rm -rf "$ROOT" + return 0 +} +trap cleanup EXIT + +ok() { PASSED=$((PASSED + 1)); printf ' \033[32m✓\033[0m %s\n' "$1"; } +ko() { FAILED=$((FAILED + 1)); printf ' \033[31m✗\033[0m %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; return 0; } + +check_rc() { # check_rc + if [ "$1" -eq "$2" ]; then ok "$3"; else ko "$3" "code de retour $2, attendu $1"; fi +} + +check_contains() { # check_contains + # Un motif vide matcherait n'importe quoi : l'assertion passerait sans rien + # vérifier, ce qui est pire que ne pas tester. Il est donc refusé. + if [ -z "$2" ]; then ko "$3" "motif de recherche vide — l'assertion ne vérifierait rien"; return 0; fi + case "$1" in + *"$2"*) ok "$3" ;; + *) ko "$3" "« $2 » absent de la sortie : $1" ;; + esac +} + +check_not_contains() { + if [ -z "$2" ]; then ko "$3" "motif de recherche vide — l'assertion ne vérifierait rien"; return 0; fi + case "$1" in + *"$2"*) ko "$3" "« $2 » présent alors qu'il ne devait pas l'être" ;; + *) ok "$3" ;; + esac +} + +check_file() { # check_file + if [ -f "$1" ]; then ok "$2"; else ko "$2" "$1 absent"; fi +} + +check_no_file() { + if [ -e "$1" ]; then ko "$2" "$1 présent"; else ok "$2"; fi +} + +# L'empreinte de l'artefact dans la fixture — la valeur attendue, lue depuis la +# source du générateur et non recopiée ici. Le tag fait partie du motif : c'est +# lui qui distingue deux releases, et deux releases ont des empreintes +# différentes. +fixture_sha() { # fixture_sha [fichier] + grep -E "[[:space:]]\*?typr-$1-$2(\.tar\.gz|\.zip)\$" "${3:-$FIXTURE}" | head -1 | cut -d' ' -f1 +} + +work() { # work — dossier de travail sous $ROOT, donc nettoyé avec lui + local dir="$ROOT/$1" + rm -rf "$dir" + mkdir -p "$dir" + printf '%s' "$dir" +} + +render() { # render + local out="$1"; shift + "$RENDER" --tag "$TAG" --checksums "$FIXTURE" --out "$out" "$@" 2>&1 +} + +# Le bloc attendu d'une cible : son URL **et** son sha, côte à côte. Vérifier +# séparément que les quatre empreintes apparaissent dans le fichier ne prouverait +# rien — deux cibles interverties donneraient exactement le même texte. +expect_block() { # expect_block + printf ' url "https://github.com/we-data-ch/typr/releases/download/%s/typr-%s-%s.tar.gz"\n sha256 "%s"' \ + "$TAG" "$TAG" "$1" "$(fixture_sha "$TAG" "$1")" +} + +# --------------------------------------------------------------------------- +# Analyse statique +# --------------------------------------------------------------------------- +test_static_analysis() { + echo "Analyse statique" + + # `ok` et `ko` sortent toujours en 0, donc le `||` ne se déclenche jamais à + # tort : SC2015 signalerait ici un if-then-else qui n'existe pas. + # shellcheck disable=SC2015 + sh -n "$RENDER" 2>&1 && ok "sh -n" || ko "sh -n" + # shellcheck disable=SC2015 + bash -n "$RENDER" 2>&1 && ok "bash -n" || ko "bash -n" + + if command -v dash >/dev/null 2>&1; then + # shellcheck disable=SC2015 + dash -n "$RENDER" 2>&1 && ok "dash -n (POSIX strict)" || ko "dash -n" + else + printf ' – dash absent, POSIX strict non vérifié\n' + fi + + if command -v shellcheck >/dev/null 2>&1; then + # Aucune exclusion : render.sh ne se sert ni de `local` (non POSIX) ni + # d'aucun attribut bash. + if out=$(shellcheck -s sh "$RENDER" 2>&1); then + ok "shellcheck" + else + ko "shellcheck" "$out" + fi + else + printf ' – shellcheck absent, non exécuté\n' + fi +} + +# --------------------------------------------------------------------------- +# Dérive gabarit ↔ générateur +# +# Un gabarit dont un marqueur a été renommé (`@@VERSION@@` → `@@VERSION_X@@`) +# produit une formule qui part en production avec `@@VERSION_X@@` dedans : rien +# ne le signale tant qu'un utilisateur n'installe pas. Le générateur le refuse, +# mais seulement s'il connaît encore le nom du marqueur — ce qui est précisément +# ce qui a pu changer. D'où ce test, qui compare les deux listes. +# --------------------------------------------------------------------------- +test_marker_drift() { + echo "Dérive gabarit / générateur" + + local in_tpl in_gen only_tpl only_gen + in_tpl=$(grep -oh '@@[A-Z0-9_]*@@' \ + "$ROOT_DIR/Formula/typr.rb.in" \ + "$ROOT_DIR/parts/on_linux.rb.in" \ + "$ROOT_DIR/parts/on_linux_absent.rb.in" \ + "$ROOT_DIR/README.md.in" | sort -u) + in_gen=$(grep -oh '@@[A-Z0-9_]*@@' "$RENDER" | sort -u) + + only_tpl=$(comm -23 <(printf '%s\n' "$in_tpl") <(printf '%s\n' "$in_gen") | tr '\n' ' ') + [ -z "$only_tpl" ] \ + && ok "aucun marqueur de gabarit inconnu du générateur" \ + || ko "aucun marqueur de gabarit inconnu du générateur" "$only_tpl" + + only_gen=$(comm -13 <(printf '%s\n' "$in_tpl") <(printf '%s\n' "$in_gen") | tr '\n' ' ') + [ -z "$only_gen" ] \ + && ok "aucun marqueur du générateur absent des gabarits" \ + || ko "aucun marqueur du générateur absent des gabarits" "$only_gen" + + # Un gabarit sans retour à la ligne final donne une formule que `brew style` + # refuse, et le retrait ne se voit qu'en installant. + local tpl + for tpl in "$ROOT_DIR/Formula/typr.rb.in" \ + "$ROOT_DIR/parts/on_linux.rb.in" \ + "$ROOT_DIR/parts/on_linux_absent.rb.in" \ + "$ROOT_DIR/README.md.in"; do + if [ -z "$(tail -c1 "$tpl")" ]; then + ok "$(basename "$tpl") se termine par un retour à la ligne" + else + ko "$(basename "$tpl") se termine par un retour à la ligne" "$tpl" + fi + done +} + +# --------------------------------------------------------------------------- +# Ligne de commande +# --------------------------------------------------------------------------- +test_usage() { + echo " --help et erreurs de saisie" + local out + out=$("$RENDER" --help 2>&1) + check_rc 0 $? "--help sort en 0" + check_contains "$out" "Usage" "--help affiche l'usage" + + out=$("$RENDER" --bogus 2>&1) + check_rc 2 $? "un argument inconnu sort en 2" + check_contains "$out" "argument inconnu" "l'argument est nommé" + + out=$("$RENDER" --out /tmp 2>&1) + check_rc 2 $? "--tag absent sort en 2" + check_contains "$out" "--tag est obligatoire" "l'absence de --tag est nommée" + + out=$("$RENDER" --tag 2>&1) + check_rc 2 $? "--tag sans valeur sort en 2" + + # Le tag entre dans une URL et dans une substitution sed : une forme trop + # libre passerait par la porte de l'injection. + for tag in 'pas-un-tag' 'v1.2' 'v1.2.3/../../etc' 'vX.Y.Z' 'v1.2.3 ../../x'; do + out=$("$RENDER" --tag "$tag" --checksums "$FIXTURE" --out "$(work bad-tag)" 2>&1) + check_rc 2 $? "tag refusé : $tag" + done +} + +# --------------------------------------------------------------------------- +# Chemin heureux +# --------------------------------------------------------------------------- +test_render() { + echo "Rendu complet" + local out dir formula readme + dir=$(work render) + out=$(render "$dir") + check_rc 0 $? "rendu" + check_contains "$out" "TypR 9.9.9" "la version est annoncée" + + formula="$dir/Formula/typr.rb" + readme="$dir/README.md" + check_file "$formula" "Formula/typr.rb est écrit" + check_file "$readme" "README.md est écrit" + [ -f "$formula" ] || return 0 + + check_contains "$(cat "$formula")" "/download/${TAG}/" "la version est portée par l'URL" + check_contains "$(cat "$formula")" "class Typr < Formula" "la classe est Typr" + + local target + for target in x86_64-apple-darwin aarch64-apple-darwin \ + x86_64-unknown-linux-musl aarch64-unknown-linux-musl; do + check_contains "$(cat "$formula")" "$(expect_block "$target")" \ + "url et sha256 corrects pour $target" + done + + # La vraie tentation du contrôleur d'"exactitude" : `typr-v9.9.9-x86_64- + # unknown-linux-musl.tar.gz` et `…-linux-gnu.tar.gz` ne diffèrent que par un + # mot, et un `.` non échappé dans le motif fait matcher n'importe quoi à sa + # place. Le GNU ne doit donc apparaître nulle part. + check_not_contains "$(cat "$formula")" "$(fixture_sha "$TAG" x86_64-unknown-linux-gnu)" \ + "le binaire GNU n'est pas retenu à la place du musl" + check_not_contains "$(cat "$formula")" "unknown-linux-gnu" \ + "aucune cible GNU dans la formule" + + local shas + shas=$(grep -cE '^[[:space:]]+sha256 "[0-9a-f]{64}"$' "$formula") + if [ "$shas" -eq 4 ]; then ok "quatre empreintes déclarées"; else ko "quatre empreintes déclarées" "$shas trouvées"; fi + + # Le bloc `on_linux` est inséré depuis une autre fichier : son indentation + # peut être aplatie sans que rien d'autre ne bouge, et `brew style` ne le + # signalerait qu'à la release. On vérifie donc l'imbrication. + check_contains "$(cat "$formula")" " on_linux do + on_intel do" "le bloc on_linux est imbriqué" + + # Invariant plus large, valable pour tout le corps de la classe : un + # commentaire calé à la colonne 0 y serait encore du Ruby valide, donc + # inoffensif — mais seulement parce qu'il est commentaire. Le jour où la même + # transformation aplatit un `on_linux do`, le fichier resterait parsable et le + # défaut passerait le test de syntaxe. + local col0 + col0=$(sed -n '/^class Typr < Formula/,$p' "$formula" \ + | grep '^[^[:space:]]' \ + | grep -cvE '^(class Typr < Formula|end)$' || true) + if [ "$col0" -eq 0 ]; then + ok "aucune ligne du corps de la classe sans indentation" + else + ko "aucune ligne du corps de la classe sans indentation" \ + "$col0 lignes calées à la colonne 0 : $(sed -n '/^class Typr < Formula/,$p' "$formula" | grep '^[^[:space:]]' | head -2)" + fi + + # Un marqueur non substitué partirait en production tel quel. + check_not_contains "$(cat "$formula")" "@@" "aucun marqueur résiduel dans la formule" + check_not_contains "$(cat "$readme")" "@@" "aucun marqueur résiduel dans le README" + + # Le README est dérivé de la formule, pas d'une deuxième source. + check_contains "$(cat "$readme")" "Typed superset of R — transpiler and type checker" \ + "le README reprend le desc de la formule" + check_contains "$(cat "$readme")" "https://we-data-ch.github.io/typr.github.io/" \ + "le README reprend le homepage de la formule" + check_contains "$(cat "$readme")" "brew install we-data-ch/typr/typr" \ + "le README donne la commande d'installation" + check_contains "$(cat "$readme")" "couvert" "le README annonce la couverture Linux" + + # Rien d'autre dans le dossier du tap : le job `install-hosting` refuse + # lui-même de laisser un reste, et un vieux fichier servirait encore à son + # adresse. + local extra + extra=$(cd "$dir" && find . -type f ! -path './Formula/typr.rb' ! -path './README.md') + if [ -z "$extra" ]; then ok "aucun fichier surnumeraire dans le tap"; else ko "aucun fichier surnumeraire dans le tap" "$extra"; fi +} + +# --------------------------------------------------------------------------- +# Sorties d'outils +# --------------------------------------------------------------------------- +test_pin_and_prerelease() { + echo "Version épinglée et prerelease" + local out dir + + # Sans le « v », comme install.sh qui le remet. + dir=$(work no-v) + out=$("$RENDER" --tag "9.9.9" --checksums "$FIXTURE" --out "$dir" 2>&1) + check_rc 0 $? "un tag sans le v initial est accepté" + check_contains "$(cat "$dir/Formula/typr.rb")" '/download/v9.9.9/' "la version est rendue sans le v" + + # release.yml marque `-alpha.N` et `-beta.N` prerelease : la formule doit les + # accepter, sinon le canal Homebrew s'arrêterait à la première d'entre elles. + dir=$(work prerelease) + out=$("$RENDER" --tag v9.9.9-alpha.1 --checksums "$FIXTURE" --out "$dir" 2>&1) + check_rc 1 $? "une prerelease sans artefacts propres est refusée" + check_contains "$out" "ne contient aucune ligne pour" "l'absence est nommée, pas devinée" + + # Dossier de sortie imbriqué absent : il doit être créé. + dir="$ROOT/nested/deep/tap" + rm -rf "$ROOT/nested" + out=$("$RENDER" --tag "$TAG" --checksums "$FIXTURE" --out "$dir" 2>&1) + check_rc 0 $? "dossier de sortie imbriqué créé" + check_file "$dir/Formula/typr.rb" "la formule est écrite dans le dossier imbriqué" +} + +# --------------------------------------------------------------------------- +# Refus +# --------------------------------------------------------------------------- +test_missing_line() { + echo "Ligne absente, doublon, malformation" + local out dir rel + dir=$(work missing) + out=$(render "$dir") + check_rc 0 $? "rendu de référence" + + # Sans la ligne d'un artefact macOS : rien ne peut être rendu. v0.5.12 est la + # seule version publiée, mais c'est aussi le cas banal d'une release dont le + # job de build a échoué sur une cible. + rel=$(work sums-missing) + grep -v 'aarch64-apple-darwin' "$FIXTURE" > "$rel/checksums.txt" + out=$("$RENDER" --tag "$TAG" --checksums "$rel/checksums.txt" --out "$(work out-missing)" 2>&1) + check_rc 1 $? "checksums.txt sans la ligne attendue → échec" + check_contains "$out" "aarch64-apple-darwin" "l'artefact manquant est nommé" + check_no_file "$(work out-missing)/Formula/typr.rb" "aucune formule n'est écrite" + + # Deux lignes pour le même nom : choisir l'une des deux reviendrait à publier + # une empreinte arbitraire. + rel=$(work sums-dup) + { cat "$FIXTURE"; printf '%s typr-%s-x86_64-apple-darwin.tar.gz\n' \ + "$(fixture_sha "$TAG" aarch64-apple-darwin)" "$TAG"; } > "$rel/checksums.txt" + out=$("$RENDER" --tag "$TAG" --checksums "$rel/checksums.txt" --out "$(work out-dup)" 2>&1) + check_rc 1 $? "deux lignes pour le même artefact → échec" + check_contains "$out" "2 lignes" "le nombre de lignes est nommé" + check_no_file "$(work out-dup)/Formula/typr.rb" "aucune formule n'est écrite sur un doublon" + + # Malformée : la ligne existe, le SHA est illisible. Un fichier tronqué ne doit + # pas produire une formule que Homebrew refuserait ensuite — il le dirait par + # le nom du fichier, sans jamais nommer la release comme cause. + for bad in 'pas-un-sha' \ + "$(fixture_sha "$TAG" x86_64-apple-darwin | cut -c1-63)" \ + "$(fixture_sha "$TAG" x86_64-apple-darwin | cut -c1-63)z"; do + rel=$(work sums-bad) + awk -v bad="$bad" -v t="typr-$TAG-x86_64-apple-darwin.tar.gz" \ + '{ if ($NF == t) print bad " " $NF; else print }' "$FIXTURE" > "$rel/checksums.txt" + out=$("$RENDER" --tag "$TAG" --checksums "$rel/checksums.txt" --out "$(work out-bad)" 2>&1) + check_rc 1 $? "SHA malformé refusé : $bad" + check_contains "$out" "malform" "la malformation est nommée" + done + + # checksums.txt absent du tout. + out=$("$RENDER" --tag "$TAG" --checksums "$ROOT/inexistant.txt" --out "$(work out-ghost)" 2>&1) + check_rc 1 $? "checksums.txt absent → échec" + check_contains "$out" "introuvable" "l'absence est nommée" +} + +# --------------------------------------------------------------------------- +# Release sans binaire musl +# +# État réel de toutes les versions publiées : la formule doit rester valide en +# macOS seul plutôt que de refuser de se générer — et ne surtout pas pointer le +# binaire GNU à la place, ce qui réintroduirait le défaut `GLIBC_2.39 not found`. +# --------------------------------------------------------------------------- +test_without_musl() { + echo "Release sans binaire musl" + local out dir + dir=$(work legacy) + out=$("$RENDER" --tag "$LEGACY_TAG" --checksums "$LEGACY_FIXTURE" --out "$dir" 2>&1) + check_rc 0 $? "le rendu aboutit malgré l'absence de musl" + check_contains "$out" "on_linux est omis" "l'omission est annoncée" + + formula="$dir/Formula/typr.rb" + check_file "$formula" "la formule est écrite" + [ -f "$formula" ] || return 0 + + check_not_contains "$(cat "$formula")" "on_linux do" "aucun bloc on_linux" + # Le commentaire qui explique l'absence nomme les cibles attendues — c'est + # voulu. Ce qui ne doit pas exister, c'est une *référence* à un artefact + # Linux : le mot « musl » dans un commentaire ne téléchargerait rien. + check_not_contains "$(cat "$formula")" "download/$LEGACY_TAG/typr-$LEGACY_TAG-x86_64-unknown-linux" \ + "aucun artefact Linux référencé" + check_not_contains "$(cat "$formula")" "$(fixture_sha "$LEGACY_TAG" x86_64-unknown-linux-gnu "$LEGACY_FIXTURE")" \ + "le binaire GNU n'est pas utilisé comme repli" + check_contains "$(cat "$formula")" "ne publie pas encore de binaire musl" \ + "la formule explique pourquoi Linux manque" + check_contains "$(cat "$dir/README.md")" "pas encore" "le README ne promet pas Linux" + + local shas + shas=$(grep -cE '^[[:space:]]+sha256 "[0-9a-f]{64}"$' "$formula") + if [ "$shas" -eq 2 ]; then ok "deux empreintes déclarées (macOS)"; else ko "deux empreintes déclarées (macOS)" "$shas trouvées"; fi +} + +# --------------------------------------------------------------------------- +# Gabarit modifié : le générateur doit refuser plutôt que publier tel quel +# --------------------------------------------------------------------------- +test_template_drift() { + echo "Gabarit modifié" + local out dir sandbox + sandbox=$(work sandbox) + cp "$RENDER" "$sandbox/render.sh" + mkdir -p "$sandbox/Formula" "$sandbox/parts" + cp "$ROOT_DIR/Formula/typr.rb.in" "$sandbox/Formula/" + cp "$ROOT_DIR/parts/on_linux.rb.in" "$ROOT_DIR/parts/on_linux_absent.rb.in" "$sandbox/parts/" + cp "$ROOT_DIR/README.md.in" "$sandbox/" + + # Le marqueur est renommé côté gabarit, pas côté générateur : exactement le + # réagencement de nom qu'un commit peut introduire. + sed -i 's/@@VERSION@@/@@TYPR_VERSION@@/' "$sandbox/Formula/typr.rb.in" + out=$("$sandbox/render.sh" --tag "$TAG" --checksums "$FIXTURE" --out "$(work out-drift)" 2>&1) + check_rc 1 $? "un marqueur renommé est refusé" + check_contains "$out" "marqueur" "le marqueur non substitué est nommé" + check_no_file "$(work out-drift)/Formula/typr.rb" "aucune formule n'est écrite" + + # Retrait du retour à la ligne final : invisible dans une diff, fatal pour + # `brew style`. + cp "$ROOT_DIR/Formula/typr.rb.in" "$sandbox/Formula/" + printf '%s' "$(cat "$ROOT_DIR/Formula/typr.rb.in")" > "$sandbox/Formula/typr.rb.in" + out=$("$sandbox/render.sh" --tag "$TAG" --checksums "$FIXTURE" --out "$(work out-nonl)" 2>&1) + check_rc 1 $? "une formule sans retour à la ligne final est refusée" + check_contains "$out" "retour à la ligne" "la raison est nommée" +} + +# --------------------------------------------------------------------------- +# Lecture par URL — le mode réel, celui du job `brew` +# +# Le serveur de fixtures est celui de install/tests : il sert une arborescence +# réelle, donc un vrai chemin `/releases/download//checksums.txt`. Réécrire +# un deuxième serveur pour le même besoin serait du poids inutile. +# --------------------------------------------------------------------------- +test_url_mode() { + echo "Lecture par URL" + if ! command -v python3 >/dev/null 2>&1; then + printf ' – python3 absent, mode URL non exercé\n' + return 0 + fi + + local server_root port out dir + server_root=$(work origin) + mkdir -p "$server_root/we-data-ch/typr/releases/download/$TAG" + cp "$FIXTURE" "$server_root/we-data-ch/typr/releases/download/$TAG/checksums.txt" + + python3 "$ROOT_DIR/../../install/tests/fixture-server.py" "$server_root" \ + > "$server_root/.port" & + SERVER_PID=$! + + port="" + for _ in $(seq 1 50); do + port=$(grep -oE 'PORT [0-9]+' "$server_root/.port" 2>/dev/null | cut -d' ' -f2) + [ -n "$port" ] && break + sleep 0.1 + done + if [ -z "$port" ]; then + ko "le serveur de fixtures n'a pas démarré" + return 0 + fi + + # L'URL par défaut dérive du tag : c'est elle que le job `brew` utilise, sans + # jamais l'écrire en dur. + dir=$(work url) + out=$(TYPR_ORIGIN="http://127.0.0.1:$port" "$RENDER" --tag "$TAG" --out "$dir" 2>&1) + check_rc 0 $? "l'URL de checksums.txt est déduite du tag" + check_contains "$(cat "$dir/Formula/typr.rb" 2>/dev/null)" "$(fixture_sha "$TAG" x86_64-apple-darwin)" \ + "la formule rendue par URL a le bon sha256" + + # 404 : le message doit nommer l'URL, pas laisser croire à un bug de rendu. + out=$(TYPR_ORIGIN="http://127.0.0.1:$port" "$RENDER" --tag v1.2.3 --out "$(work url-404)" 2>&1) + check_rc 1 $? "une release absente échoue" + check_contains "$out" "introuvable" "l'absence est nommée" +} + +# --------------------------------------------------------------------------- + +main() { + printf 'render.sh — tests\n\n' + ROOT=$(mktemp -d) + + test_static_analysis + test_marker_drift + test_usage + test_render + test_pin_and_prerelease + test_missing_line + test_without_musl + test_template_drift + test_url_mode + + printf '\n%d réussis, %d échoués\n' "$PASSED" "$FAILED" + [ "$FAILED" -eq 0 ] +} + +main "$@" \ No newline at end of file diff --git a/packaging/render.sh b/packaging/render.sh new file mode 100755 index 00000000..e8e4257c --- /dev/null +++ b/packaging/render.sh @@ -0,0 +1,404 @@ +#!/bin/sh +# TypR — génération des manifestes WinGet et Scoop à partir d'un tag. +# +# packaging/render.sh --tag v0.5.12 +# +# Produit les six fichiers des deux canaux Windows, tels que les dépôts de +# destination les attendent : +# +# winget/we-data-ch.TypR.yaml version → PR winget-pkgs +# winget/we-data-ch.TypR.installer.yaml installateurs (x64, arm64) +# winget/we-data-ch.TypR.locale.en-US.yaml locale +# scoop/bucket/typr.json version courante du bucket +# scoop/bin/typr/.json historique +# scoop/README.md README du bucket +# +# Rien n'est écrit à la main, et surtout pas un SHA-256 : recopié à la main, il +# finit forcément par désigner un binaire qui n'est plus celui de la release. Ici +# les empreintes viennent du fichier que la release a publié, donc aucun +# manifeste ne peut en diverger — et les deux canaux lisent le même fichier, ce +# qui les empêche de diverger entre eux. +# +# POSIX sh de bout en bout : ni bash, ni ruby, ni python. La validation par +# WinGet et par Scoop ne peut pas tourner ici ; elle est faite par la suite de +# tests (schémas officiels) et par la CI (installation réelle). +# +# Ce que ce fichier répète de `packaging/homebrew/render.sh` — la lecture du +# `checksums.txt`, l'isolement d'une ligne, la vérification 64 hexadécimaux — y est +# recopié volontairement plutôt que factorisé dans une bibliothèque partagée : +# les deux générateurs sont testés isolément, dans des bacs à sable où aucun +# fichier voisin n'est disponible. Un correctif appliqué à une seule des deux +# copies se verrait ici, par la suite de tests. + +set -eu + +# --------------------------------------------------------------------------- +# Configuration +# +# Les deux variables existent pour être surchargées par les tests, qui servent +# une arborescence de release locale. +# --------------------------------------------------------------------------- +REPO="${TYPR_REPO:-we-data-ch/typr}" +ORIGIN="${TYPR_ORIGIN:-https://github.com}" + +HERE=$(cd "$(dirname "$0")" && pwd) + +WINGET_VERSION_IN="$HERE/winget/we-data-ch.TypR.yaml.in" +WINGET_INSTALLER_IN="$HERE/winget/we-data-ch.TypR.installer.yaml.in" +WINGET_LOCALE_IN="$HERE/winget/we-data-ch.TypR.locale.en-US.yaml.in" +SCOOP_IN="$HERE/scoop/typr.json.in" +SCOOP_README_IN="$HERE/scoop/README.md.in" + +# Les deux cibles Windows, dans l'ordre où elles apparaissent dans les manifestes. +# L'ordre est une convention lisible, pas une contrainte : chaque entrée porte +# son URL et son empreinte, donc les deux sont interchangeables sans que rien ne +# puisse devenir faux. +A_WINDOWS_X86="x86_64-pc-windows-msvc" +A_WINDOWS_ARM="aarch64-pc-windows-msvc" + +# Nom de fichier WinGet : l'identifiant du paquet, puis le type de manifeste. +WINGET_ID="we-data-ch.TypR" + +TAG="" +CHECKSUMS="" +OUT="." +RELEASE_DATE="" + +TMP="" + +# --------------------------------------------------------------------------- +# Sortie +# --------------------------------------------------------------------------- +# Erreur d'environnement ou de vérification : le monde est en cause (1). +die() { + printf 'typr-render : %s\n' "$1" >&2 + exit 1 +} + +# Erreur de saisie : la ligne de commande est en cause (2). Même convention que +# install.sh, install.ps1 et homebrew/render.sh, pour qu'un appelant puisse +# réagir pareil. +usage_die() { + printf 'typr-render : %s\n' "$1" >&2 + printf '\n' >&2 + usage >&2 + exit 2 +} + +step() { printf '→ %s\n' "$*"; } +note() { printf ' %s\n' "$*"; } +warn() { printf 'typr-render : attention : %s\n' "$*" >&2; } + +cleanup() { + if [ -n "$TMP" ] && [ -d "$TMP" ]; then + rm -rf "$TMP" + fi +} + +usage() { + cat <<'EOF' +TypR — générateur des manifestes WinGet et Scoop + +Usage : + render.sh --tag [options] + +Options : + --tag version à publier (ex. v0.5.12 ; le v initial est optionnel) + --checksums checksums.txt de la release : URL (défaut) ou fichier local + --out où écrire winget/ et scoop/ (défaut : .) + --release-date ReleaseDate WinGet, format AAAA-MM-JJ (défaut : aujourd'hui, UTC) + --help cette aide + +Écrit : + winget/we-data-ch.TypR.yaml, .installer.yaml, .locale.en-US.yaml + scoop/bucket/typr.json, scoop/bin/typr/.json, scoop/README.md + +Ces fichiers sont entièrement générés : ne jamais les éditer à la main. Voir +packaging/README.md. +EOF +} + +# --------------------------------------------------------------------------- +# Arguments +# --------------------------------------------------------------------------- +while [ $# -gt 0 ]; do + case "$1" in + --tag) + [ $# -ge 2 ] || usage_die "--tag attend une version (ex. --tag v0.5.12)." + TAG="$2" + shift 2 + ;; + --tag=*) + TAG="${1#--tag=}" + shift + ;; + --checksums) + [ $# -ge 2 ] || usage_die "--checksums attend une URL ou un chemin." + CHECKSUMS="$2" + shift 2 + ;; + --checksums=*) + CHECKSUMS="${1#--checksums=}" + shift + ;; + --out) + [ $# -ge 2 ] || usage_die "--out attend un dossier." + OUT="$2" + shift 2 + ;; + --out=*) + OUT="${1#--out=}" + shift + ;; + --release-date) + [ $# -ge 2 ] || usage_die "--release-date attend une date (AAAA-MM-JJ)." + RELEASE_DATE="$2" + shift 2 + ;; + --release-date=*) + RELEASE_DATE="${1#--release-date=}" + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + usage_die "argument inconnu : $1" + ;; + esac +done + +[ -n "$TAG" ] || usage_die "--tag est obligatoire." + +case "$TAG" in + v*) ;; + *) TAG="v$TAG" ;; +esac + +# La forme exacte est vérifiée, pas seulement le préfixe `v` : le tag entre dans +# une URL, dans un nom de fichier (`scoop/bin/typr/.json`) et dans une +# substitution sed, donc un `v../../x` n'a rien à faire ici. `-alpha.1` et +# `-beta.2` sont acceptés : release.yml marque ces versions prerelease, et les +# deux canaux savent les publier. +printf '%s' "$TAG" \ + | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$' \ + || usage_die "tag illisible : '$TAG' (attendu : vX.Y.Z, ou vX.Y.Z-alpha.N)." + +VERSION="${TAG#v}" + +# WinGet classe les versions par version puis par ReleaseDate. Une date illisible +# ferait échouer la validation du dépôt avec un message qui ne parle que de la +# date — mieux vaut la nommer ici, où le tag est en cause. +if [ -z "$RELEASE_DATE" ]; then + RELEASE_DATE=$(date -u +%Y-%m-%d) \ + || die "date du jour illisible par \`date\` — passez --release-date AAAA-MM-JJ." +fi + +printf '%s' "$RELEASE_DATE" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' \ + || usage_die "release-date illisible : '$RELEASE_DATE' (attendu : AAAA-MM-JJ)." + +for required in "$WINGET_VERSION_IN" "$WINGET_INSTALLER_IN" "$WINGET_LOCALE_IN" \ + "$SCOOP_IN" "$SCOOP_README_IN"; do + [ -f "$required" ] || die "gabarit manquant : $required" +done + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +TMP=$(mktemp -d "${TMPDIR:-/tmp}/typr-render.XXXXXX") \ + || die "impossible de créer un dossier temporaire." + +# --------------------------------------------------------------------------- +# checksums.txt +# --------------------------------------------------------------------------- +command -v curl >/dev/null 2>&1 \ + || die "curl est requis et n'est pas installé." + +CHECKSUMS_FILE="$TMP/checksums.txt" + +case "$CHECKSUMS" in + ''|*://*) + URL="$CHECKSUMS" + [ -n "$URL" ] \ + || URL="$ORIGIN/$REPO/releases/download/$TAG/checksums.txt" + step "Téléchargement de $URL" + curl -fsSL "$URL" -o "$CHECKSUMS_FILE" \ + || die "checksums.txt introuvable pour $TAG. + URL : $URL" + ;; + *) + [ -f "$CHECKSUMS" ] || die "checksums.txt introuvable : $CHECKSUMS" + cp "$CHECKSUMS" "$CHECKSUMS_FILE" + ;; +esac + +# --------------------------------------------------------------------------- +# SHA-256 +# +# Une ligne par artefact, au format `sha256sum`. On isole la ligne qui concerne +# l'artefact cherché — `sha256sum -c` ne conviendrait pas, il tenterait de +# vérifier les sept autres archives et sortirait en erreur alors que la nôtre est +# valide. Les points du nom sont échappés : sans cela, le motif matche +# n'importe quel caractère à leur place, et `x86_64-pc-windows-msvc` se ferait +# prendre pour `x86_64-pc-windows-msvc` suivi de n'importe quoi. +# --------------------------------------------------------------------------- +lookup_sha() { # lookup_sha — vide si absent, exit 1 si malformé + artifact="$1" + pattern=$(printf '%s' "$artifact" | sed 's/[.[\*^$]/\\&/g') + lines=$(grep -E "[[:space:]]\*?${pattern}\$" "$CHECKSUMS_FILE" || true) + count=$(printf '%s' "$lines" | grep -c . || true) + + [ "$count" -gt 1 ] && die "checksums.txt contient $count lignes pour $artifact : +$lines +Deux lignes pour le même nom : la release est incohérente, et choisir l'une +des deux reviendrait à publier une empreinte arbitraire." + + [ "$count" -eq 1 ] || return 0 + + sha=$(printf '%s\n' "$lines" | cut -d' ' -f1) + printf '%s\n' "$sha" | grep -Eq '^[0-9a-f]{64}$' \ + || die "ligne malformée dans checksums.txt pour $artifact : + $lines +Un SHA-256 fait 64 caractères hexadécimaux." + + printf '%s\n' "$sha" +} + +require_sha() { # require_sha — die si absent + sha=$(lookup_sha "$1") || exit 1 + [ -n "$sha" ] \ + || die "checksums.txt de $TAG ne contient aucune ligne pour $1. + Fichier reçu : +$(cat "$CHECKSUMS_FILE")" + printf '%s' "$sha" +} + +# --------------------------------------------------------------------------- +# Rendu +# --------------------------------------------------------------------------- +SHA_WINDOWS_X86=$(require_sha "typr-$TAG-$A_WINDOWS_X86.zip") || exit 1 +SHA_WINDOWS_ARM=$(require_sha "typr-$TAG-$A_WINDOWS_ARM.zip") || exit 1 + +step "TypR $VERSION" +note "tag : $TAG" +note "release date : $RELEASE_DATE" +note "sortie : $OUT" + +# Les valeurs substituées sont des URL et des empreintes : aucun caractère `&` +# ni `|`, qui seraient des métacaractères de sed. La seule exception possible +# viendrait du tag, validé plus haut. +BASE_URL="$ORIGIN/$REPO/releases/download/$TAG" +URL_WINDOWS_X86="$BASE_URL/typr-$TAG-$A_WINDOWS_X86.zip" +URL_WINDOWS_ARM="$BASE_URL/typr-$TAG-$A_WINDOWS_ARM.zip" + +# Les trois manifestes WinGet sont rendus par la même substitution : `sed` ne +# sait pas répéter une substitution par cible, alors que `awk` le ferait — mais +# `awk` n'est pas garanti sur un macOS minimal, et POSIX sh l'est. +for template in version installer locale; do + case "$template" in + version) in_file="$WINGET_VERSION_IN" ;; + installer) in_file="$WINGET_INSTALLER_IN" ;; + locale) in_file="$WINGET_LOCALE_IN" ;; + esac + sed \ + -e "s|@@VERSION@@|$VERSION|g" \ + -e "s|@@RELEASE_DATE@@|$RELEASE_DATE|g" \ + -e "s|@@URL_X86_64_WINDOWS@@|$URL_WINDOWS_X86|g" \ + -e "s|@@SHA256_X86_64_WINDOWS@@|$SHA_WINDOWS_X86|g" \ + -e "s|@@URL_AARCH64_WINDOWS@@|$URL_WINDOWS_ARM|g" \ + -e "s|@@SHA256_AARCH64_WINDOWS@@|$SHA_WINDOWS_ARM|g" \ + "$in_file" > "$TMP/winget-$template.yaml" +done + +sed \ + -e "s|@@VERSION@@|$VERSION|g" \ + -e "s|@@URL_X86_64_WINDOWS@@|$URL_WINDOWS_X86|g" \ + -e "s|@@SHA256_X86_64_WINDOWS@@|$SHA_WINDOWS_X86|g" \ + -e "s|@@URL_AARCH64_WINDOWS@@|$URL_WINDOWS_ARM|g" \ + -e "s|@@SHA256_AARCH64_WINDOWS@@|$SHA_WINDOWS_ARM|g" \ + "$SCOOP_IN" > "$TMP/typr.json" + +# Un gabarit dont le nom d'un marqueur a changé produirait un manifeste servi +# tel quel, avec `@@…@@` dedans — pour WinGet, une pull request qui ne passe +# jamais la validation ; pour Scoop, un bucket cassé. +for rendered in "$TMP/winget-version.yaml" "$TMP/winget-installer.yaml" \ + "$TMP/winget-locale.yaml" "$TMP/typr.json"; do + grep -q '@@' "$rendered" \ + && die "$(basename "$rendered") contient encore un marqueur non substitué : +$(grep -n '@@' "$rendered")" +done + +# Un gabarit écrit sans retour à la ligne final donne un fichier que les +# validateurs refusent, et le retrait ne se voit que dans le diff du dépôt de +# destination. Aucune de ces anomalies ne se voit dans la sortie de ce script, +# d'où le contrôle. +for rendered in "$TMP/winget-version.yaml" "$TMP/winget-installer.yaml" \ + "$TMP/winget-locale.yaml" "$TMP/typr.json"; do + [ -z "$(tail -c1 "$rendered")" ] \ + || die "$(basename "$rendered") ne se termine pas par un retour à la ligne." +done + +# Le compte est une garde, pas une décoration : il vérifie que les deux cibles +# Windows ont chacune produit une URL et une empreinte, sans quoi un gabarit +# amputé d'une moitié partirait sans bruit. +shas=$(grep -cE '^[[:space:]]*InstallerSha256: [0-9a-f]{64}$' "$TMP/winget-installer.yaml" || true) +[ "$shas" -eq 2 ] \ + || die "le manifeste d'installation déclare $shas empreintes, attendu 2. +Le gabarit et le rendu ne correspondent plus." + +grep -Fq "PackageVersion: $VERSION" "$TMP/winget-installer.yaml" \ + || die "le manifeste d'installation ne déclare pas PackageVersion: $VERSION." + +grep -Fq "ReleaseDate: \"$RELEASE_DATE\"" "$TMP/winget-installer.yaml" \ + || die "le manifeste d'installation ne déclare pas ReleaseDate: \"$RELEASE_DATE\"." + +# Le README du bucket est dérivé du manifeste, pas d'une deuxième source : +# description et URL y sont relues dans ce qui vient d'être écrit, donc les deux +# fichiers ne peuvent pas diverger. +DESC=$(sed -n 's/^[[:space:]]*"description": "\(.*\)",\{0,1\}$/\1/p' "$TMP/typr.json" | head -1) +HOMEPAGE=$(sed -n 's/^[[:space:]]*"homepage": "\(.*\)",\{0,1\}$/\1/p' "$TMP/typr.json" | head -1) + +[ -n "$DESC" ] || die "aucune description lisible dans le manifeste rendu." +[ -n "$HOMEPAGE" ] || die "aucune homepage lisible dans le manifeste rendu." + +sed \ + -e "s|@@DESC@@|$DESC|g" \ + -e "s|@@HOMEPAGE@@|$HOMEPAGE|g" \ + -e "s|@@VERSION@@|$VERSION|g" \ + "$SCOOP_README_IN" > "$TMP/README.md" + +# Le README est rendu après le manifeste, parce qu'il en relit la description : +# il a donc besoin de ses propres contrôles. +grep -q '@@' "$TMP/README.md" \ + && die "le README rendu contient encore un marqueur non substitué : +$(grep -n '@@' "$TMP/README.md")" + +[ -z "$(tail -c1 "$TMP/README.md")" ] \ + || die "README.md ne se termine pas par un retour à la ligne." + +# --------------------------------------------------------------------------- +# Écriture +# +# Rien n'est écrit dans le dossier de sortie tant que le rendu n'est pas +# complet : un échec au milieu ne doit pas laisser un manifeste tronqué que la +# CI pousserait vers winget-pkgs ou vers le bucket. +# --------------------------------------------------------------------------- +mkdir -p "$OUT/winget" || die "impossible de créer $OUT/winget." +mkdir -p "$OUT/scoop/bucket" "$OUT/scoop/bin/typr" \ + || die "impossible de créer $OUT/scoop." + +cp "$TMP/winget-version.yaml" "$OUT/winget/$WINGET_ID.yaml" +cp "$TMP/winget-installer.yaml" "$OUT/winget/$WINGET_ID.installer.yaml" +cp "$TMP/winget-locale.yaml" "$OUT/winget/$WINGET_ID.locale.en-US.yaml" + +cp "$TMP/typr.json" "$OUT/scoop/bucket/typr.json" +cp "$TMP/typr.json" "$OUT/scoop/bin/typr/$VERSION.json" +cp "$TMP/README.md" "$OUT/scoop/README.md" + +note "sha256 : $SHA_WINDOWS_X86 ($A_WINDOWS_X86)" +note "sha256 : $SHA_WINDOWS_ARM ($A_WINDOWS_ARM)" + +printf '\n' +step "Écrit : $OUT/winget/ (3 manifestes) et $OUT/scoop/ (bucket, historique, README)" diff --git a/packaging/scoop/README.md.in b/packaging/scoop/README.md.in new file mode 100644 index 00000000..72e248df --- /dev/null +++ b/packaging/scoop/README.md.in @@ -0,0 +1,46 @@ +# TypR — bucket Scoop + +@@DESC@@ + +## Installation + +```powershell +scoop bucket add typr https://github.com/we-data-ch/scoop-bucket +scoop install typr +``` + +`bucket add` n'est nécessaire qu'une fois. Scoop installe sans droits +administrateur et met `typr` sur le PATH. + +## Utilisation + +```powershell +typr check fichier.ty +typr build fichier.ty +typr --version +``` + +Mise à jour : + +```powershell +scoop update typr +``` + +Désinstallation : + +```powershell +scoop uninstall typr +``` + +## Versions + +Ce dépôt ne publie aucun binaire. Le manifeste ne contient que l'URL de la +release et le SHA-256 des deux archives Windows, lus dans le `checksums.txt` de +cette release. Un tag `vX.Y.Z` de [we-data-ch/typr](https://github.com/we-data-ch/typr) +produit le commit suivant ici — voir `packaging/README.md` dans le dépôt source. + +TypR @@VERSION@@ est la version publiée dans ce bucket. + +Documentation : @@HOMEPAGE@@ + +Licence : Apache-2.0 — voir `LICENSE`. diff --git a/packaging/scoop/typr.json.in b/packaging/scoop/typr.json.in new file mode 100644 index 00000000..5ff7c3fa --- /dev/null +++ b/packaging/scoop/typr.json.in @@ -0,0 +1,21 @@ +{ + "$schema": "https://raw.githubusercontent.com/ScoopInstaller/Scoop/master/schema.json", + "_comment": "GENERATED FILE — do not edit by hand. Template: packaging/scoop/typr.json.in in we-data-ch/typr. Rendered by packaging/render.sh --tag vX.Y.Z. Note: the Scoop schema allows only _comment among underscore-prefixed keys, and rejects every unknown key outright.", + "version": "@@VERSION@@", + "description": "A typed superset of R — transpiler and type checker", + "homepage": "https://we-data-ch.github.io/typr.github.io/", + "license": "Apache-2.0", + "url": [ + "@@URL_X86_64_WINDOWS@@", + "@@URL_AARCH64_WINDOWS@@" + ], + "hash": [ + "@@SHA256_X86_64_WINDOWS@@", + "@@SHA256_AARCH64_WINDOWS@@" + ], + "bin": "typr.exe", + "notes": [ + "TypR is pre-1.0: 0.x releases can change the CLI surface.", + "Installed by Scoop under %USERPROFILE%\\scoop\\apps\\typr — not under %LOCALAPPDATA%\\Programs\\typr as irm | iex does. Both put `typr` on the PATH for the current user, without administrator rights." + ] +} diff --git a/packaging/tests/fixtures/checksums-v9.9.9.txt b/packaging/tests/fixtures/checksums-v9.9.9.txt new file mode 100644 index 00000000..09f18119 --- /dev/null +++ b/packaging/tests/fixtures/checksums-v9.9.9.txt @@ -0,0 +1,13 @@ +# checksums.txt factice — la même forme que celui produit par le job +# `release` de .github/workflows/release.yml (sha256sum, huit archives), +# pour la release de test v9.9.9. Utilisé par packaging/tests/run-tests.sh +# et par le job `packaging` de ci.yml, qui rend les manifestes sans toucher +# au réseau. Aucune de ces empreintes ne correspond à un fichier. +67bfbab2f377ffb28cc9d394222f7403d71202d1559cba9f179e5728cc8bffc2 typr-v9.9.9-x86_64-unknown-linux-musl.tar.gz +2ceb889725cc3afea080085a48588f191235ff2bf54b3a6596609b692c019394 typr-v9.9.9-aarch64-unknown-linux-musl.tar.gz +9eb96299648b6fb476ad2176cfd326e6683b896d5c982ec0f30f5407b550dd91 typr-v9.9.9-x86_64-unknown-linux-gnu.tar.gz +ebd691b6a05239269b8d4b0a1e5a691505e12d80fc8c9ef6130ddf42688e0490 typr-v9.9.9-aarch64-unknown-linux-gnu.tar.gz +b9fd9fdc0cb0e6b6b07a4cf7a5f80161290ef786b95a2551a80476b5245dc277 typr-v9.9.9-x86_64-apple-darwin.tar.gz +6fe8e339164f7261977dd9ce689f50e075fc31872eda6a47b91299cf52e5a7da typr-v9.9.9-aarch64-apple-darwin.tar.gz +c42898b3daa997df6dc7dff3ab5e704be2059360894d973a2fc364ec8ff8ab14 typr-v9.9.9-x86_64-pc-windows-msvc.zip +8d8d51a553587a69ea63dd3883ba7e7730561f1375c8e62ae7dcb53b425ba81a typr-v9.9.9-aarch64-pc-windows-msvc.zip diff --git a/packaging/tests/run-tests.sh b/packaging/tests/run-tests.sh new file mode 100755 index 00000000..b31178a2 --- /dev/null +++ b/packaging/tests/run-tests.sh @@ -0,0 +1,718 @@ +#!/usr/bin/env bash +# Tests de packaging/render.sh (manifestes WinGet et Scoop), sans réseau. +# +# Le générateur ne lit qu'une chose : le `checksums.txt` d'une release. Toute sa +# valeur tient donc à trois propriétés qu'aucune relecture ne montre — +# +# 1. il prend **la** bonne ligne (le fichier liste huit archives, dont six +# Linux/macOS et les deux GNU qu'un canal Windows n'a jamais le droit de +# pointer) ; +# 2. il necroise jamais une URL avec l'empreinte d'une autre cible — les deux +# cibles Windows ne diffèrent que par un mot du nom ; +# 3. il refuse de produire un manifeste à moitié rendu. +# +# C'est ce que ces tests couvrent, plus la dérive entre les gabarits et le +# générateur — le défaut qui n'apparaît qu'à la prochaine release, chez +# l'utilisateur. +# +# packaging/tests/run-tests.sh +# +# Le même script est exécuté par le job `packaging` de ci.yml. + +set -uo pipefail + +HERE=$(cd "$(dirname "$0")" && pwd) +PACKAGING_DIR=$(cd "$HERE/.." && pwd) +RENDER="$PACKAGING_DIR/render.sh" +FIXTURE="$HERE/fixtures/checksums-v9.9.9.txt" +VALIDATE="$HERE/validate-schemas.py" + +TAG="v9.9.9" +VERSION="9.9.9" +RELEASE_DATE="2026-01-02" + +# Les deux cibles Windows, dans l'ordre où le générateur les déclare. +WIN_X86="x86_64-pc-windows-msvc" +WIN_ARM="aarch64-pc-windows-msvc" + +WINGET_VERSION_FILE="we-data-ch.TypR.yaml" +WINGET_INSTALLER_FILE="we-data-ch.TypR.installer.yaml" +WINGET_LOCALE_FILE="we-data-ch.TypR.locale.en-US.yaml" + +PASSED=0 +FAILED=0 +ROOT="" +SERVER_PID="" + +cleanup() { + [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null + [ -n "$ROOT" ] && rm -rf "$ROOT" + return 0 +} +trap cleanup EXIT + +ok() { PASSED=$((PASSED + 1)); printf ' \033[32m✓\033[0m %s\n' "$1"; } +ko() { FAILED=$((FAILED + 1)); printf ' \033[31m✗\033[0m %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; return 0; } + +check_rc() { # check_rc + if [ "$1" -eq "$2" ]; then ok "$3"; else ko "$3" "code de retour $2, attendu $1"; fi +} + +check_contains() { # check_contains + # Un motif vide matcherait n'importe quoi : l'assertion passerait sans rien + # vérifier, ce qui est pire que ne pas tester. Il est donc refusé. + if [ -z "$2" ]; then ko "$3" "motif de recherche vide — l'assertion ne vérifierait rien"; return 0; fi + case "$1" in + *"$2"*) ok "$3" ;; + *) ko "$3" "« $2 » absent de la sortie : $1" ;; + esac +} + +check_not_contains() { + if [ -z "$2" ]; then ko "$3" "motif de recherche vide — l'assertion ne vérifierait rien"; return 0; fi + case "$1" in + *"$2"*) ko "$3" "« $2 » présent alors qu'il ne devait pas l'être" ;; + *) ok "$3" ;; + esac +} + +check_file() { # check_file + if [ -f "$1" ]; then ok "$2"; else ko "$2" "$1 absent"; fi +} + +check_no_file() { + if [ -e "$1" ]; then ko "$2" "$1 présent"; else ok "$2"; fi +} + +# Un manifeste WinGet porte des commentaires qui expliquent le rendu — dont +# certains nomment précisément ce qui ne doit pas apparaître (pourquoi pas +# d'`InstallLocation`, par exemple). Les contrôles de contenu portent donc sur +# les *champs*, pas sur le texte : un commentaire ne télécharge rien, et une +# assertion qui le prendrait en compte finirait un jour par interdire sa propre +# documentation. +fields() { # fields + grep -v '^[[:space:]]*#' "$1" || true +} + +# L'empreinte de l'artefact dans la fixture — la valeur attendue, lue depuis la +# fixture et non recopiée ici. Le tag fait partie du motif : c'est lui qui +# distingue deux releases, et deux releases ont des empreintes différentes. +fixture_sha() { # fixture_sha [fichier] + grep -E "[[:space:]]\*?typr-$TAG-$1\.zip\$" "${2:-$FIXTURE}" | head -1 | cut -d' ' -f1 +} + +work() { # work — dossier de travail sous $ROOT, donc nettoyé avec lui + local dir="$ROOT/$1" + rm -rf "$dir" + mkdir -p "$dir" + printf '%s' "$dir" +} + +render() { # render [args...] + local out="$1"; shift + "$RENDER" --tag "$TAG" --checksums "$FIXTURE" --release-date "$RELEASE_DATE" --out "$out" 2>&1 +} + +# Le bloc attendu d'une cible WinGet : son architecture, son URL **et** son +# empreinte, côte à côte. Vérifier séparément que les quatre valeurs +# apparaissent dans le fichier ne prouverait rien — deux cibles interverties +# donneraient exactement le même texte. C'est l'erreur que le canal Homebrew a +# déjà laissée passer, une fois. +expect_winget_block() { # expect_winget_block + printf -- '- Architecture: %s\n NestedInstallerFiles:\n - RelativeFilePath: typr.exe\n PortableCommandAlias: typr\n InstallerUrl: https://github.com/we-data-ch/typr/releases/download/%s/typr-%s-%s.zip\n InstallerSha256: %s' \ + "$1" "$TAG" "$TAG" "$2" "$(fixture_sha "$2")" +} + +# Le manifeste Scoop, lu comme Scoop le lit : `url` et `hash` sont alignés par +# index, `bin` désigne l'exécutable, et l'alias WinGet doit désigner le même +# programme. C'est fait en Python parce que le point à vérifier — l'alignement +# index par index — n'est pas exprimable autrement. +check_scoop() { # check_scoop + # La lecture du manifeste Scoop est faite en Python : l'alignement index par + # index de `url` et `hash` ne s'exprime pas autrement. Chaque problème y est + # listé, ce que le shell ne sait pas faire. + local details + if details=$(python3 - "$1" "$2" "$FIXTURE" "$TAG" 2>&1 <<'PY' +import json, os, re, sys + +manifest, installer, fixture, tag = sys.argv[1:5] +problems = [] + +with open(manifest, encoding="utf-8") as handle: + doc = json.load(handle) + +with open(fixture, encoding="utf-8") as handle: + wanted = {} + for line in handle: + line = line.strip() + if line and not line.startswith("#"): + sha, _, name = line.partition(" ") + wanted[name.strip()] = sha + +if doc.get("version") != tag.lstrip("v"): + problems.append(f"version : {doc.get('version')!r}, attendu {tag.lstrip('v')!r}") + +urls, hashes = doc.get("url"), doc.get("hash") +if not isinstance(urls, list) or not isinstance(hashes, list): + problems.append("url et hash doivent être des listes alignées") +else: + if len(urls) != len(hashes): + problems.append(f"{len(urls)} URL pour {len(hashes)} empreintes") + for url, sha in zip(urls, hashes): + name = url.rsplit("/", 1)[-1] + if not re.fullmatch(r"[0-9a-f]{64}", sha): + problems.append(f"empreinte mal formée pour {name} : {sha}") + if wanted.get(name) != sha: + problems.append( + f"{name} → {sha}, alors que la release publie {wanted.get(name)}" + ) + if f"typr-{tag}-" not in name: + problems.append(f"{name} ne vient pas de la release {tag}") + +bin_name = doc.get("bin") +if bin_name != "typr.exe": + problems.append(f"bin : {bin_name!r}, attendu 'typr.exe'") + +# Même programme dans les deux canaux : l'alias WinGet est le nom de l'exécutable +# Scoop, sans son extension. +alias = None +with open(installer, encoding="utf-8") as handle: + for line in handle: + if "PortableCommandAlias:" in line: + alias = line.split(":", 1)[1].strip() +if isinstance(bin_name, str) and alias != os.path.splitext(bin_name)[0]: + problems.append(f"alias WinGet {alias!r} ≠ exécutable Scoop {bin_name!r}") + +for problem in problems: + print(f" {problem}") +sys.exit(1 if problems else 0) +PY + ); then + ok "$3" + else + ko "$3" "$details" + fi +} + +# --------------------------------------------------------------------------- +# Analyse statique +# --------------------------------------------------------------------------- +test_static_analysis() { + echo "Analyse statique" + + # `ok` et `ko` sortent toujours en 0, donc le `||` ne se déclenche jamais à + # tort : SC2015 signalerait ici un if-then-else qui n'existe pas. + # shellcheck disable=SC2015 + sh -n "$RENDER" 2>&1 && ok "sh -n" || ko "sh -n" + # shellcheck disable=SC2015 + bash -n "$RENDER" 2>&1 && ok "bash -n" || ko "bash -n" + # shellcheck disable=SC2015 + bash -n "$HERE/run-tests.sh" 2>&1 && ok "bash -n (suite)" || ko "bash -n (suite)" + # Le cache d'octets est redirigé vers le répertoire de travail de la suite : + # `py_compile` écrit d'ordinaire `__pycache__/` à côté du fichier, et + # `packaging/tests/` n'a rien à faire committer. + # shellcheck disable=SC2015 + PYTHONPYCACHEPREFIX="$(work pycache)" python3 -m py_compile "$VALIDATE" 2>&1 \ + && ok "py_compile" || ko "py_compile" + + if command -v dash >/dev/null 2>&1; then + # shellcheck disable=SC2015 + dash -n "$RENDER" 2>&1 && ok "dash -n (POSIX strict)" || ko "dash -n" + else + printf ' – dash absent, POSIX strict non vérifié\n' + fi + + if command -v shellcheck >/dev/null 2>&1; then + # Aucune exclusion : render.sh ne se sert ni de `local` (non POSIX) ni + # d'aucun attribut bash. + if out=$(shellcheck -s sh "$RENDER" 2>&1); then + ok "shellcheck" + else + ko "shellcheck" "$out" + fi + if out=$(shellcheck "$HERE/run-tests.sh" 2>&1); then + ok "shellcheck (suite)" + else + ko "shellcheck (suite)" "$out" + fi + else + printf ' – shellcheck absent, non exécuté\n' + fi +} + +# --------------------------------------------------------------------------- +# Dérive gabarit ↔ générateur +# +# Un gabarit dont un marqueur a été renommé (`@@VERSION@@` → `@@VERSION_X@@`) +# produit un manifeste qui part en production avec `@@VERSION_X@@` dedans : rien +# ne le signale tant qu'un utilisateur n'installe pas. Le générateur le refuse, +# mais seulement s'il connaît encore le nom du marqueur — ce qui est précisément +# ce qui a pu changer. D'où ce test, qui compare les deux listes. +# --------------------------------------------------------------------------- +test_marker_drift() { + echo "Dérive gabarit / générateur" + + local in_tpl in_gen only_tpl only_gen + in_tpl=$(grep -oh '@@[A-Z0-9_]*@@' \ + "$PACKAGING_DIR/winget/$WINGET_VERSION_FILE.in" \ + "$PACKAGING_DIR/winget/$WINGET_INSTALLER_FILE.in" \ + "$PACKAGING_DIR/winget/$WINGET_LOCALE_FILE.in" \ + "$PACKAGING_DIR/scoop/typr.json.in" \ + "$PACKAGING_DIR/scoop/README.md.in" | sort -u) + in_gen=$(grep -oh '@@[A-Z0-9_]*@@' "$RENDER" | sort -u) + + only_tpl=$(comm -23 <(printf '%s\n' "$in_tpl") <(printf '%s\n' "$in_gen") | tr '\n' ' ') + if [ -z "$only_tpl" ]; then + ok "aucun marqueur de gabarit inconnu du générateur" + else + ko "aucun marqueur de gabarit inconnu du générateur" "$only_tpl" + fi + + only_gen=$(comm -13 <(printf '%s\n' "$in_tpl") <(printf '%s\n' "$in_gen") | tr '\n' ' ') + if [ -z "$only_gen" ]; then + ok "aucun marqueur du générateur absent des gabarits" + else + ko "aucun marqueur du générateur absent des gabarits" "$only_gen" + fi + + # Un gabarit sans retour à la ligne final donne un fichier que les validateurs + # de winget-pkgs et de Scoop refusent, et le retrait ne se voit qu'en + # installant. + local tpl + for tpl in "$PACKAGING_DIR/winget/$WINGET_VERSION_FILE.in" \ + "$PACKAGING_DIR/winget/$WINGET_INSTALLER_FILE.in" \ + "$PACKAGING_DIR/winget/$WINGET_LOCALE_FILE.in" \ + "$PACKAGING_DIR/scoop/typr.json.in" \ + "$PACKAGING_DIR/scoop/README.md.in"; do + if [ -z "$(tail -c1 "$tpl")" ]; then + ok "$(basename "$tpl") se termine par un retour à la ligne" + else + ko "$(basename "$tpl") se termine par un retour à la ligne" "$tpl" + fi + done +} + +# --------------------------------------------------------------------------- +# Ligne de commande +# --------------------------------------------------------------------------- +test_usage() { + echo " --help et erreurs de saisie" + local out + out=$("$RENDER" --help 2>&1) + check_rc 0 $? "--help sort en 0" + check_contains "$out" "Usage" "--help affiche l'usage" + + out=$("$RENDER" --bogus 2>&1) + check_rc 2 $? "un argument inconnu sort en 2" + check_contains "$out" "argument inconnu" "l'argument est nommé" + + out=$("$RENDER" --out /tmp 2>&1) + check_rc 2 $? "--tag absent sort en 2" + check_contains "$out" "--tag est obligatoire" "l'absence de --tag est nommée" + + out=$("$RENDER" --tag 2>&1) + check_rc 2 $? "--tag sans valeur sort en 2" + out=$("$RENDER" --tag "$TAG" --release-date 2>&1) + check_rc 2 $? "--release-date sans valeur sort en 2" + out=$("$RENDER" --tag "$TAG" --out 2>&1) + check_rc 2 $? "--out sans valeur sort en 2" + + # Le tag entre dans une URL, dans un nom de fichier + # (`scoop/bin/typr/.json`) et dans une substitution sed : une forme + # trop libre passerait par la porte de l'injection. + for tag in 'pas-un-tag' 'v1.2' 'v1.2.3/../../etc' 'vX.Y.Z' 'v1.2.3 ../../x' 'v1.2.3;rm'; do + out=$("$RENDER" --tag "$tag" --checksums "$FIXTURE" --out "$(work bad-tag)" 2>&1) + check_rc 2 $? "tag refusé : $tag" + done + + # `ReleaseDate` est lue par WinGet comme une chaîne ; une date illisible ferait + # échouer la validation du dépôt en ne parlant que d'elle. + out=$("$RENDER" --tag "$TAG" --checksums "$FIXTURE" \ + --release-date '02/01/2026' --out "$(work bad-date)" 2>&1) + check_rc 2 $? "release-date illisible refusée" + check_contains "$out" "AAAA-MM-JJ" "le format attendu est nommé" +} + +# --------------------------------------------------------------------------- +# Chemin heureux +# --------------------------------------------------------------------------- +test_render() { + echo "Rendu complet" + local out dir installer locale version_manifest + dir=$(work render) + out=$(render "$dir") + check_rc 0 $? "rendu" + check_contains "$out" "TypR $VERSION" "la version est annoncée" + + version_manifest="$dir/winget/$WINGET_VERSION_FILE" + installer="$dir/winget/$WINGET_INSTALLER_FILE" + locale="$dir/winget/$WINGET_LOCALE_FILE" + + check_file "$version_manifest" "le manifeste de version est écrit" + check_file "$installer" "le manifeste d'installation est écrit" + check_file "$locale" "la locale est écrite" + check_file "$dir/scoop/bucket/typr.json" "le manifeste du bucket est écrit" + check_file "$dir/scoop/bin/typr/$VERSION.json" "le manifeste d'historique est écrit" + check_file "$dir/scoop/README.md" "le README du bucket est écrit" + [ -f "$installer" ] || return 0 + + # Trois manifestes, une seule version : WinGet refuse une pull request dont les + # versions ne concordent pas, et il le dit d'une façon illisible + # (« manifest version mismatch ») quand ça vient d'un copier-coller. + local f + for f in "$version_manifest" "$installer" "$locale" "$dir/scoop/bucket/typr.json"; do + check_contains "$(cat "$f")" "$VERSION" "la version $VERSION est déclarée dans $(basename "$f")" + check_not_contains "$(cat "$f")" "PackageVersion: v$VERSION" "aucun v initial dans le nom de version WinGet" + check_not_contains "$(cat "$f")" "\"version\": \"v$VERSION\"" "aucun v initial dans la version Scoop" + done + + check_contains "$(cat "$version_manifest")" "DefaultLocale: en-US" "la version désigne la locale en-US" + check_contains "$(cat "$locale")" "PackageLocale: en-US" "la locale se déclare en-US" + check_contains "$(cat "$version_manifest")" "ManifestType: version" "le manifeste se déclare version" + check_contains "$(cat "$installer")" "ManifestType: installer" "l'installateur se déclare installer" + check_contains "$(cat "$locale")" "ManifestType: defaultLocale" "la locale se déclare defaultLocale" + + # Chaque cible porte son URL et son empreinte côte à côte, sous son + # architecture. Une cible intervertie donnerait le même nombre d'empreintes. + check_contains "$(cat "$installer")" "$(expect_winget_block x64 "$WIN_X86")" \ + "architecture, URL et SHA corrects pour x64" + check_contains "$(cat "$installer")" "$(expect_winget_block arm64 "$WIN_ARM")" \ + "architecture, URL et SHA corrects pour arm64" + + local shas + shas=$(grep -cE '^[[:space:]]*InstallerSha256: [0-9a-f]{64}$' "$installer") + if [ "$shas" -eq 2 ]; then ok "deux empreintes déclarées"; else ko "deux empreintes déclarées" "$shas trouvées"; fi + + # Les deux archives Windows ne diffèrent que par un mot du nom, et un `.` non + # échappé dans le motif de recherche ferait matcher n'importe quoi à sa place. + # Aucune des six archives Linux/macOS ne doit apparaître, et en particulier + # pas les GNU, que le canal Homebrew a déjà confondus un jour. + check_not_contains "$(fields "$installer")" "tar.gz" "aucune archive Linux/macOS dans l'installateur WinGet" + check_not_contains "$(fields "$installer")" "unknown-linux" "aucune cible Linux dans l'installateur WinGet" + check_not_contains "$(fields "$installer")" "apple-darwin" "aucune cible macOS dans l'installateur WinGet" + local gnu + gnu=$(grep -E '[[:space:]]typr-v9\.9\.9-x86_64-unknown-linux-gnu\.tar\.gz$' "$FIXTURE" | cut -d' ' -f1) + check_not_contains "$(cat "$installer")$(cat "$dir/scoop/bucket/typr.json")" "$gnu" \ + "l'empreinte GNU n'est retenue dans aucun canal" + check_not_contains "$(cat "$dir/scoop/bucket/typr.json")" "tar.gz" "aucune archive Linux/macOS dans le manifeste Scoop" + + # `url` et `hash` alignés par index, `bin` juste, alias concordant. + check_scoop "$dir/scoop/bucket/typr.json" "$installer" \ + "le manifeste Scoop est cohérent (url/hash/bin/alias)" + + # L'historique et le bucket sont le même fichier : deux rendus distincts + # divergeraient au premier changement de gabarit. + if diff -q "$dir/scoop/bucket/typr.json" "$dir/scoop/bin/typr/$VERSION.json" >/dev/null 2>&1; then + ok "le manifeste d'historique est identique au manifeste du bucket" + else + ko "le manifeste d'historique est identique au manifeste du bucket" \ + "$(diff "$dir/scoop/bucket/typr.json" "$dir/scoop/bin/typr/$VERSION.json" | head -4)" + fi + + # Rien d'autre dans la sortie : un vieux fichier y servirait encore à son + # adresse, dans un dépôt où l'historique fait foi. + local extra expected + expected=$(printf '%s\n' \ + "scoop/README.md" "scoop/bucket/typr.json" "scoop/bin/typr/$VERSION.json" \ + "winget/$WINGET_VERSION_FILE" "winget/$WINGET_INSTALLER_FILE" \ + "winget/$WINGET_LOCALE_FILE" | sort) + extra=$( (cd "$dir" && find . -type f | sed 's|^\./||' | sort) | grep -vxF "$expected" || true) + if [ -z "$extra" ]; then ok "aucun fichier surnumeraire dans la sortie"; else ko "aucun fichier surnumeraire dans la sortie" "$extra"; fi +} + +# --------------------------------------------------------------------------- +# Les pièges WinGet et Scoop, une fois pour toutes +# +# Ce sont des défauts que rien n'affiche à la relecture : ils n'apparaissent que +# dans le dépôt de destination, ou pas du tout. +# --------------------------------------------------------------------------- +test_platform_pitfalls() { + echo "Pièges WinGet et Scoop" + local out dir installer manifest + dir=$(work pitfalls) + out=$(render "$dir") + check_rc 0 $? "rendu de référence" + + installer="$dir/winget/$WINGET_INSTALLER_FILE" + manifest="$dir/scoop/bucket/typr.json" + [ -f "$installer" ] || return 0 + + check_contains "$(cat "$installer")" 'ReleaseDate: "'"$RELEASE_DATE"'"' \ + "ReleaseDate est guillemeté (une date nue devient un objet date en YAML 1.1)" + check_contains "$(cat "$installer")" "InstallerType: zip" "l'installateur est un zip" + check_contains "$(cat "$installer")" "NestedInstallerType: portable" "le zip est traité comme portable" + check_contains "$(cat "$installer")" "RelativeFilePath: typr.exe" "l'exécutable est nommé dans l'archive" + + # `InstallLocation` est un argument d'installeur, pas un dossier de destination + # : un zip n'en accepte pas, et WinGet installe sans élévation de toute façon. + check_not_contains "$(fields "$installer")" "InstallerSwitches" \ + "aucun champ InstallerSwitches sur un zip (dont InstallLocation, qui n'est pas un dossier)" + check_not_contains "$(fields "$installer")" "Scope:" \ + "aucun champ Scope (le zip est déjà en installation utilisateur)" + + # Le schéma de Scoop refuse toute clé inconnue, `_comment` étant la seule + # tolérée : une clé de confort se paie par un manifeste refusé. + check_not_contains "$(cat "$manifest")" '"_comment_"' "aucune clé _comment_* dans le manifeste Scoop" + local unknown + unknown=$(python3 - "$manifest" <<'PY' +import json, sys +allowed = {"$schema", "_comment", "version", "description", "homepage", "license", + "url", "hash", "bin", "notes", "architecture", "checkver", "autoupdate", + "depends", "conflicts", "suggest", "installer", "uninstaller", "env_add_path", + "env_set", "shortcuts", "psmodule", "pre_install", "post_install", "pre_uninstall", + "post_uninstall", "persist", "extract_dir", "extract_to", "encoding", "innosetup"} +extra = sorted(set(json.load(open(sys.argv[1], encoding="utf-8"))) - allowed) +print(" ".join(extra)) +PY +) + if [ -z "$unknown" ]; then ok "aucune clé inconnue dans le manifeste Scoop"; else ko "aucune clé inconnue dans le manifeste Scoop" "$unknown"; fi +} + +# --------------------------------------------------------------------------- +# Version épinglée et prerelease +# --------------------------------------------------------------------------- +test_pin_and_prerelease() { + echo "Version épinglée et prerelease" + local out dir sums + + # Sans le « v », comme install.sh qui le remet. + dir=$(work no-v) + out=$("$RENDER" --tag "$VERSION" --checksums "$FIXTURE" --release-date "$RELEASE_DATE" --out "$dir" 2>&1) + check_rc 0 $? "un tag sans le v initial est accepté" + check_contains "$(cat "$dir/winget/$WINGET_VERSION_FILE" 2>/dev/null)" "PackageVersion: $VERSION" \ + "la version est rendue sans le v" + + # release.yml marque `-alpha.N` et `-beta.N` prerelease : les deux canaux + # doivent les accepter, sinon le canal WinGet s'arrêterait à la première + # d'entre elles. La fixture est réécrite sur un tag de prerelease, parce + # qu'une release prerelease publie bien ses propres archives. + sums=$(work sums-prerelease) + sed 's/v9\.9\.9/v0.5.13-alpha.1/g' "$FIXTURE" > "$sums/checksums.txt" + dir=$(work prerelease) + out=$("$RENDER" --tag v0.5.13-alpha.1 --checksums "$sums/checksums.txt" --release-date "$RELEASE_DATE" --out "$dir" 2>&1) + check_rc 0 $? "une prerelease est rendue" + check_contains "$(cat "$dir/winget/$WINGET_VERSION_FILE" 2>/dev/null)" "PackageVersion: 0.5.13-alpha.1" \ + "la prerelease est déclarée sans v" + check_contains "$(cat "$dir/scoop/bucket/typr.json" 2>/dev/null)" '"version": "0.5.13-alpha.1"' \ + "la prerelease est déclarée dans le manifeste Scoop" + check_file "$dir/scoop/bin/typr/0.5.13-alpha.1.json" "l'historique porte le nom de la prerelease" + + # Sans artefact à son nom, rien ne peut être rendu : une release dont le job de + # build a échoué sur une cible Windows ne doit pas produire un manifeste qui + # pointerait une autre archive. + dir=$(work prerelease-missing) + out=$("$RENDER" --tag v9.9.9-alpha.1 --checksums "$FIXTURE" --release-date "$RELEASE_DATE" --out "$dir" 2>&1) + check_rc 1 $? "une prerelease sans artefacts propres est refusée" + check_contains "$out" "ne contient aucune ligne pour" "l'absence est nommée, pas devinée" + check_no_file "$dir/winget/$WINGET_VERSION_FILE" "aucun manifeste n'est écrit" + + # Dossier de sortie imbriqué absent : il doit être créé. + dir="$ROOT/nested/deep/out" + rm -rf "$ROOT/nested" + out=$(render "$dir") + check_rc 0 $? "dossier de sortie imbriqué créé" + check_file "$dir/scoop/bucket/typr.json" "le manifeste est écrit dans le dossier imbriqué" +} + +# --------------------------------------------------------------------------- +# Refus +# --------------------------------------------------------------------------- +test_missing_line() { + echo "Ligne absente, doublon, malformation" + local out dir rel + dir=$(work missing) + out=$(render "$dir") + check_rc 0 $? "rendu de référence" + + # Sans la ligne d'un artefact : rien ne peut être rendu. + rel=$(work sums-missing) + grep -v "$WIN_ARM" "$FIXTURE" > "$rel/checksums.txt" + out=$("$RENDER" --tag "$TAG" --checksums "$rel/checksums.txt" --release-date "$RELEASE_DATE" --out "$(work out-missing)" 2>&1) + check_rc 1 $? "checksums.txt sans la ligne attendue → échec" + check_contains "$out" "$WIN_ARM" "l'artefact manquant est nommé" + check_no_file "$(work out-missing)/winget/$WINGET_INSTALLER_FILE" "aucun manifeste n'est écrit" + check_no_file "$(work out-missing)/scoop/bucket/typr.json" "aucun manifeste Scoop n'est écrit" + + # Deux lignes pour le même nom : choisir l'une des deux reviendrait à publier + # une empreinte arbitraire. + rel=$(work sums-dup) + { cat "$FIXTURE"; printf '%s typr-%s-%s.zip\n' "$(fixture_sha "$WIN_X86")" "$TAG" "$WIN_X86"; } > "$rel/checksums.txt" + out=$("$RENDER" --tag "$TAG" --checksums "$rel/checksums.txt" --release-date "$RELEASE_DATE" --out "$(work out-dup)" 2>&1) + check_rc 1 $? "deux lignes pour le même artefact → échec" + check_contains "$out" "2 lignes" "le nombre de lignes est nommé" + check_no_file "$(work out-dup)/winget/$WINGET_INSTALLER_FILE" "aucun manifeste n'est écrit sur un doublon" + + # Malformée : la ligne existe, le SHA est illisible. Un fichier tronqué ne doit + # pas produire un manifeste que winget-pkgs refusera ensuite — il le dirait par + # le nom du champ, sans jamais nommer la release comme cause. + # (64 zéros ne sont pas un cas de test : c'est un SHA bien formé, et le + # générateur a raison de l'accepter.) + for bad in 'pas-un-sha' \ + "$(fixture_sha "$WIN_X86" | cut -c1-63)" \ + "$(fixture_sha "$WIN_X86" | cut -c1-63)z" \ + "$(fixture_sha "$WIN_X86")0"; do + rel=$(work sums-bad) + awk -v bad="$bad" -v t="typr-$TAG-$WIN_X86.zip" \ + '{ if ($NF == t) print bad " " $NF; else print }' "$FIXTURE" > "$rel/checksums.txt" + out=$("$RENDER" --tag "$TAG" --checksums "$rel/checksums.txt" --release-date "$RELEASE_DATE" --out "$(work out-bad)" 2>&1) + check_rc 1 $? "SHA malformé refusé : $bad" + check_contains "$out" "malform" "la malformation est nommée" + done + + # checksums.txt absent du tout. + out=$("$RENDER" --tag "$TAG" --checksums "$ROOT/inexistant.txt" --out "$(work out-ghost)" 2>&1) + check_rc 1 $? "checksums.txt absent → échec" + check_contains "$out" "introuvable" "l'absence est nommée" +} + +# --------------------------------------------------------------------------- +# Gabarit modifié : le générateur doit refuser plutôt que publier tel quel +# --------------------------------------------------------------------------- +test_template_drift() { + echo "Gabarit modifié" + local out sandbox + sandbox=$(work sandbox) + cp "$RENDER" "$sandbox/render.sh" + mkdir -p "$sandbox/winget" "$sandbox/scoop" + cp "$PACKAGING_DIR/winget/"*.in "$sandbox/winget/" + cp "$PACKAGING_DIR/scoop/"*.in "$sandbox/scoop/" + + # Le marqueur est renommé côté gabarit, pas côté générateur : exactement le + # réagencement de nom qu'un commit peut introduire. + sed -i 's/@@VERSION@@/@@TYPR_VERSION@@/' "$sandbox/winget/$WINGET_VERSION_FILE.in" + out=$("$sandbox/render.sh" --tag "$TAG" --checksums "$FIXTURE" --release-date "$RELEASE_DATE" --out "$(work out-drift)" 2>&1) + check_rc 1 $? "un marqueur renommé est refusé" + check_contains "$out" "marqueur" "le marqueur non substitué est nommé" + check_no_file "$(work out-drift)/winget/$WINGET_VERSION_FILE" "aucun manifeste n'est écrit" + + # Retrait du retour à la ligne final : invisible dans une diff, fatal pour la + # validation du dépôt de destination. + cp "$PACKAGING_DIR/winget/$WINGET_VERSION_FILE.in" "$sandbox/winget/" + printf '%s' "$(cat "$PACKAGING_DIR/winget/$WINGET_VERSION_FILE.in")" > "$sandbox/winget/$WINGET_VERSION_FILE.in" + out=$("$sandbox/render.sh" --tag "$TAG" --checksums "$FIXTURE" --release-date "$RELEASE_DATE" --out "$(work out-nonl)" 2>&1) + check_rc 1 $? "un manifeste sans retour à la ligne final est refusé" + check_contains "$out" "retour à la ligne" "la raison est nommée" + + # Un gabarit amputé d'une cible : le compte d'empreintes le remarque, au lieu + # de pousser un instalteur qui ne dit plus rien de l'arm64. + cp "$PACKAGING_DIR/winget/$WINGET_VERSION_FILE.in" "$sandbox/winget/" + cp "$PACKAGING_DIR/winget/$WINGET_INSTALLER_FILE.in" "$sandbox/winget/" + sed -i '/^- Architecture: arm64/,/^ InstallerSha256: [0-9a-f]\{64\}$/d' \ + "$sandbox/winget/$WINGET_INSTALLER_FILE.in" + out=$("$sandbox/render.sh" --tag "$TAG" --checksums "$FIXTURE" --release-date "$RELEASE_DATE" --out "$(work out-one-arch)" 2>&1) + check_rc 1 $? "un gabarit amputé d'une cible est refusé" + check_contains "$out" "empreinte" "le compte d'empreintes est nommé" +} + +# --------------------------------------------------------------------------- +# Schémas officiels +# +# WinGet refuse en amont toute pull request dont un manifeste ne passe pas son +# JSON Schema. Le vérifier ici coûte deux secondes ; le faire dire par le dépôt +# coûte une release. +# --------------------------------------------------------------------------- +test_schemas() { + echo "Schémas officiels" + + local out dir + dir=$(work schemas) + out=$(render "$dir") + check_rc 0 $? "rendu de référence" + + out=$(python3 "$VALIDATE" "$dir" 2>&1) + local rc=$? + if [ "$rc" -eq 0 ]; then + ok "les manifestes rendus passent les schémas officiels" + elif [ "$rc" -eq 2 ]; then + printf ' – validation sautée : %s\n' "$(printf '%s' "$out" | tail -1)" + else + ko "les manifestes rendus passent les schémas officiels" "$out" + fi + + # Un manifeste cassé doit être rejeté, sinon ce test ne prouve rien. + local broken + broken=$(work schemas-broken) + cp -r "$dir/." "$broken/" + sed -i 's/^ InstallerSha256: .*/ InstallerSha256: pas-un-sha/' "$broken/winget/$WINGET_INSTALLER_FILE" + out=$(python3 "$VALIDATE" "$broken" 2>&1) + check_rc 1 $? "un manifeste invalide est rejeté" + check_contains "$out" "✗" "le manifeste fautif est nommé" + + # Hors ligne sans cache : ni échec ni faux positif — le contrôle est déclaré + # non fait, pas simulé. + out=$(TYPR_SCHEMA_CACHE="$(work no-schemas)" "$VALIDATE" --offline "$dir" 2>&1) + check_rc 2 $? "hors ligne sans cache, la validation est déclarée sautée" +} + +# --------------------------------------------------------------------------- +# Lecture par URL — le mode réel, celui des jobs `winget` et `scoop` +# +# Le serveur de fixtures est celui de install/tests : il sert une arborescence +# réelle, donc un vrai chemin `/releases/download//checksums.txt`. Réécrire +# un deuxième serveur pour le même besoin serait du poids inutile. +# --------------------------------------------------------------------------- +test_url_mode() { + echo "Lecture par URL" + if ! command -v python3 >/dev/null 2>&1; then + printf ' – python3 absent, mode URL non exercé\n' + return 0 + fi + + local server_root port out dir + server_root=$(work origin) + mkdir -p "$server_root/we-data-ch/typr/releases/download/$TAG" + cp "$FIXTURE" "$server_root/we-data-ch/typr/releases/download/$TAG/checksums.txt" + + python3 "$PACKAGING_DIR/../install/tests/fixture-server.py" "$server_root" \ + > "$server_root/.port" & + SERVER_PID=$! + + port="" + for _ in $(seq 1 50); do + port=$(grep -oE 'PORT [0-9]+' "$server_root/.port" 2>/dev/null | cut -d' ' -f2) + [ -n "$port" ] && break + sleep 0.1 + done + if [ -z "$port" ]; then + ko "le serveur de fixtures n'a pas démarré" + return 0 + fi + + # L'URL par défaut dérive du tag : c'est elle que les jobs de release + # utilisent, sans jamais l'écrire en dur. + dir=$(work url) + out=$(TYPR_ORIGIN="http://127.0.0.1:$port" "$RENDER" --tag "$TAG" --release-date "$RELEASE_DATE" --out "$dir" 2>&1) + check_rc 0 $? "l'URL de checksums.txt est déduite du tag" + check_contains "$(cat "$dir/winget/$WINGET_INSTALLER_FILE" 2>/dev/null)" "$(fixture_sha "$WIN_X86")" \ + "l'installateur WinGet rendu par URL a le bon sha256" + check_contains "$(cat "$dir/scoop/bucket/typr.json" 2>/dev/null)" "$(fixture_sha "$WIN_ARM")" \ + "le manifeste Scoop rendu par URL a le bon sha256" + + # 404 : le message doit nommer l'URL, pas laisser croire à un bug de rendu. + out=$(TYPR_ORIGIN="http://127.0.0.1:$port" "$RENDER" --tag v1.2.3 --release-date "$RELEASE_DATE" --out "$(work url-404)" 2>&1) + check_rc 1 $? "une release absente échoue" + check_contains "$out" "introuvable" "l'absence est nommée" +} + +# --------------------------------------------------------------------------- + +main() { + printf 'render.sh (WinGet + Scoop) — tests\n\n' + ROOT=$(mktemp -d) + + test_static_analysis + test_marker_drift + test_usage + test_render + test_platform_pitfalls + test_pin_and_prerelease + test_missing_line + test_template_drift + test_schemas + test_url_mode + + printf '\n%d réussis, %d échoués\n' "$PASSED" "$FAILED" + [ "$FAILED" -eq 0 ] +} + +main "$@" diff --git a/packaging/tests/validate-schemas.py b/packaging/tests/validate-schemas.py new file mode 100755 index 00000000..2a1e5697 --- /dev/null +++ b/packaging/tests/validate-schemas.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""TypR — validation des manifestes WinGet et Scoop contre les schémas officiels. + + packaging/tests/validate-schemas.py + +WinGet refuse en amont toute pull request dont un manifeste ne passe pas son +JSON Schema, et le dépôt `scoop` attire des manifests invalides de toutes +sortes. Valider ici, avant la publication, coûte deux secondes ; se faire refuser +par un dépôt coûte une release. + +Les schémas ne sont pas écrits dans ce dépôt : ce sont ceux de winget-pkgs et de +Scoop, telechargés une fois et mis en cache. `winget-pkgs` n'accepte que les +schemas qu'il publie lui-meme — un schema recopie dans notre dépôt deriverait +dès la version suivante, en validant moins que ce qui sera exige. + +Sorties : + 0 tout est valide + 1 au moins un manifeste est invalide + 2 validation impossible (PyYAML absent, schema indisponible hors ligne) + +Options : + --schemas cache des schemas (defaut : $TYPR_SCHEMA_CACHE) + --offline n'echoue pas si un schema manque, il saute le controle +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +SCOOP_SCHEMA_URL = ( + "https://raw.githubusercontent.com/ScoopInstaller/Scoop/master/schema.json" +) +WINGET_SCHEMA_TMPL = "https://aka.ms/winget-manifest.{kind}.{version}.schema.json" + +# WinGet nomme ses trois manifestes `version`, `installer`, `defaultLocale`. +# `locale` est l'ancien nom, encore accepté par le dépôt : on le tolère aussi, +# pour qu'une version de Winget plus ancienne n'explique pas le refus autrement. +WINGET_KIND_BY_TYPE = { + "version": "version", + "installer": "installer", + "defaultLocale": "defaultLocale", + "locale": "defaultLocale", +} + + +def default_cache() -> Path: + env = os.environ.get("TYPR_SCHEMA_CACHE") + if env: + return Path(env) + base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache") + return Path(base) / "typr-packaging" / "schemas" + + +def fetch(url: str, dest: Path, offline: bool) -> Path | None: + """Ramène le schéma `url` dans `dest`, ou None si impossible et hors ligne.""" + if dest.is_file(): + return dest + if offline: + print(f" (hors ligne) schéma absent du cache : {url}") + return None + dest.parent.mkdir(parents=True, exist_ok=True) + try: + with tempfile.NamedTemporaryFile( + dir=dest.parent, delete=False, suffix=".part" + ) as tmp: + tmp_path = Path(tmp.name) + subprocess.run( + ["curl", "-fsSL", url, "-o", str(tmp_path)], + check=True, + capture_output=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + detail = getattr(exc, "stderr", b"") or b"" + print( + f" téléchargement impossible : {url}\n" + f" {detail.decode(errors='replace').strip() or exc}", + file=sys.stderr, + ) + tmp_path.unlink(missing_ok=True) + return None + tmp_path.replace(dest) + return dest + + +def load_schema(url: str, cache: Path, offline: bool) -> dict | None: + dest = cache / url.rsplit("/", 1)[-1] + path = fetch(url, dest, offline) + if path is None: + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f" schéma illisible : {path} ({exc})", file=sys.stderr) + return None + + +def check(instance, schema: dict, label: str) -> bool: + """Valide une instance ; renvoie True si elle est valide.""" + try: + import jsonschema + except ImportError: + print(" jsonschema absent — pip install jsonschema", file=sys.stderr) + raise + + validator_cls = jsonschema.validators.validator_for(schema) + validator_cls.check_schema(schema) + errors = sorted( + validator_cls(schema).iter_errors(instance), + key=lambda e: list(getattr(e, "absolute_path", [])), + ) + if not errors: + print(f" ✓ {label}") + return True + print(f" ✗ {label}") + for err in errors[:20]: + where = "/".join(str(p) for p in err.absolute_path) or "(racine)" + print(f" {where} : {err.message}") + if len(errors) > 20: + print(f" … et {len(errors) - 20} autres") + return False + + +def validate_winget(path: Path, cache: Path, offline: bool) -> bool | None: + import yaml + + try: + doc = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + print(f" ✗ {path.name} : YAML illisible — {exc}") + return False + if not isinstance(doc, dict): + print(f" ✗ {path.name} : la racine n'est pas un objet") + return False + + kind = WINGET_KIND_BY_TYPE.get(doc.get("ManifestType", "")) + if kind is None: + print( + f" ✗ {path.name} : ManifestType inconnu " + f"({doc.get('ManifestType')!r})" + ) + return False + + version = str(doc.get("ManifestVersion", "")) + url = WINGET_SCHEMA_TMPL.format(kind=kind, version=version) + schema = load_schema(url, cache, offline) + if schema is None: + return None + return check(doc, schema, f"{path.name} ({kind} {version or '?'})") + + +def validate_scoop(path: Path, cache: Path, offline: bool) -> bool | None: + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f" ✗ {path.name} : JSON illisible — {exc}") + return False + schema = load_schema(SCOOP_SCHEMA_URL, cache, offline) + if schema is None: + return None + return check(doc, schema, path.name) + + +def main() -> int: + parser = argparse.ArgumentParser(add_help=True) + parser.add_argument("directory", type=Path) + parser.add_argument("--schemas", type=Path, default=None) + parser.add_argument("--offline", action="store_true") + args = parser.parse_args() + + try: + import yaml # noqa: F401 + except ImportError: + print( + "PyYAML absent : la validation des manifestes WinGet est sautée " + "(pip install pyyaml).", + file=sys.stderr, + ) + return 2 + + root = args.directory + if not root.is_dir(): + print(f"dossier introuvable : {root}", file=sys.stderr) + return 2 + + cache = args.schemas or default_cache() + offline = args.offline or bool(os.environ.get("TYPR_SCHEMA_OFFLINE")) + + winget = sorted((root / "winget").glob("*.yaml")) + scoop = sorted((root / "scoop").rglob("*.json")) + if not winget and not scoop: + print(f"aucun manifeste à valider sous {root}", file=sys.stderr) + return 2 + + results: list[bool | None] = [] + if winget: + print(f"WinGet — {len(winget)} manifeste(s)") + for path in winget: + results.append(validate_winget(path, cache, offline)) + if scoop: + print(f"Scoop — {len(scoop)} manifeste(s)") + for path in scoop: + results.append(validate_scoop(path, cache, offline)) + + failures = [r for r in results if r is False] + skipped = [r for r in results if r is None] + + if failures: + print(f"\n{len(failures)} manifeste(s) invalide(s).") + return 1 + if skipped: + print( + f"\n{len(skipped)} manifeste(s) non contrôlé(s) : schéma indisponible." + ) + return 2 + print(f"\n{len(results)} manifeste(s) valide(s).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/winget/we-data-ch.TypR.installer.yaml.in b/packaging/winget/we-data-ch.TypR.installer.yaml.in new file mode 100644 index 00000000..8deba9d8 --- /dev/null +++ b/packaging/winget/we-data-ch.TypR.installer.yaml.in @@ -0,0 +1,53 @@ +# TypR — manifeste d'installation WinGet. +# +# FICHIER GÉNÉRÉ — ne pas l'éditer à la main. +# +# Gabarit : packaging/winget/we-data-ch.TypR.installer.yaml.in (dépôt we-data-ch/typr) +# Généré par: packaging/render.sh --tag vX.Y.Z +# +# `zip` + `NestedInstallerType: portable` : l'archive publiée contient +# `typr.exe` à sa racine (release.yml, `Compress-Archive -Path typr.exe`), et +# WinGet en fait un shim `typr` sur le PATH. C'est la forme employée par les +# applications portables distribuées en zip dans winget-pkgs. +# +# Deux points où le plan de distribution se trompaient, et où il est utile que la +# réponse reste lisible ici : +# +# * `InstallerSwitches.InstallLocation` est un *argument d'installeur*, pas un +# dossier de destination. Un zip n'a pas d'installeur : il n'y a donc aucun +# moyen de demander à WinGet d'écrire dans +# `%LOCALAPPDATA%\Programs\typr`, comme le fait install.ps1. WinGet extrait +# dans son propre dossier de paquet et y met `typr` sur le PATH — sans +# droits administrateur, ce qui est le point qui compte. +# * `InstallerSwitches.Silent` n'a pas lieu d'être ici non plus : il n'y a pas +# d'interface à piloter. +# +# Chaque entrée porte son URL et son empreinte côte à côte : c'est la seule +# façon que l'une ne puisse pas désigner l'archive de l'autre. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json + +PackageIdentifier: we-data-ch.TypR +PackageVersion: @@VERSION@@ +InstallerType: zip +NestedInstallerType: portable +# `ReleaseDate` est écrit entre guillemets à dessein : lu par un analyseur YAML +# 1.1 — PyYAML, et tout ce qui l'imite — `2026-01-02` est une date, pas une +# chaîne, et le schéma de winget-pkgs exige une chaîne. Guillemeté, le même +# fichier est un `string` partout, sans dépendre de la version de la norme que +# l'outil de validation applique. +ReleaseDate: "@@RELEASE_DATE@@" +Installers: +- Architecture: x64 + NestedInstallerFiles: + - RelativeFilePath: typr.exe + PortableCommandAlias: typr + InstallerUrl: @@URL_X86_64_WINDOWS@@ + InstallerSha256: @@SHA256_X86_64_WINDOWS@@ +- Architecture: arm64 + NestedInstallerFiles: + - RelativeFilePath: typr.exe + PortableCommandAlias: typr + InstallerUrl: @@URL_AARCH64_WINDOWS@@ + InstallerSha256: @@SHA256_AARCH64_WINDOWS@@ +ManifestType: installer +ManifestVersion: 1.12.0 diff --git a/packaging/winget/we-data-ch.TypR.locale.en-US.yaml.in b/packaging/winget/we-data-ch.TypR.locale.en-US.yaml.in new file mode 100644 index 00000000..c873aad9 --- /dev/null +++ b/packaging/winget/we-data-ch.TypR.locale.en-US.yaml.in @@ -0,0 +1,43 @@ +# TypR — locale WinGet (en-US, celle que le manifeste de version désigne). +# +# FICHIER GÉNÉRÉ — ne pas l'éditer à la main. +# +# Gabarit : packaging/winget/we-data-ch.TypR.locale.en-US.yaml.in (dépôt we-data-ch/typr) +# Généré par: packaging/render.sh --tag vX.Y.Z +# +# `ShortDescription` est la même phrase que la `desc` de la formule Homebrew et +# que la `description` du manifeste Scoop : c'est ce que l'utilisateur lit dans +# `winget search`, dans `brew info` et dans `scoop info`. Trois fichiers, une +# seule phrase — la suite de tests compare les trois rendus. +# +# Pas de `Tags` à rallonge : ils servent au classement dans le Store, et une +# liste qui ne dit rien de l'outil coûte plus qu'elle ne rapporte. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json + +PackageIdentifier: we-data-ch.TypR +PackageVersion: @@VERSION@@ +PackageLocale: en-US +Publisher: we-data-ch +PublisherUrl: https://github.com/we-data-ch +PublisherSupportUrl: https://github.com/we-data-ch/typr/issues +PackageName: TypR +PackageUrl: https://github.com/we-data-ch/typr +License: Apache-2.0 +LicenseUrl: https://github.com/we-data-ch/typr/blob/main/LICENSE +ShortDescription: A typed superset of R — transpiler and type checker +Description: |- + TypR is a typed superset of R. It compiles to readable, stock R, with static + type checking, modules, sum types and interfaces — so that a wrong argument + type, an undocumented return or a silent coercion is reported before the code + ever runs. + The Windows binaries ship with the C runtime linked statically, so they start + on a freshly installed Windows 10 or 11 with no Visual C++ Redistributable. +Moniker: typr +Tags: +- cli +- r +- static-analysis +- transpiler +- types +ManifestType: defaultLocale +ManifestVersion: 1.12.0 diff --git a/packaging/winget/we-data-ch.TypR.yaml.in b/packaging/winget/we-data-ch.TypR.yaml.in new file mode 100644 index 00000000..50b0bad9 --- /dev/null +++ b/packaging/winget/we-data-ch.TypR.yaml.in @@ -0,0 +1,18 @@ +# TypR — manifeste de version WinGet. +# +# FICHIER GÉNÉRÉ — ne pas l'éditer à la main. +# +# Gabarit : packaging/winget/we-data-ch.TypR.yaml.in (dépôt we-data-ch/typr) +# Généré par: packaging/render.sh --tag vX.Y.Z +# +# Il ne contient que ce que WinGet sait de la version : ni URL, ni empreinte. +# La version est sans le « v » initial, que WinGet refuse, et vient du tag — le +# même que lit le job `verify` de release.yml, donc le même que tous les autres +# canaux. +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json + +PackageIdentifier: we-data-ch.TypR +PackageVersion: @@VERSION@@ +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.12.0 diff --git a/refined_types.md b/refined_types.md new file mode 100644 index 00000000..6c19c048 --- /dev/null +++ b/refined_types.md @@ -0,0 +1,1144 @@ +# Types raffinés — Spécification d’implémentation + +## 1. Statut + +Cette spécification définit l'implémentation des **types raffinés** dans TypR. Elle a pour but de remplacer les types dépendants dans le future. + +L'objectif est de permettre l'expression de contraintes supplémentaires sur des types existants sans créer un nouveau type structurel pour chaque combinaison de contraintes. + +Exemples : + +```typr +int +int & (> 0) + +[int] +[int] & length(5) +``` + +Le système doit également permettre, lorsque cela est possible, de transformer automatiquement les raffinements en **validations runtime dans le code R généré**. + +--- + +# 2. Modèle conceptuel + +Un type raffiné est une **intersection de type** entre un type de base et une ou plusieurs propriétés. + +```text +RefinedType(A, P₁, ..., Pₙ) +``` + +est représenté conceptuellement par : + +```text +A & P₁ & ... & Pₙ +``` + +où : + +* `A` est un type TypR ; +* `Pᵢ` est une propriété vérifiable ; +* `&` est l'opérateur général d'intersection de types. + +Exemple : + +```typr +[int] & length(5) +``` + +signifie : + +> une valeur de type `[int]` qui satisfait également la propriété `length(5)`. + +Le mécanisme de raffinement est donc **générique** et ne doit pas être implémenté comme une famille de types spéciaux (`VectorOfLength`, `PositiveInt`, etc.). + +--- + +# 3. Syntaxe + +## 3.1 Intersection + +La syntaxe générale est : + +```text +TypeExpr & TypeExpr +``` + +`&` est associatif et commutatif au niveau sémantique. + +Ainsi : + +```typr +A & B +B & A +``` + +désignent le même ensemble de valeurs. + +Une expression peut contenir plusieurs intersections : + +```typr +A & B & C +``` + +--- + +## 3.2 Raffinements + +Un raffinement est une expression reconnue comme une propriété applicable à un type. + +Exemples initiaux : + +```typr +int & (> 0) +int & (< 10) + +[int] & length(5) +``` + +La grammaire exacte des propriétés n'est pas imposée par cette spécification ; celles-ci sont définies par le registre interne des raffinements du compilateur. + +--- + +# 4. Sucre syntaxique des vecteurs + +La notation : + +```typr +[5, int] +``` + +doit être acceptée comme sucre syntaxique pour : + +```typr +[int] & length(5) +``` + +Le parser ou une phase de désucrage doit normaliser les deux formes vers la même représentation interne. + +Le type : + +```typr +[5, int] +``` + +ne doit donc pas créer un nouveau constructeur de type distinct. + +La forme canonique interne est : + +```text +Intersection( + Vector(Int), + Length(5) +) +``` + +--- + +# 5. Représentation de type + +L'AST/type representation doit représenter explicitement les intersections. + +Une représentation minimale est : + +```rust +enum Type { + ... + Intersection(Vec), + ... +} +``` + +Cependant, une représentation normalisée séparant type de base et propriétés est recommandée : + +```rust +struct RefinedType { + base: Type, + refinements: Vec, +} +``` + +avec : + +```rust +enum Refinement { + ... +} +``` + +Cette représentation est une optimisation sémantique et ne doit pas changer le comportement du langage. + +Le compilateur doit être capable de retrouver l'équivalent général : + +```text +A & P & Q +``` + +à partir de cette structure. + +--- + +# 6. Normalisation + +Toute intersection doit être normalisée avant les principales opérations du type checker. + +La normalisation doit : + +1. aplatir les intersections imbriquées ; +2. supprimer les doublons ; +3. rendre l'ordre des composants insignifiant ; +4. séparer les contraintes du type de base lorsque cela est possible ; +5. détecter les contradictions évidentes. + +Exemple : + +```typr +int & (> 0) & int +``` + +devient : + +```typr +int & (> 0) +``` + +Et : + +```typr +A & (B & C) +``` + +devient : + +```typr +A & B & C +``` + +--- + +# 7. Types et propriétés + +Une propriété ne constitue pas nécessairement un type indépendant utilisable partout. + +Elle représente une contrainte applicable à un type compatible. + +Exemple : + +```typr +[int] & length(5) +``` + +est valide car `[int]` possède la capacité nécessaire à l'évaluation de `length`. + +En revanche, une expression telle que : + +```typr +int & length(5) +``` + +doit être rejetée si `int` ne possède pas cette capacité. + +Le type checker doit donc vérifier la compatibilité : + +```text +Base type + ↓ +supports refinement? + ↓ +yes / no +``` + +--- + +# 8. Capacités internes + +Les propriétés doivent être associées à des **capacités internes** du compilateur. + +Exemples : + +```text +Lengthable +Comparable +Ordered +``` + +Ces capacités sont des concepts d'implémentation et ne constituent pas, dans cette première version, un mécanisme extensible par les utilisateurs. + +Exemples : + +```text +length(5) → nécessite Lengthable +(> 0) → nécessite Comparable / Ordered +(< 10) → nécessite Comparable / Ordered +``` + +Le système doit permettre au type checker de déterminer qu'un type possède la capacité requise. + +--- + +# 9. Sous-typage + +Un type raffiné est un sous-type du type qu'il raffine. + +```text +A & P <: A +``` + +Exemples : + +```text +int & (> 0) <: int + +[int] & length(5) <: [int] +``` + +De même : + +```text +A & P & Q <: A & P +``` + +Cette règle est fondamentale. + +Une fonction demandant : + +```typr +let f(x: [int]) = ... +``` + +doit accepter une valeur de type : + +```typr +[int] & length(5) +``` + +sans conversion. + +--- + +# 10. Relations entre raffinements + +Le type checker doit exposer au minimum trois opérations conceptuelles sur les raffinements : + +```text +compatible(P, Q) +implies(P, Q) +contradicts(P, Q) +``` + +Exemples : + +```text +length(5) compatible length(5) → true +length(5) contradicts length(10) → true +(> 10) contradicts (< 5) → true +(> 5) implies (> 0) → true +``` + +L'analyse exacte peut être limitée dans une première version. + +Lorsqu'une relation ne peut pas être déterminée statiquement, le compilateur doit conserver la contrainte plutôt que supposer qu'elle est vraie ou fausse. + +--- + +# 11. Contradictions + +Le type checker doit détecter les contradictions manifestes. + +Exemples : + +```typr +int & (> 10) & (< 5) +``` + +```typr +[int] & length(5) & length(10) +``` + +Ces types doivent être considérés comme inhabités. + +Le compilateur peut produire une erreur au moment de leur construction ou de leur utilisation. + +Une analyse complète des domaines numériques n'est pas requise dans la première implémentation. + +--- + +# 12. Raffinements sur les paramètres + +Les raffinements sont autorisés dans les types de paramètres. + +```typr +let f(x: int & (> 0)) = ... +``` + +Le corps de `f` peut considérer `x` comme satisfaisant le raffinement. + +Le compilateur ne doit pas générer une vérification supplémentaire à chaque utilisation de `x`. + +La vérification éventuelle est associée à l'entrée dans le contexte où le contrat est requis. + +--- + +# 13. Raffinements sur les valeurs de retour + +Les raffinements sont également autorisés sur les types de retour. + +```typr +let f(): [int] & length(5) = ... +``` + +Le compilateur doit vérifier que la valeur produite par le corps satisfait le type de retour. + +Lorsque cette propriété ne peut pas être démontrée statiquement, une validation runtime doit être générée. + +--- + +# 14. Inférence + +Le type checker doit conserver les raffinements connus statiquement. + +Exemple : + +```typr +let x: [int] & length(5) = ... +``` + +`x` possède ce type dans l'environnement de typage. + +La version initiale doit également permettre l'inférence de raffinements structurels lorsqu'ils sont déterminables sans analyse complexe. + +Exemple : + +```typr +let x = [1, 2, 3, 4, 5] +``` + +peut être typé : + +```text +[int] & length(5) +``` + +Cette capacité doit être conçue comme une optimisation de l'information disponible et ne doit pas être nécessaire à la correction du système. + +--- + +# 15. Préservation des raffinements + +Les opérations doivent déclarer leur effet sur les raffinements. + +Une opération peut : + +```text +preserve +produce +weaken +invalidate +``` + +une propriété. + +Exemple : + +```typr +let x: [int] & length(5) +let y = x +``` + +Le type de `y` conserve : + +```text +[int] & length(5) +``` + +À l'inverse : + +```typr +let z = x[1] +``` + +produit : + +```text +int +``` + +et ne conserve pas `length(5)`. + +--- + +# 16. Opérations produisant de nouveaux raffinements + +Lorsqu'une opération permet de déduire statiquement une nouvelle propriété, le type checker doit pouvoir l'ajouter au type résultat. + +Exemple : + +```typr +let x: [int] & length(5) +let y = x[1:3] +``` + +peut être typé : + +```text +[int] & length(3) +``` + +si la sémantique de l'opération garantit cette longueur. + +Cette fonctionnalité doit être implémentée de façon déclarative autant que possible. + +--- + +# 17. Invalidations + +Une opération dont le résultat ne permet plus de garantir un raffinement doit supprimer celui-ci du type statique. + +Exemple conceptuel : + +```text +x : [int] & length(5) + +operation(x) + +result : [int] +``` + +si `operation` ne garantit aucune longueur. + +Il est interdit au type checker de conserver automatiquement un raffinement qu'une opération peut rendre faux. + +--- + +# 18. Vérification statique + +Lorsqu'un raffinement peut être prouvé au compile-time, aucune validation runtime correspondante ne doit être générée. + +Exemple : + +```typr +let x: [int] & length(5) = [1,2,3,4,5] +``` + +Le compilateur peut déduire : + +```text +length(x) = 5 +``` + +Aucun test supplémentaire n'est requis dans le code R généré. + +--- + +# 19. Vérification runtime + +Lorsqu'un raffinement est requis mais ne peut pas être prouvé statiquement, le compilateur doit générer une validation runtime. + +Exemple : + +```typr +let f(x: [int] & length(5)) = ... +``` + +Si `x` provient d'une source dynamique, le code généré doit effectuer une vérification équivalente à : + +```r +if (length(x) != 5) { + stop(...) +} +``` + +La forme exacte du code R généré reste une décision de codegen. + +--- + +# 20. Principe "check at boundary" + +Une propriété doit être vérifiée au point où elle devient nécessaire, plutôt qu'à chaque utilisation. + +Exemple : + +```text +source dynamique + ↓ +validation length(5) + ↓ +x : [int] & length(5) + ↓ +plusieurs utilisations sans nouvelle validation +``` + +Une fois la vérification effectuée dans un contexte donné, le type checker peut considérer la propriété comme établie dans ce contexte. + +Le compilateur doit éviter les validations redondantes lorsqu'il dispose déjà d'une preuve ou d'une vérification antérieure encore valide. + +--- + +# 21. Génération des validations + +Chaque `Refinement` doit fournir au codegen les informations nécessaires pour générer sa validation. + +Conceptuellement : + +```rust +trait RefinementCodegen { + fn check( + &self, + value: ValueRef, + context: &CodegenContext, + ) -> Expr; +} +``` + +Exemple : + +```text +Length(5) + ↓ +length(value) == 5 +``` + +et : + +```text +GreaterThan(0) + ↓ +value > 0 +``` + +Le code généré doit être idiomatique R autant que possible. + +--- + +# 22. Origine des validations + +Une validation runtime ne doit être générée que pour une contrainte effectivement imposée par le programme. + +Le compilateur ne doit pas instrumenter arbitrairement toutes les valeurs avec toutes les propriétés connues. + +Exemple : + +```typr +let x: [int] = ... +``` + +ne doit pas déclencher de vérification de longueur. + +En revanche : + +```typr +let y: [int] & length(5) = x +``` + +doit déclencher une validation si aucune preuve statique ne permet de garantir `length(x) == 5`. + +--- + +# 23. Raffinements et coercions + +Une valeur ne doit pas automatiquement être considérée comme raffinée uniquement parce qu'elle possède le type de base. + +Ainsi : + +```text +[int] +``` + +n'implique pas : + +```text +[int] & length(5) +``` + +Une conversion vers un type raffiné doit donc constituer un **site de preuve ou de validation**. + +Conceptuellement : + +```text +[int] + ↓ +check + ↓ +[int] & length(5) +``` + +--- + +# 24. Narrowing / raffinement par condition + +La conception doit permettre ultérieurement le raffinement d'un type à la suite d'une condition. + +Exemple : + +```typr +if length(x) == 5 { + ... +} +``` + +À l'intérieur du bloc, le compilateur pourrait établir : + +```text +x : [int] & length(5) +``` + +Cette fonctionnalité n'est pas obligatoire pour la première version, mais la représentation des raffinements doit être conçue pour la supporter. + +--- + +# 25. Génériques + +Les raffinements doivent être compatibles avec les types génériques. + +Exemple cible : + +```typr +let first(x: [T] & length(> 0)): T = ... +``` + +La validation de ce type nécessite que le système puisse déterminer les capacités dont dépend le raffinement. + +La version initiale peut limiter les raffinements génériques à ceux dont les capacités sont connues statiquement. + +--- + +# 26. Types composites + +Les raffinements ne doivent pas être limités aux vecteurs. + +Le mécanisme doit être applicable à tout type pour lequel la propriété possède une sémantique. + +Exemples futurs : + +```typr +string & length(10) + +int & (> 0) + +Record & some_property(...) +``` + +Les raffinements spécifiques à un domaine ne doivent donc pas être codés directement dans le système général des vecteurs. + +--- + +# 27. Interaction avec les fonctions R + +Pour chaque fonction ou opérateur connu du compilateur, les informations de typage doivent pouvoir préciser : + +```text +arguments consommés +type résultat +raffinements produits +raffinements préservés +raffinements invalidés +``` + +Cela est particulièrement important pour les opérations vectorielles et le mécanisme de lifting de TypR. + +Exemple conceptuel : + +```text +length + [T] → int + +slice + [T] & length(N), range(a,b) + → [T] & length(...) +``` + +Les fonctions externes dont les effets ne sont pas connus doivent être traitées de manière conservatrice. + +--- + +# 28. Interaction avec `c()` + +`c()` et les autres opérations de combinaison de vecteurs doivent être considérées comme potentiellement génératrices de nouveaux raffinements. + +Par exemple : + +```typr +let x = c([1,2], [3,4,5]) +``` + +peut éventuellement être inféré comme : + +```text +[int] & length(5) +``` + +si les longueurs sont connues. + +La conservation des raffinements doit cependant être dépendante de la sémantique réelle de l'opération et non d'une règle générale supposée. + +--- + +# 29. Diagnostic statique + +Les erreurs de typage doivent afficher les raffinements sous une forme lisible. + +Exemple : + +```text +Type mismatch + +Expected: + [int] & length(5) + +Found: + [int] & length(3) +``` + +Pour une contradiction : + +```text +Unsatisfiable type refinement: + +[int] & length(5) & length(10) +``` + +Pour une propriété incompatible : + +```text +Invalid refinement: + +length(5) cannot be applied to int +``` + +--- + +# 30. Diagnostic runtime + +Les validations générées doivent produire des messages permettant d'identifier : + +1. la propriété attendue ; +2. la valeur observée lorsque cela est pertinent ; +3. le point du programme où le contrat a été violé. + +Exemple : + +```text +Type refinement violation: +expected vector length 5, got 3 +``` + +Le format exact du message peut être défini indépendamment du type checker. + +--- + +# 31. Erreurs de vérification + +Lorsqu'une propriété est impossible à établir statiquement, le compilateur ne doit jamais transformer silencieusement l'incertitude en vérité. + +Il existe trois états conceptuels : + +```text +Proven true +Proven false +Unknown +``` + +`Unknown` déclenche une validation runtime lorsqu'une garantie est nécessaire. + +`False` produit une erreur statique lorsque la contradiction est certaine. + +--- + +# 32. Phases d'implémentation + +L'implémentation dans `typr-core` doit suivre au minimum les étapes suivantes : + +```text +Parser + ↓ +AST + ↓ +Desugaring + ↓ +Type normalization + ↓ +Refinement validation + ↓ +Subtype checking + ↓ +Refinement propagation + ↓ +Static proof / constraint checking + ↓ +Runtime-check insertion + ↓ +R code generation +``` + +Le codegen ne doit pas avoir à comprendre la logique complète des raffinements : les décisions de typage et les points de validation doivent être déterminés auparavant. + +--- + +# 33. Invariants du type checker + +Le type checker doit garantir les invariants suivants. + +### Invariant 1 + +Une intersection est indépendante de l'ordre de ses composants. + +```text +A & B = B & A +``` + +### Invariant 2 + +Une intersection redondante est normalisée. + +```text +A & A = A +``` + +### Invariant 3 + +Un raffinement est toujours associé à un type compatible. + +### Invariant 4 + +Un type raffiné est toujours un sous-type de son type de base. + +### Invariant 5 + +Une propriété statiquement prouvée ne nécessite pas de validation runtime. + +### Invariant 6 + +Une propriété inconnue requise au runtime doit provoquer une validation. + +### Invariant 7 + +Une propriété invalidée par une opération ne peut pas être conservée dans le type résultant. + +--- + +# 34. Tests obligatoires + +L'implémentation doit inclure des tests pour au minimum : + +```text +int & (> 0) + +[int] & length(5) + +[5, int] + +A & B == B & A + +A & A == A + +(A & B) & C == A & B & C + +[int] & length(5) <: [int] + +[int] & length(5) & length(10) → contradiction + +[int] → [int] & length(5) → runtime check + +statically provable refinement → no runtime check + +refinement invalidation + +refinement propagation + +parameter refinement + +return refinement +``` + +Des tests de codegen doivent vérifier à la fois : + +```text +correctness of generated R +absence of unnecessary checks +presence of required checks +``` + +--- + +# 35. Première implémentation recommandée + +La première version du système doit rester volontairement limitée. + +Elle doit fournir : + +```text +& +intersection de types + +length(N) +longueur exacte + +> +< +comparaisons numériques simples + +normalisation +déduplication +contradictions évidentes + +subtyping +A & P <: A + +runtime validation +génération automatique des checks R +``` + +La première version ne doit pas chercher à construire un solveur SMT ou un système de preuve général. + +--- + +# 36. Extensions futures + +Le système doit être conçu pour pouvoir accueillir ultérieurement : + +```text +length(> 0) +length(< 100) + +>= +<= + +membership +x ∈ Set + +regex / string constraints + +numeric ranges + +nullability constraints + +data-frame shape constraints + +column constraints + +refinements dépendant de champs + +flow-sensitive refinement + +generic dependent refinements +``` + +Ces extensions ne doivent pas nécessiter de modifier le principe fondamental : + +```text +base type & property +``` + +--- + +# 37. Exemple complet + +Code TypR : + +```typr +type Coordinates = [float] & length(2) + +let distance( + a: Coordinates, + b: Coordinates +): float = ... +``` + +Une valeur déjà connue : + +```typr +let a: Coordinates = [1.0, 2.0] +``` + +peut être prouvée statiquement. + +Une valeur dynamique : + +```typr +let a: [float] = read_coordinates() +let d = distance(a, b) +``` + +nécessite une validation avant l'appel : + +```text +a : [float] + +distance expects: + [float] & length(2) + +therefore: + insert runtime check +``` + +Le R généré pourra conceptuellement contenir : + +```r +if (length(a) != 2) { + stop("Type refinement violation: expected length 2") +} + +distance(a, b) +``` + +Le corps de `distance` peut ensuite manipuler `a` en supposant : + +```text +a : [float] & length(2) +``` + +sans réintroduire la vérification à chaque accès. + +--- + +# 38. Principe architectural final + +Les types raffinés de TypR doivent être implémentés comme une **extension du système d'intersection existant**, et non comme un système de types parallèle. + +Le cœur du modèle est : + +```text +Type + = +Base Type + & +Properties +``` + +Par conséquent : + +```typr +[int] +``` + +reste un type ordinaire, + +```typr +[int] & length(5) +``` + +est une intersection, + +et : + +```typr +[5, int] +``` + +est uniquement une notation abrégée. + +Le compilateur a ensuite la responsabilité de déterminer, pour chaque propriété : + +```text +Est-elle compatible avec le type ? + ↓ +Est-elle déjà prouvée ? + ↓ +Peut-elle être déduite ? + ↓ +A-t-elle été invalidée ? + ↓ +Faut-il générer une validation R ? +``` + +Cette architecture permet à TypR d'utiliser progressivement les raffinements comme **contrats statiques et runtime**, tout en conservant un système de types simple, composable et compatible avec la nature incrémentale de TypR. diff --git a/refined_types_plan.md b/refined_types_plan.md new file mode 100644 index 00000000..4f976ae0 --- /dev/null +++ b/refined_types_plan.md @@ -0,0 +1,251 @@ +# Types raffinés — plan d'implémentation + +Plan pour `refined_types.md`, écrit après lecture de `typr-core` (état au 2026-09-29, branche `develop`). + +--- + +## 0. Ce qui existe déjà (et ce qui change la donne) + +L'analyse du code montre que plusieurs pièces de la spec existent déjà, sous une autre forme. + +| Élément de la spec | État actuel dans le code | +|---|---| +| `&` dans les types | Parsé (`parsing/types.rs:1062`) en `Type::Operator(TypeOperator::Intersection, ..)`, mais **limité aux Records/Interfaces** (`type_arithmetic.rs::accepts_record_kind`, `norm_intersection`). `int & int` échoue. | +| Aplatissement, commutativité, déduplication | `IntersectionType` (`components/type/intersection_type.rs`) aplatit l'arbre binaire dans un `HashSet`. L'égalité `A & B == B & A` est déjà en place (`type/mod.rs:1229`). | +| `length(N)` sur les vecteurs | **La longueur est déjà dans le type** : `Type::Vec(VecType, index, elem, h)`. `[5, int]` donne `Vec(S3, Integer(5), int)` et `[int]` donne `Vec(S3, Any, int)`. | +| `[5,int] <: [int]` | Fonctionne déjà (`Integer(5) <: Any`, `type/mod.rs:221`). | +| Contradiction `length(5)` / `length(3)` | Déjà une erreur statique : `let x: [5, int] <- [1,2,3]` est rejeté. | +| Inférence `[1,2,3,4,5] : [5, int]` (§14) | Déjà en place. | +| `c()` produit une longueur (§28) | Déjà en place : `c([1,2],[3,4,5])` a le type `Vec[5, int]`, `c(x, x)` a le type `Vec[10, int]`. | +| Préservation par opération vectorisée | `x + 1` garde `[5, int]` (lifting, `Type::lift`). | +| Validations runtime | Seulement en mode test `--checked` (`transpiling/checked_assertions.rs`, `typr_assert_type`). Elles contrôlent la classe, pas la longueur. | + +Les sondes faites contre le binaire actuel ont aussi révélé des défauts à corriger en chemin : + +- **`int & (> 0)` fait paniquer le parseur** (`parsing/types.rs:974`, `compute_operators` → `_ => panic!()`), parce que `>` est lu comme un `Op` dans `index_algebra`. +- `[int] & length(5)` est parsé en `[any, int] & length` : `length` devient un `Type::Variable` et le `(5)` est perdu. +- `x[1:3]` et `x[x > 2]` sont typés **`int`** alors que ce sont des vecteurs. C'est un bug existant, et c'est précisément la propagation dont parlent les §15-16. +- `[int]` passé là où `[3, int]` est attendu est **une erreur statique** aujourd'hui. La spec (§22-23) en fait un site de validation runtime : **c'est un changement de comportement**, voir D3. + +--- + +## 1. Décisions de conception + +### D1. Représentation : un variant `Refined` plus un variant transitoire `Property` + +```rust +// components/type/mod.rs — à ajouter À LA FIN de l'enum (voir §Risques : sérialisation .bin) +Refined(Box, RefinementSet, HelpData), // base & propriétés, forme normalisée +Property(Refinement, HelpData), // sortie du parseur uniquement ; ne doit jamais survivre à reduce_type +``` + +C'est la forme « `RefinedType { base, refinements }` » que recommande la spec au §5. On n'étend pas `Operator(Intersection)` à la place, pour deux raisons : + +- dans le code actuel, `&` sur des Records veut dire *fusion structurelle*, pas prédicat ; +- un arbre binaire ne donne ni l'ordre canonique ni la détection des contradictions sans renormaliser à chaque fois. + +Le parseur continue de produire `Operator(Intersection, base, Property(p))`. C'est `norm_intersection` qui les replie en `Refined`. + +### D2. `RefinementSet` : raisonner par *mesures* et *intervalles* + +Plutôt qu'une liste de propriétés à comparer deux à deux, on normalise chaque propriété sur une **mesure** munie d'un **intervalle** : + +```rust +// components/type/refinement.rs (nouveau) +pub enum Measure { Value, Length } // extensible : Nchar, Nrow, Ncol... +pub struct Interval { lo: Bound, hi: Bound } // Bound = Open(c) | Closed(c) | Unbounded +pub struct RefinementSet(BTreeMap); // ordonné → Hash/Eq déterministes + +pub enum Refinement { // forme de surface (parse / affichage) + Length(i32), // length(5) → Length ∈ [5,5] + Gt(Lit), Lt(Lit), // (> 0) → Value ∈ (0, +∞) +} +``` + +Avec cette forme, les exigences de la spec tombent d'elles-mêmes : + +| Exigence | Traduction | +|---|---| +| dédup, commutativité, associativité (§6, invariants 1-2) | intersection d'intervalles par mesure | +| `contradicts(P,Q)` (§10-11) | intervalle vide (`(>10)&(<5)`, `length(5)&length(10)`) | +| `implies(P,Q)` | inclusion d'intervalles (`(>5)` ⊂ `(>0)`) | +| `compatible(P,Q)` | intersection non vide | +| extensions §36 (`length(> 0)`, `>=`, plages) | autres bornes sur le même modèle, sans changer le principe | +| narrowing §24 | on intersecte l'intervalle de la condition | + +Bonus : sur `int`, on peut repérer `int & (> 0) & (< 1)` comme vide, en arrondissant les bornes aux entiers. + +Il faut une représentation hachable des constantes numériques (`Type: Hash + Eq`). On réutilise celle de `Tnum` plutôt qu'un `f64` brut. + +### D3. Trois états, et `is_subtype` reste strict + +`is_subtype` est un booléen mis en cache (`SUBTYPE_CACHE`) et utilisé partout : dispatch, unification, sélection de surcharge. **Il ne doit répondre `true` que pour `Proven`.** L'état `Unknown` passe par une fonction séparée, appelée uniquement aux frontières : + +```rust +// processes/type_checking/refinement_check.rs (nouveau) +pub enum Proof { Proven, Refuted, Unknown } +pub enum Coercion { Static, Runtime(RefinementSet /* résidu non prouvé */), Reject } +pub fn coerce_to(found: &Type, expected: &Type, ctx: &Context) -> Coercion +``` + +La règle : `coerce_to` renvoie `Runtime(résidu)` si `base(found) <: base(expected)` et que le résidu est `Unknown`. Il renvoie `Reject` si le résidu est `Refuted`. + +Si on laissait `is_subtype` devenir tolérant, un `[int]` sélectionnerait une surcharge `[5, int]`, et le code deviendrait insoundable sans que rien ne le signale. + +### D4. La longueur des vecteurs reste dans l'index de `Type::Vec`, pour l'instant + +La spec (§4) veut la forme canonique `Intersection(Vector(Int), Length(5))`. On fait **l'inverse en v1** : `[int] & length(5)` se normalise en `Vec(S3, Integer(5), int)`. Une vue `refinements_of(&Type) -> RefinementSet` relit la longueur depuis l'index. + +Pourquoi : +- `Type::Vec(` apparaît dans **135 motifs** répartis sur 20 fichiers ; +- le lifting vectoriel (`Type::lift`) et les génériques d'index `#N` (`[#N, T]`, arithmétique `#N+1`) dépendent tous de ce slot ; +- une migration en une fois casserait les 868 tests pour un gain nul en v1. + +Le §5 l'autorise explicitement (« optimisation sémantique, ne doit pas changer le comportement »), à condition de retrouver `A & P` à partir de la structure : c'est le rôle de `refinements_of`. + +Les intervalles de longueur non ponctuels (`length(> 0)`, futur) iraient dans le `RefinementSet` d'un `Refined(Vec(S3, Any, T), …)`. La migration complète vers `Length` est la phase 9, optionnelle. + +### D5. Les obligations runtime sont décidées au typage, pas au codegen (§32) + +Quand `coerce_to` renvoie `Runtime(résidu)`, le type checker enregistre une **obligation** dans le `Context` : une table latérale indexée par la position (`HelpData`) de l'expression, sur le modèle de `type_recorder.rs`. Le transpileur se contente de la consommer et ne raisonne jamais sur les raffinements. + +Ensuite, la variable entre dans l'environnement avec le type raffiné. C'est le « check at boundary » du §20 : aucune nouvelle vérification aux usages suivants. + +### D6. Forme du R généré + +Le public cible est constitué de développeurs R qui lisent le code généré. Il faut donc que ce soit **court, lisible et inconditionnel** (hors du mode `--checked`). Je recommande de petits helpers du prélude qui renvoient la valeur, pour qu'ils fonctionnent aussi en position d'expression (arguments, `return`) : + +```r +a <- typr_refine_length(read_coordinates(), 2L, "main.ty:12") +distance(typr_refine_length(a, 2L, "main.ty:14"), b) +``` + +Le message produit est `Type refinement violation at main.ty:14: expected length 2, got 3` (§30). + +Pour les comparaisons, le test est `isTRUE(all(x > 0))`, qui est sûr face aux `NA` et aux vecteurs. Une variante « `if (...) stop(...)` inline » reste possible pour les `let`. Le choix est à trancher, voir Q4. + +--- + +## 2. Phases + +Chaque phase laisse `cargo test --workspace` et `typr case run` au vert. + +### Phase 0 — Préparation +- Commiter d'abord le travail en cours sur les angles morts (cas 0068-0072). Il touche `type/mod.rs`, `parsing/types.rs` et `type_checking/mod.rs`, exactement les fichiers de la phase 1, et mélanger les deux diffs serait pénible. +- Trancher les questions ouvertes (§3 ci-dessous), surtout Q1 à Q3. + +### Phase 1 — Modèle (`typr-core/components/type`) +- Créer `refinement.rs` avec `Measure`, `Interval`, `RefinementSet`, `Refinement`, et les opérations `meet`, `is_empty`, `implies`, `compatible`, `contradicts`. +- Ajouter `Type::Refined` et `Type::Property` à la fin de l'enum. Compléter les `match` exhaustifs dans `typr-core`, `typr-graph`, `typr-lsp` et `typr-wasm` : le compilateur les listera. +- Ajouter `refinements_of(&Type)`. Elle lit l'index `Vec` → `Length`, les littéraux `Integer(Val(3))`/`Number` → `Value ∈ [3,3]`, et `Refined` → son ensemble. +- Mettre à jour `type_category.rs` (catégorie `Refined`) et `type_printer.rs` : affichage `[int] & length(5)` et `int & (> 0)`. +- **Tests unitaires** : l'algèbre des intervalles, plus les lignes « A&B==B&A », « A&A==A », « (A&B)&C » et « contradiction » du §34. + +### Phase 2 — Parser et syntaxe +- Dans `parsing/types.rs`, ajouter un parseur `refinement_property`, essayé **avant** `parenthese_value` et `type_variable` dans `single_type` : + - `(` `>`|`<` littéral `)` → `Type::Property(Gt/Lt)` + - `length(` entier `)` → `Type::Property(Length)` +- Corriger la panique de `compute_operators` (erreur de parse au lieu de `panic!`) et ajouter un cas `cases/` pour `int & (> 0)`. +- `[5, int]` ne change pas : c'est déjà la forme de stockage d'après D4. +- **Manifeste de syntaxe** (`components/syntax/mod.rs`) : déclarer `length` comme mot de type et `>`/`<` en position de type. Les deux tests d'invariant l'exigent. Ensuite, lancer `typr syntax --write` pour régénérer la grammaire tmLanguage. +- ⚠️ Côté `typr.github.io`, `scripts/syntax-glossary.mjs` doit recevoir la glose des nouveaux lexèmes, sinon le build de la doc casse. Le changement doit être fait **en même temps** dans les deux dépôts. + +### Phase 3 — Normalisation et validation (`type_arithmetic.rs`) +Étendre `norm_intersection` : +- `(X, Property p)` → `Refined(X, {p})` après le contrôle de capacité. Même chose en symétrique. +- `(Refined(A,ps), Property q)` → `Refined(A, ps ⊓ q)`. +- `(Refined(A,ps), Refined(B,qs))` → `Refined(norm(A&B), ps ⊓ qs)`. +- `(Vec(_, Any, T), Length n)` → `Vec(_, n, T)` (D4). Si `(Vec(_, m, T), Length n)` avec `m ≠ n`, c'est une contradiction. +- Littéral + propriété : `3 & (> 0)` → `3` si prouvé, contradiction si réfuté. +- Un ensemble vide donne l'erreur **`UnsatisfiableRefinement`** (§11, §29). +- Un `Property` seul qui survit à la réduction donne une erreur « une propriété n'est pas un type ». + +Les **capacités** (§8) passent par une simple fonction `capabilities(base) -> {Lengthable, Ordered}` : +- `Vec` → Lengthable ; +- `Integer`/`Number` → Ordered ; +- générique non contraint → rien en v1. + +Si la capacité manque, on lève **`InvalidRefinement("length(5)", int)`**. + +Le cas `&` sur des Records reste inchangé. + +Ajouter les nouveaux variants dans `error_message/type_error.rs` avec leurs codes `T0xx` (utilisés par le MCP) et leurs messages au format du §29. + +`type Coordinates = [num] & length(2)` fonctionne sans travail spécifique, par la réduction des alias. + +### Phase 4 — Sous-typage (`type/mod.rs::is_subtype_raw`) +- `Refined(A, ps) <: B` si `A <: B` (invariant 4, §9). +- `A <: Refined(B, qs)` si `A <: B` et `decide(refinements_of(A), qs) == Proven`. +- `Refined(A, ps) <: Refined(B, qs)` si `A <: B` et `ps implies qs`. +- Placer ces règles **avant** les règles génériques `Operator(Intersection, ..)` existantes, qui feraient sinon `t1 <: typ || t2 <: typ` sur un `Property`. +- Vérifier l'unification (`unification.rs`) : un générique `T` lié à un argument raffiné prend le type raffiné complet (paramétricité, donc sûr). En revanche, les opérateurs arithmétiques et les fonctions non annotées renvoient le **type de base**. L'invariant 7 est ainsi garanti par construction. + +### Phase 5 — Frontières et obligations (§12, 13, 19-23) +- Créer `refinement_check.rs` avec `coerce_to` (D3) et la table d'obligations dans le `Context` (D5). +- Brancher `coerce_to` là où un type attendu rencontre un type trouvé : + - `let_expression.rs` : annotation de `let` ; + - `function_application.rs` : arguments, côté appelant (§37) ; + - `function.rs` : type de retour ; + - les constructeurs de record, dans un second temps. +- Paramètres : à l'intérieur du corps, `x` a le type raffiné sans aucune vérification (§12). +- **Changement de comportement** : `[int]` → `[3, int]` passe d'une erreur statique (T001/T002) à une vérification runtime. Il faut repérer les tests et `cases/` qui attendaient l'erreur (`typr case run` les signalera en REGRESS) et les réécrire comme témoins de l'absence ou de la présence de vérification. + +### Phase 6 — Codegen (`transpiling/`) +- Écrire le module `transpiling/refinement_checks.rs`. Il consomme les obligations et génère les appels du prélude (D6). Chaque `Measure` sait produire son test (§21) : `Length` → `length(x) == n`, `Value` → `x > c` / `x < c`. +- Ajouter les helpers `typr_refine_*` au prélude R, là où vit `typr_assert_type`. +- Aucune vérification n'est émise quand l'obligation est absente (invariants 5 et 6, §18, §22). +- Plus tard, en v1.1 (Q3) : un prologue de vérification pour les fonctions **exportées** d'un package, puisque l'appelant y est du R non typé. + +### Phase 7 — Propagation et invalidation (§15-17, §27-28) +- Corriger au passage `x[1:3]` et `x[x > 2]`, aujourd'hui typés `int`. Ils doivent donner respectivement `[3, int]` (si les bornes sont littérales, sinon `[int]`) et `[int]`. +- `length(x)` avec `x : [5, int]` doit donner le type littéral `5`, ce qui prépare le narrowing. +- Règle par défaut, conservatrice : une fonction dont l'effet n'est pas déclaré renvoie son type de retour déclaré, **sans** raffinement. +- Les effets déclaratifs (preserve/produce/invalidate) sont d'abord codés en dur pour une poignée de primitives (`[`, `:`, `c`, `length`, `rev`, `head`). Une syntaxe d'annotation dans `configs/std/*.ty` pourra venir plus tard. + +### Phase 8 — Tests, cas, documentation +- Les **tests unitaires** couvrent les 14 lignes du §34. +- Côté **`cases/`**, prévoir un cas par règle de codegen. Le format `expect.toml` (des règles sur des sous-chaînes) convient bien à « présence de check » et à « absence de check inutile » : lire `cases/README.md` pour la règle d'absence. Au minimum : + - un littéral prouvé ne produit pas de check ; + - `[int]` → `[5,int]` produit un check ; + - raffinement de paramètre ; + - raffinement de retour ; + - contradiction ; + - capacité invalide ; + - `int & (> 0)` (la panique actuelle). +- Mettre à jour `syntaxe.md`, dans ses **deux** copies, et la page de référence des types dans `typr.github.io` (Diátaxis : reference, plus une explication dans `concepts/`). Les exemples doivent passer `typr check`, car la CI `check:examples` les vérifie. +- Lancer `typr std` si les signatures du std changent (effets de la phase 7). + +### Phase 9 — Plus tard (hors v1) +- Narrowing par condition (§24) : `if (length(x) == 5)` intersecte l'intervalle dans la branche. Le modèle par intervalles le permet directement. +- Génériques `[T] & length(> 0)` (§25) et bornes d'intervalle sur `Length`. +- Migration de l'index de `Type::Vec` vers `Measure::Length`, et remplacement des types dépendants `#N` : c'est l'objectif final annoncé par la spec au §1. + +--- + +## 3. Questions ouvertes sur la spec + +1. **`string & length(10)`** (§26) : en R, `length("abc")` vaut `1`. Parle-t-on de `nchar` ? Si oui, il faut une mesure distincte (`Nchar`), sans quoi le raffinement serait trompeur pour un dev R. +2. **`(> 0)` sur un vecteur** : `[int] & (> 0)` veut-il dire « tous les éléments » ? D'ailleurs un `int` TypR est un vecteur R de longueur 1. Je propose le sens « tous », vérifié par `isTRUE(all(x > 0))`, ce qui règle aussi le cas `NA`. +3. **Où placer les vérifications de paramètres** : côté appelant (§37) ou dans le prologue de l'appelé ? Je recommande l'appelant pour le code TypR (on évite le check quand c'est prouvé, invariant 5), plus un prologue pour les fonctions exportées (appels depuis du R non typé). +4. **Le narrowing implicite avec vérification runtime** (`[int]` → `[5,int]` accepté puis vérifié) remplace une erreur statique actuelle. Veut-on un avertissement, ou une option pour exiger une conversion explicite ? +5. **Forme canonique du §4** : la v1 stocke la longueur dans `Type::Vec` (D4). Il faut confirmer que c'est acceptable tant que `refinements_of` redonne `A & length(N)`. +6. **Bornes `num` vs `int`** : `int & (> 0.5)` doit-il être accepté (équivalent à `>= 1`) ou rejeté ? + +--- + +## 4. Risques + +- **Les `.bin` du std sont sérialisés** (`serde`) : ajouter les variants **à la fin** de `enum Type` pour garder les indices, puis lancer `typr std`. +- **Le cache de sous-typage** : `Refined` doit avoir un `Hash`/`Eq` canonique (d'où le `BTreeMap`), sinon `A&B` et `B&A` occupent deux entrées différentes. +- **Récursion** : la normalisation passe par `reduce_type`. Garder `RUST_MIN_STACK=8388608` en tête si un test déborde. +- **Soundness** : ne jamais laisser `is_subtype` répondre `true` sur `Unknown` (D3). C'est l'invariant qui empêche de « transformer silencieusement l'incertitude en vérité » (§31). + +## 5. Ordre de livraison + +| PR | Contenu | Impact utilisateur | +|---|---|---| +| 1 | Phase 0 + Phase 1 + correction de la panique `int & (> 0)` | aucun (modèle interne) | +| 2 | Phases 2-4 : syntaxe, normalisation, sous-typage, erreurs statiques | `int & (> 0)` et `[int] & length(5)` acceptés ; contradictions et capacités invalides détectées | +| 3 | Phases 5-6 : obligations et codegen | vérifications runtime (changement de comportement Q4) | +| 4 | Phase 7 : propagation, avec la correction de `x[1:3]` | types plus précis | +| 5 | Phase 8, docs (dans les deux dépôts) | — | diff --git a/rfcs/0000-interface-bound-generics.md b/rfcs/0000-interface-bound-generics.md new file mode 100644 index 00000000..268f94e5 --- /dev/null +++ b/rfcs/0000-interface-bound-generics.md @@ -0,0 +1,117 @@ +- **Status:** implemented (branch `develop`, not yet merged) +- **RFC PR:** we-data-ch/typr#0000 +- **Tracking issue:** — +- **Implemented in:** not yet released +- **Start date:** 2026-09-30 + +# Implicit generics on interfaces: `Interface@Id` + +## Summary + +An interface name in a parameter type can carry a suffix `@Id` (`Lovable@A`) that names the hidden +type variable standing for "some type satisfying `Lovable`". Two parameters written `Lovable@A` and +`Lovable@B` are independent; two written `Lovable@A` share one type; the return type can name the +variable it comes from. A bare `Lovable` is sugar for `Lovable@Lovable`: **one variable per +interface**. No `T`/`U` is needed. The emitted R does not change. + +## Motivation + +Two parameters of the same interface used to behave differently in the body and at the call site: + +```typr +let second <- fn(a: Lovable, b: Lovable): Lovable { b }; +let r: Cat <- second(cat, dog); # accepted; `let r: Dog <- ...` was rejected ("Received Cat") +``` + +- In the body, `a` and `b` were two distinct rigid variables. +- At the call site, one variable was shared, and the first argument fixed it. The return type was + bound to the first argument, and `b: Dog` was never checked against `a: Cat`. This is a soundness hole. +- `fn(a: Lovable@A, b: Lovable@B)` was accepted and the `@A` silently dropped. +- `type Same <- interface { same: (Self, Self) -> bool }; fn(a: Same, b: Same) { a.same(b) }` was + accepted with two distinct rigid variables, because `PartialEq for Type` treats any two + `Generic` as equal. + +## Guide-level explanation + +```typr +let cmp <- fn(a: Lovable@A, b: Lovable@B): bool { a.love() == b.love() }; # cmp(cat, dog): ok +let second <- fn(a: Lovable@A, b: Lovable@B): Lovable@B { b }; # second(cat, dog): Dog +let same <- fn(a: Lovable, b: Lovable): bool { ... }; # same(cat, dog): error +``` + +Teaching line for an R user: "if you want two parameters to be independent, give each a small tag, +`@A`, `@B`". Same tag, same type. No tag, one shared tag per interface. + +## Reference-level explanation + +**Syntax.** `@Id` is written immediately after an interface alias, no space. `Id` is one uppercase +letter, or `_` for a fresh variable at each occurrence. It can be nested (`[#N, Eq@A]`, +`(Lovable@A) -> ...`). A malformed suffix (`Lovable @A`, `Lovable@Self`, `Lovable@Abc`) is a syntax +error S018 (`DetachedBoundSuffix`), never silently ignored. The prefix `@A` (a generic of kind +interface) is unchanged; `@` is already in the syntax manifest, so no lexeme is added. + +**Representation.** `Type::Bounded(id, bound, help_data)`, last variant of `Type` (binary +serialization). It exists only in signatures. + +**Typing.** +1. *Normalization* (`type_checking/signature_normalization.rs`): `I@_` gets a fresh id; a bare + interface alias becomes `Bounded(I, I)`. Errors: T047 two different bounds for one id; T048 an id + equal to a free generic of the signature; T017 an id only in the return type. +2. *Body* (`function.rs`): one rigid variable **per id**. A return `Lovable@B` accepts only the + rigid of `B`. Two distinct rigid variables are no longer subtypes of each other + (`is_subtype_raw` compares rigid names; `PartialEq for Type` is untouched, unification depends on it). +3. *Call site* (`instantiate_at_call`): each id is bound to its argument's type (the bound must be + satisfied; a repeated id requires mutual subtyping), then `Bounded(id)` is substituted in the + parameters and the return type, under `Vec`, `Tuple` and `Function`. + +**Emitted R.** Unchanged (same S3 dispatch on the interface name, same `--checked` assertions). Tested. + +**Error messages.** A call whose ids cannot be bound reports `NoMatchingSignature`, listing the +declared signatures. A dedicated "`A` bound to `Cat` then `Dog`" message is not written yet. + +## Gradual typing + +Unaffected: the feature only applies to interface-annotated parameters. Unannotated parameters stay `Any`. + +## Drawbacks + +- **Behavior change.** A function with two bare parameters of the same interface, called with two + different concrete types, used to compile and is now rejected. The measured corpus (tests, cases, + docs, blog, standard library) has none, but user code may. +- One more piece of syntax next to `T`, `#N`, `@I`. + +## Rationale and alternatives + +- **Bare `I` = fresh variable per occurrence** (rejected). It makes the return type ambiguous with + two parameters, and is unsound for `(Self, Self)` interfaces (`Eq`, `Ord`). +- **`where A: I`** (rejected): needs explicit `T`, which is what the design avoids. +- **`Lovable#Id`** (rejected): `#` already prefixes index generics. +- **Extend `KindedGen`** (rejected): its key is a `Kind`, not a type; the bound must travel with the + type through substitution. +- **A unification arm for `Bounded`** (planned, then dropped): substituting per signature before the + existing filters is simpler and reuses them unchanged. + +## Prior art + +Rust `impl Trait` in argument position (independent anonymous types) versus `` (shared); +Haskell class constraints; TypeScript `T extends I`. R itself has no static counterpart (S3/S4 dispatch +is dynamic), so R users have no existing expectation to break. + +## Unresolved questions + +- `swap` with a `tuple{...}` return, `[#N, Eq@A]` elements and lambda parameters have no dedicated test. +- `try_interface_subtype_match` is kept for a single bare interface parameter (`unique`, `sort`); it could be removed. +- The rigid name shows up in a return-type error (`__RIGID_1`) instead of the id. +- Parametrized interfaces (`interface`, `Box@A`) are not parsed today. + +## Future possibilities + +Parametrized interfaces: `Bounded` already accepts a bound like `Box` without a change of representation. + +## Implementation checklist + +- [x] `cases/0086`, `0087`, `0088` +- [x] `typr syntax --check` (no lexeme changed) +- [x] `syntaxe.md` updated (only the `typr.github.io` copy exists) +- [ ] Documentation PR on `we-data-ch/typr.github.io`, landing in the same release +- [ ] `Implemented in:` filled in above diff --git a/rfcs/0000-calling-untyped-r-functions.md b/rfcs/0028-calling-untyped-r-functions.md similarity index 71% rename from rfcs/0000-calling-untyped-r-functions.md rename to rfcs/0028-calling-untyped-r-functions.md index c1641856..3a06b6d9 100644 --- a/rfcs/0000-calling-untyped-r-functions.md +++ b/rfcs/0028-calling-untyped-r-functions.md @@ -1,6 +1,6 @@ -- **Status:** draft -- **RFC PR:** we-data-ch/typr#0000 -- **Tracking issue:** — +- **Status:** accepted +- **RFC PR:** we-data-ch/typr#28 +- **Tracking issue:** we-data-ch/typr#29 - **Implemented in:** not yet - **Start date:** 2026-09-09 @@ -108,8 +108,10 @@ already carries `parameters`. 1. `Lang::RFunction` with parameters `p₁…pₙ` is typed as a function `(Any, …, Any) -> Any` with *n* parameters, instead of the current `Type::UnknownFunction` with none. -2. Applying it checks arity only. Argument types are not checked and not - propagated. +2. Applying it checks arity only, as the literal count of parsed parameters + (`parameters.len()`) — `Lang::RFunction` carries no default values and no + `...` today (see "Resolved during review" below), so there is no case to + special-case yet. Argument types are not checked and not propagated. 3. The result type is `Any`. 4. Preloaded untyped builtins keep `Type::UnknownFunction`, since `functions_R.txt` gives no arity, but `UnknownFunction` becomes **variadic**: @@ -161,10 +163,9 @@ you write when you want the value back inside the type system. no untyped call passes the type checker at all. This deliberately opens a hole and calls it a feature. - **Arity is checked, which may surprise R users.** R's own arity rules are - looser (partial matching, `...`, missing arguments with defaults). An R - function with defaults, `function(a, b = 2)`, would be called `f(1)` in R and - rejected here unless defaults are read from the parsed parameter list. That - detail must be settled before implementation — see below. + looser (partial matching, `...`, missing arguments with defaults). See + "Defaults and `...`" below for why this drawback does not currently bite: the + syntax that would trigger it isn't parseable yet. ## Rationale and alternatives @@ -205,19 +206,41 @@ site's example checks precisely because it does not compile. RFC: it already gives a way to call untyped R with a *declared* signature. The question is whether the undeclared case deserves an answer too. -## Unresolved questions - -- **Defaults and `...`.** How is `function(a, b = 2)` counted, and what happens - to `function(...)` with R's dots? Arity checking is only worth having if it is - right; if reading defaults out of `Lang::RFunction` is awkward, the fallback is - Option 2 (variadic) for those cases specifically. -- **Should the result be `Any` or `Foreign`?** `Foreign` is the existing - idiom for values that came from R and need an accessor. Using it would make - untyped results consistent with `@extern` returns; using `Any` keeps `as!` as - the single exit. -- **Does the same reasoning extend to `R { ... }` blocks?** They already produce - a value; if that value is `Any`, this RFC's `as!` story covers them too and - should say so. +## Resolved during review + +These three questions were open when this RFC was drafted. Checked against the +compiler (0.5.12): + +- **Defaults and `...`.** Neither exists in `Lang::RFunction` today. Its + `parameters: Vec` is built by the parser's generic `variable` combinator + (`processes/parsing/elements.rs`), which accepts a bare name plus an optional + `: Type` annotation — no `= expr` default syntax, no special-casing of `...`. + `function(a, b = 2)` and `function(...)` are not parseable as an `RFunction` + today; there is nothing for arity checking to get wrong, because the grammar + gives it nothing but a plain count of bare names. So point 2 of the + Reference-level explanation is exactly `parameters.len()`, no fallback needed. + Adding default-parameter or dots syntax to the escape hatch is a separate, + larger change (it touches the syntax manifest) and its own RFC if ever + proposed; this one does not depend on it. +- **`Any`, not `Foreign`.** `Foreign` is for a value TypR acknowledges + is opaque *and* gives an accessor story for. An untyped call's result could be + anything a plain R function returns — vector, list, S3 object — and forcing it + through `Foreign` would imply an accessor contract this RFC does not define. + `Any` is the honest choice, and it composes with the rest of this proposal: + a future package-registry design that degrades low-trust or generated + signatures to "callable, unchecked" (see `Type::UnknownFunction` above) needs + exactly this same fallback shape, so settling on `Any` here keeps that door + open instead of introducing a second opaque-value convention to reconcile + later. +- **`R { ... }` blocks: out of scope, and for a concrete reason.** They are + *not* already `Any` — the type checker assigns them `Type::Empty` + (`processes/type_checking/mod.rs`), a distinct placeholder from + `Type::UnknownFunction`/`Type::Any` with its own, more permissive unification + rule (`Type::Empty` unifies with anything; `Type::Any` only satisfies a + target that is itself `Any`). Folding `R { ... }` into this RFC's `Any`/`as!` + story would be a behavior change to existing code, not a documentation + footnote, since it would make `R { ... }` results stop unifying freely with + concrete types. Left for a separate RFC if ever proposed. ## Future possibilities @@ -227,11 +250,16 @@ of scope here. ## Implementation checklist -- [ ] `cases/` entry: untyped `function(a, b)` defined, called, and cast -- [ ] `cases/` entry: arity error message -- [ ] `cases/` entry: preloaded builtin called with arguments (`Position(1, 2)`) -- [ ] `syntaxe.md` §12 updated in both copies -- [ ] `docs/philosophy/intro.md` and `docs/reference/escape-hatches.md` updated +- [x] `cases/` entry: untyped `function(a, b)` defined, called, and cast + (`cases/0062-untyped-r-function-callable`) +- [x] `cases/` entry: arity error message (`cases/0063-untyped-r-function-arity-error`) +- [x] `cases/` entry: preloaded builtin called with arguments (`Position(1, 2)`) + (`cases/0064-untyped-preloaded-builtin-variadic`) +- [x] `syntaxe.md` §12 updated (`typr.github.io/syntaxe.md`; no second copy + exists under `typr/` yet) +- [x] `docs/philosophy/intro.md` and `docs/reference/escape-hatches.md` updated on `typr.github.io`; the `noplayground` marker on the philosophy block - removed (see `doc_correction.md` §C in that repository) -- [ ] `Implemented in:` filled in above + removed — both blocks verified with `typr check` (0.5.12+d85f8d9) and + `npm run check:examples` +- [ ] `Implemented in:` filled in above — pending a release past 0.5.12; the + implementing commit (`d85f8d9`) is on `develop`, not yet tagged diff --git a/rfcs/0031-external-type-definitions.md b/rfcs/0031-external-type-definitions.md new file mode 100644 index 00000000..9774465c --- /dev/null +++ b/rfcs/0031-external-type-definitions.md @@ -0,0 +1,527 @@ +- **Status:** accepted +- **RFC PR:** we-data-ch/typr#31 +- **Tracking issue:** we-data-ch/typr#32 +- **Implemented in:** not yet +- **Start date:** 2026-09-12 + +# External type definitions + + + +## Summary + +A TypR project can declare a `.ty` file written by someone else as the type +description of an R package it imports, pin it to an exact commit and content +digest in a new `typr.lock`, and load it into the type-checking context at a +configurable trust threshold (`T1`/`T2`/`T3`). An entry below that threshold is +never rejected and never errors: it is loaded as a variadic function returning +`Any`, so an untrustworthy or wrong external definition can only make TypR +check *less*, never make a correct program stop compiling. `typr gen-types` +(already shipped) becomes the bootstrap path for this: its output is a valid +`T3` external definition, consumable the same way as a hand-written one. + +This is the second milestone (J2) of the type-registry design in +`typR/registry.md`, whose §5 (definition format), §7 (resolution and locking) +and §8.3 (conflict order) this RFC turns into a committed contract. It +deliberately excludes the registry service itself (a hosted index of +definitions, `typr search`, `typr add` auto-discovery) — that is J3 and later, +and needs no language or CLI contract change beyond what is specified here: +until it exists, `typr types add ` is the only way to point at a +definition, which is sufficient to use everything below. + +## Motivation + +TypR programs already import untyped R packages today, and the escape hatches +to describe them exist and work: `@extern`, `@importFrom` + a hand-written +`@name: (T) -> R;`, `Foreign` for opaque values, `Option` for +`from_nullable()`. What is missing is not expressive power — it is a *place to +put* those declarations that isn't the consuming project's own source, and a +way to load them safely. + +Concretely, two things are true at once and neither has an answer today: + +1. **Nobody will hand-type `shiny`.** It exports on the order of 400 functions. + `typR/typr/crates/typr-cli/src/gen_types.rs` (shipped, J1) already produces + a `.ty` file for any installed package via `Rscript`-based introspection — + but that file has nowhere to live except inside the consuming project, and + nothing loads it automatically. Two projects using `shiny` today duplicate + the same generated file, or don't bother and leave every `shiny::*` call + untyped. + +2. **A hand-written or generated definition can be wrong**, and R's ABI makes + "wrong" dangerous, not just imprecise. `typR/ai_context/external_packages_incompatibilities.md` + documents that a TypR value does not cross into a base-R function for free: + `int` is `structure(x, class = c("Integer", "integer", "Any", "Generic"))`, + `[N, T]` is an S3 list (`typed_vec`), not an atomic vector. A definition that + only states `(int) -> int` for a function that actually strips those + attributes on return produces code that type-checks and returns the wrong + runtime type — silently. There is currently no mechanism that limits the + blast radius of that mistake to "less checking" instead of "a corrupted + type", because there is no mechanism for loading a third-party `.ty` at all. + +Today, describing `dplyr::filter` requires either editing the consuming +project's own source with hand-rolled `@importFrom`/`@extern` declarations (not +shareable, not versioned against the package, redone by every project), or +nothing, leaving the call fully untyped with no hover, no signature help, no +argument names. This RFC is what turns "someone wrote this once" into +"everyone using `dplyr` in TypR benefits from it, without trusting it more than +the project asks to." + +## Guide-level explanation + +Say you are writing a Shiny app in TypR and want typed access to +`fluidPage`/`titlePanel`, and someone has already published a definition for +it. + +**Pointing at a definition.** `typr.toml` gets a new `[types]` table. It is +*not* a dependency list — `DESCRIPTION`'s `Imports:` remains the one place R +dependencies are declared, exactly as today (`typr add shiny` still runs +`usethis::use_package('shiny')` under the hood). `[types]` only says which type +description to use for a package already imported, and how much to trust +descriptions you did not pin explicitly: + +```toml +# typr.toml +[types] +trust = "T2" # T1 = paranoid, T2 = default, T3 = trust generated code too +shiny = "github:alice/typr-shiny" # explicit pin — wins over everything else +``` + +```bash +typr types add github:alice/typr-shiny +``` + +resolves `alice/typr-shiny` on GitHub, fetches its `HEAD`, checks the manifest +(below), and records exactly what was fetched in a new `typr.lock`: + +```toml +# typr.lock — generated, commit this file +[[definition]] +package = "shiny" +repository = "github:alice/typr-shiny" +version = "0.3.0" +rev = "a1b2c3d4e5f6…" +digest = "sha256:…" +tier = "T2" +r_version_seen = "1.11.1" +``` + +From then on, `typr check`/`build`/`run` load `alice/typr-shiny`'s `.ty` files +the same way they load `std.ty` today, and: + +```typr +@importFrom shiny fluidPage; +fluidPage("hello") # hover shows the real signature and doc, not "Any" +``` + +type-checks against the real declared signature, with completion and hover in +the LSP and MCP showing where the definition came from and at what tier. + +**No definition, or one you don't trust enough.** Nothing breaks. If `shiny` +had no pinned definition at all, `fluidPage` is exactly as untyped as any other +R name today — callable, arity-checked when known, returning `Any` +(`rfcs/0028-calling-untyped-r-functions.md`, already shipped). If a definition +*is* pinned but its declared tier is below the project's `trust`, the effect is +identical: every one of its entries loads as `(Any, …) -> Any` instead of its +declared signature. The project never has to remove or fight a bad definition +to keep building — lowering `trust`, or unpinning it, is enough, and doing +neither is also safe. + +**Bootstrapping your own.** If nobody has published one yet: + +```bash +typr gen-types shiny --out ./ty/ +``` + +produces a `T3` definition from the package installed locally (arity and +argument names from `formals()`, everything else `Any`) — this already ships. +This RFC adds `typr types vendor`, which copies whatever is currently resolved +(generated or fetched) into the project's own tree, so the build stops +depending on the network or on the upstream repository's continued existence. + +**How you'd explain it to an R user who knows TypR's basics but nothing about +this feature:** "The `#! tier` you've seen on standard-library entries isn't +just internal bookkeeping — you can attach the same trust levels to +descriptions of *any* R package, written by anyone, and TypR will never let a +description it doesn't fully trust turn your correct code into a type error." + +## Reference-level explanation + +### Definition repository layout and manifest + +A definition repository (`typr-shiny/` in the example above) has this shape: + +```text +typr-shiny/ +├── README.md +├── typr-def.toml # manifest +├── ty/ +│ ├── core.ty +│ └── ui.ty +├── R/ # optional — see Capabilities and R shims below +│ └── shims.R +└── tests/ + └── smoke.ty # a program that must compile against this definition +``` + +`typr-def.toml`, not `typr.toml`, on purpose: the two files answer different +questions (the definition's own metadata vs. a consuming project's +configuration) and living in different repositories does not stop someone from +copy-pasting one into the other by habit if the name is shared. + +```toml +format_version = 1 # REQUIRED — an unknown format_version is refused, not guessed at + +[package] +name = "shiny" +since = "1.11.0" # a floor, not a closed range — see Version compatibility below +# until = "2.0.0" # only when a break is *known*, never speculative + +[definition] +version = "0.3.0" # semver of the definition itself, independent of the package's +tier = "T2" # default tier for entries with no `#! tier:` of their own + +[provider] +type = "community" # official | community | generated | local +repository = "github:alice/typr-shiny" + +[capabilities] +r_shims = false # ships executable R alongside the declarations? +extern_raw = false # uses `extern: (...) -> T r#"...R..."#` verbatim blocks? +``` + +`format_version` is what keeps this survivable across N repositories the +project does not control: when the definition format changes, the compiler +reads old manifests it recognizes or refuses the ones it doesn't — it never +silently misparses one. + +### `.ty` files and the two new annotations + +Definitions are ordinary `.ty` files, parsed and type-checked the same way as +`std.ty`, with the same `#!` annotation block already implemented in +`crates/typr-core/src/processes/spg/stdlib_meta.rs` (`pkg`, `tier`, `param`, +`ret`, `coercion`/`note`, `example`, `seealso`). That parser currently drops +any key it does not recognize (`_ => {} // ignore silently`), which is exactly +how it stays forward-compatible — this RFC uses that door to add two new keys +rather than changing the parser's shape: + +```typr +#! pkg: shiny +#! tier: T2 +#! since: 1.11.0 +#! ret: UI object, opaque on the TypR side +#! example: fluidPage(titlePanel("hello")) +@importFrom shiny fluidPage; +@fluidPage: (Any) -> Foreign; + +#! pkg: dplyr +#! tier: T3 +#! since: 1.1.0 +#! until: 2.0.0 +#! param .data: input table +@importFrom dplyr filter; +@filter: (Any, Any) -> Any; +``` + +`FunctionMeta` gains `since: Option` and `until: Option`, +parsed identically to `ret`/`coercion` (a single value, `strip_leading_colon`). +Nothing about `@extern`/`@importFrom`/`Foreign` changes: choosing between +them *is* the declaration of how the boundary is crossed +(`typR/ai_context/tuto_external_packages.md`), and that choice is exactly what +these files make. + +### Loading external `.ty` into the context + +`crates/typr-cli/src/standard_library.rs` builds the checking context from +`R_T1_SOURCES` (plus `R_DOC_ONLY_SOURCES`) — a fixed, embedded list of +`(filename, source)` pairs. This RFC adds a second list built at project-load +time from `typr.lock`: for each resolved definition, read its `.ty` files from +the on-disk cache (below), parse `#! tier` per entry (falling back to the +manifest's `[definition] tier` when absent), and merge into the same table +`build_typed_vartype` already builds from `R_T1_SOURCES` — with one difference +from today's uniform T1 preload: **each entry's tier is compared against the +project's configured `trust`.** + +- entry tier ≥ project `trust`: loaded with its declared signature, exactly as + `R_T1_SOURCES` entries are today. +- entry tier < project `trust`: loaded as `Type::UnknownFunction` (variadic, + returns `Any`) instead of its declared signature — the same representation + `rfcs/0028-calling-untyped-r-functions.md` already gives every untyped R + name. No error, no warning bubbled up to a build failure; the LSP and MCP + still surface the declared signature and doc (`§11` below), only the + type-checker itself degrades. +- package with no resolved definition at all: unchanged from today — every + name from it is `UnknownFunction` unless the project's own source declares + it. + +This is the mechanism that makes D2 (`typR/registry.md` §0) real: nothing +downstream of this merge step needs to know *why* an entry became `Any` — a +missing definition, a low tier, and an out-of-range version (next section) all +collapse to the same representation. + +### Version compatibility: a floor, not a range + +R has no npm/cargo-style version resolution — CRAN publishes one current +version, and a user has "whatever is installed". Declaring `supports = +["1.11.x"]` would make compatibility depend on the resolving machine (breaking +`typr.lock`'s point), produce false "incompatible" verdicts the moment a +package ships a patch release (R rarely breaks its own API), and require +upkeep nobody will do. + +Instead: `since` is a floor. At load time, the introspected installed version +(already recovered by `introspect_pkg.R`'s `P` line, used today by +`r_name_cache`) is compared against it: + +- installed ≥ `since` (and ≤ `until`, when present): used without warning. +- installed < `since`, or > `until` when declared: TypR **warns and degrades to + `Any`** for that package's entries — never refuses to build. The version + actually observed is written into `typr.lock` as `r_version_seen`, which is + what lets any future CI on a definition repository notice drift between what + it declares and what real installs report. + +### Resolution, `typr.lock`, cache, vendoring + +```text +typr add shiny + → DESCRIPTION: Imports += shiny (usethis — unchanged) + → typr.toml [types]: explicit pin for `shiny`? + yes → use it, done + no → no definition resolved (until J3's registry exists to search) + → fetch resolved repository at HEAD, or the pinned rev + → verify content digest + → ~/.cache/typr/types/// + → typr.lock updated + → available to the compiler, the LSP and the MCP +``` + +New CLI surface (`crates/typr-cli/src/cli.rs`, alongside the existing `Add`, +`Check`, `Cache` subcommands): + +| Command | Role | +|---|---| +| `typr add ` | unchanged (`usethis::use_package`) — this RFC adds nothing here until J3 lets it also look up a definition automatically | +| `typr types add ` | pin a definition (`github:owner/repo[@rev]`) — fetch, verify, write into `typr.toml`/`typr.lock` | +| `typr types update [pkg]` | re-fetch and re-pin `typr.lock` for one or all resolved definitions | +| `typr types list` | what's resolved, with tier and provenance | +| `typr types vendor` | copy resolved `.ty` files into the project tree, so the build no longer depends on the network or on the upstream repository still existing | + +`typr.toml`'s `[types]` and `typr.lock` are new files with no analog in the +project today; `DESCRIPTION` is untouched and remains authoritative for R +dependencies (`§7.1` — one source of truth per question). + +### Capabilities and R shims + +A `.ty` file is inert; `extern: (…) -> T r#"…R…"#` blocks and any `R/` shim +directory are not — they execute in the consuming project's process. The +threat model is `npm postinstall`'s, not `@types/*`'s package.json's. This RFC +adopts the manifest's `[capabilities]` gate as a hard rule, not a convention: + +- a definition whose manifest leaves `r_shims`/`extern_raw` at their default + (`false`) but ships either anyway is **rejected at fetch time**, before a + single byte of it is loaded — not just flagged in CI on the definition's own + repository. +- a definition that sets either to `true` shows an explicit warning on the + first `typr types add`/`typr add` that resolves it, and requires + confirmation (or `--allow-r`). +- nothing is executed during discovery or resolution: fetching a definition is + a source download, full stop; only actually building/running the consuming + project can execute a shim, exactly as it would execute any other R in the + project. + +### Failure modes are never hard errors + +Every one of the following degrades to the *some entries are `Any`* case +above; none of them fails a build: + +- the pinned repository is unreachable (network, deleted, rewritten history — + digest mismatch on the cached copy). +- the resolved manifest has no `format_version`, or one this compiler build + doesn't recognize (the *definition* is refused; the package's names fall + back to fully untyped, same as having none). +- `since`/`until` exclude the installed R package version. +- the project's `trust` excludes the definition's declared tier. + +## Gradual typing + +This proposal is gradual typing's boundary case taken to its logical end: a +package with **zero** resolved definitions behaves exactly as today (every +name `UnknownFunction`, callable, arity-checked when knowable, `Any`-typed +result — `rfcs/0028`). Resolving a `T3` (generated) definition changes *only* +argument names and documentation surfaced to a human or to the LSP/MCP; the +type-checker's view is unchanged, because `T3` entries load exactly like +`UnknownFunction` today. Type constraints appear at `T2`/`T1`, and only for the +entries that actually declare them, only when the project's own `trust` +accepts that tier. There is no annotation density this proposal forces: +a project that never touches `typr.toml [types]` sees no behavior change at +all. + +## Drawbacks + +- **Two new project files** (`typr.toml [types]`, `typr.lock`) for a project + type (R packages) that already has one dependency manifest (`DESCRIPTION`). + The mitigation is the explicit division of labor in §7.1 of + `typR/registry.md`, but it is still a second file to explain to newcomers. +- **The trust/degradation model is invisible by default.** A project that sets + `trust = "T2"` and pins a `T3` definition gets *no* type checking for that + package and no error telling it so — by design (D2), but it means a mistake + here reads as "TypR isn't catching this" rather than as a loud failure. + `typr types list` showing tier per package is the mitigation; it needs to be + something people actually run. +- **Fetching arbitrary GitHub repositories as part of a build-adjacent command** + (`typr types add`, `typr types update`) is new network/trust surface for a + compiler CLI that has not had it before, even with the capability gate in + place. The digest-pinning in `typr.lock` bounds this to "you get what you + first approved," not "you get whatever is at HEAD right now" — but it is a + new class of thing this CLI does. +- **`since`/`until` add two more `#!` keys to a metadata format that already + has seven**; each addition is small, but the format is not designed against + a fixed budget, and every key is a piece every generator and consumer has to + handle for good, per `format_version`'s forward-compatibility promise. + +## Rationale and alternatives + +The central choice is D2 (`typR/registry.md` §0): **degrade to `Any`, never +error.** The alternative — treat a definition below trust, or a version +mismatch, as a hard type error — was considered and rejected, because a +third-party definition is exactly the kind of input a project does not +control, and "a package you don't maintain published something" becoming +"your correct code stops compiling" is the single failure mode a community +type registry cannot survive. This is only possible because +`rfcs/0028-calling-untyped-r-functions.md` already made `UnknownFunction` +variadic and callable — before that RFC, "degrade to `Any`" had nowhere safe to +land. + +The second choice is pinning by content digest in a lockfile rather than a +semver range (`registry.md` §7.2, §D4/§D5): R has no ecosystem-wide version +resolver, so a range like `supports = ["1.11.x"]` would depend on the +resolving machine and rot without anyone noticing. A digest is the only thing +that is reproducible without inventing R version-range semantics R itself +doesn't have. + +The cost of doing nothing is not small: it is the status quo described in +Motivation — every project hand-rolling or duplicating its own `@importFrom` +declarations for the same handful of popular packages, with no sharing +mechanism at all. `typr gen-types` (J1, already shipped) is only half-useful +without this RFC, because its output currently has nowhere to be shared or +loaded except by hand-copying files between projects. + +## Prior art + +- **R itself**: no package in CRAN or Bioconductor ships a machine-readable + type description of its own API; `Rd`/roxygen comments are prose. There is + no existing convention to be compatible with, which is why this proposal + invents one rather than adapting one. +- **DefinitelyTyped** (`@types/*` for TypeScript) is the closest analog: + community-maintained type descriptions decoupled from the packages they + describe, in a monorepo. Its main lesson, already reflected in + `registry.md` §8.2 (not this RFC's scope, since J2 has no registry yet): a + single monorepo makes format migrations tractable across a long tail of + small packages, at the cost of requiring PRs into a repo the community + doesn't fully own. Its second lesson, which *is* this RFC's concern: + `@types` packages can and do drift from the real library's behavior, and + TypeScript has no equivalent of D2 — a wrong `@types` package produces + compile errors on correct code, which is the exact failure this RFC's + degradation rule is designed to avoid. +- **Rust's `cargo vendor`** is the direct model for `typr types vendor`: + making a build reproducible and independent of an upstream repository's + continued existence, distinct from the cache used for day-to-day iteration. +- **npm's `postinstall` scripts** are the threat model for the capabilities + gate in §"Capabilities and R shims" — the industry's cautionary tale for + "a dependency description can execute arbitrary code," which is why this + RFC treats `r_shims`/`extern_raw` as a hard gate rather than a documented + convention. + +## Unresolved questions + +Carried over from `registry.md` §14, to the extent they bear on J2 rather than +the registry service itself: + +- **Q1** — is `typr-def.toml` the right filename, or should it be folded into + something reused elsewhere? This RFC takes the name as settled for the + purpose of shipping J2; revisiting it later is a rename, not a redesign. +- **Q2** — do third-party definitions get to ship R at all (shims, + `extern r#"…"#`)? This RFC allows it behind the `[capabilities]` gate, but + whether entire categories of package (those needing S3/S4 shims) are + reachable *without* shims is open, and may push some of that need into the + standard library instead of third-party definitions. +- **Q4** — a definition's own `type X <- Foreign;` declarations leak into + the consuming project's namespace; this RFC does not specify a namespacing + rule for that, and two definitions declaring the same type name is + unresolved. +- **Q5** — can a definition be type-checked (in the definition repository's + own CI, or when a consuming project resolves it) when its package is not + installed locally? This determines whether definition repositories, and + projects that pin them, need R + the package installed just to run `typr + check`. Left open for J2; matters more once J4 (registry CI) exists. + +## Future possibilities + +Explicitly out of scope here, deferred to `registry.md`'s later milestones: + +- **J3 — the registry itself**: a hosted index (`typr-lang/registry`) that + lets `typr add`/`typr types update` *discover* a definition instead of + requiring an explicit `typr types add `, plus `typr search`. Nothing + in this RFC needs to change for that to land — J3 only adds a lookup step + before the resolution flow specified here, and the conflict order in + `registry.md` §8.3 (explicit pin, then official, then community by tier, + then locally generated, then nothing) already anticipates it. +- **J4 — registry CI**: mechanical validation (exports exist, arity matches + `formals()`, no unconstrained `...` at `T1`) and drift re-detection over + time, run centrally instead of per-consumer. +- **J6 — the Store**: a web UI over the registry. Explicitly optional per + `registry.md` D6, and needs nothing from this RFC beyond the manifest and + lockfile shapes already specified. +- Typing S4/R6/S7 by anything more precise than `Foreign` is its own RFC, + out of scope here as it is in `registry.md` §15. + +## Implementation checklist + + + +- [x] `typr-def.toml` manifest parsing + `format_version` gate + (`crates/typr-cli/src/type_definition.rs::parse_manifest`) +- [x] `since`/`until` added to `FunctionMeta` (`stdlib_meta.rs`) and to the + manifest +- [x] external `.ty` loading merged into `standard_library.rs`'s context build, + keyed by resolved `typr.lock` entries + (`crates/typr-cli/src/standard_library.rs::load_project_type_definitions`, + called from every `check`/`build`/`run` entry point in `project.rs`; + also applies the `since`/`until` version-floor degradation via + `degrade_if_version_out_of_range`; see `typR/registry.md` §13 J2 for + detail) +- [x] `trust` threshold + degrade-to-`UnknownFunction` at merge time + (`crates/typr-cli/src/standard_library.rs::load_external_ty_definitions`, + `VarType::degrade_to_any`; see `typR/registry.md` §13 J2 for detail) +- [x] `typr types add|update|list|vendor`, `typr.lock` read/write, digest + verification, `~/.cache/typr/types///` + (`crates/typr-cli/src/type_registry.rs`; see `typR/registry.md` §13 J2 + for detail) +- [ ] `cases/`: missing definition, tier below `trust`, version below `since`, + version above `until`, unreachable repository, digest mismatch — each + asserting *no hard error*, only degradation. Covered so far only by + Rust unit/integration tests + (`standard_library.rs::tests`, `type_registry.rs::tests`) and a manual + end-to-end smoke test through the real `typr` binary — not yet by a + `cases/NNNN-…` entry replayed through `typr case run`, since that + would need a portable way to pre-populate `~/.cache/typr/types/` for a + sandboxed `repro/` (the cache lives outside the project directory by + design, §7.4). See `typR/registry.md` §13 J2 for detail. +- [x] `[capabilities]` gate enforced at fetch time (reject undeclared R; + confirm-or-`--allow-r` for declared) + (`crates/typr-cli/src/type_registry.rs::check_capabilities`, enforced + inside `fetch()` before anything is admitted to the cache; the + `--allow-r` confirmation prompt itself is not implemented — today a + declared `r_shims`/`extern_raw` definition passes with a printed + warning, never a blocking confirmation) +- [x] `syntaxe.md` — no lexeme changes were made throughout this RFC's + implementation +- [ ] Documentation PR on `we-data-ch/typr.github.io` (a How-to page for + consuming an external definition; a Reference page for the manifest and + `#!` keys), landing in the same release +- [ ] `Implemented in:` filled in above