Skip to main content

embed_src/
embed.rs

1use regex::Regex;
2use std::path::Path;
3
4use crate::lang::ext_to_lang;
5use crate::ui;
6
7/// Return a backtick fence long enough to avoid collisions with backtick runs in `content`.
8fn 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
19/// Parse a `lines` attribute value and extract the matching lines from content.
20///
21/// Supported formats (all 1-indexed):
22///   - `"5"` — single line 5
23///   - `"5-10"` — lines 5 through 10 (inclusive)
24///   - `"5-"` — line 5 through end of file
25///   - `"-10"` — line 1 through 10
26fn 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        // Single line number.
44        let n = spec.parse::<usize>().unwrap_or(1);
45        (n, n)
46    };
47
48    // Clamp to valid range.
49    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
59/// Result of processing a single file.
60pub struct ProcessResult {
61    pub original: String,
62    pub processed: String,
63}
64
65/// Process a file: find all `embed-src src="..."` directives and replace the
66/// content between them and their closing `/embed-src` markers.
67pub 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
80/// Process content, resolving source paths relative to `base_dir`.
81///
82/// Markers are comment-agnostic: any line containing
83/// `embed-src src="path"` is an opening marker, and any line containing
84/// `/embed-src` is a closing marker. This allows embedding in any file type
85/// (markdown, Rust, Python, YAML, etc.).
86///
87/// By default, content is inserted raw. Use the `fence` attribute to wrap in
88/// markdown code fences: `fence` or `fence="auto"` auto-detects the language
89/// from the source extension; `fence="python"` uses an explicit language tag.
90pub 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    // Match /embed-src preceded by a non-word character (space, comment chars, etc.)
95    // but not as part of a URL like "urmzd/embed-src".
96    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        // Track backtick-fenced code blocks so directives inside them are skipped.
109        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            // Emit the opening marker line.
141            result.push(line.to_string());
142
143            // Skip lines until we find the closing marker or run out of lines.
144            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                // No closing marker: emit remaining lines unchanged.
156                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            // Read source file.
165            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                    // Emit original lines unchanged.
171                    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            // Apply line-range filter if specified.
180            let file_content = match &lines_attr {
181                Some(spec) => extract_lines(&file_content, spec),
182                None => file_content,
183            };
184
185            // Insert content: raw or fenced.
186            if has_fence {
187                let lang = match &fence_attr {
188                    Some(lang) if !lang.is_empty() && lang != "auto" => lang.to_string(),
189                    _ => {
190                        // auto-detect from extension
191                        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                // Raw insertion.
204                let trimmed = file_content.trim_end();
205                if !trimmed.is_empty() {
206                    result.push(trimmed.to_string());
207                }
208            }
209
210            // Emit the closing marker line.
211            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        // Should leave content unchanged when no closing tag.
243        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        // End beyond file length: clamp to last line.
274        assert_eq!(extract_lines(content, "2-100"), "b\nc");
275        // Start beyond file length: empty.
276        assert_eq!(extract_lines(content, "100"), "");
277    }
278}