oas_forge/
doc_parser.rs

1use syn::punctuated::Punctuated;
2use syn::{Attribute, Expr, ExprLit, Lit, Meta};
3
4/// Helper to extract doc comments from attributes
5pub fn extract_doc_comments(attrs: &[Attribute]) -> Vec<String> {
6    let mut doc_lines = Vec::new();
7    for attr in attrs {
8        if attr.path().is_ident("doc") {
9            if let Meta::NameValue(meta) = &attr.meta {
10                if let Expr::Lit(expr_lit) = &meta.value {
11                    if let Lit::Str(lit_str) = &expr_lit.lit {
12                        doc_lines.push(lit_str.value());
13                    }
14                }
15            }
16        }
17    }
18    doc_lines
19}
20
21pub fn apply_casing(text: &str, case: &str) -> String {
22    match case {
23        "lowercase" => text.to_lowercase(),
24        "UPPERCASE" => text.to_uppercase(),
25        "PascalCase" => {
26            // Check if it contains underscores (snake_case -> PascalCase)
27            if text.contains('_') {
28                text.split('_')
29                    .map(|part| {
30                        let mut c = part.chars();
31                        match c.next() {
32                            None => String::new(),
33                            Some(f) => f.to_uppercase().to_string() + c.as_str(),
34                        }
35                    })
36                    .collect()
37            } else {
38                // Assume it is already Pascal or camel, just ensure first char is Upper
39                let mut c = text.chars();
40                match c.next() {
41                    None => String::new(),
42                    Some(f) => f.to_uppercase().to_string() + c.as_str(),
43                }
44            }
45        }
46        "camelCase" => {
47            // Check if it contains underscores (snake_case -> camelCase)
48            if text.contains('_') {
49                let parts: Vec<&str> = text.split('_').collect();
50                if parts.is_empty() {
51                    return String::new();
52                }
53                let first = parts[0].to_lowercase();
54                let rest: String = parts[1..]
55                    .iter()
56                    .map(|part| {
57                        let mut c = part.chars();
58                        match c.next() {
59                            None => String::new(),
60                            Some(f) => f.to_uppercase().to_string() + c.as_str(),
61                        }
62                    })
63                    .collect();
64                first + &rest
65            } else {
66                // Just ensure first char is Lower
67                let mut c = text.chars();
68                match c.next() {
69                    None => String::new(),
70                    Some(f) => f.to_lowercase().to_string() + c.as_str(),
71                }
72            }
73        }
74        "snake_case" => {
75            let mut s = String::new();
76            for (i, c) in text.chars().enumerate() {
77                if c.is_uppercase() && i > 0 {
78                    s.push('_');
79                }
80                if let Some(lower) = c.to_lowercase().next() {
81                    s.push(lower);
82                }
83            }
84            s
85        }
86        "SCREAMING_SNAKE_CASE" => apply_casing(text, "snake_case").to_uppercase(),
87        "kebab-case" => apply_casing(text, "snake_case").replace('_', "-"),
88        "SCREAMING-KEBAB-CASE" => apply_casing(text, "kebab-case").to_uppercase(),
89        _ => text.to_string(),
90    }
91}
92
93/// Extracts doc comments and handles "@openapi rename/rename-all" + Serde logic.
94pub fn extract_naming_and_doc(
95    attrs: &[Attribute],
96    default_name: &str,
97) -> (
98    String,
99    String,
100    Option<String>,
101    Vec<String>,
102    Option<String>,
103    Option<String>,
104) {
105    let mut doc_lines = Vec::new();
106    // We collect cleaned lines here (without @openapi tags)
107    let mut clean_doc_lines = Vec::new();
108
109    let mut final_name = default_name.to_string();
110    let mut rename_rule = None;
111    let mut serde_tag = None;
112    let mut serde_content = None;
113
114    // 1. Check Serde Attributes (Lower Precedence)
115    for attr in attrs {
116        if attr.path().is_ident("serde") {
117            if let Meta::List(list) = &attr.meta {
118                if let Ok(nested) =
119                    list.parse_args_with(Punctuated::<Meta, syn::Token![,]>::parse_terminated)
120                {
121                    for meta in nested {
122                        if let Meta::NameValue(nv) = meta {
123                            if nv.path.is_ident("rename") {
124                                if let Expr::Lit(ExprLit {
125                                    lit: Lit::Str(s), ..
126                                }) = nv.value
127                                {
128                                    final_name = s.value();
129                                }
130                            } else if nv.path.is_ident("rename_all") {
131                                if let Expr::Lit(ExprLit {
132                                    lit: Lit::Str(s), ..
133                                }) = nv.value
134                                {
135                                    rename_rule = Some(s.value());
136                                }
137                            } else if nv.path.is_ident("tag") {
138                                if let Expr::Lit(ExprLit {
139                                    lit: Lit::Str(s), ..
140                                }) = nv.value
141                                {
142                                    serde_tag = Some(s.value());
143                                }
144                            } else if nv.path.is_ident("content") {
145                                if let Expr::Lit(ExprLit {
146                                    lit: Lit::Str(s), ..
147                                }) = nv.value
148                                {
149                                    serde_content = Some(s.value());
150                                }
151                            }
152                        }
153                    }
154                }
155            }
156        }
157    }
158
159    // 2. Doc Comments (Higher Precedence)
160    for attr in attrs {
161        if attr.path().is_ident("doc") {
162            if let Meta::NameValue(meta) = &attr.meta {
163                if let Expr::Lit(expr_lit) = &meta.value {
164                    if let Lit::Str(lit_str) = &expr_lit.lit {
165                        let val = lit_str.value();
166                        doc_lines.push(val.clone());
167                        let trimmed = val.trim();
168
169                        if trimmed.starts_with("@openapi") {
170                            let rest = trimmed.strip_prefix("@openapi").unwrap().trim();
171                            if rest.starts_with("rename-all") {
172                                let rule = rest
173                                    .strip_prefix("rename-all")
174                                    .unwrap()
175                                    .trim()
176                                    .trim_matches('"');
177                                rename_rule = Some(rule.to_string());
178                            } else if rest.starts_with("rename") {
179                                let name_part = rest
180                                    .strip_prefix("rename")
181                                    .unwrap()
182                                    .trim()
183                                    .trim_matches('"');
184                                final_name = name_part.to_string();
185                            } else {
186                                // Only if not a rename directive, treat as doc content?
187                                // Actually, standard logic splits @openapi lines separate.
188                                // We just pass it through here.
189                            }
190                        } else {
191                            clean_doc_lines.push(val.trim().to_string());
192                        }
193                    }
194                }
195            }
196        }
197    }
198
199    (
200        final_name,
201        clean_doc_lines.join(" "),
202        rename_rule,
203        doc_lines,
204        serde_tag,
205        serde_content,
206    )
207}
208
209use serde_json::{Value, json};
210
211/// Extracts validation attributes from `#[validate(...)]` and maps them to OpenAPI properties.
212pub fn extract_validation(attrs: &[Attribute]) -> Value {
213    let mut validation_schema = serde_json::Map::new();
214
215    for attr in attrs {
216        if attr.path().is_ident("validate") {
217            if let Meta::List(list) = &attr.meta {
218                if let Ok(nested) =
219                    list.parse_args_with(Punctuated::<Meta, syn::Token![,]>::parse_terminated)
220                {
221                    for meta in nested {
222                        match meta {
223                            // Helper: #[validate(email)]
224                            Meta::Path(p) if p.is_ident("email") => {
225                                validation_schema.insert("format".to_string(), json!("email"));
226                            }
227                            // Helper: #[validate(url)]
228                            Meta::Path(p) if p.is_ident("url") => {
229                                validation_schema.insert("format".to_string(), json!("uri"));
230                            }
231                            // Helper: #[validate(length(min = 1, max = 10))]
232                            Meta::List(list) if list.path.is_ident("length") => {
233                                if let Ok(args) = list.parse_args_with(
234                                    Punctuated::<Meta, syn::Token![,]>::parse_terminated,
235                                ) {
236                                    for arg in args {
237                                        if let Meta::NameValue(nv) = arg {
238                                            if let Expr::Lit(ExprLit {
239                                                lit: Lit::Int(i), ..
240                                            }) = nv.value
241                                            {
242                                                if let Ok(val) = i.base10_parse::<u64>() {
243                                                    if nv.path.is_ident("min") {
244                                                        validation_schema.insert(
245                                                            "minLength".to_string(),
246                                                            json!(val),
247                                                        );
248                                                    } else if nv.path.is_ident("max") {
249                                                        validation_schema.insert(
250                                                            "maxLength".to_string(),
251                                                            json!(val),
252                                                        );
253                                                    }
254                                                }
255                                            }
256                                        }
257                                    }
258                                }
259                            }
260                            // Helper: #[validate(range(min = 1, max = 10))]
261                            Meta::List(list) if list.path.is_ident("range") => {
262                                if let Ok(args) = list.parse_args_with(
263                                    Punctuated::<Meta, syn::Token![,]>::parse_terminated,
264                                ) {
265                                    for arg in args {
266                                        if let Meta::NameValue(nv) = arg {
267                                            if let Expr::Lit(ExprLit {
268                                                lit: Lit::Int(i), ..
269                                            }) = nv.value
270                                            {
271                                                if let Ok(val) = i.base10_parse::<i64>() {
272                                                    if nv.path.is_ident("min") {
273                                                        validation_schema.insert(
274                                                            "minimum".to_string(),
275                                                            json!(val),
276                                                        );
277                                                    } else if nv.path.is_ident("max") {
278                                                        validation_schema.insert(
279                                                            "maximum".to_string(),
280                                                            json!(val),
281                                                        );
282                                                    }
283                                                }
284                                            }
285                                        }
286                                    }
287                                }
288                            }
289                            // Helper: #[validate(regex = "path")] or #[validate(pattern = "...")]
290                            Meta::NameValue(nv) => {
291                                if nv.path.is_ident("pattern") {
292                                    if let Expr::Lit(ExprLit {
293                                        lit: Lit::Str(s), ..
294                                    }) = nv.value
295                                    {
296                                        validation_schema
297                                            .insert("pattern".to_string(), json!(s.value()));
298                                    }
299                                }
300                            }
301                            _ => {}
302                        }
303                    }
304                }
305            }
306        }
307    }
308    Value::Object(validation_schema)
309}