Skip to content

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 the unicode-width crate, 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 --panel content row rendered 43 cells wide inside a 40-cell box, physically breaking it.
  • Now: crates/rich/src/cell_widths.rs vendors upstream's table (464 ranges, 213 narrow_to_wide entries, Unicode 17.0.0), generated by scripts/gen_cell_widths.py; cells::cell_len ports _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 a UNICODE_VERSION environment 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_VERSION picks 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::render uses upstream's own RE_TAGS expression 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 in golden/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 meta as marshal.dumps(dict); Style here holds a Meta (string keys, MetaValue scalars 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 random link_id is 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 Text uses code-point offsets for spans; our Text uses 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: Text word-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 (["", "宽", …]) — and divide_line therefore 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 <= width never triggers the empty push). 0-width combining marks still stay attached to their base char (char-based, no grapheme table).
  • Residual (minor): chop_cells is 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_cells is upstream's grapheme form, over the ported split_graphemes, so ZWJ sequences and flags fold as upstream folds them.

6. Box substitution is opt-in (no legacy-terminal auto-detection)

  • Differs: Box.substitute is ported — Box::substitute applies upstream's whole legacy Windows table (ROUNDED, HEAVY, HEAVY_EDGE and HEAVY_HEAD to SQUARE, MINIMAL_HEAVY_HEAD to MINIMAL, SIMPLE_HEAVY to SIMPLE) when legacy_windows is set, and maps a box not flagged ascii to ASCII when ascii_only is set; Panel/Table apply it from their render options, with their own safe_box over the console's. The legacy_windows/safe_box/ascii_only console 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_only from a non-UTF-8 encoding at Console build time remains possible. legacy_windows is 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_wrap neighbour 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_three and table_many_columns in golden/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), as json.dumps does: shortest round-trip digits, exponent form below 1e-4 and from 1e16, and e+NN/e-NN padding (1e+20, 1e-07). Golden json_python_floats pins it against rich 15.0.0, alongside json_python_numbers for 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 Syntax renderable — so they're highlighted but not byte-identical to upstream (syntect ≠ Pygments; see #18). Links render as an OSC 8 hyperlink + the markdown.link_url style — byte-identical to upstream except the random id= field we omit (#20). Inline styling inside table cells is byte-parity since 0.0.11 (golden markdown_table_inline): cells hold styled Text, as upstream's TableDataElement does. The constructor options justify and style are byte-parity too (golden markdown_options); code_theme, inline_code_lexer and inline_code_theme take syntect theme names. (The trailing-blank-line quirk for a document ending in a thematic break is also matched — golden markdown_hr_end.) Every markdown.* style, <kbd>'s markdown.kbd among them, is looked up in the console's theme as upstream does, in upstream's stack order; a theme entry replaces the default outright (golden markdown_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.
  • Resolved: the decoder now reads OSC 8 sequences (\x1b]8;<params>;<url>\x1b\) and attaches the URL to the running Style via Style::update_link (the empty closing sequence clears it; id=/other params are ignored). Re-rendering reproduces upstream byte-for-byte except the random id= 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's id= is random).

11. ~~Layout — empty-leaf placeholder~~ (resolved)

  • Resolved: an empty leaf renders upstream's _Placeholder panel (name, size and highlighted repr), byte-parity in upstream_features.tsv, along with name/visible, split/add_split/unsplit, lookup by name, tree and map. map keys leaves by name and child-index path (Rust has no object identity to key by). refresh_screen is ported, with Console::update_screen and update_screen_lines.
  • Resolved: height-aware leaves — Panel now consumes options.height and expands to fill its region (byte-parity), via Console::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 Screen ends with its last row and no line break, as upstream's does, so the next output continues that row. Renderable::print_end_line tells Console::print whether a renderable ends its last line (screen_print, screen_then_text and screen_application_mode in golden/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)…), which fancy-regex can'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's ThemeStackError) and use_theme(theme), which returns a ThemeContext guard 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, and Console::theme() still returns &Theme (the top of the stack). Theme::from_styles, config, from_file and read port upstream's theme files, including configparser's lower-cased keys, [DEFAULT], continuation lines, % interpolation and error classes.
  • Behaviour kept from upstream: use_theme always inherits; upstream's ThemeContext never forwards its inherit argument (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 Console value, which fits a console that Live::spawn moves to another thread. Golden theme_stack.tsv covers push, pop, nested push, use_theme, Theme.config and from_file byte 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), and export_svg are all ported with byte-parity (svg.rs, golden tests/golden/svg_export.svg). The only SVG residual: upstream's default unique_id is adler32 over Python's repr() of each Segment, which Rust can't reproduce — so Console::export_svg takes an explicit unique_id, and output is byte-parity with export_svg(title=…, unique_id=…). Same shape as the OSC8 id= deviation (#20).
  • The record=True form (Console::export_svg_recorded, 0.0.23) takes the id in ExportSvgOptions::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-repr default 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 via export::format_template) and SVG font_aspect_ratio are ported (export::export_html_with, svg::export_svg_with, golden core_gaps.tsv). A code_format using conversions or format specs ({code!r}, {width:>5}) is refused with ExportFormatError rather than formatted.
  • Verdict (0.0.22): permanent. The default id hashes Python's repr() of every segment, which only CPython can produce; an explicit unique_id gives 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's get_time), task start/stop/finish times, the 30-second speed sample window, update/advance/reset/start_task/stop_task/remove_task with upstream's exact sample and finish rules, and TimeElapsed, TimeRemaining (compact, elapsed-when-finished and its 0.5 s render cache), TransferSpeed, FileSize, TotalFileSize, Spinner, TaskProgress { show_speed } and binary Download columns. Progress::new() now uses upstream's default columns (description, bar, percentage, time remaining). Golden progress_time.tsv replays 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).
  • TextColumn format strings over task attributes and per-task fields, through a port of Python's format mini-language.
  • RenderableColumn, whose cells may span several lines.
  • A live display: Progress::start returns a LiveProgress redrawn by the Live refresh thread (§17). Its byte stream matches upstream's auto_refresh=False Live (golden progress_live.tsv).
  • track(), both LiveProgress::track and the module-level track.
  • The display is now upstream's make_tasks_table: a Table::grid with each column's table-column options (ProgressColumn::with_table_column), so expand, BarColumn(bar_width=None) (ProgressColumn::BarWith) and narrow-width wrapping match. transient, disable, and wrap_file/open (LiveProgress::wrap_read/open) are ported (goldens in progress_time.tsv and progress_live.tsv).
  • Resolved (0.0.22):
  • track follows upstream's _TrackThread: a thread advances the task by the items counted every update_period, and when the iterator is exhausted or dropped the task's completed is set to the count and the display refreshed (LiveProgress::track_with takes completed, an existing task and the period).
  • The display takes its table at each refresh through Live's get_renderable, as upstream does, so a print between refreshes draws the table of the last refresh below it.
  • Progress.print (LiveProgress::print/print_str) and redirect_stdout/redirect_stderr (LiveProgress::stdout/stderr) go through the live display (§17). Golden parity_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.
  • open reads bytes; upstream's text mode (TextIOWrapper) is left to the caller, for example BufReader::lines over the returned reader. Text decoding belongs to the caller's reader.
  • Not a divergence: a LiveProgress always runs the refresh thread (upstream auto_refresh=True). For a manual display, render the Progress itself in a Live.

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, upstream screen=True: the renderable fills a Screen, 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::start runs a status in its own transient display. Golden parity_live.tsv (38 cases).
  • Permanent:
  • The redirect is a writer, not the process's stdout. Upstream assigns a FileProxy to sys.stdout and sys.stderr, so a plain print lands above the display. Rust cannot do that safely: print! and std::io::stdout() always reach file descriptor 1; std::io::set_output_capture is unstable and internal; and moving the descriptor with dup2 needs unsafe, platform-specific calls that would also capture the display's own output and every other thread's. So Live::stdout/stderr (and the same on AutoLive, LiveProgress, LiveStatus) return a FileProxy the program writes to instead. Behind it everything is upstream's: lines are buffered and decoded from ANSI, flush prints a partial line as console markup, a partial line is flushed when the display stops (CPython's close on 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 Live owns its console and output sink. Upstream's display pushes a render hook onto a shared console, so any console.print goes above it; here the console and the sink are moved into the Live (and into its refresh thread), so printing goes through Live::print. The output is the same.
  • Nested displays are drawn from what they last rendered (0.0.23). As upstream's, start pushes the display on its console's live stack (Console::set_live) and stop pops 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's Group), 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 no LiveError for it. Golden console.tsv (live_set_live, live_nested and the live_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 Live owns its console: displays nest when they are given one console's clones. A clone shares nothing else.
    • A Live is not Send, 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 last update without 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 AutoLive keeps 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.
  • Jupyter widgets are not ported (§19).

18. Syntax highlighting uses syntect, not Pygments (non-byte-parity)

  • Differs: Syntax (syntax.rs) highlights code with the syntect crate, 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 is base16-ocean.dark (a syntect built-in), not rich's ansi_dark/ monokai. dedent and Syntax.from_path are ported (golden syntax_dedent_* and syntax_from_path in parity_docs.tsv); guess_lexer answers with the path's extension, which syntect looks grammars up by, rather than a Pygments alias, and from_path reads 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 and ansi_dark. CodeHighlighter::token_style supplies the theme's Text and Comment styles for line numbers and guides. The default case follows upstream too: a long line is cropped at the width, and under overflow="ignore" rows are padded in background_style (the theme background stays on the code), as upstream does.
  • Why: Rust has no Pygments; syntect is the standard Rust equivalent (mirrors how cells delegates East-Asian-width to unicode-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::CodeHighlighter trait. SyntectHighlighter is the default, and Syntax::highlighter / Markdown::highlighter accept 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 any Syntax without 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 in rich_ext::testing::conformance.
  • Upstream's ANSI themes. ansi_dark and ansi_light use upstream's ANSI_DARK/ANSI_LIGHT styles 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 the logging handler 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 with ReprHighlighter. Because the coloring targets Python-repr spellings, Rust-specific tokens differ — true/false (vs True/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 its Error::source() chain (Caused by:) in a red-bordered panel. There are no stack frames or source snippets — Rust errors don't carry them (pair with std::backtrace::Backtrace at the call site if you want a frame list).
  • LogRender (log_render.rs) is now a faithful port of _log_render.py (golden log_render.tsv), except that it takes the time already formatted rather than a datetime and a strftime format. rich-ext's RichHandler is the logging.RichHandler counterpart: it renders log and tracing events through the LogAdapter/EventLayer sinks. Its default time is UTC [HH:MM:SS] (local time would need a time-zone dependency), structured fields follow the message as key=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 over PrettyValue, a description of the Python object (Pretty::from_value, pretty_repr, and scope.py's render_scope on top), byte for byte against rich 15.0.0 (parity_docs.tsv). What stays Rust-native needs reflection: Pretty::new over Debug, inspect, repr.py's decorators, tracebacks with frames and locals, and logging's rich tracebacks.
  • inspect from derive data (0.0.24, #796). rs-rich-ext's inspect module renders upstream's _inspect.py report (a fitted scope.border panel titled with the type, the first paragraph of the doc, the value in an inspect.value.border panel, and a grid of right-aligned name = rows in upstream's inspect.* styles) for Rust values. The facts come from #[derive(Inspect)] (rs-rich-macros, behind ext's macros feature) at compile time, not from runtime reflection: the type's type_name and doc comment, each field's name, type as written, doc comment, visibility and Debug value, and an enum's variants. Core is unchanged: _inspect.py stays ⛔ in the mirror. What differs from upstream:
  • only a type that derives Inspect (or implements Inspectable by hand) can be inspected; there is no inspect of a type, function or module, so no signature line for a callable;
  • methods are listed only from an inherent impl block 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;
  • dunder has no counterpart (Rust has no dunder names), and private means non-pub, not a leading _; all turns on methods, private and our types and variants;
  • titles read <struct '…'> / <enum '…'>; values are Debug output fitted to the width (one line, else {:#?}), without Pretty's max_length/max_string cuts; field doc comments follow their values when docs is on; #[inspect(nested)] fields are nested panels, cut at max_depth; the "not shown" hint names Inspect::all.
  • Differs: Style::with_link renders an OSC 8 hyperlink as \x1b]8;;{url}\x1b\…\x1b]8;;\x1b\. Upstream adds a random id= 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_strikethrough ports markdown-it's tokenize (odd runs split into ~ plus ~~ delimiters), balance_pairs and postProcess (a lone ~ before a closer moves after it), scoped per inline run with link labels nested. a ~~~x~~~ b renders a ~ + struck x + ~ b, as upstream does. Golden markdown_strike.tsv covers 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* strikes a *b upstream. 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_pairs pass, under the Markdown issue (#9). Pinned by tildes_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 validateLink refuses ([1]: javascript:x) is not a definition upstream, so its line prints as a paragraph. The port now finds such definitions in pulldown-cmark's reference_definitions(), escapes their [ and parses again, so the line prints as upstream prints it, continuation lines included, and links using the label stay literal (golden markdown_refused_definition_* in parity_docs.tsv).

25. JSON nesting stops at exactly 10 000 levels

  • Differs: Json::new refuses documents nested deeper than json::MAX_DEPTH (10 000) with CPython's RecursionError message. 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: Text marks a final empty line with an empty segment, which Segment::split_lines keeps, so Padding(Text("a\n")), Panel(Text("a\n\n")), Table cells, Tree labels and Align keep the blank line as upstream does (the rich-cli --syntax bottom 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: decode now splits as rich 15.0.0 does: after each \n, keeping a \r before it (which then resets the line), with a final empty line for text ending in a newline (unit test decode_splits_as_rich_15, values from rich 15.0.0). decode_split_newlines stays 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, decode text.lines() one line at a time.

29. ~~Renderables cannot end without a newline when printed~~ (resolved)

  • Resolved (0.0.22): Rule(end=…) with an end that has no trailing newline and a printed LiveRender leave the line open, as upstream's do, through Renderable::print_end_line (rule_end and live_render_print in golden/parity_widgets.tsv). Inside containers both already matched.

30. A lone surrogate in Json prints as U+FFFD without ensure_ascii

  • Differs: Python's json accepts an unpaired \uD800-\uDFFF escape, and json.dumps(ensure_ascii=False) puts the surrogate itself in the output. A Rust String cannot hold one, so the port prints U+FFFD in its place. With ensure_ascii (re-escaped as \ud800) and for sort_keys ordering and repeated-key detection the port is exact: strings are kept as generalized UTF-8 until they are printed (golden json_lone_surrogate_ascii in audit_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_style raises MissingStyle for a name that is neither in the theme nor parseable, so Table(row_styles=["nope"]), Panel(border_style="nope") and the like fail at render time. Core's renderables resolve every such option with Theme::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 and Text spans are already silent upstream.)
  • Why: Renderable::rich_render has no error channel; Style::parse and Console::get_style return 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_render through every container and every extension, for input that Style::parse and Console::get_style already reject before printing.

32. Columns(width=0) with no horizontal padding renders nothing

  • Differs: upstream computes max_width // (width + padding) and raises ZeroDivisionError when both are zero. Core's Columns returns no output for that case instead of panicking (the Python binding raises ZeroDivisionError itself before calling core).
  • Why: Renderable::rich_render has 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 (golden markdown_nested_table_* in parity_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 (golden markdown_fuzz_*).
  • Still differs (permanent): markdown-it tries its table rule before its list rule, so - a|b over an indented -|- is a table whose header cell is - a upstream; 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, -j is --emoji and -x is --lexer (its JSON is -J, and --syntax has no short). This CLI gave -j to --json and -x to --syntax before it ported those options. It keeps them, accepts upstream's -J for --json too, and spells the other two out: --emoji and --lexer have no short name. Every other upstream short alias is upstream's, -h for --head included, so help is --help only.
  • 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 (--head with --markdown) is an error here; rich-cli ignores it. So are the --text-* justifies, --rule-style and --rule-char without --rule (or, for the justifies, --print). --head/--tail values beyond a 64-bit integer are refused, where Python takes any integer. Options take their value as the next argument only: click's --opt=value and clustered short flags (-nL) are refused as unknown options.
  • Why: changing -j and -x would 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 --rst hands the document to rich-rst 2.2.0, which parses it with its vendored docutils (0.22.4), applies docutils' transforms and renders the tree. rich_ext::rst parses 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 roles rich-rst registers), 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), replace and image substitutions, and the code (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-table and csv-table (its data inline) directives, and include, which says file access is off, as rich-rst does by default. Other directives docutils or rich-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 the date substitution, 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 RecursionError at about 170 levels, where rich-rst raises.
  • Same: everything the subset covers prints byte for byte what rich-rst 2.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, from scripts/capture_rst_golden.py). As everywhere, code blocks are highlighted by syntect (§18) and OSC 8 links carry no id=.
  • 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-rst 1.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::protocol adds RegionSink, set on a Console through ConsoleRegions. Only when one is installed do Panel, Table (and its header, body and footer cells), Rule, and Markdown headings and code blocks call it, and tag the segments they drew with a rich.region key 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::frame strips 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.rs checks 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_html and Frame::to_svg use core's templates and write the same bytes as Console::export_html and export_svg for 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 &amp;, " as &quot;); 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_html does not filter it: a javascript: 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::export unit 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 unsafe Win32 calls in core, which denies unsafe_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 with getpass, which turns the terminal's echo off. The port's prompts read through an InputSource and have no password option: the standard one echoes what is typed.
  • Why: turning echo off needs terminal-mode calls (termios, the Win32 console), that is platform code or unsafe in a core that denies it, or a new dependency for one option. An application that needs it passes its own InputSource that reads without echo (for example over the rpassword crate); 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 a Splitter subclass as well as a name, so a program can lay children out its own way. The port's Splitter is a closed enum of upstream's two, Row and Column; a name upstream would reject (NoSplitter) is None from Splitter::from_name.
  • Why: both built-in splitters are ported and byte-identical; an open splitter would make Splitter a trait object, losing Copy and Eq on a public type, for an extension point upstream does not use itself.
  • Remove: add a Splitter::Custom that 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_link and clear_meta_and_links copy the cached definition, so str(style.update_link(None)) still names the old link when str(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_renderable and protocol.rich_cast (__rich__, with its guard against objects that claim every attribute), abc.RichRenderable (an isinstance hook) and _extension.load_ipython_extension (IPython) are not ported. A Rust type is renderable when it implements Renderable, which the compiler checks; a type that renders as another one implements Renderable by delegating, as Color and Palette do for upstream's __rich__. Measurement.get cannot meet a non-renderable, so it never raises NotRenderableError.
  • 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 Console writes to standard output. Callers collect output with record= (export_*_recorded), begin_capture/ end_capture or 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_), no print_exception (Python tracebacks, §19), and no log_locals (the caller's local variables). Console.log finds 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.control is a list of control codes; here it is a bool, the codes already written into the segment's text. And Segment::simplify never merges a control segment into the text that follows it, where upstream's does (which then lets export_html and export_text show 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.rs records its HTML sessions without one.
  • Remove: not planned.

45. Markup handler tags evaluate a subset of Python literals

  • Differs: upstream's [@handler=…] tags run ast.literal_eval on 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 a MarkupError here. Syntax errors carry Python's messages for unclosed and mismatched brackets and unterminated strings; any other invalid parameter reports malformed node or string without the AST node and memory address upstream's ValueError names.
  • 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_datetime is datetime.now(), local time, formatted by the C library's strftime. Core has no time zone data, so the port's default clock is SystemTime::now() and its strftime (the C-locale directives, in log_render.rs) formats the UTC wall-clock time. A console built with get_datetime or log_time_formatter shows what it is given, so a program can add its offset. The caller is the code that called Console::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, as os.path.abspath does. Console::log_at takes the file and line. Progress.log (LiveProgress::log, 0.0.23) and Live::log find their caller the same way, and have log_at forms too.
  • Why: local time needs a time zone database or platform calls (unsafe in 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_renderables receives 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 opaque Box<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 'static box, as the port's containers require. Hooks that add output before or after (as upstream's live display does) see no difference. Golden console.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_html and save_svg were ported first as closure forms (they record what the closure prints). The record=True forms are named export_*_recorded and save_*_recorded, with upstream's keyword arguments in ExportHtmlOptions and ExportSvgOptions. Exporting from a console that is not recording is RichError::Console with upstream's assertion message, and an unusable code_format is RichError::Console too (upstream raises Python's KeyError).
  • Nested closure captures (Console::capture) each return what was printed inside them. Upstream's nested capture() contexts share one buffer per thread, so the inner one takes everything. begin_capture and end_capture follow upstream.
  • on_broken_pipe is replaced through ConsoleBuilder::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 at os.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 size read. COLUMNS and LINES are read at construction in both.
  • set_live/clear_live keep a stack of Arc<dyn Any>, shared by a console and its clones. A Live pushes 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::Renderable is shared (Arc<dyn Renderable + Send + Sync>), as a Table cell is, because a log lays its message out in a table.
  • pop_render_hook and clear_live with nothing to pop do nothing; upstream raises IndexError.
  • 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 from https:// to http://, and renders the body with its terminal controls intact. This CLI (0.0.24) refuses a redirect from an https:// hop to an http:// one, with an error naming both, before that request is made; an http:// URL given on the command line is still fetched. Content fetched from a URL is sanitized by default in every mode, as --sanitize does: ESC, C1 and other controls (window titles, OSC 52 clipboard writes, OSC 8 links, cursor moves) are shown as inert text, while rich'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 (or sanitize = false in the user's own config, not a working-directory rich.toml) lets a URL's escapes through. The downgrade has no flag: fetch the http:// 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 variable RICH_THEME, default ansi_dark) is the Pygments style that Syntax and Markdown code blocks are highlighted with. Read from rich-cli 1.8.1's rich_cli/__main__.py (a copy of the source, not the oracle venv).
  • Here: --theme NAME selects a named theme of rich styles from the config file (or an installed marketplace theme), applied to the whole console with --theme-style and --theme-file layered over it; an unknown name is a usage error. The code theme is --code-theme NAME (with --highlighter), and RICH_THEME is not read. With neither option, code is highlighted as before.
  • Why: the CLI's config themes (0.0.9) took --theme before 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): --inspect evals the resource as a Python expression and shows rich.inspect of the object (its methods, not its dunders); a failed eval prints the traceback and exits with an error. Read from rich-cli 1.8.1's rich_cli/__main__.py.
  • Here: --inspect (and rich 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, --redact and --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 '-'> and See rich --help for options, and exits 0. Read from rich-cli 1.8.1's rich_cli/__main__.py (print_usage).
  • Here: with no resource and no mode (and not --batch), it shows the capability demo, as --demo does without its delay, and exits 0. rich --help is 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 HighlightLines path.
  • Opt-in: the syntax-cache Cargo feature enables repository-specific repeated-line parsing reuse. It is off by default in both rs-rich and rs-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: Syntax and Markdown code blocks run syntect with the pure-Rust fancy-regex engine.
  • Opt-in: the onig Cargo 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 in rs-rich and rs-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 rich run 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: onig changes 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, or rs-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 now code plus syntect, and markdown implies code; the default feature set, and so the default build and its output, are as before.
  • Opt-in: the code Cargo feature, off by default, keeps Syntax, the CodeHighlighter plumbing and everything Markdown needs without syntect (default-features = false, features = ["code", "markdown"]). Such a build has no built-in engine: a Syntax or Markdown code block highlights with its own highlighter or the console's default (ConsoleCodeHighlighting, which rich_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 keep Syntax and 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.