Skip to main content

microsoft_fast_build/
locator.rs

1use std::collections::HashMap;
2use std::path::Path;
3use crate::error::RenderError;
4
5pub struct Locator {
6    templates: HashMap<String, String>,
7}
8
9impl Locator {
10    /// Create a Locator by scanning files matching glob patterns.
11    /// Each pattern is a glob like `"./components/**/*.html"`.
12    /// The element name for each file is its stem (e.g. `my-button` from `my-button.html`).
13    /// Errors if two different files resolve to the same element name.
14    pub fn from_patterns(patterns: &[&str]) -> Result<Self, RenderError> {
15        // element_name → list of file paths that produce it
16        let mut element_files: HashMap<String, Vec<String>> = HashMap::new();
17
18        for &pattern in patterns {
19            let dir_str = static_prefix_dir(pattern);
20            let dir_path = Path::new(&dir_str);
21
22            if !dir_path.exists() || !dir_path.is_dir() {
23                continue;
24            }
25
26            let mut files = Vec::new();
27            walk_html_files(dir_path, &mut files).map_err(|e| RenderError::TemplateReadError {
28                path: dir_str.clone(),
29                message: e.to_string(),
30            })?;
31
32            let norm_pattern = normalize_path(pattern);
33            for file_path in files {
34                let norm_file = normalize_path(&file_path.to_string_lossy());
35                if glob_match(&norm_pattern, &norm_file) {
36                    if let Some(stem) = file_path.file_stem().and_then(|s| s.to_str()) {
37                        element_files
38                            .entry(stem.to_string())
39                            .or_default()
40                            .push(norm_file);
41                    }
42                }
43            }
44        }
45
46        let mut templates = HashMap::new();
47        for (element, paths) in element_files {
48            if paths.len() > 1 {
49                return Err(RenderError::DuplicateTemplate { element, paths });
50            }
51            let path = &paths[0];
52            let content = std::fs::read_to_string(path).map_err(|e| RenderError::TemplateReadError {
53                path: path.clone(),
54                message: e.to_string(),
55            })?;
56            templates.insert(element, content);
57        }
58
59        Ok(Locator { templates })
60    }
61
62    /// Create a Locator from an explicit map (useful for testing without filesystem).
63    pub fn from_templates(templates: HashMap<String, String>) -> Self {
64        Locator { templates }
65    }
66
67    /// Add a template directly.
68    pub fn add_template(&mut self, element_name: &str, content: &str) {
69        self.templates.insert(element_name.to_string(), content.to_string());
70    }
71
72    pub fn get_template(&self, element_name: &str) -> Option<&str> {
73        self.templates.get(element_name).map(|s| s.as_str())
74    }
75
76    pub fn has_template(&self, element_name: &str) -> bool {
77        self.templates.contains_key(element_name)
78    }
79}
80
81/// Normalize path separators to `/` and strip a leading `./`.
82fn normalize_path(path: &str) -> String {
83    let normalized = path.replace('\\', "/");
84    normalized.trim_start_matches("./").to_string()
85}
86
87/// Return the static directory prefix before the first wildcard in `pattern`.
88fn static_prefix_dir(pattern: &str) -> String {
89    let first_wild = pattern.find(|c: char| c == '*' || c == '?');
90    let base = match first_wild {
91        None => match pattern.rfind('/') {
92            Some(i) => pattern[..=i].to_string(),
93            None => ".".to_string(),
94        },
95        Some(i) => {
96            let before = &pattern[..i];
97            match before.rfind('/') {
98                Some(j) => pattern[..=j].to_string(),
99                None => ".".to_string(),
100            }
101        }
102    };
103    base
104}
105
106/// Recursively walk `dir` and collect all `.html` files.
107fn walk_html_files(dir: &Path, result: &mut Vec<std::path::PathBuf>) -> std::io::Result<()> {
108    for entry in std::fs::read_dir(dir)? {
109        let entry = entry?;
110        let path = entry.path();
111        if path.is_dir() {
112            walk_html_files(&path, result)?;
113        } else if path.extension().and_then(|s| s.to_str()) == Some("html") {
114            result.push(path);
115        }
116    }
117    Ok(())
118}
119
120/// Match a normalized path against a normalized glob pattern.
121/// `*` matches any chars within one path segment.
122/// `**` matches zero or more path segments.
123/// `?` matches exactly one character within a segment.
124fn glob_match(pattern: &str, path: &str) -> bool {
125    let pat_segs: Vec<&str> = pattern.split('/').collect();
126    let path_segs: Vec<&str> = path.split('/').collect();
127    match_segments(&pat_segs, &path_segs)
128}
129
130fn match_segments(pat: &[&str], path: &[&str]) -> bool {
131    if pat.is_empty() {
132        return path.is_empty();
133    }
134    if pat[0] == "**" {
135        // `**` matches zero or more path segments.
136        // Try consuming zero segments (skip `**` and keep current path).
137        if match_segments(&pat[1..], path) {
138            return true;
139        }
140        // Try consuming one more path segment (keep `**` and advance path).
141        if !path.is_empty() && match_segments(pat, &path[1..]) {
142            return true;
143        }
144        return false;
145    }
146    if path.is_empty() {
147        return false;
148    }
149    if match_segment(pat[0], path[0]) {
150        match_segments(&pat[1..], &path[1..])
151    } else {
152        false
153    }
154}
155
156fn match_segment(pat: &str, seg: &str) -> bool {
157    let pat_chars: Vec<char> = pat.chars().collect();
158    let seg_chars: Vec<char> = seg.chars().collect();
159    match_chars(&pat_chars, &seg_chars)
160}
161
162fn match_chars(pat: &[char], seg: &[char]) -> bool {
163    if pat.is_empty() {
164        return seg.is_empty();
165    }
166    if pat[0] == '*' {
167        // `*` matches 0 or more characters (within a single segment).
168        for i in 0..=seg.len() {
169            if match_chars(&pat[1..], &seg[i..]) {
170                return true;
171            }
172        }
173        return false;
174    }
175    if seg.is_empty() {
176        return false;
177    }
178    if pat[0] == '?' || pat[0] == seg[0] {
179        match_chars(&pat[1..], &seg[1..])
180    } else {
181        false
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_glob_star() {
191        assert!(glob_match("tests/fixtures/*.html", "tests/fixtures/my-button.html"));
192        assert!(!glob_match("tests/fixtures/*.html", "tests/fixtures/duplicate/my-button.html"));
193    }
194
195    #[test]
196    fn test_glob_double_star() {
197        assert!(glob_match("tests/fixtures/**/*.html", "tests/fixtures/my-button.html"));
198        assert!(glob_match("tests/fixtures/**/*.html", "tests/fixtures/duplicate/my-button.html"));
199        assert!(!glob_match("tests/fixtures/**/*.html", "tests/fixtures/my-button.txt"));
200    }
201
202    #[test]
203    fn test_glob_question_mark() {
204        assert!(glob_match("tests/fixtures/my-?.html", "tests/fixtures/my-x.html"));
205        assert!(!glob_match("tests/fixtures/my-?.html", "tests/fixtures/my-xy.html"));
206    }
207}