no_std: not now¶
Status: decided for 0.0.21: not now. No code changes. This note
records what was measured, so the question doesn't have to be measured again
from scratch. It answers row 23 of the ratatui comparison's gap list
(no_std/alloc layer: low value, large; "not now; would be a new crate,
never rich by default"). It's linked from
intuiTUIve against ratatui.
The question¶
Should any rs-rich crate build with #![no_std] plus alloc, so that styles,
segments, text and cells can be used on targets without an operating system,
such as microcontrollers driving a small display?
What ratatui does¶
ratatui's no_std concept page (read from
the website's source, written
for ratatui 0.30.2) describes this:
ratatui = { default-features = false }builds withno_stdplusalloc.portable-atomiccovers targets without atomics.- Only widgets, layout, text and the buffer work there. None of the built-in
backends do: crossterm, termion and termwiz need
std. The app brings aBackend, for examplemousefoodforembedded-graphicsdisplays. The page's example is an ESP32 (riscv32imc-unknown-none-elf). - Widget authors depend on
ratatui-corewithout default features, write#![no_std]withextern crate alloc, usecoreandalloctypes, and keep astdfeature for conveniences. Data is passed in, not read from files. - The check is
cargo check --no-default-featureson ano_stdtarget.
This works for ratatui because its core never owned the terminal: a
Buffer of cells is the product, and output is the backend's job.
What we measured¶
On branch 021-nostd (cut from e264aa5, the 0.0.21 versions), Rust 1.99.
No crate in the workspace is no_std today.
The first blockers on a bare-metal target¶
cargo check --target x86_64-unknown-none (installed with rustup for this
note) stops in the dependencies, before any of our code is compiled:
| Crate (features) | Stops at | Why |
|---|---|---|
rs-rich (default) |
memchr |
built with its std feature, which fancy-regex's and serde_json's default std features turn on |
rs-rich --no-default-features |
memchr, regex-syntax |
the same, and regex-syntax's std, also from fancy-regex's defaults |
rs-rich-ext --no-default-features |
memchr, bit-vec |
the same; bit-vec's std comes from fancy-regex's defaults |
rs-rich-intuituive (no features) |
serde_core, among hundreds of errors |
serde's std feature, then everything below |
These first blockers are cheap: each of those crates has a no_std mode,
and only our feature choices turn std on. The ones behind them are not.
Dependencies¶
From cargo tree -e normal:
| Dependency | Pulled by | no_std? |
|---|---|---|
unicode-width, hashbrown, indexmap, memchr, bit-vec |
rich, rich-ext | yes |
fancy-regex 0.19 (and regex-automata, regex-syntax) |
rich, rich-ext | yes, with default-features = false (it has an alloc-only mode) |
serde_json |
rich (Console::print_json, JSON) |
yes, with its alloc feature instead of std |
terminal_size |
rich (Console's size) |
no: it asks the OS |
anstyle-query |
rich (colour-system detection) | no: it reads the environment |
syntect |
rich's default syntax feature |
no: files, std::sync, its own dumps |
pulldown-cmark |
rich's default markdown feature |
no |
rs-rich-plugin-api's inventory |
rich-ext | no_std, but it registers through platform static constructors |
crossterm, signal-hook, mio, filedescriptor, parking_lot |
rich-interact, so every intuiTUIve app | no: terminals, signals, file descriptors |
kasuari (the constraint solver) |
intuiTUIve | yes, with default-features = false |
Our own code¶
Counts of std:: paths in each crate's src/ (tests included, so they are
upper bounds), and what a #![no_std] attempt says.
rs-rich, 60 files. Adding #![no_std] and extern crate alloc to
lib.rs (a throwaway experiment, not committed) gives 1,276 errors. 1,066
are the missing std prelude (Vec, String, format!, …), which
use alloc::… fixes mechanically. 209 name std:: paths, led by
console.rs (48), progress.rs (46), prompt.rs (13), protocol.rs (12),
json.rs (12) and theme.rs (11). The ones with no core or alloc
equivalent:
| API | Where | Count |
|---|---|---|
std::io (Write, stdout, stdin, Result) |
console.rs, pager.rs, progress.rs, prompt.rs, live.rs |
58 |
std::sync beyond Arc and atomics (Mutex, OnceLock, LazyLock, mpsc) |
19 files, including markup.rs (compiled regexes), cells.rs and theme.rs |
about 35 |
std::env (var, var_os, temp_dir) |
console.rs (terminal detection, NO_COLOR, COLUMNS), cells.rs, theme.rs, pager.rs, progress.rs |
16 |
std::thread (current, spawn, ThreadId) |
console.rs, progress.rs, live.rs, tree.rs |
12 |
std::time::Instant |
console.rs (monotonic, the default get_time), progress.rs, live.rs |
3 |
std::fs, std::process, std::panic::catch_unwind |
progress.rs, theme.rs, pager.rs, console.rs, live.rs |
15 |
HashMap and HashSet with the default (random) hasher |
console.rs, json.rs, theme.rs, syntax_cache.rs |
9 |
The modules an alloc layer would want are close to clean on their own:
segment.rs uses only std::mem, style.rs only mem and hash, and
color.rs and control.rs nothing. But they are not separable along module
lines:
segment.rsandtext.rsimportconsole(forConsoleOptionsand the render protocol), andstyle.rsimportstextandtheme. Upstream's modules are shaped the same way, and the mirror keeps that shape.cells.rsreadsUNICODE_VERSIONfrom the environment once, into aOnceLock, as upstream does.markup.rskeeps its compiled regexes inLazyLocks.
rs-rich-ext --no-default-features: 56 of its modules are always
compiled. Of those, std::io is used in 19, std::time in 15, std::env
in 11, std::fs in 8, std::process in 8, HashMap in 8 and
std::thread in 3. Those are the crate's purpose (files, git, processes,
notifications, the clipboard, logging), not incidental. Its non-optional
dependencies add rs-rich-plugin-api and unicode-width.
rs-rich-intuituive with no features: 59 of its 67 modules are
compiled. Instant or another std::time type is used in 19 of them (23
calls to Instant::now(); 22 files use only Duration, which core has),
threads in 5 (app, jobs, reactive, state, task), the environment
in 5, processes in 5, files in 4, HashMap in 15, and Rc/RefCell in 32
(fine: alloc has both). Its dependency on rs-rich-interact brings in
crossterm and the rest of the terminal stack above, and rs-rich-interact
turns on rich's syntax feature, so syntect too.
The nearer goal: wasm in a browser¶
The docs playground spike (docs/design/docs-playground.md on
claude/next-priority-items-3c5p8e-wip-play, with the spike in
playground/spike/) asked the practical version of this question: can
rs-rich run where there is no terminal and no OS, in a browser?
wasm32-unknown-unknownhas astdthat compiles.rs-rich, both by default and--no-default-features, builds for it unchanged, and so dors-rich-ext(all its feature sets that were tried),rs-rich-diagram,rs-rich-data,rs-rich-mermaid,rs-rich-artandrs-rich-micro.- What fails to build is the host layer: crossterm in
rs-rich-interact(the spike swapped in a 20-line stub on wasm, so intuiTUIve builds too), PTYs inrs-rich-recordandrs-rich-web,cap-std/notify/ctrlcin the CLI, and tree-sitter's C build inrs-rich-lumis. - What builds but panics at run time is the same list as above:
Instant::now()andSystemTime::now()("time not implemented on this platform"), andstd::thread::spawn.console::monotonic()would panic, so the playground passes its own clock throughConsoleBuilder::get_time(upstream'sget_timeseam) and core needs no change.Live's andProgress's refresh threads and intuiTUIve's background tasks can't be used there. stdout,std::envandstd::fsreturn nothing orErr; they don't panic. - With those avoided, it works: markup, tables and
export_htmlrender in the page, and the intuiTUIve to-do app runs onApp::driverin xterm.js. The lean core module is about 444 KB gzipped.
So the portability users can actually reach today is wasm, and it costs
nothing in core: the seams it needs (get_time, Console::capture,
App::driver) are already there.
Options¶
A. A new alloc-only crate (rs-rich-core or similar)¶
A new crate, #![no_std] with alloc, holding the plain data: Color,
ColorTriplet, Style, Segment, Control, cell widths, perhaps Text
spans and markup parsing on fancy-regex without std. rich would
re-export its types, so nothing changes for users of rich.
- It is the only shape that fits AGENTS.md:
richkeeps its upstream modules and behaviour, and the new crate is ours. - But
richwould depend on it, and the types would move out of the mirror. Every upstream sync that touchesstyle.py,segment.py,color.py,cells.py,text.pyormarkup.pywould land in two crates. Those are the most-synced modules there are. That's a structural divergence, and it would need an accepted plan and a DIVERGENCES entry. - The cut is not clean (above):
SegmentandTextreachConsolethrough the render protocol,StylereachesTheme, andcellsreads the environment. Each needs a seam, and the seam is a change to ported code. - What it would buy is a
StyleandSegmenttype on a microcontroller. To draw anything there, an app would also wantText's wrapping,Table,Paneland a backend, and those live inConsole's render pipeline, which is I/O-shaped by design. ratatui got this cheaply because its core was a buffer from the start; ours is aConsole.
B. A no_std feature on rich¶
rich would get #![cfg_attr(not(feature = "std"), no_std)], with std
on by default and Console, Live, Progress, Prompt and the pager
behind it.
- A default build would still behave like upstream, so the letter of
AGENTS.md's third rule would hold. But making the feature work means
cfg-gating most of
console.rsand every renderable's use of it, and replacingstd::sync,HashMap's hasher andenvreads across 19+ files. That is an edit to almost every ported module for something upstream doesn't have, which is what the mirror rule exists to prevent: every sync would have to re-apply it. - Turning
stdoff by default (ratatui's shape) would change whatrs-richwith default features is. Ruled out.
C. Do nothing now¶
Keep the crates as they are. Treat wasm as the portability target, and keep the door to A open by not making it harder.
Costs¶
| A: new crate | B: feature on rich |
C: nothing | |
|---|---|---|---|
| Size | L: a crate, moved types, seams in segment/text/style/cells, a DIVERGENCES entry |
L: cfg gates across most of crates/rich |
none |
| Sync cost | every sync of the six busiest modules lands in two crates | every sync re-applies the gates | none |
| Who gains | embedded-display users, who haven't asked | the same | — |
| Risk | the mirror's module map stops matching upstream's | parity drift hidden behind a feature | none |
Decision¶
Not now (C). Nobody has asked for rs-rich on a microcontroller. The cost lands on the thing this repo protects most, upstream syncs. And the target users can actually reach, a browser, already works without any of it.
What would reopen it¶
Any one of these:
- a user, or a crate in the workspace, needs rs-rich's types on a target
with no
stdat all (*-none-*, an RTOS), not just without a terminal; - upstream
richsplits its data types away fromConsole, so a split here would follow upstream's shape instead of fighting it; - ratatui interop needs
rs-rich-ratatui's conversions in ano_stdapp. Today they takerich::StyleandSegment, so this would be option A's first user.
Reopening means a plan for option A, with a DIVERGENCES entry. Option B stays ruled out.
What we do meanwhile¶
- Keep new generic code free of needless
stdwhere it costs nothing. Inrich-ext,rich-interactand intuiTUIve's core: take time as a value (now: Instantor a clock passed in), notInstant::now()deep inside a widget; take data as arguments, not paths; prefercore::/alloc::paths when both work; keep threads, files and the environment at the edges (the app, a plugin, a feature). This is ratatui's advice for widget authors, and it's also what makes code testable and wasm-safe. It is not a rule to rewrite existing code, and it never applies tocrates/rich, which follows upstream. - Use the wasm targets as the practical portability check. That
rs-richandrs-rich-extbuild forwasm32-unknown-unknown, and thatrich-interactand intuiTUIve do with the playground's crossterm gate, is the property worth keeping. Acargo check --target wasm32-unknown-unknownjob for those crates in CI would catch a regression. That's a proposal for the playground plan, not part of this note. - Cheap dependency hygiene when someone is in the manifest anyway: turn
off default features we don't use (
rs-rich-ratatuionratatui-core, gap 22 of the comparison). Don't do it inrichforno_std's sake.