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}