vtcode_commons/
text_fence.rs1pub const MAX_CLEAN_TOOL_NAME_LEN: usize = 64;
10
11pub 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 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
49pub 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 ranges.push(segment_start..text.len());
75 ranges.retain(|range| range.start < range.end);
76 ranges
77}
78
79pub 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
92pub 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
108pub 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}