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).
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.
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.
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.
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.
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.
6. Box substitution is opt-in (no legacy-terminal auto-detection)¶
- Differs:
Box.substituteis ported —Box::substitutemaps the fancy boxes (ROUNDED/HEAVY/HEAVY_HEAD) toSQUAREwhenlegacy_windowsis set, and any non-ASCII box toASCIIwhenascii_onlyis set;Panel/Tableapply it. 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-detect
legacy_windows(WINDOWS && no VT support) andascii_only(non-UTF-8 encoding) atConsolebuild time, under the Windows-console issue (#12).
7. Table — one rare padding edge remains¶
- Differs: sizing (content, shrink-to-fit,
expand), per-column justify, explicit per-column width, per-columnratio/min_width/max_width, per-column style,no_wrap(crop to one line with ellipsis), ellipsis overflow (the table default), a table-level style,pad_edge/show_edge/collapse_padding, title, caption, andshow_linesare all ported and byte-parity. The only residual: a wrapping column squeezed to width 0 by a greedyno_wrapneighbor still renders its cell padding (upstream drops it) — a rare over-constrained case. - Why: the width-0 padding edge only appears when a table is narrower than its no_wrap content plus one other column.
- Remove: drop padding on zero-width columns under the Table issue (#5).
8. Json — exotic number formatting differs from CPython¶
- Differs: non-ASCII strings and key order are byte-parity with upstream
(golden
json_unicode):rich.json.JSONdefaults toensure_ascii=False, so our UTF-8 output matches, andserde_json'spreserve_orderkeeps input key order — the earlier "we don't\u-escape" concern was a false alarm (upstream doesn't escape either). What can still differ is number formatting for exotic values: shortest round-trip is now exact (round 8:serde_json's default float parser took a fast path landing 1 ULP from the value in the file, so ~12% of computed doubles rendered as a different double; thefloat_roundtripfeature fixes it, pinned by a unit test). What can still differ is exponent notation (CPython renders1e+20/1e-07; ryu viaserde_jsonrenders1e20/1e-7, and the two use different thresholds for when to switch to exponent form). Integers now retain all input digits, and overflowing exponents render as signed Infinity, matching Python. - Why: matching CPython exactly means replicating its
float_repr(shortest-round-trip and its decimal/exponent threshold +e[+-]NNpadding), which ryu formats differently. - Remove: port CPython's
float_reprunder the JSON issue (#10).
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). The one remaining gap: inline styling within a table cell (e.g.**bold**inside a cell) is collected as plain text, since Table cells are strings, notTextrenderables. (The trailing-blank-line quirk for a document ending in a thematic break is now matched — goldenmarkdown_hr_end.) - Why: these are the common elements; cell-level inline styling needs Table cells to become full renderables (a larger refactor).
- Remove: give Table cells styled
Textcontent, then route inline markdown into table cells, under the Markdown issue (#9).
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¶
- Differs: an empty
Layoutleaf renders as blank space, not upstream's interactive_Placeholderpanel (which shows the layout name/size). - Why: the split/sizing/tiling core is the valuable part; the placeholder is a debugging aid.
- Remove: add a placeholder renderable under the Layout issue (#7).
- 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¶
- Differs: printing a full-height
Screenemits a trailing newline after the last row (like every other renderable in this port), whereas upstream's line-oriented print pipeline omits the final separator for aScreenthat exactly fills the console height. - Why: our
printuniformly appends one newline after a renderable; matching upstream's per-renderable trailing-newline suppression is a print-pipeline concern that would affect the shared path. - Remove: model upstream's line-based print (crop/emit rows without a trailing separator when a renderable fills the height) under the Console issue (#1).
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)¶
- Differs: style names on spans now resolve against the rendering console's
theme, as upstream does — that half is done (
StyleType,Theme::get_style). What is missing is upstream's per-console theme stack:Console.push_theme,pop_themeand theuse_themecontext manager. Names resolve against the console's single current theme instead. - Why: the stack forces a
&mut self-vs-interior-mutability decision that this port should not make casually. An RAII guard borrowing theConsolemutably makesconsole.print(...)inside the guard a borrow error — which is the entire use case — and aRefCellstack breaksConsole::theme() -> &Themeand adds analready borrowedpanic class on re-entrant renders. Upstream's stack is also thread-local, which sits awkwardly with aConsolethat gets moved between threads byLive::spawn. - Remove: design the stack against those constraints, under its own issue. Late-bound span names are a strict prerequisite and are now in place.
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.
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). - 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.
16. Progress — deterministic columns done; time/rate/spinner + Live deferred¶
- Differs:
Progressnow renders a configurableProgressColumnlist (default: description, flexing bar, percentage), with the deterministic columns ported byte-parity — description, static text, the bar, percentage, M-of-N ({completed}/{total}), and download (0.5/1.0 kB, shared SI byte unit viafilesize::pick_unit_and_suffix). The grid layout matches upstream'sTable.grid(padding=(0, 1)): fixed columns take their widest cell, the bar flexes (capped at 40), single unstyled space between columns. Still deferred: the non-deterministic columns (spinner, transfer-speed, time-remaining/elapsed) and the in-placeLiverefresh loop. - Why: the ported columns are deterministic (testable); the time/rate/spinner
columns depend on wall-clock elapsed and the refresh loop needs
Live(#17). - Remove: add the time/rate/spinner columns (with the
Liveloop) under the Live/progress issue (#6).
17. Live — auto-refresh thread done; alt-screen/redirect deferred¶
- Differs:
Liveimplements the deterministicstart/update/refresh/stopflow (byte-parity with upstream'sauto_refresh=False,transient=FalseLive), and a background auto-refresh thread —Live::spawn(...)returns an [AutoLive] handle that redraws every1/refresh_per_second(and on eachupdate), a port of upstream'srefresh_per_second. The thread constructs and owns theLiveinternally, so onlySendinputs (renderable/console/writer) cross over — which madeConsoleSend(its highlighter boxes are nowdyn Highlighter + Send). Still deferred:transient/alt-screen modes, stdout/stderr redirection, and the console render-hook integration;Livealso renders to a genericWritesink rather than throughConsole's own file. - Why: those remaining pieces are large plumbing; the refresh loop itself is ported and (with a long interval, so no timeout fires) even deterministically tested to emit the same stream through the thread.
- Remove: add a refresh thread +
transient/alt-screen handling, and route throughConsole, under the Live/progress issue (#6).
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. Line numbers, theSyntax.from_pathloader, word-wrap/line_range, and background-highlight ranges are not yet ported. - Why: Rust has no Pygments;
syntectis the standard Rust equivalent (mirrors howcellsdelegates East-Asian-width tounicode-width). Byte-parity is impossible across highlighter engines. - Remove: not removable while using a different engine; the divergence is inherent. Future work can add line numbers, themes matching rich's names, and the path/loader conveniences.
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) formats one log record — optional time, a severity-colored level, message, optional path — into a styled line, using the same column styles (log.time,logging.level.*,log.path). It takes aLogLevelenum + strings rather than depending onlog/tracing; wiring alog::Loghandler on top is arich-extfollow-up.- 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.
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.
21. Strikethrough delimiter runs of three or more tildes¶
- Differs:
~x~(single tilde) and~~x~~(double) match upstream exactly — the first is literal text, the second is struck through. A run of three or more tildes does not:~~~x~~~renders as literal~~~x~~~here, while upstream renders~x~(it consumes the outer pair and strikes the rest). - Why: the two parsers resolve delimiter runs differently.
pulldown-cmarkemits no strikethrough event at all for a triple run, so there is nothing to re-interpret after the fact; matching upstream would mean reimplementing markdown-it's delimiter-run algorithm rather than reading its output. - Remove: port markdown-it's
tokenize/postProcessdelimiter pairing for strikethrough, under the Markdown issue (#9).
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.
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.
- Enable:
cargo build -p rs-rich-cli --release --features syntax-cache. See benchmarks.