From df4bb3e727c3c3872c36024437721bcbaee894f5 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:38:39 +0200 Subject: [PATCH 01/11] fix(math): cap nesting on every recursive entry and parse \sqrt indexes in one pass $\sqrt[\sqrt[...x$ recursed once per index with no depth check and re-scanned the remainder at each level, aborting the process on a deep enough formula. A linear pre-scan now shows any formula nested deeper than 32 levels as its source, every recursive entry (seq, command, environment) checks the cap itself, and the \sqrt index is rendered in place. --- src/layout/math.rs | 232 ++++++++++++++++++++++++++++++++++++++------- tests/security.rs | 16 ++++ 2 files changed, 212 insertions(+), 36 deletions(-) diff --git a/src/layout/math.rs b/src/layout/math.rs index 13311a6..89ae2a1 100644 --- a/src/layout/math.rs +++ b/src/layout/math.rs @@ -17,15 +17,21 @@ //! (inline math puts them on one line: `(a b; c d)`). //! - Unknown commands are kept verbatim with their brace arguments. //! -//! Never panics on malformed input; nesting deeper than [`MAX_DEPTH`] is -//! shown as its source. In ASCII mode nothing is converted: the source is -//! shown with `\left`/`\right` and spacing commands removed ([`clean`]). +//! Never panics on malformed input; a formula nested deeper than +//! [`MAX_DEPTH`] is shown as its source, in time linear in its length. In +//! ASCII mode nothing is converted: the source is shown with +//! `\left`/`\right` and spacing commands removed ([`clean`]). use super::scripts; +use std::cell::Cell; use unicode_width::UnicodeWidthStr; -/// Deepest group/argument/environment nesting rendered; deeper content is -/// shown as written. Keeps the recursion bounded on hostile input. +/// Deepest nesting rendered; a formula that goes deeper is shown as its +/// source. A linear pre-scan ([`nesting`]) catches brace, `\sqrt[` and +/// environment nesting before any work; the check at the top of every +/// recursive entry (`Renderer::seq`, `Renderer::command`, +/// `Renderer::environment`) catches the rest (`\not\not…`), so no path can +/// recurse past it on hostile input. const MAX_DEPTH: usize = 32; /// Inline math as one line of text. @@ -33,7 +39,7 @@ pub fn render_inline(src: &str, ascii: bool) -> String { if ascii { return clean(src).join(" "); } - Renderer { inline: true }.top(src).flatten() + Renderer::new(true).top(src).flatten() } /// Display math as one or more lines (environments and `\\` give rows). @@ -41,7 +47,7 @@ pub fn render_display(src: &str, ascii: bool) -> Vec { if ascii { return clean(src); } - Renderer { inline: false } + Renderer::new(false) .top(src) .rows .into_iter() @@ -212,6 +218,80 @@ impl Block { struct Renderer { inline: bool, + /// Set when a recursive entry passed [`MAX_DEPTH`]: every level then + /// returns at once and [`Renderer::top`] shows the source instead. + capped: Cell, +} + +/// The deepest nesting of `{…}`, `\sqrt[…]` and `\begin…\end` in `src`, in +/// one linear pass. Unbalanced closers never go below zero. +fn nesting(src: &str) -> usize { + let chars: Vec = src.chars().collect(); + let (mut depth, mut max, mut brackets) = (0usize, 0usize, 0usize); + let mut i = 0; + while i < chars.len() { + match chars[i] { + '\\' => { + let (name, next) = command_name(&chars, i + 1); + match name.as_str() { + "begin" => depth += 1, + "end" => depth = depth.saturating_sub(1), + "sqrt" => { + let mut j = next; + while chars.get(j).is_some_and(|c| c.is_whitespace()) { + j += 1; + } + if chars.get(j) == Some(&'[') { + depth += 1; + brackets += 1; + i = j + 1; + max = max.max(depth); + continue; + } + } + _ => {} + } + i = next.max(i + 1); + max = max.max(depth); + continue; + } + '{' => depth += 1, + '}' => depth = depth.saturating_sub(1), + ']' if brackets > 0 => { + brackets -= 1; + depth = depth.saturating_sub(1); + } + _ => {} + } + max = max.max(depth); + i += 1; + } + max +} + +/// The source as written, for a formula too deep to render: one entry per +/// non-blank line. +fn as_source(src: &str) -> Block { + let rows: Vec = src + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + if rows.is_empty() { + return Block::text(""); + } + Block { rows, base: 0 } +} + +/// Where `Renderer::seq` stops. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Close { + /// The end of input. + End, + /// The `}` closing a group. + Brace, + /// The `]` closing an optional argument. + Bracket, } /// A cursor over the characters of one source string. @@ -280,9 +360,33 @@ impl Cursor { } impl Renderer { + fn new(inline: bool) -> Self { + Renderer { + inline, + capped: Cell::new(false), + } + } + + /// Marks the render as too deep; every level unwinds from here. + fn cap(&self) -> Block { + self.capped.set(true); + Block::text("") + } + /// A whole formula: split into rows on top-level `\\` (display) and - /// aligned on `&` when it has any. + /// aligned on `&` when it has any. Too deep to render: its source. fn top(&self, src: &str) -> Block { + if nesting(src) > MAX_DEPTH { + return as_source(src); + } + let out = self.layout(src); + if self.capped.get() { + return as_source(src); + } + out + } + + fn layout(&self, src: &str) -> Block { let rows = split_top(src); if rows.len() == 1 && rows[0].len() == 1 { return self.render(&rows[0][0], 0); @@ -311,22 +415,33 @@ impl Renderer { /// Render `src` as a sequence, at nesting `depth`. fn render(&self, src: &str, depth: usize) -> Block { let mut cur = Cursor::new(src); - self.seq(&mut cur, depth, false) + self.seq(&mut cur, depth, Close::End) } - /// Render up to the end of input, or up to and including the `}` that - /// closes the group when `in_group`. - fn seq(&self, cur: &mut Cursor, depth: usize, in_group: bool) -> Block { + /// Render up to `close`: the end of input, or up to and including the + /// `}` / `]` that closes the group. Past [`MAX_DEPTH`], or once any + /// level has been, it stops at once (see [`Renderer::capped`]). + fn seq(&self, cur: &mut Cursor, depth: usize, close: Close) -> Block { + if depth > MAX_DEPTH { + return self.cap(); + } let mut out = Block::text(""); while let Some(c) = cur.peek() { + if self.capped.get() { + return out; + } match c { '}' => { cur.i += 1; - if in_group { + if close == Close::Brace { return out; } out.push_str("}"); } + ']' if close == Close::Bracket => { + cur.i += 1; + return out; + } '{' => { cur.i += 1; let g = self.group(cur, depth); @@ -372,12 +487,9 @@ impl Renderer { } /// A brace group whose `{` was just consumed. Past the depth cap its - /// content is shown as written. + /// content is shown as written (checked in `seq`). fn group(&self, cur: &mut Cursor, depth: usize) -> Block { - if depth + 1 > MAX_DEPTH { - return Block::text(cur.raw_group()); - } - self.seq(cur, depth + 1, true) + self.seq(cur, depth + 1, Close::Brace) } /// One argument: a brace group, a command, or a single character. @@ -391,13 +503,7 @@ impl Renderer { Some('\\') => { cur.i += 1; let mut out = Block::text(""); - if depth + 1 > MAX_DEPTH { - let (name, next) = command_name(&cur.s, cur.i); - cur.i = next; - out.push_str(&format!("\\{name}")); - } else { - self.command(cur, depth + 1, &mut out); - } + self.command(cur, depth + 1, &mut out); out } Some(c) => { @@ -408,8 +514,13 @@ impl Renderer { } } - /// A command; its backslash was just consumed. + /// A command; its backslash was just consumed. Past [`MAX_DEPTH`] the + /// render stops (see [`Renderer::capped`]). fn command(&self, cur: &mut Cursor, depth: usize, out: &mut Block) { + if depth > MAX_DEPTH || self.capped.get() { + self.cap(); + return; + } let (name, next) = command_name(&cur.s, cur.i); cur.i = next; match name.as_str() { @@ -435,13 +546,8 @@ impl Renderer { cur.skip_ws(); let index = if cur.peek() == Some('[') { cur.i += 1; - let start = cur.i; - while cur.peek().is_some_and(|c| c != ']') { - cur.i += 1; - } - let raw: String = cur.s[start..cur.i].iter().collect(); - cur.i = (cur.i + 1).min(cur.s.len()); - let n = self.render(&raw, depth + 1).flatten(); + // Rendered in place up to its `]`: one pass, depth-capped. + let n = self.seq(cur, depth + 1, Close::Bracket).flatten(); let n: String = n.chars().filter(|c| !c.is_whitespace()).collect(); match scripts::to_superscript(&n) { Some(s) => s, @@ -534,6 +640,9 @@ impl Renderer { /// `\begin{env}` … `\end{env}`; the `\begin{env}` was just consumed. fn environment(&self, cur: &mut Cursor, env: &str, depth: usize) -> Block { + if depth + 1 > MAX_DEPTH || self.capped.get() { + return self.cap(); + } // Column spec of `array`, position of `aligned`: not needed. if env == "array" { cur.raw_arg(); @@ -546,9 +655,6 @@ impl Renderer { cur.i = (cur.i + 1).min(cur.s.len()); } let body = end_of_environment(cur); - if depth + 1 > MAX_DEPTH { - return Block::text(format!("\\begin{{{env}}}{body}\\end{{{env}}}")); - } let rows = split_top(&body); if self.inline { let (l, r) = match env { @@ -1421,4 +1527,58 @@ mod tests { // Top-level rows and alignment without an environment. assert_eq!(display(r"a &= b \\ cc &= d"), [" a = b", "cc = d"]); } + + /// Runs `f` on a 1 MB stack, as a worker thread would, and fails if it + /// overflows or takes longer than half a second (debug build). + fn on_small_stack(name: &str, f: impl FnOnce() + Send + 'static) { + let start = std::time::Instant::now(); + std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(f) + .unwrap() + .join() + .unwrap_or_else(|_| panic!("{name}: overflowed its stack")); + let took = start.elapsed(); + assert!(took.as_millis() < 500, "{name}: took {took:?}"); + } + + // Regression: `\sqrt[` rendered its index with an unchecked recursive + // call (and re-scanned the remainder at each level), so 200,000 of them + // aborted the process. Every recursive entry is capped now. + #[test] + fn hostile_nesting_is_bounded_and_linear() { + const N: usize = 200_000; + let cases: Vec<(&str, String)> = vec![ + ("sqrt index", format!("{}x", r"\sqrt[".repeat(N))), + ("frac", format!("{}x", r"\frac{".repeat(N))), + ("superscript", format!("x{}y", "^{".repeat(N))), + ("subscript", format!("x{}y", "_{".repeat(N))), + ("matrix", format!("{}a", r"\begin{matrix}".repeat(N))), + ("left", format!("{}x", r"\left(".repeat(N))), + ("not", format!("{}x", r"\not".repeat(N))), + ( + "mixed", + format!( + "{}x", + r"\sqrt[\frac{^{_{\begin{pmatrix}\left(\mathbb{".repeat(N / 7) + ), + ), + ]; + for (name, src) in cases { + on_small_stack(name, move || { + let a = render_inline(&src, false); + let b = render_display(&src, false); + assert!(!a.is_empty() && !b.is_empty()); + }); + } + } + + #[test] + fn sqrt_index_still_renders() { + assert_eq!(inline(r"\sqrt[3]{x}"), "³√x"); + assert_eq!(inline(r"\sqrt[n]{x}"), "ⁿ√x"); + assert_eq!(inline(r"\sqrt[{]}]{x}"), "(])√x"); + // Unclosed index: no panic. + let _ = inline(r"\sqrt[3"); + } } diff --git a/tests/security.rs b/tests/security.rs index 4852e83..1f14e12 100644 --- a/tests/security.rs +++ b/tests/security.rs @@ -178,6 +178,22 @@ fn absurdly_deep_nesting_cannot_crash_the_renderer() { assert!(out.contains("deep")); } +// Regression: `$\sqrt[\sqrt[…x$` recursed once per `\sqrt[` with no depth +// check and aborted `ink --plain` with a stack overflow. +#[test] +fn deeply_nested_math_cannot_crash_the_renderer() { + let out = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(|| { + let source = format!("before ${}x$ after\n", r"\sqrt[".repeat(200_000)); + render_plain(&source, &args()).unwrap() + }) + .unwrap() + .join() + .expect("math renderer overflowed its stack"); + assert!(out.contains("before") && out.contains("after")); +} + /// A document whose heading and body carry raw ESC/OSC/BEL bytes: an OSC 0 /// window-title set, a CSI clear-screen, and a bare BEL. const HOSTILE_DOC: &str = From afdd292554aa674e64dc4b56faaf86272fe67389 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:41:09 +0200 Subject: [PATCH 02/11] fix(parser): turn off ^superscript^ and ||spoiler|| syntax Both comrak extensions paired carets and double bars in ordinary prose: 'Compute 2^10 and 3^5' rendered as 2^(10 and 3)5, and '(a||b) or (c||d)' hid 'b) or (c'. Neither is GitHub markdown, so both are off and such text renders verbatim as in v0.9.0. / HTML still gives scripts. --- README.md | 2 +- docs/llms-full.txt | 2 +- docs/llms.txt | 2 +- src/layout/mod.rs | 49 ------------------------------------- src/layout/table.rs | 36 ++------------------------- src/parser/mod.rs | 21 +++++++--------- tests/cli.rs | 4 +-- tests/fixtures/ascii.md | 2 +- tests/rendering_features.rs | 48 ++++++++++++++++++++++-------------- tests/security.rs | 6 ++--- 10 files changed, 49 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index 67a2f3a..8b93c13 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ ink --plain README.md Headings, bold, italic, strikethrough, links, blockquotes, lists, task lists, tables, footnotes, horizontal rules — all rendered with proper styling and colors. -Tables follow their column alignment (`:--`, `:-:`, `--:`), wide CJK and emoji cells included. Superscript `x^2^` (or `x2`) and subscript `H2O` come out as `x²` and `H₂O` (as `^(…)`/`_(…)` when a character has no Unicode form). Single and double tildes, `~x~` and `~~x~~`, strike through as on GitHub. `||spoilers||` stay hidden until you select or copy them, and definition lists (`Term` then `: definition`) get a bold term with its definitions indented underneath. +Tables follow their column alignment (`:--`, `:-:`, `--:`), wide CJK and emoji cells included. HTML superscript `x2` and subscript `H2O` come out as `x²` and `H₂O` (as `^(…)`/`_(…)` when a character has no Unicode form). Carets and double bars in prose (`2^10`, `a||b`) are shown as written: `^x^` and `||x||` are not GitHub markdown. Single and double tildes, `~x~` and `~~x~~`, strike through as on GitHub, and definition lists (`Term` then `: definition`) get a bold term with its definitions indented underneath. ### Syntax-highlighted code blocks diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 1946bc5..f03bf59 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -17,7 +17,7 @@ ink is a feature-rich terminal markdown reader built in Rust. It renders markdow - Ordered and unordered lists with nesting - Task lists with checkbox rendering - Footnotes -- Superscript ^x^ and x, subscript x (Unicode forms, ^(x)/_(x) fallback); ~x~ and ~~x~~ are strikethrough as on GitHub; ||spoilers||, definition lists (Term / : definition) +- Superscript x and subscript x (Unicode forms, ^(x)/_(x) fallback); ^x^ and ||x|| are not syntax (carets and double bars in prose are shown as written); ~x~ and ~~x~~ are strikethrough as on GitHub; definition lists (Term / : definition) - Horizontal rules - Inline/block math ($...$, $$...$$, ```math) rendered as Unicode text: Greek letters, operators, \mathbb/\mathcal letters, super/subscripts, \frac as a/b, \sqrt, matrix/cases/aligned environments on several lines; unknown commands kept as written; ASCII mode shows the LaTeX source. A lone $ in prose stays a dollar sign. :emoji: shortcodes resolved to glyphs - HTML blocks shown as text; common inline HTML (
, , , ) handled rather than dropped, / as Unicode sub/superscripts diff --git a/docs/llms.txt b/docs/llms.txt index cda2302..241ee29 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -6,7 +6,7 @@ ink is a command-line tool that renders markdown files in the terminal with rich ## What it does -- Renders markdown with syntax highlighting, tables (with column alignment), blockquotes, lists, task lists, footnotes, definition lists, superscript (^x^, ) and subscript (), spoilers, and horizontal rules +- Renders markdown with syntax highlighting, tables (with column alignment), blockquotes, lists, task lists, footnotes, definition lists, HTML superscript () and subscript (), and horizontal rules - Displays inline images using terminal graphics protocols (Kitty, iTerm2, Sixel) where supported, falling back to Unicode half-block characters (any true-color terminal); --image-protocol selects the renderer - Renders mermaid diagrams (flowcharts, sequence diagrams, pie charts, Gantt charts) as ASCII art - Supports GitHub-style admonitions (NOTE, TIP, IMPORTANT, WARNING, CAUTION) with custom titles, and Obsidian callouts diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 7a5e904..7743da1 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -1824,55 +1824,6 @@ fn collect_inline_seq<'a>( collect_inlines(child, ctx, spans, &style, depth + 1); continue; } - NodeValue::Superscript | NodeValue::Subscript => { - let sup = matches!(data.value, NodeValue::Superscript); - drop(data); - // Raised/lowered text is one run of characters: inner markup - // is flattened (Unicode forms have no bold or italic). - let text = collect_child_text(child); - let ascii = crate::glyphs::current().ascii; - spans.push(StyledSpan { - text: if sup { - scripts::superscript(&text, ascii, false) - } else { - scripts::subscript(&text, ascii, false) - }, - style: parent_style.clone(), - }); - continue; - } - NodeValue::SpoileredText => { - drop(data); - // `||text||`: the bars stay visible (dim) so the spoiler is - // marked even without colour; the text itself is drawn in its - // background colour, a solid bar until selected or copied. - let bars = SpanStyle { - dim: true, - ..parent_style.clone() - }; - let hidden = SpanStyle { - fg: Some(ctx.theme.colors.code_bg.clone()), - bg: Some(ctx.theme.colors.code_bg.clone()), - dim: true, - ..parent_style.clone() - }; - spans.push(StyledSpan { - text: "||".to_string(), - style: bars.clone(), - }); - let first = spans.len(); - collect_inlines(child, ctx, spans, &hidden, depth + 1); - // Inner links/code set their own colours: conceal them too. - for span in &mut spans[first..] { - span.style.fg = hidden.fg.clone(); - span.style.bg = hidden.bg.clone(); - } - spans.push(StyledSpan { - text: "||".to_string(), - style: bars, - }); - continue; - } NodeValue::Link(link) => { let url = link.url.clone(); let style = SpanStyle { diff --git a/src/layout/table.rs b/src/layout/table.rs index a8aefa3..0e6beb5 100644 --- a/src/layout/table.rs +++ b/src/layout/table.rs @@ -548,43 +548,11 @@ fn collect_cell_text<'a>(node: &'a AstNode<'a>) -> String { use comrak::arena_tree::NodeEdge; // Iterative pre-order walk: inline nesting can be thousands deep. let mut buf = String::new(); - // A raised/lowered run is converted as a whole; its subtree is skipped. - let mut skip_until: Option<&'a AstNode<'a>> = None; for edge in node.traverse() { - let inner = match edge { - NodeEdge::Start(n) => n, - NodeEdge::End(n) => { - if skip_until.is_some_and(|s| std::ptr::eq(s, n)) { - skip_until = None; - } else if skip_until.is_none() - && matches!(n.data.borrow().value, NodeValue::SpoileredText) - { - buf.push_str("||"); - } - continue; - } - }; - if skip_until.is_some() { + let NodeEdge::Start(inner) = edge else { continue; - } + }; match &inner.data.borrow().value { - NodeValue::Superscript | NodeValue::Subscript => { - let sup = matches!(inner.data.borrow().value, NodeValue::Superscript); - let mut text = String::new(); - for d in inner.descendants() { - if let NodeValue::Text(t) = &d.data.borrow().value { - text.push_str(t); - } - } - let ascii = crate::glyphs::current().ascii; - buf.push_str(&if sup { - super::scripts::superscript(&text, ascii, false) - } else { - super::scripts::subscript(&text, ascii, false) - }); - skip_until = Some(inner); - } - NodeValue::SpoileredText => buf.push_str("||"), NodeValue::Math(m) => buf.push_str(&super::math::render_inline( &m.literal, crate::glyphs::current().ascii, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 7e9e357..a06aaa4 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -43,12 +43,12 @@ pub fn options() -> Options<'static> { // `` $`…`$ `` / ```` ```math ````. opts.extension.math_dollars = true; opts.extension.math_code = true; - // `^sup^`, `||spoiler||`, and definition lists (`Term` then - // `: definition`). Not `subscript`: it turns GitHub's single-tilde - // `~strike~` into subscript (`` still works). Not `underline`: it - // turns `__bold__` into underlined text. - opts.extension.superscript = true; - opts.extension.spoiler = true; + // Definition lists (`Term` then `: definition`). Not `superscript` + // (`^x^`) or `spoiler` (`||x||`): neither is GitHub markdown, and both + // pair up ordinary prose (`2^10 and 3^5`, `(a||b) or (c||d)`); + // ``/`` still work. Not `subscript`: it turns GitHub's + // single-tilde `~strike~` into subscript. Not `underline`: it turns + // `__bold__` into underlined text. opts.extension.description_lists = true; // In ASCII mode, an ASCII document must stay ASCII: no `:smile:` → emoji // and no smart quotes, dashes or ellipses. @@ -100,13 +100,13 @@ pub fn options_for(source: &str) -> Options<'static> { /// Upper bound on AST depth: every nesting level is opened by at least one /// of these bytes (`>` blockquotes; `-` `*` `+` `.` `)` list markers; `*` -/// `_` `~` emphasis and strikethrough; `^` superscript; `|` -/// spoilers; `:` definition lists; `[` links, images, footnotes), plus the +/// `_` `~` emphasis and strikethrough; `:` definition lists; `[` links, +/// images, footnotes), plus the /// few fixed levels (document, paragraph, text). fn nesting_bound(source: &str) -> usize { 8 + source .bytes() - .filter(|b| b"<>*_~^|:[-+.)".contains(b)) + .filter(|b| b"<>*_~:[-+.)".contains(b)) .count() } @@ -184,9 +184,6 @@ mod tests { format!("{}x{}", "*".repeat(20000), "*".repeat(20000)), // Openers need not be adjacent to nest. format!("{}x{}", "*a _".repeat(2000), "_ a*".repeat(2000)), - // Superscripts and spoilers nest like emphasis. - format!("{}x{}", "^a *".repeat(2000), "* a^".repeat(2000)), - format!("{}x{}", "||a ^".repeat(2000), "^ a||".repeat(2000)), ] { let src = src + NOTE; let arena = Arena::new(); diff --git a/tests/cli.rs b/tests/cli.rs index 9962b10..73a6962 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -582,7 +582,7 @@ fn plain_keeps_256_colours_on_xterm_256color() { /// `tests/fixtures/ascii.md` is pure ASCII and covers headings (levels 1-3), /// nested lists, an ordered list, a task list, a table that wraps, a fenced /// code block, nested blockquotes, admonitions and callouts with titles, -/// super/subscripts, a spoiler, a definition list, a rule and (shown with +/// super/subscripts, a definition list, a rule and (shown with /// `--frontmatter`) YAML frontmatter, and inline and display math. In /// ASCII mode /// every byte ink prints for it must be 7-bit. Nothing is exempt for this @@ -627,7 +627,7 @@ fn ascii_mode_extension_fallbacks() { "| (*) A custom title", "| (i) An Obsidian callout", "| Callout body.", - "Water is H_(2)O, e = mc^(2), struck, struck and ||a spoiler||.", + "Water is H_(2)O, e = mc^(2), struck, struck and 2^10 (a||b).", " Term\n > Its definition.", // Math: the cleaned LaTeX source, never Unicode. "Math x^2 + \\alpha costs $5 and $10.", diff --git a/tests/fixtures/ascii.md b/tests/fixtures/ascii.md index 5011c18..ce1b969 100644 --- a/tests/fixtures/ascii.md +++ b/tests/fixtures/ascii.md @@ -44,7 +44,7 @@ fn main() { > [!info]- An Obsidian callout > Callout body. -Water is H2O, e = mc^2^, ~~struck~~, ~struck~ and ||a spoiler||. +Water is H2O, e = mc2, ~~struck~~, ~struck~ and 2^10 (a||b). Term : Its definition. diff --git a/tests/rendering_features.rs b/tests/rendering_features.rs index d8604c9..82fabaa 100644 --- a/tests/rendering_features.rs +++ b/tests/rendering_features.rs @@ -145,18 +145,40 @@ fn find_span<'l>( } #[test] -fn superscript_and_html_subscript_use_unicode_forms() { - let out = render("H2O, e = mc^2^, x^n+1^ and aij.\n"); +fn html_superscript_and_subscript_use_unicode_forms() { + let out = render("H2O, e = mc2, xn+1 and aij.\n"); assert!(out.contains("H₂O, e = mc², xⁿ⁺¹ and aᵢⱼ."), "{out}"); } #[test] -fn superscript_without_a_unicode_form_falls_back() { +fn html_superscript_without_a_unicode_form_falls_back() { // No superscript `q`; no subscript `b`: the whole run falls back. - let out = render("x^q^ and yab\n"); + let out = render("xq and yab\n"); assert!(out.contains("x^(q) and y_(ab)"), "{out}"); } +// Regression: the `superscript` and `spoiler` extensions paired carets and +// double bars in ordinary prose (`2^(10 and 3)5`, a hidden `b) or (c`). +// Neither syntax is GitHub markdown; both are off and prose is verbatim. +#[test] +fn carets_and_double_bars_in_prose_render_verbatim() { + for src in [ + "Compute 2^10 and 3^5 here. Also x^2 + y^2 = z^2.", + "if (a||b) or (c||d) then", + "Not syntax: ^x^ and ||x||.", + ] { + let out = render(&format!("{src}\n")); + assert!(out.contains(src), "{src:?} became:\n{out}"); + let lines = layout(&format!("{src}\n")); + for span in lines.iter().flat_map(|l| &l.spans) { + assert!( + span.style.fg.is_none() || span.style.fg != span.style.bg, + "concealed span {span:?}" + ); + } + } +} + #[test] fn single_and_double_tildes_strike_through_as_on_github() { for src in ["~~struck~~ and about ~5ms\n", "~struck~ and about ~5ms\n"] { @@ -176,18 +198,6 @@ fn double_underscore_stays_bold() { assert!(span.style.bold && !span.style.underline, "{span:?}"); } -#[test] -fn spoiler_text_is_concealed_between_visible_bars() { - let out = render("Vader is ||his father||.\n"); - assert!(out.contains("Vader is ||his father||."), "{out}"); - let lines = layout("Vader is ||his *father*||.\n"); - for word in ["his ", "father"] { - let span = find_span(&lines, word); - assert!(span.style.fg.is_some() && span.style.fg == span.style.bg); - } - assert!(find_span(&lines, "||").style.dim); -} - #[test] fn definition_list_term_bold_and_definitions_marked() { let out = render("Term\n: First definition\n: Second one\n\nOther\n: More\n"); @@ -251,10 +261,10 @@ fn bracketed_text_that_is_not_a_type_stays_a_quote() { } #[test] -fn table_cells_render_scripts_and_spoilers() { - let out = render("| a | b |\n|---|---|\n| x^2^ | ||s|| |\n"); +fn table_cells_keep_carets_verbatim() { + let out = render("| a | b |\n|---|---|\n| 2^10 | x^2^ |\n"); let rows = table_rows(&out); - assert_eq!(rows[1], "│ x² │ ||s|| │"); + assert_eq!(rows[1], "│ 2^10 │ x^2^ │"); } // ── Frontmatter ── diff --git a/tests/security.rs b/tests/security.rs index 1f14e12..96da4a1 100644 --- a/tests/security.rs +++ b/tests/security.rs @@ -261,9 +261,9 @@ fn diff_strips_escapes_from_document_lines() { assert!(!stdout.contains("\x1b[2J"), "CSI leaked: {stdout:?}"); } -/// Text carried by the markdown extensions (callout titles, spoilers, -/// super/subscripts, definition terms, aligned table cells) goes through the -/// same sanitizer as everything else. +/// Text carried by the markdown extensions (callout titles, definition +/// terms, aligned table cells) and by caret/bar runs shown verbatim goes +/// through the same sanitizer as everything else. #[test] fn extension_text_cannot_inject_escapes() { let source = "> [!NOTE] title \x1b]52;c;SGVsbG8=\x07 \x1b[2J\n> body\n\n\ From f005100a2677f12507883910ae1cb529c3894620 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:43:02 +0200 Subject: [PATCH 03/11] fix(frontmatter): never hide a leading JSON object Any document whose first line was { and that had a "key": line was stripped as JSON frontmatter, so a file that is a JSON object printed nothing. JSON is now recognised only with --frontmatter and only when markdown follows the object; then it gets the metadata box. Without the flag a leading JSON object is document text, as in v0.9.0. YAML/TOML are unchanged. --- README.md | 2 +- docs/llms-full.txt | 2 +- docs/llms.txt | 2 +- src/parser/frontmatter.rs | 78 ++++++++++++++++++++++++++----------- tests/rendering_features.rs | 28 ++++++++++++- 5 files changed, 85 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 8b93c13..ce488b9 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ Flowcharts (every node shape, link style and direction, with subgraphs drawn as ### Frontmatter -YAML (`---`), TOML (`+++`) and JSON (`{`) frontmatter is hidden by default. With `--frontmatter` (or `frontmatter = true`) it shows as a small key/value box at the top of the document — lists comma-joined, nested values as written — instead of being read as markdown. +YAML (`---`) and TOML (`+++`) frontmatter is hidden by default. With `--frontmatter` (or `frontmatter = true`) it shows as a small key/value box at the top of the document — lists comma-joined, nested values as written — instead of being read as markdown. A leading JSON object (`{` on the first line) is never hidden: with `--frontmatter` it gets the same box when markdown follows it, and otherwise it is shown as document text. ### Wikilinks diff --git a/docs/llms-full.txt b/docs/llms-full.txt index f03bf59..22131d3 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -21,7 +21,7 @@ ink is a feature-rich terminal markdown reader built in Rust. It renders markdow - Horizontal rules - Inline/block math ($...$, $$...$$, ```math) rendered as Unicode text: Greek letters, operators, \mathbb/\mathcal letters, super/subscripts, \frac as a/b, \sqrt, matrix/cases/aligned environments on several lines; unknown commands kept as written; ASCII mode shows the LaTeX source. A lone $ in prose stays a dollar sign. :emoji: shortcodes resolved to glyphs - HTML blocks shown as text; common inline HTML (
, , , ) handled rather than dropped, / as Unicode sub/superscripts -- YAML, TOML and JSON frontmatter: stripped by default; --frontmatter shows it as a key/value box +- YAML and TOML frontmatter: stripped by default; --frontmatter shows it as a key/value box. A leading JSON object is never stripped; with --frontmatter it is shown as the box only when markdown follows it ### Images - Real pixel rendering via terminal graphics protocols (Kitty graphics, iTerm2 inline, Sixel), auto-detected at startup; --image-protocol auto|kitty|iterm2|sixel|halfblocks diff --git a/docs/llms.txt b/docs/llms.txt index 241ee29..59b4ca3 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -10,7 +10,7 @@ ink is a command-line tool that renders markdown files in the terminal with rich - Displays inline images using terminal graphics protocols (Kitty, iTerm2, Sixel) where supported, falling back to Unicode half-block characters (any true-color terminal); --image-protocol selects the renderer - Renders mermaid diagrams (flowcharts, sequence diagrams, pie charts, Gantt charts) as ASCII art - Supports GitHub-style admonitions (NOTE, TIP, IMPORTANT, WARNING, CAUTION) with custom titles, and Obsidian callouts -- YAML, TOML and JSON frontmatter: hidden by default, shown as a key/value box with --frontmatter +- YAML and TOML frontmatter: hidden by default, shown as a key/value box with --frontmatter (JSON frontmatter is never hidden; with --frontmatter it gets the box when markdown follows it) - Supports wikilink syntax: [[page]] and [[page|display text]] - Renders inline/block math ($...$, $$...$$, ```math) as Unicode text (Greek, operators, super/subscripts, fractions, roots, matrices, cases) and resolves :emoji: shortcodes - Interactive TUI with multi-tab support, in-document search (n/N to cycle matches), keyboard link opening (press f), a help overlay (?), and a table of contents sidebar diff --git a/src/parser/frontmatter.rs b/src/parser/frontmatter.rs index ebcaad0..dd420d4 100644 --- a/src/parser/frontmatter.rs +++ b/src/parser/frontmatter.rs @@ -1,7 +1,10 @@ //! Frontmatter: a YAML (`---`), TOML (`+++`) or JSON (`{`) metadata block at //! the very start of a document. //! -//! By default it is stripped before parsing. With `--frontmatter` it is +//! By default YAML and TOML frontmatter is stripped before parsing; a +//! leading JSON object never is, since a document that opens with `{` (or +//! *is* a JSON object) is far more often content than metadata. With +//! `--frontmatter` YAML and TOML — and JSON followed by markdown — are //! replaced by a fenced block with the info string [`FENCE_INFO`], which the //! layout draws as a key/value box (see `layout::frontmatter`) instead of //! letting comrak read the metadata as markdown (a rule plus a setext @@ -50,24 +53,31 @@ pub struct Frontmatter<'s> { pub rest: &'s str, } -/// Find frontmatter at byte 0 of `source`. +/// Find YAML or TOML frontmatter at byte 0 of `source` (never JSON: see +/// [`split_shown`]). /// /// YAML and TOML are recognized only when the very first line is exactly the /// opening delimiter (`---` / `+++`; trailing whitespace ok), a later line is /// exactly a closing delimiter (`---` or `...` / `+++`), and at least one line -/// in between looks like a key (`key:` / `key =`). JSON is recognized when -/// the first line is exactly `{`, the object closes (braces counted outside -/// strings) at the end of a line, and a line inside starts with `"key":`. -/// Anything else — e.g. a document that merely opens with a thematic break, -/// or an unterminated block — is not frontmatter. +/// in between looks like a key (`key:` / `key =`). Anything else — e.g. a +/// document that merely opens with a thematic break, or an unterminated +/// block — is not frontmatter. pub fn split(source: &str) -> Option> { split_delimited(source, "---", &["---", "..."], ':', Format::Yaml) .or_else(|| split_delimited(source, "+++", &["+++"], '=', Format::Toml)) - .or_else(|| split_json(source)) } -/// Strip frontmatter from markdown source. -/// Returns (frontmatter, remaining_content). +/// Frontmatter to show with `--frontmatter`: [`split`], or a JSON object. +/// JSON is recognized when the first line is exactly `{`, the object closes +/// (braces counted outside strings) at the end of a line, a line inside +/// starts with `"key":`, and non-blank markdown follows it — a file that is +/// only a JSON object is a document, not metadata. +pub fn split_shown(source: &str) -> Option> { + split(source).or_else(|| split_json(source)) +} + +/// Strip YAML/TOML frontmatter from markdown source (JSON is never +/// stripped). Returns (frontmatter, remaining_content). pub fn strip_frontmatter(source: &str) -> (Option, String) { match split(source) { Some(fm) => (Some(fm.body.trim().to_string()), fm.rest.to_string()), @@ -75,16 +85,21 @@ pub fn strip_frontmatter(source: &str) -> (Option, String) { } } -/// The markdown to parse for `source`: frontmatter stripped, or — when -/// `show` — replaced by a fenced [`FENCE_INFO`] block the layout draws as a -/// metadata box. Without frontmatter, `source` unchanged. +/// The markdown to parse for `source`: YAML/TOML frontmatter stripped, or — +/// when `show` — frontmatter (JSON included, see [`split_shown`]) replaced +/// by a fenced [`FENCE_INFO`] block the layout draws as a metadata box. +/// Without frontmatter, `source` unchanged. Hiding removes whole lines at +/// the head only; a leading JSON object is left in place. pub fn prepare(source: &str, show: bool) -> String { - let Some(fm) = split(source) else { - return source.to_string(); - }; if !show { - return fm.rest.to_string(); + return match split(source) { + Some(fm) => fm.rest.to_string(), + None => source.to_string(), + }; } + let Some(fm) = split_shown(source) else { + return source.to_string(); + }; // A fence longer than any backtick run inside, so no line of the // metadata can close it early. let longest = fm.body.split(|c| c != '`').map(str::len).max().unwrap_or(0); @@ -176,10 +191,11 @@ fn split_json(source: &str) -> Option> { .find('"') .is_some_and(|q| l[1 + q + 1..].trim_start().starts_with(':')) }); - return has_key.then_some(Frontmatter { + let rest = &source[line_end..]; + return (has_key && !rest.trim().is_empty()).then_some(Frontmatter { format: Format::Json, body, - rest: &source[line_end..], + rest, }); } } @@ -659,7 +675,8 @@ mod tests { #[test] fn json_frontmatter() { let source = "{\n \"title\": \"Hi {there}\",\n \"tags\": [\"a\", \"b\"]\n}\n# Content\n"; - let fm = split(source).unwrap(); + assert!(split(source).is_none(), "JSON is never hidden"); + let fm = split_shown(source).unwrap(); assert_eq!(fm.format, Format::Json); assert_eq!(fm.rest, "# Content\n"); assert_eq!( @@ -680,9 +697,24 @@ mod tests { "{\n\"a\": \"open string\n}\n", // string runs off the line "{ \"a\": 1 }\n", // opener not alone on its line "{\n\"a\": [1, 2}\n]\n", // mismatched + "{\n\"a\": 1\n}\n", // nothing after: a JSON file + "{\n\"a\": 1\n}\n \n\n", // only blank lines after ] { - assert!(split(source).is_none(), "{source:?}"); + assert!(split_shown(source).is_none(), "{source:?}"); + } + } + + // Regression: a document that is a JSON object (or opens with a bare + // JSON sample) was stripped as frontmatter and printed nothing. + #[test] + fn json_is_never_stripped_by_default() { + for source in ["{\n \"a\": 1\n}\n", "{\n \"a\": 1\n}\n# Doc\n"] { + assert_eq!(prepare(source, false), source); + assert_eq!(strip_frontmatter(source), (None, source.to_string())); } + // Shown: only when markdown follows the object. + assert_eq!(prepare("{\n \"a\": 1\n}\n", true), "{\n \"a\": 1\n}\n"); + assert!(prepare("{\n \"a\": 1\n}\n# Doc\n", true).starts_with("```ink-frontmatter json\n")); } #[test] @@ -750,11 +782,11 @@ mod tests { } } let deep = format!( - "{{\n\"a\": {}{}\n}}\n", + "{{\n\"a\": {}{}\n}}\nBody\n", "[".repeat(10000), "]".repeat(10000) ); - let fm = split(&deep).unwrap(); + let fm = split_shown(&deep).unwrap(); let _ = entries(fm.format, fm.body); } } diff --git a/tests/rendering_features.rs b/tests/rendering_features.rs index 82fabaa..a51851f 100644 --- a/tests/rendering_features.rs +++ b/tests/rendering_features.rs @@ -278,7 +278,6 @@ fn frontmatter_hidden_by_default() { for src in [ "---\ntitle: Hello\nauthor: me\n---\n# Doc\n", "+++\ntitle = \"Hello\"\n+++\n# Doc\n", - "{\n \"title\": \"Hello\"\n}\n# Doc\n", ] { let out = render(src); assert!( @@ -328,6 +327,33 @@ fn json_frontmatter_renders_as_a_metadata_box() { assert!(!out.contains("\"title\""), "{out}"); } +// Regression: a file that is a JSON object rendered as nothing, its +// "frontmatter" stripped. A leading JSON object is document text unless +// `--frontmatter` is on and markdown follows it. +#[test] +fn json_documents_are_never_hidden() { + let json = "{\n \"name\": \"ink\",\n \"version\": 1\n}\n"; + for out in [render(json), with_frontmatter(json)] { + // Shown as markdown text, as in v0.9.0 (smart quotes, one paragraph). + assert!(out.contains("name") && out.contains("version"), "{out}"); + assert!(!out.contains("frontmatter"), "{out}"); + } + // A markdown file opening with a bare JSON sample keeps it by default. + let src = "{\n \"title\": \"Hello\"\n}\n# Doc\n"; + let out = render(src); + assert!( + out.contains("title") && out.contains("Hello") && out.contains("Doc"), + "{out}" + ); + assert!(!out.contains("frontmatter"), "{out}"); + // With --frontmatter and markdown after it, it is the metadata box. + let out = with_frontmatter(src); + assert!( + out.contains("╭─ frontmatter") && out.contains("│ title Hello │"), + "{out}" + ); +} + #[test] fn unterminated_frontmatter_falls_back_to_markdown() { // No closing delimiter: rendered as markdown, exactly as before. From 67c7fdd9ef5c5dd05b5d26449724b917c589fcab Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:45:37 +0200 Subject: [PATCH 04/11] fix(html): keep links and styles inside / close_script collapsed everything between the tags into one span with the first span's style and wrapped it in _(...)/^(...), so a caption lost its links. The Unicode script mapping now applies only to a single plain-text run whose every character has a script form (^(x)/_(x) in ASCII mode); anything else is left exactly as it was, with no wrapper. --- README.md | 2 +- docs/llms-full.txt | 4 +-- src/layout/html.rs | 65 +++++++++++++++++++++---------------- src/layout/mod.rs | 4 +-- tests/cli.rs | 1 + tests/fixtures/ascii.md | 2 ++ tests/rendering_features.rs | 43 ++++++++++++++++++++++-- 7 files changed, 85 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index ce488b9..0d468e0 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ ink --plain README.md Headings, bold, italic, strikethrough, links, blockquotes, lists, task lists, tables, footnotes, horizontal rules — all rendered with proper styling and colors. -Tables follow their column alignment (`:--`, `:-:`, `--:`), wide CJK and emoji cells included. HTML superscript `x2` and subscript `H2O` come out as `x²` and `H₂O` (as `^(…)`/`_(…)` when a character has no Unicode form). Carets and double bars in prose (`2^10`, `a||b`) are shown as written: `^x^` and `||x||` are not GitHub markdown. Single and double tildes, `~x~` and `~~x~~`, strike through as on GitHub, and definition lists (`Term` then `: definition`) get a bold term with its definitions indented underneath. +Tables follow their column alignment (`:--`, `:-:`, `--:`), wide CJK and emoji cells included. HTML superscript `x2` and subscript `H2O` come out as `x²` and `H₂O` (`x^(2)` and `H_(2)O` with `--ascii`); when a character has no Unicode form, or the tag holds links or other markup (a `` caption), its content is shown as written. Carets and double bars in prose (`2^10`, `a||b`) are shown as written: `^x^` and `||x||` are not GitHub markdown. Single and double tildes, `~x~` and `~~x~~`, strike through as on GitHub, and definition lists (`Term` then `: definition`) get a bold term with its definitions indented underneath. ### Syntax-highlighted code blocks diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 22131d3..a59494c 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -17,10 +17,10 @@ ink is a feature-rich terminal markdown reader built in Rust. It renders markdow - Ordered and unordered lists with nesting - Task lists with checkbox rendering - Footnotes -- Superscript x and subscript x (Unicode forms, ^(x)/_(x) fallback); ^x^ and ||x|| are not syntax (carets and double bars in prose are shown as written); ~x~ and ~~x~~ are strikethrough as on GitHub; definition lists (Term / : definition) +- Superscript x and subscript x as Unicode forms (^(x)/_(x) in ASCII mode) when the content is plain text that has them; otherwise (captions, links, markup) the content is shown as written; ^x^ and ||x|| are not syntax (carets and double bars in prose are shown as written); ~x~ and ~~x~~ are strikethrough as on GitHub; definition lists (Term / : definition) - Horizontal rules - Inline/block math ($...$, $$...$$, ```math) rendered as Unicode text: Greek letters, operators, \mathbb/\mathcal letters, super/subscripts, \frac as a/b, \sqrt, matrix/cases/aligned environments on several lines; unknown commands kept as written; ASCII mode shows the LaTeX source. A lone $ in prose stays a dollar sign. :emoji: shortcodes resolved to glyphs -- HTML blocks shown as text; common inline HTML (
, , , ) handled rather than dropped, / as Unicode sub/superscripts +- HTML blocks shown as text; common inline HTML (
, , , ) handled rather than dropped, / as Unicode sub/superscripts where possible - YAML and TOML frontmatter: stripped by default; --frontmatter shows it as a key/value box. A leading JSON object is never stripped; with --frontmatter it is shown as the box only when markdown follows it ### Images diff --git a/src/layout/html.rs b/src/layout/html.rs index df309be..cef6e8b 100644 --- a/src/layout/html.rs +++ b/src/layout/html.rs @@ -233,8 +233,9 @@ pub(super) fn img_src_alt(attrs: &[(String, String)]) -> Option<(String, String) pub(super) struct HtmlStyles { /// (tag name, style inside it, hides its content) stack: Vec<(String, SpanStyle, bool)>, - /// Open ``/`` tags and the span index their text starts at. - scripts: Vec<(String, usize)>, + /// Open ``/`` tags, the span index their text starts at and + /// the style text had at the opening tag. + scripts: Vec<(String, usize, SpanStyle)>, } impl HtmlStyles { @@ -314,42 +315,50 @@ impl HtmlStyles { self.stack.push((name.to_string(), style, hide)); } - /// Note where a ``/`` starts in `spans`. - fn open_script(&mut self, name: &str, spans: &[StyledSpan]) { + /// Note where a ``/`` starts in `spans`, and in what style. + fn open_script(&mut self, name: &str, spans: &[StyledSpan], base: &SpanStyle) { if matches!(name, "sub" | "sup") { - self.scripts.push((name.to_string(), spans.len())); + let style = self.style(base).clone(); + self.scripts.push((name.to_string(), spans.len(), style)); } } - /// At ``/``: the text since the opening tag becomes one - /// span of Unicode sub/superscript characters, or `_(…)` / `^(…)` when - /// a character has no such form (always in ASCII mode), like the - /// markdown `^sup^` extension. An unclosed tag leaves its text as is. + /// At ``/``: when the text since the opening tag is one + /// plain run (no link, no other markup, no break) and every character + /// has a Unicode sub/superscript form — `2`, `n+1`, `ij` — it becomes + /// those characters (`_(…)` / `^(…)` in ASCII mode). Anything else, such + /// as a `` caption with links in it, is left exactly as it is: text, + /// links and styles intact. An unclosed tag leaves its text as is. fn close_script(&mut self, name: &str, spans: &mut Vec) { - let Some(idx) = self.scripts.iter().rposition(|(n, _)| n == name) else { + let Some(idx) = self.scripts.iter().rposition(|(n, _, _)| n == name) else { return; }; - let start = self.scripts[idx].1; - self.scripts.truncate(idx); - if start >= spans.len() { + // Tags opened inside this one and never closed end with it. + self.scripts.truncate(idx + 1); + let Some((_, start, style)) = self.scripts.pop() else { + return; + }; + if start >= spans.len() || spans[start..].iter().any(|s| s.style != style) { return; } let text: String = spans[start..].iter().map(|s| s.text.as_str()).collect(); let text = text.trim(); - let style = spans[start].style.clone(); - spans.truncate(start); - if text.is_empty() { + let sup = name == "sup"; + let mapped = if sup { + super::scripts::to_superscript(text) + } else { + super::scripts::to_subscript(text) + }; + let Some(mapped) = mapped else { return; - } - let ascii = crate::glyphs::current().ascii; - spans.push(StyledSpan { - text: if name == "sup" { - super::scripts::superscript(text, ascii, false) - } else { - super::scripts::subscript(text, ascii, false) - }, - style, - }); + }; + let text = if crate::glyphs::current().ascii { + format!("{}({text})", if sup { '^' } else { '_' }) + } else { + mapped + }; + spans.truncate(start); + spans.push(StyledSpan { text, style }); } /// Close the innermost open tag of this name (and anything left open @@ -394,7 +403,7 @@ pub(super) fn inline_fragment( } } _ => { - styles.open_script(&name, spans); + styles.open_script(&name, spans, base); styles.open(&name, &attrs, base, ctx); } }, @@ -546,7 +555,7 @@ pub(super) fn layout_html_block( } } } - styles.open_script(&name, &block.para); + styles.open_script(&name, &block.para, &base); styles.open(&name, &attrs, &base, ctx); } Token::Close { name } => { diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 7743da1..a1e78ff 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -2678,9 +2678,9 @@ mod html_rendering { fn inline_sub_sup_and_unknown_tags_keep_their_text() { let lines = render_text_lines("H2O and x2 s\n", 60); assert_eq!(lines[0], "H₂O and x² s"); - // No Unicode form for every character: the fallback form. + // No Unicode form for every character: shown as written. let lines = render_text_lines("yab and xq\n", 60); - assert_eq!(lines[0], "y_(ab) and x^(q)"); + assert_eq!(lines[0], "yab and xq"); // In an HTML block too. let lines = render_text_lines("

H2O and xn+1

\n", 60); assert_eq!(lines[0], "H₂O and xⁿ⁺¹"); diff --git a/tests/cli.rs b/tests/cli.rs index 73a6962..feadc6b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -628,6 +628,7 @@ fn ascii_mode_extension_fallbacks() { "| (i) An Obsidian callout", "| Callout body.", "Water is H_(2)O, e = mc^(2), struck, struck and 2^10 (a||b).", + "Caption: small print here.", " Term\n > Its definition.", // Math: the cleaned LaTeX source, never Unicode. "Math x^2 + \\alpha costs $5 and $10.", diff --git a/tests/fixtures/ascii.md b/tests/fixtures/ascii.md index ce1b969..c4969e4 100644 --- a/tests/fixtures/ascii.md +++ b/tests/fixtures/ascii.md @@ -46,6 +46,8 @@ fn main() { Water is H2O, e = mc2, ~~struck~~, ~struck~ and 2^10 (a||b). +Caption: small print here. + Term : Its definition. diff --git a/tests/rendering_features.rs b/tests/rendering_features.rs index a51851f..4e49c68 100644 --- a/tests/rendering_features.rs +++ b/tests/rendering_features.rs @@ -151,10 +151,47 @@ fn html_superscript_and_subscript_use_unicode_forms() { } #[test] -fn html_superscript_without_a_unicode_form_falls_back() { - // No superscript `q`; no subscript `b`: the whole run falls back. +fn html_script_without_a_unicode_form_is_left_as_text() { + // No superscript `q`; no subscript `b`: the text is shown as written, + // with no `^(…)`/`_(…)` wrapper. let out = render("xq and yab\n"); - assert!(out.contains("x^(q) and y_(ab)"), "{out}"); + assert!(out.contains("xq and yab"), "{out}"); +} + +// Regression: ``/`` collapsed everything inside into one span +// with the first span's style, dropping links, and wrapped it in `_(…)`. +// GitHub READMEs use `` for small captions: those pass through. +#[test] +fn html_sub_caption_keeps_its_links() { + let src = "Photo by [Ann](https://a.example) and [Bob](https://b.example)\n"; + let out = render(src); + assert!(out.contains("Photo by Ann and Bob"), "{out}"); + assert!(!out.contains("_("), "{out}"); + let lines = layout(src); + assert_eq!( + find_span(&lines, "Ann").style.link_url.as_deref(), + Some("https://a.example") + ); + assert_eq!( + find_span(&lines, "Bob").style.link_url.as_deref(), + Some("https://b.example") + ); + assert_eq!(find_span(&lines, "Photo by").style.link_url, None); +} + +#[test] +fn html_script_with_markup_inside_is_left_as_is() { + let lines = layout("x**2** and yi *j*\n"); + assert!(find_span(&lines, "2").style.bold); + assert!(find_span(&lines, "j").style.italic); + let out = render("x**2** and yi *j*\n"); + assert!(out.contains("x2 and yi j"), "{out}"); +} + +#[test] +fn unclosed_html_script_leaves_its_text() { + let out = render("a2 b and cn\n"); + assert!(out.contains("a2 b and cn"), "{out}"); } // Regression: the `superscript` and `spoiler` extensions paired carets and From 3143b700f7b9965861a8d2af8325507a3f17241b Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:46:54 +0200 Subject: [PATCH 05/11] fix(frontmatter): only strip brackets from a real flow list Any value starting with [ and ending with ] was read as a flow list and lost its outer brackets: 'link: [text](url) and [more]' showed as 'text](url) and [more'. A value is a list now only when its opening [ is closed by the final ] (quotes respected); otherwise it is shown verbatim. --- src/parser/frontmatter.rs | 69 ++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/src/parser/frontmatter.rs b/src/parser/frontmatter.rs index dd420d4..8449520 100644 --- a/src/parser/frontmatter.rs +++ b/src/parser/frontmatter.rs @@ -245,24 +245,42 @@ fn unquote(v: &str) -> String { v.to_string() } -/// `[a, "b", c]` → `a, b, c` (one level; nested brackets kept raw). +/// `[a, "b", c]` → `a, b, c` (one level; nested brackets kept raw). Only +/// when the `[` that opens the value is closed by its very last `]`: +/// `[text](url) and [more]` or `[draft] notes [v2]` are plain scalars. fn flow_list(v: &str) -> Option { - let inner = v.trim().strip_prefix('[')?.strip_suffix(']')?; - let mut items = Vec::new(); - let (mut depth, mut start, mut quote) = (0usize, 0usize, None::); + let v = v.trim(); + let inner = v.strip_prefix('[')?.strip_suffix(']')?; + // Item boundaries (top-level commas) and the outer pair, in one scan. + // A quote only opens a string at the start of an item, as in YAML and + // TOML, so the apostrophe in `[don't, x]` is just a character. + let mut cuts = Vec::new(); + let (mut depth, mut quote, mut item_start) = (0usize, None::, true); for (i, c) in inner.char_indices() { match (quote, c) { (Some(q), c) if c == q => quote = None, (Some(_), _) => {} - (None, '"' | '\'') => quote = Some(c), + (None, '"' | '\'') if item_start => quote = Some(c), (None, '[' | '{') => depth += 1, - (None, ']' | '}') => depth = depth.saturating_sub(1), + // Closing the outer `[` before the end: not one list. + (None, ']' | '}') => depth = depth.checked_sub(1)?, (None, ',') if depth == 0 => { - items.push(unquote(&inner[start..i])); - start = i + 1; + cuts.push(i); + item_start = true; + continue; } _ => {} } + item_start = item_start && c.is_whitespace(); + } + if quote.is_some() || depth != 0 { + return None; + } + let mut items = Vec::new(); + let mut start = 0; + for i in cuts { + items.push(unquote(&inner[start..i])); + start = i + 1; } let last = unquote(&inner[start..]); if !last.is_empty() || !items.is_empty() { @@ -731,6 +749,41 @@ mod tests { assert_eq!(get("note"), ["plain"]); } + // Regression: any value starting with `[` and ending with `]` lost its + // outer brackets (`[text](url) and [more]` → `text](url) and [more`). + #[test] + fn only_one_outer_bracket_pair_is_a_flow_list() { + for (value, shown) in [ + ("[a, b]", "a, b"), + ("[\"a, b\", c]", "a, b, c"), + ("[[1,2],[3]]", "[1,2], [3]"), + ("[]", ""), + ("[text](url) and [more]", "[text](url) and [more]"), + ("[draft] notes [v2]", "[draft] notes [v2]"), + ("[a] [b]", "[a] [b]"), + ("[\"]\", x]", "], x"), + ("[\"open, x]", "[\"open, x]"), + ] { + let body = format!("k: {value}\n"); + assert_eq!( + entries(Format::Yaml, &body), + [("k".to_string(), vec![shown.to_string()])], + "yaml {value:?}" + ); + let body = format!("k = {value}\n"); + assert_eq!( + entries(Format::Toml, &body), + [("k".to_string(), vec![shown.to_string()])], + "toml {value:?}" + ); + } + // YAML: a quote mid-word is a character, not a string. + assert_eq!( + entries(Format::Yaml, "k: [don't, x]\n"), + [("k".to_string(), vec!["don't, x".to_string()])] + ); + } + #[test] fn toml_entries_sections_and_arrays() { let body = "title = \"Hello\"\ntags = [\"a\", \"b\"]\nlist = [\n 1,\n 2,\n]\n[params]\ndraft = false\n"; From 090af0a7f8019941f28d10397040b1f7bd13f75c Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:49:55 +0200 Subject: [PATCH 06/11] fix(line-range): repair fences inside blockquotes and list items Fence detection only looked at indent 0-3 of the raw line, so a block in a blockquote (> ```py) or an indented list item was neither reopened nor had its orphan closer dropped: --line-range 7 printed the code as quote prose, 7: opened an empty code box swallowing the rest. Blockquote markers and list indentation are now stripped when detecting fences, the prefix is remembered, and reopened/closing fences are emitted with it. A block reopened inside a list item is drawn at the top level, well-formed. --- README.md | 2 +- src/parser/line_range.rs | 210 +++++++++++++++++++++++++++++++++------ 2 files changed, 183 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 0d468e0..844df73 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ ink --slides deck.md Point `ink --plain` at a long document on an interactive terminal and it pages the output through `$PAGER` (default `less -R`) — a drop-in markdown replacement for `cat`/`less`. Piped or redirected output prints straight through, so it stays friendly for scripts, fzf previews, and git. Use `--no-pager` to always print directly. When stdout is not a terminal, `ink file.md` behaves like `ink --plain file.md`, as `bat` and `glow` do. -`--line-range` works like `bat`'s: `ink --line-range 40:80 README.md` renders only those lines of the markdown *source* (1-based, inclusive; also `40:`, `:80`, a single line, or several `--line-range`s). A range that cuts through a fenced code block keeps the block intact and highlighted. It always prints plain output. +`--line-range` works like `bat`'s: `ink --line-range 40:80 README.md` renders only those lines of the markdown *source* (1-based, inclusive; also `40:`, `:80`, a single line, or several `--line-range`s). A range that cuts through a fenced code block keeps the block intact and highlighted, inside a blockquote too. It always prints plain output. Color follows `--color ` (default `auto`). In `auto` mode, `--plain` and `ink diff` emit color and OSC 8 hyperlinks only when stdout is a terminal, so redirects, pipes, and `git` textconv get clean text. Precedence: an explicit `--color` wins; then `NO_COLOR` turns color off; then `CLICOLOR_FORCE=1` or `FORCE_COLOR` turns it on; then `TERM=dumb` turns it off; then the terminal check. Tools that display ANSI from a pipe need `--color=always`: diff --git a/src/parser/line_range.rs b/src/parser/line_range.rs index 0fc2a6f..bfb1509 100644 --- a/src/parser/line_range.rs +++ b/src/parser/line_range.rs @@ -4,9 +4,13 @@ //! Ranges are 1-based and inclusive, and may overlap; the kept lines stay in //! document order. A run of kept lines that starts or ends inside a fenced //! code block gets the fence reopened (with the original info string, so it -//! is still highlighted) or closed, keeping the block well-formed. Disjoint -//! runs are separated by a blank line so they do not merge into one -//! paragraph. +//! is still highlighted) or closed, keeping the block well-formed; a closing +//! fence whose opener was cut off is dropped. Fences inside blockquotes +//! (`> ```py`) and list items (indented, or on the marker line) are tracked +//! too, and repaired with the same `>` prefix so the block stays in its +//! quote. A block reopened inside a list item loses the list (its marker +//! line is not in the range) and is drawn at the top level. Disjoint runs +//! are separated by a blank line so they do not merge into one paragraph. /// An inclusive, 1-based line range; `None` bounds are open. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -57,9 +61,13 @@ impl LineRange { } } -/// An open fenced code block: its indentation, fence and info string. +/// An open fenced code block: the blockquote markers in front of it (as +/// written), the column of its fence after them, the fence and its info +/// string. #[derive(Clone)] struct Fence { + quote: String, + depth: usize, indent: usize, ch: u8, len: usize, @@ -69,35 +77,108 @@ struct Fence { impl Fence { fn opener(&self) -> String { let fence = (self.ch as char).to_string().repeat(self.len); - format!("{}{fence}{}\n", " ".repeat(self.indent), self.info) + format!( + "{}{}{fence}{}\n", + self.quote, + " ".repeat(self.indent), + self.info + ) } fn closer(&self) -> String { let fence = (self.ch as char).to_string().repeat(self.len); - format!("{}{fence}\n", " ".repeat(self.indent)) + format!("{}{}{fence}\n", self.quote, " ".repeat(self.indent)) } -} -/// A fence line: up to three spaces, then three or more backticks or tildes. -fn fence_marker(line: &str) -> Option<(usize, u8, usize, &str)> { - let indent = line.bytes().take_while(|&b| b == b' ').count(); - if indent > 3 { - return None; + /// This fence as reopened mid-block: its list context is gone, so at + /// most three spaces of indent (four would make an indented code block). + fn reopened(&self) -> Fence { + Fence { + indent: self.indent.min(3), + ..self.clone() + } } - let rest = &line[indent..]; - let ch = *rest.as_bytes().first()?; - if ch != b'`' && ch != b'~' { - return None; +} + +/// The blockquote markers at the start of `line` (each up to three spaces, +/// `>`, an optional space): their byte length and how many there are. +fn quote_prefix(line: &str) -> (usize, usize) { + let b = line.as_bytes(); + let (mut end, mut depth) = (0usize, 0usize); + loop { + let mut j = end; + while j < b.len() && j - end < 3 && b[j] == b' ' { + j += 1; + } + if b.get(j) != Some(&b'>') { + return (end, depth); + } + j += 1; + if b.get(j) == Some(&b' ') { + j += 1; + } + end = j; + depth += 1; } - let len = rest.bytes().take_while(|&b| b == ch).count(); - if len < 3 { - return None; +} + +/// The width of a list marker (`-`, `*`, `+`, `1.`, `1)`) and the spaces +/// after it at the start of `s`, or 0. +fn list_marker(s: &str) -> usize { + let b = s.as_bytes(); + let digits = b.iter().take_while(|c| c.is_ascii_digit()).count(); + let marker = match b.first() { + Some(b'-' | b'*' | b'+') => 1, + _ if (1..=9).contains(&digits) && matches!(b.get(digits), Some(b'.' | b')')) => digits + 1, + _ => return 0, + }; + let spaces = b[marker..].iter().take_while(|&&c| c == b' ').count(); + if spaces == 0 { + 0 + } else { + marker + spaces } - let info = rest[len..].trim_end_matches(['\n', '\r']); - if ch == b'`' && info.contains('`') { - return None; +} + +/// A line read for fences: its blockquote markers and, when it is a fence +/// (three or more backticks or tildes after the markers, any indentation +/// and an optional list marker), the fence. +struct FenceLine<'s> { + quote: &'s str, + depth: usize, + fence: Option<(usize, u8, usize, &'s str)>, +} + +fn fence_line(line: &str) -> FenceLine<'_> { + let (end, depth) = quote_prefix(line); + let rest = &line[end..]; + let spaces = rest.bytes().take_while(|&b| b == b' ').count(); + let col = spaces + list_marker(&rest[spaces..]); + let fence = (|| { + let f = &rest[col..]; + let ch = *f.as_bytes().first()?; + if ch != b'`' && ch != b'~' { + return None; + } + let len = f.bytes().take_while(|&b| b == ch).count(); + if len < 3 { + return None; + } + let info = f[len..].trim_end_matches(['\n', '\r']); + if ch == b'`' && info.contains('`') { + return None; + } + Some((col, ch, len, info)) + })(); + FenceLine { + quote: &line[..end], + depth, + fence, } - Some((indent, ch, len, info)) +} + +fn kept_at(ranges: &[LineRange], n: usize) -> bool { + ranges.iter().any(|r| r.contains(n)) } /// The lines of `source` that fall in any of `ranges`, fences repaired. @@ -117,10 +198,21 @@ pub fn select(source: &str, ranges: &[LineRange]) -> String { for (i, line) in source.split_inclusive('\n').enumerate() { let n = i + 1; // Classify this line against the document's own fences. - let marker = fence_marker(line); - let (inside_before, closes, opens) = match (&open, marker) { + let fl = fence_line(line); + // A line with fewer `>` than the open block's ends the blockquote, + // and the code block with it (no closing fence needed). + if open.as_ref().is_some_and(|f| fl.depth < f.depth) { + open = None; + if kept_at(ranges, n) && prev_kept { + // The output's copy ends the same way. + out_open = None; + } + } + let (inside_before, closes, opens) = match (&open, fl.fence) { + // A closer is at the opener's quote depth (a deeper `> ```` is + // content of the block). (Some(f), Some((_, ch, len, info))) - if ch == f.ch && len >= f.len && info.trim().is_empty() => + if fl.depth == f.depth && ch == f.ch && len >= f.len && info.trim().is_empty() => { (true, true, None) } @@ -129,6 +221,8 @@ pub fn select(source: &str, ranges: &[LineRange]) -> String { false, false, Some(Fence { + quote: fl.quote.to_string(), + depth: fl.depth, indent, ch, len, @@ -138,7 +232,7 @@ pub fn select(source: &str, ranges: &[LineRange]) -> String { (None, None) => (false, false, None), }; - let kept = ranges.iter().any(|r| r.contains(n)); + let kept = kept_at(ranges, n); if kept { if !prev_kept { if any_kept { @@ -152,8 +246,9 @@ pub fn select(source: &str, ranges: &[LineRange]) -> String { // Starting inside a block body: reopen it. if inside_before && !closes { if let Some(f) = &open { + let f = f.reopened(); out.push_str(&f.opener()); - out_open = Some(f.clone()); + out_open = Some(f); } } } @@ -270,6 +365,65 @@ mod tests { assert_eq!(select(doc, &[r("6:")]), "after\n"); } + // Regression: only fences at indent 0-3 of the raw line were seen, so a + // block in a blockquote was neither reopened (`7` printed the code as + // quote prose) nor had its orphan closer dropped (`7:` opened an empty + // code box that swallowed the rest). + #[test] + fn fences_inside_blockquotes_are_repaired_in_the_quote() { + let doc = "# T\n\nText\n\n> Quote\n> ```py\n> x = 1\n> y = 2\n> ```\n\nafter\n"; + assert_eq!(select(doc, &[r("7")]), "> ```py\n> x = 1\n> ```\n"); + assert_eq!( + select(doc, &[r("8:")]), + "> ```py\n> y = 2\n> ```\n\nafter\n" + ); + // Starting on the closer: dropped. + assert_eq!(select(doc, &[r("9:")]), "\nafter\n"); + // Starting at the opener, ending inside: closed in the quote. + assert_eq!(select(doc, &[r("6:7")]), "> ```py\n> x = 1\n> ```\n"); + // A deeper `> ```` inside a top-level block is content, not a closer. + let doc = "```\n> ```\nz\n```\n"; + assert_eq!(select(doc, &[r("3")]), "```\nz\n```\n"); + } + + #[test] + fn nested_blockquote_fences_keep_every_marker() { + let doc = "> > ~~~ sh\n> > ls\n> > pwd\n> > ~~~\n> > after\n"; + assert_eq!(select(doc, &[r("3")]), "> > ~~~ sh\n> > pwd\n> > ~~~\n"); + assert_eq!(select(doc, &[r("4:")]), "> > after\n"); + // A line without the markers ends the quote and the block with it. + let doc = "> ```\n> a\nplain\n"; + assert_eq!(select(doc, &[r("2:")]), "> ```\n> a\nplain\n"); + } + + #[test] + fn fences_inside_list_items_are_tracked() { + let doc = "- item\n\n ```rust\n let a = 1;\n let b = 2;\n ```\n- next\n"; + // Reopened without the list: a well-formed top-level block (at most + // three spaces of indent, never an indented code block). + assert_eq!( + select(doc, &[r("5")]), + " ```rust\n let b = 2;\n ```\n" + ); + assert_eq!(select(doc, &[r("6:")]), "- next\n"); + // Kept from the item's start: closed at the fence's own indent. + assert_eq!( + select(doc, &[r("1:4")]), + "- item\n\n ```rust\n let a = 1;\n ```\n" + ); + // The fence on the marker line itself. + let doc = "1. ```sh\n ls\n ```\nafter\n"; + assert_eq!(select(doc, &[r("2")]), " ```sh\n ls\n ```\n"); + assert_eq!(select(doc, &[r("3:")]), "after\n"); + } + + #[test] + fn a_closer_longer_than_its_opener_closes_it() { + let doc = "```\na\n`````\nb\n"; + assert_eq!(select(doc, &[r("2")]), "```\na\n```\n"); + assert_eq!(select(doc, &[r("3:")]), "b\n"); + } + #[test] fn tilde_and_long_fences_are_tracked() { let doc = "~~~~ py\n```\nx\n~~~~\ny\n"; From c18df5f297682492c005be7baa41afbfda4369d6 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:52:38 +0200 Subject: [PATCH 07/11] fix(toc): give focus back to the document when the sidebar is not drawn Focusing the TOC and then shrinking the window to 40 columns or fewer hid the sidebar but left it focused: keys moved an invisible cursor and the status bar kept the TOC hints. One sidebar_shown() rule now decides the draw, the layout width and the focus action, and every frame drops focus (and any filter) when the sidebar is not shown. --- src/app.rs | 31 +++++++++++++++++---- tests/pty.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 98 insertions(+), 10 deletions(-) diff --git a/src/app.rs b/src/app.rs index 523784f..56a6450 100644 --- a/src/app.rs +++ b/src/app.rs @@ -630,6 +630,18 @@ fn run_inner( } } + // The sidebar is not drawn (the window shrank, or it was hidden): + // a focused TOC would take keys for an invisible cursor and keep its + // key reminder in the status bar. Give focus back to the document. + { + let width = terminal.size()?.width; + let toc = &mut tabs[active_tab].toc; + if toc.nav.focused && !sidebar_shown(toc.visible, width) { + toc.leave(); + dirty = true; + } + } + // A copy message is worth two seconds of the status bar, no longer. if let Some((_, at)) = flash { if at.elapsed() >= Duration::from_secs(2) { @@ -725,7 +737,7 @@ fn run_inner( tab_info, ); - let (toc_area, doc_area) = if tab.toc.visible && main_area.width > 40 { + let (toc_area, doc_area) = if sidebar_shown(tab.toc.visible, main_area.width) { let horizontal = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Length(tab.toc.width), Constraint::Min(1)]) @@ -1226,7 +1238,7 @@ fn run_inner( let tab = &mut tabs[active_tab]; if tab.toc.headings.is_empty() { flash = Some(("no headings in this document".into(), Instant::now())); - } else if width <= 40 { + } else if !sidebar_shown(true, width) { flash = Some(( "window too narrow for the table of contents".into(), Instant::now(), @@ -2191,22 +2203,29 @@ fn restore_nav_entry( .min(tab.ratatui_lines.len().saturating_sub(1)); } -/// Rebuild one tab for the current width/theme, preserving scroll and TOC -/// visibility. Only the tab the user is looking at is rebuilt eagerly; the -/// rest are refreshed lazily the next time they're switched to. +/// Whether the TOC sidebar is drawn at this terminal width: it must be +/// open and the window wider than 40 columns. The one rule for the draw, +/// the layout width, focusing it, and dropping focus on a resize. +fn sidebar_shown(toc_visible: bool, width: u16) -> bool { + toc_visible && width > 40 +} + /// The width the document is actually laid out in: the TOC pane (when open /// on a wide-enough terminal, mirroring the draw-time gate) takes its columns /// out of the budget. Toggling the TOC previously kept the full-width layout /// and truncated every line at draw time. fn effective_width(full: u16, toc_visible: bool) -> u16 { const TOC_PANE: u16 = 30; - if toc_visible && full > 40 { + if sidebar_shown(toc_visible, full) { full.saturating_sub(TOC_PANE) } else { full } } +/// Rebuild one tab for the current width/theme, preserving scroll and TOC +/// visibility. Only the tab the user is looking at is rebuilt eagerly; the +/// rest are refreshed lazily the next time they're switched to. fn rebuild_tab( tab: &mut Tab, args: &Args, diff --git a/tests/pty.rs b/tests/pty.rs index 2cc62a1..256de13 100644 --- a/tests/pty.rs +++ b/tests/pty.rs @@ -15,10 +15,25 @@ struct Session { output: Vec, } +/// What the scripted terminal does next: type bytes, or change its size +/// (the kernel then sends ink SIGWINCH). +enum Reply { + Type(Vec), + Resize(u16, u16), +} + fn run_in_pty( args: &[&str], envs: &[(&str, &str)], mut respond: impl FnMut(&[u8], Duration) -> Option>, +) -> Session { + run_in_pty_ctl(args, envs, move |out, t| respond(out, t).map(Reply::Type)) +} + +fn run_in_pty_ctl( + args: &[&str], + envs: &[(&str, &str)], + mut respond: impl FnMut(&[u8], Duration) -> Option, ) -> Session { let (mut master, slave) = open_pty(); let config_home = tempfile::tempdir().unwrap(); @@ -63,8 +78,20 @@ fn run_in_pty( let mut output = Vec::new(); let mut buf = [0u8; 8192]; while start.elapsed() < Duration::from_secs(15) { - if let Some(reply) = respond(&output, start.elapsed()) { - master.write_all(&reply).unwrap(); + match respond(&output, start.elapsed()) { + Some(Reply::Type(bytes)) => master.write_all(&bytes).unwrap(), + Some(Reply::Resize(cols, rows)) => { + let size = libc::winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + // SAFETY: a valid fd and a pointer to a live winsize. + let rc = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &size) }; + assert_eq!(rc, 0, "TIOCSWINSZ failed"); + } + None => {} } let mut pfd = libc::pollfd { fd, @@ -467,6 +494,17 @@ type Marks = std::rc::Rc>>; /// repeated until it takes). `marks` records how much output there was when /// each step went out, so a test can tell what was drawn before and after. fn type_steps(steps: Vec>, marks: Marks) -> impl FnMut(&[u8], Duration) -> Option> { + let steps = steps.into_iter().map(Reply::Type).collect(); + let mut ctl = control_steps(steps, marks); + move |out, t| match ctl(out, t)? { + Reply::Type(bytes) => Some(bytes), + Reply::Resize(..) => unreachable!(), + } +} + +/// [`type_steps`] with resizes among the steps. +fn control_steps(steps: Vec, marks: Marks) -> impl FnMut(&[u8], Duration) -> Option { + let mut steps = steps.into_iter().map(Some).collect::>(); let mut up_at = None; let mut sent = 0; let mut last_q = Duration::ZERO; @@ -481,11 +519,11 @@ fn type_steps(steps: Vec>, marks: Marks) -> impl FnMut(&[u8], Duration) if sent < steps.len() { sent += 1; marks.borrow_mut().push(out.len()); - return Some(steps[sent - 1].clone()); + return steps[sent - 1].take(); } if t > last_q + Duration::from_millis(500) { last_q = t; - return Some(b"q".to_vec()); + return Some(Reply::Type(b"q".to_vec())); } None } @@ -545,6 +583,37 @@ fn the_toc_takes_focus_filters_and_jumps() { assert!(contains(&s.output, b"\x1b[?1049l"), "reader did not exit"); } +// Regression: focusing the TOC and then shrinking the window to 40 columns +// or fewer hid the sidebar but left it focused, so keys drove an invisible +// cursor and the status bar kept the TOC's key reminder. +#[test] +fn shrinking_the_window_gives_focus_back_to_the_document() { + let doc = long_doc_with_anchor_link(); + let marks = Marks::default(); + let steps = vec![ + Reply::Type(b"o".to_vec()), + Reply::Resize(38, 30), + Reply::Type(b"j".to_vec()), + ]; + let args = reader_args(doc.path()); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let s = run_in_pty_ctl(&args, &[], control_steps(steps, Marks::clone(&marks))); + let marks = marks.borrow(); + assert_eq!(marks.len(), 3, "steps not all sent"); + let focused = String::from_utf8_lossy(&s.output[marks[0]..marks[1]]); + assert!(focused.contains("j/k"), "TOC never took focus"); + // After the resize the sidebar is gone and so is its key reminder. + let resized = String::from_utf8_lossy(&s.output[marks[1]..marks[2]]); + assert!(resized.contains("filler"), "no redraw after the resize"); + assert!(!resized.contains("j/k"), "TOC hints survived: {resized}"); + assert!(resized.contains("search"), "no document key reminder"); + // `j` scrolls the document (its lines are redrawn one row up) instead + // of moving a hidden TOC cursor (which redraws nothing). + let after = String::from_utf8_lossy(&s.output[marks[2]..]); + assert!(after.contains("filler"), "j did not scroll the document"); + assert!(contains(&s.output, b"\x1b[?1049l"), "reader did not exit"); +} + #[test] fn the_focused_toc_writes_only_7_bit_bytes_in_ascii_mode() { let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/ascii.md"); From 004c99630618434049bb6d904db473b83a84e857 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:54:03 +0200 Subject: [PATCH 08/11] fix(mermaid): leave [*] pseudo-states out of undrawable-frame notes When composite-state or subgraph frames could not be drawn, the fallback note listed every member, including the unlabelled [*] start/end states: "Running: , A, B, ". Members with an empty label are now skipped. --- src/layout/mermaid/engine.rs | 5 ++++- src/layout/mermaid/tests.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/layout/mermaid/engine.rs b/src/layout/mermaid/engine.rs index 9f1b864..188cdbd 100644 --- a/src/layout/mermaid/engine.rs +++ b/src/layout/mermaid/engine.rs @@ -838,9 +838,12 @@ pub fn layout(g: &Graph, p: &Params, avail: usize) -> Result { let mut notes = Vec::new(); if !frames_ok { for (c, cl) in g.clusters.iter().enumerate() { + // Unlabelled members (the `[*]` start/end pseudo-states) are + // left out: they would read as empty items (`Running: , A`). let members: Vec = (0..n) .filter(|&v| node_path[v].contains(&c)) - .map(|v| g.nodes[v].label.replace('\n', " ")) + .map(|v| g.nodes[v].label.replace('\n', " ").trim().to_string()) + .filter(|label| !label.is_empty()) .collect(); if !members.is_empty() { let title = if cl.title.is_empty() { diff --git a/src/layout/mermaid/tests.rs b/src/layout/mermaid/tests.rs index ec51dc3..031e687 100644 --- a/src/layout/mermaid/tests.rs +++ b/src/layout/mermaid/tests.rs @@ -307,6 +307,24 @@ fn state_diagram_start_end_fork_and_back_edge() { ); } +// Regression: when composite-state frames cannot be drawn, the note listing +// each state's members included the unlabelled `[*]` pseudo-states, giving +// `Running: , A, B, `. +#[test] +fn undrawable_state_frames_list_only_named_members() { + let src = "stateDiagram-v2\n[*] --> Idle\nIdle --> Running\nstate Running {\n[*] --> A\nA --> B\nB --> [*]\n}\nstate Paused {\n[*] --> C\nC --> D\n}\nRunning --> Paused\nPaused --> Running\nRunning --> Idle\nRunning --> [*]"; + for width in [40, 60, 100] { + let out = render(src, width); + let flat = out.split_whitespace().collect::>().join(" "); + assert!(flat.contains("Running: A, B "), "{width}:\n{out}"); + assert!(flat.contains("Paused: C, D "), "{width}:\n{out}"); + assert!( + !flat.contains(": ,") && !flat.contains(", ,") && !flat.contains(", │"), + "{width}:\n{out}" + ); + } +} + // ── Class and ER diagrams ── #[test] From 8e388715bcb96f8583e10d5b90ad2b353485eee4 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:56:38 +0200 Subject: [PATCH 09/11] fix(editor): find .cmd/.bat editor shims on PATH on Windows std's Command only appends .exe on Windows, so EDITOR="code --wait" (VS Code, Cursor and VSCodium install code.cmd and the like) failed with "program not found". A bare editor name is now resolved against PATH and PATHEXT (default .COM;.EXE;.BAT;.CMD) by a pure, unit-tested function and the full path is spawned; explicit paths and names with an extension are left alone. std quotes .bat/.cmd arguments itself and refuses ones it cannot escape; that error is shown in the status bar. The Windows behaviour itself is not verified on Windows. --- docs/llms-full.txt | 2 +- src/app.rs | 16 ++++++- src/editor.rs | 107 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/docs/llms-full.txt b/docs/llms-full.txt index a59494c..a04a949 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -57,7 +57,7 @@ ink is a feature-rich terminal markdown reader built in Rust. It renders markdow - Table of contents sidebar showing heading structure, tracks scroll position - Interactive TOC: o focuses it (opening it if closed); j/k/arrows move, g/G first/last, Ctrl+d/Ctrl+u page, Enter jumps (recorded in back/forward history), h/l fold subheadings, / filters headings as you type, Esc returns; click a heading to jump, the wheel over the sidebar scrolls it - Navigation history with back/forward ([ ] keys or Alt+Arrow) -- e opens the current local file in $VISUAL / $EDITOR (fallback vi; notepad on Windows) at the line of the heading being read (+N for vi/vim/nvim/nano/emacs/kak/micro, --goto file:N for VS Code/Codium/Cursor, file:N for subl/zed/hx), then reloads it and keeps the position; stdin and URL documents cannot be edited +- e opens the current local file in $VISUAL / $EDITOR (fallback vi; notepad on Windows) at the line of the heading being read (+N for vi/vim/nvim/nano/emacs/kak/micro, --goto file:N for VS Code/Codium/Cursor, file:N for subl/zed/hx), then reloads it and keeps the position; on Windows a bare editor name is looked up on PATH with PATHEXT, so .cmd/.bat shims such as code.cmd work; stdin and URL documents cannot be edited - Mouse wheel scrolling, click a link to follow it (same path as f: browser for web/mail, in-app for .md and #anchor), plus drag-to-select (can be disabled via behavior.mouse_capture so the terminal handles clicks/selection instead) - Event-driven redraw (no CPU when idle) and virtualized rendering for large documents - Responsive to terminal resizing; shows a message instead of breaking on very small terminals diff --git a/src/app.rs b/src/app.rs index 56a6450..222d76a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1740,7 +1740,21 @@ fn run_suspended( let _ = restore_terminal(); #[cfg(unix)] term_signal::suspend(); - let status = std::process::Command::new(&argv[0]) + // Windows: std only tries `.exe`, so find `code.cmd` and the like + // on PATH ourselves (see `editor::resolve_program`). + #[cfg(windows)] + let program: std::ffi::OsString = crate::editor::resolve_program( + &argv[0], + std::env::var("PATH").ok().as_deref(), + std::env::var("PATHEXT").ok().as_deref(), + |p| p.is_file(), + ) + .map_or_else(|| argv[0].clone().into(), Into::into); + #[cfg(not(windows))] + let program = &argv[0]; + // A `.bat`/`.cmd` argument std cannot quote safely is an error here + // (shown in the status bar), never handed to cmd.exe unescaped. + let status = std::process::Command::new(program) .args(&argv[1..]) .status(); #[cfg(unix)] diff --git a/src/editor.rs b/src/editor.rs index 6d671d2..b0ec95a 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -99,7 +99,10 @@ fn line_arg_for(program: &str) -> LineArg { .and_then(|n| n.to_str()) .unwrap_or(program) .to_ascii_lowercase(); - let name = name.strip_suffix(".exe").unwrap_or(&name); + let name = [".exe", ".cmd", ".bat"] + .iter() + .find_map(|ext| name.strip_suffix(ext)) + .unwrap_or(&name); match name { "vi" | "vim" | "nvim" | "gvim" | "view" | "nano" | "emacs" | "emacsclient" | "kak" | "micro" => LineArg::Plus, @@ -143,10 +146,112 @@ pub fn editor_argv(command: &str, path: &str, line: Option) -> Option, + pathext_var: Option<&str>, + exists: impl Fn(&std::path::Path) -> bool, +) -> Option { + if program.is_empty() + || program.contains(['/', '\\', ':']) + || std::path::Path::new(program).extension().is_some() + { + return None; + } + let exts: Vec<&str> = pathext_var + .filter(|v| !v.trim().is_empty()) + .unwrap_or(".COM;.EXE;.BAT;.CMD") + .split(';') + .map(str::trim) + .filter(|e| e.starts_with('.') && e.len() > 1) + .collect(); + for dir in path_var?.split(';') { + let dir = dir.trim().trim_matches('"'); + if dir.is_empty() { + continue; + } + for ext in &exts { + let candidate = std::path::Path::new(dir).join(format!("{program}{ext}")); + if exists(&candidate) { + return Some(candidate); + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn windows_resolution_finds_cmd_shims_and_prefers_exe() { + use std::path::{Path, PathBuf}; + let at = |dir: &str, name: &str| Path::new(dir).join(name); + // Exact matches: Windows file lookups ignore case, this stub does not. + let files = |list: Vec| move |p: &Path| list.iter().any(|f| f == p); + let (win, code, tools) = (r"C:\Windows", r"C:\Code\bin", r"C:\Tools"); + let path = Some(r"C:\Windows;C:\Code\bin;C:\Tools"); + // Only a `.cmd` shim: found (std alone would look for code.exe). + assert_eq!( + resolve_program("code", path, None, files(vec![at(code, "code.CMD")])), + Some(at(code, "code.CMD")) + ); + // `.exe` and `.cmd` side by side: `.exe` (PATHEXT order). + assert_eq!( + resolve_program( + "code", + path, + None, + files(vec![at(code, "code.CMD"), at(code, "code.EXE")]) + ), + Some(at(code, "code.EXE")) + ); + // PATH order first, as cmd.exe does; PATHEXT from the environment. + assert_eq!( + resolve_program( + "subl", + path, + Some(".EXE;.CMD"), + files(vec![at(win, "subl.CMD"), at(tools, "subl.EXE")]) + ), + Some(at(win, "subl.CMD")) + ); + let any = |_: &Path| true; + // Explicit paths and names with an extension are left alone. + for program in [ + r"C:\Code\bin\code", + r".\code", + "bin/code", + "code.cmd", + "notepad.exe", + "", + ] { + assert_eq!(resolve_program(program, path, None, any), None, "{program}"); + } + // No match, or no PATH: spawned as given. + assert_eq!(resolve_program("code", path, None, |_: &Path| false), None); + assert_eq!(resolve_program("code", None, None, any), None); + } + + #[test] + fn shim_extensions_keep_the_line_syntax() { + assert_eq!( + editor_argv("code.cmd --wait", "doc.md", Some(7)).unwrap(), + v(&["code.cmd", "--wait", "--goto", "doc.md:7"]) + ); + } + fn v(words: &[&str]) -> Vec { words.iter().map(|w| w.to_string()).collect() } From b565af17711203016aa0be242d695025b66b7a51 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 18:59:14 +0200 Subject: [PATCH 10/11] refactor(app): one reload_tab for re-layout, --watch and edit reloads rebuild_tab, the --watch branch and reload_after_edit each rebuilt a tab and each had to remember the sidebar's visibility, its interaction state and the current-heading selection. They now share reload_tab with an explicit Anchor (KeepOffset for re-layout and --watch, Heading after e). The accidental differences are unified: rebuild_tab clamped the offset to len - 1, now len - viewport like every scroll and the other two paths. viewport_heading reuses TocState::current_heading, the rule update_selection uses. Toggling or focusing the TOC re-lays out at a new width and now refreshes search matches, as a resize already did. --- src/app.rs | 239 +++++++++++++++++++++++++++++++++++++++++------------ src/toc.rs | 17 ++-- 2 files changed, 197 insertions(+), 59 deletions(-) diff --git a/src/app.rs b/src/app.rs index 222d76a..9def339 100644 --- a/src/app.rs +++ b/src/app.rs @@ -580,22 +580,16 @@ fn run_inner( dirty = true; } Ok(new_source) => { - let scroll = tabs[active_tab].scroll_offset; - let term_w = - effective_width(terminal.size()?.width, tabs[active_tab].toc.visible); - let mut new_tab = - build_tab(new_source, input, &args, term_w, theme_gen, graphics); - let new_max = new_tab - .ratatui_lines - .len() - .saturating_sub(viewport_height as usize); - new_tab.scroll_offset = scroll.min(new_max); - new_tab.toc.visible = tabs[active_tab].toc.visible; - new_tab - .toc - .inherit(std::mem::take(&mut tabs[active_tab].toc.nav)); - new_tab.toc.update_selection(new_tab.scroll_offset); - tabs[active_tab] = new_tab; + reload_tab( + &mut tabs[active_tab], + new_source.into(), + Anchor::KeepOffset, + &args, + terminal.size()?.width, + viewport_height as usize, + theme_gen, + graphics, + ); search.update_matches(&tabs[active_tab].lowered); if file_missing { file_missing = false; @@ -622,6 +616,7 @@ fn run_inner( &mut tabs[active_tab], &args, terminal.size()?.width, + viewport_height as usize, theme_gen, graphics, ); @@ -1000,6 +995,7 @@ fn run_inner( &mut tabs[active_tab], &args, terminal.size()?.width, + viewport_height as usize, theme_gen, graphics, ); @@ -1016,6 +1012,7 @@ fn run_inner( &mut tabs[active_tab], &args, terminal.size()?.width, + viewport_height as usize, theme_gen, graphics, ); @@ -1212,6 +1209,7 @@ fn run_inner( &mut tabs[active_tab], &args, terminal.size()?.width, + viewport_height as usize, theme_gen, graphics, ); @@ -1225,6 +1223,7 @@ fn run_inner( &mut tabs[active_tab], &args, terminal.size()?.width, + viewport_height as usize, theme_gen, graphics, ); @@ -1246,7 +1245,15 @@ fn run_inner( } else { if !tab.toc.visible { tab.toc.toggle(); - rebuild_tab(tab, &args, width, theme_gen, graphics); + rebuild_tab( + tab, + &args, + width, + viewport_height as usize, + theme_gen, + graphics, + ); + search.update_matches(&tab.lowered); } tab.toc.update_selection(tab.scroll_offset); tab.toc.focus(); @@ -1258,9 +1265,11 @@ fn run_inner( &mut tabs[active_tab], &args, terminal.size()?.width, + viewport_height as usize, theme_gen, graphics, ); + search.update_matches(&tabs[active_tab].lowered); } // Help overlay @@ -1333,6 +1342,7 @@ fn run_inner( &mut tabs[active_tab], &args, terminal.size()?.width, + viewport_height as usize, theme_gen, graphics, ); @@ -1348,6 +1358,7 @@ fn run_inner( &mut tabs[active_tab], &args, terminal.size()?.width, + viewport_height as usize, theme_gen, graphics, ); @@ -1647,12 +1658,8 @@ fn not_editable(tab: &Tab) -> Option { /// The heading the viewport is in: the last one at or above its top (one /// on the second row counts, as for the TOC), with its index. fn viewport_heading(tab: &Tab) -> Option<(usize, &crate::toc::TocEntry)> { - tab.toc - .headings - .iter() - .enumerate() - .rev() - .find(|(_, h)| h.line_index <= tab.scroll_offset + 1) + let i = tab.toc.current_heading(tab.scroll_offset)?; + Some((i, &tab.toc.headings[i])) } /// The 1-based line of the file to open the editor at, for the top of the @@ -1789,23 +1796,66 @@ fn reload_after_edit( ) -> Result<(), String> { let src = std::fs::read_to_string(&tab.filename) .map_err(|e| format!("cannot reload {}: {e}", short_name(&tab.filename)))?; - let anchor = viewport_heading(tab).map(|(i, h)| { - let nth = tab.toc.headings[..i] - .iter() - .filter(|o| o.text == h.text) - .count(); - ( - h.text.clone(), - nth, - tab.scroll_offset as isize - h.line_index as isize, - ) - }); + reload_tab( + tab, + src.into(), + Anchor::Heading, + args, + term_width, + viewport, + gen, + graphics, + ); + Ok(()) +} + +/// Where [`reload_tab`] puts the reader in the new layout. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Anchor { + /// The same line offset (`--watch`, a re-layout of the same source). + KeepOffset, + /// The same heading (by text and occurrence) at the same distance below + /// it, else the same offset (after `e`: lines may have been added above). + Heading, +} + +/// Lay `tab` out again from `source`: the one way a tab is rebuilt, for a +/// re-layout (`rebuild_tab`), `--watch` and a reload after `e`. Keeps the +/// sidebar's visibility and interaction state, places the reader by +/// `anchor`, clamps the offset like every scroll does (the last screen +/// full: `len - viewport`, not below 0), and re-selects the current heading. +/// The caller refreshes search matches, which live outside the tab. +#[allow(clippy::too_many_arguments)] +fn reload_tab( + tab: &mut Tab, + source: Arc, + anchor: Anchor, + args: &Args, + term_width: u16, + viewport: usize, + gen: u32, + graphics: &crate::graphics::Graphics, +) { + let heading = (anchor == Anchor::Heading) + .then(|| viewport_heading(tab)) + .flatten() + .map(|(i, h)| { + let nth = tab.toc.headings[..i] + .iter() + .filter(|o| o.text == h.text) + .count(); + ( + h.text.clone(), + nth, + tab.scroll_offset as isize - h.line_index as isize, + ) + }); let scroll = tab.scroll_offset; let toc_visible = tab.toc.visible; let toc_nav = std::mem::take(&mut tab.toc.nav); let filename = tab.filename.clone(); *tab = build_tab( - src, + source, &filename, args, effective_width(term_width, toc_visible), @@ -1814,8 +1864,7 @@ fn reload_after_edit( ); tab.toc.visible = toc_visible; tab.toc.inherit(toc_nav); - let max_scroll = tab.ratatui_lines.len().saturating_sub(viewport); - let same_heading = anchor.and_then(|(text, nth, delta)| { + let same_heading = heading.and_then(|(text, nth, delta)| { let h = tab .toc .headings @@ -1824,9 +1873,9 @@ fn reload_after_edit( .nth(nth)?; Some((h.line_index as isize + delta).max(0) as usize) }); + let max_scroll = tab.ratatui_lines.len().saturating_sub(viewport); tab.scroll_offset = same_heading.unwrap_or(scroll).min(max_scroll); tab.toc.update_selection(tab.scroll_offset); - Ok(()) } /// The link under a document position, if any: the `link_url` of the span @@ -2244,25 +2293,27 @@ fn rebuild_tab( tab: &mut Tab, args: &Args, term_width: u16, + viewport: usize, gen: u32, graphics: &crate::graphics::Graphics, ) { - let source = Arc::clone(&tab.source); - let filename = tab.filename.clone(); - let scroll = tab.scroll_offset; - let toc_visible = tab.toc.visible; - let toc_nav = std::mem::take(&mut tab.toc.nav); let mtime = tab.mtime; - let width = effective_width(term_width, toc_visible); - *tab = build_tab(source, &filename, args, width, gen, graphics); - tab.toc.inherit(toc_nav); + let source = Arc::clone(&tab.source); + // Clamped: the new layout may have far fewer lines (e.g. after widening) + // — an unclamped stale offset blanked the viewport and panicked + // link-mode. + reload_tab( + tab, + source, + Anchor::KeepOffset, + args, + term_width, + viewport, + gen, + graphics, + ); // Same source as before, so the same snapshot time. tab.mtime = mtime; - // Clamp: the new layout may have far fewer lines (e.g. after widening) — - // an unclamped stale offset blanked the viewport and panicked link-mode. - tab.scroll_offset = scroll.min(tab.ratatui_lines.len().saturating_sub(1)); - tab.toc.visible = toc_visible; - tab.toc.update_selection(tab.scroll_offset); } /// Refresh a tab only if it was laid out for a different width or theme. @@ -2270,11 +2321,12 @@ fn ensure_tab_current( tab: &mut Tab, args: &Args, term_width: u16, + viewport: usize, gen: u32, graphics: &crate::graphics::Graphics, ) { if tab.built_width != effective_width(term_width, tab.toc.visible) || tab.built_gen != gen { - rebuild_tab(tab, args, term_width, gen, graphics); + rebuild_tab(tab, args, term_width, viewport, gen, graphics); } } @@ -3026,6 +3078,89 @@ mod section_tests { assert_eq!(not_editable(&t), None); } + // The three ways a tab is rebuilt (re-layout, `--watch`, reload after + // `e`) share `reload_tab`: the same state carried over and one clamp + // rule. `rebuild_tab` used to clamp to `len - 1`, leaving a mostly + // empty screen where every scroll stops at `len - viewport`. + #[test] + fn every_reload_keeps_the_sidebar_and_clamps_to_a_full_last_screen() { + let graphics = crate::graphics::Graphics::halfblocks(); + let args = test_args(); + let doc: String = (0..30) + .map(|i| format!("## H{i}\n\nbody {i}\n\n")) + .collect(); + let short: String = (0..5).map(|i| format!("## H{i}\n\nbody {i}\n\n")).collect(); + let viewport = 10; + for anchor in [Anchor::KeepOffset, Anchor::Heading] { + let mut t = build_tab(doc.as_str(), "t.md", &args, 80, 0, &graphics); + t.toc.visible = true; + t.toc.focus(); + t.scroll_offset = 100; + reload_tab( + &mut t, + short.as_str().into(), + anchor, + &args, + 120, + viewport, + 0, + &graphics, + ); + assert!(t.toc.visible && t.toc.nav.focused, "{anchor:?}"); + assert_eq!(t.built_width, effective_width(120, true), "{anchor:?}"); + let len = t.ratatui_lines.len(); + assert!(len > viewport); + assert_eq!(t.scroll_offset, len - viewport, "{anchor:?}"); + assert_eq!( + t.toc.selected, + t.toc.current_heading(t.scroll_offset).unwrap() + ); + } + // A re-layout of the same source keeps its snapshot time and clamps + // the same way. + let mut t = build_tab(doc.as_str(), "t.md", &args, 80, 0, &graphics); + let mtime = Some(SystemTime::UNIX_EPOCH); + t.mtime = mtime; + t.scroll_offset = usize::MAX / 2; + rebuild_tab(&mut t, &args, 200, viewport, 0, &graphics); + assert_eq!(t.mtime, mtime); + assert_eq!(t.scroll_offset, t.ratatui_lines.len() - viewport); + // Keep-offset ignores headings moving; re-anchoring follows them. + let shifted = format!("# New\n\nadded\n\n{doc}"); + let at_h20 = |t: Tab| scrolled_to(t, "H20"); + let mut keep = at_h20(build_tab(doc.as_str(), "t.md", &args, 80, 0, &graphics)); + let before = keep.scroll_offset; + reload_tab( + &mut keep, + shifted.as_str().into(), + Anchor::KeepOffset, + &args, + 80, + viewport, + 0, + &graphics, + ); + assert_eq!(keep.scroll_offset, before); + let mut follow = at_h20(build_tab(doc.as_str(), "t.md", &args, 80, 0, &graphics)); + reload_tab( + &mut follow, + shifted.as_str().into(), + Anchor::Heading, + &args, + 80, + viewport, + 0, + &graphics, + ); + let h20 = follow + .toc + .headings + .iter() + .find(|h| h.text == "H20") + .unwrap(); + assert_eq!(follow.scroll_offset, h20.line_index - 1); + } + #[test] fn reloading_after_an_edit_stays_on_the_same_heading() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/toc.rs b/src/toc.rs index 5fe697f..c7d2e9e 100644 --- a/src/toc.rs +++ b/src/toc.rs @@ -138,13 +138,16 @@ impl TocState { /// `n`/`N` and TOC jumps put it, under its blank spacing line, and the /// sidebar used to mark the heading before it. pub fn update_selection(&mut self, current_line: usize) { - for (i, entry) in self.headings.iter().enumerate().rev() { - if entry.line_index <= current_line + 1 { - self.selected = i; - return; - } - } - self.selected = 0; + self.selected = self.current_heading(current_line).unwrap_or(0); + } + + /// The heading a viewport whose top is `line` is in: the last one at or + /// above it, one on its second row included (see + /// [`Self::update_selection`]). `None` above the first heading. + pub fn current_heading(&self, line: usize) -> Option { + self.headings + .iter() + .rposition(|entry| entry.line_index <= line + 1) } /// Does heading `i` have subheadings? From 7437baf12c80f4d71d7fc7af2ebdfd61d5f3cce5 Mon Sep 17 00:00:00 2001 From: Amin Borghei Date: Thu, 1 Oct 2026 19:06:13 +0200 Subject: [PATCH 11/11] test(math): loosen the wall-clock bound in the hostile-nesting test 500 ms was within noise of a busy CI runner (the frac case took 500.6 ms on ubuntu-latest); the test guards against quadratic behaviour that took minutes, so a 20 s bound still catches it. --- src/layout/math.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/layout/math.rs b/src/layout/math.rs index 89ae2a1..b18a109 100644 --- a/src/layout/math.rs +++ b/src/layout/math.rs @@ -1529,7 +1529,10 @@ mod tests { } /// Runs `f` on a 1 MB stack, as a worker thread would, and fails if it - /// overflows or takes longer than half a second (debug build). + /// overflows or is far slower than linear work should be. The bound is + /// loose on purpose: these inputs take ~0.1 s in a local debug build and + /// ~0.5 s on a busy CI runner, while the quadratic behaviour this guards + /// against took minutes. fn on_small_stack(name: &str, f: impl FnOnce() + Send + 'static) { let start = std::time::Instant::now(); std::thread::Builder::new() @@ -1539,7 +1542,7 @@ mod tests { .join() .unwrap_or_else(|_| panic!("{name}: overflowed its stack")); let took = start.elapsed(); - assert!(took.as_millis() < 500, "{name}: took {took:?}"); + assert!(took.as_secs() < 20, "{name}: took {took:?}"); } // Regression: `\sqrt[` rendered its index with an unchecked recursive