From b8ce0d9273b961c93baaa248bde6e6a470810936 Mon Sep 17 00:00:00 2001 From: kunlinglio <142668432+kunlinglio@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:20:51 +0800 Subject: [PATCH 1/3] Added support for extracting paths from comments that use markdown syntax --- CHANGELOG.md | 1 + README.md | 2 +- src/document/mod.rs | 10 +- src/parser/tree_sitter/mod.rs | 287 +++++++++++++++++++----- src/parser/tree_sitter/ts_dockerfile.rs | 18 ++ src/parser/tree_sitter/ts_general.rs | 28 +++ src/parser/tree_sitter/ts_html.rs | 18 ++ src/parser/tree_sitter/ts_markdown.rs | 17 ++ 8 files changed, 323 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a0d8bd..6cf4e1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the Path Server will be documented in this file. ### Added - Added language support assert to tree-sitter parser tests. +- **Core**: Added support for extracting paths from comments that use markdown syntax. - **Core**: Added MDX language support. ([#43](https://github.com/kunlinglio/path-server/pull/43)) ## [1.3.1] - 2026-05-29 diff --git a/README.md b/README.md index d884a13..c79e097 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ Run `zed: open settings file` from the command palette to edit user settings jso - [x] Support path highlight. - [x] Support remote window. - [x] Improve path extraction precision. -- [ ] **Zed**: Support all language by use "wildcard" in extension.toml (Waiting for Zed extension api support) +- [x] Extract paths from comments that use markdown syntax. ## Development ### Recommended Workflow diff --git a/src/document/mod.rs b/src/document/mod.rs index 866cb88..5013340 100644 --- a/src/document/mod.rs +++ b/src/document/mod.rs @@ -114,18 +114,22 @@ impl Document { Ok(self.text[line_start..line_end].to_string()) } + // TODO: This name is confusing pub fn offset_to_utf16_pos(&self, offset: usize) -> PathServerResult<(usize, usize)> { offset_to_utf16_position(&self.index, offset) } + // TODO: This name is confusing pub fn utf16_pos_to_offset(&self, line: usize, character: usize) -> PathServerResult { utf16_position_to_offset(&self.index, line, character) } - pub fn offset_to_utf8_pos(&self, offset: usize) -> PathServerResult<(usize, usize)> { + // TODO: This name is confusing + pub fn offset_to_utf8_pos(&self, offset: usize) -> (usize, usize) { offset_to_utf8_position(&self.index, offset) } + // TODO: This name is confusing pub fn get_tree(&self) -> Option<&Tree> { self.tree.as_ref() } @@ -172,10 +176,10 @@ fn offset_to_utf16_position(index: &LineIndex, offset: usize) -> PathServerResul Ok((wide_offset.line as usize, wide_offset.col as usize)) } -fn offset_to_utf8_position(index: &LineIndex, offset: usize) -> PathServerResult<(usize, usize)> { +fn offset_to_utf8_position(index: &LineIndex, offset: usize) -> (usize, usize) { let text_offset = TextSize::new(offset as u32); let line_col = index.line_col(text_offset); - Ok((line_col.line as usize, line_col.col as usize)) + (line_col.line as usize, line_col.col as usize) } #[cfg(test)] diff --git a/src/parser/tree_sitter/mod.rs b/src/parser/tree_sitter/mod.rs index 20eab21..ef13a48 100644 --- a/src/parser/tree_sitter/mod.rs +++ b/src/parser/tree_sitter/mod.rs @@ -132,9 +132,9 @@ pub fn update_tree( return Ok(None); }; // prepare InputEdit for tree-sitter - let start = old_document.offset_to_utf8_pos(change_start_byte)?; - let old_end = old_document.offset_to_utf8_pos(change_old_end_byte)?; - let new_end = new_document.offset_to_utf8_pos(change_new_end_byte)?; + let start = old_document.offset_to_utf8_pos(change_start_byte); + let old_end = old_document.offset_to_utf8_pos(change_old_end_byte); + let new_end = new_document.offset_to_utf8_pos(change_new_end_byte); let edit = tree_sitter::InputEdit { start_byte: change_start_byte, old_end_byte: change_old_end_byte, @@ -169,30 +169,80 @@ pub fn extract_strings(document: &Document) -> PathServerResult { - ts_markdown::extract_strings(&document.text, &tree.root_node())? - } - Language::html => { - ts_html::extract_strings(&document.text, &tree.root_node(), &document.language) - } + let (candidates, comment_ranges) = match document.language { + Language::markdown | Language::mdx => ( + ts_markdown::extract_strings(&document.text, &tree.root_node())?, + ts_markdown::extract_comments(&document.text, &tree.root_node()), + ), + Language::html => ( + ts_html::extract_strings(&document.text, &tree.root_node(), &document.language), + ts_html::extract_comments(&document.text, &tree.root_node(), &document.language), + ), Language::javascript | Language::typescript | Language::python | Language::rust | Language::c - | Language::c_plus_plus => { - ts_general::extract_strings(&document.text, &tree.root_node(), &document.language) - } - Language::dockerfile => { - ts_dockerfile::extract_strings(&document.text, &tree.root_node(), &document.language) - } + | Language::c_plus_plus => ( + ts_general::extract_strings(&document.text, &tree.root_node(), &document.language), + ts_general::extract_comments(&document.text, &tree.root_node(), &document.language), + ), + Language::dockerfile => ( + ts_dockerfile::extract_strings(&document.text, &tree.root_node(), &document.language), + ts_dockerfile::extract_comments(&document.text, &tree.root_node(), &document.language), + ), _ => unreachable!("Unsupported language: {}", document.language), }; - let deduplicated: HashSet = HashSet::from_iter(candidates); + let comment_candidates = extract_paths_from_comment_ranges(document, &comment_ranges)?; + let all = candidates.into_iter().chain(comment_candidates.into_iter()); + let deduplicated: HashSet<_> = all.collect(); Ok(Some(deduplicated.into_iter().collect())) } +/// Re-parse comment text as markdown to extract paths written in markdown syntax +fn extract_paths_from_comment_ranges( + document: &Document, + comment_ranges: &[(usize, usize)], +) -> PathServerResult> { + let mut parser = tree_sitter::Parser::new(); + parser + .set_language(&ts_languages::get_md_language()) + .map_err(|e| { + PathServerError::ParseError(format!( + "Failed to set markdown parser for comments: {}", + e + )) + })?; + Ok(comment_ranges + .iter() + .map(|(start, end)| { + let range = tree_sitter::Range { + start_byte: *start, + end_byte: *end, + start_point: byte_offset_to_point(&document, *start), + end_point: byte_offset_to_point(&document, *end), + }; + parser.set_included_ranges(&[range]).map_err(|_| { + PathServerError::ParseError("Failed to set included ranges for comment".into()) + })?; + if let Some(tree) = parser.parse(&document.text, None) { + ts_markdown::extract_strings(&document.text, &tree.root_node()) + } else { + Err(PathServerError::ParseError( + "Failed to parse comment text as markdown".into(), + )) + } + }) + .flatten() + .flatten() + .collect()) +} + +fn byte_offset_to_point(document: &Document, offset: usize) -> tree_sitter::Point { + let (row, column) = document.offset_to_utf8_pos(offset); + tree_sitter::Point { row, column } +} + #[cfg(test)] mod tests { use super::*; @@ -205,6 +255,7 @@ mod tests { } /// Print the entire tree-sitter AST + #[allow(dead_code)] fn print_tree(language: &Language, source: &str) { let ts_lang = ts_languages::from_language(language).expect("tree-sitter language not available"); @@ -286,7 +337,6 @@ mod tests { assert!(tree_sitter_supported("javascript")); // normal string let normal_src = r#"const tpl = "hello world";"#; - print_tree(&Language::javascript, normal_src); let res = parse_and_extract(Language::javascript, normal_src); assert!( res.iter().any(|c| c.content == "hello world"), @@ -294,7 +344,6 @@ mod tests { ); // template string with interpolation let template_src = r#"const tpl = `hello ${name} world`;"#; - print_tree(&Language::javascript, template_src); let res = parse_and_extract(Language::javascript, template_src); assert!( res.iter().any(|c| c.content == "hello "), @@ -306,12 +355,34 @@ mod tests { ); // string with escaped characters let escape_src = r#"const s = "line1\\line2";"#; - print_tree(&Language::javascript, escape_src); let res = parse_and_extract(Language::javascript, escape_src); assert!( res.iter().any(|c| c.content == "line1\\\\line2"), "missing 'line1\\\\line2' with escaped newline" ); + // Markdown link in JS comment + let src = r#"// See [README](./README.md) +const x = 1;"#; + let res = parse_and_extract(Language::javascript, src); + assert!( + res.iter().any(|c| c.content == "./README.md"), + "missing link destination in JS line comment" + ); + let src = r#"/* `./README.md` */ +const x = 1;"#; + let res = parse_and_extract(Language::javascript, src); + assert!( + res.iter().any(|c| c.content == "./README.md"), + "missing link destination in JS line comment" + ); + // Path in block comment + let src = r#"/* import from ./lib/helper.js */ +const x = 1;"#; + let res = parse_and_extract(Language::javascript, src); + assert!( + res.iter().any(|c| c.content == "./lib/helper.js"), + "missing path in JS block comment" + ); } #[test] @@ -319,7 +390,6 @@ mod tests { assert!(tree_sitter_supported("typescript")); // normal string let normal_src = r#"const tpl: string = "hello world";"#; - print_tree(&Language::typescript, normal_src); let res = parse_and_extract(Language::typescript, normal_src); assert!( res.iter().any(|c| c.content == "hello world"), @@ -327,7 +397,6 @@ mod tests { ); // template string with interpolation let template_src = r#"const tpl: string = `ts ${val} end`;"#; - print_tree(&Language::typescript, template_src); let res = parse_and_extract(Language::typescript, template_src); assert!( res.iter().any(|c| c.content == "ts "), @@ -339,12 +408,34 @@ mod tests { ); // string with escaped characters let escape_src = r#"const s: string = "line1\\line2";"#; - print_tree(&Language::typescript, escape_src); let res = parse_and_extract(Language::typescript, escape_src); assert!( res.iter().any(|c| c.content == "line1\\\\line2"), "missing 'line1\\\\line2' with escaped newline" ); + // Markdown link in JS comment + let src = r#"// See [README](./README.md) +const x = 1;"#; + let res = parse_and_extract(Language::javascript, src); + assert!( + res.iter().any(|c| c.content == "./README.md"), + "missing link destination in JS line comment" + ); + let src = r#"/* `./README.md` */ +const x = 1;"#; + let res = parse_and_extract(Language::javascript, src); + assert!( + res.iter().any(|c| c.content == "./README.md"), + "missing link destination in JS line comment" + ); + // Path in block comment + let src = r#"/* import from ./lib/helper.js */ +const x = 1;"#; + let res = parse_and_extract(Language::javascript, src); + assert!( + res.iter().any(|c| c.content == "./lib/helper.js"), + "missing path in JS block comment" + ); } #[test] @@ -354,19 +445,17 @@ mod tests { let normal_src = r#" s = "hello" t = 'world' - u = """multi\nline""" + u = """multi \n line""" "#; - print_tree(&Language::python, normal_src); let res = parse_and_extract(Language::python, normal_src); assert!(res.iter().any(|c| c.content == "hello"), "missing 'hello'"); assert!(res.iter().any(|c| c.content == "world"), "missing 'world'"); assert!( - res.iter().any(|c| c.content.trim() == r#"multi\nline"#), + res.iter().any(|c| c.content.trim() == r#"multi \n line"#), "missing 'multi\nline' in triple-quoted string" ); // f-string let f_string_src = r#"s = f"hello {name}""#; - print_tree(&Language::python, f_string_src); let res = parse_and_extract(Language::python, f_string_src); assert!( res.iter().any(|c| c.content == "hello "), @@ -374,31 +463,83 @@ mod tests { ); // string with escaped characters let escape_src = r#"s = "line1\\line2""#; - print_tree(&Language::python, escape_src); let res = parse_and_extract(Language::python, escape_src); assert!( res.iter().any(|c| c.content == "line1\\\\line2"), "missing 'line1\\\\line2' with escaped newline" ); + // comment + let src = r#"# config file at `./config/settings.toml` +x = 1"#; + let res = parse_and_extract(Language::python, src); + assert!( + res.iter().any(|c| c.content == "./config/settings.toml"), + "missing path in Python comment" + ); + let src = r#"# config file at [](./config/settings.toml) +x = 1"#; + let res = parse_and_extract(Language::python, src); + assert!( + res.iter().any(|c| c.content == "./config/settings.toml"), + "missing path in Python comment" + ); } #[test] fn test_rust_extract_strings() { assert!(tree_sitter_supported("rust")); + // normal string let src = "let a = \"hello\"; let b = r#\"raw content\"#"; - print_tree(&Language::rust, src); let res = parse_and_extract(Language::rust, src); assert!(res.iter().any(|c| c.content == "hello"), "missing 'hello'"); assert!( res.iter().any(|c| c.content == "raw content"), "missing raw string content" ); - let escaped_src = "let s = \"line1\\\\nline2\";"; - print_tree(&Language::rust, escaped_src); + // escaped string + let escaped_src = "let s = \"line1\\\\n line2\";"; let res = parse_and_extract(Language::rust, escaped_src); assert!( - res.iter().any(|c| c.content == "line1\\\\nline2"), - "missing 'line1\\\\nline2' with escaped newline" + res.iter().any(|c| c.content == "line1\\\\n line2"), + "missing 'line1\\\\n line2' with escaped newline" + ); + // bare path in line comment + let src = r#"// ./src/main.rs +let x = 1;"#; + let res = parse_and_extract(Language::rust, src); + assert!( + res.iter().any(|c| c.content == "./src/main.rs"), + "missing bare path in Rust line comment" + ); + // markdown link in doc comment + let src = r#"/// See [the docs](./docs/README.md) for more. +let x = 1;"#; + let res = parse_and_extract(Language::rust, src); + assert!( + res.iter().any(|c| c.content == "./docs/README.md"), + "missing markdown link destination in Rust doc comment" + ); + // path in block comment + let src = r#"/* config: ./config.toml */"#; + let res = parse_and_extract(Language::rust, src); + assert!( + res.iter().any(|c| c.content == "./config.toml"), + "missing path in Rust block comment" + ); + // markdown link in inner doc comment + let src = r#"//! Inner doc [crate](./src/lib.rs) +let x = 1;"#; + let res = parse_and_extract(Language::rust, src); + assert!( + res.iter().any(|c| c.content == "./src/lib.rs"), + "missing markdown link in Rust inner doc comment" + ); + // path in doc block comment + let src = r#"/** Outer doc block with `./config.toml` */"#; + let res = parse_and_extract(Language::rust, src); + assert!( + res.iter().any(|c| c.content == "./config.toml"), + "missing code span path in Rust doc block comment" ); } @@ -406,14 +547,12 @@ mod tests { fn test_markdown_extract_strings() { assert!(tree_sitter_supported("markdown")); let link = "![a picture](./public/image.png)"; - print_tree(&Language::markdown, link); let res = parse_and_extract(Language::markdown, link); assert!( res.iter().any(|c| c.content == "./public/image.png"), "missing link destination" ); let text_in_quotes = "some text and `./public/image1.png`\nmore text and './public/image2.png'\n even more and \"./public/image3.png\""; - print_tree(&Language::markdown, text_in_quotes); let res = parse_and_extract(Language::markdown, text_in_quotes); eprintln!("{:?}", res); assert!( @@ -429,7 +568,6 @@ mod tests { "missing path in code span" ); let text_in_starts = "some text and *bold* and **strong**"; - print_tree(&Language::markdown, text_in_starts); let res = parse_and_extract(Language::markdown, text_in_starts); assert!(res.iter().any(|c| c.content == "bold"), "missing bold text"); assert!( @@ -443,7 +581,6 @@ mod tests { cd ./extensions/vscode/ ``` "#; - print_tree(&Language::markdown, common_path_in_text); let res = parse_and_extract(Language::markdown, common_path_in_text); assert!( res.iter().any(|c| c.content == "./extensions/vscode/"), @@ -465,7 +602,6 @@ The **Path Server** project is organized in mono-repository structure with core > Quote: ./extensions/vscode/more "#; - print_tree(&Language::markdown, complicated_case); let res = parse_and_extract(Language::markdown, complicated_case); eprintln!("{:?}", res); assert!( @@ -489,17 +625,19 @@ Project Timer is a lightweight VS Code extension that tracks the time you spend demo "#; - print_tree(&Language::markdown, md_with_html); let res = parse_and_extract(Language::markdown, md_with_html); eprintln!("{:?}", res); assert!( res.iter().any(|c| c.content == "./resources/demo.gif"), "missing path in HTML block" ); - } - - #[test] - fn test_mdx_extract_strings_with_markdown_parser() { + // HTML comment + let src = "\n# Title"; + let res = parse_and_extract(Language::markdown, src); + assert!( + res.iter().any(|c| c.content == "./docs/api.md"), + "missing path in markdown HTML comment" + ); assert!(tree_sitter_supported("mdx")); let mdx = r#" import Demo from './components/Demo' @@ -509,8 +647,7 @@ import Demo from './components/Demo' "#; - let doc = Document::new(mdx.to_string(), "mdx").expect("failed to create MDX document"); - let res = extract_strings(&doc).unwrap().unwrap(); + let res = parse_and_extract(Language::mdx, mdx); eprintln!("{:?}", res); assert!( res.iter().any(|c| c.content == "./components/Demo"), @@ -529,8 +666,8 @@ import Demo from './components/Demo' #[test] fn test_html_extract_string() { assert!(tree_sitter_supported("html")); + // normal document let simple = r#" "#; - print_tree(&Language::html, simple); let res = parse_and_extract(Language::html, simple); eprintln!("{:?}", res); assert!(res.iter().any(|c| c.content == "echarts.min.js")); @@ -549,44 +686,79 @@ import Demo from './components/Demo'
Some content include a path ./extension.toml
"#; - print_tree(&Language::html, html); let res = parse_and_extract(Language::html, html); eprintln!("{:?}", res); assert!(res.iter().any(|c| c.content == "echarts.min.js")); assert!(res.iter().any(|c| c.content == "statistics.css")); assert!(res.iter().any(|c| c.content == "./extension.toml")); + // comment + let src = r#" +

content

"#; + let res = parse_and_extract(Language::html, src); + assert!( + res.iter().any(|c| c.content == "./partials/header.html"), + "missing path in HTML comment" + ); } #[test] fn test_c_extract_string() { + // string literal assert!(tree_sitter_supported("c")); let str_with_escaped = r#"char *str = "Hello, \"World\"!";"#; - print_tree(&Language::c, str_with_escaped); let res = parse_and_extract(Language::c, str_with_escaped); eprintln!("{:?}", res); assert!(res.iter().any(|c| c.content == "Hello, \\\"World\\\"!")); - + // path in #include directive let path_in_include = r#"#include "path/to/header.h""#; - print_tree(&Language::c, path_in_include); let res = parse_and_extract(Language::c, path_in_include); eprintln!("{:?}", res); assert!(res.iter().any(|c| c.content == "path/to/header.h")); + // Markdown-style inline code in C comment + let src = r#"// include `src/header.h` +int x = 1;"#; + let res = parse_and_extract(Language::c, src); + assert!( + res.iter().any(|c| c.content == "src/header.h"), + "missing code span path in C comment" + ); + // Path in block comment + let src = r#"/* See ./include/config.h */ +int x = 1;"#; + let res = parse_and_extract(Language::c, src); + assert!( + res.iter().any(|c| c.content == "./include/config.h"), + "missing path in C block comment" + ); } #[test] fn test_cpp_extract_string() { assert!(tree_sitter_supported("cpp")); let str_with_escaped = r#"std::string str = "Hello, \"World\"!";"#; - print_tree(&Language::c_plus_plus, str_with_escaped); let res = parse_and_extract(Language::c_plus_plus, str_with_escaped); eprintln!("{:?}", res); assert!(res.iter().any(|c| c.content == "Hello, \\\"World\\\"!")); let path_in_include = r#"#include "path/to/header.h""#; - print_tree(&Language::c_plus_plus, path_in_include); let res = parse_and_extract(Language::c_plus_plus, path_in_include); eprintln!("{:?}", res); assert!(res.iter().any(|c| c.content == "path/to/header.h")); + // comment + let src = r#"// See [README](./README.md) +int x = 1;"#; + let res = parse_and_extract(Language::c_plus_plus, src); + assert!( + res.iter().any(|c| c.content == "./README.md"), + "missing link destination in C++ line comment" + ); + let src = r#"/* import from ./lib/helper.hpp */ +int x = 1;"#; + let res = parse_and_extract(Language::c_plus_plus, src); + assert!( + res.iter().any(|c| c.content == "./lib/helper.hpp"), + "missing path in C++ block comment" + ); } #[test] @@ -612,7 +784,6 @@ COPY server /workdir/server COPY migrations /workdir/migrations RUN ./中文/路径/out "#; - print_tree(&Language::dockerfile, dockerfile); let res = parse_and_extract(Language::dockerfile, dockerfile); eprintln!("{:?}", res); assert!(res.iter().any(|c| c.content == "/workdir")); @@ -637,6 +808,14 @@ RUN ./中文/路径/out res.iter() .any(|c| matches!(c.content.as_str(), "/workdir/migrations")) ); - assert!(res.iter().any(|c| c.content == "./中文/路径/out"),) + assert!(res.iter().any(|c| c.content == "./中文/路径/out"),); + // comment + let src = r#"# copy from ./config/app.conf +FROM alpine"#; + let res = parse_and_extract(Language::dockerfile, src); + assert!( + res.iter().any(|c| c.content == "./config/app.conf"), + "missing path in Dockerfile comment" + ); } } diff --git a/src/parser/tree_sitter/ts_dockerfile.rs b/src/parser/tree_sitter/ts_dockerfile.rs index d9f08df..120024a 100644 --- a/src/parser/tree_sitter/ts_dockerfile.rs +++ b/src/parser/tree_sitter/ts_dockerfile.rs @@ -6,6 +6,24 @@ use super::PathCandidate; use super::super::unescape::unescape; +/// Collect byte ranges of comment nodes in Dockerfile. +pub fn extract_comments( + source: &str, + node: &tree_sitter::Node, + language: &Language, +) -> Vec<(usize, usize)> { + assert_eq!(language, &Language::dockerfile); + let mut ranges = Vec::new(); + if node.kind() == "comment" { + ranges.push((node.start_byte(), node.end_byte())); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + ranges.extend(extract_comments(source, &child, language)); + } + ranges +} + pub fn extract_strings( source: &str, node: &tree_sitter::Node, diff --git a/src/parser/tree_sitter/ts_general.rs b/src/parser/tree_sitter/ts_general.rs index 2133da5..c0470cf 100644 --- a/src/parser/tree_sitter/ts_general.rs +++ b/src/parser/tree_sitter/ts_general.rs @@ -3,6 +3,34 @@ use crate::document::Language; use super::PathCandidate; +/// Collect byte ranges of comment nodes in the tree. +pub fn extract_comments( + source: &str, + node: &tree_sitter::Node, + language: &Language, +) -> Vec<(usize, usize)> { + let mut ranges = Vec::new(); + if is_comment_node(node, language) { + ranges.push((node.start_byte(), node.end_byte())); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + ranges.extend(extract_comments(source, &child, language)); + } + ranges +} + +fn is_comment_node(node: &tree_sitter::Node, language: &Language) -> bool { + let kind = node.kind(); + match language { + Language::rust => matches!( + kind, + "line_comment" | "block_comment" | "line_doc_comment" | "block_doc_comment" + ), + _ => kind == "comment", + } +} + pub fn extract_strings( source: &str, node: &tree_sitter::Node, diff --git a/src/parser/tree_sitter/ts_html.rs b/src/parser/tree_sitter/ts_html.rs index d95735d..09f8b54 100644 --- a/src/parser/tree_sitter/ts_html.rs +++ b/src/parser/tree_sitter/ts_html.rs @@ -5,6 +5,24 @@ use crate::document::Language; use super::PathCandidate; +/// Collect byte ranges of HTML comment nodes in HTML. +pub fn extract_comments( + source: &str, + node: &tree_sitter::Node, + language: &Language, +) -> Vec<(usize, usize)> { + assert_eq!(language, &Language::html); + let mut ranges = Vec::new(); + if node.kind() == "comment" { + ranges.push((node.start_byte(), node.end_byte())); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + ranges.extend(extract_comments(source, &child, language)); + } + ranges +} + pub fn extract_strings( source: &str, node: &tree_sitter::Node, diff --git a/src/parser/tree_sitter/ts_markdown.rs b/src/parser/tree_sitter/ts_markdown.rs index 199d9bf..8d84905 100644 --- a/src/parser/tree_sitter/ts_markdown.rs +++ b/src/parser/tree_sitter/ts_markdown.rs @@ -164,3 +164,20 @@ pub fn extract_strings( Ok(strings.into_iter().collect::>()) } + +/// Collect byte ranges of HTML comment nodes () in markdown/mdx. +/// In tree-sitter-markdown these are parsed as `html_tag` nodes. +pub fn extract_comments(source: &str, node: &tree_sitter::Node) -> Vec<(usize, usize)> { + let mut ranges = Vec::new(); + if node.kind() == "html_tag" { + let text = &source[node.start_byte()..node.end_byte()]; + if text.starts_with("") { + ranges.push((node.start_byte(), node.end_byte())); + } + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + ranges.extend(extract_comments(source, &child)); + } + ranges +} From e0281dc0fa093e9f0e3a460cccd576703e16b7d0 Mon Sep 17 00:00:00 2001 From: kunlinglio <142668432+kunlinglio@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:27:43 +0800 Subject: [PATCH 2/3] Rename offset conversion methods to use byte position instead of UTF-8 --- src/document/mod.rs | 10 +++------- src/parser/tree_sitter/mod.rs | 8 ++++---- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/document/mod.rs b/src/document/mod.rs index 5013340..8e96218 100644 --- a/src/document/mod.rs +++ b/src/document/mod.rs @@ -114,22 +114,18 @@ impl Document { Ok(self.text[line_start..line_end].to_string()) } - // TODO: This name is confusing pub fn offset_to_utf16_pos(&self, offset: usize) -> PathServerResult<(usize, usize)> { offset_to_utf16_position(&self.index, offset) } - // TODO: This name is confusing pub fn utf16_pos_to_offset(&self, line: usize, character: usize) -> PathServerResult { utf16_position_to_offset(&self.index, line, character) } - // TODO: This name is confusing - pub fn offset_to_utf8_pos(&self, offset: usize) -> (usize, usize) { - offset_to_utf8_position(&self.index, offset) + pub fn offset_to_byte_pos(&self, offset: usize) -> (usize, usize) { + offset_to_byte_position(&self.index, offset) } - // TODO: This name is confusing pub fn get_tree(&self) -> Option<&Tree> { self.tree.as_ref() } @@ -176,7 +172,7 @@ fn offset_to_utf16_position(index: &LineIndex, offset: usize) -> PathServerResul Ok((wide_offset.line as usize, wide_offset.col as usize)) } -fn offset_to_utf8_position(index: &LineIndex, offset: usize) -> (usize, usize) { +fn offset_to_byte_position(index: &LineIndex, offset: usize) -> (usize, usize) { let text_offset = TextSize::new(offset as u32); let line_col = index.line_col(text_offset); (line_col.line as usize, line_col.col as usize) diff --git a/src/parser/tree_sitter/mod.rs b/src/parser/tree_sitter/mod.rs index ef13a48..d4239e2 100644 --- a/src/parser/tree_sitter/mod.rs +++ b/src/parser/tree_sitter/mod.rs @@ -132,9 +132,9 @@ pub fn update_tree( return Ok(None); }; // prepare InputEdit for tree-sitter - let start = old_document.offset_to_utf8_pos(change_start_byte); - let old_end = old_document.offset_to_utf8_pos(change_old_end_byte); - let new_end = new_document.offset_to_utf8_pos(change_new_end_byte); + let start = old_document.offset_to_byte_pos(change_start_byte); + let old_end = old_document.offset_to_byte_pos(change_old_end_byte); + let new_end = new_document.offset_to_byte_pos(change_new_end_byte); let edit = tree_sitter::InputEdit { start_byte: change_start_byte, old_end_byte: change_old_end_byte, @@ -239,7 +239,7 @@ fn extract_paths_from_comment_ranges( } fn byte_offset_to_point(document: &Document, offset: usize) -> tree_sitter::Point { - let (row, column) = document.offset_to_utf8_pos(offset); + let (row, column) = document.offset_to_byte_pos(offset); tree_sitter::Point { row, column } } From d872b4d005d5df77f43008e81e9318d4accf9f14 Mon Sep 17 00:00:00 2001 From: kunlinglio <142668432+kunlinglio@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:49:32 +0800 Subject: [PATCH 3/3] Fix cargo clippy complaints --- src/parser/tree_sitter/mod.rs | 40 ++++++++++++------------- src/parser/tree_sitter/ts_dockerfile.rs | 8 ++--- src/parser/tree_sitter/ts_general.rs | 8 ++--- src/parser/tree_sitter/ts_html.rs | 8 ++--- 4 files changed, 25 insertions(+), 39 deletions(-) diff --git a/src/parser/tree_sitter/mod.rs b/src/parser/tree_sitter/mod.rs index d4239e2..d3171ae 100644 --- a/src/parser/tree_sitter/mod.rs +++ b/src/parser/tree_sitter/mod.rs @@ -176,7 +176,7 @@ pub fn extract_strings(document: &Document) -> PathServerResult ( ts_html::extract_strings(&document.text, &tree.root_node(), &document.language), - ts_html::extract_comments(&document.text, &tree.root_node(), &document.language), + ts_html::extract_comments(&tree.root_node(), &document.language), ), Language::javascript | Language::typescript @@ -185,16 +185,16 @@ pub fn extract_strings(document: &Document) -> PathServerResult ( ts_general::extract_strings(&document.text, &tree.root_node(), &document.language), - ts_general::extract_comments(&document.text, &tree.root_node(), &document.language), + ts_general::extract_comments(&tree.root_node(), &document.language), ), Language::dockerfile => ( ts_dockerfile::extract_strings(&document.text, &tree.root_node(), &document.language), - ts_dockerfile::extract_comments(&document.text, &tree.root_node(), &document.language), + ts_dockerfile::extract_comments(&tree.root_node(), &document.language), ), _ => unreachable!("Unsupported language: {}", document.language), }; let comment_candidates = extract_paths_from_comment_ranges(document, &comment_ranges)?; - let all = candidates.into_iter().chain(comment_candidates.into_iter()); + let all = candidates.into_iter().chain(comment_candidates); let deduplicated: HashSet<_> = all.collect(); Ok(Some(deduplicated.into_iter().collect())) } @@ -219,21 +219,19 @@ fn extract_paths_from_comment_ranges( let range = tree_sitter::Range { start_byte: *start, end_byte: *end, - start_point: byte_offset_to_point(&document, *start), - end_point: byte_offset_to_point(&document, *end), + start_point: byte_offset_to_point(document, *start), + end_point: byte_offset_to_point(document, *end), }; parser.set_included_ranges(&[range]).map_err(|_| { PathServerError::ParseError("Failed to set included ranges for comment".into()) })?; - if let Some(tree) = parser.parse(&document.text, None) { - ts_markdown::extract_strings(&document.text, &tree.root_node()) - } else { - Err(PathServerError::ParseError( - "Failed to parse comment text as markdown".into(), - )) - } + let tree = parser.parse(&document.text, None).ok_or_else(|| { + PathServerError::ParseError("Failed to parse comment text as markdown".into()) + })?; + ts_markdown::extract_strings(&document.text, &tree.root_node()) }) - .flatten() + .collect::>>()? + .into_iter() .flatten() .collect()) } @@ -413,28 +411,28 @@ const x = 1;"#; res.iter().any(|c| c.content == "line1\\\\line2"), "missing 'line1\\\\line2' with escaped newline" ); - // Markdown link in JS comment + // Markdown link in TS comment let src = r#"// See [README](./README.md) const x = 1;"#; - let res = parse_and_extract(Language::javascript, src); + let res = parse_and_extract(Language::typescript, src); assert!( res.iter().any(|c| c.content == "./README.md"), - "missing link destination in JS line comment" + "missing link destination in TS line comment" ); let src = r#"/* `./README.md` */ const x = 1;"#; - let res = parse_and_extract(Language::javascript, src); + let res = parse_and_extract(Language::typescript, src); assert!( res.iter().any(|c| c.content == "./README.md"), - "missing link destination in JS line comment" + "missing link destination in TS line comment" ); // Path in block comment let src = r#"/* import from ./lib/helper.js */ const x = 1;"#; - let res = parse_and_extract(Language::javascript, src); + let res = parse_and_extract(Language::typescript, src); assert!( res.iter().any(|c| c.content == "./lib/helper.js"), - "missing path in JS block comment" + "missing path in TS block comment" ); } diff --git a/src/parser/tree_sitter/ts_dockerfile.rs b/src/parser/tree_sitter/ts_dockerfile.rs index 120024a..1c056ca 100644 --- a/src/parser/tree_sitter/ts_dockerfile.rs +++ b/src/parser/tree_sitter/ts_dockerfile.rs @@ -7,11 +7,7 @@ use super::PathCandidate; use super::super::unescape::unescape; /// Collect byte ranges of comment nodes in Dockerfile. -pub fn extract_comments( - source: &str, - node: &tree_sitter::Node, - language: &Language, -) -> Vec<(usize, usize)> { +pub fn extract_comments(node: &tree_sitter::Node, language: &Language) -> Vec<(usize, usize)> { assert_eq!(language, &Language::dockerfile); let mut ranges = Vec::new(); if node.kind() == "comment" { @@ -19,7 +15,7 @@ pub fn extract_comments( } let mut cursor = node.walk(); for child in node.children(&mut cursor) { - ranges.extend(extract_comments(source, &child, language)); + ranges.extend(extract_comments(&child, language)); } ranges } diff --git a/src/parser/tree_sitter/ts_general.rs b/src/parser/tree_sitter/ts_general.rs index c0470cf..264fc43 100644 --- a/src/parser/tree_sitter/ts_general.rs +++ b/src/parser/tree_sitter/ts_general.rs @@ -4,18 +4,14 @@ use crate::document::Language; use super::PathCandidate; /// Collect byte ranges of comment nodes in the tree. -pub fn extract_comments( - source: &str, - node: &tree_sitter::Node, - language: &Language, -) -> Vec<(usize, usize)> { +pub fn extract_comments(node: &tree_sitter::Node, language: &Language) -> Vec<(usize, usize)> { let mut ranges = Vec::new(); if is_comment_node(node, language) { ranges.push((node.start_byte(), node.end_byte())); } let mut cursor = node.walk(); for child in node.children(&mut cursor) { - ranges.extend(extract_comments(source, &child, language)); + ranges.extend(extract_comments(&child, language)); } ranges } diff --git a/src/parser/tree_sitter/ts_html.rs b/src/parser/tree_sitter/ts_html.rs index 09f8b54..08a8335 100644 --- a/src/parser/tree_sitter/ts_html.rs +++ b/src/parser/tree_sitter/ts_html.rs @@ -6,11 +6,7 @@ use crate::document::Language; use super::PathCandidate; /// Collect byte ranges of HTML comment nodes in HTML. -pub fn extract_comments( - source: &str, - node: &tree_sitter::Node, - language: &Language, -) -> Vec<(usize, usize)> { +pub fn extract_comments(node: &tree_sitter::Node, language: &Language) -> Vec<(usize, usize)> { assert_eq!(language, &Language::html); let mut ranges = Vec::new(); if node.kind() == "comment" { @@ -18,7 +14,7 @@ pub fn extract_comments( } let mut cursor = node.walk(); for child in node.children(&mut cursor) { - ranges.extend(extract_comments(source, &child, language)); + ranges.extend(extract_comments(&child, language)); } ranges }