Skip to main content

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    let mut in_openapi_block = false;
161    for attr in attrs {
162        if attr.path().is_ident("doc") {
163            if let Meta::NameValue(meta) = &attr.meta {
164                if let Expr::Lit(expr_lit) = &meta.value {
165                    if let Lit::Str(lit_str) = &expr_lit.lit {
166                        let val = lit_str.value();
167                        doc_lines.push(val.clone());
168                        let trimmed = val.trim();
169
170                        if trimmed.starts_with("@openapi") {
171                            in_openapi_block = true;
172                            let rest = trimmed.strip_prefix("@openapi").unwrap().trim();
173                            if rest.starts_with("rename-all") {
174                                let rule = rest
175                                    .strip_prefix("rename-all")
176                                    .unwrap()
177                                    .trim()
178                                    .trim_matches('"');
179                                rename_rule = Some(rule.to_string());
180                            } else if rest.starts_with("rename") {
181                                let name_part = rest
182                                    .strip_prefix("rename")
183                                    .unwrap()
184                                    .trim()
185                                    .trim_matches('"');
186                                final_name = name_part.to_string();
187                            }
188                        } else if !in_openapi_block {
189                            clean_doc_lines.push(val.trim().to_string());
190                        }
191                    }
192                }
193            }
194        }
195    }
196
197    (
198        final_name,
199        clean_doc_lines.join(" "),
200        rename_rule,
201        doc_lines,
202        serde_tag,
203        serde_content,
204    )
205}
206
207use serde_json::{Value, json};
208
209/// Extracts validation attributes from `#[validate(...)]` and maps them to OpenAPI properties.
210pub fn extract_validation(attrs: &[Attribute]) -> Value {
211    let mut validation_schema = serde_json::Map::new();
212
213    for attr in attrs {
214        if attr.path().is_ident("validate") {
215            if let Meta::List(list) = &attr.meta {
216                if let Ok(nested) =
217                    list.parse_args_with(Punctuated::<Meta, syn::Token![,]>::parse_terminated)
218                {
219                    for meta in nested {
220                        match meta {
221                            // Helper: #[validate(email)]
222                            Meta::Path(p) if p.is_ident("email") => {
223                                validation_schema.insert("format".to_string(), json!("email"));
224                            }
225                            // Helper: #[validate(url)]
226                            Meta::Path(p) if p.is_ident("url") => {
227                                validation_schema.insert("format".to_string(), json!("uri"));
228                            }
229                            // Helper: #[validate(length(min = 1, max = 10))]
230                            Meta::List(list) if list.path.is_ident("length") => {
231                                if let Ok(args) = list.parse_args_with(
232                                    Punctuated::<Meta, syn::Token![,]>::parse_terminated,
233                                ) {
234                                    for arg in args {
235                                        if let Meta::NameValue(nv) = arg {
236                                            if let Expr::Lit(ExprLit {
237                                                lit: Lit::Int(i), ..
238                                            }) = nv.value
239                                            {
240                                                if let Ok(val) = i.base10_parse::<u64>() {
241                                                    if nv.path.is_ident("min") {
242                                                        validation_schema.insert(
243                                                            "minLength".to_string(),
244                                                            json!(val),
245                                                        );
246                                                    } else if nv.path.is_ident("max") {
247                                                        validation_schema.insert(
248                                                            "maxLength".to_string(),
249                                                            json!(val),
250                                                        );
251                                                    }
252                                                }
253                                            }
254                                        }
255                                    }
256                                }
257                            }
258                            // Helper: #[validate(range(min = 1, max = 10))]
259                            Meta::List(list) if list.path.is_ident("range") => {
260                                if let Ok(args) = list.parse_args_with(
261                                    Punctuated::<Meta, syn::Token![,]>::parse_terminated,
262                                ) {
263                                    for arg in args {
264                                        if let Meta::NameValue(nv) = arg {
265                                            if let Expr::Lit(ExprLit {
266                                                lit: Lit::Int(i), ..
267                                            }) = nv.value
268                                            {
269                                                if let Ok(val) = i.base10_parse::<i64>() {
270                                                    if nv.path.is_ident("min") {
271                                                        validation_schema.insert(
272                                                            "minimum".to_string(),
273                                                            json!(val),
274                                                        );
275                                                    } else if nv.path.is_ident("max") {
276                                                        validation_schema.insert(
277                                                            "maximum".to_string(),
278                                                            json!(val),
279                                                        );
280                                                    }
281                                                }
282                                            }
283                                        }
284                                    }
285                                }
286                            }
287                            // Helper: #[validate(regex = "path")] or #[validate(pattern = "...")]
288                            Meta::NameValue(nv) => {
289                                if nv.path.is_ident("pattern") {
290                                    if let Expr::Lit(ExprLit {
291                                        lit: Lit::Str(s), ..
292                                    }) = nv.value
293                                    {
294                                        validation_schema
295                                            .insert("pattern".to_string(), json!(s.value()));
296                                    }
297                                }
298                            }
299                            _ => {}
300                        }
301                    }
302                }
303            }
304        }
305    }
306    Value::Object(validation_schema)
307}