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}