oas_forge/
scanner.rs

1use crate::dsl;
2use crate::error::{Error, Result};
3use crate::generics::Monomorphizer;
4use crate::index::Registry;
5use crate::preprocessor;
6use crate::visitor::{self, ExtractedItem};
7use regex::Regex;
8use std::collections::HashSet;
9use std::path::PathBuf;
10use std::sync::OnceLock;
11use walkdir::WalkDir;
12
13/// Represents a source-mapped snippet of OpenAPI definition.
14#[derive(Debug, Clone)]
15pub struct Snippet {
16    pub content: String,
17    pub file_path: PathBuf,
18    pub line_number: usize,
19    pub operation_id: Option<String>,
20}
21
22// DX Macros Preprocessor
23// Implementation of auto-quoting and short-hands.
24pub fn preprocess_macros(snippet: &Snippet, registry: &mut Registry) -> Snippet {
25    let content = &snippet.content;
26    let mut new_lines = Vec::new();
27
28    // Regex definition
29    static GENERIC_RE: OnceLock<Regex> = OnceLock::new();
30    let generic_re =
31        GENERIC_RE.get_or_init(|| Regex::new(r"\$([a-zA-Z0-9_]+)<([a-zA-Z0-9_, ]+)>").unwrap());
32
33    static MACRO_INSERT_RE: OnceLock<Regex> = OnceLock::new();
34    let macro_insert_re = MACRO_INSERT_RE
35        .get_or_init(|| Regex::new(r"^(\s*)(-)?\s*@insert\s+([a-zA-Z0-9_]+)$").unwrap());
36
37    static MACRO_EXTEND_RE: OnceLock<Regex> = OnceLock::new();
38    let macro_extend_re =
39        MACRO_EXTEND_RE.get_or_init(|| Regex::new(r"^(\s*)@extend\s+(.+)$").unwrap());
40
41    // static MACRO_RETURN_RE: OnceLock<Regex> = OnceLock::new();
42    // let macro_return_re = MACRO_RETURN_RE.get_or_init(|| {
43    //    Regex::new(r#"^(\s*)@return\s+(\d{3})\s*:\s*([^\s"]+)(?:\s+"(.*)")?$"#).unwrap()
44    // });
45
46    static ARRAY_SHORT_RE: OnceLock<Regex> = OnceLock::new();
47    let array_short_re =
48        ARRAY_SHORT_RE.get_or_init(|| Regex::new(r"\$Vec<([a-zA-Z0-9_]+)>").unwrap());
49
50    for line in content.lines() {
51        let current_lines = vec![line.to_string()];
52
53        // 0. Remove conflicting @return expansion
54        // dsl.rs handles @return differently and robustly.
55        // Expanding it here creates a conflict because dsl.rs does not recognize the expanded YAML.
56
57        /*
58        // Was:
59        if let Some(caps) = macro_return_re.captures(line) {
60             // ...
61        }
62        */
63
64        for sub_line in current_lines {
65            let mut processed_line = sub_line.clone();
66
67            // 1. Array Shorthand ($Vec<T>)
68            // Replace ALL occurrences in the line
69            while let Some(caps) = array_short_re.captures(&processed_line.clone()) {
70                let full_match = caps.get(0).unwrap().as_str();
71                let type_name = caps.get(1).unwrap().as_str();
72                // Inline JSON syntax for array
73                let replacement = format!(
74                    "{{ type: array, items: {{ $ref: \"#/components/schemas/{}\" }} }}",
75                    type_name
76                );
77                processed_line = processed_line.replace(full_match, &replacement);
78            }
79
80            // 2. Generics Flattening (Inline) + Instantiation
81            // (Existing logic)
82            while let Some(caps) = generic_re.captures(&processed_line.clone()) {
83                let full_match = caps.get(0).unwrap().as_str();
84                let name = caps.get(1).unwrap().as_str();
85                let args_raw = caps.get(2).unwrap().as_str();
86
87                // Instantiate via Monomorphizer
88                let mut mono = Monomorphizer::new(registry);
89                let concrete_name = mono.monomorphize(name, args_raw);
90
91                // Replace with Smart Ref format ($Name)
92                let replacement = format!("${}", concrete_name);
93                processed_line = processed_line.replace(full_match, &replacement);
94            }
95
96            // 3. Short-hand @insert
97            if let Some(caps) = macro_insert_re.captures(&processed_line) {
98                let indent = &caps[1];
99                let name = &caps[3];
100
101                if !registry.fragments.contains_key(name) {
102                    let final_indent = format!("{}- ", indent);
103                    new_lines.push(format!(
104                        "{}$ref: \"#/components/parameters/{}\"",
105                        final_indent, name
106                    ));
107                    continue;
108                }
109            }
110
111            // 4. Auto-Quoting @extend
112            if let Some(caps) = macro_extend_re.captures(&processed_line) {
113                let indent = &caps[1];
114                let content = &caps[2];
115                let escaped_content = content.replace('\'', "''");
116                new_lines.push(format!("{}x-openapi-extend: '{}'", indent, escaped_content));
117                continue;
118            }
119
120            new_lines.push(processed_line);
121        }
122    }
123
124    Snippet {
125        content: new_lines.join("\n"),
126        file_path: snippet.file_path.clone(),
127        line_number: snippet.line_number,
128        operation_id: snippet.operation_id.clone(),
129    }
130}
131
132pub fn substitute_smart_references(content: &str, schemas: &HashSet<String>) -> String {
133    let mut result = String::with_capacity(content.len());
134    let chars: Vec<char> = content.chars().collect();
135    let mut i = 0;
136
137    while i < chars.len() {
138        if chars[i] == '$' {
139            let mut j = i + 1;
140            if j < chars.len() && (chars[j].is_alphabetic() || chars[j] == '_') {
141                while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') {
142                    j += 1;
143                }
144
145                let ident: String = chars[i + 1..j].iter().collect();
146
147                if schemas.contains(&ident) {
148                    let is_quoted = i > 0 && chars[i - 1] == '"';
149
150                    if !is_quoted {
151                        result.push('"');
152                    }
153                    result.push_str("#/components/schemas/");
154                    result.push_str(&ident);
155                    if !is_quoted {
156                        result.push('"');
157                    }
158
159                    i = j;
160                    continue;
161                }
162            }
163        }
164        result.push(chars[i]);
165        i += 1;
166    }
167    result
168}
169
170fn finalize_substitution(content: &str) -> String {
171    let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string());
172    let step1 = content.replace(r"\$", "$");
173    step1.replace("{{CARGO_PKG_VERSION}}", &version)
174}
175
176pub fn scan_directories(roots: &[PathBuf], includes: &[PathBuf]) -> Result<Vec<Snippet>> {
177    let mut registry = Registry::new();
178    let mut operation_snippets: Vec<Snippet> = Vec::new();
179    let mut files_found = false;
180
181    let mut all_paths = Vec::new();
182
183    for root in roots {
184        for entry in WalkDir::new(root) {
185            let entry = entry.map_err(|e| Error::Io(std::io::Error::other(e)))?;
186            let path = entry.path().to_path_buf();
187            if path.is_file() {
188                all_paths.push(path);
189            }
190        }
191    }
192    for path in includes {
193        if path.exists() {
194            all_paths.push(path.to_path_buf());
195        }
196    }
197
198    if !all_paths.is_empty() {
199        files_found = true;
200    }
201
202    // PASS 1: Indexing
203    for path in all_paths {
204        if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
205            match ext {
206                "rs" => {
207                    let extracted = visitor::extract_from_file(path.clone())?;
208                    for item in extracted {
209                        match item {
210                            ExtractedItem::Schema {
211                                name,
212                                content,
213                                line,
214                            } => {
215                                if let Some(n) = name {
216                                    registry.insert_schema(n, content.clone());
217                                }
218                                operation_snippets.push(Snippet {
219                                    content,
220                                    file_path: path.clone(),
221                                    line_number: line,
222                                    operation_id: None,
223                                });
224                            }
225                            ExtractedItem::RouteDSL {
226                                content,
227                                line,
228                                operation_id,
229                            } => {
230                                operation_snippets.push(Snippet {
231                                    content,
232                                    file_path: path.clone(),
233                                    line_number: line,
234                                    operation_id: Some(operation_id),
235                                });
236                            }
237                            ExtractedItem::Fragment {
238                                name,
239                                params,
240                                content,
241                                ..
242                            } => {
243                                registry.insert_fragment(name, params, content);
244                            }
245                            ExtractedItem::Blueprint {
246                                name,
247                                params,
248                                content,
249                                ..
250                            } => {
251                                registry.insert_blueprint(name, params, content);
252                            }
253                        }
254                    }
255                }
256                "json" | "yaml" | "yml" => {
257                    let content = std::fs::read_to_string(&path)?;
258                    operation_snippets.push(Snippet {
259                        content,
260                        file_path: path.clone(),
261                        line_number: 1,
262                        operation_id: None,
263                    });
264                }
265                _ => {}
266            }
267        }
268    }
269
270    // PASS 2: Pre-Processing & DSL Compilation
271    let mut preprocessed_snippets = Vec::new();
272    for snippet in operation_snippets {
273        // 2a. Expand Macros
274        let macrod_snippet = preprocess_macros(&snippet, &mut registry);
275
276        // 2b. Expand Fragments
277        let expanded_content = preprocessor::preprocess(&macrod_snippet.content, &registry);
278
279        // 2c. Compile DSL -> YAML
280        let final_content = if let Some(op_id) = &macrod_snippet.operation_id {
281            let lines: Vec<String> = expanded_content.lines().map(|s| s.to_string()).collect();
282            if let Some(yaml) = dsl::parse_route_dsl(&lines, op_id) {
283                yaml
284            } else {
285                // If it was captured as DSL but failed parsing (e.g. no @route?), fallback.
286                // But visitor logic ensures @route exists.
287                // Parsing might fail if parse_route_dsl logic filters it out.
288                expanded_content
289            }
290        } else {
291            expanded_content
292        };
293
294        preprocessed_snippets.push(Snippet {
295            content: final_content,
296            file_path: macrod_snippet.file_path,
297            line_number: macrod_snippet.line_number,
298            operation_id: macrod_snippet.operation_id,
299        });
300    }
301
302    // PASS 3: Monomorphization
303    let mut monomorphizer = Monomorphizer::new(&mut registry);
304    let mut mono_snippets: Vec<Snippet> = Vec::new();
305
306    for snippet in preprocessed_snippets {
307        let mono_content = monomorphizer.process(&snippet.content);
308        mono_snippets.push(Snippet {
309            content: mono_content,
310            file_path: snippet.file_path,
311            line_number: snippet.line_number,
312            operation_id: snippet.operation_id,
313        });
314    }
315
316    // Inject Concrete Schemas
317    let mut generated_snippets = Vec::new();
318    for (name, content) in &registry.concrete_schemas {
319        let wrapped = format!(
320            "components:\n  schemas:\n    {}:\n{}",
321            name,
322            indent(content)
323        );
324        generated_snippets.push(Snippet {
325            content: wrapped,
326            file_path: PathBuf::from("<generated>"),
327            line_number: 1,
328            operation_id: None,
329        });
330    }
331    mono_snippets.extend(generated_snippets);
332
333    // PASS 4: Substitution
334    let mut all_schemas = registry.schemas.keys().cloned().collect::<HashSet<_>>();
335    all_schemas.extend(registry.concrete_schemas.keys().cloned());
336
337    let mut final_snippets = Vec::new();
338    for snippet in mono_snippets {
339        let subbed = substitute_smart_references(&snippet.content, &all_schemas);
340        let finalized_content = finalize_substitution(&subbed);
341        final_snippets.push(Snippet {
342            content: finalized_content,
343            file_path: snippet.file_path,
344            line_number: snippet.line_number,
345            operation_id: snippet.operation_id,
346        });
347    }
348
349    if !files_found {
350        return Err(Error::NoFilesFound);
351    }
352
353    Ok(final_snippets)
354}
355
356fn indent(s: &str) -> String {
357    s.lines()
358        .map(|l| format!("      {}", l))
359        .collect::<Vec<_>>()
360        .join("\n")
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn test_escaping() {
369        let input = r"price: \$100";
370        let output = finalize_substitution(input);
371        assert_eq!(output, "price: $100");
372    }
373
374    #[test]
375    fn test_vec_macro() {
376        let mut registry = Registry::new();
377        let snippet = Snippet {
378            content: "tags: $Vec<Tag>".to_string(),
379            file_path: PathBuf::from("test.rs"),
380            line_number: 1,
381            operation_id: None,
382        };
383        let processed = preprocess_macros(&snippet, &mut registry);
384        assert!(processed.content.contains("type: array"));
385        assert!(processed.content.contains("items:"));
386        assert!(
387            processed
388                .content
389                .contains("$ref: \"#/components/schemas/Tag\"")
390        );
391    }
392
393    // tests for @return removed as logic moved to dsl.rs
394}