oas_forge/
preprocessor.rs

1use crate::index::Registry;
2use regex::Regex;
3use std::sync::OnceLock;
4
5static INSERT_RE: OnceLock<Regex> = OnceLock::new();
6static EXTEND_RE: OnceLock<Regex> = OnceLock::new();
7
8/// Pre-processes a snippet by expanding @insert and @extend directives.
9pub fn preprocess(content: &str, registry: &Registry) -> String {
10    let lines: Vec<&str> = content.lines().collect();
11    let mut new_lines = Vec::new();
12
13    // Initialize Regexes once
14    // Support optional args: @insert Name OR @insert Name(args)
15    // Regex: @insert\s+([Ident])(?:\((.*)\))?
16    let insert_re =
17        INSERT_RE.get_or_init(|| Regex::new(r"@insert\s+([a-zA-Z0-9_]+)(?:\((.*)\))?").unwrap());
18    let extend_re =
19        EXTEND_RE.get_or_init(|| Regex::new(r"@extend\s+([a-zA-Z0-9_]+)(?:\((.*)\))?").unwrap());
20
21    // Helper to parse args from regex capture
22    fn parse_args_from_caps(args_str: Option<regex::Match>) -> Vec<String> {
23        match args_str {
24            Some(m) => {
25                let s = m.as_str();
26                if s.trim().is_empty() {
27                    Vec::new()
28                } else {
29                    s.split(',')
30                        .map(|x| x.trim().trim_matches('"').to_string())
31                        .collect()
32                }
33            }
34            None => Vec::new(),
35        }
36    }
37
38    // Phase A: Textual Preparation
39    // @insert -> text injection
40    // @extend -> x-openapi-extend injection
41
42    let mut i = 0;
43    while i < lines.len() {
44        let line = lines[i];
45
46        if let Some(caps) = insert_re.captures(line) {
47            // @insert logic (Textual)
48            let name = caps.get(1).unwrap().as_str();
49            let args = parse_args_from_caps(caps.get(2));
50
51            if let Some(fragment) = registry.fragments.get(name) {
52                let expanded = substitute_fragment_args(&fragment.body, &fragment.params, &args);
53                let indent = line
54                    .chars()
55                    .take_while(|c| c.is_whitespace())
56                    .collect::<String>();
57
58                // Detect doc comment marker (/// or //!)
59                let trim_start = line.trim_start();
60                let doc_marker = if trim_start.starts_with("///") {
61                    Some("///")
62                } else if trim_start.starts_with("//!") {
63                    Some("//!")
64                } else {
65                    None
66                };
67
68                if !expanded.trim().is_empty() {
69                    for frag_line in expanded.lines() {
70                        if let Some(marker) = doc_marker {
71                            // Preserve marker + space
72                            // If frag_line already has marker? unlikely.
73                            new_lines.push(format!("{}{} {}", indent, marker, frag_line));
74                        } else {
75                            new_lines.push(format!("{}{}", indent, frag_line));
76                        }
77                    }
78                }
79            } else {
80                log::warn!("Fragment '{}' not found for @insert", name);
81                new_lines.push(line.to_string());
82            }
83        } else if let Some(caps) = extend_re.captures(line) {
84            // @extend logic (AST Marker)
85            let name = caps.get(1).unwrap().as_str();
86            let args_raw = caps.get(2).map(|m| m.as_str()).unwrap_or("");
87
88            // We preserve indentation and inject a special key.
89            // x-openapi-extend: "Name(arg1, arg2)"
90            let indent = line
91                .chars()
92                .take_while(|c| c.is_whitespace())
93                .collect::<String>();
94            // If args exist, format as Name(args), else Name
95            let marker_val = if args_raw.is_empty() {
96                name.to_string()
97            } else {
98                format!("{}({})", name, args_raw)
99            };
100            new_lines.push(format!("{}x-openapi-extend: \"{}\"", indent, marker_val));
101        } else {
102            new_lines.push(line.to_string());
103        }
104        i += 1;
105    }
106
107    let phase_a_output = new_lines.join("\n");
108
109    // Phase B: Structural Merge
110    // Try to parse as YAML Value. If fails, return textual output (fallback).
111    match serde_yaml::from_str::<serde_yaml::Value>(&phase_a_output) {
112        Ok(mut root) => {
113            process_value(&mut root, registry);
114            serde_yaml::to_string(&root).unwrap_or(phase_a_output)
115        }
116        Err(_) => {
117            // Likely a partial snippet (list item or partial object).
118            // Return text, but @extend markers are present.
119            // If it's a snippet, @extend might not work fully structurally.
120            // For now, we return phase_a_output.
121            // (User Note: Snippet must be valid YAML for @extend to work structurally)
122            phase_a_output
123        }
124    }
125}
126
127fn process_value(val: &mut serde_yaml::Value, registry: &Registry) {
128    if let serde_yaml::Value::Mapping(map) = val {
129        // Check for x-openapi-extend
130        let extend_key = serde_yaml::Value::String("x-openapi-extend".to_string());
131
132        let mut fragment_to_merge = None;
133
134        if let Some(extend_val) = map.remove(&extend_key) {
135            if let Some(extend_str) = extend_val.as_str() {
136                fragment_to_merge = Some(extend_str.to_string());
137            }
138        }
139
140        // Recurse children FIRST? or merge first?
141        // Merge first so we can process children of merged result?
142        // Or Process children then merge?
143        // Usually merge first adds new keys, which might need processing.
144        // But fragments are already "raw".
145        // Let's merge first.
146
147        if let Some(extend_str) = fragment_to_merge {
148            // Parse "Name(args)"
149            // reuse parse logic? We need simple parse here.
150            let (name, args) = parse_extend_str(&extend_str);
151
152            if let Some(fragment) = registry.fragments.get(&name) {
153                let expanded = substitute_fragment_args(&fragment.body, &fragment.params, &args);
154                if let Ok(frag_val) = serde_yaml::from_str::<serde_yaml::Value>(&expanded) {
155                    merge_values(val, frag_val);
156                } else {
157                    log::warn!("Fragment '{}' body is not valid YAML", name);
158                }
159            } else {
160                log::warn!("Fragment '{}' not found for @extend", name);
161            }
162        }
163
164        // Recurse (re-borrow map after modification)
165        // Check new keys too.
166        if let serde_yaml::Value::Mapping(map) = val {
167            for (_, v) in map {
168                process_value(v, registry);
169            }
170        }
171    } else if let serde_yaml::Value::Sequence(seq) = val {
172        for v in seq {
173            process_value(v, registry);
174        }
175    }
176}
177
178fn merge_values(target: &mut serde_yaml::Value, source: serde_yaml::Value) {
179    match (target, source) {
180        (serde_yaml::Value::Mapping(t_map), serde_yaml::Value::Mapping(s_map)) => {
181            for (k, v) in s_map {
182                if let Some(existing) = t_map.get_mut(&k) {
183                    merge_values(existing, v);
184                } else {
185                    t_map.insert(k, v);
186                }
187            }
188        }
189        (t, s) => {
190            *t = s;
191        }
192    }
193}
194
195fn parse_extend_str(s: &str) -> (String, Vec<String>) {
196    if let Some(idx) = s.find('(') {
197        let name = s[..idx].trim().to_string();
198        let args_str = s[idx + 1..].trim_end_matches(')');
199        let args = if args_str.trim().is_empty() {
200            Vec::new()
201        } else {
202            args_str
203                .split(',')
204                .map(|x| x.trim().trim_matches('"').to_string())
205                .collect()
206        };
207        (name, args)
208    } else {
209        (s.trim().to_string(), Vec::new())
210    }
211}
212
213// Helper to substitute named args {{param}} in fragment
214fn substitute_fragment_args(fragment: &str, params: &[String], args: &[String]) -> String {
215    let mut result = fragment.to_string();
216    for (i, param) in params.iter().enumerate() {
217        if let Some(arg) = args.get(i) {
218            let placeholder = format!("{{{{{}}}}}", param); // {{param}}
219            result = result.replace(&placeholder, arg);
220        }
221    }
222    result
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn test_insert_with_indentation() {
231        let mut registry = Registry::new();
232        registry.insert_fragment(
233            "Headers".to_string(),
234            vec![],
235            "header: x-val\nother: y-val".to_string(),
236        );
237
238        let input = "  @insert Headers(\"\")";
239        let output = preprocess(input, &registry);
240
241        // AST transformation normalizes indentation to root level
242        let expected = "header: x-val\nother: y-val\n";
243        assert_eq!(output, expected);
244    }
245
246    #[test]
247    fn test_fragment_with_args() {
248        let mut registry = Registry::new();
249        registry.insert_fragment(
250            "Field".to_string(),
251            vec!["name".to_string()],
252            "name: {{name}}".to_string(),
253        );
254
255        let input = "@insert Field(\"my-name\")";
256        let output = preprocess(input, &registry);
257        assert_eq!(output, "name: my-name\n");
258    }
259
260    #[test]
261    fn test_missing_fragment() {
262        let registry = Registry::new();
263        let input = "@insert Missing(\"\")";
264        let output = preprocess(input, &registry);
265        // Fallback to text (phase A) because parsing might fail or pass
266        // "@insert Missing" is likely treated as scalar string or invalid YAML?
267        // "@insert Missing..." is just text.
268        // If it parses as string, it returns string "...\n"
269        // If it fails to parse (because of @?), it returns raw text.
270        // "@" is reserved in YAML? At start of scalar?
271        // Let's assert what we get.
272        // In fallback path: same as input.
273        assert_eq!(output, "@insert Missing(\"\")");
274    }
275}