Skip to main content

vtcode_commons/
text_fence.rs

1//! Shared fenced-code-block scanning and tool-name hygiene.
2//!
3//! Content-derived tool-call extraction must not treat markup inside fences
4//! (documentation, test fixtures) or mid-prose tag mentions as executable
5//! calls. This module is pure infrastructure used by the runloop `text_tools`
6//! parsers and the skill sub-LLM textual fallback.
7
8/// Maximum accepted tool-name length for content-derived calls.
9pub const MAX_CLEAN_TOOL_NAME_LEN: usize = 64;
10
11/// Classify a line as a fenced-code-block delimiter.
12///
13/// Returns `Some((fence_char, is_closing))` when the line opens or closes a
14/// fence: an opener is 3+ backticks/tildes (info string allowed), a closer is
15/// 3+ of the same fence character with nothing but whitespace after. A line
16/// with an info string while a fence is open is body text (`None`).
17pub fn fence_delimiter_line(line: &str, open_char: Option<char>) -> Option<(char, bool)> {
18    let trimmed = line.trim();
19    let mut chars = trimmed.chars();
20    let first = chars.next()?;
21    if first != '`' && first != '~' {
22        return None;
23    }
24    let mut count = 1usize;
25    let mut closed_run = false;
26    for ch in chars {
27        if ch == first {
28            count += 1;
29        } else {
30            closed_run = true;
31            break;
32        }
33    }
34    if count < 3 {
35        return None;
36    }
37    if closed_run {
38        // Info string present: only valid as an opener.
39        return Some((first, false));
40    }
41    let only_whitespace = trimmed.chars().skip(count).all(char::is_whitespace);
42    match open_char {
43        Some(open) if open == first && only_whitespace => Some((first, true)),
44        Some(_) => None,
45        None => Some((first, false)),
46    }
47}
48
49/// Byte ranges of `text` outside fenced code blocks.
50///
51/// Markup inside a fence is quoted documentation, not an executable call.
52pub fn unfenced_byte_ranges(text: &str) -> Vec<std::ops::Range<usize>> {
53    let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
54    let mut open_char: Option<char> = None;
55    let mut segment_start = 0usize;
56    let mut cursor = 0usize;
57    for line in text.split_inclusive('\n') {
58        let line_start = cursor;
59        cursor += line.len();
60        let Some((fence_char, is_closing)) = fence_delimiter_line(line, open_char) else {
61            continue;
62        };
63        if is_closing {
64            open_char = None;
65        } else if open_char.is_none() {
66            ranges.push(segment_start..line_start);
67            open_char = Some(fence_char);
68        }
69        segment_start = cursor;
70    }
71    // Fail-open for truncated streams: an unclosed fence opener must not
72    // swallow a real tool call in the tail (existing runloop contract).
73    // Closed fences still exclude their bodies via the ranges above.
74    ranges.push(segment_start..text.len());
75    ranges.retain(|range| range.start < range.end);
76    ranges
77}
78
79/// First byte offset of `needle` at or after `from`, outside fenced code blocks.
80pub fn find_unfenced_from(text: &str, needle: &str, from: usize) -> Option<usize> {
81    unfenced_byte_ranges(text).into_iter().find_map(|range| {
82        let start = range.start.max(from);
83        if start >= range.end {
84            return None;
85        }
86        text.get(start..range.end)
87            .and_then(|slice| slice.find(needle))
88            .map(|index| start + index)
89    })
90}
91
92/// Whether `raw` is a clean tool identifier (not prose, not a fixture blob).
93pub fn is_clean_tool_name(raw: &str) -> bool {
94    let trimmed = raw.trim();
95    if trimmed.is_empty() || trimmed.len() > MAX_CLEAN_TOOL_NAME_LEN {
96        return false;
97    }
98    let mut chars = trimmed.chars();
99    let Some(first) = chars.next() else {
100        return false;
101    };
102    if !first.is_ascii_alphabetic() {
103        return false;
104    }
105    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
106}
107
108/// Whether `raw` is safe to dispatch as a native tool name (not a prose blob).
109///
110/// Allows MCP-visible names (`mcp__github__list`) and names with `:`/`-`/`.`
111/// while rejecting whitespace/newlines/backticks and absurd lengths.
112pub fn is_dispatchable_tool_name(raw: &str) -> bool {
113    let trimmed = raw.trim();
114    if trimmed.is_empty() || trimmed.len() > MAX_CLEAN_TOOL_NAME_LEN {
115        return false;
116    }
117    if trimmed
118        .chars()
119        .any(|ch| ch.is_whitespace() || matches!(ch, '`' | '\u{2014}' | '\u{2013}'))
120    {
121        return false;
122    }
123    true
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn unfenced_ranges_exclude_fence_bodies() {
132        let text = "before\n```sh\nINSIDE\n```\nafter\n";
133        let ranges = unfenced_byte_ranges(text);
134        assert_eq!(ranges.len(), 2);
135        let first = ranges.first().expect("first unfenced range");
136        let second = ranges.get(1).expect("second unfenced range");
137        assert!(text.get(first.clone()).is_some_and(|slice| slice.contains("before")));
138        assert!(text.get(second.clone()).is_some_and(|slice| slice.contains("after")));
139    }
140
141    #[test]
142    fn find_unfenced_skips_matches_inside_fences() {
143        let text = "docs\n```\nNEEDLE\n```\nok NEEDLE\n";
144        let hit = find_unfenced_from(text, "NEEDLE", 0).expect("unfenced match");
145        assert_eq!(text.get(hit..hit + "NEEDLE".len()), Some("NEEDLE"));
146    }
147
148    #[test]
149    fn clean_and_dispatchable_name_rules() {
150        assert!(is_clean_tool_name("exec_command"));
151        assert!(!is_clean_tool_name("has space"));
152        assert!(is_dispatchable_tool_name("mcp::github::list"));
153        assert!(!is_dispatchable_tool_name("has space"));
154    }
155}