Divergences from upstream¶
Every intentional deviation of crates/rich from Python rich is recorded here,
with a justification. The default build of crates/rich must otherwise behave
exactly like upstream. Anything that is purely our own feature belongs in
rich-ext and is not a divergence — it doesn't go here.
Format: what differs · why · how to remove it (if temporary).
Verdicts (0.0.22)¶
Every entry has a verdict. Resolved entries are struck through. The open ones are permanent, each for the reason in its entry:
| Kind | Entries |
|---|---|
| Resolved in 0.0.22 | §5's residual, §7, §12, §24, §28, §29, §33 (one list-marker edge stays, see the entry) |
| Resolved earlier | §1 (table), §8, §10, §11, §13, §14 (stack), §27 |
| Permanent: the language or platform | §1 (UNICODE_VERSION), §3, §3a, §6, §14 (per console), §14a, §15, §19 (reflection), §20, §25, §30, §38, §41, §42, §43, §44, §45, §47, §52 (--inspect) |
| Permanent: another engine | §9 and §18 (syntect, not Pygments), §21 (pulldown-cmark's emphasis) |
| Permanent: an API or CLI choice | §2, §16 (f64 counts, byte reads), §17 (a Live owns its console; proxy writers, not the process's stdout; nested displays drawn from their last rendering), §31, §32, §34, §40, §46, §48, §49, §50 (rich URL's downgrade refusal and sanitizing, 0.0.24), §51 (--theme), §53 (no arguments) |
| Permanent: ours, opt-in or additive | §22, §23, §26, §35, §36, §37, §39 |
Current divergences¶
1. ~~Cell-width table delegated to unicode-width~~ — RESOLVED¶
- Was: upstream ships a generated width table (
_cell_widths.py); we called theunicode-widthcrate, on the premise that both implement the same Unicode East Asian Width rules. The removal condition recorded here was "only if a concrete codepoint mismatch is found". - Resolved: that condition was met. A sweep of 127,754 code points found the
crate disagreeing with rich 15.0.0 on 348 of them — 312 Mc spacing marks
(Devanagari
िand friends: crate 1, upstream 0), 16 Cf format characters, 5 Sk modifier symbols, plus Lm/Lo/Mn/Po. Emoji were worse, because upstream measures clusters: a ZWJ family was 8 cells to us and 2 to upstream, a skin-tone thumb 4 vs 2, and❤️/⚠️/✔️1 vs 2. Every disagreement misaligned a table, panel or wrap point — a--panelcontent row rendered 43 cells wide inside a 40-cell box, physically breaking it. - Now:
crates/rich/src/cell_widths.rsvendors upstream's table (464 ranges, 213narrow_to_wideentries, Unicode 17.0.0), generated byscripts/gen_cell_widths.py;cells::cell_lenports_cell_len's cluster pass (a ZWJ consumes the following character, U+FE0F promotes a narrow one to two cells). The sweep now reports 0 mismatches across 127,754 code points plus every emoji cluster tested. - Still divergent: upstream's
_unicode_data.load()honours aUNICODE_VERSIONenvironment variable to select any of 21 tables (4.1.0–17.0.0); we always use the shipped one. Tracked separately. - Verdict (0.0.22): the remainder is permanent. Upstream's
UNICODE_VERSIONpicks one of 21 vendored tables for terminals that measure with an old Unicode; shipping all 21 would multiply the table's size for a variable almost no one sets. The port measures with the newest table, upstream's default.
2. Markup: the lenient print path is opt-out, not opt-in¶
- Differs: the scanner now matches upstream exactly.
markup::renderuses upstream's ownRE_TAGSexpression rather than a hand-rolled loop, so backslash-run parity, the ban on[inside a tag body, and zero-length spans all behave identically. Pinned by 34 byte-parity fixtures ingolden/markup_edge.tsv, half of which assert which side raises.
One gap remains: the infallible Console::print_str / build_text still fall
back to printing the raw text where upstream would raise MarkupError. The
strict behaviour is available as try_print_str / try_print_justified /
try_build_text, and rich --print uses it, so user-supplied markup is
reported rather than rendered literally. Console::render_str, which renders
plain-string table headers and cells, tree labels and Columns items (and so
rich --csv cells), is lenient too: rendering has no error path, so malformed
markup there prints literally where upstream raises.
Also unmodelled: @-tag meta payloads. The tag applies no style and its
parameters are discarded rather than being literal_eval'd into a meta map.
- Why: the infallible signatures keep the ~30 internal call sites that pass
literal markup free of unwrap/? noise, where a parse error is a bug rather
than a runtime condition.
- Remove: make the strict form the default, renaming the lenient one to
*_lossy.
- Verdict (0.0.22): permanent, an API choice. The strict path exists
(try_print_str, try_print_justified, try_build_text) and the CLI uses
it; the lenient default only differs for malformed markup. Making strict
the default renames public methods across every crate, which belongs to
the 0.1.0 API review, not to parity. @-tag meta payloads need Python's
literal_eval, and meta never renders (see §3a).
3a. Style.meta is a typed map, not marshal bytes¶
- Differs: upstream stores
metaasmarshal.dumps(dict);Stylehere holds aMeta(string keys,MetaValuescalars and lists) kept in insertion order, which gives the same equality ({a, b}≠{b, a},True≠1), combination ({**a, **b}) and truthiness. Non-marshal-able Python values have no representation;Style.from_meta's randomlink_idis not modelled (#20). Tuples and lists are kept apart (MetaValue::Tuple,MetaValue::List), as markup[@handler]tags need (§44). - Why: Rust has no marshal; the map round-trips what the bindings need.
- Verdict (0.0.22): permanent. Rust has no
marshal, and meta never reaches the terminal.
3. Byte offsets in Text spans¶
- Differs: upstream
Textuses code-point offsets for spans; ourTextuses byte offsets internally. - Why: simpler and faster in Rust; observable behavior is identical for the ported operations (append/stylize/render), and ASCII-only callers such as the example highlighter are unaffected.
- Remove: not planned unless a public API needs code-point indexing; if so, expose a char-index helper without changing the internal representation.
- Verdict (0.0.22): permanent. Internal only: no output differs, and the public offsets that matter are converted.
5. ~~Over-long-word fold suppresses one empty chunk vs upstream~~ (resolved)¶
- Resolved:
Textword-wrapping (_wrap.divide_line) and the over-long-word fold (cells.chop_cells) are byte-parity with upstream. The!line.is_empty()guard has been dropped, so folding a character wider than the fold width (e.g. a 2-cell CJK char to width 1) now emits upstream's empty leading chunk (["", "宽", …]) — anddivide_linetherefore produces the same duplicate break position (hence the same empty line) as upstream. Verified against real rich 15.0.0 (chop_cells("宽宽", 1) == ["", "宽", "宽"], unit-tested), and unchanged for normal text (cw <= widthnever triggers the empty push). 0-width combining marks still stay attached to their base char (char-based, no grapheme table). - Residual (minor):
chop_cellsis char-based, not grapheme-based, so it still differs from upstream for multi-codepoint graphemes folded below their own width (ZWJ emoji, regional-indicator flags) — vanishingly rare, and it would need a grapheme-segmentation dependency to close. - Verdict (0.0.22): resolved. The residual above no longer holds:
cells::chop_cellsis upstream's grapheme form, over the portedsplit_graphemes, so ZWJ sequences and flags fold as upstream folds them.
6. Box substitution is opt-in (no legacy-terminal auto-detection)¶
- Differs:
Box.substituteis ported —Box::substituteapplies upstream's whole legacy Windows table (ROUNDED,HEAVY,HEAVY_EDGEandHEAVY_HEADtoSQUARE,MINIMAL_HEAVY_HEADtoMINIMAL,SIMPLE_HEAVYtoSIMPLE) whenlegacy_windowsis set, and maps a box not flaggedasciitoASCIIwhenascii_onlyis set;Panel/Tableapply it from their render options, with their ownsafe_boxover the console's. Thelegacy_windows/safe_box/ascii_onlyconsole flags exist. What differs: those flags default off and are not auto-detected from the runtime terminal (upstream auto-detects legacy Windows / a non-UTF-8 encoding), so the default build always emits the requested glyphs. - Why: keeps default output deterministic (and golden fixtures, captured in UTF-8 non-legacy mode, unaffected); runtime terminal detection is platform code.
- Remove: auto-detecting
ascii_onlyfrom a non-UTF-8 encoding atConsolebuild time remains possible.legacy_windowsis never auto-detected: the legacy console is not supported (§38). - Verdict (0.0.22): permanent. Upstream detects from
sys.stdout.encoding, which has no portable Rust equivalent (a Rust string is always UTF-8). The flags are there for callers that know their terminal.
7. ~~Table — one rare padding edge remains~~ (resolved)¶
- Resolved (0.0.22): a wrapping column squeezed to nothing by a
no_wrapneighbour renders as upstream's does. Sweeps of two- and three-column tables at every width from 1 to 25, with and without a box, edges, edge padding, wider padding and collapsed padding, and of up to 40 columns in a narrow console, are byte-identical to rich 15.0.0 (table_squeezed_column,table_squeezed_threeandtable_many_columnsingolden/parity_widgets.tsv).
8. ~~Json — exotic number formatting differs from CPython~~ (resolved in 0.0.11)¶
- Floats are now written with a port of Python's
float.__repr__(rich::pyformat::float_repr), asjson.dumpsdoes: shortest round-trip digits, exponent form below1e-4and from1e16, ande+NN/e-NNpadding (1e+20,1e-07). Goldenjson_python_floatspins it against rich 15.0.0, alongsidejson_python_numbersfor integers and Infinity.
9. Markdown covers most elements (code blocks are non-parity)¶
- Differs: paragraphs, ATX headings (h1–h6), bullet + ordered lists, block
quotes, thematic breaks (hr), inline strong/emphasis/code, and GFM tables
are rendered byte-parity. Fenced/indented code blocks now render via the
Syntaxrenderable — so they're highlighted but not byte-identical to upstream (syntect ≠ Pygments; see #18). Links render as an OSC 8 hyperlink + themarkdown.link_urlstyle — byte-identical to upstream except the randomid=field we omit (#20). Inline styling inside table cells is byte-parity since 0.0.11 (goldenmarkdown_table_inline): cells hold styledText, as upstream'sTableDataElementdoes. The constructor optionsjustifyandstyleare byte-parity too (goldenmarkdown_options);code_theme,inline_code_lexerandinline_code_themetakesyntecttheme names. (The trailing-blank-line quirk for a document ending in a thematic break is also matched — goldenmarkdown_hr_end.) Everymarkdown.*style,<kbd>'smarkdown.kbdamong them, is looked up in the console's theme as upstream does, in upstream's stack order; a theme entry replaces the default outright (goldenmarkdown_theme, 0.0.23). - Why: code blocks go through
syntect, not Pygments (#18). - Remove: see the Syntax entry (#18).
- Verdict (0.0.22): permanent, for code blocks only, as §18. Every other element is parity.
10. ~~AnsiDecoder skips OSC hyperlinks~~ (resolved)¶
- Resolved: the decoder now reads OSC 8 sequences (
\x1b]8;<params>;<url>\x1b\) and attaches the URL to the runningStyleviaStyle::update_link(the empty closing sequence clears it;id=/other params are ignored). Re-rendering reproduces upstream byte-for-byte except the randomid=field upstream adds, which we omit for determinism — the same, already-documented deviation as #20. Covered by round-trip unit tests (a golden isn't possible precisely because upstream'sid=is random).
11. ~~Layout — empty-leaf placeholder~~ (resolved)¶
- Resolved: an empty leaf renders upstream's
_Placeholderpanel (name, size and highlighted repr), byte-parity inupstream_features.tsv, along withname/visible,split/add_split/unsplit, lookup by name,treeandmap.mapkeys leaves by name and child-index path (Rust has no object identity to key by).refresh_screenis ported, withConsole::update_screenandupdate_screen_lines. - Resolved: height-aware leaves —
Panelnow consumesoptions.heightand expands to fill its region (byte-parity), viaConsole::render_lines's height handling. Other containers can adopt the same pattern as needed.
12. ~~Screen keeps its trailing row separator when printed~~ (resolved)¶
- Resolved (0.0.22): a printed
Screenends with its last row and no line break, as upstream's does, so the next output continues that row.Renderable::print_end_linetellsConsole::printwhether a renderable ends its last line (screen_print,screen_then_textandscreen_application_modeingolden/parity_widgets.tsv).
13. ~~ISO8601Highlighter covers standard extended formats only~~ (resolved)¶
- Resolved: all of upstream's ISO 8601 patterns are now ported, in the same
order — compact/basic calendar dates (
20230615), ordinal dates, week dates, basic times, standalone timezones, and the space-separated date-time forms. Upstream's single PCRE-conditional pattern ((?(hyphen)…), whichfancy-regexcan't compile) is rewritten as two non-conditional alternatives — the all-hyphen/colon form and the all-basic form — which together match exactly the same strings the conditional does. Byte-parity with real rich 15.0.0 across compact/basic/ordinal/week/split forms (unit-tested).
14. ~~No theme stack (push_theme/pop_theme)~~ (resolved)¶
- Resolved (0.0.10, core 0.0.6):
Console::push_theme(theme, inherit),pop_theme()(an error on the base theme, as upstream'sThemeStackError) anduse_theme(theme), which returns aThemeContextguard that derefs to the console and pops on drop — including during a panic unwind. Printing through the guard avoids the borrow problem that previously blocked the design, andConsole::theme()still returns&Theme(the top of the stack).Theme::from_styles,config,from_fileandreadport upstream's theme files, includingconfigparser's lower-cased keys,[DEFAULT], continuation lines,%interpolation and error classes. - Behaviour kept from upstream:
use_themealways inherits; upstream'sThemeContextnever forwards itsinheritargument (verified against rich 15.0.0), so this port omits the ignored parameter. - Remaining difference: upstream's stack is thread-local; here the stack
belongs to the
Consolevalue, which fits a console thatLive::spawnmoves to another thread. Goldentheme_stack.tsvcovers push, pop, nested push,use_theme,Theme.configandfrom_filebyte for byte. - Verdict (0.0.22): permanent for the remaining difference: a stack per console is what a console moved between threads needs.
14a. Style::parse results are not cached¶
- Differs: upstream LRU-caches style-definition parsing; we re-resolve names
once per render (into a vector parallel to the spans, like upstream's
style_map) with no cross-render cache. - Why: the per-render resolution already collapses the repeated work inside a render, which is where it mattered; a global cache would need a lock or thread-local and has not been shown to be worth it.
- Remove: measure first; add only if a profile justifies it.
- Verdict (0.0.22): permanent. A cache changes speed, not output.
15. Exports done (HTML both forms + SVG); SVG needs an explicit unique_id¶
- Differs:
Console::export_html(inline styles),export_html_classes(the default.r1 {…}stylesheet form), andexport_svgare all ported with byte-parity (svg.rs, goldentests/golden/svg_export.svg). The only SVG residual: upstream's defaultunique_idisadler32over Python'srepr()of eachSegment, which Rust can't reproduce — soConsole::export_svgtakes an explicitunique_id, and output is byte-parity withexport_svg(title=…, unique_id=…). Same shape as the OSC8id=deviation (#20). - The
record=Trueform (Console::export_svg_recorded, 0.0.23) takes the id inExportSvgOptions::unique_id, whose default is"terminal". - Why: the default id is non-deterministic (breaks golden tests) and only namespaces the CSS classes / element ids within one document.
- Remove: add an
adler32-of-reprdefault id only if a caller needs the exact auto-generated ids (rare); the explicit-id form already round-trips. - Links (
<a href>),code_format(both exports, as Python format strings viaexport::format_template) and SVGfont_aspect_ratioare ported (export::export_html_with,svg::export_svg_with, goldencore_gaps.tsv). Acode_formatusing conversions or format specs ({code!r},{width:>5}) is refused withExportFormatErrorrather than formatted. - Verdict (0.0.22): permanent. The default id hashes Python's
repr()of every segment, which only CPython can produce; an explicitunique_idgives upstream's bytes.
16. Progress: two permanent differences (settled in 0.0.22)¶
- Resolved (0.0.10, core 0.0.6): the time, rate and spinner columns and the
task model are ported: an injectable clock (
Progress::clock, upstream'sget_time), task start/stop/finish times, the 30-second speed sample window,update/advance/reset/start_task/stop_task/remove_taskwith upstream's exact sample and finish rules, andTimeElapsed,TimeRemaining(compact, elapsed-when-finished and its 0.5 s render cache),TransferSpeed,FileSize,TotalFileSize,Spinner,TaskProgress { show_speed }and binaryDownloadcolumns.Progress::new()now uses upstream's default columns (description, bar, percentage, time remaining). Goldenprogress_time.tsvreplays the same step programs through Python and Rust. - Resolved (0.0.11, core 0.0.7):
- The pulse bar for unstarted or indeterminate tasks, with the ASCII and
no-colour fallbacks (golden
progress_bar.tsv). TextColumnformat strings over task attributes and per-taskfields, through a port of Python's format mini-language.RenderableColumn, whose cells may span several lines.- A live display:
Progress::startreturns aLiveProgressredrawn by theLiverefresh thread (§17). Its byte stream matches upstream'sauto_refresh=FalseLive (goldenprogress_live.tsv). track(), bothLiveProgress::trackand the module-leveltrack.- The display is now upstream's
make_tasks_table: aTable::gridwith each column's table-column options (ProgressColumn::with_table_column), soexpand,BarColumn(bar_width=None)(ProgressColumn::BarWith) and narrow-width wrapping match.transient,disable, andwrap_file/open(LiveProgress::wrap_read/open) are ported (goldens inprogress_time.tsvandprogress_live.tsv). - Resolved (0.0.22):
trackfollows upstream's_TrackThread: a thread advances the task by the items counted everyupdate_period, and when the iterator is exhausted or dropped the task's completed is set to the count and the display refreshed (LiveProgress::track_withtakescompleted, an existing task and the period).- The display takes its table at each refresh through
Live'sget_renderable, as upstream does, so a print between refreshes draws the table of the last refresh below it. Progress.print(LiveProgress::print/print_str) andredirect_stdout/redirect_stderr(LiveProgress::stdout/stderr) go through the live display (§17). Goldenparity_live.tsv.- Permanent:
- Task totals and counts are
f64, so{task.total}formats a whole number as an int. Upstream keeps whatever type the caller passed. Rust has no dynamic int/float. openreads bytes; upstream's text mode (TextIOWrapper) is left to the caller, for exampleBufReader::linesover the returned reader. Text decoding belongs to the caller's reader.- Not a divergence: a
LiveProgressalways runs the refresh thread (upstreamauto_refresh=True). For a manual display, render theProgressitself in aLive.
17. Live: owns its console and sink; redirect through proxy writers (settled in 0.0.22)¶
- Resolved (0.0.22): the alternate screen (
Live::screen, upstreamscreen=True: the renderable fills aScreen, homed on each refresh, and the screen is switched off at stop), upstream's render hook (Live::print/print_str: the display is erased, the output printed, and the display drawn again below it), the stdout/stderr redirect (file_proxy.rs, below),start(refresh)(Live::start_with),update(refresh)(Live::update_with, taken at the next refresh),get_renderable,is_started, and dumb terminals (no redraws, no control codes).Status::startruns a status in its own transient display. Goldenparity_live.tsv(38 cases). - Permanent:
- The redirect is a writer, not the process's stdout. Upstream
assigns a
FileProxytosys.stdoutandsys.stderr, so a plainprintlands above the display. Rust cannot do that safely:print!andstd::io::stdout()always reach file descriptor 1;std::io::set_output_captureis unstable and internal; and moving the descriptor withdup2needs unsafe, platform-specific calls that would also capture the display's own output and every other thread's. SoLive::stdout/stderr(and the same onAutoLive,LiveProgress,LiveStatus) return aFileProxythe program writes to instead. Behind it everything is upstream's: lines are buffered and decoded from ANSI,flushprints a partial line as console markup, a partial line is flushed when the display stops (CPython'scloseon the dropped proxy), and the proxy writes to its file whenever the display is not redirecting (stopped, the flag off, or not a terminal). - A
Liveowns its console and output sink. Upstream's display pushes a render hook onto a shared console, so anyconsole.printgoes above it; here the console and the sink are moved into theLive(and into its refresh thread), so printing goes throughLive::print. The output is the same. - Nested displays are drawn from what they last rendered (0.0.23).
As upstream's,
startpushes the display on its console's live stack (Console::set_live) andstoppops the top of it (clear_live). A display started while the stack holds one is nested: it draws nothing itself, the first display draws every display on the stack one after another (upstream'sGroup), a nested display's refresh redraws the first, and a nested display prints its renderable when it stops, unless it is transient. rich 15.0.0 raises noLiveErrorfor it. Goldenconsole.tsv(live_set_live,live_nestedand thelive_nested_*cases: refreshes from either display, prints, transient displays, both stop orders, a file, the alternate screen). What differs:- The stack is shared by a console and its clones, since each
Liveowns its console: displays nest when they are given one console's clones. A clone shares nothing else. - A
Liveis notSend, so the stack holds what other displays need of it: its renderable as it last rendered it, as segments, and its frame. A nested display renders its renderable when it starts, is updated, refreshes and, with a refresh thread, at each tick, and the first display draws those segments; upstream's first display renders every display's renderable at each of its refreshes. A refresh of a nested display draws the first display's renderable as that one last rendered it (or as its lastupdatewithout a refresh). Renderables that do not change by themselves come out the same; a spinner in a nested display with no refresh thread does not turn. - Upstream's nested display has no refresh thread. A nested
AutoLivekeeps its thread, and its ticks only render its renderable for the first display to draw. - A nested display's refresh writes the first display's frame to the nested display's own sink, so displays that nest should write to one terminal, as upstream's write to one console's file.
- A print from a display that does not draw goes through the first display on the stack, not through every render hook on the console.
- The stack is shared by a console and its clones, since each
- Jupyter widgets are not ported (§19).
18. Syntax highlighting uses syntect, not Pygments (non-byte-parity)¶
- Differs:
Syntax(syntax.rs) highlights code with thesyntectcrate, whereas upstream uses Pygments. The two ship different grammars and themes, so the token colors are not byte-identical to Python rich — this is the one renderable whose output is functional rather than golden-tested. The default theme isbase16-ocean.dark(a syntect built-in), not rich'sansi_dark/monokai.dedentandSyntax.from_pathare ported (goldensyntax_dedent_*andsyntax_from_pathinparity_docs.tsv);guess_lexeranswers with the path's extension, which syntect looks grammars up by, rather than a Pygments alias, andfrom_pathreads UTF-8 only. - Upstream layout (0.0.x).
line_numbers,start_line,line_range,highlight_lines,code_width,background_color,indent_guides,stylize_range, 4-sided padding and unpadded lines for transparent themes follow upstream's_get_syntax, golden-tested with the plain-text lexer andansi_dark.CodeHighlighter::token_stylesupplies the theme'sTextandCommentstyles for line numbers and guides. The default case follows upstream too: a long line is cropped at the width, and underoverflow="ignore"rows are padded inbackground_style(the theme background stays on the code), as upstream does. - Why: Rust has no Pygments;
syntectis the standard Rust equivalent (mirrors howcellsdelegates East-Asian-width tounicode-width). Byte-parity is impossible across highlighter engines. - Verdict (0.0.22): permanent. Not removable while using a different
engine; the divergence is inherent. Everything around the highlighter (the
layout, line numbers, ranges, padding,
dedent,from_path) is parity. - Pluggable engine (0.0.12). Highlighting goes through the
protocol::CodeHighlightertrait.SyntectHighlighteris the default, andSyntax::highlighter/Markdown::highlighteraccept any other engine. With the default, output is byte-identical to 0.0.11. Core validates an engine's spans against the source, so an engine can change colours but never the text. A console can also carry a default engine and theme (protocol::ConsoleCodeHighlighting), used by anySyntaxwithout its own; a console without one highlights exactly as before. Adapters (the shipped syntect and lumis ones, and any user's) are held to one contract by the conformance kit inrich_ext::testing::conformance. - Upstream's ANSI themes.
ansi_darkandansi_lightuse upstream'sANSI_DARK/ANSI_LIGHTstyles and the terminal's 16 colours, with no background. Pygments token types become TextMate scopes:
| Pygments token | TextMate scopes |
|---|---|
Comment |
comment |
Comment.Preproc |
meta.preprocessor, meta.annotation (Rust attributes), keyword.control.import.include |
Keyword |
keyword, storage.type, storage.modifier, constant.language |
Keyword.Type |
support.type, storage.type.primitive/numeric/builtin |
Operator (unstyled) |
keyword.operator |
Operator.Word |
keyword.operator.logical, keyword.operator.word |
Name.Builtin |
support.function.builtin, variable.language |
Name.Function |
entity.name.function |
Name.Namespace |
entity.name.namespace, entity.name.module |
Name.Class |
entity.name.class/struct/enum/union/trait/type |
Name.Exception |
support.type.exception |
Name.Decorator |
meta.decorator, meta.annotation.python, entity.name.function.decorator |
Name.Variable |
instance, class, global and shell variables |
Name.Constant |
variable.other.constant, constant.other, entity.name.constant, support.constant |
Name.Attribute |
entity.other.attribute-name |
Name.Tag |
entity.name.tag |
String |
string, and string prefixes (storage.type.string) |
Number |
constant.numeric |
Generic.Deleted / Inserted |
markup.deleted / markup.inserted |
Generic.Heading / Subheading |
markup.heading / markup.heading.2–6, meta.diff.range |
Error |
invalid |
Whitespace and Generic.Prompt have no TextMate equivalent and stay
unstyled.
19. Python-object modules are reimagined for Rust¶
- Differs:
pretty.py/repr.py/_inspect.py,traceback.py, and thelogginghandler render Python objects, exceptions, and log records via runtime reflection — which Rust doesn't have. So these are reimagined, not faithfully ported: Pretty(pretty.rs) formats a value with its [Debug] impl ({:#?}/{:?}) and colorizes the result withReprHighlighter. Because the coloring targets Python-repr spellings, Rust-specific tokens differ —true/false(vsTrue/False) are left unstyled — and there is no field/attribute introspection (inspect). No golden test; verified functionally.Traceback(traceback.rs) renders an error's message and itsError::source()chain (Caused by:) in a red-bordered panel. There are no stack frames or source snippets — Rust errors don't carry them (pair withstd::backtrace::Backtraceat the call site if you want a frame list).LogRender(log_render.rs) is now a faithful port of_log_render.py(goldenlog_render.tsv), except that it takes the time already formatted rather than adatetimeand astrftimeformat.rich-ext'sRichHandleris thelogging.RichHandlercounterpart: it renderslogandtracingevents through theLogAdapter/EventLayersinks. Its default time is UTC[HH:MM:SS](local time would need a time-zone dependency), structured fields follow the message askey=value, and there are no rich tracebacks.- Why: a 1:1 port isn't possible without reflection; the Rust-native analogs deliver the same utility (colorized value/error/log rendering).
- Remove: inherent to the language difference; not removable.
- Verdict (0.0.22): narrowed, the rest permanent.
pretty.py's layout is now ported overPrettyValue, a description of the Python object (Pretty::from_value,pretty_repr, andscope.py'srender_scopeon top), byte for byte against rich 15.0.0 (parity_docs.tsv). What stays Rust-native needs reflection:Pretty::newoverDebug,inspect,repr.py's decorators, tracebacks with frames and locals, andlogging's rich tracebacks. inspectfrom derive data (0.0.24, #796).rs-rich-ext'sinspectmodule renders upstream's_inspect.pyreport (a fittedscope.borderpanel titled with the type, the first paragraph of the doc, the value in aninspect.value.borderpanel, and a grid of right-alignedname =rows in upstream'sinspect.*styles) for Rust values. The facts come from#[derive(Inspect)](rs-rich-macros, behind ext'smacrosfeature) at compile time, not from runtime reflection: the type'stype_nameand doc comment, each field's name, type as written, doc comment, visibility andDebugvalue, and an enum's variants. Core is unchanged:_inspect.pystays ⛔ in the mirror. What differs from upstream:- only a type that derives
Inspect(or implementsInspectableby hand) can be inspected; there is noinspectof a type, function or module, so no signature line for a callable; - methods are listed only from an inherent
implblock marked#[inspect](with#[inspect(methods)]on the type), with their signatures as written (pub fn norm(&self, scale: f64) -> f64); trait methods and associated items are not listed; dunderhas no counterpart (Rust has no dunder names), andprivatemeans non-pub, not a leading_;allturns onmethods,privateand ourtypesandvariants;- titles read
<struct '…'>/<enum '…'>; values areDebugoutput fitted to the width (one line, else{:#?}), withoutPretty'smax_length/max_stringcuts; field doc comments follow their values whendocsis on;#[inspect(nested)]fields are nested panels, cut atmax_depth; the "not shown" hint namesInspect::all.
20. Hyperlinks omit upstream's random id= field¶
- Differs:
Style::with_linkrenders an OSC 8 hyperlink as\x1b]8;;{url}\x1b\…\x1b]8;;\x1b\. Upstream adds a randomid=field (\x1b]8;id={random};{url}…) so a terminal can group the segments of one link for hover highlighting. We omit it, making output deterministic (and golden-testable); the link still works, and byte output otherwise matches. - Why: the random id is non-deterministic (breaks golden tests) and only affects hover-grouping of a multi-segment link.
- Remove: add a stable per-link id (e.g. a hash of the URL) if hover grouping is ever needed — but it still wouldn't match upstream's random value.
- Verdict (0.0.22): permanent. A random value cannot be golden-tested, and the link works without it.
21. Strikethrough across emphasis (narrowed in 0.0.10)¶
- Resolved (0.0.10, core 0.0.6): tilde runs of any length now pair as
upstream's markdown-it pairs them. pulldown-cmark's own strikethrough is off;
pair_strikethroughports markdown-it's tokenize (odd runs split into~plus~~delimiters),balance_pairsand postProcess (a lone~before a closer moves after it), scoped per inline run with link labels nested.a ~~~x~~~ brendersa ~+ struckx+~ b, as upstream does. Goldenmarkdown_strike.tsvcovers 24 cases byte for byte, including odd and uneven runs, intraword and punctuation neighbours, escapes, soft breaks, headings, tight lists, links and emphasis nesting. - Still differs: markdown-it pairs
*/_and~in one pass, so a tilde closer that falls inside an emphasis span opened after the tilde opener wins and dissolves that emphasis:~~a *b~~ c*strikesa *bupstream. Here pulldown-cmark has already paired the emphasis, and a tilde pair is never allowed to cross an emphasis span, so the tildes stay literal. Crossings the other way (*a ~~b* c~~, no strikethrough) already match. - Why: matching that last case means reimplementing CommonMark emphasis pairing alongside strikethrough instead of using pulldown-cmark's.
- Remove: port markdown-it's emphasis delimiter handling into the same
balance_pairspass, under the Markdown issue (#9). Pinned bytildes_crossing_a_later_emphasis_stay_literal. - Verdict (0.0.22): permanent. The one remaining case needs a tilde pair
and an emphasis span that cross, which no one writes on purpose. Fixing it
means our own CommonMark emphasis pairing in place of pulldown-cmark's
(an outer
*can re-pair once the inner span dissolves), a parser of its own for one malformed input.
24. ~~A refused Markdown reference definition is not printed~~ (resolved in 0.0.22)¶
- Resolved: a reference definition whose destination markdown-it's
validateLinkrefuses ([1]: javascript:x) is not a definition upstream, so its line prints as a paragraph. The port now finds such definitions in pulldown-cmark'sreference_definitions(), escapes their[and parses again, so the line prints as upstream prints it, continuation lines included, and links using the label stay literal (goldenmarkdown_refused_definition_*inparity_docs.tsv).
25. JSON nesting stops at exactly 10 000 levels¶
- Differs:
Json::newrefuses documents nested deeper thanjson::MAX_DEPTH(10 000) with CPython'sRecursionErrormessage. CPython's own cutoff depends on its C stack: 3.12+ refuses from about 9 998 levels, 3.11 from about 995 (its 1 000-frame recursion limit). Documents between those cutoffs and 10 000 levels parse here. - Why: an unbounded depth made rendering (which indents every line by its depth) cost quadratic memory until the process was killed; a machine-dependent cutoff cannot be reproduced exactly.
- Remove: not removable exactly; the constant can be tuned.
- Verdict (0.0.22): permanent. CPython's cutoff depends on its C stack.
27. ~~A Text's trailing blank line is lost inside containers~~ (resolved)¶
- Resolved:
Textmarks a final empty line with an empty segment, whichSegment::split_lineskeeps, soPadding(Text("a\n")),Panel(Text("a\n\n")),Tablecells,Treelabels andAlignkeep the blank line as upstream does (the rich-cli--syntaxbottom pad that compensated for it was removed in the same change).
28. ~~AnsiDecoder::decode splits lines like rich 12~~ (resolved in 0.0.22)¶
- Resolved:
decodenow splits as rich 15.0.0 does: after each\n, keeping a\rbefore it (which then resets the line), with a final empty line for text ending in a newline (unit testdecode_splits_as_rich_15, values from rich 15.0.0).decode_split_newlinesstays as the same thing under its old name. rich-cli's notebook and capture views, which follow rich-cli 1.8.1 on rich 12, decodetext.lines()one line at a time.
29. ~~Renderables cannot end without a newline when printed~~ (resolved)¶
- Resolved (0.0.22):
Rule(end=…)with anendthat has no trailing newline and a printedLiveRenderleave the line open, as upstream's do, throughRenderable::print_end_line(rule_endandlive_render_printingolden/parity_widgets.tsv). Inside containers both already matched.
30. A lone surrogate in Json prints as U+FFFD without ensure_ascii¶
- Differs: Python's
jsonaccepts an unpaired\uD800-\uDFFFescape, andjson.dumps(ensure_ascii=False)puts the surrogate itself in the output. A RustStringcannot hold one, so the port prints U+FFFD in its place. Withensure_ascii(re-escaped as\ud800) and forsort_keysordering and repeated-key detection the port is exact: strings are kept as generalized UTF-8 until they are printed (goldenjson_lone_surrogate_asciiinaudit_edges.tsv). - Why: the rendered output is a Rust string; upstream's own output cannot
be written to a UTF-8 terminal either (
UnicodeEncodeError). - Remove: not removable without non-UTF-8 output.
- Verdict (0.0.22): permanent. A Rust string cannot hold a surrogate.
31. Unknown style names in renderable options resolve to no style¶
- Differs: upstream's
Console.get_styleraisesMissingStylefor a name that is neither in the theme nor parseable, soTable(row_styles=["nope"]),Panel(border_style="nope")and the like fail at render time. Core's renderables resolve every such option withTheme::get_style_or_null/console.get_style(..).unwrap_or_default(), consistently: the unknown name applies no style and the rest renders. (Style strings in markup andTextspans are already silent upstream.) - Why:
Renderable::rich_renderhas no error channel;Style::parseandConsole::get_stylereturn errors for callers that want to validate first. - Remove: not planned (verdict for 0.0.22: permanent). A render error
would need a fallible
rich_renderthrough every container and every extension, for input thatStyle::parseandConsole::get_stylealready reject before printing.
32. Columns(width=0) with no horizontal padding renders nothing¶
- Differs: upstream computes
max_width // (width + padding)and raisesZeroDivisionErrorwhen both are zero. Core'sColumnsreturns no output for that case instead of panicking (the Python binding raisesZeroDivisionErroritself before calling core). - Why:
Renderable::rich_renderhas no error channel (see §31). - Remove: not planned (verdict for 0.0.22: permanent), for the reason in §31.
33. ~~An invalid GFM delimiter row inside a list or quote stays a table~~ (resolved in 0.0.22)¶
- Resolved: a table nested in a list item or block quote is checked
against markdown-it's delimiter-row rules too. The container markers of its
later lines (
>, the item's indent) are stripped before the re-parse, so a rejected one prints as the paragraph markdown-it prints (goldenmarkdown_nested_table_*inparity_docs.tsv). - Also resolved: markdown-it lets a table interrupt a paragraph
(
p\na|b\n-|-is a paragraph and then a table); pulldown-cmark keeps those lines in the paragraph. A top-level paragraph is now split before such a header row (goldenmarkdown_fuzz_*). - Still differs (permanent): markdown-it tries its table rule before its
list rule, so
- a|bover an indented-|-is a table whose header cell is- aupstream; pulldown-cmark starts a list there. Matching it would mean replacing pulldown-cmark's block parser, for a document no one writes on purpose.
34. rich -j is --json and rich -x is --syntax¶
- Differs: in rich-cli 1.8.1,
-jis--emojiand-xis--lexer(its JSON is-J, and--syntaxhas no short). This CLI gave-jto--jsonand-xto--syntaxbefore it ported those options. It keeps them, accepts upstream's-Jfor--jsontoo, and spells the other two out:--emojiand--lexerhave no short name. Every other upstream short alias is upstream's,-hfor--headincluded, so help is--helponly. - Also: where rich-cli exits 255 on a usage error it reports itself
(
cannot specify both head and tail), this CLI exits 2, its usage class. A source option given in a mode where it does nothing (--headwith--markdown) is an error here; rich-cli ignores it. So are the--text-*justifies,--rule-styleand--rule-charwithout--rule(or, for the justifies,--print).--head/--tailvalues beyond a 64-bit integer are refused, where Python takes any integer. Options take their value as the next argument only: click's--opt=valueand clustered short flags (-nL) are refused as unknown options. - Why: changing
-jand-xwould break scripts written for this CLI to fix scripts written for rich-cli, and both uses are common. - Remove: a major CLI release could move them to upstream's meanings.
- Verdict (0.0.22): permanent until a major CLI release.
35. rich --rst renders a subset of reStructuredText¶
- Differs: rich-cli's
--rsthands the document torich-rst2.2.0, which parses it with its vendored docutils (0.22.4), applies docutils' transforms and renders the tree.rich_ext::rstparses the common subset into the same tree and applies the same transforms (the document title and subtitle, bibliographic fields, substitutions, footnote numbers and symbols, hyperlink targets, the table of contents, transitions). The subset: sections, paragraphs and inline markup (docutils' roles and the Sphinx rolesrich-rstregisters), bullet, enumerated, definition, field and option lists, literal, doctest and parsed-literal blocks, block quotes, line blocks, grid and simple tables (cells that span included), footnotes, citations, hyperlink targets (named, anonymous, indirect, embedded),replaceandimagesubstitutions, and thecode(code-block,sourcecode), admonition,seealso,versionadded,versionchanged,deprecated,image,figure,contents,topic,rubric,math,sidebar,raw,container,compound,line-block,epigraph,highlights,pull-quote,table,list-tableandcsv-table(its data inline) directives, andinclude, which says file access is off, asrich-rstdoes by default. Other directives docutils orrich-rst's Sphinx registry knows (unicode,date,sectnum,header,footer,class,role,toctree,literalinclude,glossary,hlist,only,centered, the domain directives and a few more) render nothing here; an unknown directive shows its source, as upstream's error does. Also not ported: docutils' quoted literal blocks (unindented lines after::that start with punctuation) and thedatesubstitution, which render nothing; and a byte-order mark at the start of the file, which the CLI's decoding strips and docutils keeps as text. - Deeper than docutils: body elements nest at most 100 deep (lists in
lists, quotes in quotes), and very deep documents stop nesting sooner once
re-reading their indented lines passes a budget linear in their size;
past that the rest is one plain paragraph. docutils itself stops with a
RecursionErrorat about 170 levels, whererich-rstraises. - Same: everything the subset covers prints byte for byte what
rich-rst2.2.0 prints on rich 15.0.0, quirks included (crates/rich-ext/tests/rst.rs: 19 documents at two widths, 10 of them in colour too, fromscripts/capture_rst_golden.py). As everywhere, code blocks are highlighted by syntect (§18) and OSC 8 links carry noid=. - Why: there is no docutils for Rust, and the subset is what READMEs and docstrings use.
- Remove: as more of docutils' directives are ported.
- Verdict (0.0.23): permanent for the parts docutils alone can do. The
port moved from
rich-rst1.3.2 to 2.2.0 in 0.0.23 (plan item 2): titles by section level, real tables, list markers, numbered footnotes and resolved references, where 1.3.2 showed the raw tree.
36. Semantic regions: an opt-in seam, no default change (#226)¶
- Differs: nothing by default.
rich::protocoladdsRegionSink, set on aConsolethroughConsoleRegions. Only when one is installed doPanel,Table(and its header, body and footer cells),Rule, and Markdown headings and code blocks call it, and tag the segments they drew with arich.regionkey in the style's metadata. Metadata never renders, so the terminal bytes are unchanged even then; the tagged styles do compare unequal to untagged ones, so core's own HTML export of a tagged stream splits spans more often (rich_ext::framestrips the tags first). - Same: without a sink, which is the default, no renderable calls
anything or tags anything: every golden is unchanged, and
crates/rich/tests/regions.rschecks the bytes with a sink too. - Why: which cells are a heading or a table cell is known only inside
the renderables; the render tree (
docs/design/render-tree.md) needs them for semantic HTML and accessibility. Upstream has no equivalent. - Remove: not planned; the seam is additive.
- Verdict (0.0.22): permanent. Opt-in and additive.
37. Frame export (rich_ext::frame) differs from export_html and export_svg in small ways¶
- Differs:
Frame::to_htmlandFrame::to_svguse core's templates and write the same bytes asConsole::export_htmlandexport_svgfor a stream without regions, with these exceptions: - links are kept:
<a href>in HTML as core writes it, and an<a>around the text in SVG, where core (and upstream) drop the link. The URL is escaped for the attribute (&as&,"as"); core writes it as it is; - an empty segment is not part of a frame, so it takes no class number and draws no zero-width background;
- SVG text is stretched over the cells it covers, where upstream counts characters; the two differ only for wide characters, which upstream squeezes into half their width;
- characters XML forbids (control characters) become U+FFFD in SVG text, and are percent-encoded in an SVG link, so the file stays well-formed;
- with regions, HTML gains nested
<span class="rich-region …">wrappers with ARIA roles, and a line break is written outside every span. - Same: a link's scheme is not filtered, as upstream's
export_htmldoes not filter it: ajavascript:link stays clickable. Exporting text you do not trust? Drop its links first (Style::clear_meta_and_links). - Same: the parity is tested on tables, panels, trees, rules, Markdown
and upstream's SVG fixture (
crates/rich-ext/tests/frame_export.rs,frame::exportunit tests). - Why: these are our exporters (the render tree's), not upstream's; they keep what core's lose.
- Remove: not planned.
- Verdict (0.0.22): permanent. These are our exporters, not upstream's.
38. The Windows legacy console is not supported¶
- Differs: upstream falls back to the Win32 console API
(
_windows.py,_win32_console.py,_windows_renderer.py) on a Windows console without virtual terminal processing. The port does not: on Windows it enables VT processing and writes escape sequences, so a console that cannot do VT shows them raw. - Why: every supported Windows release (10 and later) has VT
processing in its console. The fallback would need
unsafeWin32 calls in core, which deniesunsafe_code, for terminals almost nobody runs. Decided for 1.0.0 on 2026-10-11 (plan, decision 7; #12). - Remove: not planned.
- Verdict (0.0.22): permanent (decided for 1.0.0).
46. Prompts have no password option¶
- Differs: upstream's
Prompt(password=True)reads the answer withgetpass, which turns the terminal's echo off. The port's prompts read through anInputSourceand have nopasswordoption: the standard one echoes what is typed. - Why: turning echo off needs terminal-mode calls (termios, the Win32
console), that is platform code or
unsafein a core that denies it, or a new dependency for one option. An application that needs it passes its ownInputSourcethat reads without echo (for example over therpasswordcrate); rich-interact's input component masks what is typed. - Verdict (0.0.22): permanent unless core takes a dependency for it (a decision for the maintainer).
40. Layout has only the row and column splitters¶
- Differs: upstream's
Layout.split(…, splitter=…)takes aSplittersubclass as well as a name, so a program can lay children out its own way. The port'sSplitteris a closed enum of upstream's two,RowandColumn; a name upstream would reject (NoSplitter) isNonefromSplitter::from_name. - Why: both built-in splitters are ported and byte-identical; an open
splitter would make
Splittera trait object, losingCopyandEqon a public type, for an extension point upstream does not use itself. - Remove: add a
Splitter::Customthat holds a trait object, if a user asks for one.
41. Style keeps no stale cached strings¶
- Differs: upstream caches a style's definition string
(
_style_definition) and escape codes (_ansi) on the instance.copy,update_linkandclear_meta_and_linkscopy the cached definition, sostr(style.update_link(None))still names the old link whenstr(style)ran first; and the escape codes cached for one colour system are reused for another. Here a style's definition and codes are always computed from what it holds. - Why: the results depend on call order, which no caller can rely on.
The parity fixtures avoid the order (
style_api,segment_misc). - Remove: not planned.
42. The Python object protocols have no counterpart¶
- Differs:
protocol.is_renderableandprotocol.rich_cast(__rich__, with its guard against objects that claim every attribute),abc.RichRenderable(anisinstancehook) and_extension.load_ipython_extension(IPython) are not ported. A Rust type is renderable when it implementsRenderable, which the compiler checks; a type that renders as another one implementsRenderableby delegating, asColorandPalettedo for upstream's__rich__.Measurement.getcannot meet a non-renderable, so it never raisesNotRenderableError. - Why: runtime reflection (§19); the trait is the Rust form of the protocol.
- Remove: inherent to the language difference; not removable.
43. Console features tied to Python objects or Jupyter¶
- Differs: the port's
Consolewrites to standard output. Callers collect output withrecord=(export_*_recorded),begin_capture/end_captureor the closure forms (capture,export_*,page), not by passing a Python file object (file=,stderr=,Console.file). There is no Jupyter mode (force_jupyter,is_jupyter,_repr_mimebundle_), noprint_exception(Python tracebacks, §19), and nolog_locals(the caller's local variables).Console.logfinds its caller with#[track_caller]rather than a stack frame (§47). - Why: these read Python objects at run time. The Python bindings
(
crates/rich-py) provide them on top of the core. - Remove: inherent to the language difference; not removable.
44. Segment control codes are a flag¶
- Differs: upstream's
Segment.controlis a list of control codes; here it is abool, the codes already written into the segment's text. AndSegment::simplifynever merges a control segment into the text that follows it, where upstream's does (which then letsexport_htmlandexport_textshow the escape codes). - Why: nothing in the port reads the codes back out of a segment; the merge is an upstream accident.
- The same holds for a recording console (0.0.23): its HTML export shows no
control code where upstream's shows the raw escape, so
golden_console.rsrecords its HTML sessions without one. - Remove: not planned.
45. Markup handler tags evaluate a subset of Python literals¶
- Differs: upstream's
[@handler=…]tags runast.literal_evalon the parameters. The port evaluates numbers, strings,True,False,None, tuples and lists, which is what a style's meta can hold (§3a). A dict, set, bytes or complex value is aMarkupErrorhere. Syntax errors carry Python's messages for unclosed and mismatched brackets and unterminated strings; any other invalid parameter reportsmalformed node or stringwithout the AST node and memory address upstream'sValueErrornames. - Why: there is no Python parser in Rust, and the address is different on every run.
- Remove: not planned.
47. Console.log: UTC times, and the caller from #[track_caller]¶
- Differs: upstream's default
get_datetimeisdatetime.now(), local time, formatted by the C library'sstrftime. Core has no time zone data, so the port's default clock isSystemTime::now()and itsstrftime(the C-locale directives, inlog_render.rs) formats the UTC wall-clock time. A console built withget_datetimeorlog_time_formattershows what it is given, so a program can add its offset. The caller is the code that calledConsole::log, found with#[track_caller]where upstream walks the stack; its file is the path the compiler recorded (relative to the workspace for a local crate), and the link is that path made absolute against the working directory, asos.path.abspathdoes.Console::log_attakes the file and line.Progress.log(LiveProgress::log, 0.0.23) andLive::logfind their caller the same way, and havelog_atforms too. - Why: local time needs a time zone database or platform calls
(
unsafein a core that denies it, or a new dependency); Rust has no stack frames to inspect. - Remove: if core takes a time zone dependency (a decision for the
maintainer). Golden
console.tsv(console_log*) fixes the clock and the caller on both sides.
48. Render hooks are handed rendered output¶
- Differs: upstream's
RenderHook.process_renderablesreceives the renderables a print collected and they are rendered afterwards, at the print's options. The port's hook receives each one already rendered at those options (an opaqueBox<dyn Renderable>that writes the same lines). A hook that puts one inside another renderable (a panel, say) shows the lines rendered at the print's width, where upstream renders it again at the panel's. - Why: a print borrows its renderables, and a hook must be able to
keep what it is handed in a
'staticbox, as the port's containers require. Hooks that add output before or after (as upstream's live display does) see no difference. Goldenconsole.tsv(console_render_hooks). - Remove: not planned.
49. Console API forms for upstream's Python ones¶
- Differs:
export_text,export_html,export_svg,save_text,save_htmlandsave_svgwere ported first as closure forms (they record what the closure prints). Therecord=Trueforms are namedexport_*_recordedandsave_*_recorded, with upstream's keyword arguments inExportHtmlOptionsandExportSvgOptions. Exporting from a console that is not recording isRichError::Consolewith upstream's assertion message, and an unusablecode_formatisRichError::Consoletoo (upstream raises Python'sKeyError).- Nested closure captures (
Console::capture) each return what was printed inside them. Upstream's nestedcapture()contexts share one buffer per thread, so the inner one takes everything.begin_captureandend_capturefollow upstream. on_broken_pipeis replaced throughConsoleBuilder::on_broken_pipe, upstream overrides it in a subclass. The default makes the console quiet and exits with status 1, as upstream; it does not point standard output atos.devnull(a Rust process does not flush at exit).- The terminal's size is read once, when the console is built; upstream
asks for it on each
sizeread.COLUMNSandLINESare read at construction in both. set_live/clear_livekeep a stack ofArc<dyn Any>, shared by a console and its clones. ALivepushes an entry with its last rendering and its frame when it starts and pops the stack when it stops (0.0.23); upstream pushes the display itself (§17).- A
LogObject::Renderableis shared (Arc<dyn Renderable + Send + Sync>), as aTablecell is, because a log lays its message out in a table. pop_render_hookandclear_livewith nothing to pop do nothing; upstream raisesIndexError.- Why: additive public API (the closure forms keep their names), and the Rust form of a subclass override, a context manager or a list.
- Remove: not planned.
50. rich URL: no HTTPS downgrade, and fetched content is sanitized¶
- Differs: rich-cli 1.8.1 fetches with
requests.get, which follows a redirect fromhttps://tohttp://, and renders the body with its terminal controls intact. This CLI (0.0.24) refuses a redirect from anhttps://hop to anhttp://one, with an error naming both, before that request is made; anhttp://URL given on the command line is still fetched. Content fetched from a URL is sanitized by default in every mode, as--sanitizedoes: ESC, C1 and other controls (window titles, OSC 52 clipboard writes, OSC 8 links, cursor moves) are shown as inert text, whilerich's own styling is unaffected. - Why: a server, or anyone on the path of a downgraded hop, controls those bytes; neither should reach the terminal, or be fetched in the clear, without the user asking. Local files and stdin keep upstream's behaviour.
- Opt out:
--no-sanitize(orsanitize = falsein the user's own config, not a working-directoryrich.toml) lets a URL's escapes through. The downgrade has no flag: fetch thehttp://address itself. - Verdict (0.0.24): permanent (a CLI choice).
51. rich --theme names a style theme; the code theme is --code-theme¶
- Upstream (rich-cli 1.8.1):
--theme THEME(environment variableRICH_THEME, defaultansi_dark) is the Pygments style thatSyntaxand Markdown code blocks are highlighted with. Read from rich-cli 1.8.1'srich_cli/__main__.py(a copy of the source, not the oracle venv). - Here:
--theme NAMEselects a named theme of rich styles from the config file (or an installed marketplace theme), applied to the whole console with--theme-styleand--theme-filelayered over it; an unknown name is a usage error. The code theme is--code-theme NAME(with--highlighter), andRICH_THEMEis not read. With neither option, code is highlighted as before. - Why: the CLI's config themes (0.0.9) took
--themebefore the rich-cli options were ported, and a theme for every style is the wider meaning. Moving it would break configs and scripts written for this CLI; the code theme has a name of its own instead. - Verdict (0.0.24): permanent (a CLI choice) until a major CLI release.
52. rich --inspect reads structured data, not a Python expression¶
- Upstream (rich-cli 1.8.1):
--inspectevals the resource as a Python expression and showsrich.inspectof the object (its methods, not its dunders); a failedevalprints the traceback and exits with an error. Read from rich-cli 1.8.1'srich_cli/__main__.py. - Here:
--inspect(andrich inspect) reads the resource (a path, or-for standard input) as structured data (JSON, YAML, TOML, XML, INI, dotenv) and shows it as a tree through rs-rich-ext's data views, with--format,--filter,--redactand--select. - Why: a Rust program has no interpreter to evaluate an expression in, nor live objects to reflect on (§19); and evaluating a command-line argument as code is a hazard this CLI does not take on. Structured data is what the flag is used for here.
- Verdict (0.0.24): permanent (the language, and a CLI choice).
53. rich with no arguments shows the capability demo¶
- Upstream (rich-cli 1.8.1): with no resource and no mode, it prints
Usage: rich [OPTIONS] <PATH,TEXT,URL, or '-'>andSee rich --help for options, and exits 0. Read from rich-cli 1.8.1'srich_cli/__main__.py(print_usage). - Here: with no resource and no mode (and not
--batch), it shows the capability demo, as--demodoes without its delay, and exits 0.rich --helpis the usage. - Why: this CLI's first run was its demo before the rich-cli options were ported; a tour of what it renders tells a new user more than a usage line, and nothing scripted depends on the usage line's text.
- Verdict (0.0.24): permanent (a CLI choice).
Feature-flagged divergences¶
22. Escape-safe JSON presentation (json-escape-safe)¶
This Cargo feature is off by default in both rs-rich and rs-rich-cli.
The default build keeps Python rich 15.0.0 folding and cropping, including
boundaries inside escapes. Enable with cargo build -p rs-rich-cli --features
json-escape-safe. Library callers additionally opt in with
Json::new(input)?.escape_safe(true); enabling the feature alone leaves existing
library calls unchanged.
Opted-in rendering groups short escapes and \uXXXX when they fit the available
width. Cropping stops before an incomplete escape. Folding recalculates each
boundary from the remaining content, preserving the suffix after an adjusted
break (#98). At widths smaller than the escape itself, folding splits its ASCII
spelling to preserve bytes within the width; atomicity is impossible there.
Cropping remains intentionally lossy presentation output. This behavior requires
JSON lexical context unavailable in generic Text rendering. Remove this special
handling if upstream adopts the same escape-aware layout.
- Verdict (0.0.22): permanent. Off by default; the default build is
unchanged.
Explicit text encoding extension (0.0.4 development)¶
--encoding is a CLI convenience implemented by rich_ext::encoding::Encoding.
It provides strict UTF-8 and UTF-16 decoding only when requested. Default file
replacement decoding and strict stdin/URL UTF-8 are retained. BOM diagnostics
add stderr guidance without automatic encoding changes. This adds no core
dependency on extensions. See encoding policy.
23. Optional syntax parse reuse (syntax-cache)¶
- Default: the faithful mirror uses Syntect's normal
HighlightLinespath. - Opt-in: the
syntax-cacheCargo feature enables repository-specific repeated-line parsing reuse. It is off by default in bothrs-richandrs-rich-cli; the CLI feature forwards to core. The helper lives in a separately gated module. - Scope: exact repeated lines may reuse operations only when their before/after parser state equals one reference captured after a first line of at most 4096 bytes. A longer first line disables caching. No later live state is cloned, and cache entries contain operations rather than captured states. This prevents a large heredoc opener from being copied once per body line.
- Limits: at most 64 lines, each at most 4096 bytes and 256 operations. The source and rendered output still allocate memory. Results are workload-specific; varied source may see no gain. Grammars, themes and live highlight-state updates remain unchanged. Differential tests and native export comparisons verify output.
- Adapters: the cache belongs to the syntect adapter only; another
CodeHighlighter(such as lumis) is unaffected by the feature. - Enable:
cargo build -p rs-rich-cli --release --features syntax-cache. See benchmarks. - Verdict (0.0.22): permanent. Off by default; the default build is unchanged.
26. Optional Oniguruma regex engine (onig)¶
- Default:
Syntaxand Markdown code blocks run syntect with the pure-Rustfancy-regexengine. - Opt-in: the
onigCargo feature switches syntect to Oniguruma, a C library compiled from bundled source (a C compiler is needed at build time). It is off by default inrs-richandrs-rich-cli; the CLI feature forwards to core. Grammars, themes and the highlighting API are unchanged. - Why: highlighting is 2–4× faster. At 0.0.11, a full
richrun on a 1,784-line Rust file dropped from 0.43 s to 0.16 s, and on a 2,916-line Python file from 0.76 s to 0.36 s. The binary grows by about 330 KB. - Adapters:
onigchanges the syntect adapter only. The 0.0.12 benchmarks compare it with lumis. - Output: the two engines agree on the grammars syntect ships. Rendering all 558 source, docs and data files in this repository through both builds gave byte-identical output. A grammar regex that only one engine accepts could still differ.
- Enable:
cargo build -p rs-rich-cli --release --features onig, orrs-rich = { version = "…", features = ["onig"] }. - Verdict (0.0.22): permanent. Off by default; the default build is unchanged.
39. Syntax and Markdown without syntect (code)¶
- Default: unchanged.
syntax(on by default) is nowcodeplus syntect, andmarkdownimpliescode; the default feature set, and so the default build and its output, are as before. - Opt-in: the
codeCargo feature, off by default, keepsSyntax, theCodeHighlighterplumbing and everythingMarkdownneeds without syntect (default-features = false, features = ["code", "markdown"]). Such a build has no built-in engine: aSyntaxor Markdown code block highlights with its own highlighter or the console's default (ConsoleCodeHighlighting, whichrich_ext's registry installs), and with neither renders as plain code in the same layout, like a theme without a background. Upstream always highlights with Pygments. - Why: an application that only highlights SQL (through
rich_ext::sql::SqlHighlighter) can drop syntect and its bincode 1.x, which carries a security advisory, and keepSyntaxand Markdown fences. - Enable:
rs-rich = { version = "…", default-features = false, features = ["code", "markdown"] }. - Verdict (0.0.22): permanent. Off by default; the default build is unchanged.