diff --git a/Cargo.lock b/Cargo.lock index c90b5d5..0dd6518 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -963,10 +963,11 @@ dependencies = [ "mq-markdown", "rstest", "terminal_size", - "tree-sitter 0.25.10", + "tree-sitter", "tree-sitter-bash", "tree-sitter-c", "tree-sitter-clojure", + "tree-sitter-containerfile", "tree-sitter-cpp", "tree-sitter-css", "tree-sitter-elixir", @@ -978,13 +979,22 @@ dependencies = [ "tree-sitter-java", "tree-sitter-javascript", "tree-sitter-json", + "tree-sitter-kotlin-ng", + "tree-sitter-lua", + "tree-sitter-make", "tree-sitter-mq", "tree-sitter-ocaml", + "tree-sitter-php", "tree-sitter-python", + "tree-sitter-ruby", "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-sequel", "tree-sitter-swift", - "tree-sitter-toml", + "tree-sitter-toml-ng", "tree-sitter-typescript", + "tree-sitter-yaml", + "unicode-width 0.2.2", "viuer", ] @@ -1710,16 +1720,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "tree-sitter" -version = "0.20.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e747b1f9b7b931ed39a548c1fae149101497de3c1fc8d9e18c62c1a66c683d3d" -dependencies = [ - "cc", - "regex", -] - [[package]] name = "tree-sitter" version = "0.25.10" @@ -1761,7 +1761,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4004884cc509449a1d78fa3e1f02b4e953d0a8065984445304795e72e885338c" dependencies = [ "cc", - "tree-sitter 0.25.10", + "tree-sitter", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-containerfile" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1555ef425ecf06d7f65d58b3f9d3db3bb7fbd4ad135b4e7e40f194b0859ea16f" +dependencies = [ + "cc", "tree-sitter-language", ] @@ -1834,7 +1844,7 @@ dependencies = [ "regex", "streaming-iterator", "thiserror", - "tree-sitter 0.25.10", + "tree-sitter", ] [[package]] @@ -1877,12 +1887,42 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-kotlin-ng" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e800ebbda938acfbf224f4d2c34947a31994b1295ee6e819b65226c7b51b4450" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-language" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +[[package]] +name = "tree-sitter-lua" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8daaf5f4235188a58603c39760d5fa5d4b920d36a299c934adddae757f32a10c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-make" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-mq" version = "0.1.11" @@ -1903,6 +1943,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-php" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-python" version = "0.25.0" @@ -1913,6 +1963,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-rust" version = "0.24.2" @@ -1923,6 +1983,26 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-scala" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de5a4a7ff23a55474ce6a741d52aaeca7a82fe9421bb982b86e98c6ac8629397" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-sequel" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-swift" version = "0.7.3" @@ -1934,13 +2014,13 @@ dependencies = [ ] [[package]] -name = "tree-sitter-toml" -version = "0.20.0" +name = "tree-sitter-toml-ng" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca517f578a98b23d20780247cc2688407fa81effad5b627a5a364ec3339b53e8" +checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" dependencies = [ "cc", - "tree-sitter 0.20.10", + "tree-sitter-language", ] [[package]] @@ -1953,6 +2033,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "unicode-id" version = "0.3.6" diff --git a/Cargo.toml b/Cargo.toml index f8e328b..d430c52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ all-languages = [ "lang-clojure", "lang-cpp", "lang-css", + "lang-dockerfile", "lang-elm", "lang-go", "lang-haskell", @@ -34,21 +35,30 @@ all-languages = [ "lang-java", "lang-javascript", "lang-json", + "lang-kotlin", + "lang-lua", + "lang-make", "lang-mq", "lang-ocaml", + "lang-php", "lang-python", + "lang-ruby", "lang-rust", + "lang-scala", + "lang-sql", "lang-swift", "lang-toml", "lang-typescript", + "lang-yaml", "lang-elixir", ] -default = ["lang-rust", "lang-javascript", "lang-typescript", "lang-python", "lang-json", "lang-bash", "lang-html", "lang-css", "lang-mq", "lang-elixir"] +default = ["lang-rust", "lang-javascript", "lang-typescript", "lang-python", "lang-json", "lang-bash", "lang-html", "lang-css", "lang-mq", "lang-elixir", "lang-yaml", "lang-toml", "lang-ruby", "lang-sql"] lang-bash = ["dep:tree-sitter-bash"] lang-c = ["dep:tree-sitter-c"] lang-clojure = ["dep:tree-sitter-clojure"] lang-cpp = ["dep:tree-sitter-cpp"] lang-css = ["dep:tree-sitter-css"] +lang-dockerfile = ["dep:tree-sitter-containerfile"] lang-elixir = ["dep:tree-sitter-elixir"] lang-elm = ["dep:tree-sitter-elm"] lang-go = ["dep:tree-sitter-go"] @@ -57,13 +67,21 @@ lang-html = ["dep:tree-sitter-html"] lang-java = ["dep:tree-sitter-java"] lang-javascript = ["dep:tree-sitter-javascript"] lang-json = ["dep:tree-sitter-json"] +lang-kotlin = ["dep:tree-sitter-kotlin-ng"] +lang-lua = ["dep:tree-sitter-lua"] +lang-make = ["dep:tree-sitter-make"] lang-mq = ["dep:tree-sitter-mq"] lang-ocaml = ["dep:tree-sitter-ocaml"] +lang-php = ["dep:tree-sitter-php"] lang-python = ["dep:tree-sitter-python"] +lang-ruby = ["dep:tree-sitter-ruby"] lang-rust = ["dep:tree-sitter-rust"] +lang-scala = ["dep:tree-sitter-scala"] +lang-sql = ["dep:tree-sitter-sequel"] lang-swift = ["dep:tree-sitter-swift"] -lang-toml = ["dep:tree-sitter-toml"] +lang-toml = ["dep:tree-sitter-toml-ng"] lang-typescript = ["dep:tree-sitter-typescript"] +lang-yaml = ["dep:tree-sitter-yaml"] [dependencies] clap = {version = "4.6.1", features = ["derive"]} @@ -77,6 +95,7 @@ tree-sitter = "0.25.10" tree-sitter-bash = {version = "0.25", optional = true} tree-sitter-c = {version = "0.24", optional = true} tree-sitter-clojure = {version = "0.1.0", optional = true} +tree-sitter-containerfile = {version = "0.8", optional = true} tree-sitter-cpp = {version = "0.23.4", optional = true} tree-sitter-css = {version = "0.25", optional = true} tree-sitter-elixir = {version = "0.3.5", optional = true} @@ -88,13 +107,22 @@ tree-sitter-html = {version = "0.23", optional = true} tree-sitter-java = {version = "0.23", optional = true} tree-sitter-javascript = {version = "0.25", optional = true} tree-sitter-json = {version = "0.24", optional = true} +tree-sitter-kotlin-ng = {version = "1.1", optional = true} +tree-sitter-lua = {version = "0.5", optional = true} +tree-sitter-make = {version = "1.1", optional = true} tree-sitter-mq = {version = "0.1.10", optional = true} tree-sitter-ocaml = {version = "0.25.0", optional = true} +tree-sitter-php = {version = "0.24", optional = true} tree-sitter-python = {version = "0.25.0", optional = true} +tree-sitter-ruby = {version = "0.23", optional = true} tree-sitter-rust = {version = "0.24", optional = true} +tree-sitter-scala = {version = "0.26", optional = true} +tree-sitter-sequel = {version = "0.3", optional = true} tree-sitter-swift = {version = "0.7.3", optional = true} -tree-sitter-toml = {version = "0.20.0", optional = true} +tree-sitter-toml-ng = {version = "0.7", optional = true} tree-sitter-typescript = {version = "0.23.2", optional = true} +tree-sitter-yaml = {version = "0.7", optional = true} +unicode-width = "0.2.2" viuer = {version = "0.11"} [dev-dependencies] diff --git a/README.md b/README.md index d33f3bd..1a9fd25 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ Built with [mq](https://github.com/harehare/mq) - jq-like command-line tool for ## Features -- ๐ŸŽจ **Syntax Highlighting**: Tree-sitter powered syntax highlighting for 13+ programming languages -- ๐Ÿ“ **Rich Markdown Rendering**: Support for headers, lists, code blocks, links, images, and more -- ๐Ÿ”” **GitHub-style Callouts**: NOTE, TIP, IMPORTANT, WARNING, CAUTION +- ๐ŸŽจ **Syntax Highlighting**: Tree-sitter powered syntax highlighting for 29+ programming and config languages +- ๐Ÿ“ **Rich Markdown Rendering**: Support for headers, lists, code blocks, links, images, tables, and more +- ๐Ÿงœ **Mermaid Diagrams**: Best-effort ASCII-art rendering of simple `graph`/`flowchart` blocks +- ๐Ÿ”” **GitHub-style Callouts**: NOTE, TIP, IMPORTANT, WARNING, CAUTION, rendered as wrapped, bordered boxes - ๐Ÿ”— **Clickable Links**: Terminal hyperlinks using OSC 8 ## Installation @@ -43,11 +44,22 @@ cargo install --git https://github.com/harehare/mq-view.git ## Supported Languages -- Rust, JavaScript, TypeScript (+ TSX) -- Python, Go, Java -- C, C++ -- HTML, CSS, JSON -- Bash/Shell +Enabled by default: + +- Rust, JavaScript, TypeScript (+ TSX), Python +- HTML, CSS, JSON, YAML, TOML +- Bash/Shell, Ruby, SQL +- Elixir, mq + +Available with the `all-languages` feature: + +- Go, Java, Kotlin, Scala +- C, C++, Swift +- PHP, Lua, Clojure, Haskell, OCaml, Elm +- Dockerfile, Makefile + +See `Cargo.toml` for the full list of `lang-*` feature flags if you only need +one or two extra languages instead of all of them. ## Usage @@ -65,6 +77,23 @@ Pipe markdown content: echo "# Hello\n\n\`\`\`rust\nfn main() {}\n\`\`\`" | mq-view ``` +### Mermaid Diagrams + +Fenced code blocks tagged ` ```mermaid ` are rendered as ASCII art instead of +plain text when the diagram is a simple `graph`/`flowchart`: + +```mermaid +graph TD + A[Start] --> B{Is it working?} + B -->|Yes| C[Great success] + B -->|No| D[Debug it] +``` + +This only understands a small subset of mermaid flowchart syntax (nodes, +shapes, and edges with optional labels). Other diagram types (sequence, +class, gantt, ...) and advanced flowchart syntax fall back to a regular, +syntax-highlighted code block. + ## License MIT diff --git a/assets/demo.gif b/assets/demo.gif index ec5690b..02ffe84 100644 Binary files a/assets/demo.gif and b/assets/demo.gif differ diff --git a/assets/demo.md b/assets/demo.md index b841c1f..09b0ed2 100644 --- a/assets/demo.md +++ b/assets/demo.md @@ -4,39 +4,36 @@ `mq` is a jq-like command-line tool for Markdown processing. -## Features - -### 1. Filter Headers - -You can filter headers by level and content. - -### 2. Extract Content +> [!TIP] +> Pipe any Markdown file into `mq-view` to get syntax highlighting, +> callouts, tables, and mermaid diagrams right in your terminal. ## Code Examples -```mq -def main(): - print("Hello, mq!") -end +```rust +fn main() { + println!("Hello, mq!"); +} ``` -## Notes +## How It Works -- Simple to use -- Written in Rust -- Fast and efficient +```mermaid +graph LR + A[Markdown] --> B[mq] + B --> C[mq-view] +``` ## Tables -mq can also process tables in markdown files: - | Feature | Description | Status | | ----------- | ------------------------ | ------ | | Headers | Filter headers by level | โœ… | -| Content | Extract specific content | โœ… | +| Callouts | NOTE, TIP, WARNING, ... | โœ… | | Tables | Process markdown tables | โœ… | -| Code blocks | Handle code snippets | โœ… | +| Mermaid | Render simple flowcharts | โœ… | -You can query and transform table data just like other markdown elements. +> [!WARNING] +> Always double-check generated queries before running them on real data. Try mq today! diff --git a/queries/clojure_highlights.scm b/queries/clojure_highlights.scm new file mode 100644 index 0000000..d27587a --- /dev/null +++ b/queries/clojure_highlights.scm @@ -0,0 +1,29 @@ +;; Literals + +(num_lit) @number + +[ + (char_lit) + (str_lit) +] @string + +[ + (bool_lit) + (nil_lit) +] @constant.builtin + +(kwd_lit) @constant + +;; Comments + +(comment) @comment + +;; Treat quasiquotation as operators for the purpose of highlighting. + +[ + "'" + "`" + "~" + "@" + "~@" +] @operator diff --git a/queries/kotlin_highlights.scm b/queries/kotlin_highlights.scm new file mode 100644 index 0000000..750c4af --- /dev/null +++ b/queries/kotlin_highlights.scm @@ -0,0 +1,144 @@ +; Custom highlights query for the tree-sitter-kotlin-ng grammar +; (node names differ from the older fwcd/tree-sitter-kotlin grammar, +; so this cannot be reused as-is from upstream). + +[ + (line_comment) + (block_comment) +] @comment + +(string_content) @string +(character_literal) @string +(number_literal) @number +(float_literal) @number + +(this_expression) @variable.builtin +(super_expression) @variable.builtin + +(class_declaration name: (identifier) @type) +(object_declaration name: (identifier) @type) +(user_type (identifier) @type) + +(function_declaration name: (identifier) @function) + +(parameter (identifier) @variable.parameter) + +(import (identifier) @namespace) +(import (qualified_identifier) @namespace) +(package_header (qualified_identifier) @namespace) + +(annotation "@" @attribute) + +[ + "fun" + "val" + "var" + "class" + "object" + "interface" + "enum" + "companion" + "constructor" + "init" + "typealias" + "package" + "import" + "return" + "throw" + "if" + "else" + "when" + "for" + "do" + "while" + "try" + "catch" + "finally" + "is" + "in" + "!in" + "!is" + "as" + "as?" + "by" + "out" + "public" + "private" + "protected" + "internal" + "open" + "final" + "abstract" + "override" + "sealed" + "data" + "inline" + "noinline" + "crossinline" + "suspend" + "operator" + "infix" + "tailrec" + "vararg" + "lateinit" + "const" + "external" + "annotation" + "actual" + "expect" + "value" + "inner" + "get" + "set" + "where" + "dynamic" +] @keyword + +[ + "(" + ")" + "[" + "]" + "{" + "}" +] @punctuation.bracket + +[ + "." + "," + ";" + ":" + "::" +] @punctuation.delimiter + +[ + "=" + "==" + "===" + "!=" + "!==" + "<" + "<=" + ">" + ">=" + "&&" + "||" + "!" + "+" + "-" + "*" + "/" + "%" + "+=" + "-=" + "*=" + "/=" + "%=" + "++" + "--" + "->" + "?." + "?:" + ".." + "..<" +] @operator diff --git a/src/highlighter.rs b/src/highlighter.rs index 884e67e..e29ff30 100644 --- a/src/highlighter.rs +++ b/src/highlighter.rs @@ -124,6 +124,61 @@ impl SyntaxHighlighter { tree_sitter_elixir::LANGUAGE.into(), tree_sitter_elixir::HIGHLIGHTS_QUERY, ), + #[cfg(feature = "lang-toml")] + "toml" => ( + tree_sitter_toml_ng::LANGUAGE.into(), + tree_sitter_toml_ng::HIGHLIGHTS_QUERY, + ), + #[cfg(feature = "lang-clojure")] + "clojure" | "clj" => ( + tree_sitter_clojure::LANGUAGE.into(), + include_str!("../queries/clojure_highlights.scm"), + ), + #[cfg(feature = "lang-yaml")] + "yaml" | "yml" => ( + tree_sitter_yaml::LANGUAGE.into(), + tree_sitter_yaml::HIGHLIGHTS_QUERY, + ), + #[cfg(feature = "lang-ruby")] + "ruby" | "rb" => ( + tree_sitter_ruby::LANGUAGE.into(), + tree_sitter_ruby::HIGHLIGHTS_QUERY, + ), + #[cfg(feature = "lang-php")] + "php" => ( + tree_sitter_php::LANGUAGE_PHP.into(), + tree_sitter_php::HIGHLIGHTS_QUERY, + ), + #[cfg(feature = "lang-lua")] + "lua" => ( + tree_sitter_lua::LANGUAGE.into(), + tree_sitter_lua::HIGHLIGHTS_QUERY, + ), + #[cfg(feature = "lang-kotlin")] + "kotlin" | "kt" | "kts" => ( + tree_sitter_kotlin_ng::LANGUAGE.into(), + include_str!("../queries/kotlin_highlights.scm"), + ), + #[cfg(feature = "lang-scala")] + "scala" => ( + tree_sitter_scala::LANGUAGE.into(), + tree_sitter_scala::HIGHLIGHTS_QUERY, + ), + #[cfg(feature = "lang-make")] + "make" | "makefile" => ( + tree_sitter_make::LANGUAGE.into(), + tree_sitter_make::HIGHLIGHTS_QUERY, + ), + #[cfg(feature = "lang-sql")] + "sql" => ( + tree_sitter_sequel::LANGUAGE.into(), + tree_sitter_sequel::HIGHLIGHTS_QUERY, + ), + #[cfg(feature = "lang-dockerfile")] + "dockerfile" | "docker" => ( + tree_sitter_containerfile::LANGUAGE.into(), + tree_sitter_containerfile::HIGHLIGHTS_QUERY, + ), _ => return None, }; @@ -303,6 +358,50 @@ mod tests { #[cfg_attr(feature = "lang-mq", case::mq("mq", r#"fn(): "Hello, world!""#))] #[cfg_attr(feature = "lang-mq", case::bool("mq", r#"fn(): true"#))] #[cfg_attr(feature = "lang-mq", case::number("mq", r#"fn(): 42"#))] + #[cfg_attr( + feature = "lang-toml", + case::toml("toml", "[package]\nname = \"hello\"\nversion = \"1.0.0\"") + )] + #[cfg_attr( + feature = "lang-clojure", + case::clojure("clojure", r#"(defn main [] (println "Hello, world!"))"#) + )] + #[cfg_attr( + feature = "lang-yaml", + case::yaml("yaml", "name: hello\nversion: 1.0.0") + )] + #[cfg_attr( + feature = "lang-ruby", + case::ruby("ruby", r#"def main; puts "Hello, world!"; end"#) + )] + #[cfg_attr( + feature = "lang-php", + case::php("php", r#", @@ -37,7 +37,7 @@ fn main() -> Result<()> { let markdown: Markdown = content.parse().map_err(|e| miette::miette!("{}", e))?; let config = RenderConfig { - header_full_width_highlight: args.header_highlight, + header_full_width_highlight: !args.no_header_highlight, }; let stdout = io::stdout(); diff --git a/src/mermaid.rs b/src/mermaid.rs new file mode 100644 index 0000000..a988a6a --- /dev/null +++ b/src/mermaid.rs @@ -0,0 +1,649 @@ +//! Best-effort ASCII-art rendering of simple mermaid flowcharts. +//! +//! This only understands a small subset of mermaid `graph`/`flowchart` syntax +//! (nodes, shapes, and edges with optional labels). Other diagram types +//! (sequenceDiagram, classDiagram, gantt, ...) and advanced flowchart syntax +//! (subgraph styling, click handlers, complex multi-label edges, ...) are not +//! laid out; callers should fall back to rendering the raw source when `render` +//! returns `None`. +use crate::renderer::visible_width; +use colored::*; +use std::collections::{HashMap, VecDeque}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum Direction { + #[default] + TopDown, + LeftRight, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Shape { + Rectangle, + Rounded, + Diamond, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EdgeStyle { + Solid, + Dotted, + Thick, +} + +#[derive(Debug, Clone)] +struct MNode { + label: String, + shape: Shape, +} + +#[derive(Debug, Clone)] +struct MEdge { + from: String, + to: String, + label: Option, + style: EdgeStyle, +} + +#[derive(Debug, Default)] +struct MermaidGraph { + direction: Direction, + order: Vec, + nodes: HashMap, + edges: Vec, +} + +const ARROWS: &[(&str, EdgeStyle)] = &[ + ("-.->", EdgeStyle::Dotted), + ("-.-", EdgeStyle::Dotted), + ("==>", EdgeStyle::Thick), + ("===", EdgeStyle::Thick), + ("-->", EdgeStyle::Solid), + ("---", EdgeStyle::Solid), + ("--x", EdgeStyle::Solid), + ("--o", EdgeStyle::Solid), +]; + +/// Render mermaid source as ASCII art, returning `None` for unsupported +/// diagram types or syntax this module can't make sense of. +pub fn render(source: &str, max_width: usize) -> Option { + let graph = parse(source)?; + if graph.order.is_empty() { + return None; + } + Some(layout(&graph, max_width)) +} + +fn parse(source: &str) -> Option { + let mut lines = source.lines().map(str::trim).filter(|l| !l.is_empty()); + + let header = lines.next()?; + let mut header_parts = header.split_whitespace(); + let kind = header_parts.next()?.to_lowercase(); + if kind != "graph" && kind != "flowchart" { + return None; + } + let direction = match header_parts.next().map(str::to_uppercase).as_deref() { + Some("LR") | Some("RL") => Direction::LeftRight, + _ => Direction::TopDown, + }; + + let mut graph = MermaidGraph { + direction, + ..Default::default() + }; + + for line in lines { + if line.starts_with("%%") + || line == "end" + || line.starts_with("subgraph") + || line.starts_with("classDef") + || line.starts_with("class ") + || line.starts_with("style ") + || line.starts_with("click ") + || line.starts_with("linkStyle") + { + continue; + } + parse_line(line, &mut graph); + } + + Some(graph) +} + +fn parse_line(line: &str, graph: &mut MermaidGraph) { + let mut rest = line; + let mut pending_from: Option = None; + + loop { + let found = ARROWS + .iter() + .filter_map(|(pat, style)| rest.find(pat).map(|pos| (pos, *pat, *style))) + .min_by_key(|(pos, pat, _)| (*pos, std::cmp::Reverse(pat.len()))); + + let Some((pos, pat, style)) = found else { + let text = rest.trim(); + if !text.is_empty() { + let id = register_node(graph, text); + if let Some(from) = pending_from.take() { + graph.edges.push(MEdge { + from, + to: id, + label: None, + style: EdgeStyle::Solid, + }); + } + } + break; + }; + + let before = rest[..pos].trim(); + let from_id = if let Some(from) = pending_from.take() { + from + } else { + register_node(graph, before) + }; + + let mut after = &rest[pos + pat.len()..]; + let mut label = None; + let after_trimmed = after.trim_start(); + if after_trimmed.starts_with('|') + && let Some(end) = after_trimmed[1..].find('|') + { + label = Some(after_trimmed[1..1 + end].trim().to_string()); + after = &after_trimmed[1 + end + 1..]; + } + + let next_arrow_pos = ARROWS.iter().filter_map(|(p, _)| after.find(p)).min(); + + let node_text = match next_arrow_pos { + Some(p) => after[..p].trim(), + None => after.trim(), + }; + let to_id = register_node(graph, node_text); + + graph.edges.push(MEdge { + from: from_id, + to: to_id.clone(), + label, + style, + }); + + if next_arrow_pos.is_some() { + pending_from = Some(to_id); + rest = after; + } else { + break; + } + } +} + +/// Parse a single node token like `A`, `A[Label]`, `A(Label)`, `A((Label))`, +/// `A{Label}`, registering (or updating) it in the graph and returning its id. +fn register_node(graph: &mut MermaidGraph, text: &str) -> String { + let text = text.trim(); + let id_len = text + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .count(); + let id: String = text.chars().take(id_len).collect(); + let id = if id.is_empty() { text.to_string() } else { id }; + let remainder = text[id_len.min(text.len())..].trim(); + + let (shape, label) = if remainder.is_empty() { + (Shape::Rectangle, None) + } else if let Some(inner) = strip_pair(remainder, "((", "))") { + (Shape::Rounded, Some(inner)) + } else if let Some(inner) = strip_pair(remainder, "([", "])") { + (Shape::Rounded, Some(inner)) + } else if let Some(inner) = strip_pair(remainder, "(", ")") { + (Shape::Rounded, Some(inner)) + } else if let Some(inner) = strip_pair(remainder, "{", "}") { + (Shape::Diamond, Some(inner)) + } else if let Some(inner) = strip_pair(remainder, "[", "]") { + (Shape::Rectangle, Some(inner)) + } else if let Some(inner) = strip_pair(remainder, ">", "]") { + (Shape::Rectangle, Some(inner)) + } else { + (Shape::Rectangle, Some(remainder.to_string())) + }; + + let label = label + .map(|l| l.trim().trim_matches('"').to_string()) + .unwrap_or_else(|| id.clone()); + + if !graph.order.iter().any(|existing| existing == &id) { + graph.order.push(id.clone()); + } + graph + .nodes + .entry(id.clone()) + .and_modify(|n| { + // Prefer a richer label/shape over a bare id-only reference. + if label != id { + n.label = label.clone(); + n.shape = shape; + } + }) + .or_insert(MNode { label, shape }); + + id +} + +fn strip_pair(text: &str, open: &str, close: &str) -> Option { + if text.starts_with(open) && text.ends_with(close) && text.len() >= open.len() + close.len() { + Some(text[open.len()..text.len() - close.len()].to_string()) + } else { + None + } +} + +fn box_chars(shape: Shape) -> (char, char, char, char, char, char) { + match shape { + Shape::Rectangle => ('โ”Œ', 'โ”', 'โ””', 'โ”˜', 'โ”€', 'โ”‚'), + Shape::Rounded => ('โ•ญ', 'โ•ฎ', 'โ•ฐ', 'โ•ฏ', 'โ”€', 'โ”‚'), + Shape::Diamond => ('โ•”', 'โ•—', 'โ•š', 'โ•', 'โ•', 'โ•‘'), + } +} + +fn shape_color(s: &str, shape: Shape) -> ColoredString { + match shape { + Shape::Rectangle => s.cyan(), + Shape::Rounded => s.green(), + Shape::Diamond => s.yellow(), + } +} + +/// Render a single node as a 3-line box, returning the lines and their +/// (unstyled) display width. +fn render_box(node: &MNode) -> (Vec, usize) { + let (tl, tr, bl, br, h, v) = box_chars(node.shape); + let width = visible_width(&node.label) + 2; + let top = format!("{}{}{}", tl, h.to_string().repeat(width), tr); + let bottom = format!("{}{}{}", bl, h.to_string().repeat(width), br); + let total_width = width + 2; + let vbar = shape_color(&v.to_string(), node.shape).to_string(); + ( + vec![ + shape_color(&top, node.shape).to_string(), + format!("{} {} {}", vbar, node.label, vbar), + shape_color(&bottom, node.shape).to_string(), + ], + total_width, + ) +} + +fn arrow_label(label: &Option) -> String { + label.clone().unwrap_or_default() +} + +fn edge_style_arrow(style: EdgeStyle, horizontal: bool) -> &'static str { + match (style, horizontal) { + (EdgeStyle::Solid, true) => "โ”€โ”€โ–ถ", + (EdgeStyle::Dotted, true) => "โ”„โ”„โ–ถ", + (EdgeStyle::Thick, true) => "โ•โ•โ–ถ", + (EdgeStyle::Solid, false) => "โ”‚", + (EdgeStyle::Dotted, false) => "โ”Š", + (EdgeStyle::Thick, false) => "โ•‘", + } +} + +fn rank_of(graph: &MermaidGraph) -> HashMap { + let mut indegree: HashMap<&str, usize> = graph.order.iter().map(|n| (n.as_str(), 0)).collect(); + for edge in &graph.edges { + if let Some(d) = indegree.get_mut(edge.to.as_str()) { + *d += 1; + } + } + + let mut remaining = indegree.clone(); + let mut rank: HashMap = HashMap::new(); + // Tracks nodes that have been assigned a final rank and enqueued exactly + // once. Edges pointing back into a `settled` node are cycle back-edges; + // they're ignored for layering so Kahn's algorithm can never stall or + // re-enqueue the same node forever. + let mut settled: std::collections::HashSet = std::collections::HashSet::new(); + let mut queue: VecDeque = VecDeque::new(); + + for id in &graph.order { + if indegree[id.as_str()] == 0 { + rank.insert(id.clone(), 0); + settled.insert(id.clone()); + queue.push_back(id.clone()); + } + } + + loop { + while let Some(id) = queue.pop_front() { + let r = rank[&id]; + for edge in graph.edges.iter().filter(|e| e.from == id) { + if settled.contains(&edge.to) { + continue; + } + let entry = rank.entry(edge.to.clone()).or_insert(0); + if r + 1 > *entry { + *entry = r + 1; + } + if let Some(d) = remaining.get_mut(edge.to.as_str()) { + *d = d.saturating_sub(1); + if *d == 0 { + settled.insert(edge.to.clone()); + queue.push_back(edge.to.clone()); + } + } + } + } + + // Anything left unsettled is part of a cycle: every remaining node + // still has an unsettled predecessor. Break the deadlock by ranking + // the next such node from whichever predecessors are already + // settled (ignoring the back-edge), then resume. + let next = graph.order.iter().find(|id| !settled.contains(id.as_str())); + let Some(next) = next else { break }; + + let predecessor_rank = graph + .edges + .iter() + .filter(|e| &e.to == next && settled.contains(&e.from)) + .filter_map(|e| rank.get(&e.from)) + .max() + .copied(); + let r = predecessor_rank.map(|p| p + 1).unwrap_or(0); + rank.insert(next.clone(), r); + settled.insert(next.clone()); + queue.push_back(next.clone()); + } + + rank +} + +fn simple_chain(graph: &MermaidGraph) -> Option> { + if graph.order.is_empty() || graph.edges.len() != graph.order.len().saturating_sub(1) { + return None; + } + let mut outdeg: HashMap<&str, usize> = HashMap::new(); + let mut indeg: HashMap<&str, usize> = HashMap::new(); + for edge in &graph.edges { + *outdeg.entry(edge.from.as_str()).or_insert(0) += 1; + *indeg.entry(edge.to.as_str()).or_insert(0) += 1; + } + if outdeg.values().any(|&d| d > 1) || indeg.values().any(|&d| d > 1) { + return None; + } + + let start = graph + .order + .iter() + .find(|id| indeg.get(id.as_str()).copied().unwrap_or(0) == 0)?; + + let mut chain = vec![start.clone()]; + let mut current = start.clone(); + let next_of: HashMap<&str, &str> = graph + .edges + .iter() + .map(|e| (e.from.as_str(), e.to.as_str())) + .collect(); + while let Some(next) = next_of.get(current.as_str()) { + chain.push(next.to_string()); + current = next.to_string(); + if chain.len() > graph.order.len() { + return None; // cycle guard + } + } + + if chain.len() == graph.order.len() { + Some(chain) + } else { + None + } +} + +fn edge_between<'a>(graph: &'a MermaidGraph, from: &str, to: &str) -> Option<&'a MEdge> { + graph.edges.iter().find(|e| e.from == from && e.to == to) +} + +fn layout(graph: &MermaidGraph, max_width: usize) -> String { + let mut out = String::new(); + + if graph.direction == Direction::LeftRight + && let Some(chain) = simple_chain(graph) + { + render_horizontal_chain(graph, &chain, max_width, &mut out); + return out; + } + + let rank = rank_of(graph); + let max_rank = rank.values().copied().max().unwrap_or(0); + let mut ranks: Vec> = vec![Vec::new(); max_rank + 1]; + for id in &graph.order { + ranks[rank[id]].push(id.clone()); + } + + let mut rendered_edges = vec![false; graph.edges.len()]; + + for (r, row) in ranks.iter().enumerate() { + render_row(graph, row, &mut out); + + if r + 1 >= ranks.len() { + continue; + } + let next = &ranks[r + 1]; + let edge_indices: Vec = graph + .edges + .iter() + .enumerate() + .filter(|(_, e)| row.contains(&e.from) && next.contains(&e.to)) + .map(|(i, _)| i) + .collect(); + for &i in &edge_indices { + rendered_edges[i] = true; + } + + if row.len() == 1 && next.len() == 1 && edge_indices.len() == 1 { + let edge = &graph.edges[edge_indices[0]]; + let label = arrow_label(&edge.label); + let width = box_display_width(&graph.nodes[&row[0]]); + let pad = width / 2; + out.push_str(&" ".repeat(pad)); + out.push_str(edge_style_arrow(edge.style, false)); + out.push('\n'); + if !label.is_empty() { + out.push_str(&" ".repeat(pad.saturating_sub(visible_width(&label) / 2))); + out.push_str(&label.italic().to_string()); + out.push('\n'); + } + out.push_str(&" ".repeat(pad)); + out.push('โ–ผ'); + out.push('\n'); + } else if !edge_indices.is_empty() { + for &i in &edge_indices { + render_edge_line(&mut out, graph, &graph.edges[i]); + } + out.push('\n'); + } + } + + let leftover: Vec = (0..graph.edges.len()) + .filter(|&i| !rendered_edges[i]) + .collect(); + if !leftover.is_empty() { + out.push_str(&"(other connections)\n".bright_black().to_string()); + for i in leftover { + render_edge_line(&mut out, graph, &graph.edges[i]); + } + } + + out +} + +fn render_edge_line(out: &mut String, graph: &MermaidGraph, edge: &MEdge) { + let from_label = &graph.nodes[&edge.from].label; + let to_label = &graph.nodes[&edge.to].label; + let arrow = edge_style_arrow(edge.style, true); + let label = arrow_label(&edge.label); + if label.is_empty() { + out.push_str(&format!( + " {} {} {}\n", + from_label.bright_black(), + arrow.bright_black(), + to_label.bright_black() + )); + } else { + out.push_str(&format!( + " {} {}[{}]{} {}\n", + from_label.bright_black(), + arrow.bright_black(), + label.italic(), + arrow.bright_black(), + to_label.bright_black() + )); + } +} + +fn box_display_width(node: &MNode) -> usize { + visible_width(&node.label) + 4 +} + +fn render_row(graph: &MermaidGraph, row: &[String], out: &mut String) { + if row.is_empty() { + return; + } + let boxes: Vec> = row + .iter() + .map(|id| render_box(&graph.nodes[id]).0) + .collect(); + + for line_idx in 0..3 { + let mut line = String::new(); + for (i, b) in boxes.iter().enumerate() { + if i > 0 { + line.push_str(" "); + } + line.push_str(&b[line_idx]); + } + out.push_str(&line); + out.push('\n'); + } +} + +fn render_horizontal_chain( + graph: &MermaidGraph, + chain: &[String], + max_width: usize, + out: &mut String, +) { + let mut lines = vec![String::new(), String::new(), String::new()]; + let mut current_width = 0usize; + + for (i, id) in chain.iter().enumerate() { + let (b, w) = render_box(&graph.nodes[id]); + + let mut segment_width = w; + let mut connector: Option<(String, usize)> = None; + if i > 0 { + let prev = &chain[i - 1]; + if let Some(edge) = edge_between(graph, prev, id) { + let arrow = edge_style_arrow(edge.style, true); + let label = arrow_label(&edge.label); + let text = if label.is_empty() { + format!(" {} ", arrow) + } else { + format!(" {}[{}] ", arrow, label) + }; + segment_width += visible_width(&text); + connector = Some((text, w)); + } + } + + if current_width > 0 && current_width + segment_width > max_width { + for l in lines.iter() { + out.push_str(l); + out.push('\n'); + } + out.push('\n'); + lines = vec![String::new(), String::new(), String::new()]; + current_width = 0; + } + + if let Some((text, _)) = connector { + let text_width = visible_width(&text); + lines[1].push_str(&text); + lines[0].push_str(&" ".repeat(text_width)); + lines[2].push_str(&" ".repeat(text_width)); + current_width += text_width; + } + + for (idx, l) in b.iter().enumerate() { + lines[idx].push_str(l); + } + current_width += w; + } + + for l in lines { + out.push_str(&l); + out.push('\n'); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_render_simple_chain_td() { + let result = render("graph TD\nA-->B-->C", 80).unwrap(); + assert!(result.contains('A')); + assert!(result.contains('B')); + assert!(result.contains('C')); + assert!(result.contains('โ–ผ')); + } + + #[test] + fn test_render_simple_chain_lr() { + let result = render("graph LR\nA-->B-->C", 80).unwrap(); + assert!(result.contains('A')); + assert!(result.contains("โ”€โ”€โ–ถ")); + } + + #[test] + fn test_render_labeled_edge() { + let result = render("graph TD\nA-->|yes|B", 80).unwrap(); + assert!(result.contains("yes")); + } + + #[test] + fn test_render_node_shapes_and_labels() { + let result = render("graph TD\nA[Start]-->B{Decision}-->C(End)", 80).unwrap(); + assert!(result.contains("Start")); + assert!(result.contains("Decision")); + assert!(result.contains("End")); + } + + #[test] + fn test_render_branching_falls_back_to_list() { + let result = render("graph TD\nA-->B\nA-->C", 80).unwrap(); + assert!(result.contains('B')); + assert!(result.contains('C')); + } + + #[test] + fn test_render_graph_with_cycle_terminates_and_keeps_all_edges() { + // A regression test for a deadlock in the rank-layering algorithm: + // the back-edge D-->B previously caused Kahn's algorithm to stall + // forever once a node was re-queued infinitely. + let result = render("graph TD\nA-->B\nB-->|Yes|C\nB-->|No|D\nD-->B\nC-->E", 80).unwrap(); + for label in ["A", "B", "C", "D", "E"] { + assert!(result.contains(label), "missing node {label}"); + } + } + + #[test] + fn test_unsupported_diagram_returns_none() { + assert!(render("sequenceDiagram\nAlice->>Bob: Hello", 80).is_none()); + } + + #[test] + fn test_empty_returns_none() { + assert!(render("", 80).is_none()); + } +} diff --git a/src/renderer.rs b/src/renderer.rs index 9b7e758..70a08f4 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -5,6 +5,7 @@ use std::io::{self, Write}; use std::path::Path; use std::sync::LazyLock; use terminal_size::{Height, Width, terminal_size}; +use unicode_width::UnicodeWidthStr; /// Configuration for rendering markdown #[derive(Debug, Clone)] @@ -21,9 +22,6 @@ impl Default for RenderConfig { } } -/// Unicode header symbols (โ‘ โ‘กโ‘ขโ‘ฃโ‘คโ‘ฅ) -const HEADER_SYMBOLS: &[&str] = &["โ‘ ", "โ‘ก", "โ‘ข", "โ‘ฃ", "โ‘ค", "โ‘ฅ"]; - /// Unicode bullet symbols for lists const LIST_BULLETS: &[&str] = &["โ—", "โ—‹", "โ—†", "โ—‡"]; @@ -173,6 +171,133 @@ pub fn render_markdown_to_string(markdown: &Markdown) -> io::Result { String::from_utf8(output).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } +/// Visible column width of a string, ignoring ANSI escape sequences +/// (SGR color codes and OSC 8 hyperlinks) so that box borders and wrapping +/// stay aligned even when the content contains colored or clickable text. +/// Visible runs are measured with their real terminal column width (not a +/// raw `char` count), so wide CJK text and emoji - including multi-codepoint +/// sequences like an emoji + variation selector - line up correctly too. +pub(crate) fn visible_width(s: &str) -> usize { + let mut width = 0; + let mut run = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\x1b' { + width += UnicodeWidthStr::width(run.as_str()); + run.clear(); + match chars.peek() { + Some('[') => { + chars.next(); + for c2 in chars.by_ref() { + if c2.is_ascii_alphabetic() { + break; + } + } + } + Some(']') => { + chars.next(); + while let Some(c2) = chars.next() { + if c2 == '\x07' { + break; + } + if c2 == '\x1b' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } + } + } + _ => {} + } + continue; + } + run.push(c); + } + width += UnicodeWidthStr::width(run.as_str()); + width +} + +/// Greedily word-wrap `s` so each line's visible width fits within `width` +/// columns (ANSI escapes don't count toward the width). +fn wrap_visible(s: &str, width: usize) -> Vec { + if width == 0 || s.trim().is_empty() { + return vec![s.to_string()]; + } + let mut lines = Vec::new(); + let mut current = String::new(); + let mut current_width = 0; + + for word in s.split(' ').filter(|w| !w.is_empty()) { + let word_width = visible_width(word); + if current.is_empty() { + current = word.to_string(); + current_width = word_width; + } else if current_width + 1 + word_width <= width { + current.push(' '); + current.push_str(word); + current_width += 1 + word_width; + } else { + lines.push(current); + current = word.to_string(); + current_width = word_width; + } + } + if !current.is_empty() { + lines.push(current); + } + if lines.is_empty() { + lines.push(String::new()); + } + lines +} + +/// Render `lines` inside a bordered box. Lines are never wrapped (so +/// syntax-highlighted code keeps its original structure); a line wider than +/// the box just overflows past the right border instead of being cut. +fn box_inner_width(header_width: usize) -> usize { + WIDTH.saturating_sub(4).max(header_width + 2) +} + +fn render_boxed_lines( + writer: &mut W, + header: Option<&str>, + color: colored::Color, + lines: &[String], +) -> io::Result<()> { + let header_width = header.map(visible_width).unwrap_or(0); + let inner_width = box_inner_width(header_width); + let border = "โ”€".repeat(inner_width + 2); + + let top = match header { + Some(h) if !h.is_empty() => format!( + "โ”Œโ”€ {} {}โ”", + h, + "โ”€".repeat(inner_width.saturating_sub(header_width + 1)) + ), + _ => format!("โ”Œ{}โ”", border), + }; + writeln!(writer, "{}", top.color(color))?; + + for line in lines { + let w = visible_width(line); + if w <= inner_width { + let pad = inner_width - w; + writeln!( + writer, + "{} {}{} {}", + "โ”‚".color(color), + line, + " ".repeat(pad), + "โ”‚".color(color) + )?; + } else { + writeln!(writer, "{} {}", "โ”‚".color(color), line)?; + } + } + + writeln!(writer, "{}", format!("โ””{}โ”˜", border).color(color))?; + Ok(()) +} + fn detect_callout(text: &str) -> Option<&'static Callout> { let trimmed = text.trim(); if trimmed.starts_with("[!") @@ -212,14 +337,16 @@ fn render_node_inline( writeln!(writer)?; } - let symbol = HEADER_SYMBOLS - .get((heading.depth - 1) as usize) - .unwrap_or(&"โ‘ฅ"); + // Repeat the marker once per heading level (โ–ถ, โ–ถโ–ถ, โ–ถโ–ถโ–ถ, ...) so + // depth stays legible even in fonts that render circled digits + // (โ‘ โ‘กโ‘ข) too small to read at a glance. + let symbol = "โ–ถ".repeat(heading.depth.clamp(1, 6) as usize); let text = render_inline_content(&heading.values); if config.header_full_width_highlight { - let padding = WIDTH.saturating_sub(text.chars().count() + 3); + let padding = + WIDTH.saturating_sub(visible_width(&text) + visible_width(&symbol) + 2); let line = format!("{}{}", text, " ".repeat(padding)); // Full-width background highlighting @@ -329,19 +456,38 @@ fn render_node_inline( } Node::Code(code) => { - write!(writer, "{}", "\n```".bright_black())?; - if let Some(lang) = &code.lang { - write!(writer, "{}", lang.bright_black())?; - } - writeln!(writer)?; + let is_mermaid = code + .lang + .as_deref() + .is_some_and(|lang| lang.eq_ignore_ascii_case("mermaid")); - // Apply syntax highlighting if language is specified - let highlighted = highlighter.highlight(&code.value, code.lang.as_deref()); - write!(writer, "{}", highlighted)?; + let mermaid_diagram = is_mermaid + .then(|| crate::mermaid::render(&code.value, *WIDTH)) + .flatten(); - writeln!(writer)?; - writeln!(writer, "{}", "```".bright_black())?; - writeln!(writer)?; + if let Some(diagram) = mermaid_diagram { + writeln!(writer)?; + write!(writer, "{}", diagram)?; + writeln!(writer)?; + } else { + // Apply syntax highlighting if language is specified + let highlighted = highlighter.highlight(&code.value, code.lang.as_deref()); + let lines: Vec = highlighted + .strip_suffix('\n') + .unwrap_or(&highlighted) + .split('\n') + .map(str::to_string) + .collect(); + + writeln!(writer)?; + render_boxed_lines( + writer, + code.lang.as_deref(), + colored::Color::BrightBlack, + &lines, + )?; + writeln!(writer)?; + } } Node::CodeInline(code) => { @@ -434,11 +580,9 @@ fn render_node_inline( } } } - Node::Text(text) => { - if detect_callout(&text.value).is_some() { - found_callout = true; - break; - } + Node::Text(text) if detect_callout(&text.value).is_some() => { + found_callout = true; + break; } _ => {} } @@ -450,7 +594,7 @@ fn render_node_inline( }; if is_callout { - render_callout_blockquote(blockquote, depth, highlighter, config, writer)?; + render_callout_blockquote(blockquote, writer)?; } else { render_regular_blockquote(blockquote, depth, highlighter, config, writer)?; } @@ -559,121 +703,90 @@ fn render_list( Ok(()) } +/// mq-markdown doesn't consistently wrap inline blockquote content in a +/// `Fragment`: a single-line callout comes through as flat `Text`/`Link`/... +/// nodes directly under `Blockquote`, while other inputs nest them inside a +/// `Fragment`. Flatten one level so callers can treat both shapes the same. +fn flatten_inline(values: &[Node]) -> Vec<&Node> { + let mut out = Vec::new(); + for value in values { + if let Node::Fragment(para) = value { + out.extend(para.values.iter()); + } else { + out.push(value); + } + } + out +} + +/// Render a single inline node's textual content for use inside a callout +/// box (where everything gets re-wrapped to the box width, so embedded +/// line breaks from the original markdown source are normalized to spaces). +fn inline_node_to_text(node: &Node) -> String { + match node { + Node::Text(text) => text.value.replace('\n', " "), + Node::Link(link) => { + let text = render_inline_content(&link.values); + let url = link.url.as_str(); + if text.trim().is_empty() { + format!(" ๐Ÿ”— {}", make_clickable_link(url, url)) + } else { + format!(" ๐Ÿ”— {}", make_clickable_link(url, &text)) + } + } + Node::Break(_) => "\n".to_string(), + other => render_inline_content(std::slice::from_ref(other)), + } +} + fn render_callout_blockquote( blockquote: &mq_markdown::Blockquote, - _depth: usize, - highlighter: &mut SyntaxHighlighter, - config: &RenderConfig, writer: &mut W, ) -> io::Result<()> { - // Find the callout type from any text node in the blockquote - let mut callout_info = None; - let mut callout_text = String::new(); + let inline_nodes = flatten_inline(&blockquote.values); - for value in &blockquote.values { - match value { - Node::Fragment(para) => { - for child in ¶.values { - if let Node::Text(text) = child - && let Some(callout) = detect_callout(&text.value) - { - callout_info = Some(callout); - // Extract content after the callout marker - if let Some(end) = text.value.find(']') { - callout_text = text.value[end + 1..].trim_start().to_string(); - } - break; - } - } - } - Node::Text(text) => { - if let Some(callout) = detect_callout(&text.value) { - callout_info = Some(callout); - if let Some(end) = text.value.find(']') { - callout_text = text.value[end + 1..].trim_start().to_string(); - } - break; - } - } - _ => {} - } - if callout_info.is_some() { - break; - } - } + // Find the marker node and the callout type it declares. + let marker_idx = inline_nodes + .iter() + .position(|n| matches!(n, Node::Text(t) if detect_callout(&t.value).is_some())); + let Some(marker_idx) = marker_idx else { + return Ok(()); + }; + let Node::Text(marker_text) = inline_nodes[marker_idx] else { + unreachable!() + }; + let Some(callout) = detect_callout(&marker_text.value) else { + unreachable!() + }; - if let Some(callout) = callout_info { - // Print the callout header - let header = format!("{} {}", callout.icon, callout.name) - .color(callout.color) - .bold(); - writeln!(writer, "โ”Œโ”€ {}", header)?; + // Build one continuous string for the body: the part of the marker text + // after `]`, followed by every later inline node's text. Soft line + // breaks inside source text are normalized to spaces; explicit `Break` + // nodes become paragraph separators (kept as `\n`) for re-wrapping. + let mut body = String::new(); + if let Some(end) = marker_text.value.find(']') { + body.push_str(&marker_text.value[end + 1..].replace('\n', " ")); + } + for node in &inline_nodes[marker_idx + 1..] { + body.push_str(&inline_node_to_text(node)); + } - // Print the content - if !callout_text.is_empty() { - writeln!(writer, "โ”‚ {}", callout_text)?; + let mut content_lines: Vec = Vec::new(); + for paragraph in body.split('\n') { + if paragraph.trim().is_empty() { + continue; } + content_lines.push(paragraph.trim().to_string()); + } - // Print remaining content from blockquote - let mut found_callout_marker = false; - for value in &blockquote.values { - match value { - Node::Fragment(para) => { - let mut line_content = String::new(); - for child in ¶.values { - match child { - Node::Text(text) => { - if !found_callout_marker && detect_callout(&text.value).is_some() { - found_callout_marker = true; - // Skip the callout marker part - if let Some(end) = text.value.find(']') { - let remaining = text.value[end + 1..].trim_start(); - if !remaining.is_empty() { - line_content.push_str(remaining); - } - } - } else { - line_content.push_str(&text.value); - } - } - Node::Link(link) => { - let text = render_inline_content(&link.values); - let url = link.url.as_str(); - if text.trim().is_empty() { - line_content.push_str(&format!( - " ๐Ÿ”— {}", - make_clickable_link(url, url) - )); - } else { - line_content.push_str(&format!( - " ๐Ÿ”— {}", - make_clickable_link(url, &text) - )); - } - } - _ => { - // Handle all other inline formatting - line_content - .push_str(&render_inline_content(std::slice::from_ref(child))); - } - } - } - if !line_content.trim().is_empty() && found_callout_marker { - writeln!(writer, "โ”‚ {}", line_content)?; - } - } - _ => { - if found_callout_marker { - write!(writer, "โ”‚ ")?; - render_node_inline(value, 0, false, highlighter, config, writer)?; - } - } - } - } + let header_text = format!("{} {}", callout.icon, callout.name); + let inner_width = box_inner_width(visible_width(&header_text)); + let wrapped_lines: Vec = content_lines + .iter() + .flat_map(|line| wrap_visible(line, inner_width)) + .collect(); - writeln!(writer, "โ””โ”€")?; - } - Ok(()) + render_boxed_lines(writer, Some(&header_text), callout.color, &wrapped_lines) } fn render_regular_blockquote( @@ -782,7 +895,7 @@ fn render_table( } // Pad with spaces to align columns - let content_width = content.chars().count(); + let content_width = visible_width(&content); if content_width < width { write!(writer, "{}", " ".repeat(width - content_width))?; } @@ -841,7 +954,7 @@ fn calculate_column_widths(nodes: &[Node]) -> Vec { for (col_idx, cell_node) in row.values.iter().enumerate() { if let Node::TableCell(cell) = cell_node { let content = render_inline_content(&cell.values); - let width = content.chars().count(); + let width = visible_width(&content); if col_idx >= column_widths.len() { column_widths.resize(col_idx + 1, 0); @@ -852,7 +965,7 @@ fn calculate_column_widths(nodes: &[Node]) -> Vec { } Node::TableCell(cell) => { let content = render_inline_content(&cell.values); - let width = content.chars().count(); + let width = visible_width(&content); if cell.column >= column_widths.len() { column_widths.resize(cell.column + 1, 0); @@ -949,7 +1062,7 @@ fn render_table_row( } // Pad with spaces to align columns - let content_width = content.chars().count(); + let content_width = visible_width(&content); if content_width < width { write!(writer, "{}", " ".repeat(width - content_width))?; } @@ -979,7 +1092,7 @@ fn render_table_cell( } // Pad with spaces to align columns - let content_width = content.chars().count(); + let content_width = visible_width(&content); if content_width < width { write!(writer, "{}", " ".repeat(width - content_width))?; } @@ -1044,6 +1157,19 @@ mod tests { assert!(result.contains("Heading 6")); } + #[test] + fn test_heading_full_width_highlight_padding_accounts_for_symbol_and_links() { + // Regression test: the full-width background bar must reach exactly + // `WIDTH` visible columns regardless of heading depth (the "โ–ถ" + // marker is repeated per level, so its width isn't always 1) and + // regardless of embedded OSC 8 hyperlink escapes inflating the raw + // string length without affecting what's actually printed. + let markdown: Markdown = "## [Linked Heading](https://example.com)".parse().unwrap(); + let result = render_markdown_to_string(&markdown).unwrap(); + let line = result.lines().find(|l| !l.trim().is_empty()).unwrap(); + assert_eq!(visible_width(line), *WIDTH); + } + #[test] fn test_render_markdown_to_string_list() { let markdown: Markdown = "- Item 1\n- Item 2\n- Item 3".parse().unwrap();