From b0e19d5d03e6ff438c4476ceb2bbfb238e782bb3 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 14 Nov 2026 19:16:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(project):=20markdown.outline=20=E6=8C=89?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E8=BE=93=E5=87=BA=20Markdown=20=E7=9A=84?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E7=BB=93=E6=9E=84=E6=A0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用与 markdown.render 相同的 Comrak 方言解析,把标题按级别组织成与 LSP documentSymbol 归一化结果同形的符号树:selectionRange 是标题本身,range 是到下一个同级或更高级标题的整节;位置为零起始行和 UTF-16 列。宿主在没有语言服务器时也能为 Markdown 文件显示结构,且与预览页的目录一致。共享样例 rust/fixtures/editor/markdown-outline-v1.json。 --- rust/Cargo.lock | 1 + rust/contracts/rust-core-api.md | 37 ++ rust/crates/core/src/protocol/command.rs | 3 + rust/crates/core/src/runtime/dispatcher.rs | 22 +- rust/crates/core/src/runtime/request_trace.rs | 1 + rust/crates/core/src/tests/project.rs | 33 ++ rust/crates/project/Cargo.toml | 3 +- rust/crates/project/src/lib.rs | 4 +- rust/crates/project/src/markdown.rs | 22 +- rust/crates/project/src/markdown_outline.rs | 359 ++++++++++++++++++ rust/fixtures/editor/markdown-outline-v1.json | 189 +++++++++ 11 files changed, 662 insertions(+), 12 deletions(-) create mode 100644 rust/crates/project/src/markdown_outline.rs create mode 100644 rust/fixtures/editor/markdown-outline-v1.json diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 43f0a3a..2f6dda1 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1385,6 +1385,7 @@ dependencies = [ "lithe-git", "lithe-protocol", "lithe-request-context", + "lithe-text", "lithe-workspace", "regex", "serde", diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index be3a972..e5b4b7c 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -551,6 +551,7 @@ or removing a command fails until the table changes in the same commit. | `processLifecycle.inventory` | Group a process snapshot of one Lithe instance into terminals, Agent tasks and background services | | `processLifecycle.settle` | Turn confirmation answers into what ends, what is kept and the updated settings | | `markdown.render` | Renders and sanitizes shared Markdown | +| `markdown.outline` | Outlines the headings of shared Markdown as a document-symbol tree | | `markdown.resolveReference` | Decides what a link or an image of a rendered Markdown document points at | | `keymap.parseContext` | Compile a bounded boolean shortcut context (from `lithe-core-rules`) | | `keymap.evaluateContext` | Evaluate a precompiled shortcut context (from `lithe-core-rules`) | @@ -5559,6 +5560,42 @@ load what this command refuses. Shared examples: `rust/fixtures/editor/markdown-render-v1.json`, `markdown-html-v1.json` and `markdown-rich-content-v1.json`. +### Markdown heading outline + +`markdown.outline` accepts `{ "source": "" }` and returns +`{ "symbols": [...] }`, the headings of the source as a tree in the normalized +document-symbol shape `lsp.request` returns for `textDocument/documentSymbol`. +Hosts show it where they show a language server's symbols, so a Markdown file +has a structure without a language server, and it lists the headings the +preview's outline lists: the source is parsed with the dialect of +`markdown.render`. + +- Every heading Comrak parses is listed in document order, including ATX and + setext headings inside block quotes, lists and alerts. Front matter, code and + raw HTML headings such as `

` are not headings. +- `name` is the heading's text as the preview shows it: text, inline code, math + literals and shortcode emoji, without images, footnote references and HTML + tags, with each run of whitespace collapsed to one space and the ends trimmed. + A heading whose name is empty is left out, and its section belongs to the + heading before it. +- A heading is a child of the nearest heading before it with a lower level, so a + skipped level nests one step. +- `kind` is `15` (`SymbolKind.String`, as editors mark Markdown headings); + `detail` and `containerName` are `null`, `tags` is empty and `deprecated` is + `false`. +- Positions are zero-based lines and UTF-16 columns (`{ line, utf16Column }`); + `\n`, `\r\n` and a lone `\r` end a line. `selectionRange` covers the heading + from its first character to just after its last, a setext heading's underline + included. `range` is its section: from the start of its first line to the start + of the line of the next heading of the same or a higher level, or to the end of + the source. + +Hosts request the outline off their window thread, discard a response to an +older revision of the source, and bound the source size they send as they bound +the preview's. + +Shared examples: `rust/fixtures/editor/markdown-outline-v1.json`. + ### Structured logging of recoverable LSP maintenance warnings `lsp.pollEvents` log observations may carry the existing `error` object even at diff --git a/rust/crates/core/src/protocol/command.rs b/rust/crates/core/src/protocol/command.rs index d6f972f..ac897bd 100644 --- a/rust/crates/core/src/protocol/command.rs +++ b/rust/crates/core/src/protocol/command.rs @@ -126,6 +126,8 @@ pub enum CoreCommand { ProcessLifecycleSettle, /// Renders and sanitizes shared Markdown (`markdown.render`). MarkdownRender, + /// Outlines the headings of shared Markdown as document symbols (`markdown.outline`). + MarkdownOutline, /// Decides what a link or an image of a rendered Markdown document points at /// (`markdown.resolveReference`). MarkdownResolveReference, @@ -1187,6 +1189,7 @@ impl CoreCommand { "processLifecycle.inventory" => Some(Self::ProcessLifecycleInventory), "processLifecycle.settle" => Some(Self::ProcessLifecycleSettle), "markdown.render" => Some(Self::MarkdownRender), + "markdown.outline" => Some(Self::MarkdownOutline), "markdown.resolveReference" => Some(Self::MarkdownResolveReference), "keymap.parseContext" => Some(Self::Keymap(KeymapCommand::ParseContext)), "keymap.evaluateContext" => Some(Self::Keymap(KeymapCommand::EvaluateContext)), diff --git a/rust/crates/core/src/runtime/dispatcher.rs b/rust/crates/core/src/runtime/dispatcher.rs index c94b12c..7480c1b 100644 --- a/rust/crates/core/src/runtime/dispatcher.rs +++ b/rust/crates/core/src/runtime/dispatcher.rs @@ -4,8 +4,8 @@ //! child module named after it; the remaining small families dispatch here. use crate::project::{ - self, DocumentLifecycleRequest, FileReadRequest, FileWriteRequest, MarkdownReferenceRequest, - MarkdownRenderRequest, + self, DocumentLifecycleRequest, FileReadRequest, FileWriteRequest, MarkdownOutlineRequest, + MarkdownReferenceRequest, MarkdownRenderRequest, }; use crate::protocol::{CoreCommand, CoreError, CoreRequest, CoreResponse, ErrorCode, GitCommand}; use lithe_github::{NormalizeResponseRequest, ParseRemoteRequest, RequestPlanRequest}; @@ -456,6 +456,24 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::MarkdownOutline => { + match serde_json::from_value::(parsed.payload).map_err( + |error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Markdown outline request", + ) + .with_details(error.to_string()) + }, + ) { + Ok(request) => CoreResponse::success( + id, + serde_json::to_value(crate::project::outline(request)) + .expect("Markdown outline response should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::MarkdownResolveReference => { match serde_json::from_value::(parsed.payload).map_err( |error| { diff --git a/rust/crates/core/src/runtime/request_trace.rs b/rust/crates/core/src/runtime/request_trace.rs index ac7c245..3fec126 100644 --- a/rust/crates/core/src/runtime/request_trace.rs +++ b/rust/crates/core/src/runtime/request_trace.rs @@ -154,6 +154,7 @@ pub(crate) fn is_hot(method: &str) -> bool { "document.classifyText" | "document.lifecycle" | "markdown.render" + | "markdown.outline" | "markdown.resolveReference" | "workspace.classifyVisibility" | "workspace.visibilityDefaults" diff --git a/rust/crates/core/src/tests/project.rs b/rust/crates/core/src/tests/project.rs index 11a220f..591429d 100644 --- a/rust/crates/core/src/tests/project.rs +++ b/rust/crates/core/src/tests/project.rs @@ -926,6 +926,39 @@ fn markdown_render_command_returns_sanitized_html() { assert!(!html.contains("alert(1)")); } +#[test] +fn markdown_outline_command_answers_the_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../fixtures/editor/markdown-outline-v1.json" + )) + .expect("Markdown outline fixture should decode"); + for case in fixture["cases"].as_array().expect("fixture cases") { + let request = serde_json::json!({ + "id": "markdown-outline-1", + "command": "markdown.outline", + "payload": {"source": case["source"]} + }); + let response: Value = serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("outline request should encode"), + )) + .expect("outline response should be JSON"); + + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "{}", case["name"]); + } +} + +#[test] +fn markdown_outline_command_rejects_a_payload_without_source() { + let response: Value = serde_json::from_str(&execute_json( + r#"{"id":"markdown-outline-2","command":"markdown.outline","payload":{}}"#, + )) + .expect("outline response should be JSON"); + + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "invalid_request"); +} + #[test] fn markdown_resolve_reference_command_answers_links_and_resources() { let resolve = |reference: &str, purpose: &str| -> Value { diff --git a/rust/crates/project/Cargo.toml b/rust/crates/project/Cargo.toml index c0393e0..15f55d4 100644 --- a/rust/crates/project/Cargo.toml +++ b/rust/crates/project/Cargo.toml @@ -3,7 +3,7 @@ name = "lithe-project" version = "0.1.0" edition = "2021" license = "Apache-2.0" -description = "Project services for Lithe: workspace files, indexing policy, search, local history and document rendering" +description = "Project services for Lithe: workspace files, indexing policy, search, local history, document rendering and Markdown outlines" [features] # Lets lithe-core's tests trace which directories a workspace scan enumerates. @@ -19,6 +19,7 @@ lithe-diagnostics = { path = "../diagnostics" } lithe-git = { path = "../git" } lithe-protocol = { path = "../protocol" } lithe-request-context = { path = "../request-context" } +lithe-text = { path = "../text" } lithe-workspace = { path = "../workspace" } regex = "1.11" serde = { version = "1.0", features = ["derive"] } diff --git a/rust/crates/project/src/lib.rs b/rust/crates/project/src/lib.rs index b2631a6..ab21858 100644 --- a/rust/crates/project/src/lib.rs +++ b/rust/crates/project/src/lib.rs @@ -1,5 +1,5 @@ //! Project services: workspace files and visibility, indexing policy, search, local history, -//! document persistence decisions and Markdown rendering. +//! document persistence decisions, and Markdown rendering and heading outlines. //! //! Language-server sessions sit above this crate. Calls that change indexing or the search index //! take an `update_providers` callback, which lithe-core uses to tell running sessions which @@ -16,6 +16,7 @@ pub mod files; mod history; pub mod indexing; mod markdown; +mod markdown_outline; mod markdown_reference; mod name_matching; pub mod name_ranking; @@ -27,6 +28,7 @@ pub use document_lifecycle::*; pub use files::*; pub use history::*; pub use markdown::*; +pub use markdown_outline::*; pub use markdown_reference::*; pub use search_paging::{search_page, SearchPageRequest}; diff --git a/rust/crates/project/src/markdown.rs b/rust/crates/project/src/markdown.rs index 7a10294..92eb00d 100644 --- a/rust/crates/project/src/markdown.rs +++ b/rust/crates/project/src/markdown.rs @@ -195,13 +195,10 @@ pub struct MarkdownRenderResponse { pub html: String, } -/// Renders the shared Markdown dialect used by every Lithe frontend. -/// -/// Raw HTML is deliberately enabled in Comrak so useful document elements such -/// as `
` survive parsing, then the complete fragment is passed through -/// Ammonia. This keeps formatting HTML while removing executable content, -/// event handlers, embeds, and unsafe URL schemes. -pub fn render(request: MarkdownRenderRequest) -> MarkdownRenderResponse { +/// The Comrak options of the shared dialect. Rendering and the heading outline +/// parse with the same options, so a heading the preview shows is a heading the +/// outline lists, on the same source line. +pub(crate) fn dialect() -> Options<'static> { let mut options = Options::default(); options.extension.strikethrough = true; // The final Ammonia pass is the security boundary. Leaving tagfilter off @@ -226,8 +223,17 @@ pub fn render(request: MarkdownRenderRequest) -> MarkdownRenderResponse { options.extension.header_id_prefix_in_href = true; options.render.r#unsafe = true; options.render.sourcepos = true; + options +} - let rendered = markdown_to_html(&request.source, &options); +/// Renders the shared Markdown dialect used by every Lithe frontend. +/// +/// Raw HTML is deliberately enabled in Comrak so useful document elements such +/// as `
` survive parsing, then the complete fragment is passed through +/// Ammonia. This keeps formatting HTML while removing executable content, +/// event handlers, embeds, and unsafe URL schemes. +pub fn render(request: MarkdownRenderRequest) -> MarkdownRenderResponse { + let rendered = markdown_to_html(&request.source, &dialect()); let mut sanitizer = Builder::default(); sanitizer .add_tags(&["section", "tfoot", "input", "audio", "video", "source"]) diff --git a/rust/crates/project/src/markdown_outline.rs b/rust/crates/project/src/markdown_outline.rs new file mode 100644 index 0000000..6cde5a3 --- /dev/null +++ b/rust/crates/project/src/markdown_outline.rs @@ -0,0 +1,359 @@ +//! Heading outline of a document in the Markdown dialect shared by every +//! frontend. +//! +//! Hosts show Markdown headings where they show a language server's document +//! symbols, so the outline takes the normalized document-symbol shape of +//! `lsp.request` for `textDocument/documentSymbol`: zero-based lines and UTF-16 +//! columns, with each heading's section as its range. + +use crate::markdown::dialect; +use comrak::nodes::{AstNode, NodeHeading, NodeValue, Sourcepos}; +use comrak::{parse_document, Arena}; +use lithe_text::{LspPositionResponse, LspRangeResponse}; +use serde::{Deserialize, Serialize}; + +/// LSP `SymbolKind.String`, the kind editors give Markdown headings. +pub const MARKDOWN_HEADING_SYMBOL_KIND: i64 = 15; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Markdown source to outline with the shared dialect. +pub struct MarkdownOutlineRequest { + /// Markdown text to outline. + pub source: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// The headings of a Markdown document as a tree of document symbols. +pub struct MarkdownOutlineResponse { + /// Top-level headings in document order, each holding the headings under it. + pub symbols: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// One heading in the normalized document-symbol shape. +pub struct MarkdownOutlineSymbol { + /// Heading text as the preview shows it, with runs of whitespace collapsed. + pub name: String, + /// Always `None`; present so the symbol has every normalized field. + pub detail: Option, + /// Always [`MARKDOWN_HEADING_SYMBOL_KIND`]. + pub kind: i64, + /// Always empty; headings carry no symbol tags. + pub tags: Vec, + /// Always `false`. + pub deprecated: bool, + /// The heading's section: from the start of its first line to the start of + /// the line of the next heading of the same or a higher level, or to the end + /// of the document. + pub range: LspRangeResponse, + /// The heading itself, from its first character to just after its last, + /// underline included for a setext heading. + pub selection_range: LspRangeResponse, + /// Always `None`; the tree carries the hierarchy. + pub container_name: Option, + /// The headings directly under this one, in document order. + pub children: Vec, +} + +/// Outlines the headings of `request.source`. +/// +/// Every heading Comrak parses with the shared dialect is listed in document +/// order, including headings inside block quotes, lists and alerts; front matter +/// and raw HTML headings are not headings. A heading whose text is empty after +/// collapsing whitespace is left out, and its section belongs to the heading +/// before it. A heading is a child of the nearest heading before it with a lower +/// level, so a skipped level nests one step. +pub fn outline(request: MarkdownOutlineRequest) -> MarkdownOutlineResponse { + let source = request.source.as_str(); + let arena = Arena::new(); + let root = parse_document(&arena, source, &dialect()); + let lines = SourceLines::new(source); + let document_end = lines.end(); + + let mut symbols = Vec::new(); + let mut open: Vec<(u8, MarkdownOutlineSymbol)> = Vec::new(); + for node in root.descendants() { + let (level, sourcepos) = { + let data = node.data(); + let NodeValue::Heading(NodeHeading { level, .. }) = data.value else { + continue; + }; + (level, data.sourcepos) + }; + let name = heading_name(node); + if name.is_empty() { + continue; + } + let selection_range = lines.range(sourcepos); + let section_start = LspPositionResponse { + line: selection_range.start.line, + utf16_column: 0, + }; + while open + .last() + .is_some_and(|(open_level, _)| *open_level >= level) + { + let (_, mut closed) = open.pop().expect("open heading"); + closed.range.end = section_start; + attach(&mut open, &mut symbols, closed); + } + open.push(( + level, + MarkdownOutlineSymbol { + name, + detail: None, + kind: MARKDOWN_HEADING_SYMBOL_KIND, + tags: Vec::new(), + deprecated: false, + range: LspRangeResponse { + start: section_start, + end: document_end, + }, + selection_range, + container_name: None, + children: Vec::new(), + }, + )); + } + while let Some((_, closed)) = open.pop() { + attach(&mut open, &mut symbols, closed); + } + MarkdownOutlineResponse { symbols } +} + +/// Adds a closed heading to the heading still open above it, or to the top level. +fn attach( + open: &mut [(u8, MarkdownOutlineSymbol)], + symbols: &mut Vec, + symbol: MarkdownOutlineSymbol, +) { + match open.last_mut() { + Some((_, parent)) => parent.children.push(symbol), + None => symbols.push(symbol), + } +} + +/// The text of a heading as the preview shows it: text, code, math and emoji, +/// without images, footnote references or HTML tags, with every run of +/// whitespace collapsed to one space and the ends trimmed. +fn heading_name<'a>(heading: &'a AstNode<'a>) -> String { + let mut text = String::new(); + append_text(heading, &mut text); + text.split_whitespace().collect::>().join(" ") +} + +fn append_text<'a>(node: &'a AstNode<'a>, text: &mut String) { + for child in node.children() { + match &child.data().value { + NodeValue::Text(literal) => text.push_str(literal), + NodeValue::Code(code) => text.push_str(&code.literal), + NodeValue::Math(math) => text.push_str(&math.literal), + NodeValue::ShortCode(code) => text.push_str(&code.emoji), + NodeValue::SoftBreak | NodeValue::LineBreak => text.push(' '), + NodeValue::Image(_) + | NodeValue::FootnoteReference(_) + | NodeValue::HtmlInline(_) + | NodeValue::Raw(_) => {} + _ => append_text(child, text), + } + } +} + +/// The lines of a source as Comrak counts them, to turn its one-based line and +/// UTF-8 byte columns into zero-based lines and UTF-16 columns. +struct SourceLines<'s> { + /// Each line's text without its line ending. `\n`, `\r\n` and a lone `\r` + /// end a line; a source that ends with one has an empty last line. + lines: Vec<&'s str>, +} + +impl<'s> SourceLines<'s> { + fn new(source: &'s str) -> Self { + let bytes = source.as_bytes(); + let mut lines = Vec::new(); + let mut start = 0; + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'\n' => { + lines.push(&source[start..index]); + start = index + 1; + } + b'\r' => { + lines.push(&source[start..index]); + if bytes.get(index + 1) == Some(&b'\n') { + index += 1; + } + start = index + 1; + } + _ => {} + } + index += 1; + } + lines.push(&source[start..]); + Self { lines } + } + + /// The position just after the last character of the source. + fn end(&self) -> LspPositionResponse { + let last = self.lines.len() - 1; + LspPositionResponse { + line: last as i64, + utf16_column: utf16_len(self.lines[last]), + } + } + + /// The half-open range of a node whose source position Comrak reports from + /// its first character to its last, both inclusive. + fn range(&self, sourcepos: Sourcepos) -> LspRangeResponse { + LspRangeResponse { + start: self.position(sourcepos.start.line, sourcepos.start.column - 1, false), + end: self.position(sourcepos.end.line, sourcepos.end.column, true), + } + } + + /// The position of the byte `column` (from zero) of the one-based `line`, + /// clamped to the source and moved to a character boundary: forward for the + /// end of a range, so it takes in a character that `column` falls inside. + fn position(&self, line: usize, column: usize, end: bool) -> LspPositionResponse { + let index = line.saturating_sub(1).min(self.lines.len() - 1); + let text = self.lines[index]; + let mut byte = column.min(text.len()); + while !text.is_char_boundary(byte) { + if end { + byte += 1; + } else { + byte -= 1; + } + } + LspPositionResponse { + line: index as i64, + utf16_column: utf16_len(&text[..byte]), + } + } +} + +fn utf16_len(text: &str) -> i64 { + text.encode_utf16().count() as i64 +} + +#[cfg(test)] +mod tests { + use super::{outline, MarkdownOutlineRequest, MARKDOWN_HEADING_SYMBOL_KIND}; + use serde_json::{json, Value}; + + fn symbols(source: &str) -> Value { + serde_json::to_value( + outline(MarkdownOutlineRequest { + source: source.to_owned(), + }) + .symbols, + ) + .expect("outline should encode") + } + + /// Names of the tree as `name(children…)`, to compare its shape at a glance. + fn shape(symbols: &Value) -> String { + symbols + .as_array() + .expect("symbols") + .iter() + .map(|symbol| { + let name = symbol["name"].as_str().expect("name"); + let children = shape(&symbol["children"]); + if children.is_empty() { + name.to_owned() + } else { + format!("{name}({children})") + } + }) + .collect::>() + .join(" ") + } + + #[test] + fn satisfies_shared_outline_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../fixtures/editor/markdown-outline-v1.json" + )) + .expect("Markdown outline fixture should decode"); + for case in fixture["cases"].as_array().expect("fixture cases") { + let name = case["name"].as_str().expect("case name"); + let source = case["source"].as_str().expect("case source"); + assert_eq!(symbols(source), case["expected"]["symbols"], "{name}"); + } + } + + #[test] + fn skipped_levels_nest_once_and_sections_end_at_the_next_peer() { + let output = symbols("# A\n### B\n## C\ntext\n# D\n"); + + assert_eq!(shape(&output), "A(B C) D"); + assert_eq!(output[0]["kind"], MARKDOWN_HEADING_SYMBOL_KIND); + assert_eq!( + output[0]["range"], + json!({"start": {"line": 0, "utf16Column": 0}, "end": {"line": 4, "utf16Column": 0}}) + ); + assert_eq!( + output[0]["children"][0]["range"]["end"], + json!({"line": 2, "utf16Column": 0}) + ); + assert_eq!( + output[1]["range"]["end"], + json!({"line": 5, "utf16Column": 0}) + ); + } + + #[test] + fn headings_inside_containers_are_listed_but_code_and_html_are_not() { + let output = symbols( + "# Top\n\n> ## Quoted\n\n- ### In list\n\n```\n# not a heading\n```\n\n

Raw

\n\n # indented code\n", + ); + + assert_eq!(shape(&output), "Top(Quoted(In list))"); + assert_eq!( + output[0]["children"][0]["selectionRange"]["start"], + json!({"line": 2, "utf16Column": 2}) + ); + } + + #[test] + fn empty_headings_are_left_out_and_their_section_joins_the_one_before() { + let output = symbols("# A\n##\n### B\n# \n## C\n"); + + assert_eq!(shape(&output), "A(B C)"); + } + + #[test] + fn names_keep_what_the_preview_shows_and_drop_images_and_markup() { + let output = symbols( + "# ![logo](logo.png) **Bold** `code` small :smile: $x^2$ note[^1]\n\n[^1]: Footnote\n", + ); + + assert_eq!(output[0]["name"], "Bold code small 😄 x^2 note"); + } + + #[test] + fn empty_source_has_no_symbols() { + assert_eq!(symbols(""), json!([])); + assert_eq!(symbols("plain text\n"), json!([])); + } + + #[test] + fn lone_carriage_returns_end_lines_as_comrak_counts_them() { + let output = symbols("# A\r## B\rtext"); + + assert_eq!(shape(&output), "A(B)"); + assert_eq!( + output[0]["children"][0]["selectionRange"]["start"], + json!({"line": 1, "utf16Column": 0}) + ); + assert_eq!( + output[0]["range"]["end"], + json!({"line": 2, "utf16Column": 4}) + ); + } +} diff --git a/rust/fixtures/editor/markdown-outline-v1.json b/rust/fixtures/editor/markdown-outline-v1.json new file mode 100644 index 0000000..0791b12 --- /dev/null +++ b/rust/fixtures/editor/markdown-outline-v1.json @@ -0,0 +1,189 @@ +{ + "version": 1, + "cases": [ + { + "name": "ATX and setext headings nest by level, each section running to the next heading of the same or a higher level", + "source": "# Title\n\nIntro\n\nSetext Two\n----------\n\n### Three\n\nBody\n\n## Back\n", + "expected": { + "symbols": [ + { + "name": "Title", + "detail": null, + "kind": 15, + "tags": [], + "deprecated": false, + "range": {"start": {"line": 0, "utf16Column": 0}, "end": {"line": 12, "utf16Column": 0}}, + "selectionRange": {"start": {"line": 0, "utf16Column": 0}, "end": {"line": 0, "utf16Column": 7}}, + "containerName": null, + "children": [ + { + "name": "Setext Two", + "detail": null, + "kind": 15, + "tags": [], + "deprecated": false, + "range": {"start": {"line": 4, "utf16Column": 0}, "end": {"line": 11, "utf16Column": 0}}, + "selectionRange": {"start": {"line": 4, "utf16Column": 0}, "end": {"line": 5, "utf16Column": 10}}, + "containerName": null, + "children": [ + { + "name": "Three", + "detail": null, + "kind": 15, + "tags": [], + "deprecated": false, + "range": {"start": {"line": 7, "utf16Column": 0}, "end": {"line": 11, "utf16Column": 0}}, + "selectionRange": {"start": {"line": 7, "utf16Column": 0}, "end": {"line": 7, "utf16Column": 9}}, + "containerName": null, + "children": [] + } + ] + }, + { + "name": "Back", + "detail": null, + "kind": 15, + "tags": [], + "deprecated": false, + "range": {"start": {"line": 11, "utf16Column": 0}, "end": {"line": 12, "utf16Column": 0}}, + "selectionRange": {"start": {"line": 11, "utf16Column": 0}, "end": {"line": 11, "utf16Column": 7}}, + "containerName": null, + "children": [] + } + ] + } + ] + } + }, + { + "name": "front matter keeps source lines and is not a heading", + "source": "---\ntitle: '# Not a heading'\n---\n\n# Real\n", + "expected": { + "symbols": [ + { + "name": "Real", + "detail": null, + "kind": 15, + "tags": [], + "deprecated": false, + "range": {"start": {"line": 4, "utf16Column": 0}, "end": {"line": 5, "utf16Column": 0}}, + "selectionRange": {"start": {"line": 4, "utf16Column": 0}, "end": {"line": 4, "utf16Column": 6}}, + "containerName": null, + "children": [] + } + ] + } + }, + { + "name": "block quote heading with CJK and emoji counts UTF-16 columns; inline markup and shortcodes become text", + "source": "> # 中文😀标题\n\n## `code` *em* :tada:\n", + "expected": { + "symbols": [ + { + "name": "中文😀标题", + "detail": null, + "kind": 15, + "tags": [], + "deprecated": false, + "range": {"start": {"line": 0, "utf16Column": 0}, "end": {"line": 3, "utf16Column": 0}}, + "selectionRange": {"start": {"line": 0, "utf16Column": 2}, "end": {"line": 0, "utf16Column": 10}}, + "containerName": null, + "children": [ + { + "name": "code em 🎉", + "detail": null, + "kind": 15, + "tags": [], + "deprecated": false, + "range": {"start": {"line": 2, "utf16Column": 0}, "end": {"line": 3, "utf16Column": 0}}, + "selectionRange": {"start": {"line": 2, "utf16Column": 0}, "end": {"line": 2, "utf16Column": 21}}, + "containerName": null, + "children": [] + } + ] + } + ] + } + }, + { + "name": "CRLF line endings", + "source": "# A\r\n\r\n## B\r\n", + "expected": { + "symbols": [ + { + "name": "A", + "detail": null, + "kind": 15, + "tags": [], + "deprecated": false, + "range": {"start": {"line": 0, "utf16Column": 0}, "end": {"line": 3, "utf16Column": 0}}, + "selectionRange": {"start": {"line": 0, "utf16Column": 0}, "end": {"line": 0, "utf16Column": 3}}, + "containerName": null, + "children": [ + { + "name": "B", + "detail": null, + "kind": 15, + "tags": [], + "deprecated": false, + "range": {"start": {"line": 2, "utf16Column": 0}, "end": {"line": 3, "utf16Column": 0}}, + "selectionRange": {"start": {"line": 2, "utf16Column": 0}, "end": {"line": 2, "utf16Column": 4}}, + "containerName": null, + "children": [] + } + ] + } + ] + } + }, + { + "name": "a skipped level nests once; code, raw HTML and empty headings are not listed", + "source": "# A\n### B\n\n```\n# code\n```\n\n

Raw

\n\n##\n## C\n", + "expected": { + "symbols": [ + { + "name": "A", + "detail": null, + "kind": 15, + "tags": [], + "deprecated": false, + "range": {"start": {"line": 0, "utf16Column": 0}, "end": {"line": 11, "utf16Column": 0}}, + "selectionRange": {"start": {"line": 0, "utf16Column": 0}, "end": {"line": 0, "utf16Column": 3}}, + "containerName": null, + "children": [ + { + "name": "B", + "detail": null, + "kind": 15, + "tags": [], + "deprecated": false, + "range": {"start": {"line": 1, "utf16Column": 0}, "end": {"line": 10, "utf16Column": 0}}, + "selectionRange": {"start": {"line": 1, "utf16Column": 0}, "end": {"line": 1, "utf16Column": 5}}, + "containerName": null, + "children": [] + }, + { + "name": "C", + "detail": null, + "kind": 15, + "tags": [], + "deprecated": false, + "range": {"start": {"line": 10, "utf16Column": 0}, "end": {"line": 11, "utf16Column": 0}}, + "selectionRange": {"start": {"line": 10, "utf16Column": 0}, "end": {"line": 10, "utf16Column": 4}}, + "containerName": null, + "children": [] + } + ] + } + ] + } + }, + { + "name": "no headings", + "source": "", + "expected": { + "symbols": [] + } + } + ] +}