1use regex::Regex;
2use std::path::Path;
3
4use crate::lang::ext_to_lang;
5use crate::ui;
6
7fn make_fence(content: &str) -> String {
9 let max_run = content
10 .as_bytes()
11 .split(|&b| b != b'`')
12 .map(|run| run.len())
13 .max()
14 .unwrap_or(0);
15 let fence_len = if max_run >= 3 { max_run + 1 } else { 3 };
16 "`".repeat(fence_len)
17}
18
19fn extract_lines(content: &str, spec: &str) -> String {
27 let lines: Vec<&str> = content.lines().collect();
28 let total = lines.len();
29
30 let (start, end) = if let Some((left, right)) = spec.split_once('-') {
31 let s = if left.is_empty() {
32 1
33 } else {
34 left.parse::<usize>().unwrap_or(1)
35 };
36 let e = if right.is_empty() {
37 total
38 } else {
39 right.parse::<usize>().unwrap_or(total)
40 };
41 (s, e)
42 } else {
43 let n = spec.parse::<usize>().unwrap_or(1);
45 (n, n)
46 };
47
48 let start = start.max(1).min(total + 1);
50 let end = end.max(start).min(total);
51
52 if start > total {
53 return String::new();
54 }
55
56 lines[(start - 1)..end].join("\n")
57}
58
59pub struct ProcessResult {
61 pub original: String,
62 pub processed: String,
63}
64
65pub fn process_file(path: &Path) -> Result<ProcessResult, String> {
68 let content = std::fs::read_to_string(path)
69 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
70
71 let base_dir = path.parent().unwrap_or(Path::new("."));
72 let processed = process_content(&content, base_dir);
73
74 Ok(ProcessResult {
75 original: content,
76 processed,
77 })
78}
79
80pub fn process_content(content: &str, base_dir: &Path) -> String {
91 let open_re = Regex::new(r#"embed-src\s+src="([^"]+)""#).unwrap();
92 let lines_re = Regex::new(r#"lines="([^"]+)""#).unwrap();
93 let fence_re = Regex::new(r#"\bfence(?:="([^"]*)")?"#).unwrap();
94 let close_re = Regex::new(r#"(?:^|[^a-zA-Z0-9_])/embed-src\b"#).unwrap();
97
98 let lines: Vec<&str> = content.lines().collect();
99 let mut result = Vec::new();
100 let mut i = 0;
101 let has_trailing_newline = content.ends_with('\n');
102 let mut in_fence = false;
103 let mut fence_len: usize = 0;
104
105 while i < lines.len() {
106 let line = lines[i];
107
108 let trimmed = line.trim_start();
110 if trimmed.starts_with("```") {
111 let backtick_count = trimmed.bytes().take_while(|&b| b == b'`').count();
112 if !in_fence {
113 in_fence = true;
114 fence_len = backtick_count;
115 result.push(line.to_string());
116 i += 1;
117 continue;
118 } else if backtick_count >= fence_len {
119 in_fence = false;
120 fence_len = 0;
121 result.push(line.to_string());
122 i += 1;
123 continue;
124 }
125 }
126
127 if in_fence {
128 result.push(line.to_string());
129 i += 1;
130 continue;
131 }
132
133 if let Some(cap) = open_re.captures(line) {
134 let src_path = cap[1].to_string();
135 let lines_attr = lines_re.captures(line).map(|c| c[1].to_string());
136 let fence_cap = fence_re.captures(line);
137 let has_fence = fence_cap.is_some();
138 let fence_attr = fence_cap.and_then(|c| c.get(1).map(|m| m.as_str().to_string()));
139
140 result.push(line.to_string());
142
143 let mut found_close = false;
145 let mut close_line_idx = i + 1;
146 while close_line_idx < lines.len() {
147 if close_re.is_match(lines[close_line_idx]) {
148 found_close = true;
149 break;
150 }
151 close_line_idx += 1;
152 }
153
154 if !found_close {
155 ui::warn(&format!(
157 "no closing /embed-src found for directive at line {}",
158 i + 1
159 ));
160 i += 1;
161 continue;
162 }
163
164 let file_path = base_dir.join(&src_path);
166 let file_content = match std::fs::read_to_string(&file_path) {
167 Ok(c) => c,
168 Err(e) => {
169 ui::warn(&format!("could not read {}: {}", file_path.display(), e));
170 for line in &lines[(i + 1)..=close_line_idx] {
172 result.push(line.to_string());
173 }
174 i = close_line_idx + 1;
175 continue;
176 }
177 };
178
179 let file_content = match &lines_attr {
181 Some(spec) => extract_lines(&file_content, spec),
182 None => file_content,
183 };
184
185 if has_fence {
187 let lang = match &fence_attr {
188 Some(lang) if !lang.is_empty() && lang != "auto" => lang.to_string(),
189 _ => {
190 let ext = Path::new(&src_path)
192 .extension()
193 .and_then(|e| e.to_str())
194 .unwrap_or("");
195 ext_to_lang(ext).to_string()
196 }
197 };
198 let fence = make_fence(&file_content);
199 result.push(format!("{}{}", fence, lang));
200 result.push(file_content.trim_end().to_string());
201 result.push(fence);
202 } else {
203 let trimmed = file_content.trim_end();
205 if !trimmed.is_empty() {
206 result.push(trimmed.to_string());
207 }
208 }
209
210 result.push(lines[close_line_idx].to_string());
212 i = close_line_idx + 1;
213 } else {
214 result.push(line.to_string());
215 i += 1;
216 }
217 }
218
219 let mut output = result.join("\n");
220 if has_trailing_newline {
221 output.push('\n');
222 }
223 output
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use std::path::Path;
230
231 #[test]
232 fn no_directives() {
233 let input = "# Hello\n\nSome text.\n";
234 let result = process_content(input, Path::new("."));
235 assert_eq!(result, input);
236 }
237
238 #[test]
239 fn missing_close_tag() {
240 let input = "<!-- embed-src src=\"foo.rs\" -->\nstale content\n";
241 let result = process_content(input, Path::new("."));
242 assert_eq!(result, input);
244 }
245
246 #[test]
247 fn extract_lines_single() {
248 let content = "line1\nline2\nline3\n";
249 assert_eq!(extract_lines(content, "2"), "line2");
250 }
251
252 #[test]
253 fn extract_lines_range() {
254 let content = "a\nb\nc\nd\ne\n";
255 assert_eq!(extract_lines(content, "2-4"), "b\nc\nd");
256 }
257
258 #[test]
259 fn extract_lines_open_end() {
260 let content = "a\nb\nc\nd\n";
261 assert_eq!(extract_lines(content, "3-"), "c\nd");
262 }
263
264 #[test]
265 fn extract_lines_open_start() {
266 let content = "a\nb\nc\nd\n";
267 assert_eq!(extract_lines(content, "-2"), "a\nb");
268 }
269
270 #[test]
271 fn extract_lines_out_of_bounds() {
272 let content = "a\nb\nc\n";
273 assert_eq!(extract_lines(content, "2-100"), "b\nc");
275 assert_eq!(extract_lines(content, "100"), "");
277 }
278}