diff --git a/README.md b/README.md
index 67a2f3a..844df73 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` (`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
@@ -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
@@ -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/docs/llms-full.txt b/docs/llms-full.txt
index 1946bc5..a04a949 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -17,11 +17,11 @@ 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 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
-- YAML, TOML and JSON frontmatter: stripped by default; --frontmatter shows it as a key/value box
+- 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
- Real pixel rendering via terminal graphics protocols (Kitty graphics, iTerm2 inline, Sixel), auto-detected at startup; --image-protocol auto|kitty|iterm2|sixel|halfblocks
@@ -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/docs/llms.txt b/docs/llms.txt
index cda2302..59b4ca3 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -6,11 +6,11 @@ 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
-- 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/app.rs b/src/app.rs
index 523784f..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,
);
@@ -630,6 +625,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 +732,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)])
@@ -988,6 +995,7 @@ fn run_inner(
&mut tabs[active_tab],
&args,
terminal.size()?.width,
+ viewport_height as usize,
theme_gen,
graphics,
);
@@ -1004,6 +1012,7 @@ fn run_inner(
&mut tabs[active_tab],
&args,
terminal.size()?.width,
+ viewport_height as usize,
theme_gen,
graphics,
);
@@ -1200,6 +1209,7 @@ fn run_inner(
&mut tabs[active_tab],
&args,
terminal.size()?.width,
+ viewport_height as usize,
theme_gen,
graphics,
);
@@ -1213,6 +1223,7 @@ fn run_inner(
&mut tabs[active_tab],
&args,
terminal.size()?.width,
+ viewport_height as usize,
theme_gen,
graphics,
);
@@ -1226,7 +1237,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(),
@@ -1234,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();
@@ -1246,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
@@ -1321,6 +1342,7 @@ fn run_inner(
&mut tabs[active_tab],
&args,
terminal.size()?.width,
+ viewport_height as usize,
theme_gen,
graphics,
);
@@ -1336,6 +1358,7 @@ fn run_inner(
&mut tabs[active_tab],
&args,
terminal.size()?.width,
+ viewport_height as usize,
theme_gen,
graphics,
);
@@ -1635,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
@@ -1728,7 +1747,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)]
@@ -1763,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),
@@ -1788,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
@@ -1798,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
@@ -2191,45 +2266,54 @@ 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,
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.
@@ -2237,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);
}
}
@@ -2993,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/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()
}
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/math.rs b/src/layout/math.rs
index 13311a6..b18a109 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,61 @@ 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 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()
+ .stack_size(1 << 20)
+ .spawn(f)
+ .unwrap()
+ .join()
+ .unwrap_or_else(|_| panic!("{name}: overflowed its stack"));
+ let took = start.elapsed();
+ assert!(took.as_secs() < 20, "{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/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]
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index 7a5e904..a1e78ff 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 {
@@ -2727,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/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/frontmatter.rs b/src/parser/frontmatter.rs
index ebcaad0..8449520 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,
});
}
}
@@ -229,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() {
@@ -659,7 +693,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 +715,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]
@@ -699,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";
@@ -750,11 +835,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/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";
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/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?
diff --git a/tests/cli.rs b/tests/cli.rs
index 9962b10..feadc6b 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,8 @@ 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).",
+ "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 5011c18..c4969e4 100644
--- a/tests/fixtures/ascii.md
+++ b/tests/fixtures/ascii.md
@@ -44,7 +44,9 @@ 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).
+
+Caption: small print here.
Term
: Its definition.
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");
diff --git a/tests/rendering_features.rs b/tests/rendering_features.rs
index d8604c9..4e49c68 100644
--- a/tests/rendering_features.rs
+++ b/tests/rendering_features.rs
@@ -145,16 +145,75 @@ 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() {
- // No superscript `q`; no subscript `b`: the whole run falls back.
- let out = render("x^q^ and yab\n");
- assert!(out.contains("x^(q) and y_(ab)"), "{out}");
+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("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
+// 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]
@@ -176,18 +235,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 +298,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 ──
@@ -268,7 +315,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!(
@@ -318,6 +364,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.
diff --git a/tests/security.rs b/tests/security.rs
index 4852e83..96da4a1 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 =
@@ -245,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\