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\