From efc7bb47517b3b2e5abe0d418b25dfbc9fc5e821 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Mon, 24 Aug 2026 19:03:08 +0800 Subject: [PATCH] refactor: dedupe backend helpers and polish signature caps, markers, clap help --- crates/ctx-exec/src/lib.rs | 6 +- crates/ctx-exec/tests/compress_test.rs | 17 +++++ crates/ctx-symbol/src/lang/c.rs | 13 ---- crates/ctx-symbol/src/lang/cpp.rs | 13 ---- crates/ctx-symbol/src/lang/csharp.rs | 13 ---- crates/ctx-symbol/src/lang/go.rs | 13 ---- crates/ctx-symbol/src/lang/html.rs | 14 ---- crates/ctx-symbol/src/lang/java.rs | 13 ---- crates/ctx-symbol/src/lang/javascript.rs | 26 +------ crates/ctx-symbol/src/lang/lua.rs | 22 +----- crates/ctx-symbol/src/lang/markdown.rs | 13 ---- crates/ctx-symbol/src/lang/mod.rs | 1 + crates/ctx-symbol/src/lang/python.rs | 13 ---- crates/ctx-symbol/src/lang/ruby.rs | 26 +------ crates/ctx-symbol/src/lang/rust.rs | 13 ---- crates/ctx-symbol/src/lang/typescript.rs | 26 +------ crates/ctx-symbol/src/lang/util.rs | 13 ++++ crates/ctx-symbol/src/language.rs | 94 ++++++++++++++++++------ crates/ctx-symbol/src/lib.rs | 36 +++++---- crates/ctx-symbol/tests/symbol_test.rs | 28 ++++++- crates/ctxctl/src/main.rs | 6 +- crates/ctxctl/tests/cli_test.rs | 38 +++++++++- 22 files changed, 202 insertions(+), 255 deletions(-) create mode 100644 crates/ctx-symbol/src/lang/util.rs diff --git a/crates/ctx-exec/src/lib.rs b/crates/ctx-exec/src/lib.rs index 9a18431..6573f22 100644 --- a/crates/ctx-exec/src/lib.rs +++ b/crates/ctx-exec/src/lib.rs @@ -433,7 +433,11 @@ impl StreamCompressor { } fn omit_marker(n: usize) -> String { - format!("... [{n} lines omitted]") + if n == 1 { + "... [1 line omitted]".to_string() + } else { + format!("... [{n} lines omitted]") + } } fn saved_pct(original: usize, compressed: usize) -> u32 { diff --git a/crates/ctx-exec/tests/compress_test.rs b/crates/ctx-exec/tests/compress_test.rs index 0ccce71..d3c3342 100644 --- a/crates/ctx-exec/tests/compress_test.rs +++ b/crates/ctx-exec/tests/compress_test.rs @@ -556,3 +556,20 @@ fn stream_compressor_with_no_pushes_is_empty() { assert_eq!(result.stats.saved_percent, 0); assert!(!result.stats.compression_ineffective()); } + +#[test] +fn singular_omission_run_says_line_not_lines() { + // One line between the head and tail windows: the marker must read + // "[1 line omitted]", not "[1 lines omitted]". + let options = CompressOptions { + head_lines: 1, + tail_lines: 1, + collapse_threshold: 2, + ..opts() + }; + let result = compress("a\nb\nc\n", &options).unwrap(); + assert_eq!( + String::from_utf8_lossy(&result.text), + "a\n... [1 line omitted]\nc" + ); +} diff --git a/crates/ctx-symbol/src/lang/c.rs b/crates/ctx-symbol/src/lang/c.rs index 075c62f..ab75477 100644 --- a/crates/ctx-symbol/src/lang/c.rs +++ b/crates/ctx-symbol/src/lang/c.rs @@ -75,19 +75,6 @@ impl Language for CLang { .map(|s| s.trim().to_string()) } - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { - let start = node.start_byte(); - let text = source - .get(start..node.end_byte().min(source.len())) - .unwrap_or("…"); - let line = text.split('\n').next().unwrap_or("").trim(); - if line.is_empty() { - "…".to_string() - } else { - line.to_string() - } - } - fn has_doc_comment(&self, _node: &tree_sitter::Node) -> bool { true } diff --git a/crates/ctx-symbol/src/lang/cpp.rs b/crates/ctx-symbol/src/lang/cpp.rs index 1b5ede3..2903945 100644 --- a/crates/ctx-symbol/src/lang/cpp.rs +++ b/crates/ctx-symbol/src/lang/cpp.rs @@ -81,19 +81,6 @@ impl Language for CppLang { .map(|s| s.trim().to_string()) } - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { - let start = node.start_byte(); - let text = source - .get(start..node.end_byte().min(source.len())) - .unwrap_or("…"); - let line = text.split('\n').next().unwrap_or("").trim(); - if line.is_empty() { - "…".to_string() - } else { - line.to_string() - } - } - fn has_doc_comment(&self, _node: &tree_sitter::Node) -> bool { true } diff --git a/crates/ctx-symbol/src/lang/csharp.rs b/crates/ctx-symbol/src/lang/csharp.rs index 5600cc2..c761876 100644 --- a/crates/ctx-symbol/src/lang/csharp.rs +++ b/crates/ctx-symbol/src/lang/csharp.rs @@ -62,19 +62,6 @@ impl Language for CSharpLang { .map(|s| s.trim().to_string()) } - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { - let start = node.start_byte(); - let text = source - .get(start..node.end_byte().min(source.len())) - .unwrap_or("…"); - let line = text.split('\n').next().unwrap_or("").trim(); - if line.is_empty() { - "…".to_string() - } else { - line.to_string() - } - } - fn has_doc_comment(&self, _node: &tree_sitter::Node) -> bool { true } diff --git a/crates/ctx-symbol/src/lang/go.rs b/crates/ctx-symbol/src/lang/go.rs index 75aa7b2..d7b603c 100644 --- a/crates/ctx-symbol/src/lang/go.rs +++ b/crates/ctx-symbol/src/lang/go.rs @@ -43,19 +43,6 @@ impl Language for GoLang { .map(|s| s.trim().to_string()) } - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { - let start = node.start_byte(); - let text = source - .get(start..node.end_byte().min(source.len())) - .unwrap_or("…"); - let line = text.split('\n').next().unwrap_or("").trim(); - if line.is_empty() { - "…".to_string() - } else { - line.to_string() - } - } - fn has_doc_comment(&self, _node: &tree_sitter::Node) -> bool { true } diff --git a/crates/ctx-symbol/src/lang/html.rs b/crates/ctx-symbol/src/lang/html.rs index 5b59a9f..6d31201 100644 --- a/crates/ctx-symbol/src/lang/html.rs +++ b/crates/ctx-symbol/src/lang/html.rs @@ -64,18 +64,4 @@ impl Language for HtmlLang { } None } - - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { - // `` — the opening line only. - let start = node.start_byte(); - let text = source - .get(start..node.end_byte().min(source.len())) - .unwrap_or("…"); - let line = text.split('\n').next().unwrap_or("").trim(); - if line.is_empty() { - "…".to_string() - } else { - line.to_string() - } - } } diff --git a/crates/ctx-symbol/src/lang/java.rs b/crates/ctx-symbol/src/lang/java.rs index f3bc762..a1fc3c5 100644 --- a/crates/ctx-symbol/src/lang/java.rs +++ b/crates/ctx-symbol/src/lang/java.rs @@ -52,19 +52,6 @@ impl Language for JavaLang { .map(|s| s.trim().to_string()) } - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { - let start = node.start_byte(); - let text = source - .get(start..node.end_byte().min(source.len())) - .unwrap_or("…"); - let line = text.split('\n').next().unwrap_or("").trim(); - if line.is_empty() { - "…".to_string() - } else { - line.to_string() - } - } - fn has_doc_comment(&self, _node: &tree_sitter::Node) -> bool { true } diff --git a/crates/ctx-symbol/src/lang/javascript.rs b/crates/ctx-symbol/src/lang/javascript.rs index f69977c..de9747e 100644 --- a/crates/ctx-symbol/src/lang/javascript.rs +++ b/crates/ctx-symbol/src/lang/javascript.rs @@ -1,5 +1,6 @@ //! JavaScript language backend. +use crate::lang::util::string_value; use crate::language::Language; use crate::symbol::SymbolKind; use std::path::Path; @@ -50,19 +51,6 @@ impl Language for JavaScriptLang { .map(|s| s.trim().to_string()) } - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { - let start = node.start_byte(); - let text = source - .get(start..node.end_byte().min(source.len())) - .unwrap_or("…"); - let line = text.split('\n').next().unwrap_or("").trim(); - if line.is_empty() { - "…".to_string() - } else { - line.to_string() - } - } - fn has_doc_comment(&self, _node: &tree_sitter::Node) -> bool { true } @@ -106,15 +94,3 @@ impl Language for JavaScriptLang { .unwrap_or_default() } } - -/// Text of a string literal node without its quotes. -fn string_value(node: tree_sitter::Node, source: &str) -> Option { - let text = node.utf8_text(source.as_bytes()).ok()?.trim(); - let unquoted = text.strip_prefix(['\'', '"'])?; - Some( - unquoted - .strip_suffix(['\'', '"']) - .unwrap_or(unquoted) - .to_string(), - ) -} diff --git a/crates/ctx-symbol/src/lang/lua.rs b/crates/ctx-symbol/src/lang/lua.rs index b8af144..27e75d2 100644 --- a/crates/ctx-symbol/src/lang/lua.rs +++ b/crates/ctx-symbol/src/lang/lua.rs @@ -56,19 +56,6 @@ impl Language for LuaLang { .map(|s| s.trim().to_string()) } - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { - let start = node.start_byte(); - let text = source - .get(start..node.end_byte().min(source.len())) - .unwrap_or("…"); - let line = text.split('\n').next().unwrap_or("").trim(); - if line.is_empty() { - "…".to_string() - } else { - line.to_string() - } - } - fn has_doc_comment(&self, _node: &tree_sitter::Node) -> bool { true } @@ -111,12 +98,5 @@ fn string_value(node: tree_sitter::Node, source: &str) -> Option { { return Some(text.trim().to_string()); } - let text = node.utf8_text(source.as_bytes()).ok()?.trim(); - let unquoted = text.strip_prefix(['\'', '"'])?; - Some( - unquoted - .strip_suffix(['\'', '"']) - .unwrap_or(unquoted) - .to_string(), - ) + crate::lang::util::string_value(node, source) } diff --git a/crates/ctx-symbol/src/lang/markdown.rs b/crates/ctx-symbol/src/lang/markdown.rs index d580eb4..a1e8085 100644 --- a/crates/ctx-symbol/src/lang/markdown.rs +++ b/crates/ctx-symbol/src/lang/markdown.rs @@ -48,19 +48,6 @@ impl Language for MarkdownLang { Some(cleaned.to_string()) } - /// The full heading line as written (`## Sub heading`). - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { - let text = source - .get(node.start_byte()..node.end_byte().min(source.len())) - .unwrap_or("…"); - let line = text.split('\n').next().unwrap_or("").trim(); - if line.is_empty() { - "…".to_string() - } else { - line.to_string() - } - } - /// Extend the heading's range to its enclosing `section`, i.e. through /// the whole chapter including nested subsections. fn definition_byte_range(&self, node: &tree_sitter::Node) -> std::ops::Range { diff --git a/crates/ctx-symbol/src/lang/mod.rs b/crates/ctx-symbol/src/lang/mod.rs index a0027e6..6958086 100644 --- a/crates/ctx-symbol/src/lang/mod.rs +++ b/crates/ctx-symbol/src/lang/mod.rs @@ -15,3 +15,4 @@ pub mod python; pub mod ruby; pub mod rust; pub mod typescript; +pub(crate) mod util; diff --git a/crates/ctx-symbol/src/lang/python.rs b/crates/ctx-symbol/src/lang/python.rs index e321f1c..22b1669 100644 --- a/crates/ctx-symbol/src/lang/python.rs +++ b/crates/ctx-symbol/src/lang/python.rs @@ -48,19 +48,6 @@ impl Language for PythonLang { .map(|s| s.trim().to_string()) } - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { - let start = node.start_byte(); - let text = source - .get(start..node.end_byte().min(source.len())) - .unwrap_or("…"); - let line = text.split('\n').next().unwrap_or("").trim(); - if line.is_empty() { - "…".to_string() - } else { - line.to_string() - } - } - fn has_doc_comment(&self, _node: &tree_sitter::Node) -> bool { true } diff --git a/crates/ctx-symbol/src/lang/ruby.rs b/crates/ctx-symbol/src/lang/ruby.rs index d776ced..6240055 100644 --- a/crates/ctx-symbol/src/lang/ruby.rs +++ b/crates/ctx-symbol/src/lang/ruby.rs @@ -1,5 +1,6 @@ //! Ruby language backend. +use crate::lang::util::string_value; use crate::language::Language; use crate::symbol::SymbolKind; use std::path::Path; @@ -40,19 +41,6 @@ impl Language for RubyLang { .map(|s| s.trim().to_string()) } - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { - let start = node.start_byte(); - let text = source - .get(start..node.end_byte().min(source.len())) - .unwrap_or("…"); - let line = text.split('\n').next().unwrap_or("").trim(); - if line.is_empty() { - "…".to_string() - } else { - line.to_string() - } - } - fn has_doc_comment(&self, _node: &tree_sitter::Node) -> bool { true } @@ -111,15 +99,3 @@ impl Language for RubyLang { .unwrap_or_default() } } - -/// Text of a string literal node without its quotes. -fn string_value(node: tree_sitter::Node, source: &str) -> Option { - let text = node.utf8_text(source.as_bytes()).ok()?.trim(); - let unquoted = text.strip_prefix(['\'', '"'])?; - Some( - unquoted - .strip_suffix(['\'', '"']) - .unwrap_or(unquoted) - .to_string(), - ) -} diff --git a/crates/ctx-symbol/src/lang/rust.rs b/crates/ctx-symbol/src/lang/rust.rs index 8f32ab3..e4bfc2b 100644 --- a/crates/ctx-symbol/src/lang/rust.rs +++ b/crates/ctx-symbol/src/lang/rust.rs @@ -47,19 +47,6 @@ impl Language for RustLang { .map(|s| s.trim().to_string()) } - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { - let start = node.start_byte(); - let text = source - .get(start..node.end_byte().min(source.len())) - .unwrap_or("…"); - let line = text.split('\n').next().unwrap_or("").trim(); - if line.is_empty() { - "…".to_string() - } else { - line.to_string() - } - } - fn has_doc_comment(&self, _node: &tree_sitter::Node) -> bool { true } diff --git a/crates/ctx-symbol/src/lang/typescript.rs b/crates/ctx-symbol/src/lang/typescript.rs index 76f7512..6a56e90 100644 --- a/crates/ctx-symbol/src/lang/typescript.rs +++ b/crates/ctx-symbol/src/lang/typescript.rs @@ -1,5 +1,6 @@ //! TypeScript / JavaScript language backend. +use crate::lang::util::string_value; use crate::language::Language; use crate::symbol::SymbolKind; use std::path::Path; @@ -65,19 +66,6 @@ impl Language for TypeScriptLang { .map(|s| s.trim().to_string()) } - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { - let start = node.start_byte(); - let text = source - .get(start..node.end_byte().min(source.len())) - .unwrap_or("…"); - let line = text.split('\n').next().unwrap_or("").trim(); - if line.is_empty() { - "…".to_string() - } else { - line.to_string() - } - } - fn has_doc_comment(&self, _node: &tree_sitter::Node) -> bool { true } @@ -134,15 +122,3 @@ impl Language for TypeScriptLang { vec![target] } } - -/// Text of a string literal node without its quotes. -fn string_value(node: tree_sitter::Node, source: &str) -> Option { - let text = node.utf8_text(source.as_bytes()).ok()?.trim(); - let unquoted = text.strip_prefix(['\'', '"'])?; - Some( - unquoted - .strip_suffix(['\'', '"']) - .unwrap_or(unquoted) - .to_string(), - ) -} diff --git a/crates/ctx-symbol/src/lang/util.rs b/crates/ctx-symbol/src/lang/util.rs new file mode 100644 index 0000000..1cf3343 --- /dev/null +++ b/crates/ctx-symbol/src/lang/util.rs @@ -0,0 +1,13 @@ +//! Helpers shared by language backends. + +/// Text of a string literal node without its quotes. +pub(crate) fn string_value(node: tree_sitter::Node, source: &str) -> Option { + let text = node.utf8_text(source.as_bytes()).ok()?.trim(); + let unquoted = text.strip_prefix(['\'', '"'])?; + Some( + unquoted + .strip_suffix(['\'', '"']) + .unwrap_or(unquoted) + .to_string(), + ) +} diff --git a/crates/ctx-symbol/src/language.rs b/crates/ctx-symbol/src/language.rs index 1a37c8d..c6b9cb4 100644 --- a/crates/ctx-symbol/src/language.rs +++ b/crates/ctx-symbol/src/language.rs @@ -55,7 +55,21 @@ pub trait Language: Send + Sync { /// Produce the "signature" line(s) for a definition node — usually the /// first line or a compact header. - fn signature(&self, node: &tree_sitter::Node, source: &str) -> String; + /// + /// Default: the node's first source line, trimmed (`…` when empty). + /// Backends whose signature is not the first line (e.g. CSS, where the + /// selector list alone is the header) override this. + fn signature(&self, node: &tree_sitter::Node, source: &str) -> String { + let text = source + .get(node.start_byte()..node.end_byte().min(source.len())) + .unwrap_or("…"); + let line = text.split('\n').next().unwrap_or("").trim(); + if line.is_empty() { + "…".to_string() + } else { + line.to_string() + } + } /// Return true if `node` may carry a doc comment immediately above it. /// Backends override to handle comment idioms (e.g. `//`, `///`, `/** */`). @@ -478,12 +492,8 @@ pub(crate) fn clean_signature(raw: &str) -> String { } } - if sig.len() > MAX_SIGNATURE { - let mut cut = MAX_SIGNATURE - 1; - while cut > 0 && !sig.is_char_boundary(cut) { - cut -= 1; - } - sig.truncate(cut); + if sig.chars().count() > MAX_SIGNATURE { + sig = sig.chars().take(MAX_SIGNATURE - 1).collect(); sig.push('…'); } if sig.is_empty() { @@ -575,29 +585,51 @@ fn collect_definitions(parsed: &ParsedSource, out: &mut Vec) { } /// Look one level up in the tree for a doc-comment sibling immediately before -/// the definition node. Best-effort; backends with richer comment handling can -/// override via their own [`Language::doc_comment`]. +/// the definition node. "Immediately" is strict: the previous sibling must be +/// a comment and no blank line may sit between them — a comment separated +/// from code by a blank line or by another definition documents nothing. +/// Best-effort; backends with richer comment handling can override via their +/// own [`Language::doc_comment`]. pub(crate) fn doc_comment_above(parsed: &ParsedSource, node: &tree_sitter::Node) -> Option { if !parsed.language.has_doc_comment(node) { return None; } - let mut prev = node.prev_sibling(); - let mut depth = 0; - while let Some(sib) = prev { - if sib.kind().contains("comment") { - let text = strip_comment_markers(sib.utf8_text(parsed.source.as_bytes()).ok()?); - if text.is_empty() { - return None; + let cmt = node.prev_sibling()?; + if !cmt.kind().contains("comment") { + return None; + } + let raw = cmt.utf8_text(parsed.source.as_bytes()).ok()?; + // Some grammars fold the comment's trailing newline into the comment + // node; cut it so adjacency is judged on real separating lines only. + let text_end = cmt.start_byte() + raw.trim_end_matches(['\n', '\r']).len(); + let gap = &parsed.source[text_end..node.start_byte()]; + if has_blank_line(gap) { + return None; + } + let text = strip_comment_markers(raw); + if text.is_empty() { + return None; + } + Some(text) +} + +/// True when `gap` contains a blank line: a newline followed only by +/// whitespace before the next newline (or end of gap). +fn has_blank_line(gap: &str) -> bool { + let mut after_newline = false; + for ch in gap.chars() { + match ch { + '\n' => { + if after_newline { + return true; + } + after_newline = true; } - return Some(text); - } - depth += 1; - if depth > 2 { - break; + ' ' | '\t' | '\r' => {} + _ => after_newline = false, } - prev = sib.prev_sibling(); } - None + false } /// Strip comment markers from a doc-comment node's text: `///`/`//!`/`//`, @@ -679,4 +711,20 @@ mod tests { assert!(sig.ends_with('…')); assert!(sig.chars().count() <= 120); } + + #[test] + fn cjk_signatures_cap_by_chars_not_bytes() { + // 100 CJK chars are 300 bytes: a byte-based cap would truncate this + // to ~40 chars; the cap counts characters, so it is kept whole. + let sig = clean_signature(&"汉".repeat(100)); + assert_eq!(sig.chars().count(), 100); + assert!(!sig.contains('…')); + } + + #[test] + fn truncation_still_caps_cjk_at_max_chars() { + let sig = clean_signature(&format!("fn f() {{ {} }}", "汉".repeat(200))); + assert_eq!(sig.chars().count(), 120); + assert!(sig.ends_with('…')); + } } diff --git a/crates/ctx-symbol/src/lib.rs b/crates/ctx-symbol/src/lib.rs index 45c3cf7..98b7dd1 100644 --- a/crates/ctx-symbol/src/lib.rs +++ b/crates/ctx-symbol/src/lib.rs @@ -257,9 +257,8 @@ pub fn compact_symbol(parsed: &ParsedSource, symbol: &Symbol) -> String { } out.push_str(indent); out.push_str(parsed.language.comment_prefix()); - out.push_str(" ... ["); - out.push_str(&omitted.to_string()); - out.push_str(" lines omitted]"); + out.push(' '); + out.push_str(&omit_marker(omitted)); let closer = parsed.language.comment_close(); if !closer.is_empty() { out.push(' '); @@ -284,6 +283,15 @@ fn emit_newline(text: &str) -> &'static str { if text.contains("\r\n") { "\r\n" } else { "\n" } } +/// Fold marker for `n` omitted lines, singular-correct (`[1 line omitted]`). +fn omit_marker(n: usize) -> String { + if n == 1 { + "... [1 line omitted]".to_string() + } else { + format!("... [{n} lines omitted]") + } +} + /// The foldable body node of a definition: the `body` field of the /// (unwrapped) definition node, else the first descendant (source order) /// whose kind is in [`Language::body_node_kinds`]. `None` when the backend @@ -436,9 +444,8 @@ fn fold_at_body_node( out.push_str(nl); out.push_str(indent); out.push_str(parsed.language.comment_prefix()); - out.push_str(" ... ["); - out.push_str(&omitted.to_string()); - out.push_str(" lines omitted]"); + out.push(' '); + out.push_str(&omit_marker(omitted)); if keep_tail { out.push_str(nl); out.push_str(tail); @@ -472,9 +479,8 @@ fn fold_at_body_node( out.push_str(nl); out.push_str(indent); out.push_str(parsed.language.comment_prefix()); - out.push_str(" ... ["); - out.push_str(&omitted.to_string()); - out.push_str(" lines omitted]"); + out.push(' '); + out.push_str(&omit_marker(omitted)); let closer = parsed.language.comment_close(); if !closer.is_empty() { out.push(' '); @@ -643,12 +649,16 @@ fn boundary_continues( return false; } let prev = prev.trim_end(); - let mut tokens = [ + // Inside a preprocessor directive a trailing `\` splices the next line — + // the directive's own business, never a fold-blocking continuation. + const CONTINUATION_TOKENS: [char; 15] = [ '+', '-', '*', '/', '%', '=', '(', '[', ',', '&', '|', '~', '^', '<', '\\', ]; - if in_preproc { - tokens[14] = '\0'; - } + let tokens: &[char] = if in_preproc { + &CONTINUATION_TOKENS[..CONTINUATION_TOKENS.len() - 1] + } else { + &CONTINUATION_TOKENS + }; if prev.ends_with(tokens) { return true; } diff --git a/crates/ctx-symbol/tests/symbol_test.rs b/crates/ctx-symbol/tests/symbol_test.rs index 4df711f..9849364 100644 --- a/crates/ctx-symbol/tests/symbol_test.rs +++ b/crates/ctx-symbol/tests/symbol_test.rs @@ -764,7 +764,7 @@ fn compact_crlf_source_folds_with_stable_line_endings() { "directive line kept: {compact:?}" ); assert!( - compact.contains("// ... [1 lines omitted]"), + compact.contains("// ... [1 line omitted]"), "macro folds, comment continuation kept: {compact:?}" ); assert_eq!( @@ -818,7 +818,7 @@ fn compact_crlf_comment_masking_stays_aligned() { .expect("M extracted"); let compact = ctx_symbol::compact_symbol(&parsed, &m); assert!( - compact.contains("// ... [1 lines omitted]"), + compact.contains("// ... [1 line omitted]"), "closed comment does not block the fold: {compact:?}" ); assert_eq!( @@ -1429,3 +1429,27 @@ fn walks_survive_deeply_nested_source() { let compact = ctx_symbol::compact_symbol(&parsed, &symbols[0]); assert!(compact.contains("function f("), "header kept: {compact}"); } + +#[test] +fn doc_comment_requires_direct_adjacency() { + // A blank line between comment and definition breaks attachment. + let src = "\n/// Orphan docs.\n\npub fn lonely() {}\n"; + let symbols = ctx_symbol::outline(src, rust_path()).unwrap(); + let lonely = symbols.iter().find(|s| s.name == "lonely").unwrap(); + assert_eq!(lonely.doc_comment, None); + + // An intervening non-comment sibling breaks attachment too. + let src = "\n/// Orphan docs.\npub struct Gap {}\npub fn distant() {}\n"; + let symbols = ctx_symbol::outline(src, rust_path()).unwrap(); + let distant = symbols.iter().find(|s| s.name == "distant").unwrap(); + assert_eq!(distant.doc_comment, None); + // The comment still documents its immediate neighbor. + let gap = symbols.iter().find(|s| s.name == "Gap").unwrap(); + assert_eq!(gap.doc_comment.as_deref(), Some("Orphan docs.")); + + // Directly-above comments keep attaching. + let src = "\n/// Attached docs.\npub fn close() {}\n"; + let symbols = ctx_symbol::outline(src, rust_path()).unwrap(); + let close = symbols.iter().find(|s| s.name == "close").unwrap(); + assert_eq!(close.doc_comment.as_deref(), Some("Attached docs.")); +} diff --git a/crates/ctxctl/src/main.rs b/crates/ctxctl/src/main.rs index efb83e0..6c8d84e 100644 --- a/crates/ctxctl/src/main.rs +++ b/crates/ctxctl/src/main.rs @@ -141,7 +141,7 @@ enum Command { /// Symbol name (exact). #[arg(long)] name: String, - /// Restrict the match to a symbol kind (class, method, variable, …). + /// Restrict the match to a symbol kind (class, method, var, …). /// Without it, the first same-name symbol in source order wins. #[arg(long, value_enum)] kind: Option, @@ -169,6 +169,10 @@ enum Command { file: PathBuf, }, /// Run a command and print its output compressed by ctx-exec. + #[command(after_help = "The command must be passed as a single quoted argument \ + (shell-word splitting applies to it):\n\n \ + ctxctl exec \"cargo test\" # ok\n \ + ctxctl exec cargo test # error: unexpected argument 'test'")] Exec { /// Command line to run; shell-word quoting applies, e.g. `"cargo test -- --list"`. #[arg(allow_hyphen_values = true)] diff --git a/crates/ctxctl/tests/cli_test.rs b/crates/ctxctl/tests/cli_test.rs index 3af36ed..813a58d 100644 --- a/crates/ctxctl/tests/cli_test.rs +++ b/crates/ctxctl/tests/cli_test.rs @@ -846,7 +846,7 @@ fn symbol_compact_prunes_body() { "signature kept: {text}" ); assert!( - text.contains("// ... [1 lines omitted]"), + text.contains("// ... [1 line omitted]"), "no marker: {text}" ); assert!(!text.contains("a + b"), "body must be folded: {text}"); @@ -2031,3 +2031,39 @@ fn deps_default_ignore_globs_do_not_taint_ancestor_dirs() { "ancestor named target must not taint the import: {value}" ); } + +#[test] +fn exec_help_explains_single_quoted_argument() { + let output = run(&["exec", "--help"]); + assert_eq!(output.status.code(), Some(0), "stderr: {}", stderr(&output)); + let text = stdout(&output); + assert!( + text.contains("single quoted argument"), + "usage hint missing: {text}" + ); + assert!(text.contains("ctxctl exec \"cargo test\""), "hint: {text}"); +} + +#[test] +fn exec_bare_multiword_command_fails_with_hint() { + // `ctxctl exec cargo test` cannot work: the positional is a single + // quoted argument. Clap rejects it and points at `--help`, which + // carries the quoting requirement (see exec_help_explains_single_quoted_argument). + let output = run(&["exec", "cargo", "test"]); + assert_eq!(output.status.code(), Some(2)); + let err = stderr(&output); + assert!(err.contains("unexpected argument"), "{err}"); + assert!(err.contains("--help"), "{err}"); +} + +#[test] +fn symbol_kind_help_lists_var_not_variable() { + let output = run(&["symbol", "--help"]); + assert_eq!(output.status.code(), Some(0), "stderr: {}", stderr(&output)); + let text = stdout(&output); + assert!( + text.contains("(class, method, var,"), + "kind help must list `var`: {text}" + ); + assert!(!text.contains("variable"), "stale kind name: {text}"); +}