diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37389efd22..25ff99359f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -278,6 +278,11 @@ Some files gain an item in many pull requests at once. They are kept so that two each add an item only meet when their items are neighbours, which spares everyone a conflict and a fresh CI run: +- **Growing Rust lists**: use slices (`const ITEMS: &[Item] = &[...];`) without a written + length. `cargo xtask lists` rejects non-test const/static arrays with more than ten lines + of entries, except fixed standard or algorithm tables listed with reasons in `xtask/src/lists.rs`. + Built-in themes need only a JSON file in `assets/styles/ui/themes/`, with a unique nonnegative + integer `order` (the picker order) and its own `provenance` line; keep existing order values. - **Message catalogs** (`assets/locales/*.json`): one message per line, sorted by id. Add your message anywhere and run `cargo xtask lists --fix`; `cargo xtask lists` (in `cargo xtask ci` and the Lists workflow) fails on an unsorted catalog or an id that appears twice. diff --git a/assets/appearances/library.rs b/assets/appearances/library.rs index cc13d1ae73..eff355617a 100644 --- a/assets/appearances/library.rs +++ b/assets/appearances/library.rs @@ -1,5 +1,5 @@ //! Built-in model appearances: names, sRGB colours and metalness. -pub const LIBRARY: [(&str, [u8; 3], f64); 20] = [ +pub const LIBRARY: &[(&str, [u8; 3], f64)] = &[ ("Aluminum - Satin", [204, 208, 214], 1.0), ("Aluminum - Anodized Black", [44, 46, 50], 1.0), ("Aluminum - Anodized Blue", [52, 92, 160], 1.0), diff --git a/assets/appearances/opaque_finishes.rs b/assets/appearances/opaque_finishes.rs index 45e9600ba7..f018d05ce1 100644 --- a/assets/appearances/opaque_finishes.rs +++ b/assets/appearances/opaque_finishes.rs @@ -1,7 +1,7 @@ //! Original illustrative opaque finishes; not measured optical or engineering data. //! (name, sRGB color, metal fraction, perceptual roughness). IOR is an illustrative 1.5. //! Separate names preserve the scalar library's appearance and saved assignments. -pub const OPAQUE_FINISHES: [(&str, [u8; 3], f64, f64); 6] = [ +pub const OPAQUE_FINISHES: &[(&str, [u8; 3], f64, f64)] = &[ ("Steel - Satin PBR", [158, 164, 172], 1.0, 0.4), ("Aluminum - Satin PBR", [204, 208, 214], 1.0, 0.3), ("Copper - Clean PBR", [204, 122, 80], 1.0, 0.25), diff --git a/assets/appearances/starter_pack.rs b/assets/appearances/starter_pack.rs index 29ee53fc45..f7893ca644 100644 --- a/assets/appearances/starter_pack.rs +++ b/assets/appearances/starter_pack.rs @@ -16,7 +16,7 @@ const fn glass(transmission_linear_rgb: [f64; 3], ior: f64, reference_path_mm: f GlassRecipe { transmission_linear_rgb, dielectric_ior: ior, reference_path_mm, boundary } } -pub const STARTER_PACK: [VisualPreset; 40] = [ +pub const STARTER_PACK: &[VisualPreset] = &[ VisualPreset::opaque( "starter.wood.oak-honey", "Wood - Honey Oak Uniform Preview", diff --git a/assets/materials/library.rs b/assets/materials/library.rs index 4416cc1df6..97753c67ca 100644 --- a/assets/materials/library.rs +++ b/assets/materials/library.rs @@ -7,7 +7,7 @@ pub const DEFAULT_MATERIAL: &str = "Default"; pub const DEFAULT_DENSITY: f64 = 1.29; /// Physical materials: name and density (g/cm³). -pub const MATERIALS: [(&str, f64); 14] = [ +pub const MATERIALS: &[(&str, f64)] = &[ (DEFAULT_MATERIAL, DEFAULT_DENSITY), ("Steel", 7.85), ("Stainless Steel", 8.0), diff --git a/assets/styles/ui/themes/1-bit-dark.json b/assets/styles/ui/themes/1-bit-dark.json index 57a48d19c6..e3da0763dd 100644 --- a/assets/styles/ui/themes/1-bit-dark.json +++ b/assets/styles/ui/themes/1-bit-dark.json @@ -1,4 +1,6 @@ { + "order": 76, + "provenance": "Original black-and-white palette", "base": "dark", "colors": { "accent": "#ffffff", diff --git a/assets/styles/ui/themes/1-bit.json b/assets/styles/ui/themes/1-bit.json index 8d12b4b357..2781f3c570 100644 --- a/assets/styles/ui/themes/1-bit.json +++ b/assets/styles/ui/themes/1-bit.json @@ -1,4 +1,6 @@ { + "order": 75, + "provenance": "Original black-and-white palette", "base": "light", "colors": { "accent": "#000000", diff --git a/assets/styles/ui/themes/akari.json b/assets/styles/ui/themes/akari.json index a9d158a66e..2614f84c09 100644 --- a/assets/styles/ui/themes/akari.json +++ b/assets/styles/ui/themes/akari.json @@ -1,4 +1,6 @@ { + "order": 220, + "provenance": "Maps the Okibi design system", "base": "light", "colors": { "accent": "#c63a00", diff --git a/assets/styles/ui/themes/amber-phosphor.json b/assets/styles/ui/themes/amber-phosphor.json index 7cade1761d..fe01d8d150 100644 --- a/assets/styles/ui/themes/amber-phosphor.json +++ b/assets/styles/ui/themes/amber-phosphor.json @@ -1,4 +1,6 @@ { + "order": 110, + "provenance": "Original SolidCraft theme", "base": "dark", "colors": { "accent": "#ffbc48", diff --git a/assets/styles/ui/themes/blueprint.json b/assets/styles/ui/themes/blueprint.json index 51e45ffee1..5acbef2463 100644 --- a/assets/styles/ui/themes/blueprint.json +++ b/assets/styles/ui/themes/blueprint.json @@ -1,4 +1,6 @@ { + "order": 120, + "provenance": "Original SolidCraft theme", "base": "dark", "colors": { "accent": "#66ddff", diff --git a/assets/styles/ui/themes/catppuccin-mocha.json b/assets/styles/ui/themes/catppuccin-mocha.json index c95372cb51..7cd840f52f 100644 --- a/assets/styles/ui/themes/catppuccin-mocha.json +++ b/assets/styles/ui/themes/catppuccin-mocha.json @@ -1,4 +1,6 @@ { + "order": 200, + "provenance": "Original SolidCraft mapping of Catppuccin (MIT palette)", "base": "dark", "colors": { "accent": "#cba6f7", diff --git a/assets/styles/ui/themes/cga.json b/assets/styles/ui/themes/cga.json index d7be4b7c8a..90e5623d2e 100644 --- a/assets/styles/ui/themes/cga.json +++ b/assets/styles/ui/themes/cga.json @@ -1,4 +1,6 @@ { + "order": 250, + "provenance": "Original mapping of the IBM Color Graphics Adapter palette", "base": "dark", "colors": { "accent": "#55ffff", diff --git a/assets/styles/ui/themes/clay-dark.json b/assets/styles/ui/themes/clay-dark.json index 2a154eacba..b440036bc3 100644 --- a/assets/styles/ui/themes/clay-dark.json +++ b/assets/styles/ui/themes/clay-dark.json @@ -1,4 +1,6 @@ { + "order": 240, + "provenance": "Original warm paper and charcoal palette", "base": "dark", "colors": { "accent": "#e8845e", diff --git a/assets/styles/ui/themes/clay.json b/assets/styles/ui/themes/clay.json index d4a6b84edf..763fbf190b 100644 --- a/assets/styles/ui/themes/clay.json +++ b/assets/styles/ui/themes/clay.json @@ -1,4 +1,6 @@ { + "order": 230, + "provenance": "Original warm paper and charcoal palette", "base": "light", "colors": { "accent": "#bf5634", diff --git a/assets/styles/ui/themes/cupertino.json b/assets/styles/ui/themes/cupertino.json index a82c4b0d21..e5448a97d7 100644 --- a/assets/styles/ui/themes/cupertino.json +++ b/assets/styles/ui/themes/cupertino.json @@ -1,4 +1,6 @@ { + "order": 90, + "provenance": "Original SolidCraft theme", "base": "light", "colors": { "accent": "#007aff", diff --git a/assets/styles/ui/themes/graphite.json b/assets/styles/ui/themes/graphite.json index 87d4675a17..457c840212 100644 --- a/assets/styles/ui/themes/graphite.json +++ b/assets/styles/ui/themes/graphite.json @@ -1,4 +1,6 @@ { + "order": 180, + "provenance": "Original SolidCraft theme", "base": "dark", "colors": { "accent": "#4aa3d8", diff --git a/assets/styles/ui/themes/green-phosphor.json b/assets/styles/ui/themes/green-phosphor.json index ae347d68b4..55eec536b5 100644 --- a/assets/styles/ui/themes/green-phosphor.json +++ b/assets/styles/ui/themes/green-phosphor.json @@ -1,4 +1,6 @@ { + "order": 100, + "provenance": "Original SolidCraft theme", "base": "dark", "colors": { "accent": "#4dff79", diff --git a/assets/styles/ui/themes/gruvbox-dark-hard.json b/assets/styles/ui/themes/gruvbox-dark-hard.json index 87f00df6f1..d1f9b019a6 100644 --- a/assets/styles/ui/themes/gruvbox-dark-hard.json +++ b/assets/styles/ui/themes/gruvbox-dark-hard.json @@ -1,4 +1,6 @@ { + "order": 20, + "provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)", "format": "solidcraft-theme/1", "name": "Gruvbox Dark Hard", "base": "dark", diff --git a/assets/styles/ui/themes/gruvbox-dark-soft.json b/assets/styles/ui/themes/gruvbox-dark-soft.json index 88afd43f24..847b57a031 100644 --- a/assets/styles/ui/themes/gruvbox-dark-soft.json +++ b/assets/styles/ui/themes/gruvbox-dark-soft.json @@ -1,4 +1,6 @@ { + "order": 30, + "provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)", "format": "solidcraft-theme/1", "name": "Gruvbox Dark Soft", "base": "dark", diff --git a/assets/styles/ui/themes/gruvbox-dark.json b/assets/styles/ui/themes/gruvbox-dark.json index 34635540d9..9c73534e0e 100644 --- a/assets/styles/ui/themes/gruvbox-dark.json +++ b/assets/styles/ui/themes/gruvbox-dark.json @@ -1,4 +1,6 @@ { + "order": 10, + "provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)", "format": "solidcraft-theme/1", "name": "Gruvbox Dark", "base": "dark", diff --git a/assets/styles/ui/themes/gruvbox-light-hard.json b/assets/styles/ui/themes/gruvbox-light-hard.json index e0f148f451..75e669b517 100644 --- a/assets/styles/ui/themes/gruvbox-light-hard.json +++ b/assets/styles/ui/themes/gruvbox-light-hard.json @@ -1,4 +1,6 @@ { + "order": 50, + "provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)", "format": "solidcraft-theme/1", "name": "Gruvbox Light Hard", "base": "light", diff --git a/assets/styles/ui/themes/gruvbox-light-soft.json b/assets/styles/ui/themes/gruvbox-light-soft.json index 91b8c4399d..24c372901f 100644 --- a/assets/styles/ui/themes/gruvbox-light-soft.json +++ b/assets/styles/ui/themes/gruvbox-light-soft.json @@ -1,4 +1,6 @@ { + "order": 60, + "provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)", "format": "solidcraft-theme/1", "name": "Gruvbox Light Soft", "base": "light", diff --git a/assets/styles/ui/themes/gruvbox-light.json b/assets/styles/ui/themes/gruvbox-light.json index 05787738f4..b61a4cd273 100644 --- a/assets/styles/ui/themes/gruvbox-light.json +++ b/assets/styles/ui/themes/gruvbox-light.json @@ -1,4 +1,6 @@ { + "order": 40, + "provenance": "Original SolidCraft mapping of Gruvbox (MIT palette)", "format": "solidcraft-theme/1", "name": "Gruvbox Light", "base": "light", diff --git a/assets/styles/ui/themes/high-contrast.json b/assets/styles/ui/themes/high-contrast.json index 8dd1515c87..792da1e1f9 100644 --- a/assets/styles/ui/themes/high-contrast.json +++ b/assets/styles/ui/themes/high-contrast.json @@ -1,4 +1,6 @@ { + "order": 170, + "provenance": "Original SolidCraft theme", "base": "dark", "colors": { "accent": "#00bfff", diff --git a/assets/styles/ui/themes/nord.json b/assets/styles/ui/themes/nord.json index e9eb44732e..03ba217ec5 100644 --- a/assets/styles/ui/themes/nord.json +++ b/assets/styles/ui/themes/nord.json @@ -1,4 +1,6 @@ { + "order": 160, + "provenance": "Original SolidCraft mapping of Nord (MIT palette)", "base": "dark", "colors": { "accent": "#88c0d0", diff --git a/assets/styles/ui/themes/nosferatu.json b/assets/styles/ui/themes/nosferatu.json index 1702b071e2..99113231ee 100644 --- a/assets/styles/ui/themes/nosferatu.json +++ b/assets/styles/ui/themes/nosferatu.json @@ -1,4 +1,6 @@ { + "order": 190, + "provenance": "Original SolidCraft mapping of Dracula (MIT palette)", "base": "dark", "colors": { "accent": "#bd93f9", diff --git a/assets/styles/ui/themes/okibi.json b/assets/styles/ui/themes/okibi.json index 34a87bfd52..a8c20ce612 100644 --- a/assets/styles/ui/themes/okibi.json +++ b/assets/styles/ui/themes/okibi.json @@ -1,4 +1,6 @@ { + "order": 210, + "provenance": "Maps the Okibi design system", "base": "dark", "colors": { "accent": "#ff7c50", diff --git a/assets/styles/ui/themes/one-dark.json b/assets/styles/ui/themes/one-dark.json index e616e1f58c..e2ba62c95c 100644 --- a/assets/styles/ui/themes/one-dark.json +++ b/assets/styles/ui/themes/one-dark.json @@ -1,4 +1,6 @@ { + "order": 130, + "provenance": "Original SolidCraft mapping of Atom One Dark (MIT palette)", "base": "dark", "colors": { "accent": "#61afef", diff --git a/assets/styles/ui/themes/platinum-9.json b/assets/styles/ui/themes/platinum-9.json index 7fb1f5f683..64cf54b729 100644 --- a/assets/styles/ui/themes/platinum-9.json +++ b/assets/styles/ui/themes/platinum-9.json @@ -1,4 +1,6 @@ { + "order": 80, + "provenance": "Original SolidCraft theme", "base": "light", "colors": { "accent": "#555599", diff --git a/assets/styles/ui/themes/solarized-light.json b/assets/styles/ui/themes/solarized-light.json index e5c7adb0d5..7c10f6cfc2 100644 --- a/assets/styles/ui/themes/solarized-light.json +++ b/assets/styles/ui/themes/solarized-light.json @@ -1,4 +1,6 @@ { + "order": 150, + "provenance": "Original SolidCraft mapping of Solarized (MIT palette)", "base": "light", "colors": { "accent": "#268bd2", diff --git a/assets/styles/ui/themes/solidcraft-95.json b/assets/styles/ui/themes/solidcraft-95.json index 3cd974ec79..6ed0ba8016 100644 --- a/assets/styles/ui/themes/solidcraft-95.json +++ b/assets/styles/ui/themes/solidcraft-95.json @@ -1,4 +1,6 @@ { + "order": 70, + "provenance": "Original SolidCraft theme", "base": "light", "colors": { "accent": "#000080", diff --git a/assets/styles/ui/themes/tokyo-night.json b/assets/styles/ui/themes/tokyo-night.json index 1897ee42f2..73937c2e60 100644 --- a/assets/styles/ui/themes/tokyo-night.json +++ b/assets/styles/ui/themes/tokyo-night.json @@ -1,4 +1,6 @@ { + "order": 140, + "provenance": "Original SolidCraft mapping of Tokyo Night (MIT palette)", "base": "dark", "colors": { "accent": "#7aa2f7", diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 44b11d67ea..574b4200a5 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -747,7 +747,7 @@ mod tests { fn stable_ids_categories_and_original_licences_cover_the_requested_pack() { let mut ids = BTreeSet::new(); let mut names = BTreeSet::new(); - for preset in &STARTER_PACK { + for preset in STARTER_PACK { assert!(preset.id.starts_with("starter.") && preset.id.len() <= 128); assert!(preset.id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-')); assert!(ids.insert(preset.id)); @@ -767,14 +767,14 @@ mod tests { (Category::Solar, 2), (Category::Regolith, 4), ] { - assert_eq!(STARTER_PACK.iter().filter(|p| p.category == category).count(), count); + assert!(STARTER_PACK.iter().filter(|p| p.category == category).count() >= count); } } #[test] fn opaque_construction_and_owned_look_roundtrip_preserve_current_response() { let mut admitted = 0; - for preset in &STARTER_PACK { + for preset in STARTER_PACK { if let Recipe::Opaque(recipe) = preset.recipe { let look = preset.opaque_look().unwrap(); assert_eq!(look.opacity, 1.0); @@ -789,7 +789,7 @@ mod tests { admitted += 1; } } - assert_eq!(admitted, 34); + assert!(admitted >= 34); } #[test] @@ -821,8 +821,10 @@ mod tests { #[test] fn legacy_library_and_six_existing_finish_names_are_not_overridden_or_duplicated() { - assert_eq!(crate::appearance::LIBRARY.len(), 20); - for (name, color, opacity) in crate::appearance::LIBRARY { + assert!(!crate::appearance::LIBRARY.is_empty()); + let mut names = BTreeSet::new(); + for &(name, color, opacity) in crate::appearance::LIBRARY { + assert!(!name.is_empty() && names.insert(name.to_ascii_lowercase())); let look = crate::appearance::library(name).unwrap(); assert_eq!(look.color, color); assert_eq!(look.opacity, opacity); @@ -844,7 +846,7 @@ mod tests { #[test] fn uniform_previews_and_planned_resource_scale_are_explicit_and_not_bindings() { let mut recipes = 0; - for preset in &STARTER_PACK { + for preset in STARTER_PACK { if let Some(recipe) = preset.future_texture_recipe { assert!(preset.name.contains("Uniform Preview")); assert!(preset.preview_note.contains("Uniform opaque preview only")); @@ -855,7 +857,7 @@ mod tests { recipes += 1; } } - assert_eq!(recipes, 14); + assert!(recipes >= 14); } } } diff --git a/crates/doc/src/drawing_tests.rs b/crates/doc/src/drawing_tests.rs index d51e8fd274..3fce8289bd 100644 --- a/crates/doc/src/drawing_tests.rs +++ b/crates/doc/src/drawing_tests.rs @@ -216,7 +216,7 @@ fn a_fixed_unit_is_a_length_unit_of_the_expression_table() { read(&v).map(|d| d.units) }; for (unit, kind, _) in expr::UNITS { - assert_eq!(units(json!({"fixed": {"unit": unit}})).is_ok(), kind == expr::Kind::Length, "{unit}"); + assert_eq!(units(json!({"fixed": {"unit": unit}})).is_ok(), *kind == expr::Kind::Length, "{unit}"); } assert_eq!(units(json!({"fixed": {"unit": "in"}})).unwrap(), Units::Fixed { unit: "in".into() }); assert_eq!(units(json!("follow_model")).unwrap(), Units::FollowModel); diff --git a/crates/doc/src/expr.rs b/crates/doc/src/expr.rs index 01688c5050..203238361b 100644 --- a/crates/doc/src/expr.rs +++ b/crates/doc/src/expr.rs @@ -127,7 +127,7 @@ impl Kind { } /// Units a parameter or an expression may use: name, kind and size (mm or radians). -pub const UNITS: [(&str, Kind, f64); 14] = [ +pub const UNITS: &[(&str, Kind, f64)] = &[ ("mm", Kind::Length, 1.0), ("cm", Kind::Length, 10.0), ("m", Kind::Length, 1000.0), @@ -145,7 +145,7 @@ pub const UNITS: [(&str, Kind, f64); 14] = [ ]; /// Functions an expression may call (name, signature) — for autocomplete and help. -pub const FUNCTIONS: [(&str, &str); 23] = [ +pub const FUNCTIONS: &[(&str, &str)] = &[ ("sin", "sin(angle)"), ("cos", "cos(angle)"), ("tan", "tan(angle)"), @@ -172,7 +172,7 @@ pub const FUNCTIONS: [(&str, &str); 23] = [ ]; /// Constants an expression may use. -pub const CONSTANTS: [&str; 2] = ["pi", "PI"]; +pub const CONSTANTS: &[&str] = &["pi", "PI"]; /// Kind and size (mm or radians per unit) of a parameter's unit; empty means unit-less. pub fn unit_info(u: &str) -> Option<(Kind, f64)> { diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 11f2fe133f..c1806c8f7d 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -717,7 +717,7 @@ fn appearance_library(_s: &mut Session, _p: &Value) -> Result { value["category"] = json!(name.split(" - ").next().unwrap_or(name)); Some(value) })); - for preset in &solidcraft_doc::appearance_pack::STARTER_PACK { + for preset in solidcraft_doc::appearance_pack::STARTER_PACK { let c = preset.preview_color(); let pbr = preset.opaque_look().ok().and_then(|look| look.pbr); list.push(json!({"id":preset.id,"name":preset.name,"category":preset.category,"revision":preset.revision, diff --git a/crates/engine/src/cmd/material_properties.rs b/crates/engine/src/cmd/material_properties.rs index fe3acd8c1c..cc6dce33f1 100644 --- a/crates/engine/src/cmd/material_properties.rs +++ b/crates/engine/src/cmd/material_properties.rs @@ -308,7 +308,7 @@ mod tests { fn material_inspection_preserves_legacy_entries_and_reports_unknowns_and_density_separately() { let mut s = fixture(); let built_in = s.execute("material.list", &json!({})).unwrap(); - for (name, density) in solidcraft_doc::MATERIALS { + for &(name, density) in solidcraft_doc::MATERIALS { let row = built_in["materials"].as_array().unwrap().iter().find(|m| m["name"] == name).unwrap(); assert_eq!(*row, json!({"name":name,"density_g_cm3":density})); let inspected = s.execute("material.inspect", &json!({"material":name.to_lowercase()})).unwrap(); diff --git a/crates/engine/src/cmd/parts.rs b/crates/engine/src/cmd/parts.rs index e6230bd8ab..6dc8cf2179 100644 --- a/crates/engine/src/cmd/parts.rs +++ b/crates/engine/src/cmd/parts.rs @@ -31,7 +31,7 @@ pub static COMMANDS: &[CommandSpec] = &[ ]; /// A family: (id, name, standard). -const FAMILIES: [(&str, &str, &str); 7] = [ +const FAMILIES: &[(&str, &str, &str)] = &[ ("socket_head_cap_screw", "Socket head cap screw", "ISO 4762"), ("hex_bolt", "Hex head bolt / screw", "ISO 4014 / ISO 4017"), ("hex_nut", "Hex nut", "ISO 4032"), diff --git a/crates/engine/src/cmd/pbr_appearance_tests.rs b/crates/engine/src/cmd/pbr_appearance_tests.rs index 7537e59a2a..1673876b2c 100644 --- a/crates/engine/src/cmd/pbr_appearance_tests.rs +++ b/crates/engine/src/cmd/pbr_appearance_tests.rs @@ -215,7 +215,7 @@ fn named_opaque_finishes_reach_the_scene_and_survive_undo_and_reopen_without_cha ("Wood - Oak", [190, 146, 92], 1.0), ("Paint - Gloss White", [246, 246, 244], 1.0), ]; - assert_eq!(solidcraft_doc::appearance::LIBRARY, legacy); + assert!(solidcraft_doc::appearance::LIBRARY.len() >= legacy.len()); for (name, color, opacity) in legacy { let matching: Vec<_> = entries.iter().filter(|entry| entry["name"] == name).collect(); assert_eq!(matching.len(), 1, "legacy row must occur exactly once: {name}"); @@ -224,11 +224,12 @@ fn named_opaque_finishes_reach_the_scene_and_survive_undo_and_reopen_without_cha assert_eq!(row["opacity"], opacity); assert!(row.get("pbr").is_none() && row.get("id").is_none(), "legacy scalar meaning changed: {name}"); } - assert_eq!( - solidcraft_doc::appearance::OPAQUE_FINISHES.map(|(name, ..)| name), + for name in ["Steel - Satin PBR", "Aluminum - Satin PBR", "Copper - Clean PBR", "ABS - Molded PBR", "PLA - Smooth PBR", "Wood - Oak Uniform PBR Preview"] - ); - for (name, ..) in solidcraft_doc::appearance::OPAQUE_FINISHES { + { + assert!(solidcraft_doc::appearance::OPAQUE_FINISHES.iter().any(|(entry, ..)| *entry == name)); + } + for &(name, ..) in solidcraft_doc::appearance::OPAQUE_FINISHES { let expected = solidcraft_doc::appearance::library(name).unwrap(); let matching: Vec<_> = entries.iter().filter(|entry| entry["name"] == name).collect(); assert_eq!(matching.len(), 1, "finish row must occur exactly once: {name}"); @@ -435,7 +436,7 @@ fn pack_listing_keeps_legacy_rows_and_labels_unavailable_recipes_without_mutatio ("Wood - Oak", [190, 146, 92], 1.0), ("Paint - Gloss White", [246, 246, 244], 1.0), ]; - assert_eq!(solidcraft_doc::appearance::LIBRARY, legacy); + assert!(solidcraft_doc::appearance::LIBRARY.len() >= legacy.len()); for (name, color, opacity) in legacy { let matching: Vec<_> = rows.iter().filter(|row| row["name"] == name).collect(); assert_eq!(matching.len(), 1, "legacy appearance must occur exactly once: {name}"); @@ -452,7 +453,7 @@ fn pack_listing_keeps_legacy_rows_and_labels_unavailable_recipes_without_mutatio ("PLA - Smooth PBR", [110, 160, 222], 0.0, 0.35), ("Wood - Oak Uniform PBR Preview", [190, 146, 92], 0.0, 0.65), ]; - assert_eq!(solidcraft_doc::appearance::OPAQUE_FINISHES, finishes); + assert!(finishes.iter().all(|finish| solidcraft_doc::appearance::OPAQUE_FINISHES.contains(finish))); for (name, color, metallic, roughness) in finishes { let matching: Vec<_> = rows.iter().filter(|row| row["name"] == name).collect(); assert_eq!(matching.len(), 1, "published finish must occur exactly once: {name}"); @@ -462,12 +463,17 @@ fn pack_listing_keeps_legacy_rows_and_labels_unavailable_recipes_without_mutatio assert_eq!(row["pbr"], json!({"metallic":metallic,"roughness":roughness,"dielectric_ior":1.5})); assert!(row.get("id").is_none()); } - assert_eq!(rows.len(), 66); - assert_eq!(rows.iter().filter(|row| row["available"] != false).count(), 60); - assert_eq!(rows.iter().filter(|row| row["available"] != false && row["opacity"] == 1.0).count(), 58); - assert_eq!(rows.iter().filter(|row| row["opacity"] != 1.0).count(), 2); - assert_eq!(rows.iter().filter(|v| v.get("id").is_some()).count(), 40); - assert_eq!(rows.iter().filter(|v| v["available"] == false).count(), 6); + assert!(!rows.is_empty()); + let mut names = std::collections::BTreeSet::new(); + for row in rows { + let name = row["name"].as_str().unwrap(); + assert!(!name.is_empty() && names.insert(name.to_ascii_lowercase())); + } + assert!(rows.iter().filter(|row| row["available"] != false).count() >= 60); + assert!(rows.iter().filter(|row| row["available"] != false && row["opacity"] == 1.0).count() >= 58); + assert!(rows.iter().filter(|row| row["opacity"] != 1.0).count() >= 2); + assert!(rows.iter().filter(|v| v.get("id").is_some()).count() >= 40); + assert!(rows.iter().filter(|v| v["available"] == false).count() >= 6); for row in rows.iter().filter(|v| v["category"] == "glass") { assert_eq!(row["available"], false); assert_eq!(row["opacity"], 1.0); @@ -608,7 +614,7 @@ fn starter_visual_defaults_and_overrides_keep_saved_physical_cards_independent() fn starter_names_and_ids_share_admission_before_all_overrides() { use crate::doc::appearance_pack::{Recipe, STARTER_PACK}; let mut s = pack_session(); - for preset in &STARTER_PACK { + for preset in STARTER_PACK { for selector in [preset.id, preset.name] { if matches!(preset.recipe, Recipe::Glass(_)) { for overrides in [ diff --git a/crates/engine/src/cmd/render/library.rs b/crates/engine/src/cmd/render/library.rs index a650d59cd0..cb74adeae3 100644 --- a/crates/engine/src/cmd/render/library.rs +++ b/crates/engine/src/cmd/render/library.rs @@ -18,7 +18,7 @@ use solidcraft_render::{ use crate::{Result, Session, params::bad}; -const KINDS: [&str; 12] = [ +const KINDS: &[&str] = &[ "materials", "lighting", "environments", @@ -45,7 +45,7 @@ fn catalog(kind: &str, command: &str) -> Result<&'static [Value]> { } let cached = CATALOGS.get_or_init(|| { let mut catalogs = BTreeMap::new(); - for kind in KINDS { + for &kind in KINDS { let source = super::library_data::catalog(kind).ok_or_else(|| format!("missing built-in catalog {kind}"))?; let value: Value = serde_json::from_str(source).map_err(|e| format!("invalid built-in catalog {kind}: {e}"))?; if !value.get("items").is_some_and(Value::is_array) { diff --git a/crates/engine/src/cmd/render/studio_presets_tests.rs b/crates/engine/src/cmd/render/studio_presets_tests.rs index cf63ef8097..7cb224f5a5 100644 --- a/crates/engine/src/cmd/render/studio_presets_tests.rs +++ b/crates/engine/src/cmd/render/studio_presets_tests.rs @@ -14,11 +14,14 @@ fn subject() -> Session { #[test] fn native_studios_resolve_without_mutation_and_apply_one_complete_undo() { let entries = super::entries(); - assert_eq!(entries.as_array().unwrap().len(), 25); - let hdrs: std::collections::BTreeSet<_> = entries.as_array().unwrap().iter().map(|item| item["hdr"].as_str().unwrap()).collect(); - assert_eq!(hdrs.len(), 24, "every authored HDR has a complete curated composition"); - for index in 1..=25 { - let id = format!("NATIVE-STUDIO-{index:03}"); + let entries = entries.as_array().unwrap(); + assert!(!entries.is_empty()); + let mut ids = std::collections::BTreeSet::new(); + let hdrs: std::collections::BTreeSet<_> = entries.iter().map(|item| item["hdr"].as_str().unwrap()).collect(); + assert!(hdrs.len() >= 24, "every authored HDR has a complete curated composition"); + for entry in entries { + let id = entry["id"].as_str().unwrap(); + assert!(!id.is_empty() && ids.insert(id)); let mut s = subject(); let before = s.doc.to_json(); let undo = s.undo.len(); diff --git a/crates/engine/src/schema/types.rs b/crates/engine/src/schema/types.rs index ed6e6b9862..7d0bd4521a 100644 --- a/crates/engine/src/schema/types.rs +++ b/crates/engine/src/schema/types.rs @@ -390,7 +390,7 @@ impl JsonSchema for StringList { /// The spellings `Operation::parse` accepts, canonical ones first (it also ignores case, spaces, /// '_' and '-', which an enum can't say). -const OPERATIONS: [&str; 13] = [ +const OPERATIONS: &[&str] = &[ "new", "join", "cut", @@ -447,7 +447,7 @@ impl JsonSchema for OperationParam { } fn json_schema(_: &mut SchemaGenerator) -> Schema { enum_schema( - &OPERATIONS, + OPERATIONS, "How the feature combines with existing bodies: new, join, cut or intersect. The others are aliases. Matching ignores case, spaces, '_' and '-'.", ) } @@ -471,7 +471,7 @@ impl JsonSchema for ExtrudeOperation { } fn json_schema(_: &mut SchemaGenerator) -> Schema { let mut values = vec!["auto"]; - values.extend(OPERATIONS); + values.extend_from_slice(OPERATIONS); enum_schema( &values, "How the extrusion combines with existing bodies: auto (cut into a body, otherwise join; new in an empty component), new, join, cut or intersect. The others are aliases. Matching ignores case, spaces, '_' and '-' (only case for auto).", @@ -479,7 +479,7 @@ impl JsonSchema for ExtrudeOperation { } } -const DIRECTIONS: [&str; 6] = ["positive", "negative", "symmetric", "one_side", "reverse", "flip"]; +const DIRECTIONS: &[&str] = &["positive", "negative", "symmetric", "one_side", "reverse", "flip"]; /// Extrude's direction, as `extent_param` reads it (lowercased, not trimmed). #[derive(Clone, Debug, PartialEq)] @@ -500,7 +500,7 @@ impl JsonSchema for DirectionParam { } fn json_schema(_: &mut SchemaGenerator) -> Schema { enum_schema( - &DIRECTIONS, + DIRECTIONS, "Which way to extrude: positive (the default), negative, or symmetric (the distance is the total thickness, split evenly around the start plane). one_side is an alias of positive; reverse and flip of negative. Matching ignores case.", ) } diff --git a/crates/geom/src/cell.rs b/crates/geom/src/cell.rs index 04b8a14488..81219707a5 100644 --- a/crates/geom/src/cell.rs +++ b/crates/geom/src/cell.rs @@ -188,7 +188,7 @@ impl Fit { /// The Unicode blocks of the scripts written in wide characters (Chinese, Japanese, Korean and Yi), /// with their full-width and vertical forms. -const WIDE: [(u32, u32); 15] = [ +const WIDE: &[(u32, u32)] = &[ (0x1100, 0x115F), (0x2E80, 0x303E), (0x3041, 0x33FF), diff --git a/crates/geom/src/drawing_view.rs b/crates/geom/src/drawing_view.rs index 4459c10db3..fd824afffd 100644 --- a/crates/geom/src/drawing_view.rs +++ b/crates/geom/src/drawing_view.rs @@ -136,7 +136,7 @@ pub struct Scale { } /// ISO 5455's recommended scales, largest first: enlargements, full size, then reductions. -pub const PRESETS: [Scale; 18] = [ +pub const PRESETS: &[Scale] = &[ Scale { paper: 50.0, model: 1.0 }, Scale { paper: 20.0, model: 1.0 }, Scale { paper: 10.0, model: 1.0 }, @@ -181,7 +181,7 @@ impl Scale { if ![size_mm, room_mm].into_iter().all(|v| v.is_finite() && v.x >= 0.0 && v.y >= 0.0) { return None; } - PRESETS.into_iter().find(|s| size_mm.x * s.paper <= room_mm.x * s.model && size_mm.y * s.paper <= room_mm.y * s.model) + PRESETS.iter().copied().find(|s| size_mm.x * s.paper <= room_mm.x * s.model && size_mm.y * s.paper <= room_mm.y * s.model) } } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index a4544a3d88..61ba6cee91 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -35,6 +35,10 @@ fn main() { println!("cargo:rerun-if-changed={dir}/logs/HEAD"); } println!("cargo:rerun-if-changed=build.rs"); + if let Err(e) = builtin_themes() { + eprintln!("themes: {e}"); + std::process::exit(1); + } scenario_tests(); if let Err(e) = merged_locales() { eprintln!("locales: {e}"); @@ -279,3 +283,33 @@ fn icon_data() -> Result<(), String> { let dest = std::path::Path::new(&std::env::var("OUT_DIR").map_err(|e| e.to_string())?).join("icon_data.rs"); std::fs::write(dest, out).map_err(|e| e.to_string()) } + +/// Embed each theme without a shared registry. Only std is needed to collect the files; +/// theme parsing and ordering use the same validation on native and web targets. +fn builtin_themes() -> Result<(), String> { + let manifest = std::env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?; + let dir = std::path::Path::new(&manifest).join("../../assets/styles/ui/themes"); + println!("cargo:rerun-if-changed={}", dir.display()); + let mut files = Vec::new(); + for entry in std::fs::read_dir(&dir).map_err(|e| e.to_string())? { + let path = entry.map_err(|e| e.to_string())?.path(); + if path.extension().is_some_and(|e| e == "json") { + files.push(path); + } + } + files.sort_by(|a, b| a.file_stem().cmp(&b.file_stem())); + let mut out = String::from("/// The theme files shipped with SolidCraft.\nconst BUILTIN: &[(&str, &str)] = &[\n"); + for path in files { + let id = path.file_stem().and_then(|s| s.to_str()).ok_or("theme file name is not UTF-8")?; + let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + // Keep each file's provenance beside its generated entry, never in a shared header. + let provenance = text + .lines() + .find(|line| line.trim_start().starts_with("\"provenance\":")) + .ok_or_else(|| format!("{}: missing provenance", path.display()))?; + out += &format!(" ({id:?}, include_str!({:?})), // {}\n", path.display().to_string(), provenance.trim()); + } + out += "];\n"; + let dest = std::path::Path::new(&std::env::var("OUT_DIR").map_err(|e| e.to_string())?).join("builtin_themes.rs"); + std::fs::write(dest, out).map_err(|e| e.to_string()) +} diff --git a/crates/ui-egui/src/control_job.rs b/crates/ui-egui/src/control_job.rs index 38d70bd185..fe2c45ebaf 100644 --- a/crates/ui-egui/src/control_job.rs +++ b/crates/ui-egui/src/control_job.rs @@ -38,7 +38,7 @@ use crate::control::ControlRequest; /// Areas whose commands only compute the design (no files written, nothing started outside the /// app), so evaluating them ahead on a copy has no effect but a warm cache. -const AHEAD_AREAS: [&str; 22] = [ +const AHEAD_AREAS: &[&str] = &[ "appearance", "assembly", "body", @@ -64,16 +64,16 @@ const AHEAD_AREAS: [&str; 22] = [ ]; /// Parameters that name a file or folder: a command given one runs here, as before. -const NAMES_A_FILE: [&str; 8] = ["path", "paths", "file", "files", "dir", "folder", "url", "out"]; +const NAMES_A_FILE: &[&str] = &["path", "paths", "file", "files", "dir", "folder", "url", "out"]; /// Commands that change nothing in the design and write a file: they run on a copy. -const ANSWERED_ON_A_COPY: [&str; 2] = ["file.export", "file.save_mesh"]; +const ANSWERED_ON_A_COPY: &[&str] = &["file.export", "file.save_mesh"]; /// Commands that only read the design, which can take long on a large one (a measure of every /// body, an inspection that measures them, #4604; a clearance or interference check of every pair /// of bodies, minutes on an assembly of 150): they run on a copy too, whatever the preference, /// since an answer from the design as it was when asked has no order to keep. -const READ_ON_A_COPY: [&str; 4] = ["inspect.measure", "document.inspect", "inspect.clearance", "inspect.interference"]; +const READ_ON_A_COPY: &[&str] = &["inspect.measure", "document.inspect", "inspect.clearance", "inspect.interference"]; /// Exports and readings computing at once; more run here. const MAX_ANSWERING: usize = 4; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0a0c53f8dd..a276246a71 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6563,7 +6563,7 @@ mod construct_ask_tests { use super::*; use crate::scenario::Harness; - const CONSTRUCT_PICK_COMMANDS: [&str; 15] = [ + const CONSTRUCT_PICK_COMMANDS: &[&str] = &[ "construct.axis.through_cylinder", "construct.axis.normal_to_face", "construct.axis.two_planes", diff --git a/crates/ui-egui/src/dialogs_appearance.rs b/crates/ui-egui/src/dialogs_appearance.rs index 798b2fe9ef..13d36d2559 100644 --- a/crates/ui-egui/src/dialogs_appearance.rs +++ b/crates/ui-egui/src/dialogs_appearance.rs @@ -942,7 +942,7 @@ mod tests { use solidcraft_engine::doc::appearance_pack::STARTER_PACK; let mut app = app(); let looks = library(&mut app); - for preset in &STARTER_PACK { + for preset in STARTER_PACK { let matching: Vec<_> = looks.iter().filter(|look| look.preset.as_ref().is_some_and(|p| p.id == preset.id)).collect(); assert_eq!(matching.len(), 1, "actual provider entry missing from UI: {}", preset.id); let look = matching[0]; @@ -952,15 +952,15 @@ mod tests { assert_eq!(preset_available(look), preset.is_opaque()); } let pack: Vec<_> = looks.iter().filter(|look| look.preset.is_some()).collect(); - assert_eq!(pack.len(), 40); - assert_eq!(pack.iter().filter(|look| preset_available(look)).count(), 34); - assert_eq!(pack.iter().filter(|look| !preset_available(look)).count(), 6); - for (name, color, opacity) in solidcraft_engine::doc::appearance::LIBRARY { + assert!(pack.len() >= 40); + assert!(pack.iter().filter(|look| preset_available(look)).count() >= 34); + assert!(pack.iter().filter(|look| !preset_available(look)).count() >= 6); + for &(name, color, opacity) in solidcraft_engine::doc::appearance::LIBRARY { let look = looks.iter().find(|look| look.name == name).unwrap(); assert_eq!((look.color, look.opacity, look.pbr, look.preset.as_ref()), (color, opacity, None, None)); } // The six published finishes remain independent of starter metadata. - for (name, color, metallic, roughness) in solidcraft_engine::doc::appearance::OPAQUE_FINISHES { + for &(name, color, metallic, roughness) in solidcraft_engine::doc::appearance::OPAQUE_FINISHES { let matching: Vec<_> = looks.iter().filter(|look| look.name == name).collect(); assert_eq!(matching.len(), 1); let look = matching[0]; @@ -969,10 +969,12 @@ mod tests { assert_eq!(look.pbr, Some(PbrParameters::new(metallic, roughness, 1.5).unwrap())); assert!(look.preset.is_none() && preset_available(look)); } - assert_eq!(looks.len(), 66); - assert_eq!(looks.iter().filter(|look| preset_available(look)).count(), 60); - assert_eq!(looks.iter().filter(|look| preset_available(look) && look.opacity == 1.0).count(), 58); - assert_eq!(looks.iter().filter(|look| look.opacity != 1.0).count(), 2); + assert!(!looks.is_empty()); + let mut names = std::collections::BTreeSet::new(); + assert!(looks.iter().all(|look| names.insert(look.name.to_ascii_lowercase()))); + assert!(looks.iter().filter(|look| preset_available(look)).count() >= 60); + assert!(looks.iter().filter(|look| preset_available(look) && look.opacity == 1.0).count() >= 58); + assert!(looks.iter().filter(|look| look.opacity != 1.0).count() >= 2); } #[test] @@ -1004,7 +1006,7 @@ mod tests { #[test] fn manual_edit_of_published_finish_keeps_scalar_override_after_selection_and_editor_reopen() { - for (name, ..) in solidcraft_engine::doc::appearance::OPAQUE_FINISHES { + for &(name, ..) in solidcraft_engine::doc::appearance::OPAQUE_FINISHES { for reopened in [false, true] { let mut app = app(); let look = library(&mut app).into_iter().find(|look| look.name == name).unwrap(); @@ -1075,7 +1077,7 @@ mod tests { let available = rows.iter().filter(|row| row.get("available").and_then(Value::as_bool) != Some(false)).count(); let selectable: Vec<_> = library(&mut h.app).into_iter().filter(preset_available).collect(); assert_eq!(selectable.len(), available); - for preset in &solidcraft_engine::doc::appearance_pack::STARTER_PACK { + for preset in solidcraft_engine::doc::appearance_pack::STARTER_PACK { assert_eq!(selectable.iter().any(|look| look.name == preset.name), preset.is_opaque()); } } diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 8d7ac13682..0a8f39886d 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -72,7 +72,7 @@ pub(crate) fn joint_choice(name: &str, kind: JointKind) -> String { /// What a joint origin input accepts (snaps on faces, edges and vertices). pub const SNAPS: u16 = FACES | EDGES | VERTICES; /// Commands whose effect is where occurrences sit (previewed on the placed model). -const MOVES: [&str; 12] = [ +const MOVES: &[&str] = &[ "joint.create", "joint.as_built", "joint.drive", diff --git a/crates/ui-egui/src/dialogs_frame.rs b/crates/ui-egui/src/dialogs_frame.rs index 35cf94efb4..a45b27d345 100644 --- a/crates/ui-egui/src/dialogs_frame.rs +++ b/crates/ui-egui/src/dialogs_frame.rs @@ -14,7 +14,7 @@ use crate::selection::{PROFILES, SKETCH_LINES, SelInput, SelectionExt as _}; use crate::ui_kit::{choice_row, expr_row, hint_row, text_row}; /// The anchors as `frame.member` names them, and their labels. -const ANCHORS: [(&str, &str); 11] = [ +const ANCHORS: &[(&str, &str)] = &[ ("origin", "Origin"), ("center", "Center"), ("centroid", "Centroid"), diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7104609bc1..ef410152f1 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -806,7 +806,7 @@ mod tests { #[test] fn navigation_preset_help_reaches_the_catalog() { let english = Locale::for_language("en"); - for preset in &crate::prefs::NAV_PRESETS { + for preset in crate::prefs::NAV_PRESETS { let id = format!("nav.{}.help", preset.id); assert_eq!(english.text(&id), preset.help, "{id}"); } diff --git a/crates/ui-egui/src/material_card_review.rs b/crates/ui-egui/src/material_card_review.rs index 6b7b952229..f517ed34fd 100644 --- a/crates/ui-egui/src/material_card_review.rs +++ b/crates/ui-egui/src/material_card_review.rs @@ -5,7 +5,7 @@ use solidcraft_engine::doc::physical_material::{Family, MaterialCard, PropertyDa use crate::{SolveApp, i18n::tr, ui_kit}; const MAX_BYTES: usize = 256 * 1024; -const KINDS: [&str; 9] = [ +const KINDS: &[&str] = &[ "density", "elasticity", "tensile_strength", @@ -161,7 +161,7 @@ fn show_card(ui: &mut egui::Ui, card: &MaterialCard, report: &Value) { let family = serde_json::to_value(card.family).unwrap_or(Value::Null); value(ui, "family", family.as_str().map(str::to_string), ""); ui_kit::text(ui, tr("ui.material_review.properties"), ui_kit::Text::Report("properties", false)); - for kind in KINDS { + for &kind in KINDS { let label = format!("ui.material_review.{kind}"); let Some(property) = card.properties.iter().find(|p| p.data.kind() == kind) else { ui_kit::text(ui, &format!("{}: {}", tr(&label), tr("ui.material_review.unknown")), ui_kit::Text::Report(kind, false)); @@ -374,7 +374,7 @@ mod tests { for catalog in crate::i18n::LOCALES.iter().filter(|c| c.id != "en-GB") { let path = std::path::Path::new(env!("OUT_DIR")).join("locales").join(format!("{}.json", catalog.id)); let messages: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); - for key in keys.into_iter().chain(KINDS) { + for key in keys.into_iter().chain(KINDS.iter().copied()) { let key = format!("ui.material_review.{key}"); assert!(messages.get(&key).and_then(Value::as_str).is_some_and(|s| !s.is_empty()), "{}: {key}", catalog.id); } diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 3314b53962..0996294337 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -33,7 +33,7 @@ use crate::tool_modifiers::ModifierCombo; use crate::ui_kit; use crate::viewport::NavMode; -pub const UNITS: [&str; 5] = ["mm", "cm", "m", "in", "ft"]; +pub const UNITS: &[&str] = &["mm", "cm", "m", "in", "ft"]; pub const OPENSCAD_NAV: &str = "openscad"; pub const TOUCHPAD_NAV: &str = "touchpad"; /// A mouse button that can drag the camera. @@ -100,7 +100,7 @@ use NavButton::{Left, Middle, Right}; use NavMode::{Orbit, Pan, Zoom}; /// The mouse mappings. The first is the default. -pub const NAV_PRESETS: [NavPreset; 10] = [ +pub const NAV_PRESETS: &[NavPreset] = &[ NavPreset { id: "fusion", label: "Fusion", @@ -233,7 +233,7 @@ pub fn nav_preset(id: &str) -> &'static NavPreset { NAV_PRESETS.iter().find(|p| p.id == id).unwrap_or(&NAV_PRESETS[0]) } /// Stable control-channel names; presentation labels may be shorter. -pub const PAGES: [&str; 9] = ["General", "Interface", "Design", "Navigation", "Keyboard Shortcuts", "Sketch", "Graphics", "AI", "Plugins"]; +pub const PAGES: &[&str] = &["General", "Interface", "Design", "Navigation", "Keyboard Shortcuts", "Sketch", "Graphics", "AI", "Plugins"]; pub const PAGE_GENERAL: usize = 0; pub const PAGE_INTERFACE: usize = 1; pub const PAGE_DESIGN: usize = 2; @@ -243,7 +243,7 @@ pub const PAGE_SKETCH: usize = 5; pub const PAGE_GRAPHICS: usize = 6; pub const PAGE_AI: usize = 7; pub const PAGE_PLUGINS: usize = 8; -const WINDOW_PAGES: [ui_kit::WindowPage; 9] = [ +const WINDOW_PAGES: &[ui_kit::WindowPage] = &[ ui_kit::WindowPage { icon: "settings", label: "ui.general", group: None, handle: "prefs:page:General", scroll: true, draw: window_page::<0> }, ui_kit::WindowPage { icon: "settings_palette", @@ -434,7 +434,7 @@ fn window_page(ui: &mut egui::Ui, app: &mut SolveApp) { page(app, ui, &Tokens::get()); } -pub const THEMES: [(&str, &str); 3] = [("dark", "Dark"), ("light", "Light"), ("system", "System")]; +pub const THEMES: &[(&str, &str)] = &[("dark", "Dark"), ("light", "Light"), ("system", "System")]; pub const MSAA: [u32; 4] = [1, 2, 4, 8]; /// Supersampling factors (#1288): off, 1.5× and 2× the viewport's pixels each way. pub const SUPERSAMPLING: [f32; 3] = [1.0, 1.5, 2.0]; @@ -1821,7 +1821,7 @@ pub fn show_mm_bare(app: &SolveApp, v: f64) -> String { } /// The window system choices (`Prefs::window_system`, #3083). -pub const WINDOW_SYSTEMS: [&str; 3] = ["auto", "wayland", "x11"]; +pub const WINDOW_SYSTEMS: &[&str] = &["auto", "wayland", "x11"]; /// winit's issue for dropping files onto a window on native Wayland, which the note links to. pub const WAYLAND_DROP_ISSUE: &str = "https://github.com/rust-windowing/winit/issues/1881"; @@ -1832,11 +1832,12 @@ pub const WAYLAND_DROP_ISSUE: &str = "https://github.com/rust-windowing/winit/is fn window_system_row(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); let label = |id: &str| crate::i18n::tr(&format!("ui.window_system_{id}")).to_string(); - let labels = WINDOW_SYSTEMS.map(label); + let labels: Vec<_> = WINDOW_SYSTEMS.iter().copied().map(label).collect(); + let labels: Vec<_> = labels.iter().map(String::as_str).collect(); // The note below says that it applies after a restart, and which one runs now. let response = ui.allocate_ui_with_layout(egui::vec2(t.form_card_width, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| { let mut selected = WINDOW_SYSTEMS.iter().position(|id| *id == app.preferences.window_system).unwrap_or(0); - if ui_kit::segmented(ui, &mut selected, &labels.each_ref().map(String::as_str), ui_kit::Segments::default()) + if ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments::default()) && let Some(id) = WINDOW_SYSTEMS.get(selected) { app.preferences.window_system = (*id).into(); @@ -1978,7 +1979,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { .actions("OK", "Cancel", Some("Apply")); let window = if capturing { window.keep_open_on_escape() } else { window }; let mut selected = app.prefs_window.section; - let (closed, action) = window.show_pages_with_header(ctx, &mut selected, &WINDOW_PAGES, app, search_header, clear_search, |ui| { + let (closed, action) = window.show_pages_with_header(ctx, &mut selected, WINDOW_PAGES, app, search_header, clear_search, |ui| { if ui_kit::button(ui, crate::i18n::tr("ui.restore_defaults"), ui_kit::Button::Normal) .on_hover_text(crate::i18n::tr("ui.reset_the_settings_on_this_page")) .clicked() diff --git a/crates/ui-egui/src/prefs/design.rs b/crates/ui-egui/src/prefs/design.rs index 964d60a954..3a5cc927c4 100644 --- a/crates/ui-egui/src/prefs/design.rs +++ b/crates/ui-egui/src/prefs/design.rs @@ -8,7 +8,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { ui_kit::settings_form(ui, t.space_24 * 7.0 + t.space_2, |ui| { ui_kit::row(ui, app.tr("ui.units_of_new_designs"), |ui| { let mut selected = UNITS.iter().position(|u| *u == app.preferences.default_units).unwrap_or(0); - ui_kit::segmented(ui, &mut selected, &UNITS, ui_kit::Segments { intrinsic: true, ..Default::default() }); + ui_kit::segmented(ui, &mut selected, UNITS, ui_kit::Segments { intrinsic: true, ..Default::default() }); if let Some(unit) = UNITS.get(selected) && app.preferences.default_units != *unit { diff --git a/crates/ui-egui/src/prefs/navigation.rs b/crates/ui-egui/src/prefs/navigation.rs index 7cd4c0c76d..5365c507b5 100644 --- a/crates/ui-egui/src/prefs/navigation.rs +++ b/crates/ui-egui/src/prefs/navigation.rs @@ -53,7 +53,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { ui_kit::settings_form(ui, 110.0, |ui| { ui_kit::row(ui, app.tr("prefs.mouse_mapping"), |ui| { let picker = ui_kit::dropdown_menu(ui, "pref_nav", nav_preset(&app.preferences.nav).translated_label(), |ui| { - for preset in &NAV_PRESETS { + for preset in NAV_PRESETS { if ui_kit::list_picker(ui, preset.id, preset.translated_label(), app.preferences.nav == preset.id).clicked() { app.preferences.nav = preset.id.into(); app.preferences.zoom_reverse = preset.reverse_zoom; diff --git a/crates/ui-egui/src/theme_files.rs b/crates/ui-egui/src/theme_files.rs index 57724957ea..a074f2ed40 100644 --- a/crates/ui-egui/src/theme_files.rs +++ b/crates/ui-egui/src/theme_files.rs @@ -614,36 +614,8 @@ pub fn export(name: &str, base: Base, t: &Tokens) -> Value { json!({"format": FORMAT, "name": name, "base": base, "colors": colors, "radii": radii, "borders": borders, "icon_palette": icon_palette}) } -/// The theme files that come with SolidCraft (original files; Gruvbox, Tokyo Night, Solarized, Nord, Nosferatu and Catppuccin map open-source palettes, MIT; Okibi and Akari map the Okibi design system; Clay and Clay Dark are original warm paper and charcoal palettes; CGA keeps to the 16 colours of the IBM Color Graphics Adapter; 1-bit and 1-bit Dark are original black-and-white palettes). -const BUILTIN: [(&str, &str); 27] = [ - ("gruvbox-dark", include_str!("../../../assets/styles/ui/themes/gruvbox-dark.json")), - ("gruvbox-dark-hard", include_str!("../../../assets/styles/ui/themes/gruvbox-dark-hard.json")), - ("gruvbox-dark-soft", include_str!("../../../assets/styles/ui/themes/gruvbox-dark-soft.json")), - ("gruvbox-light", include_str!("../../../assets/styles/ui/themes/gruvbox-light.json")), - ("gruvbox-light-hard", include_str!("../../../assets/styles/ui/themes/gruvbox-light-hard.json")), - ("gruvbox-light-soft", include_str!("../../../assets/styles/ui/themes/gruvbox-light-soft.json")), - ("solidcraft-95", include_str!("../../../assets/styles/ui/themes/solidcraft-95.json")), - ("1-bit", include_str!("../../../assets/styles/ui/themes/1-bit.json")), - ("1-bit-dark", include_str!("../../../assets/styles/ui/themes/1-bit-dark.json")), - ("platinum-9", include_str!("../../../assets/styles/ui/themes/platinum-9.json")), - ("cupertino", include_str!("../../../assets/styles/ui/themes/cupertino.json")), - ("green-phosphor", include_str!("../../../assets/styles/ui/themes/green-phosphor.json")), - ("amber-phosphor", include_str!("../../../assets/styles/ui/themes/amber-phosphor.json")), - ("blueprint", include_str!("../../../assets/styles/ui/themes/blueprint.json")), - ("one-dark", include_str!("../../../assets/styles/ui/themes/one-dark.json")), - ("tokyo-night", include_str!("../../../assets/styles/ui/themes/tokyo-night.json")), - ("solarized-light", include_str!("../../../assets/styles/ui/themes/solarized-light.json")), - ("nord", include_str!("../../../assets/styles/ui/themes/nord.json")), - ("high-contrast", include_str!("../../../assets/styles/ui/themes/high-contrast.json")), - ("graphite", include_str!("../../../assets/styles/ui/themes/graphite.json")), - ("nosferatu", include_str!("../../../assets/styles/ui/themes/nosferatu.json")), - ("catppuccin-mocha", include_str!("../../../assets/styles/ui/themes/catppuccin-mocha.json")), - ("okibi", include_str!("../../../assets/styles/ui/themes/okibi.json")), - ("akari", include_str!("../../../assets/styles/ui/themes/akari.json")), - ("clay", include_str!("../../../assets/styles/ui/themes/clay.json")), - ("clay-dark", include_str!("../../../assets/styles/ui/themes/clay-dark.json")), - ("cga", include_str!("../../../assets/styles/ui/themes/cga.json")), -]; +// Theme sources are collected at build time; picker order is validated below. +include!(concat!(env!("OUT_DIR"), "/builtin_themes.rs")); /// The two-colour built-in themes (1-bit and 1-bit Dark): pure black and white, so a few looks /// other themes tell apart by colour are told apart by text or shape instead. @@ -652,23 +624,47 @@ pub(crate) fn is_two_colour(id: &str) -> bool { matches!(id, "1-bit" | "1-bit-dark") } -/// The built-in themes, read once (one that fails to read is left out; a test reads them all). +/// The built-in themes, validated and ordered once; a test checks every shipped file. pub fn builtin() -> &'static [ThemeFile] { static THEMES: OnceLock> = OnceLock::new(); - THEMES.get_or_init(|| { - BUILTIN - .iter() - .filter_map(|(id, text)| match parse(text) { - Ok(t) => Some(ThemeFile { id: (*id).to_string(), ..t }), - Err(e) => { - log::error!("built-in theme {id}: {e}"); - None - } - }) - .collect() + THEMES.get_or_init(|| match read_builtin(BUILTIN) { + Ok(themes) => themes, + Err(e) => { + log::error!("built-in themes: {e}"); + Vec::new() + } }) } +fn read_builtin(sources: &[(&str, &str)]) -> Result, String> { + let mut orders = std::collections::BTreeSet::new(); + let mut ids = std::collections::BTreeSet::new(); + let mut themes = Vec::new(); + for &(id, text) in sources { + if !ids.insert(id) || id.is_empty() { + return Err(format!("empty or duplicate theme id: {id}")); + } + if text.len() > MAX_BYTES { + return Err(format!("theme {id} exceeds the file size limit")); + } + let value: Value = serde_json::from_str(text).map_err(|e| format!("theme {id}: {e}"))?; + let order = value.get("order").and_then(Value::as_u64).ok_or_else(|| format!("theme {id}: order must be a nonnegative integer"))?; + if !orders.insert(order) { + return Err(format!("theme {id}: duplicate order {order}")); + } + if value.get("provenance").and_then(Value::as_str).is_none_or(|s| s.trim().is_empty()) { + return Err(format!("theme {id}: missing provenance")); + } + let theme = from_value(&value).map_err(|e| format!("theme {id}: {e}"))?; + themes.push((order, ThemeFile { id: id.into(), ..theme })); + } + if themes.is_empty() { + return Err("no built-in themes".into()); + } + themes.sort_by_key(|(order, _)| *order); + Ok(themes.into_iter().map(|(_, theme)| theme).collect()) +} + /// The theme a `theme` preference names: built in, or imported. `None` for dark, light, system /// or an unknown id. pub fn find<'a>(id: &str, custom: &'a [ThemeFile]) -> Option<&'a ThemeFile> { @@ -1060,6 +1056,60 @@ mod tests { assert_eq!(t.file_image_tint, Color32::WHITE, "{id}"); } + #[test] + fn builtin_order_preserves_the_existing_picker_sequence() { + let original: &[&str] = &[ + "gruvbox-dark", + "gruvbox-dark-hard", + "gruvbox-dark-soft", + "gruvbox-light", + "gruvbox-light-hard", + "gruvbox-light-soft", + "solidcraft-95", + "1-bit", + "1-bit-dark", + "platinum-9", + "cupertino", + "green-phosphor", + "amber-phosphor", + "blueprint", + "one-dark", + "tokyo-night", + "solarized-light", + "nord", + "high-contrast", + "graphite", + "nosferatu", + "catppuccin-mocha", + "okibi", + "akari", + "clay", + "clay-dark", + "cga", + ]; + let themes = read_builtin(BUILTIN).unwrap(); + let retained: Vec<_> = themes.iter().map(|t| t.id.as_str()).filter(|id| original.contains(id)).collect(); + assert_eq!(retained, original); + assert!(BUILTIN.windows(2).all(|pair| pair[0].0 < pair[1].0), "source entries are sorted by id"); + } + + #[test] + fn builtin_metadata_rejects_missing_invalid_and_duplicate_order() { + let text = + |order: Value| json!({"format": FORMAT, "name": "Synthetic", "base": "dark", "provenance": "Original", "order": order}).to_string(); + let good = text(json!(10)); + assert!(read_builtin(&[("a", &good)]).is_ok()); + let earlier = text(json!(5)); + let ordered = read_builtin(&[("a", &good), ("b", &earlier)]).unwrap(); + assert_eq!(ordered.iter().map(|t| t.id.as_str()).collect::>(), ["b", "a"]); + assert!(read_builtin(&[("a", &good), ("a", &earlier)]).is_err()); + assert!(read_builtin(&[("a", &good), ("b", &good)]).is_err()); + for order in [Value::Null, json!(-1), json!(1.5), json!("10")] { + assert!(read_builtin(&[("a", &text(order))]).is_err()); + } + assert!(read_builtin(&[]).is_err()); + } + #[test] fn built_in_themes_read_and_differ_from_their_base() { assert_eq!(builtin().len(), BUILTIN.len(), "every built-in theme file reads"); diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 3715ecaf2a..58961868b0 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -6,7 +6,7 @@ license.workspace = true publish = false [dependencies] -proc-macro2 = { workspace = true } +proc-macro2 = { workspace = true, features = ["span-locations"] } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/xtask/src/lists.rs b/xtask/src/lists.rs index 7f40540137..cce0409bc2 100644 --- a/xtask/src/lists.rs +++ b/xtask/src/lists.rs @@ -5,6 +5,7 @@ //! //! - The message catalogs (`assets/locales/*.json`): one message per line, sorted by id, no //! duplicate ids, as `serde_json` pretty-prints them. +//! - Growing Rust lists: no written array lengths on more than ten lines of entries. use std::path::Path; @@ -37,9 +38,10 @@ pub fn run(root: &Path, fix: bool) -> Result<(), String> { stale.join(", ") )); } + check_arrays(root)?; crate::pending::check_locales(root)?; crate::pending::check_attribution(root)?; - println!("lists: message catalogs are sorted; pending locale and attribution files fold cleanly"); + println!("lists: growing arrays checked; message catalogs sorted; pending locale and attribution files fold cleanly"); Ok(()) } @@ -56,9 +58,214 @@ pub fn canonical(text: &str) -> Result { Ok(serde_json::to_string_pretty(&Value::Object(map)).map_err(|e| e.to_string())? + "\n") } +/// Fixed tables whose dimensions describe a standard or an algorithm, rather than a library. +const FIXED_ARRAYS: &[(&str, &str)] = &[ + ("assets/icons/app/shared/source/app_icon.rs", "FACES"), // Fixed five-face application mark geometry. + ("crates/doc/src/component_color.rs", "PALETTE"), // Fixed 24-step hue-wheel algorithm. + ("crates/doc/src/eval.rs", "ISO_COARSE"), // ISO metric coarse diameter/pitch pairs. + ("crates/doc/src/format.rs", "JSON_STEPS"), // Frozen historical migrations; additions use format/pending. + ("crates/engine/src/cmd/features.rs", "TABLE"), // ISO 273 metric bolt clearance-hole dimensions. + ("crates/engine/src/cmd/parts.rs", "BEARINGS"), // Standard deep-groove ball bearing dimensions. + ("crates/engine/src/cmd/parts.rs", "HEX_BOLT"), // ISO 4017 hex bolt dimensions. + ("crates/engine/src/cmd/parts.rs", "HEX_NUT"), // ISO 4032 hex nut dimensions. + ("crates/engine/src/cmd/parts.rs", "METRIC"), // Standard metric screw diameters and pitches. + ("crates/engine/src/cmd/parts.rs", "SHCS"), // ISO 4762 socket-head cap screw dimensions. + ("crates/engine/src/cmd/parts.rs", "WASHER"), // ISO 7089 plain washer dimensions. + ("crates/ui-egui/src/dialogs_assembly.rs", "TYPES"), // Joint types; TYPE_LABELS must stay the same length. + ("crates/ui-egui/src/dialogs_assembly.rs", "TYPE_LABELS"), // Labels for TYPES, index for index. + ("crates/ui-egui/src/space_mouse_calibration.rs", "STEP_PROMPTS"), // Labels for the twelve fixed calibration motions. + ("crates/ui-egui/src/space_mouse_calibration.rs", "TRACKBALL_PROMPTS"), // Trackball labels indexed by the same twelve motions. +]; + +fn test_only(attrs: &[syn::Attribute]) -> bool { + fn requires_test(meta: &syn::Meta) -> bool { + match meta { + syn::Meta::Path(path) => path.is_ident("test"), + syn::Meta::List(list) => { + let Ok(items) = list.parse_args_with(syn::punctuated::Punctuated::::parse_terminated) else { + return false; + }; + if list.path.is_ident("all") { + items.iter().any(requires_test) + } else { + list.path.is_ident("any") && !items.is_empty() && items.iter().all(requires_test) + } + } + _ => false, + } + } + attrs.iter().any(|a| a.path().is_ident("test") || (a.path().is_ident("cfg") && a.parse_args::().is_ok_and(|m| requires_test(&m)))) +} + +#[derive(Default)] +struct ArrayGuard { + found: Vec, +} + +impl ArrayGuard { + fn array(&mut self, name: &syn::Ident, ty: &syn::Type, expr: &syn::Expr) { + use syn::spanned::Spanned; + fn initializer(expr: &syn::Expr) -> Option<&syn::ExprArray> { + match expr { + syn::Expr::Array(array) => Some(array), + syn::Expr::Paren(paren) => initializer(&paren.expr), + syn::Expr::Group(group) => initializer(&group.expr), + syn::Expr::Block(block) => match block.block.stmts.last()? { + syn::Stmt::Expr(expr, None) => initializer(expr), + _ => None, + }, + _ => None, + } + } + if !matches!(ty, syn::Type::Array(_)) { + return; + } + let count = if let Some(array) = initializer(expr) { + let mut lines = std::collections::BTreeSet::new(); + for entry in &array.elems { + lines.extend(entry.span().start().line..=entry.span().end().line); + } + lines.len() + } else { + // Macro and computed initializers still carry a written array length. + expr.span().end().line.saturating_sub(expr.span().start().line + 1) + }; + if count > 10 { + self.found.push(name.to_string()); + } + } +} + +impl<'ast> syn::visit::Visit<'ast> for ArrayGuard { + fn visit_item(&mut self, item: &'ast syn::Item) { + let attrs = match item { + syn::Item::Mod(i) => &i.attrs, + syn::Item::Fn(i) => &i.attrs, + syn::Item::Const(i) => &i.attrs, + syn::Item::Static(i) => &i.attrs, + syn::Item::Impl(i) => &i.attrs, + syn::Item::Trait(i) => &i.attrs, + _ => return syn::visit::visit_item(self, item), + }; + if !test_only(attrs) { + syn::visit::visit_item(self, item); + } + } + + fn visit_item_const(&mut self, item: &'ast syn::ItemConst) { + self.array(&item.ident, &item.ty, &item.expr); + syn::visit::visit_item_const(self, item); + } + + fn visit_item_static(&mut self, item: &'ast syn::ItemStatic) { + self.array(&item.ident, &item.ty, &item.expr); + syn::visit::visit_item_static(self, item); + } + + fn visit_impl_item(&mut self, item: &'ast syn::ImplItem) { + let attrs = match item { + syn::ImplItem::Const(i) => &i.attrs, + syn::ImplItem::Fn(i) => &i.attrs, + _ => return syn::visit::visit_impl_item(self, item), + }; + if !test_only(attrs) { + syn::visit::visit_impl_item(self, item); + } + } + + fn visit_impl_item_const(&mut self, item: &'ast syn::ImplItemConst) { + self.array(&item.ident, &item.ty, &item.expr); + syn::visit::visit_impl_item_const(self, item); + } + fn visit_trait_item(&mut self, item: &'ast syn::TraitItem) { + let attrs = match item { + syn::TraitItem::Const(i) => &i.attrs, + syn::TraitItem::Fn(i) => &i.attrs, + _ => return syn::visit::visit_trait_item(self, item), + }; + if !test_only(attrs) { + syn::visit::visit_trait_item(self, item); + } + } + + fn visit_trait_item_const(&mut self, item: &'ast syn::TraitItemConst) { + if let Some((_, expr)) = &item.default { + self.array(&item.ident, &item.ty, expr); + } + syn::visit::visit_trait_item_const(self, item); + } +} + +fn array_violations(source: &str) -> Result, String> { + use syn::visit::Visit; + let ast = syn::parse_file(source).map_err(|e| e.to_string())?; + if test_only(&ast.attrs) { + return Ok(Vec::new()); + } + let mut guard = ArrayGuard::default(); + guard.visit_file(&ast); + Ok(guard.found) +} + +fn check_arrays(root: &Path) -> Result<(), String> { + fn scan(root: &Path, dir: &Path, failures: &mut Vec) -> Result<(), String> { + for entry in std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? { + let entry = entry.map_err(|e| e.to_string())?; + let path = entry.path(); + let kind = entry.file_type().map_err(|e| e.to_string())?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if kind.is_dir() { + if !matches!(name.as_ref(), "target" | ".git" | "tests" | "benches") { + scan(root, &path, failures)?; + } + } else if kind.is_file() && path.extension().is_some_and(|e| e == "rs") && name != "tests.rs" && !name.ends_with("_tests.rs") { + let relative = path.strip_prefix(root).unwrap_or(&path).to_string_lossy().replace('\\', "/"); + let source = std::fs::read_to_string(&path).map_err(|e| format!("{relative}: {e}"))?; + for name in array_violations(&source).map_err(|e| format!("{relative}: {e}"))? { + if !FIXED_ARRAYS.contains(&(relative.as_str(), name.as_str())) { + failures.push(format!("{relative}: {name}")); + } + } + } + } + Ok(()) + } + let mut failures = Vec::new(); + for dir in ["apps", "assets", "crates", "tools", "xtask"] { + scan(root, &root.join(dir), &mut failures)?; + } + failures.sort(); + if failures.is_empty() { + Ok(()) + } else { + Err(format!("use slices without written lengths for growing lists (fixed tables need a reason in FIXED_ARRAYS):\n{}", failures.join("\n"))) + } +} + #[cfg(test)] mod tests { - use super::canonical; + use super::{array_violations, canonical}; + + #[test] + fn growing_arrays_reject_lengths_and_allow_slices_short_tables_and_test_fixtures() { + let entries = " 0,\n".repeat(11); + let array = format!("const GROWING: [u8; 11] = [\n{entries}];"); + assert_eq!(array_violations(&array).unwrap(), ["GROWING"]); + assert_eq!(array_violations(&array.replace("const", "static")).unwrap(), ["GROWING"]); + assert_eq!(array_violations(&format!("const GENERATED: [u8; 11] = entries![\n{entries}];")).unwrap(), ["GENERATED"]); + assert_eq!(array_violations(&format!("impl Catalog {{ {array} }}")).unwrap(), ["GROWING"]); + assert!(array_violations(&array.replace("[u8; 11] = [", "&[u8] = &[ ")).unwrap().is_empty()); + assert!(array_violations(&format!("#[cfg(test)] mod tests {{ {array} }}")).unwrap().is_empty()); + assert!(array_violations(&format!("#![cfg(test)]\n{array}")).unwrap().is_empty()); + assert!(array_violations(&format!("#[cfg(all(test, unix))] mod tests {{ {array} }}")).unwrap().is_empty()); + assert_eq!(array_violations(&format!("#[cfg(not(test))] mod production {{ {array} }}")).unwrap(), ["GROWING"]); + assert_eq!(array_violations(&format!("trait Catalog {{ {array} }}")).unwrap(), ["GROWING"]); + assert!(array_violations(&format!("#[test] fn fixture() {{ {array} }}")).unwrap().is_empty()); + assert!(array_violations("const SHORT: [u8; 100] = [0; 100];").unwrap().is_empty()); + assert!(array_violations(&format!("const TEN: [u8; 10] = [\n{}];", "0,\n".repeat(10))).unwrap().is_empty()); + assert!(array_violations(&format!("const TEXT: &str = {array:?};")).unwrap().is_empty()); + } #[test] fn catalogs_are_sorted_one_per_line_and_a_repeated_id_keeps_its_last_message() {