diff --git a/README.md b/README.md index e5b3b75..2214236 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ MATLAB, Mathematica and Maple, where each piece lives, and how far it has got. - [C and C++](bib/c-cpp.md) — feasibility plan for go-embedded-c and go-embedded-cpp: C cells on the cc/v4 front end compiled to wasm32 and run on wazero or the browser engine, with wasi-libc; C++ cells as clang-repl compiled to wasm on the same runtime, behind a measured gate + - [R](bib/r.md) — bibliography and feasibility plan for go-embedded-r, a pure-Go R interpreter + for go-notebook cells, counted on the R 4.6.2 release: 210 primitives and 563 internals, + the GPL boundary and what is portable, the CRAN native-code wall (15 % of CRAN runnable + without compiled code), oracle harness, milestones - [template](bib/TEMPLATE.md) for a new domain `go run ./tools/checklinks` checks that every relative link in the tree resolves; CI runs it. @@ -39,3 +43,6 @@ julia source tree, the figures `bib/julia-runtime.md` quotes. `go run ./tools/srccount [--ext .c,.h] [--contains text] ...` counts files, lines and bytes under source paths, and `go run ./tools/wasmimports ` validates a module with wazero and lists what its host must provide; `bib/c-cpp.md` quotes both. +`go run ./tools/rcount [--cran PACKAGES] ` counts the primitives, internals, +grammar, opcodes, lines per language and native call sites of an R release tarball, and with a +CRAN `PACKAGES` index how much of CRAN needs compiled code: the figures `bib/r.md` quotes. diff --git a/bib/r.md b/bib/r.md new file mode 100644 index 0000000..7400c54 --- /dev/null +++ b/bib/r.md @@ -0,0 +1,404 @@ +# R — bibliography and feasibility plan for a pure-Go, embeddable R + +Written 2026-11-01. Facts only, each with a source; counts come from the sources, not from estimates. + +> **Lesson carried over from [`julia.md`](julia.md).** Its counts were first taken on julia +> master instead of the release the port is pinned to, and had to be corrected in +> [`julia-runtime.md`](julia-runtime.md) §1. Here every count is taken on the **R 4.6.2 release +> tarball** and nothing else. R has no release tags in git: the +> [wch/r-source](https://github.com/wch/r-source) mirror has one tag, `trunk` +> (`gh api repos/wch/r-source/tags`, 2026-11-01), so the pin is the CRAN tarball and its digest, +> not a commit. + +Goal: an R interpreter written in Go (working name **go-embedded-r**), `CGO_ENABLED=0`, +embeddable and `GOOS=js`/`wasip1`-capable, that backs ` ```r ` cells in go-notebook and its +WebAssembly playground, the way [go-embedded-julia/julia](https://github.com/go-embedded-julia/julia) +backs ` ```julia ` cells and [go-embedded-ruby/ruby](https://github.com/go-embedded-ruby/ruby) +(rbgo) backs ` ```ruby ` cells. No code exists; the organisation +[go-embedded-r](https://github.com/go-embedded-r) exists and holds no repository +(`gh repo list go-embedded-r`, 2026-11-01). + +## 0. Sources read, and how the counts were made + +| source | revision read | what was used | +|---|---|---| +| R source, [`R-4.6.2.tar.gz`](https://cran.r-project.org/src/base/R-4/R-4.6.2.tar.gz) | SHA-256 `c297040f6392b1dac6e5c0eb398422cfdaedf540ae2205edb9ad0d10cb10ee00`; `VERSION` = `4.6.2`; `SVN-REVISION` 90784, 2026-10-29 | `src/main/{names.c,gram.y,eval.c,summary.c,RNG.c}`, `src/include/Rinternals.h`, `src/appl/dqrdc2.f`, `src/modules/lapack/{README,LICENSE,Lapack.c}`, `src/library/*/R`, `tests/`, `doc/COPYRIGHTS`, `src/library/base/R/apply.R` (licence header) | +| CRAN [`VERSION-INFO.dcf`](https://cran.r-project.org/src/base/VERSION-INFO.dcf) | 2026-11-01 | release 4.6.2 "Diversified Portfolio" 2026-10-29; old release 4.5.3; devel 4.7.0 | +| *R Language Definition*, `doc/manual/R-lang.texi` | in the 4.6.2 tarball | section list; §Evaluation of expressions (l. 860), §Indexing (1501), §Scope of variables (1917), §Functions / Evaluation (2171), §Object-oriented programming (2460: `UseMethod` 2728, `NextMethod` 2815, group methods 2891), §Computing on the language (2975), §.Internal and .Primitive (3707), §Exception handling (3718), §Parser (3977: modes of parsing 3988-4024, tokens 4074, expressions 4345) | +| *R Internals*, `doc/manual/R-ints.texi` | in the 4.6.2 tarball | §SEXPs (73), §Environments and variable lookup (499), §Contexts (738), §Argument evaluation (838-877), §Serialization Formats (1092), §S4 objects (1386), §Lazy loading (1852), §.Internal vs .Primitive (1926-1985), §Graphics Devices / Device structures (2795-2839), §Testing R code (4878-4890) | +| CRAN [`src/contrib/PACKAGES`](https://cran.r-project.org/src/contrib/PACKAGES) | fetched 2026-11-01 11:15 CET, SHA-256 `eaca4e078582ed1d688522e9e76bf2525a0f5c462bb682e4c1985964f57ffeac` | `NeedsCompilation`, `Depends`, `Imports`, `LinkingTo` of every package (§1.4) | +| Prior art READMEs via `gh api repos//readme` | 2026-11-01 | [bedatadriven/renjin](https://github.com/bedatadriven/renjin) (+ `tools/gcc-bridge/README.md`), [oracle/fastr](https://github.com/oracle/fastr), [radfordneal/pqR](https://github.com/radfordneal/pqR), [jtalbot/riposte](https://github.com/jtalbot/riposte), [r-wasm/webr](https://github.com/r-wasm/webr) (+ `LICENSE.md`) | +| Oracle binaries | 2026-11-01 | [conda-forge `r-base`](https://api.anaconda.org/package/conda-forge/r-base) file list; CRAN [macOS arm64 directory](https://cran.r-project.org/bin/macosx/big-sur-arm64/base/); Posit [`versions.json`](https://cdn.posit.co/r/versions.json); `pkgx +r-project.org` | +| Fleet | `main`, 2026-11-01 | `gh repo list` of go-scikit, go-ndarray, go-statistics, go-numerics, go-plot, go-embedded-julia, go-embedded-r; `go-scikit/ruby` `go.mod`; `go-ndarray/ndarray` `linalg/` file list; `go-statistics/stats` file list; `go-notebook/notebook` `analysis/` and `go-notebook/docs` `content/en/` file lists; [`julia.md`](julia.md) §5 for the cell contract | + +Counting method. The tarball was read only (no `configure`, no build). Every count in §1-§2 is +the output of [`tools/rcount`](../tools/rcount/main.go) (this repository; a test plants each +table form, a commented-out entry, an entry outside the table, a symbolic offset, and refuses an +empty table or a missing file; fuzzed): + +``` +go run ./tools/rcount --cran PACKAGES +``` + +Verification: the `R_FunTab` count (773) equals the number of lines of `names.c` that begin with +`{"` (`grep -c`). A first scratch version that required a numeric offset column read 670 entries +and 182/488 primitives/internals: 28 entries have a symbolic offset (`PLUSOP`, …); the line count +exposed it. Line counts are physical lines (`wc -l`, plus one for a final unterminated line). + +## 1. What R is made of (sizes, R 4.6.2) + +### 1.1 Interpreter and numerical code + +| part | path | files | lines | role for a Go port | +|---|---|---|---|---| +| interpreter, base built-ins, I/O, graphics engine | `src/main/*.c` | 128 | 156 069 C (incl. generated `gram.c` 6 858) + 117 Fortran | spec only (GPL, §3.2) | +| grammar + hand-written lexer | `src/main/gram.y` | 1 | 4 549 | spec only; §2.2 | +| evaluator | `src/main/eval.c` | 1 | 9 426 | AST `eval` at `eval.c:1082`; byte-code engine `bcEval` at `:7500`, `bcEval_loop` at `:7520` | +| key runtime files | `envir.c` 5 084, `memory.c` 5 130, `connections.c` 7 489, `engine.c` 4 017, `errors.c` 3 011, `coerce.c` 3 132, `grep.c` 3 524, `serialize.c` 3 383, `plotmath.c` 3 237, `unique.c` 2 690, `arithmetic.c` 2 466, `subassign.c` 2 340, `attrib.c` 2 122, `deparse.c` 1 962, `subset.c` 1 423, `names.c` 1 465 | | | | +| Rmath (d/p/q/r of distributions, special functions) | `src/nmath/` | 127 | 18 847 C | oracle only (GPL-2+, §3.2) | +| LINPACK + optimisers + `pretty` | `src/appl/` | 21 | 9 829 C + 2 337 Fortran | `dqrdc2.f` (QR used by `lm`), `optim.c`, `lbfgsb.c`, `uncmin.c`, `integrate.c` | +| reference BLAS | `src/extra/blas/` | 9 | 22 593 Fortran + 225 C | BSD (from LAPACK 3.12.0); replaced by go-ndarray | +| LAPACK subset | `src/modules/lapack/` | 17 | 250 684 Fortran + 2 739 C | BSD; base R calls **26** routines (§3.3) | +| public headers | `src/include/` | 58 | 16 758 C | `Rinternals.h` etc. are LGPL-2.1+ (§3.2) | + +### 1.2 The base packages: lines per language + +| package | R | C | Fortran | Rd (help) | native call sites in its R code | +|---|---|---|---|---|---| +| base | 23 469 (169 files) | (in `src/main`) | (in `src/appl`) | 47 029 | `.Internal` 514 sites / 409 names; `.Fortran` 7; `.Call` 3 | +| stats | 36 490 (181) | 23 087 | 18 206 | 30 775 | `.Call` 230/184; `.Fortran` 16/14; `.External` 15/8; `.C` 12/10; `.External2` 10/7 | +| utils | 23 589 (103) | 5 766 | — | 16 121 | `.Call` 32/30; `.External2` 26/21; `.External` 17/15; `.Internal` 4/3 | +| methods (S4) | 18 015 (45) | 1 553 | — | 11 928 | `.Call` 23/17; `.Internal` 5/3 | +| graphics | 8 341 (57) | 12 167 | — | 8 790 | `.External2` 4/4; `.Call` 3/3; `.External` 2/2 | +| grDevices | 6 837 (46) | 26 446 (`devPS.c` 10 117, `cairo/cairoFns.c` 2 586) | — | 8 059 | `.External` 48/25; `.Call` 25/22; `.External2` 14/9 | +| grid | 17 744 (45) | 11 483 (`grid.c` 5 470) | — | 7 183 | `.Call` 27/24 | +| tools | 53 678 (67) | 10 252 (+ 3 366 yacc) | — | 5 781 | `.Call` 34/19; `.Internal` 14/7 | +| compiler (byte-code compiler) | 4 303 (11) | — | — | 197 | — | + +Base R is R code over a C boundary: `base` alone has 409 distinct `.Internal` names in 514 call +sites and almost no `.Call`. `stats` is the opposite: 41 293 lines of C and Fortran behind 230 +`.Call` sites, i.e. the numerical half of `stats` is not R code at all. + +### 1.3 The interpreter's surface + +| item | count | source | +|---|---|---| +| `R_FunTab` entries | **773**, 498 distinct C functions (`do_*`) | `names.c:83-1028`; eval column `XYZ`, Y=1 internal, Z=1 builtin (`names.c:62-71`) | +| `.Primitive` | **210** (168 builtin, 42 special) | same | +| `.Internal` | **563** (555 builtin, 8 special) | same | +| `.Internal` names reached from `base`'s R code | 409 of 563 | §1.2 | +| SEXPTYPE codes | 24 (0-10, 13-25; `ANYSXP` 18 is a pseudo-type; `S4SXP` = `OBJSXP` 25) | `Rinternals.h:111-144` | +| grammar | 44 tokens, 22 precedence lines, **12 nonterminals**, 124 rule lines; the lexer is the 3 980-line C epilogue | `gram.y:399-418`, `:421-442`, `:444-569` | +| byte-code opcodes | 129 (`BCMISMATCH_OP` … `DECLNKSTK_OP`) | `eval.c:4602-4733` | +| JIT | on by default at level 3 | `eval.c:1460` ("turn JIT on by default"), overridable by `R_ENABLE_JIT` | +| test suite | `tests/`: 95 R files, 33 316 lines; 32 `.Rout.save` reference outputs | listing of `tests/` | + +R-ints §.Internal vs .Primitive (1926-1975): primitives "make no use of R code", `formals` and +`body` return `NULL` for them, and argument matching "can be handled differently"; `.Internal` +functions are preferred "wrapped in a closure" because that "ensures standard handling of named +and default arguments". So the port's surface is 210 primitives with bespoke matching plus 563 +internals called positionally from R closures. + +### 1.4 CRAN: the native-code wall, measured + +From the PACKAGES index (§0), counting each package name once: + +| measure | packages | share | +|---|---|---| +| packages on CRAN | 25 608 | 100 % | +| `NeedsCompilation: yes` | 5 620 | 22 % | +| `LinkingTo: Rcpp` (C++) | 3 402 | 13 % | +| **pure R and every hard dependency (Depends, Imports, LinkingTo), transitively, pure R** | **3 851** | **15 %** | + +A dependency missing from the index (Bioconductor, archived) counts as not runnable; base +packages count as runnable. So an interpreter with no native-code story runs 15 % of CRAN, not +the 78 % that need no compiler themselves: most pure-R packages import a compiled one. + +Ranking compiled packages by how many packages depend on them (hard dependencies, transitively), +then providing the top *k* natively (a Go implementation of the package): + +| top *k* provided natively | runnable packages | +|---|---| +| 0 | 3 851 | +| 1 (`rlang`) | 3 890 | +| 5 (+ `cli`, `glue`, `vctrs`, `Rcpp`) | 4 011 | +| 10 (+ `magrittr`, `lattice`, `utf8`, `tibble`, `Matrix`) | 4 235 | +| 20 | 5 484 | +| 50 | 7 440 | +| 100 | 10 099 (39 %) | + +The ranking is greedy by reach, not an optimal cover; it shows the shape: the wall is a few +dozen foundational packages (tidyverse substrate, Rcpp, Matrix, lattice), then a long tail. + +## 2. Reference semantics and architecture + +### 2.1 Pipeline + +``` +source ──lexer+parser (Go, clean-room)──▶ R language objects (LANGSXP/SYMSXP/constants, srcrefs) + ──▶ eval (AST walker): promises, argument matching, closures, specials/builtins, contexts + ──▶ 210 primitives (Go) ──▶ .Internal dispatch ──▶ 563 internals (Go) + ──▶ base/stats/utils R code (fetched, §2.6) ──▶ numerical kernels (go-ndarray, go-numerics, + go-statistics) ──▶ graphics engine ──▶ SVG device +``` + +### 2.2 Parser + +- R-lang §Parser (3977-4600) defines the three modes (REPL, `parse(file)`, `parse(text=)`, + 3994-4024), comments, tokens and expressions; `gram.y` fixes precedence (`:421-442`, 22 lines + from `?` down to `(`/`[`/`[[`) and has only 12 nonterminals: the grammar is small, the lexer + (3 980 lines of C) carries the detail. Its token list (`gram.y:401-418`) shows the + context-dependent part: `=` is `EQ_ASSIGN`, `EQ_SUB` or `EQ_FORMALS` by position, a symbol is + `SYMBOL`, `SYMBOL_FUNCTION_CALL`, `SYMBOL_SUB`, `SYMBOL_FORMALS` or `SYMBOL_PACKAGE`, plus + `PIPE` (`|>`), `PIPEBIND`, `PLACEHOLDER`, `COMMENT`, `LINE_DIRECTIVE`, `INCOMPLETE_STRING` + (the REPL's continuation state). +- `gram.y` is GPL and "based on the parser package, Copyright (C) 2009--2011 Romain Francois" + (`doc/COPYRIGHTS:611-616`): not translatable. Write a recursive-descent or Pratt parser from + R-lang plus the precedence table, and check it differentially. +- **Oracle**: `utils::getParseData()` (`src/library/utils/R/sourceutils.R:147`) returns the token + table (line/column ranges, token kind, parent id, text) that `parse(keep.source=TRUE)` + records; `deparse()` of the parsed expressions gives the tree. Compare both on every R file of + the 4.6.2 tree (724 files in the nine packages of §1.2, plus 95 in `tests/`) and on CRAN + sources. + +### 2.3 Evaluator core + +| topic | reference text | Go component | +|---|---|---| +| objects | R-ints §SEXPs (73); `Rinternals.h:111-144` | one Go struct per SEXPTYPE; attributes as a pairlist; `NA_integer_` = `INT_MIN`, `NA_real_` a NaN payload (to confirm on the oracle) | +| environments, lookup | R-ints §Environments (499); R-lang §Scope (1917) | frames as Go maps with insertion order where R exposes it (`ls(sorted=FALSE)`) | +| promises, lazy evaluation | R-ints §Argument evaluation (838-877): "replaces the actual argument list by a list of promises", defaults are "promises to be evaluated in the environment just created" | `Promise{expr, env, value, forced}`; `substitute`, `missing`, `force` | +| argument matching | R-lang §Functions / Evaluation (2171) | exact, partial, positional, `...`, `..1` | +| contexts, non-local exits | R-ints §Contexts (738); R-lang §Exception handling (3718) | Go `panic`/`recover` per context, never across a wasm callback; `on.exit`, `sys.call`, `sys.function`, `parent.frame` | +| S3 | R-lang §OOP (2460-2975); R-ints §Argument evaluation (864-877) on how a method sees the generic's promises | `UseMethod`, `NextMethod`, group generics `Math`/`Ops`/`Summary`/`Complex` | +| S4 | R-ints §S4 objects (1386); `methods` is 18 015 lines of R + 1 553 C | late milestone | +| copy semantics | R-ints §Argument evaluation (862): "The `NAMED` mechanism has been replaced by reference counting" | reference counts on vectors for copy-on-modify; observable through `tracemem` only | +| visibility | R-ints §Visibility (1773); `names.c:62-66` (eval column X) | per primitive, from the table | +| computing on the language | R-lang §Computing on the language (2975-3597) | `quote`, `bquote`, `eval`, `body<-`, `formals<-`, `match.call`, `sys.call` | + +No byte-code in v1: the 129-opcode engine (`eval.c:4602-4733`, `bcEval` `:7500`) and the +`compiler` package (4 303 lines of R) are a speed device; the AST evaluator is the semantics. +R itself runs with the JIT on (`eval.c:1460`), so the performance reference (fleet rule) is +GNU R **with** its JIT; a Go byte-code tier is a later, measured decision (§6). + +### 2.4 Primitives and internals + +The 210 primitives are the language (`if`, `for`, `function`, `<-`, `[`, `[[`, `$`, `@`, +arithmetic and comparison operators, `c`, `list`, `length`, `attr`, `class`, `UseMethod`, +`standardGeneric`, `.Internal` itself …) and are written first. The 563 internals are written in +the order base R's own code reaches them (409 names from `base/R`), measured by the oracle +harness: the first target is the set needed to load `base` without error. Riposte's README (l. +54-55) put that set at "about 15 primitive and external functions" for its own design; measure it +for ours instead of assuming. + +### 2.5 Numerical boundary inside base R + +| R facility | in R 4.6.2 | Go route | +|---|---|---| +| `sum` of doubles, other reductions | `rsum` accumulates in `LDOUBLE` (`summary.c:152-154`; also `:128`, `:176`, `:336`) | on arm64 macOS `long double` is `double`, on x86-64 Linux it is 80-bit extended: results are platform-dependent. Bit parity per platform needs a software 64-bit-mantissa accumulator; decide which platform the oracle is (§6) | +| `qr`, `lm`, `glm` | `qr(LAPACK=FALSE)` and `lm.fit` go through `dqrdc2`, "a *modification* of Linpack's dqrdc … for R" with "a limited column pivoting strategy" (`src/appl/dqrdc2.f:1-15`) | port LINPACK `dqrdc` (public domain status "unclear", `doc/COPYRIGHTS:619-627`) into go-ndarray and reproduce dqrdc2's pivoting rule from its documented behaviour, checked on the oracle; `qr(LAPACK=TRUE)` maps to go-ndarray QR | +| `solve`, `chol`, `svd`, `eigen`, `det`, `qr(LAPACK=TRUE)`, `rcond`, `norm` | 26 LAPACK routines (§3.3) | go-ndarray `linalg` (`lu.go`, `qr.go`, `cholesky.go`, `svd.go`, `eig.go`, `eigh.go`, `lstsq.go`, `norm.go`); missing pivoted Cholesky `dpstrf`, pivoted QR `dgeqp3`, condition estimators `dgecon`/`dtrcon` go **into go-ndarray** | +| d/p/q/r distribution functions, `gamma`, `beta`, `choose`, Bessel | Rmath, `src/nmath/` 18 847 lines, GPL-2+ | go-statistics/stats (distributions, "accuracy measured against mpmath") and go-numerics/special (gamma, beta, erf, incomplete gamma/beta, Bessel, zeta); parity with R is a **tolerance**, not bit equality (§4) | +| RNG | Mersenne-Twister default, normal by inversion, `sample` by rejection (`RNG.c:31-32`, `:50`) | MT19937 from its authors' reference (the MT part of `RNG.c` is "Copyright (C) 1997, 1999 Makoto Matsumoto and Takuji Nishimura", `doc/COPYRIGHTS:264-267`); seed scrambling, inversion and rejection sampling reproduced from `?RNG`/`?set.seed` and checked stream-for-stream on the oracle. go-statistics draws match **NumPy**, not R: the R streams are new code | +| regex `perl=FALSE` | TRE 0.8.0, BSD (`doc/COPYRIGHTS:446-452`), `src/extra/tre` 8 939 lines | **portable**: translate TRE to Go (POSIX ERE semantics, approximate matching for `agrep`) | +| regex `perl=TRUE` | PCRE2 (binary distributions, `doc/COPYRIGHTS:688-763`) | PCRE2 syntax front end on [go-regexp/engine](https://github.com/go-regexp/engine), as `julia.md` §3.3 plans for Julia; share it | +| `serialize`, `saveRDS`, `load` | R-ints §Serialization Formats (1092); XDR from `src/extra/xdr` (BSD) | Go reader/writer from the manual; also the oracle's wire format (§4) | +| `radixsort` (`order`, `sort(method="radix")`) | `src/main/radixsort.c` "largely based on code from the data.table package" (`doc/COPYRIGHTS:634-636`) | reimplement; results are defined (stable order) so equality is exact | + +### 2.6 Base library strategy: the licence decides it + +Julia's plan runs Base unchanged through the interpreter because Base is MIT (`julia.md` §2.6). +R's base packages are GPL-2+: `src/library/base/R/apply.R:4-9` is "free software … GNU General +Public License … version 2 … or (at your option) any later version", and `doc/COPYRIGHTS:8-14` +says "R as a whole is distributed under GPL version 2 or 3". The fleet rule (TEMPLATE §3) is +GPL = oracle only. Three options: + +| option | interpreter (Go) | base/stats/utils R code (≈ 100 000 lines) | wasm/notebook | cost | +|---|---|---|---|---| +| **A. clean-room everything** | BSD-3 | rewritten in Go or new R, from manuals + Rd + oracle | self-contained | rewriting 23 469 + 36 490 + 23 589 + 18 015 lines of R whose behaviour *is* the definition of R; Riposte planned exactly this ("reimplementing R internal functions in a limited subset of R", README l. 48) and stopped | +| **B. GPL port** | GPL-2+ (C may then be translated) | shipped | GPL-3 binaries, like webR (`LICENSE.md:15`) | fastest and most faithful; makes go-notebook binaries that embed it GPL; breaks the fleet's BSD-3 rule | +| **C. BSD interpreter, R code fetched** | BSD-3, clean-room (primitives, internals, parser, evaluator written from R-lang, R-ints, Rd pages and oracle behaviour; no `src/main` file open while its Go counterpart is written) | **not redistributed**: fetched from the pinned CRAN tarball on first use, checked against its SHA-256, cached — the go-tex/texmf pattern ("it redistributes nothing … a download is not a redistribution", fleet capability map) | the browser fetches the R files from a CORS-enabled mirror, then evaluates them (or a cached snapshot built on that machine) | the interpreter is the large part; base R code runs unchanged, so upstream fixes come for free | + +Recommendation: **C**, with B kept as the fallback if C's legal reading does not hold. This is +a licence question to put to the user before M2 (§6): this file does not settle it. Under C the +start-up image of `julia.md` §6 must be built on the user's machine (it contains GPL code), +never shipped. + +### 2.7 Graphics: an SVG device + +R-ints §Graphics Devices (2795-2839): a device is a `DevDesc` holding "a set of callback +functions that will be used by the graphics engine to obtain information about the device and +initiate actions (e.g. a new page, plotting a line or some text)", several optional (`activate`, `cap`, `deactivate`, `locator`, +`holdflush`, `mode`, `newFrameConfirm`, `path`, `raster`, `size`); `GEDevDesc` adds the display +list. The engine (`engine.c` 4 017, `plotmath.c` 3 237) sits between the device and two +systems: base graphics (`graphics` 8 341 R + 12 167 C) and grid (17 744 R + 11 483 C; ggplot2 +and lattice draw through grid). Plan: a Go graphics engine with one device writing SVG (text +metrics from go-opentype fonts, so `strwidth` agrees with what the SVG shows), and +`png`/`pdf` later through go-gfx and the fleet PDF writer. R's own `svg()` is a Cairo device +(`grDevices/src/cairo/cairoFns.c`), not a model to port. Exit test: base and grid example plots +compared to the oracle's `svg()` output structurally (element count, coordinates within a +tolerance), not byte for byte. + +## 3. Reference implementations and prior art + +### 3.1 Other R implementations + +| project | approach | native code (the CRAN wall) | licence | state (as read) | +|---|---|---|---|---| +| [Renjin](https://github.com/bedatadriven/renjin) | JVM interpreter; "a drop-in replacement for GNU R" (README 8-11) | **GCC-Bridge**: GCC front end → Gimple dumped as JSON → JVM class files, with a partial C library runtime (`tools/gcc-bridge/README.md:4-16`) | GPL-2+, "includes code from R which is redistributed here" (README 73-79) | last push 2023-09-26 | +| [FastR](https://github.com/oracle/fastr) | Truffle/GraalVM; "based on the GNU-R runtime" (README 74) | implements the R extensions C API; packages using it in hot paths "may exhibit slower performance … due to the high cost of transitions between the native and managed code", mitigated by the GraalVM LLVM runtime (README 48-54); runs `ggplot2`, `dplyr`, `Rcpp`, `Shiny` examples (38) | GPLv3 (README 87) | pushed 2026-07-30 | +| [pqR](https://github.com/radfordneal/pqR) | "a modification of R-2.15.0" for speed (README 5-11) | inherits GNU R's C API | GPL-2 or 3 (README 17-19) | last push 2020-07-23 | +| [Riposte](https://github.com/jtalbot/riposte) | new VM + tracing JIT for vector code (README 48) | "Initial support for R's C-level API … remains one of the big unknowns" (README 69-70) | BSD-2 (README 34) | last push 2015-09-24; roadmap: ~200 primitives (50 done), ~580 internals (30 done), July 2013 (README 57-62) | +| [webR](https://github.com/r-wasm/webr) | GNU R compiled to wasm with Emscripten, patches to R's source (README 4); Fortran through LLVM flang (21) | packages compiled to wasm too | R parts GPL; "webR binaries are distributed under the GNU GPL Version 3" (`LICENSE.md:5,15`) | pushed 2026-10-27 | + +What they learned, as read: every implementation that reached real-world R code either reused +GNU R's own base library and C (Renjin, FastR, pqR, webR — and therefore is GPL) or did not get +past the internals (Riposte). Every one that took on CRAN had to answer native code with a +compiler (GCC-Bridge, LLVM/Sulong, Emscripten). §1.4 puts a number on why: 85 % of CRAN is +behind a compiled package. A CGO-free Go interpreter has no such compiler; its answers are §5's +M8 (Go ports of the top blockers; a C-to-Go translation route was not studied here). + +### 3.2 Licences: what is portable + +| component | licence (source) | verdict | +|---|---|---| +| interpreter `src/main`, Rmath `src/nmath`, base package R code, `gram.y` | GPL-2+ (`doc/COPYRIGHTS:8-14`; file headers) | oracle only | +| `R.h`, `Rinternals.h`, `Rmath.h`, `R_ext/*.h` | LGPL-2.1+ (`doc/COPYRIGHTS:27-37`) | facts (type codes, API names) usable; not needed beyond SEXPTYPE codes and the serialization spec | +| LAPACK, reference BLAS | modified BSD (`src/modules/lapack/LICENSE`; `doc/COPYRIGHTS:289-360`); R's copy is LAPACK 3.12.1 and R "announced the intention never to update this collection" (`src/modules/lapack/README:1-9`) | portable, but go-ndarray owns linear algebra: add missing routines there | +| LINPACK (`src/appl/d*.f`) | "status … unclear: they are said to be in the public domain in the USA" (`doc/COPYRIGHTS:22-23`, `619-627`); `dqrdc2.f` is R Core's modification | port `dqrdc` from netlib; reproduce `dqrdc2`'s rule from behaviour | +| TRE regex | BSD-2 (`doc/COPYRIGHTS:446-481`) | portable | +| XDR | BSD (`doc/COPYRIGHTS:362-395`) | trivial in Go | +| tzone `strftime` | UCB BSD-style (`doc/COPYRIGHTS:483-502`) | portable; Go `time` covers most | +| Mersenne-Twister | Matsumoto & Nishimura (`doc/COPYRIGHTS:264-267`) | implement from the authors' reference | +| AS algorithms in nmath/stats (`qnorm`, `qbeta`, `pnt`, `swilk`, …), Loader's `dbinom`/`dpois` | "based in whole or in part on Applied Statistics algorithms (C) Royal Statistical Society"; Loader (C) Lucent (`doc/COPYRIGHTS:240-261`) | implement from the published papers (not opened here), not from nmath | +| R manuals (R-lang, R-ints) and Rd pages | documentation | the specification for a clean-room implementation | + +### 3.3 What the fleet already has + +| need | fleet library (observed 2026-11-01) | gap | +|---|---|---| +| dense linear algebra | go-ndarray/ndarray `linalg` (LU, QR, Cholesky, SVD, Eig, Eigh, lstsq, norm, BLAS kernels) | of the 26 routines `Lapack.c` calls (`dgecon dgeev dgeqp3 dgesdd dgesv dgetrf dlange dormqr dpotrf dpotri dpstrf dsyevr dtrcon dtrtrs ilaver` + 11 complex `z*`): pivoted QR, pivoted Cholesky, condition estimators, complex variants; LINPACK `dqrdc`/`dqrdc2` | +| distributions, tests | go-statistics/stats (normal … zipf, KS, t-tests, rank tests, KDE, bootstrap) | R's parameterisations and argument conventions (`lower.tail`, `log.p`); R-identical RNG streams | +| special functions | go-numerics/special | `choose`/`lchoose`, `psigamma` orders, `besselI(expon.scaled)` conventions, checked on Rmath as oracle | +| optimisation, integration | go-numerics/optimize, go-numerics/integrate | R's `optim`, `nlm`, `integrate` have their own algorithms (`src/appl/optim.c`, `lbfgsb.c`, `uncmin.c`, `integrate.c`, GPL) whose iterates users see: re-derive from the published methods, tolerance parity | +| data frames | go-dataframe/dataframe | the bridge to R `data.frame` (§5) | +| FFT | go-fft/fft | `fft`, `mvfft`: tolerance parity | +| plots | go-plot/plot (SVG/PNG figures), go-gfx/gfx | an R graphics *engine* and device (§2.7) is new | +| regex | go-regexp/engine (Onigmo) | TRE port; PCRE2 front end shared with go-embedded-julia | + +## 4. Validation + +### 4.1 Oracle: GNU R 4.6.2 + +| route | macOS arm64 | Linux x86-64 | root? | status | +|---|---|---|---|---| +| conda-forge `r-base` via micromamba | 4.6.2 published for `osx-arm64` | 4.6.2 for `linux-64` | no | **recommended**: one pinned version on both (api.anaconda.org file list, 2026-11-01) | +| pkgx | `r-project.org` recipe at 4.6.1, "not published" for darwin/aarch64 | 4.6.1 | no | not usable on the dev machine today; a go-pkgx recipe bump is the sovereign route later | +| CRAN macOS `.pkg` | `big-sur-arm64/base/` lists up to `R-4.5.3-arm64.pkg` | — | installer needs admin | no | +| Posit builds | — | `versions.json` lists 4.6.2 | no (tarball) | Linux only; not opened beyond the list | +| build from the tarball | gfortran + toolchain | same | no | the fallback; slow | + +Harness (M0): a Go program that starts **one** `R --vanilla --slave` process per worker and +speaks a framed protocol on stdin/stdout. Requests are R source text; replies are the value +`serialize(x, NULL, xdr = TRUE)` (R-ints §Serialization Formats) plus captured stdout, warnings +and the condition class/message. Using R's own serialization as the wire format avoids a JSON +package on the oracle side (jsonlite is a compiled CRAN package) and makes the Go RDS reader an +M0 deliverable that the interpreter needs anyway. Timeouts and memory limits per worker; golden +files regenerated by one command. The harness was **not run** in this session (fetching +micromamba was refused by the sandbox): every oracle statement here is a plan. + +### 4.2 Corpora + +- R's own `tests/`: 95 R files (33 316 lines), 32 with `.Rout.save` reference transcripts + (`arith`, `eval-etc`, `method-dispatch`, `reg-tests-*`, `print-tests`, `d-p-q-r-tests`, + `lapack`, `lm-tests`, …); R-ints §Testing R code (4878-4890) names `make test-Specific` and + `tests/no-segfault.Rin`'s stop list. These are the conformance suite, compared output line by + output line. +- Examples in the Rd files of the base packages (47 029 lines of Rd in `base` alone), run under + both. +- Parser corpus: the 724 R files of §1.2's nine packages, `tests/`, and the R sources of the + 3 851 runnable CRAN packages (§1.4). +- Fuzzing: parser, RDS reader (gzip-compressed by default: decompression bombs must be + refused), regex front ends. + +### 4.3 What "correct" means + +| layer | measure | +|---|---| +| parser | token table (`getParseData`) and `deparse` output identical | +| evaluator, base | printed output identical to `.Rout.save` / the oracle, warnings and error messages included | +| integer, logical, character, sorting, set operations, RNG streams | exact equality | +| double arithmetic in R code | exact where R computes in `double`; `sum`/`mean` per the `LDOUBLE` decision (§2.5) | +| Rmath via go-statistics / go-numerics | ULP distance to R reported per function, and to mpmath; the better of the two is not a defect, a worse one is | +| linear algebra via go-ndarray | backward error and residual; equality of `lm` coefficients to a stated tolerance; signs of QR/eigenvectors normalised before comparing | +| graphics | SVG structure and coordinates within a tolerance | + +## 5. Proposed home and plan + +Effort in the `bib/` scale: S days, M weeks, L months, XL several months. No repository was +created by this file; names follow the go-embedded-julia layout. + +| # | work item | org/repo | depends on | size | priority | exit test | +|---|---|---|---|---|---|---| +| M0 | oracle harness + RDS (serialize v3) reader/writer | go-embedded-r/r (`rds/`, `internal/oracle`) | conda-forge r-base 4.6.2 | S | 1 | golden values round-trip R → Go → R; RDS fuzzed | +| M1 | lexer + parser, srcrefs, `deparse` | go-embedded-r/r (`syntax/`) | M0 | M | 1 | `getParseData` + `deparse` identical on the 724 + 95 files and on CRAN sources; fuzzed with depth and size bounds | +| M2 | evaluator core: SEXPs, environments, promises, matching, closures, contexts/conditions, S3, the 210 primitives | go-embedded-r/r (`eval/`, `prim/`) | M1; **licence decision** (§2.6) | XL | 1 | `tests/simple-true.R`, `arith-true.R`, `eval-etc.R`, `any-all.R`, `method-dispatch.R` against `.Rout.save` | +| M3 | base: the 563 internals in reach order, base R code loaded per §2.6, lazy-load/start-up snapshot | go-embedded-r/r (`internal/…`, `base/`) | M2 | XL | 1 | `base` loads; `reg-tests-1*.R`, `print-tests.R`, `datetime*.R`, `complex.R`, `iec60559.R`, `utf8-regex.R`; base Rd examples | +| M4 | stats, utils, methods: Rmath on go-statistics/go-numerics, R RNG streams, `lm`/`glm` (dqrdc2 rule), `optim`, `nls`, formulas and model frames; S4 | go-embedded-r/r (`stats/`), kernels into go-ndarray / go-statistics / go-numerics | M3 | XL | 2 | `d-p-q-r-tests.R`, `p-r-random-tests.R` (streams exact), `lm-tests.R`, `lapack.R`, `reg-S4.R`; stats Rd examples | +| M4b | regex: TRE port (`perl=FALSE`, `agrep`) and PCRE2 front end on go-regexp/engine | go-regexp (new `tre` repo) + go-regexp/engine | — | M | 2 | `tests/utf8-regex.R`, `PCRE.R`, base `grep`/`regexpr`/`gsub` examples | +| M5 | graphics engine + SVG device; base graphics, grid | go-embedded-r/r (`graphics/`, `grdevices/svg`) | M3, go-opentype | L | 2 | base and grid example plots vs the oracle's `svg()` (§2.7); ggplot2 later (M8) | +| M6 | notebook bridge + go-scikit bindings | go-notebook/notebook (`analysis/r.go`, next to `julia.go`, `ruby.go`), go-scikit/r (new, next to go-scikit/ruby) | M3 | M | 2 | a mixed Go/R notebook transcript in go-notebook docs (`content/en/r.md`, next to `julia.md`, `ruby.md`) | +| M7 | wasm playground: lazy R core, base fetched and cached (§2.6), SVG through the existing sanitizer | go-notebook/playground | M5, M6 | M | 3 | first result time and size recorded against the Ruby core | +| M8 | CRAN: Go ports of the top blockers (`rlang`, `cli`, `glue`, `vctrs`, `magrittr`, `utf8`, `tibble`, …; `Rcpp` cannot be "ported", only its users) | `go-r-` orgs, as `go-ruby-` | M3 | XL (open-ended) | 3 | each package's own `tests/testthat` under the Go interpreter; the runnable count of §1.4 re-measured | +| — | site repos | go-embedded-r/{docs, go-embedded-r.github.io, .github, brand} via `ghnew` | — | S | 1 | org conformance | + +Value bridge (M6), following the Ruby/Julia contract (`julia.md` §5.1): one shared interpreter +per notebook; names a cell assigns (`<-`, `=`, `->`, `<<-` at top level, from the M1 parser) are +its definitions; `NULL` ↔ nil; length-1 logical/integer/double/complex/character ↔ `bool`, +`int32`/`int`, `float64`, `complex128`, `string` with NA as a distinct Go value (never a silent +zero); numeric vectors ↔ `[]float64` or `*ndarray.Array`; matrices and arrays (`dim` attribute) +↔ `*ndarray.Array` with **column-major** strides (the question `julia-runtime.md` §6.2 answered +for Julia: a view yes, without a copy no); `data.frame` ↔ go-dataframe; named lists ↔ ordered +maps; factors ↔ codes + levels; anything else an opaque `r.Value`. The VM exposes a public +extension API outside `internal/` from day one (`bib/ruby-reuse.md` §3), so go-scikit/r can +register Go functions as R closures in their own namespace. + +## 6. Risks and open questions + +- **Biggest risk: the licence of base R** (§2.6). Option C's premise, that a BSD interpreter may + fetch and run unmodified GPL R code it does not redistribute, is a legal reading, not a + verified opinion. If it fails, the choice is between A (rewrite ≈ 100 000 lines of R whose + behaviour defines the language — Riposte's unfinished road) and B (GPL, like every + implementation that reached real code). Decide before M2. +- **The CRAN wall** (§1.4): 15 % of CRAN runs on a pure interpreter; 100 Go package ports reach + 39 %. "R in the notebook" must be promised as base + recommended-like numerics + the ported + set, not as CRAN. +- **Clean-room discipline**: implementers must work from manuals, Rd pages and oracle behaviour, + never with `src/main` or `src/nmath` open; this file read them only to count. The rule must be + in the repository's CONTRIBUTING before the first commit. +- **Platform-dependent R results**: `LDOUBLE` accumulation (`summary.c:152-154`) differs between + the arm64 macOS and x86-64 Linux oracles. Pick the oracle platform per CI job and record it; + bit parity on x86-64 needs a software extended accumulator. +- **Interpretive speed**: GNU R runs with its byte-code JIT on (`eval.c:1460`); an AST walker + will be slower on scalar loops. Vector operations (most R code) route to go-ndarray/go-simd + kernels and can be at par; report the ratio per milestone against R single-core, and open a + defect where slower, per the fleet rule. +- **Rmath parity is by tolerance**: go-statistics is accurate against mpmath and draws like NumPy; + R users compare printed digits. Where R and mpmath disagree, document it; never copy nmath. +- **Start-up**: R loads base from a lazy-load database (R-ints §Lazy loading, 1852); under + option C the Go equivalent is a snapshot built on the user's machine, so the first run in a + browser pays parsing and evaluating base R. Unknown until M3; measure against the Ruby core. +- **Security** (untrusted cells): fuzz the parser and RDS reader; bound recursion depth, + vector allocation (refuse beyond a configured limit with R's own "cannot allocate vector" + error class), regex steps; RDS bombs + refused under `GOMEMLIMIT`; `system()`, `file()`, `url()` absent on wasm and policy-gated on + the desktop. +- **Unverified in this file**: everything about running the oracle (not run); the Posit Linux + builds beyond their version list; the published AS / TOMS / Loader papers (cited through + `doc/COPYRIGHTS` only); NA bit patterns; a C-to-Go translation route for CRAN native code + (not studied); the claim that `dqrdc2`'s rule can be reproduced from behaviour alone. + +Verified on 2026-11-01: every count in §1 and §2 by `tools/rcount` on the 4.6.2 tarball of §0; +licence texts read in `doc/COPYRIGHTS`, `src/modules/lapack/LICENSE` and file headers; manual +sections read at the cited lines; prior-art READMEs read through the GitHub API; fleet facts via +`gh repo list` and the cited files. diff --git a/tools/rcount/fuzz_test.go b/tools/rcount/fuzz_test.go new file mode 100644 index 0000000..ebb6164 --- /dev/null +++ b/tools/rcount/fuzz_test.go @@ -0,0 +1,39 @@ +package main + +import "testing" + +// FuzzParse checks that the readers never panic and that their counts stay +// consistent with each other. +func FuzzParse(f *testing.F) { + f.Add(namesC) + f.Add(gramY) + f.Add(evalC) + f.Add("FUNTAB R_FunTab[] =\n/* open") + f.Add(".Call(\"x\" # '\\") + f.Add(cranIndex) + f.Fuzz(func(t *testing.T, text string) { + ft := parseFunTab(text) + if ft.PrimBuiltin+ft.PrimSpecial+ft.IntBuiltin+ft.IntSpecial != ft.Entries || ft.CFuns > ft.Entries { + t.Fatalf("inconsistent %+v", ft) + } + g := parseGram(text) + if g.RuleLines+g.Epilogue > g.Lines || g.Nonterminals > g.RuleLines { + t.Fatalf("inconsistent %+v", g) + } + for _, op := range parseOpcodes(text) { + if op == "" { + t.Fatal("empty opcode") + } + } + if c := cranStats(text); c.Compiled > c.Packages || c.PureClosure > c.Packages-c.Compiled { + t.Fatalf("inconsistent %+v", c) + } + n := newNative() + n.add(text) + for k, s := range n.Sites { + if len(n.Names[k]) > s { + t.Fatalf("%s: %d names for %d sites", k, len(n.Names[k]), s) + } + } + }) +} diff --git a/tools/rcount/main.go b/tools/rcount/main.go new file mode 100644 index 0000000..d9f9d7f --- /dev/null +++ b/tools/rcount/main.go @@ -0,0 +1,594 @@ +// Command rcount takes the counts of bib/r.md on an R source tree (the +// release tarball from CRAN, unpacked): the .Primitive/.Internal table of +// src/main/names.c, the grammar of src/main/gram.y, the byte-code opcodes of +// src/main/eval.c, lines per language under the directories that matter for +// a Go port, and the native-code call sites in the R code of the base +// packages. +package main + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/spf13/cobra" +) + +// osExit is replaced by tests. +var osExit = os.Exit + +// sizeDirs are the directories, relative to src/, whose lines are counted. +var sizeDirs = []string{ + "main", "nmath", "appl", "extra/blas", "modules/lapack", + "library/base", "library/stats", "library/utils", "library/methods", + "library/graphics", "library/grDevices", "library/grid", "library/tools", + "library/compiler", "../tests", +} + +// nativePkgs are the packages whose R/ code is scanned for native calls. +var nativePkgs = []string{"base", "stats", "utils", "methods", "graphics", "grDevices", "grid", "tools"} + +// nativeKinds are the interfaces from R code to compiled code. +var nativeKinds = []string{".Internal", ".Primitive", ".Call", ".External", ".External2", ".C", ".Fortran"} + +// FunTab is the summary of R_FunTab in names.c. +type FunTab struct { + Entries, CFuns int + PrimBuiltin, PrimSpecial int + IntBuiltin, IntSpecial int +} + +// The offset column is a number or a symbolic constant (PLUSOP, ...). +var entryRE = regexp.MustCompile(`(?m)^[ \t]*\{"([^"]+)",\s*(\w+),\s*[\w-]+,\s*(\d+),`) + +// parseFunTab reads the entries of R_FunTab up to its {NULL terminator. The +// eval column is XYZ: Y=1 is .Internal, else .Primitive; Z=1 is a builtin +// (arguments evaluated), Z=0 a special (names.c, header comment). +func parseFunTab(src string) FunTab { + var f FunTab + start := strings.Index(src, "FUNTAB R_FunTab[] =") + if start < 0 { + return f + } + body := src[start:] + if end := strings.Index(body, "{NULL"); end >= 0 { + body = body[:end] + } + cfuns := map[string]bool{} + for _, m := range entryRE.FindAllStringSubmatch(stripCComments(body), -1) { + ev, _ := strconv.Atoi(m[3]) + f.Entries++ + cfuns[m[2]] = true + internal, builtin := (ev%100)/10 == 1, ev%10 == 1 + switch { + case internal && builtin: + f.IntBuiltin++ + case internal: + f.IntSpecial++ + case builtin: + f.PrimBuiltin++ + default: + f.PrimSpecial++ + } + } + f.CFuns = len(cfuns) + return f +} + +// stripCComments blanks /* */ comments, keeping their newlines; an +// unterminated comment runs to the end. +func stripCComments(s string) string { + var b strings.Builder + for { + i := strings.Index(s, "/*") + if i < 0 { + b.WriteString(s) + return b.String() + } + b.WriteString(s[:i]) + j := strings.Index(s[i+2:], "*/") + if j < 0 { + return b.String() + } + end := i + 2 + j + 2 + b.WriteString(strings.Repeat("\n", strings.Count(s[i:end], "\n"))) + s = s[end:] + } +} + +// Gram is the summary of a yacc grammar. +type Gram struct{ Lines, Tokens, Nonterminals, RuleLines, Epilogue int } + +var ntRE = regexp.MustCompile(`^[a-z_]+\s*:`) + +func parseGram(src string) Gram { + var g Gram + section := 0 + for _, ln := range strings.Split(strings.TrimSuffix(src, "\n"), "\n") { + g.Lines++ + switch { + case strings.TrimSpace(ln) == "%%": + section++ + case section == 0 && strings.HasPrefix(ln, "%token"): + for _, t := range strings.Fields(ln)[1:] { + if !strings.HasPrefix(t, "<") { + g.Tokens++ + } + } + case section == 1: + g.RuleLines++ + if ntRE.MatchString(ln) { + g.Nonterminals++ + } + case section > 1: + g.Epilogue++ + } + } + return g +} + +var opRE = regexp.MustCompile(`(?m)^\s*([A-Z0-9_]+_OP),`) + +// parseOpcodes returns the byte-code opcodes of the enum that ends in OPCOUNT. +func parseOpcodes(src string) []string { + end := strings.Index(src, "OPCOUNT\n") + if end < 0 { + return nil + } + start := strings.LastIndex(src[:end], "enum {") + if start < 0 { + return nil + } + var ops []string + for _, m := range opRE.FindAllStringSubmatch(src[start:end], -1) { + ops = append(ops, m[1]) + } + return ops +} + +var f77RE = regexp.MustCompile(`F77_CALL\(\s*(\w+)\s*\)`) + +// f77Callees returns the sorted distinct Fortran routines named by F77_CALL +// outside comments. +func f77Callees(src string) []string { + seen := map[string]bool{} + var out []string + for _, m := range f77RE.FindAllStringSubmatch(stripCComments(src), -1) { + if !seen[m[1]] { + seen[m[1]] = true + out = append(out, m[1]) + } + } + sort.Strings(out) + return out +} + +// countLines is wc -l plus one for a final unterminated line. +func countLines(b []byte) int { + n := strings.Count(string(b), "\n") + if len(b) > 0 && b[len(b)-1] != '\n' { + n++ + } + return n +} + +// lang classifies a file by extension; "" means not counted. +func lang(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".r": + return "R" + case ".c", ".h": + return "C" + case ".f", ".f90": + return "Fortran" + case ".y": + return "yacc" + case ".rd": + return "Rd" + } + return "" +} + +// Stat is a file and line count. +type Stat struct{ Files, Lines int } + +// walk calls fn with the contents of every counted file under root. +func walk(root string, fn func(path string, b []byte)) error { + return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || lang(p) == "" { + return nil + } + b, err := os.ReadFile(p) + if err != nil { + return err + } + fn(p, b) + return nil + }) +} + +var ( + internalRE = regexp.MustCompile(`\.Internal\(\s*([A-Za-z.][\w.]*)\s*\(`) + primRE = regexp.MustCompile(`\.Primitive\(\s*"([^"]+)"`) + callRE = regexp.MustCompile(`(^|[^\w.])(\.Call|\.External2|\.External|\.C|\.Fortran)\(\s*([A-Za-z_.][\w.]*|"[^"]*")`) +) + +// stripRComments drops # comments that are outside string literals. +func stripRComments(src string) string { + lines := strings.Split(src, "\n") + for k, ln := range lines { + var q byte + for i := 0; i < len(ln); i++ { + c := ln[i] + switch { + case q != 0 && c == '\\': + i++ + case q != 0 && c == q: + q = 0 + case q != 0: + case c == '"' || c == '\'' || c == '`': + q = c + case c == '#': + ln = ln[:i] + } + } + lines[k] = ln + } + return strings.Join(lines, "\n") +} + +// Native counts call sites and distinct callee names per interface. +type Native struct { + Sites map[string]int + Names map[string]map[string]bool +} + +func newNative() Native { + n := Native{Sites: map[string]int{}, Names: map[string]map[string]bool{}} + for _, k := range nativeKinds { + n.Names[k] = map[string]bool{} + } + return n +} + +func (n Native) add(src string) { + src = stripRComments(src) + for _, m := range internalRE.FindAllStringSubmatch(src, -1) { + n.Sites[".Internal"]++ + n.Names[".Internal"][m[1]] = true + } + for _, m := range primRE.FindAllStringSubmatch(src, -1) { + n.Sites[".Primitive"]++ + n.Names[".Primitive"][m[1]] = true + } + for _, m := range callRE.FindAllStringSubmatch(src, -1) { + n.Sites[m[2]]++ + n.Names[m[2]][m[3]] = true + } +} + +func report(w io.Writer, root string) error { + src := filepath.Join(root, "src") + read := func(rel string) (string, error) { + b, err := os.ReadFile(filepath.Join(src, rel)) + return string(b), err + } + text, err := read("main/names.c") + if err != nil { + return err + } + ft := parseFunTab(text) + if ft.Entries == 0 { + return fmt.Errorf("main/names.c: no R_FunTab entry found") + } + fmt.Fprintf(w, "R_FunTab %d entries, %d distinct C functions\n", ft.Entries, ft.CFuns) + fmt.Fprintf(w, " .Primitive %d (builtin %d, special %d)\n", ft.PrimBuiltin+ft.PrimSpecial, ft.PrimBuiltin, ft.PrimSpecial) + fmt.Fprintf(w, " .Internal %d (builtin %d, special %d)\n", ft.IntBuiltin+ft.IntSpecial, ft.IntBuiltin, ft.IntSpecial) + + if text, err = read("main/gram.y"); err != nil { + return err + } + g := parseGram(text) + if g.Nonterminals == 0 { + return fmt.Errorf("main/gram.y: no grammar rule found") + } + fmt.Fprintf(w, "gram.y %d lines: %d tokens, %d nonterminals, %d rule lines, %d epilogue lines\n", + g.Lines, g.Tokens, g.Nonterminals, g.RuleLines, g.Epilogue) + + if text, err = read("main/eval.c"); err != nil { + return err + } + ops := parseOpcodes(text) + if len(ops) == 0 { + return fmt.Errorf("main/eval.c: no byte-code opcode found") + } + fmt.Fprintf(w, "byte-code opcodes %d (%s .. %s)\n", len(ops), ops[0], ops[len(ops)-1]) + + if text, err = read("modules/lapack/Lapack.c"); err != nil { + return err + } + callees := f77Callees(text) + if len(callees) == 0 { + return fmt.Errorf("modules/lapack/Lapack.c: no F77_CALL found") + } + fmt.Fprintf(w, "LAPACK/BLAS routines called by modules/lapack/Lapack.c %d: %s\n", len(callees), strings.Join(callees, " ")) + + fmt.Fprintln(w, "lines per language") + for _, dir := range sizeDirs { + st := map[string]Stat{} + if err := walk(filepath.Join(src, dir), func(p string, b []byte) { + s := st[lang(p)] + s.Files++ + s.Lines += countLines(b) + st[lang(p)] = s + }); err != nil { + return err + } + for _, l := range []string{"R", "C", "Fortran", "yacc", "Rd"} { + if s, ok := st[l]; ok { + fmt.Fprintf(w, " %s\t%s\t%d files\t%d lines\n", dir, l, s.Files, s.Lines) + } + } + } + + fmt.Fprintln(w, "native call sites in R code (sites/distinct names)") + for _, pkg := range nativePkgs { + n := newNative() + if err := walk(filepath.Join(src, "library", pkg, "R"), func(_ string, b []byte) { n.add(string(b)) }); err != nil { + return err + } + fmt.Fprintf(w, " %s", pkg) + for _, k := range nativeKinds { + fmt.Fprintf(w, "\t%s %d/%d", k, n.Sites[k], len(n.Names[k])) + } + fmt.Fprintln(w) + } + return nil +} + +// basePkgs are the packages shipped with R itself (src/library), plus the +// pseudo-dependency "R". +var basePkgs = map[string]bool{ + "R": true, "base": true, "compiler": true, "datasets": true, "graphics": true, "grDevices": true, + "grid": true, "methods": true, "parallel": true, "splines": true, "stats": true, "stats4": true, + "tcltk": true, "tools": true, "utils": true, +} + +// CRAN summarises a CRAN src/contrib/PACKAGES index. +type CRAN struct { + Packages, Compiled, Rcpp int + // PureClosure counts packages that need no compilation and whose hard + // dependencies (Depends, Imports, LinkingTo), transitively, need none + // either; a dependency absent from the index counts as compiled. + PureClosure int +} + +var depVersionRE = regexp.MustCompile(`\([^)]*\)`) + +// parseDCF splits a DCF file into records of fields; continuation lines +// (starting with a blank) are joined to the previous field. +func parseDCF(text string) []map[string]string { + var recs []map[string]string + var cur map[string]string + last := "" + for _, ln := range strings.Split(text, "\n") { + switch { + case strings.TrimSpace(ln) == "": + cur = nil + case ln[0] == ' ' || ln[0] == '\t': + if cur != nil && last != "" { + cur[last] += " " + strings.TrimSpace(ln) + } + default: + k, v, ok := strings.Cut(ln, ":") + if !ok { + continue + } + if cur == nil { + cur = map[string]string{} + recs = append(recs, cur) + } + last = k + cur[k] = strings.TrimSpace(v) + } + } + return recs +} + +func deps(rec map[string]string) []string { + var out []string + for _, f := range []string{"Depends", "Imports", "LinkingTo"} { + for _, d := range strings.Split(depVersionRE.ReplaceAllString(rec[f], ""), ",") { + if d = strings.TrimSpace(d); d != "" { + out = append(out, d) + } + } + } + return out +} + +// index is a parsed CRAN PACKAGES file. +type index map[string]map[string]string + +func parseIndex(text string) index { + ix := index{} + for _, r := range parseDCF(text) { + if r["Package"] != "" { + ix[r["Package"]] = r + } + } + return ix +} + +// closure counts the packages runnable when every package in avail (and +// every base package) is provided natively: a package is runnable if it is +// in avail, or needs no compilation and all its hard dependencies are +// runnable. A dependency absent from the index is not runnable; a +// dependency cycle is not runnable (CRAN forbids hard cycles). +func (ix index) closure(avail map[string]bool) int { + state := map[string]int{} // 1 runnable, 2 not, 3 being visited + var ok func(p string) bool + ok = func(p string) bool { + if basePkgs[p] || avail[p] { + return true + } + switch state[p] { + case 1: + return true + case 2, 3: + return false + } + r, found := ix[p] + if !found || r["NeedsCompilation"] == "yes" { + state[p] = 2 + return false + } + state[p] = 3 + for _, d := range deps(r) { + if !ok(d) { + state[p] = 2 + return false + } + } + state[p] = 1 + return true + } + n := 0 + for p := range ix { + if ok(p) { + n++ + } + } + return n +} + +// blockers ranks the compiled packages by how many packages depend on them, +// directly or transitively, through hard dependencies (ties by name). +func (ix index) blockers() []string { + rev := map[string][]string{} + for p, r := range ix { + for _, d := range deps(r) { + rev[d] = append(rev[d], p) + } + } + reach := map[string]int{} + var compiled []string + for p, r := range ix { + if r["NeedsCompilation"] != "yes" { + continue + } + compiled = append(compiled, p) + seen := map[string]bool{p: true} + queue := []string{p} + for len(queue) > 0 { + q := queue[0] + queue = queue[1:] + for _, u := range rev[q] { + if !seen[u] { + seen[u] = true + queue = append(queue, u) + } + } + } + reach[p] = len(seen) - 1 + } + sort.Slice(compiled, func(i, j int) bool { + a, b := compiled[i], compiled[j] + if reach[a] != reach[b] { + return reach[a] > reach[b] + } + return a < b + }) + return compiled +} + +func cranStats(text string) CRAN { + var c CRAN + ix := parseIndex(text) + for _, r := range ix { + c.Packages++ + if r["NeedsCompilation"] == "yes" { + c.Compiled++ + } + for _, d := range strings.Split(depVersionRE.ReplaceAllString(r["LinkingTo"], ""), ",") { + if strings.TrimSpace(d) == "Rcpp" { + c.Rcpp++ + } + } + } + c.PureClosure = ix.closure(nil) + return c +} + +// unlockSteps are the numbers of top blockers provided natively in the +// unlock table. +var unlockSteps = []int{1, 5, 10, 20, 50, 100} + +// unlockTable writes how many packages become runnable when the top-k +// blockers are provided natively, for each k of unlockSteps. +func unlockTable(w io.Writer, ix index) { + order := ix.blockers() + top := 10 + if len(order) < top { + top = len(order) + } + fmt.Fprintf(w, " top compiled blockers: %s\n", strings.Join(order[:top], " ")) + for _, k := range unlockSteps { + if k > len(order) { + break + } + avail := map[string]bool{} + for _, p := range order[:k] { + avail[p] = true + } + fmt.Fprintf(w, " runnable with top %d provided natively: %d\n", k, ix.closure(avail)) + } +} + +// RootCmd is the rcount command. +func RootCmd() *cobra.Command { + var cranFile string + cmd := &cobra.Command{ + Use: "rcount ", + Short: "Count the primitives, internals, grammar, opcodes and lines of an R source tree", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := report(cmd.OutOrStdout(), args[0]); err != nil { + return err + } + if cranFile == "" { + return nil + } + b, err := os.ReadFile(cranFile) + if err != nil { + return err + } + c := cranStats(string(b)) + if c.Packages == 0 { + return fmt.Errorf("%s: no package record found", cranFile) + } + fmt.Fprintf(cmd.OutOrStdout(), "CRAN %d packages: NeedsCompilation yes %d, LinkingTo Rcpp %d, pure R with pure-R hard dependencies %d\n", + c.Packages, c.Compiled, c.Rcpp, c.PureClosure) + unlockTable(cmd.OutOrStdout(), parseIndex(string(b))) + return nil + }, + } + cmd.Flags().StringVar(&cranFile, "cran", "", "a CRAN src/contrib/PACKAGES index to summarise") + return cmd +} + +func main() { + if err := RootCmd().Execute(); err != nil { + osExit(1) + } +} diff --git a/tools/rcount/main_test.go b/tools/rcount/main_test.go new file mode 100644 index 0000000..61a0206 --- /dev/null +++ b/tools/rcount/main_test.go @@ -0,0 +1,331 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// namesC plants every eval-column case, a commented-out entry, an entry +// before the table and one after its terminator: only the six real ones count. +const namesC = `{"before", do_before, 0, 1, 1, {PP_FUNCALL, PREC_FN, 0}}, +FUNTAB R_FunTab[] = +{ +/* {"commented", do_c, 0, 11, 1, {PP_FUNCALL, PREC_FN, 0}}, */ +{"if", do_if, 0, 200, -1, {PP_IF, PREC_FN, 1}}, +{"c", do_c, 0, 1, -1, {PP_FUNCALL, PREC_FN, 0}}, +{"paste", do_paste, 0, 11, -1, {PP_FUNCALL, PREC_FN, 0}}, +{"eval", do_eval, 0, 211, 3, {PP_FUNCALL, PREC_FN, 0}}, +{"quit", do_quit, PLUSOP, 111, 3, {PP_FUNCALL, PREC_FN, 0}}, +{"sp", do_paste, 1, 10, 3, {PP_FUNCALL, PREC_FN, 0}}, +{NULL, NULL, 0, 0, 0, {PP_INVALID, PREC_FN, 0}}, +{"after", do_after, 0, 1, 1, {PP_FUNCALL}}, +` + +const gramY = "%token A B\n%token C\n%%\nprog : A\n | B\n ;\nexpr: C ;\n%%\nint x;\nint y;\n" + +const evalC = "enum { X_OP, };\nenum {\n BCMISMATCH_OP,\n RETURN_OP,\n DECLNKSTK_OP,\n OPCOUNT\n};\n" + +const lapackC = "F77_CALL(dgesv)(&n);\n/* F77_CALL(dcomment)(x) */\nF77_CALL( dgeqp3 )(a); F77_CALL(dgesv)(&m);\n" + +func TestF77Callees(t *testing.T) { + if got := strings.Join(f77Callees(lapackC), " "); got != "dgeqp3 dgesv" { + t.Fatalf("got %q", got) + } +} + +func TestParseFunTab(t *testing.T) { + f := parseFunTab(namesC) + want := FunTab{Entries: 6, CFuns: 5, PrimBuiltin: 1, PrimSpecial: 1, IntBuiltin: 3, IntSpecial: 1} + if f != want { + t.Fatalf("got %+v, want %+v", f, want) + } + if f := parseFunTab("no table"); f.Entries != 0 { + t.Fatalf("no table: %+v", f) + } + // Without a terminator the table runs to the end. + if f := parseFunTab("FUNTAB R_FunTab[] =\n{\"c\", do_c, 0, 1, -1, {PP_FUNCALL}},\n"); f.Entries != 1 { + t.Fatalf("unterminated: %+v", f) + } +} + +func TestStripCComments(t *testing.T) { + if got := stripCComments("a/* x\ny */b/* open"); got != "a\nb" { + t.Fatalf("got %q", got) + } +} + +func TestParseGram(t *testing.T) { + g := parseGram(gramY) + want := Gram{Lines: 10, Tokens: 3, Nonterminals: 2, RuleLines: 4, Epilogue: 2} + if g != want { + t.Fatalf("got %+v, want %+v", g, want) + } +} + +func TestParseOpcodes(t *testing.T) { + ops := parseOpcodes(evalC) + if strings.Join(ops, " ") != "BCMISMATCH_OP RETURN_OP DECLNKSTK_OP" { + t.Fatalf("got %v", ops) + } + if parseOpcodes("enum { A_OP, };") != nil { + t.Fatal("no OPCOUNT must give nil") + } + if parseOpcodes(" A_OP,\n OPCOUNT\n") != nil { + t.Fatal("no enum must give nil") + } +} + +func TestCountLines(t *testing.T) { + for in, want := range map[string]int{"": 0, "a": 1, "a\n": 1, "a\nb": 2} { + if got := countLines([]byte(in)); got != want { + t.Errorf("countLines(%q) = %d, want %d", in, got, want) + } + } +} + +func TestLang(t *testing.T) { + for in, want := range map[string]string{ + "a.R": "R", "a.r": "R", "a.c": "C", "a.h": "C", "a.f": "Fortran", "a.f90": "Fortran", + "gram.y": "yacc", "x.Rd": "Rd", "Makefile.in": "", "a.Rout.save": "", + } { + if got := lang(in); got != want { + t.Errorf("lang(%q) = %q, want %q", in, got, want) + } + } +} + +func TestNative(t *testing.T) { + n := newNative() + n.add(`f <- function(x) .Internal(paste0(x, NULL)) +# .Internal(commented(x)) +g <- function() .Call(C_foo, 1); h <- .External2(C_bar) +s <- "a # not a comment .Internal(instr(1))"; e <- 'it\'s # still a string' +k <- .Primitive("c"); my.C(1); .C("cfun", 1L); .Fortran(C_lminfl, 2) # .Call(C_no) +.Call(C_foo, 2)`) + want := map[string]int{".Internal": 2, ".Primitive": 1, ".Call": 2, ".External2": 1, ".External": 0, ".C": 1, ".Fortran": 1} + for k, v := range want { + if n.Sites[k] != v { + t.Errorf("%s sites = %d, want %d", k, n.Sites[k], v) + } + } + if len(n.Names[".Call"]) != 1 { + t.Errorf(".Call names = %v, want one", n.Names[".Call"]) + } +} + +func write(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func tree(t *testing.T) string { + t.Helper() + root := t.TempDir() + src := filepath.Join(root, "src") + write(t, filepath.Join(src, "main/names.c"), namesC) + write(t, filepath.Join(src, "main/gram.y"), gramY) + write(t, filepath.Join(src, "main/eval.c"), evalC) + write(t, filepath.Join(src, "modules/lapack/Lapack.c"), lapackC) + for _, d := range sizeDirs { + write(t, filepath.Join(src, d, "README"), "not counted\n") + } + for _, p := range nativePkgs { + write(t, filepath.Join(src, "library", p, "R", "a.R"), "f <- function() .Call(C_x)\n") + } + write(t, filepath.Join(src, "nmath/pnorm.c"), "a\nb\nc\n") + write(t, filepath.Join(src, "appl/x.f"), "a\n") + return root +} + +func run(t *testing.T, root string) (string, error) { + t.Helper() + var out bytes.Buffer + cmd := RootCmd() + cmd.SetArgs([]string{root}) + cmd.SetOut(&out) + cmd.SetErr(&out) + err := cmd.Execute() + return out.String(), err +} + +func TestRootCmd(t *testing.T) { + out, err := run(t, tree(t)) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "R_FunTab 6 entries, 5 distinct C functions\n", + " .Primitive 2 (builtin 1, special 1)\n", + " .Internal 4 (builtin 3, special 1)\n", + "gram.y 10 lines: 3 tokens, 2 nonterminals, 4 rule lines, 2 epilogue lines\n", + "byte-code opcodes 3 (BCMISMATCH_OP .. DECLNKSTK_OP)\n", + "LAPACK/BLAS routines called by modules/lapack/Lapack.c 2: dgeqp3 dgesv\n", + " modules/lapack\tC\t1 files\t3 lines\n", + " main\tC\t2 files\t", + " main\tyacc\t1 files\t10 lines\n", + " nmath\tC\t1 files\t3 lines\n", + " appl\tFortran\t1 files\t1 lines\n", + " library/base\tR\t1 files\t1 lines\n", + " base\t.Internal 0/0\t.Primitive 0/0\t.Call 1/1\t", + } { + if !strings.Contains(out, want) { + t.Errorf("output lacks %q:\n%s", want, out) + } + } +} + +// Every file and directory the report reads is required, and an empty table +// is a reading error, never a count of zero. +func TestRootCmdBrokenTree(t *testing.T) { + for name, edit := range map[string]func(src string) error{ + "no names.c": func(src string) error { return os.Remove(filepath.Join(src, "main/names.c")) }, + "empty FunTab": func(src string) error { return os.WriteFile(filepath.Join(src, "main/names.c"), []byte("x"), 0o644) }, + "no gram.y": func(src string) error { return os.Remove(filepath.Join(src, "main/gram.y")) }, + "empty grammar": func(src string) error { return os.WriteFile(filepath.Join(src, "main/gram.y"), []byte("%%\n"), 0o644) }, + "no eval.c": func(src string) error { return os.Remove(filepath.Join(src, "main/eval.c")) }, + "no opcodes": func(src string) error { return os.WriteFile(filepath.Join(src, "main/eval.c"), []byte("x"), 0o644) }, + "no Lapack.c": func(src string) error { return os.Remove(filepath.Join(src, "modules/lapack/Lapack.c")) }, + "no F77_CALL": func(src string) error { + return os.WriteFile(filepath.Join(src, "modules/lapack/Lapack.c"), []byte("x"), 0o644) + }, + "no size dir": func(src string) error { return os.RemoveAll(filepath.Join(src, "library/compiler")) }, + "no R dir": func(src string) error { return os.RemoveAll(filepath.Join(src, "library/grid/R")) }, + "unreadable": func(src string) error { return os.Chmod(filepath.Join(src, "nmath/pnorm.c"), 0) }, + } { + t.Run(name, func(t *testing.T) { + root := tree(t) + if err := edit(filepath.Join(root, "src")); err != nil { + t.Fatal(err) + } + if name == "unreadable" && os.Getuid() == 0 { + t.Skip("root reads a mode-0 file") + } + if _, err := run(t, root); err == nil { + t.Fatal("want an error") + } + }) + } +} + +// cranIndex plants: a pure package on base only (a), a pure package whose +// dependency compiles (b -> c), a compiled one (c), an Rcpp user (d), a +// pure package on a missing dependency (e), a pure cycle (f <-> g), a pure +// chain on a pure package (h -> a, twice so the cache answers), a record +// without Package and a line without a colon. +const cranIndex = `Package: a +Depends: R (>= 4.1), stats +NeedsCompilation: no + +Package: b +Imports: c (>= + 1.0), a +NeedsCompilation: no + +Package: c +NeedsCompilation: yes + +Package: d +LinkingTo: Rcpp (>= 1.0), BH +NeedsCompilation: yes + +Package: e +Imports: bioconductorThing +NeedsCompilation: no + +Package: f +Imports: g + +Package: g +Imports: f + +Package: i +NeedsCompilation: yes + +Package: h +Imports: a, a + +Version: 1.0 +no colon here +` + +func TestCranStats(t *testing.T) { + got := cranStats(cranIndex) + want := CRAN{Packages: 9, Compiled: 3, Rcpp: 1, PureClosure: 2} + if got != want { + t.Fatalf("got %+v, want %+v", got, want) + } + if recs := parseDCF(" orphan continuation\nA: 1\n"); len(recs) != 1 || recs[0]["A"] != "1" { + t.Fatalf("orphan continuation: %v", recs) + } +} + +func TestRootCmdCran(t *testing.T) { + root := tree(t) + idx := filepath.Join(t.TempDir(), "PACKAGES") + write(t, idx, cranIndex) + var out bytes.Buffer + cmd := RootCmd() + cmd.SetArgs([]string{"--cran", idx, root}) + cmd.SetOut(&out) + cmd.SetErr(&out) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "CRAN 9 packages: NeedsCompilation yes 3, LinkingTo Rcpp 1, pure R with pure-R hard dependencies 2\n", + " top compiled blockers: c d i\n", // d and i tie at zero dependents: by name + " runnable with top 1 provided natively: 4\n", // a, h, and now c and b + } { + if !strings.Contains(out.String(), want) { + t.Errorf("output lacks %q:\n%s", want, out.String()) + } + } + if strings.Contains(out.String(), "top 5 provided") { + t.Errorf("a step beyond the compiled packages must not print:\n%s", out.String()) + } + for name, args := range map[string][]string{ + "missing index": {"--cran", filepath.Join(t.TempDir(), "absent"), root}, + "empty index": {"--cran", filepath.Join(root, "src/main/gram.y"), root}, + "broken tree": {"--cran", idx, t.TempDir()}, + } { + cmd := RootCmd() + cmd.SetArgs(args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + if err := cmd.Execute(); err == nil { + t.Errorf("%s: want an error", name) + } + } +} + +func TestMain_(t *testing.T) { + saved, savedExit, savedOut := os.Args, osExit, os.Stdout + defer func() { os.Args, osExit, os.Stdout = saved, savedExit, savedOut }() + devnull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + defer devnull.Close() + os.Stdout = devnull + code := -1 + osExit = func(c int) { code = c } + + os.Args = []string{"rcount", tree(t)} + main() + if code != -1 { + t.Fatalf("exit %d on a good tree", code) + } + os.Args = []string{"rcount"} + main() + if code != 1 { + t.Fatalf("exit %d without an argument, want 1", code) + } +}