Skip to main content

embed_src/
embed.rs

1use regex::Regex;
2use std::path::Path;
3
4use crate::lang::ext_to_lang;
5
6/// Return a backtick fence long enough to avoid collisions with backtick runs in `content`.
7fn make_fence(content: &str) -> String {
8    let max_run = content
9        .as_bytes()
10        .split(|&b| b != b'`')
11        .map(|run| run.len())
12        .max()
13        .unwrap_or(0);
14    let fence_len = if max_run >= 3 { max_run + 1 } else { 3 };
15    "`".repeat(fence_len)
16}
17
18/// Result of processing a single file.
19pub struct ProcessResult {
20    pub original: String,
21    pub processed: String,
22}
23
24/// Process a file: find all `embed-src src="..."` directives and replace the
25/// content between them and their closing `/embed-src` markers.
26pub fn process_file(path: &Path) -> Result<ProcessResult, String> {
27    let content = std::fs::read_to_string(path)
28        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
29
30    let base_dir = path.parent().unwrap_or(Path::new("."));
31    let processed = process_content(&content, base_dir);
32
33    Ok(ProcessResult {
34        original: content,
35        processed,
36    })
37}
38
39/// Process content, resolving source paths relative to `base_dir`.
40///
41/// Markers are comment-agnostic: any line containing
42/// `embed-src src="path"` is an opening marker, and any line containing
43/// `/embed-src` is a closing marker. This allows embedding in any file type
44/// (markdown, Rust, Python, YAML, etc.).
45///
46/// By default, content is inserted raw. Use the `fence` attribute to wrap in
47/// markdown code fences: `fence` or `fence="auto"` auto-detects the language
48/// from the source extension; `fence="python"` uses an explicit language tag.
49pub fn process_content(content: &str, base_dir: &Path) -> String {
50    let open_re = Regex::new(r#"embed-src\s+src="([^"]+)"(?:\s+fence(?:="([^"]*)")?)?"#).unwrap();
51    // Match /embed-src preceded by a non-word character (space, comment chars, etc.)
52    // but not as part of a URL like "urmzd/embed-src".
53    let close_re = Regex::new(r#"(?:^|[^a-zA-Z0-9_])/embed-src\b"#).unwrap();
54
55    let lines: Vec<&str> = content.lines().collect();
56    let mut result = Vec::new();
57    let mut i = 0;
58    let has_trailing_newline = content.ends_with('\n');
59    let mut in_fence = false;
60    let mut fence_len: usize = 0;
61
62    while i < lines.len() {
63        let line = lines[i];
64
65        // Track backtick-fenced code blocks so directives inside them are skipped.
66        let trimmed = line.trim_start();
67        if trimmed.starts_with("```") {
68            let backtick_count = trimmed.bytes().take_while(|&b| b == b'`').count();
69            if !in_fence {
70                in_fence = true;
71                fence_len = backtick_count;
72                result.push(line.to_string());
73                i += 1;
74                continue;
75            } else if backtick_count >= fence_len {
76                in_fence = false;
77                fence_len = 0;
78                result.push(line.to_string());
79                i += 1;
80                continue;
81            }
82        }
83
84        if in_fence {
85            result.push(line.to_string());
86            i += 1;
87            continue;
88        }
89
90        if let Some(cap) = open_re.captures(line) {
91            let src_path = cap[1].to_string();
92            let fence_attr = cap.get(2).map(|m| m.as_str().to_string());
93            // Distinguish fence (no value) vs fence="value" vs no fence at all.
94            // Group 0 full match tells us if "fence" appeared.
95            let has_fence = cap.get(0).unwrap().as_str().contains("fence");
96
97            // Emit the opening marker line.
98            result.push(line.to_string());
99
100            // Skip lines until we find the closing marker or run out of lines.
101            let mut found_close = false;
102            let mut close_line_idx = i + 1;
103            while close_line_idx < lines.len() {
104                if close_re.is_match(lines[close_line_idx]) {
105                    found_close = true;
106                    break;
107                }
108                close_line_idx += 1;
109            }
110
111            if !found_close {
112                // No closing marker: emit remaining lines unchanged.
113                eprintln!(
114                    "Warning: no closing /embed-src found for directive at line {}",
115                    i + 1
116                );
117                i += 1;
118                continue;
119            }
120
121            // Read source file.
122            let file_path = base_dir.join(&src_path);
123            let file_content = match std::fs::read_to_string(&file_path) {
124                Ok(c) => c,
125                Err(e) => {
126                    eprintln!("Warning: could not read {}: {}", file_path.display(), e);
127                    // Emit original lines unchanged.
128                    for line in &lines[(i + 1)..=close_line_idx] {
129                        result.push(line.to_string());
130                    }
131                    i = close_line_idx + 1;
132                    continue;
133                }
134            };
135
136            // Insert content: raw or fenced.
137            if has_fence {
138                let lang = match &fence_attr {
139                    Some(lang) if !lang.is_empty() && lang != "auto" => lang.to_string(),
140                    _ => {
141                        // auto-detect from extension
142                        let ext = Path::new(&src_path)
143                            .extension()
144                            .and_then(|e| e.to_str())
145                            .unwrap_or("");
146                        ext_to_lang(ext).to_string()
147                    }
148                };
149                let fence = make_fence(&file_content);
150                result.push(format!("{}{}", fence, lang));
151                result.push(file_content.trim_end().to_string());
152                result.push(fence);
153            } else {
154                // Raw insertion.
155                let trimmed = file_content.trim_end();
156                if !trimmed.is_empty() {
157                    result.push(trimmed.to_string());
158                }
159            }
160
161            // Emit the closing marker line.
162            result.push(lines[close_line_idx].to_string());
163            i = close_line_idx + 1;
164        } else {
165            result.push(line.to_string());
166            i += 1;
167        }
168    }
169
170    let mut output = result.join("\n");
171    if has_trailing_newline {
172        output.push('\n');
173    }
174    output
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use std::path::Path;
181
182    #[test]
183    fn no_directives() {
184        let input = "# Hello\n\nSome text.\n";
185        let result = process_content(input, Path::new("."));
186        assert_eq!(result, input);
187    }
188
189    #[test]
190    fn missing_close_tag() {
191        let input = "<!-- embed-src src=\"foo.rs\" -->\nstale content\n";
192        let result = process_content(input, Path::new("."));
193        // Should leave content unchanged when no closing tag.
194        assert_eq!(result, input);
195    }
196}