fix: setting option when necessary
5 files changed, +406 -353
+5-0Cargo.toml
| @@ -34,3 +34,8 @@ private_tests = [] | ||
| 34 | 34 | name = "private_schema_tests" |
| 35 | 35 | path = "tests/private/schema_tests.rs" |
| 36 | 36 | required-features = ["private_tests"] |
| 37 | + | |
| 38 | +[[test]] | |
| 39 | +name = "private_codegen_compile" | |
| 40 | +path = "tests/private/codegen_compile_test.rs" | |
| 41 | +required-features = ["private_tests"] |
+373-0src/codegen.rs
| @@ -200,6 +200,379 @@ pub fn localize_type(rust_type: &str, lang: &dyn crate::lang::LanguageGenerator) | ||
| 200 | 200 | } |
| 201 | 201 | } |
| 202 | 202 | |
| 203 | +/// A generated project file (name + code content) | |
| 204 | +pub struct GeneratedProjectFile { | |
| 205 | + pub name: String, | |
| 206 | + pub code: String, | |
| 207 | + /// Root type for each source file that contributed to this group. | |
| 208 | + /// Vec of (source_filename, root_rust_type) — used for deserialization testing. | |
| 209 | + pub root_types: Vec<(String, String)>, | |
| 210 | +} | |
| 211 | + | |
| 212 | +/// Generate all Rust files for a project: shared.rs, per-group files, mod.rs. | |
| 213 | +/// This mirrors the code view's `rebuild_file_mode` pipeline exactly. | |
| 214 | +pub fn generate_project( | |
| 215 | + parsed_files: &[(String, serde_json::Value)], | |
| 216 | + schema: &crate::schema::SchemaOverview, | |
| 217 | + lang: &dyn crate::lang::LanguageGenerator, | |
| 218 | +) -> Vec<GeneratedProjectFile> { | |
| 219 | + use std::collections::{BTreeMap, BTreeSet}; | |
| 220 | + | |
| 221 | + let shared_names: BTreeSet<String> = schema.structs.iter().map(|s| s.name.clone()).collect(); | |
| 222 | + let unique_names: BTreeSet<String> = schema.unique_structs.iter().map(|s| s.name.clone()).collect(); | |
| 223 | + let all_structs = schema.all_structs(); | |
| 224 | + | |
| 225 | + let mut result = Vec::new(); | |
| 226 | + | |
| 227 | + // shared.rs | |
| 228 | + if !schema.structs.is_empty() { | |
| 229 | + let code = CodeGenerator::from_schema(&schema.structs).generate_code(lang); | |
| 230 | + result.push(GeneratedProjectFile { | |
| 231 | + name: lang.file_name("shared"), | |
| 232 | + code, | |
| 233 | + root_types: Vec::new(), | |
| 234 | + }); | |
| 235 | + } | |
| 236 | + | |
| 237 | + // Group files by depluralized first word | |
| 238 | + let mut groups: BTreeMap<String, Vec<(&str, &serde_json::Value)>> = BTreeMap::new(); | |
| 239 | + for (filename, value) in parsed_files { | |
| 240 | + let word = first_normal_word(filename) | |
| 241 | + .map(|w| to_pascal_case(&singularize(&w))) | |
| 242 | + .unwrap_or_else(|| "other".to_string()); | |
| 243 | + let key = singularize(&word.to_ascii_lowercase()); | |
| 244 | + groups.entry(key).or_default().push((filename.as_str(), value)); | |
| 245 | + } | |
| 246 | + | |
| 247 | + let all_schema_names: BTreeSet<String> = shared_names.iter().chain(unique_names.iter()).cloned().collect(); | |
| 248 | + | |
| 249 | + for (group_key, files) in &groups { | |
| 250 | + // Collect all struct definitions, merging fields from multiple files | |
| 251 | + let mut struct_order: Vec<String> = Vec::new(); | |
| 252 | + let mut struct_defs: BTreeMap<String, Vec<GeneratedField>> = BTreeMap::new(); | |
| 253 | + let mut root_types: Vec<(String, String)> = Vec::new(); | |
| 254 | + let mut type_aliases: Vec<String> = Vec::new(); | |
| 255 | + let mut seen_aliases: BTreeSet<String> = BTreeSet::new(); | |
| 256 | + | |
| 257 | + for (filename, value) in files { | |
| 258 | + let prefix = first_normal_word(filename) | |
| 259 | + .map(|w| to_pascal_case(&singularize(&w))) | |
| 260 | + .unwrap_or_default(); | |
| 261 | + let is_root_array = value.is_array(); | |
| 262 | + | |
| 263 | + let (root_name, array_item_name) = if is_root_array { | |
| 264 | + let singular = singularize(&prefix); | |
| 265 | + let item_name = if singular.is_empty() { | |
| 266 | + "Item".to_string() | |
| 267 | + } else { | |
| 268 | + let mut s = String::new(); | |
| 269 | + s.push(singular.chars().next().unwrap().to_ascii_uppercase()); | |
| 270 | + s.extend(singular.chars().skip(1)); | |
| 271 | + s | |
| 272 | + }; | |
| 273 | + (item_name.clone(), Some(item_name)) | |
| 274 | + } else { | |
| 275 | + let name = if prefix.is_empty() { "Root".to_string() } else { format!("{}Root", prefix) }; | |
| 276 | + (name, None) | |
| 277 | + }; | |
| 278 | + | |
| 279 | + let deser_type = if is_root_array { | |
| 280 | + format!("Vec<{}>", root_name) | |
| 281 | + } else { | |
| 282 | + root_name.clone() | |
| 283 | + }; | |
| 284 | + root_types.push((filename.to_string(), deser_type)); | |
| 285 | + | |
| 286 | + let mut gen = CodeGenerator::from_value_named(value, &root_name); | |
| 287 | + | |
| 288 | + // Schema-aware type resolution | |
| 289 | + resolve_codegen_against_schema(&mut gen, &all_structs, &shared_names); | |
| 290 | + | |
| 291 | + // Collect structs, merging duplicates | |
| 292 | + for s in gen.structs.iter().rev() { | |
| 293 | + if shared_names.contains(&s.name) { | |
| 294 | + continue; | |
| 295 | + } | |
| 296 | + | |
| 297 | + let prefixed = format!("{}{}", prefix, s.name); | |
| 298 | + let struct_name = if unique_names.contains(&prefixed) { | |
| 299 | + prefixed | |
| 300 | + } else if unique_names.contains(&s.name) { | |
| 301 | + s.name.clone() | |
| 302 | + } else if s.name != root_name && !prefix.is_empty() && !s.name.starts_with(&prefix) { | |
| 303 | + prefixed | |
| 304 | + } else { | |
| 305 | + s.name.clone() | |
| 306 | + }; | |
| 307 | + | |
| 308 | + if let Some(existing) = struct_defs.get_mut(&struct_name) { | |
| 309 | + // Merge: make fields Optional if missing or Null in this instance | |
| 310 | + merge_generated_fields(existing, &s.fields); | |
| 311 | + } else { | |
| 312 | + struct_order.push(struct_name.clone()); | |
| 313 | + struct_defs.insert(struct_name, s.fields.clone()); | |
| 314 | + } | |
| 315 | + } | |
| 316 | + | |
| 317 | + if let Some(ref item_name) = array_item_name { | |
| 318 | + let alias_name = format!("{}Root", prefix); | |
| 319 | + if !seen_aliases.contains(&alias_name) { | |
| 320 | + seen_aliases.insert(alias_name.clone()); | |
| 321 | + let aliased = if shared_names.contains(item_name) { | |
| 322 | + item_name.clone() | |
| 323 | + } else if !prefix.is_empty() && !item_name.starts_with(&prefix) { | |
| 324 | + format!("{}{}", prefix, item_name) | |
| 325 | + } else { | |
| 326 | + item_name.clone() | |
| 327 | + }; | |
| 328 | + type_aliases.push(format!("pub type {} = Vec<{}>;\n", alias_name, aliased)); | |
| 329 | + } | |
| 330 | + } | |
| 331 | + } | |
| 332 | + | |
| 333 | + // Emit code from merged struct definitions | |
| 334 | + let group_prefix = to_pascal_case(&singularize(group_key)); | |
| 335 | + let mut struct_blocks: Vec<String> = type_aliases; | |
| 336 | + for struct_name in &struct_order { | |
| 337 | + let fields = &struct_defs[struct_name]; | |
| 338 | + let prefix = group_prefix.clone(); | |
| 339 | + | |
| 340 | + let mut code = String::new(); | |
| 341 | + code.push_str(&lang.struct_open(struct_name)); | |
| 342 | + let mut field_pairs: Vec<(String, String)> = Vec::new(); | |
| 343 | + for field in fields { | |
| 344 | + let code_name = lang.field_name(&field.json_name); | |
| 345 | + let base_type = match &field.resolved_type { | |
| 346 | + Some(rt) => localize_type(rt, lang), | |
| 347 | + None => lang.type_name(&field.inferred_type), | |
| 348 | + }; | |
| 349 | + let resolved_type = if !prefix.is_empty() && !all_schema_names.contains(&base_type) { | |
| 350 | + prefix_resolved_type(&base_type, &prefix, &all_schema_names, struct_name) | |
| 351 | + } else { | |
| 352 | + base_type | |
| 353 | + }; | |
| 354 | + code.push_str(&lang.field_line(&code_name, &resolved_type, &field.json_name)); | |
| 355 | + field_pairs.push((code_name, field.json_name.clone())); | |
| 356 | + } | |
| 357 | + code.push_str(&lang.struct_close(&field_pairs)); | |
| 358 | + struct_blocks.push(code); | |
| 359 | + } | |
| 360 | + | |
| 361 | + let mut body = String::new(); | |
| 362 | + for block in &struct_blocks { | |
| 363 | + body.push_str(block); | |
| 364 | + body.push('\n'); | |
| 365 | + } | |
| 366 | + | |
| 367 | + let mut code = String::new(); | |
| 368 | + let header = lang.file_header(); | |
| 369 | + if !header.is_empty() { | |
| 370 | + code.push_str(&header); | |
| 371 | + code.push('\n'); | |
| 372 | + } | |
| 373 | + code.push_str(&lang.imports_header(&body, !shared_names.is_empty())); | |
| 374 | + code.push('\n'); | |
| 375 | + code.push_str(&body); | |
| 376 | + | |
| 377 | + result.push(GeneratedProjectFile { | |
| 378 | + name: lang.file_name(group_key), | |
| 379 | + code: code.trim_end().to_string() + "\n", | |
| 380 | + root_types, | |
| 381 | + }); | |
| 382 | + } | |
| 383 | + | |
| 384 | + // mod.rs | |
| 385 | + let mod_names: Vec<&str> = result.iter().map(|f| { | |
| 386 | + f.name.strip_suffix(".rs").unwrap_or(&f.name) | |
| 387 | + }).collect(); | |
| 388 | + if let Some(mod_code) = lang.mod_file(&mod_names) { | |
| 389 | + result.push(GeneratedProjectFile { | |
| 390 | + name: "mod.rs".to_string(), | |
| 391 | + code: mod_code, | |
| 392 | + root_types: Vec::new(), | |
| 393 | + }); | |
| 394 | + } | |
| 395 | + | |
| 396 | + result | |
| 397 | +} | |
| 398 | + | |
| 399 | +/// Resolve types in a CodeGenerator against schema structs, and generate | |
| 400 | +/// missing struct definitions from schema when referenced but not present. | |
| 401 | +pub fn resolve_codegen_against_schema( | |
| 402 | + gen: &mut CodeGenerator, | |
| 403 | + all_structs: &[crate::schema::SharedStruct], | |
| 404 | + shared_names: &std::collections::BTreeSet<String>, | |
| 405 | +) { | |
| 406 | + for s in &mut gen.structs { | |
| 407 | + let schema_match = crate::types::resolve_struct_name( | |
| 408 | + &s.fields.iter().map(|f| (f.json_name.clone(), f.inferred_type.clone())).collect(), | |
| 409 | + all_structs, | |
| 410 | + ); | |
| 411 | + let schema_fields = schema_match.and_then(|name| { | |
| 412 | + all_structs.iter().find(|ss| ss.name == name) | |
| 413 | + }); | |
| 414 | + | |
| 415 | + for field in &mut s.fields { | |
| 416 | + if field.resolved_type.is_none() { | |
| 417 | + field.resolved_type = resolve_type_to_struct(&field.inferred_type, all_structs); | |
| 418 | + if field.resolved_type.is_none() { | |
| 419 | + if let Some(ss) = schema_fields { | |
| 420 | + if let Some(schema_type) = ss.fields.get(&field.json_name) { | |
| 421 | + field.resolved_type = resolve_type_to_struct(schema_type, all_structs); | |
| 422 | + if field.resolved_type.is_some() { | |
| 423 | + field.inferred_type = schema_type.clone(); | |
| 424 | + } | |
| 425 | + } | |
| 426 | + } | |
| 427 | + } | |
| 428 | + } | |
| 429 | + } | |
| 430 | + } | |
| 431 | + | |
| 432 | + // Generate missing structs from schema | |
| 433 | + let existing_names: std::collections::BTreeSet<String> = | |
| 434 | + gen.structs.iter().map(|s| s.name.clone()).collect(); | |
| 435 | + let mut needed: Vec<String> = Vec::new(); | |
| 436 | + for s in &gen.structs { | |
| 437 | + for field in &s.fields { | |
| 438 | + if let Some(rt) = &field.resolved_type { | |
| 439 | + for name in extract_struct_names_from_resolved(rt) { | |
| 440 | + if !existing_names.contains(&name) && !shared_names.contains(&name) { | |
| 441 | + needed.push(name); | |
| 442 | + } | |
| 443 | + } | |
| 444 | + } | |
| 445 | + } | |
| 446 | + } | |
| 447 | + let mut added: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); | |
| 448 | + while let Some(name) = needed.pop() { | |
| 449 | + if added.contains(&name) || existing_names.contains(&name) || shared_names.contains(&name) { | |
| 450 | + continue; | |
| 451 | + } | |
| 452 | + added.insert(name.clone()); | |
| 453 | + if let Some(ss) = all_structs.iter().find(|ss| ss.name == name) { | |
| 454 | + let fields: Vec<GeneratedField> = ss.fields.iter().map(|(key, typ)| { | |
| 455 | + let resolved = resolve_type_to_struct(typ, all_structs); | |
| 456 | + if let Some(rt) = &resolved { | |
| 457 | + for dep in extract_struct_names_from_resolved(rt) { | |
| 458 | + needed.push(dep); | |
| 459 | + } | |
| 460 | + } | |
| 461 | + GeneratedField { | |
| 462 | + json_name: key.clone(), | |
| 463 | + inferred_type: typ.clone(), | |
| 464 | + resolved_type: resolved, | |
| 465 | + needs_rename: false, | |
| 466 | + } | |
| 467 | + }).collect(); | |
| 468 | + gen.structs.push(GeneratedStruct { | |
| 469 | + name: name.clone(), | |
| 470 | + fields, | |
| 471 | + }); | |
| 472 | + } | |
| 473 | + } | |
| 474 | +} | |
| 475 | + | |
| 476 | +/// Merge a new set of fields into an existing field list. | |
| 477 | +/// Fields missing from the new set, or whose type is Null, become Option<T>. | |
| 478 | +fn merge_generated_fields(existing: &mut Vec<GeneratedField>, new_fields: &[GeneratedField]) { | |
| 479 | + use std::collections::BTreeMap; | |
| 480 | + | |
| 481 | + let new_map: BTreeMap<&str, &GeneratedField> = new_fields | |
| 482 | + .iter() | |
| 483 | + .map(|f| (f.json_name.as_str(), f)) | |
| 484 | + .collect(); | |
| 485 | + | |
| 486 | + for field in existing.iter_mut() { | |
| 487 | + match new_map.get(field.json_name.as_str()) { | |
| 488 | + None => { | |
| 489 | + // Field missing in new instance — make it Optional | |
| 490 | + if !matches!(field.inferred_type, InferredType::Option(_) | InferredType::Null) { | |
| 491 | + field.inferred_type = InferredType::Option(Box::new(field.inferred_type.clone())); | |
| 492 | + field.resolved_type = field.resolved_type.take().map(|rt| { | |
| 493 | + if rt.starts_with("Option<") { rt } else { format!("Option<{}>", rt) } | |
| 494 | + }); | |
| 495 | + } | |
| 496 | + } | |
| 497 | + Some(new_field) => { | |
| 498 | + // Field present but type might differ (e.g., String vs Null) | |
| 499 | + if new_field.inferred_type == InferredType::Null | |
| 500 | + && !matches!(field.inferred_type, InferredType::Option(_) | InferredType::Null) | |
| 501 | + { | |
| 502 | + field.inferred_type = InferredType::Option(Box::new(field.inferred_type.clone())); | |
| 503 | + field.resolved_type = field.resolved_type.take().map(|rt| { | |
| 504 | + if rt.starts_with("Option<") { rt } else { format!("Option<{}>", rt) } | |
| 505 | + }); | |
| 506 | + } | |
| 507 | + } | |
| 508 | + } | |
| 509 | + } | |
| 510 | + | |
| 511 | + // Add fields that exist in new but not in existing (as Optional) | |
| 512 | + let existing_names: BTreeSet<String> = existing.iter().map(|f| f.json_name.clone()).collect(); | |
| 513 | + for new_field in new_fields { | |
| 514 | + if !existing_names.contains(&new_field.json_name) { | |
| 515 | + let mut field = new_field.clone(); | |
| 516 | + if !matches!(field.inferred_type, InferredType::Option(_) | InferredType::Null) { | |
| 517 | + field.inferred_type = InferredType::Option(Box::new(field.inferred_type.clone())); | |
| 518 | + field.resolved_type = field.resolved_type.take().map(|rt| { | |
| 519 | + if rt.starts_with("Option<") { rt } else { format!("Option<{}>", rt) } | |
| 520 | + }); | |
| 521 | + } | |
| 522 | + existing.push(field); | |
| 523 | + } | |
| 524 | + } | |
| 525 | +} | |
| 526 | + | |
| 527 | +fn extract_struct_names_from_resolved(rt: &str) -> Vec<String> { | |
| 528 | + let mut names = Vec::new(); | |
| 529 | + let inner = rt | |
| 530 | + .strip_prefix("Option<").and_then(|s| s.strip_suffix('>')) | |
| 531 | + .or_else(|| rt.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>'))); | |
| 532 | + if let Some(inner) = inner { | |
| 533 | + names.extend(extract_struct_names_from_resolved(inner)); | |
| 534 | + } else if !rt.is_empty() && rt.chars().next().unwrap().is_ascii_uppercase() { | |
| 535 | + names.push(rt.to_string()); | |
| 536 | + } | |
| 537 | + names | |
| 538 | +} | |
| 539 | + | |
| 540 | +fn is_struct_name(s: &str) -> bool { | |
| 541 | + let first = s.chars().next().unwrap_or('a'); | |
| 542 | + first.is_ascii_uppercase() | |
| 543 | + && !s.contains('<') | |
| 544 | + && !s.contains('[') | |
| 545 | + && !matches!( | |
| 546 | + s, | |
| 547 | + "String" | "Vec" | "Option" | "bool" | "i64" | "u64" | "f64" | "i32" | "u32" | "f32" | |
| 548 | + | "NaiveDate" | "NaiveTime" | |
| 549 | + | "Bool" | "Int" | "Double" | "Date" | "Any" | |
| 550 | + ) | |
| 551 | +} | |
| 552 | + | |
| 553 | +fn prefix_resolved_type( | |
| 554 | + type_str: &str, | |
| 555 | + prefix: &str, | |
| 556 | + shared_names: &std::collections::BTreeSet<String>, | |
| 557 | + root_name: &str, | |
| 558 | +) -> String { | |
| 559 | + if type_str.starts_with("Vec<") && type_str.ends_with('>') { | |
| 560 | + let inner = &type_str[4..type_str.len() - 1]; | |
| 561 | + format!("Vec<{}>", prefix_resolved_type(inner, prefix, shared_names, root_name)) | |
| 562 | + } else if type_str.starts_with("Option<") && type_str.ends_with('>') { | |
| 563 | + let inner = &type_str[7..type_str.len() - 1]; | |
| 564 | + format!("Option<{}>", prefix_resolved_type(inner, prefix, shared_names, root_name)) | |
| 565 | + } else if is_struct_name(type_str) | |
| 566 | + && !shared_names.contains(type_str) | |
| 567 | + && type_str != root_name | |
| 568 | + && !type_str.starts_with(prefix) | |
| 569 | + { | |
| 570 | + format!("{}{}", prefix, type_str) | |
| 571 | + } else { | |
| 572 | + type_str.to_string() | |
| 573 | + } | |
| 574 | +} | |
| 575 | + | |
| 203 | 576 | pub fn to_snake_case(s: &str) -> String { |
| 204 | 577 | let mut result = String::new(); |
| 205 | 578 | for (i, ch) in s.chars().enumerate() { |
+13-4src/lang/rust.rs
| @@ -52,7 +52,7 @@ impl LanguageGenerator for RustGenerator { | ||
| 52 | 52 | |
| 53 | 53 | fn struct_open(&self, name: &str) -> String { |
| 54 | 54 | format!( |
| 55 | - "#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct {} {{\n", | |
| 55 | + "#[derive(Debug, Clone, Default, Serialize, Deserialize)]\n#[serde(default)]\npub struct {} {{\n", | |
| 56 | 56 | name |
| 57 | 57 | ) |
| 58 | 58 | } |
| @@ -67,13 +67,13 @@ impl LanguageGenerator for RustGenerator { | ||
| 67 | 67 | |
| 68 | 68 | fn enum_open(&self, name: &str) -> String { |
| 69 | 69 | format!( |
| 70 | - "#[derive(Debug, Clone, Serialize, Deserialize)]\npub enum {} {{\n", | |
| 70 | + "#[derive(Debug, Clone, Default, Serialize, Deserialize)]\npub enum {} {{\n", | |
| 71 | 71 | name |
| 72 | 72 | ) |
| 73 | 73 | } |
| 74 | 74 | |
| 75 | 75 | fn enum_close(&self) -> String { |
| 76 | - "}\n".to_string() | |
| 76 | + " #[default]\n #[serde(other)]\n Unknown,\n}\n".to_string() | |
| 77 | 77 | } |
| 78 | 78 | |
| 79 | 79 | fn enum_variant(&self, variant_name: &str, json_value: &str) -> String { |
| @@ -88,7 +88,16 @@ impl LanguageGenerator for RustGenerator { | ||
| 88 | 88 | } |
| 89 | 89 | |
| 90 | 90 | fn type_name(&self, inferred: &InferredType) -> String { |
| 91 | - inferred.rust_type() | |
| 91 | + match inferred { | |
| 92 | + // Temporal types vary in format — String is the safe codegen choice | |
| 93 | + InferredType::DateTime | InferredType::Date | InferredType::Time => { | |
| 94 | + "String".to_string() | |
| 95 | + } | |
| 96 | + // Recurse into wrappers so Option<DateTime> → Option<String>, etc. | |
| 97 | + InferredType::Option(inner) => format!("Option<{}>", self.type_name(inner)), | |
| 98 | + InferredType::Array(inner) => format!("Vec<{}>", self.type_name(inner)), | |
| 99 | + _ => inferred.rust_type(), | |
| 100 | + } | |
| 92 | 101 | } |
| 93 | 102 | |
| 94 | 103 | fn mod_file(&self, file_names: &[&str]) -> Option<String> { |
+15-344src/views/code.rs
| @@ -2,7 +2,6 @@ use std::collections::{BTreeMap, BTreeSet}; | ||
| 2 | 2 | |
| 3 | 3 | use egui::{self, RichText, Ui}; |
| 4 | 4 | |
| 5 | -use crate::codegen::{CodeGenerator, localize_type}; | |
| 6 | 5 | use crate::theme::CatppuccinMocha; |
| 7 | 6 | |
| 8 | 7 | struct StructBlock { |
| @@ -346,86 +345,28 @@ impl CodeView { | ||
| 346 | 345 | self.files.clear(); |
| 347 | 346 | let lang = self.selected_language.generator(); |
| 348 | 347 | |
| 349 | - // shared file from schema (if available) | |
| 350 | - let shared_struct_names: std::collections::BTreeSet<String> = schema | |
| 351 | - .map(|s| s.structs.iter().map(|st| st.name.clone()).collect()) | |
| 352 | - .unwrap_or_default(); | |
| 353 | - | |
| 354 | - // unique struct names from schema — used to match disambiguated names | |
| 355 | - let unique_struct_names: std::collections::BTreeSet<String> = schema | |
| 356 | - .map(|s| s.unique_structs.iter().map(|st| st.name.clone()).collect()) | |
| 357 | - .unwrap_or_default(); | |
| 358 | - | |
| 359 | - // All structs for resolving Object types to struct names in per-file codegen | |
| 360 | - let all_schema_structs: Vec<crate::schema::SharedStruct> = schema | |
| 361 | - .map(|s| s.all_structs()) | |
| 362 | - .unwrap_or_default(); | |
| 363 | - | |
| 364 | - if let Some(s) = schema { | |
| 365 | - if !s.structs.is_empty() { | |
| 366 | - let code = CodeGenerator::from_schema(&s.structs).generate_code(lang.as_ref()); | |
| 367 | - let lines: Vec<String> = code.lines().map(|l| l.to_string()).collect(); | |
| 368 | - let struct_blocks = extract_struct_blocks(&lines, self.selected_language); | |
| 369 | - self.files.push(GeneratedFile { | |
| 370 | - name: lang.file_name("shared"), | |
| 371 | - code, | |
| 372 | - lines, | |
| 373 | - structs: struct_blocks, | |
| 374 | - is_group: false, | |
| 375 | - }); | |
| 376 | - } | |
| 377 | - } | |
| 378 | - | |
| 379 | - // Group files by depluralized first word | |
| 380 | - let mut groups: std::collections::BTreeMap<String, Vec<(&str, &serde_json::Value)>> = | |
| 381 | - std::collections::BTreeMap::new(); | |
| 382 | - for (filename, value) in parsed_files { | |
| 383 | - let word = first_normal_word(filename).unwrap_or_else(|| "other".to_string()); | |
| 384 | - let key = crate::codegen::singularize(&word.to_ascii_lowercase()); | |
| 385 | - groups.entry(key).or_default().push((filename.as_str(), value)); | |
| 386 | - } | |
| 387 | - | |
| 388 | - // Generate one file per group, merging struct definitions | |
| 389 | - for (group_key, files) in &groups { | |
| 390 | - let mut seen_structs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); | |
| 391 | - let mut struct_blocks: Vec<String> = Vec::new(); | |
| 392 | - | |
| 393 | - for (filename, value) in files { | |
| 394 | - let raw = generate_file_code_structs(value, &shared_struct_names, &unique_struct_names, &all_schema_structs, filename, lang.as_ref()); | |
| 395 | - for block in &raw { | |
| 396 | - if !seen_structs.contains(&block.name) { | |
| 397 | - seen_structs.insert(block.name.clone()); | |
| 398 | - struct_blocks.push(block.code.clone()); | |
| 399 | - } | |
| 400 | - } | |
| 401 | - } | |
| 348 | + let empty_schema = crate::schema::SchemaOverview { | |
| 349 | + structs: Vec::new(), | |
| 350 | + unique_structs: Vec::new(), | |
| 351 | + }; | |
| 352 | + let schema = schema.unwrap_or(&empty_schema); | |
| 402 | 353 | |
| 403 | - // Build struct body first, then generate imports based on what's used | |
| 404 | - let mut body = String::new(); | |
| 405 | - for block in &struct_blocks { | |
| 406 | - body.push_str(block); | |
| 407 | - body.push('\n'); | |
| 408 | - } | |
| 354 | + let project = crate::codegen::generate_project(parsed_files, schema, lang.as_ref()); | |
| 355 | + let shared_file_name = lang.file_name("shared"); | |
| 409 | 356 | |
| 410 | - let mut merged_code = String::new(); | |
| 411 | - let header = lang.file_header(); | |
| 412 | - if !header.is_empty() { | |
| 413 | - merged_code.push_str(&header); | |
| 414 | - merged_code.push('\n'); | |
| 357 | + for pf in &project { | |
| 358 | + if pf.name == "mod.rs" { | |
| 359 | + continue; | |
| 415 | 360 | } |
| 416 | - merged_code.push_str(&lang.imports_header(&body, !shared_struct_names.is_empty())); | |
| 417 | - merged_code.push('\n'); | |
| 418 | - merged_code.push_str(&body); | |
| 419 | - | |
| 420 | - let code = merged_code.trim_end().to_string() + "\n"; | |
| 421 | - let lines: Vec<String> = code.lines().map(|l| l.to_string()).collect(); | |
| 361 | + let lines: Vec<String> = pf.code.lines().map(|l| l.to_string()).collect(); | |
| 422 | 362 | let struct_blocks = extract_struct_blocks(&lines, self.selected_language); |
| 363 | + let is_group = pf.name != shared_file_name; | |
| 423 | 364 | self.files.push(GeneratedFile { |
| 424 | - name: lang.file_name(group_key), | |
| 425 | - code, | |
| 365 | + name: pf.name.clone(), | |
| 366 | + code: pf.code.clone(), | |
| 426 | 367 | lines, |
| 427 | 368 | structs: struct_blocks, |
| 428 | - is_group: true, | |
| 369 | + is_group, | |
| 429 | 370 | }); |
| 430 | 371 | } |
| 431 | 372 | |
| @@ -1287,276 +1228,6 @@ impl CodeView { | ||
| 1287 | 1228 | } |
| 1288 | 1229 | } |
| 1289 | 1230 | |
| 1290 | -/// Wrapper around codegen::first_normal_word that singularizes + PascalCases the result, | |
| 1291 | -/// matching the naming scheme used by schema disambiguation. | |
| 1292 | -fn first_normal_word(filename: &str) -> Option<String> { | |
| 1293 | - crate::codegen::first_normal_word(filename).map(|w| { | |
| 1294 | - let singular = crate::codegen::singularize(&w); | |
| 1295 | - crate::codegen::to_pascal_case(&singular) | |
| 1296 | - }) | |
| 1297 | -} | |
| 1298 | - | |
| 1299 | -struct NamedCodeBlock { | |
| 1300 | - name: String, | |
| 1301 | - code: String, | |
| 1302 | -} | |
| 1303 | - | |
| 1304 | -/// Generate individual struct blocks for a file (used for grouping/merging) | |
| 1305 | -fn generate_file_code_structs( | |
| 1306 | - value: &serde_json::Value, | |
| 1307 | - shared_names: &std::collections::BTreeSet<String>, | |
| 1308 | - unique_names: &std::collections::BTreeSet<String>, | |
| 1309 | - schema_structs: &[crate::schema::SharedStruct], | |
| 1310 | - filename: &str, | |
| 1311 | - lang: &dyn crate::lang::LanguageGenerator, | |
| 1312 | -) -> Vec<NamedCodeBlock> { | |
| 1313 | - let prefix = first_normal_word(filename).unwrap_or_default(); | |
| 1314 | - let is_root_array = value.is_array(); | |
| 1315 | - | |
| 1316 | - let (root_name, array_item_name) = if is_root_array { | |
| 1317 | - let singular = crate::codegen::singularize(&prefix); | |
| 1318 | - let item_name = if singular.is_empty() { | |
| 1319 | - "Item".to_string() | |
| 1320 | - } else { | |
| 1321 | - let mut s = String::new(); | |
| 1322 | - s.push(singular.chars().next().unwrap().to_ascii_uppercase()); | |
| 1323 | - s.extend(singular.chars().skip(1)); | |
| 1324 | - s | |
| 1325 | - }; | |
| 1326 | - (item_name.clone(), Some(item_name)) | |
| 1327 | - } else { | |
| 1328 | - let name = if prefix.is_empty() { | |
| 1329 | - "Root".to_string() | |
| 1330 | - } else { | |
| 1331 | - format!("{}Root", prefix) | |
| 1332 | - }; | |
| 1333 | - (name, None) | |
| 1334 | - }; | |
| 1335 | - | |
| 1336 | - let mut gen = CodeGenerator::from_value_named(value, &root_name); | |
| 1337 | - | |
| 1338 | - // Resolve unresolved fields against schema structs. | |
| 1339 | - // The per-file codegen infers types from one JSON instance, so nullable fields | |
| 1340 | - // (e.g. jumpseatReservations: null) lose their real type. Look up the schema's | |
| 1341 | - // merged type for those fields and resolve against known structs. | |
| 1342 | - if !schema_structs.is_empty() { | |
| 1343 | - for s in &mut gen.structs { | |
| 1344 | - // Find the matching schema struct by field overlap | |
| 1345 | - let schema_match = crate::types::resolve_struct_name( | |
| 1346 | - &s.fields.iter().map(|f| (f.json_name.clone(), f.inferred_type.clone())).collect(), | |
| 1347 | - schema_structs, | |
| 1348 | - ); | |
| 1349 | - let schema_fields = schema_match.and_then(|name| { | |
| 1350 | - schema_structs.iter().find(|ss| ss.name == name) | |
| 1351 | - }); | |
| 1352 | - | |
| 1353 | - for field in &mut s.fields { | |
| 1354 | - if field.resolved_type.is_none() { | |
| 1355 | - // First try resolving from the field's own inferred type | |
| 1356 | - field.resolved_type = | |
| 1357 | - crate::codegen::resolve_type_to_struct(&field.inferred_type, schema_structs); | |
| 1358 | - | |
| 1359 | - // If still unresolved, try the schema's merged type for this field | |
| 1360 | - if field.resolved_type.is_none() { | |
| 1361 | - if let Some(ss) = schema_fields { | |
| 1362 | - if let Some(schema_type) = ss.fields.get(&field.json_name) { | |
| 1363 | - field.resolved_type = | |
| 1364 | - crate::codegen::resolve_type_to_struct(schema_type, schema_structs); | |
| 1365 | - // Also update inferred_type so rust_type() fallback is correct | |
| 1366 | - if field.resolved_type.is_some() { | |
| 1367 | - field.inferred_type = schema_type.clone(); | |
| 1368 | - } | |
| 1369 | - } | |
| 1370 | - } | |
| 1371 | - } | |
| 1372 | - } | |
| 1373 | - } | |
| 1374 | - } | |
| 1375 | - | |
| 1376 | - // Generate structs from schema for types referenced but not generated | |
| 1377 | - // (happens when JSON value was null so collect_structs never saw the object) | |
| 1378 | - let existing_names: std::collections::BTreeSet<String> = | |
| 1379 | - gen.structs.iter().map(|s| s.name.clone()).collect(); | |
| 1380 | - let mut needed: Vec<String> = Vec::new(); | |
| 1381 | - for s in &gen.structs { | |
| 1382 | - for field in &s.fields { | |
| 1383 | - if let Some(rt) = &field.resolved_type { | |
| 1384 | - for name in extract_struct_names_from_resolved(rt) { | |
| 1385 | - if !existing_names.contains(&name) && !shared_names.contains(&name) { | |
| 1386 | - needed.push(name); | |
| 1387 | - } | |
| 1388 | - } | |
| 1389 | - } | |
| 1390 | - } | |
| 1391 | - } | |
| 1392 | - | |
| 1393 | - // Add missing structs from schema, and recurse for their dependencies | |
| 1394 | - let mut added: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); | |
| 1395 | - while let Some(name) = needed.pop() { | |
| 1396 | - if added.contains(&name) || existing_names.contains(&name) || shared_names.contains(&name) { | |
| 1397 | - continue; | |
| 1398 | - } | |
| 1399 | - added.insert(name.clone()); | |
| 1400 | - if let Some(ss) = schema_structs.iter().find(|ss| ss.name == name) { | |
| 1401 | - let fields: Vec<crate::codegen::GeneratedField> = ss.fields.iter().map(|(key, typ)| { | |
| 1402 | - let resolved = crate::codegen::resolve_type_to_struct(typ, schema_structs); | |
| 1403 | - // Queue any newly referenced structs | |
| 1404 | - if let Some(rt) = &resolved { | |
| 1405 | - for dep in extract_struct_names_from_resolved(rt) { | |
| 1406 | - needed.push(dep); | |
| 1407 | - } | |
| 1408 | - } | |
| 1409 | - crate::codegen::GeneratedField { | |
| 1410 | - json_name: key.clone(), | |
| 1411 | - inferred_type: typ.clone(), | |
| 1412 | - resolved_type: resolved, | |
| 1413 | - needs_rename: false, | |
| 1414 | - } | |
| 1415 | - }).collect(); | |
| 1416 | - gen.structs.push(crate::codegen::GeneratedStruct { | |
| 1417 | - name: name.clone(), | |
| 1418 | - fields, | |
| 1419 | - }); | |
| 1420 | - } | |
| 1421 | - } | |
| 1422 | - } | |
| 1423 | - | |
| 1424 | - let mut blocks = Vec::new(); | |
| 1425 | - | |
| 1426 | - // Type alias for array roots | |
| 1427 | - if let Some(ref item_name) = array_item_name { | |
| 1428 | - let aliased = if shared_names.contains(item_name) { | |
| 1429 | - item_name.clone() | |
| 1430 | - } else if !prefix.is_empty() && !item_name.starts_with(&prefix) { | |
| 1431 | - format!("{}{}", prefix, item_name) | |
| 1432 | - } else { | |
| 1433 | - item_name.clone() | |
| 1434 | - }; | |
| 1435 | - blocks.push(NamedCodeBlock { | |
| 1436 | - name: format!("{}Root", prefix), | |
| 1437 | - code: format!("pub type {}Root = Vec<{}>;\n", prefix, aliased), | |
| 1438 | - }); | |
| 1439 | - } | |
| 1440 | - | |
| 1441 | - // Combine shared + unique names for type resolution | |
| 1442 | - let all_schema_names: std::collections::BTreeSet<String> = shared_names | |
| 1443 | - .iter() | |
| 1444 | - .chain(unique_names.iter()) | |
| 1445 | - .cloned() | |
| 1446 | - .collect(); | |
| 1447 | - | |
| 1448 | - for s in gen.structs.iter().rev() { | |
| 1449 | - if shared_names.contains(&s.name) { | |
| 1450 | - continue; | |
| 1451 | - } | |
| 1452 | - | |
| 1453 | - // Use schema-disambiguated name if available, otherwise prefix as before | |
| 1454 | - let prefixed = format!("{}{}", prefix, s.name); | |
| 1455 | - let struct_name = if unique_names.contains(&prefixed) { | |
| 1456 | - prefixed | |
| 1457 | - } else if unique_names.contains(&s.name) { | |
| 1458 | - s.name.clone() | |
| 1459 | - } else if s.name != root_name && !prefix.is_empty() && !s.name.starts_with(&prefix) { | |
| 1460 | - prefixed | |
| 1461 | - } else { | |
| 1462 | - s.name.clone() | |
| 1463 | - }; | |
| 1464 | - | |
| 1465 | - let mut code = String::new(); | |
| 1466 | - code.push_str(&lang.struct_open(&struct_name)); | |
| 1467 | - let mut field_pairs: Vec<(String, String)> = Vec::new(); | |
| 1468 | - for field in &s.fields { | |
| 1469 | - let code_name = lang.field_name(&field.json_name); | |
| 1470 | - let base_type = match &field.resolved_type { | |
| 1471 | - Some(rt) => localize_type(rt, lang), | |
| 1472 | - None => lang.type_name(&field.inferred_type), | |
| 1473 | - }; | |
| 1474 | - let resolved_type = if !prefix.is_empty() && !all_schema_names.contains(&base_type) { | |
| 1475 | - prefix_type(&base_type, &prefix, &all_schema_names, &root_name) | |
| 1476 | - } else { | |
| 1477 | - base_type | |
| 1478 | - }; | |
| 1479 | - code.push_str(&lang.field_line(&code_name, &resolved_type, &field.json_name)); | |
| 1480 | - field_pairs.push((code_name, field.json_name.clone())); | |
| 1481 | - } | |
| 1482 | - code.push_str(&lang.struct_close(&field_pairs)); | |
| 1483 | - | |
| 1484 | - blocks.push(NamedCodeBlock { | |
| 1485 | - name: struct_name, | |
| 1486 | - code, | |
| 1487 | - }); | |
| 1488 | - } | |
| 1489 | - | |
| 1490 | - blocks | |
| 1491 | -} | |
| 1492 | - | |
| 1493 | -/// Extract bare struct names from a resolved type string like "Option<Vec<Foo>>" → ["Foo"] | |
| 1494 | -fn extract_struct_names_from_resolved(rt: &str) -> Vec<String> { | |
| 1495 | - let mut names = Vec::new(); | |
| 1496 | - let inner = rt | |
| 1497 | - .strip_prefix("Option<").and_then(|s| s.strip_suffix('>')) | |
| 1498 | - .or_else(|| rt.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>'))); | |
| 1499 | - if let Some(inner) = inner { | |
| 1500 | - names.extend(extract_struct_names_from_resolved(inner)); | |
| 1501 | - } else if !rt.is_empty() && rt.chars().next().unwrap().is_ascii_uppercase() { | |
| 1502 | - names.push(rt.to_string()); | |
| 1503 | - } | |
| 1504 | - names | |
| 1505 | -} | |
| 1506 | - | |
| 1507 | -/// Prefix struct references in type strings (handles Vec<T>, Option<T>, [T], T?, bare T) | |
| 1508 | -fn prefix_type( | |
| 1509 | - type_str: &str, | |
| 1510 | - prefix: &str, | |
| 1511 | - shared_names: &std::collections::BTreeSet<String>, | |
| 1512 | - root_name: &str, | |
| 1513 | -) -> String { | |
| 1514 | - // Rust: Vec<T> | |
| 1515 | - if type_str.starts_with("Vec<") && type_str.ends_with('>') { | |
| 1516 | - let inner = &type_str[4..type_str.len() - 1]; | |
| 1517 | - let prefixed_inner = prefix_type(inner, prefix, shared_names, root_name); | |
| 1518 | - format!("Vec<{}>", prefixed_inner) | |
| 1519 | - // Rust: Option<T> | |
| 1520 | - } else if type_str.starts_with("Option<") && type_str.ends_with('>') { | |
| 1521 | - let inner = &type_str[7..type_str.len() - 1]; | |
| 1522 | - let prefixed_inner = prefix_type(inner, prefix, shared_names, root_name); | |
| 1523 | - format!("Option<{}>", prefixed_inner) | |
| 1524 | - // Swift: [T] | |
| 1525 | - } else if type_str.starts_with('[') && type_str.ends_with(']') { | |
| 1526 | - let inner = &type_str[1..type_str.len() - 1]; | |
| 1527 | - let prefixed_inner = prefix_type(inner, prefix, shared_names, root_name); | |
| 1528 | - format!("[{}]", prefixed_inner) | |
| 1529 | - // Swift: T? | |
| 1530 | - } else if type_str.ends_with('?') { | |
| 1531 | - let inner = &type_str[..type_str.len() - 1]; | |
| 1532 | - let prefixed_inner = prefix_type(inner, prefix, shared_names, root_name); | |
| 1533 | - format!("{}?", prefixed_inner) | |
| 1534 | - } else if is_struct_name(type_str) | |
| 1535 | - && !shared_names.contains(type_str) | |
| 1536 | - && type_str != root_name | |
| 1537 | - && !type_str.starts_with(prefix) | |
| 1538 | - { | |
| 1539 | - format!("{}{}", prefix, type_str) | |
| 1540 | - } else { | |
| 1541 | - type_str.to_string() | |
| 1542 | - } | |
| 1543 | -} | |
| 1544 | - | |
| 1545 | -/// Check if a type name looks like a struct (PascalCase, not a primitive) | |
| 1546 | -fn is_struct_name(s: &str) -> bool { | |
| 1547 | - let first = s.chars().next().unwrap_or('a'); | |
| 1548 | - first.is_ascii_uppercase() | |
| 1549 | - && !s.contains('<') | |
| 1550 | - && !s.contains('[') | |
| 1551 | - && !matches!( | |
| 1552 | - s, | |
| 1553 | - // Rust primitives | |
| 1554 | - "String" | "Vec" | "Option" | "bool" | "i64" | "u64" | "f64" | "i32" | "u32" | "f32" | |
| 1555 | - | "NaiveDate" | "NaiveTime" | |
| 1556 | - // Swift primitives | |
| 1557 | - | "Bool" | "Int" | "Double" | "Date" | "Any" | |
| 1558 | - ) | |
| 1559 | -} | |
| 1560 | 1231 | |
| 1561 | 1232 | enum LineAction { |
| 1562 | 1233 | None, |
+0-5tests/schema_tests.rs
| @@ -2,8 +2,3 @@ use jv::schema::{SchemaOverview, SharedStruct}; | ||
| 2 | 2 | |
| 3 | 3 | // Public tests — run against committed sample data in samples/public/ |
| 4 | 4 | include!(concat!(env!("CARGO_MANIFEST_DIR"), "/samples/public/schema_tests.rs")); |
| 5 | - | |
| 6 | -// Private tests — only compiled if the file exists. | |
| 7 | -// To use: add JSON/HAR files to samples/private/ and tests to tests/private/schema_tests.rs | |
| 8 | -#[cfg(feature = "private_tests")] | |
| 9 | -include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/private/schema_tests.rs")); |