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                if !expanded.trim().is_empty() {
58                    for frag_line in expanded.lines() {
59                        new_lines.push(format!("{}{}", indent, frag_line));
60                    }
61                }
62            } else {
63                log::warn!("Fragment '{}' not found for @insert", name);
64                new_lines.push(line.to_string());
65            }
66        } else if let Some(caps) = extend_re.captures(line) {
67            // @extend logic (AST Marker)
68            let name = caps.get(1).unwrap().as_str();
69            let args_raw = caps.get(2).map(|m| m.as_str()).unwrap_or("");
70
71            // We preserve indentation and inject a special key.
72            // x-openapi-extend: "Name(arg1, arg2)"
73            let indent = line
74                .chars()
75                .take_while(|c| c.is_whitespace())
76                .collect::<String>();
77            // If args exist, format as Name(args), else Name
78            let marker_val = if args_raw.is_empty() {
79                name.to_string()
80            } else {
81                format!("{}({})", name, args_raw)
82            };
83            new_lines.push(format!("{}x-openapi-extend: \"{}\"", indent, marker_val));
84        } else {
85            new_lines.push(line.to_string());
86        }
87        i += 1;
88    }
89
90    let phase_a_output = new_lines.join("\n");
91
92    // Phase B: Structural Merge
93    // Try to parse as YAML Value. If fails, return textual output (fallback).
94    match serde_yaml::from_str::<serde_yaml::Value>(&phase_a_output) {
95        Ok(mut root) => {
96            process_value(&mut root, registry);
97            serde_yaml::to_string(&root).unwrap_or(phase_a_output)
98        }
99        Err(_) => {
100            // Likely a partial snippet (list item or partial object).
101            // Return text, but @extend markers are present.
102            // If it's a snippet, @extend might not work fully structurally.
103            // For now, we return phase_a_output.
104            // (User Note: Snippet must be valid YAML for @extend to work structurally)
105            phase_a_output
106        }
107    }
108}
109
110fn process_value(val: &mut serde_yaml::Value, registry: &Registry) {
111    if let serde_yaml::Value::Mapping(map) = val {
112        // Check for x-openapi-extend
113        let extend_key = serde_yaml::Value::String("x-openapi-extend".to_string());
114
115        let mut fragment_to_merge = None;
116
117        if let Some(extend_val) = map.remove(&extend_key) {
118            if let Some(extend_str) = extend_val.as_str() {
119                fragment_to_merge = Some(extend_str.to_string());
120            }
121        }
122
123        // Recurse children FIRST? or merge first?
124        // Merge first so we can process children of merged result?
125        // Or Process children then merge?
126        // Usually merge first adds new keys, which might need processing.
127        // But fragments are already "raw".
128        // Let's merge first.
129
130        if let Some(extend_str) = fragment_to_merge {
131            // Parse "Name(args)"
132            // reuse parse logic? We need simple parse here.
133            let (name, args) = parse_extend_str(&extend_str);
134
135            if let Some(fragment) = registry.fragments.get(&name) {
136                let expanded = substitute_fragment_args(&fragment.body, &fragment.params, &args);
137                if let Ok(frag_val) = serde_yaml::from_str::<serde_yaml::Value>(&expanded) {
138                    merge_values(val, frag_val);
139                } else {
140                    log::warn!("Fragment '{}' body is not valid YAML", name);
141                }
142            } else {
143                log::warn!("Fragment '{}' not found for @extend", name);
144            }
145        }
146
147        // Recurse (re-borrow map after modification)
148        // Check new keys too.
149        if let serde_yaml::Value::Mapping(map) = val {
150            for (_, v) in map {
151                process_value(v, registry);
152            }
153        }
154    } else if let serde_yaml::Value::Sequence(seq) = val {
155        for v in seq {
156            process_value(v, registry);
157        }
158    }
159}
160
161fn merge_values(target: &mut serde_yaml::Value, source: serde_yaml::Value) {
162    match (target, source) {
163        (serde_yaml::Value::Mapping(t_map), serde_yaml::Value::Mapping(s_map)) => {
164            for (k, v) in s_map {
165                if let Some(existing) = t_map.get_mut(&k) {
166                    merge_values(existing, v);
167                } else {
168                    t_map.insert(k, v);
169                }
170            }
171        }
172        (t, s) => {
173            *t = s;
174        }
175    }
176}
177
178fn parse_extend_str(s: &str) -> (String, Vec<String>) {
179    if let Some(idx) = s.find('(') {
180        let name = s[..idx].trim().to_string();
181        let args_str = s[idx + 1..].trim_end_matches(')');
182        let args = if args_str.trim().is_empty() {
183            Vec::new()
184        } else {
185            args_str
186                .split(',')
187                .map(|x| x.trim().trim_matches('"').to_string())
188                .collect()
189        };
190        (name, args)
191    } else {
192        (s.trim().to_string(), Vec::new())
193    }
194}
195
196// Helper to substitute named args {{param}} in fragment
197fn substitute_fragment_args(fragment: &str, params: &[String], args: &[String]) -> String {
198    let mut result = fragment.to_string();
199    for (i, param) in params.iter().enumerate() {
200        if let Some(arg) = args.get(i) {
201            let placeholder = format!("{{{{{}}}}}", param); // {{param}}
202            result = result.replace(&placeholder, arg);
203        }
204    }
205    result
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_insert_with_indentation() {
214        let mut registry = Registry::new();
215        registry.insert_fragment(
216            "Headers".to_string(),
217            vec![],
218            "header: x-val\nother: y-val".to_string(),
219        );
220
221        let input = "  @insert Headers(\"\")";
222        let output = preprocess(input, &registry);
223
224        // AST transformation normalizes indentation to root level
225        let expected = "header: x-val\nother: y-val\n";
226        assert_eq!(output, expected);
227    }
228
229    #[test]
230    fn test_fragment_with_args() {
231        let mut registry = Registry::new();
232        registry.insert_fragment(
233            "Field".to_string(),
234            vec!["name".to_string()],
235            "name: {{name}}".to_string(),
236        );
237
238        let input = "@insert Field(\"my-name\")";
239        let output = preprocess(input, &registry);
240        assert_eq!(output, "name: my-name\n");
241    }
242
243    #[test]
244    fn test_missing_fragment() {
245        let registry = Registry::new();
246        let input = "@insert Missing(\"\")";
247        let output = preprocess(input, &registry);
248        // Fallback to text (phase A) because parsing might fail or pass
249        // "@insert Missing" is likely treated as scalar string or invalid YAML?
250        // "@insert Missing..." is just text.
251        // If it parses as string, it returns string "...\n"
252        // If it fails to parse (because of @?), it returns raw text.
253        // "@" is reserved in YAML? At start of scalar?
254        // Let's assert what we get.
255        // In fallback path: same as input.
256        assert_eq!(output, "@insert Missing(\"\")");
257    }
258}