From 7ae1efba4d6b0c84551128894e5e760c65d23c2c Mon Sep 17 00:00:00 2001 From: invisageable Date: Wed, 10 Jun 2026 14:58:14 +0200 Subject: [PATCH] feat(zo): naming-convention warnings --- Cargo.lock | 11 + Cargo.toml | 1 + README.md | 14 +- .../initiation/en/098-error-messages.md | 23 ++ .../site/src/content/initiation/en/100-faq.md | 18 ++ crates/compiler/zo-checker/Cargo.toml | 16 ++ crates/compiler/zo-checker/src/checker.rs | 44 ++++ .../zo-checker/src/checker/name_checker.rs | 64 ++++++ crates/compiler/zo-checker/src/lib.rs | 14 ++ crates/compiler/zo-checker/src/tests.rs | 6 + .../compiler/zo-checker/src/tests/common.rs | 24 ++ .../compiler/zo-checker/src/tests/naming.rs | 137 ++++++++++++ crates/compiler/zo-error/src/error.rs | 17 +- crates/compiler/zo-error/src/id_registry.rs | 3 + crates/compiler/zo-executor/Cargo.toml | 1 + crates/compiler/zo-executor/src/executor.rs | 96 ++++++++ crates/compiler/zo-executor/src/tests.rs | 1 + .../compiler/zo-executor/src/tests/common.rs | 5 +- .../compiler/zo-executor/src/tests/naming.rs | 208 ++++++++++++++++++ crates/compiler/zo-reporter/src/aggregator.rs | 3 + crates/compiler/zo-reporter/src/collector.rs | 12 +- crates/compiler/zo-reporter/src/json.rs | 14 ++ crates/compiler/zo-reporter/src/lib.rs | 4 +- crates/compiler/zo-reporter/src/render.rs | 8 + crates/compiler/zo-reporter/src/xml.rs | 6 +- .../programming/naming_convention_warns.zo | 17 ++ notes/references.md | 3 + .../src/case/strcase/snakecase.rs | 27 ++- 28 files changed, 784 insertions(+), 13 deletions(-) create mode 100644 crates/compiler/zo-checker/Cargo.toml create mode 100644 crates/compiler/zo-checker/src/checker.rs create mode 100644 crates/compiler/zo-checker/src/checker/name_checker.rs create mode 100644 crates/compiler/zo-checker/src/lib.rs create mode 100644 crates/compiler/zo-checker/src/tests.rs create mode 100644 crates/compiler/zo-checker/src/tests/common.rs create mode 100644 crates/compiler/zo-checker/src/tests/naming.rs create mode 100644 crates/compiler/zo-executor/src/tests/naming.rs create mode 100644 crates/compiler/zo-tests/programming/naming_convention_warns.zo diff --git a/Cargo.lock b/Cargo.lock index f2fc4fcc..530bc9c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7973,6 +7973,16 @@ dependencies = [ name = "zo-c-abi" version = "0.4.0" +[[package]] +name = "zo-checker" +version = "0.4.0" +dependencies = [ + "swisskit-core", + "zo-error", + "zo-reporter", + "zo-span", +] + [[package]] name = "zo-codegen" version = "0.4.0" @@ -8215,6 +8225,7 @@ dependencies = [ "criterion", "rustc-hash 2.1.2", "zo-analyzer", + "zo-checker", "zo-constant-folding", "zo-constant-propagation", "zo-dce", diff --git a/Cargo.toml b/Cargo.toml index ad043b5d..62760120 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ zo-benches = { path = "crates/compiler/zo-benches", version = "0.4.0" } zo-binder = { path = "crates/compiler/zo-binder", version = "0.4.0" } zo-buffer = { path = "crates/compiler/zo-buffer", version = "0.4.0" } zo-bundler = { path = "crates/compiler/zo-bundler", version = "0.4.0" } +zo-checker = { path = "crates/compiler/zo-checker", version = "0.4.0" } zo-c-abi = { path = "crates/compiler/zo-c-abi", version = "0.4.0" } zo-codegen = { path = "crates/compiler/zo-codegen", version = "0.4.0" } zo-codegen-arm = { path = "crates/compiler/zo-codegen-arm", version = "0.4.0" } diff --git a/README.md b/README.md index e515f22e..86cb02c6 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,12 @@ zo RESOLVES THESE COMPROMiSES BY COMPiLiNG A SiNGLE, DECLARATiVE CODEBASE TO NAT > *« Rust makes you wait. C makes you think. zo just lets you build. » — i10e* +## status. + +zo iS iN EARLY DEVELOPMENT AND NOT READY FOR PRODUCTiON YET. + +REGARDiNG Ai USAGE — WE ARE USiNG Ai TO BUiLD zo BASED ON OUR ARCHiTECTURE (MADE BY HUMANS). THE COMPiLER CURRENTLY COVERS OVER 1500 UNiT AND iNTEGRATiON TESTS. + ### benchmark. | Compiler | Run 1 | Run 2 | Run 3 | Run 4 | Run 5 | Average | @@ -163,7 +169,7 @@ zo RESOLVES THESE COMPROMiSES BY COMPiLiNG A SiNGLE, DECLARATiVE CODEBASE TO NAT *Workload: 503 tasks in a ring (`threadring`). A token hops node-to-node `N` times compiled to native ARM64 binary (including Hindley-Milner type inference, monomorphization, type checking, constant folding, propagation, dead code elimination and link passes).* -[@methodology-and-full-numbers](./crates/compiler/zo-benches) + - @SEE — [@methodology-and-full-numbers](./crates/compiler/zo-benches) ### our pipeline. @@ -216,6 +222,8 @@ ANY iSSUES? CHECK THE iNSTALLATiON GUiDE: THiS MONO-REPO POWERS AN ECOSYSTEM OF CRATES: +> *More crates are coming. The architecture is modular and composable. Be gentle.* + **-sources** | NAME | DESCRiPTiON | @@ -237,8 +245,6 @@ THiS MONO-REPO POWERS AN ECOSYSTEM OF CRATES: ... -> *More crates are coming. The architecture is modular and composable. Be gentle.* - ## the manifesto. zo iS A COMPiLER OF A COMPiLER iNSiDE ANOTHER GiANT COMPiLER THAT iS iTSELF iNSiDE A GiGANTiC COMPiLER. @@ -255,7 +261,7 @@ WE ARE AGAiNST ABUNDANT SOFTWARE UNiFORMiTY. zo UNiFiES DESKTOP, MOBiLE AND THE WE LOVE CONTRiBUTORS. THiS iS A PLAYGROUND FOR COMPiLER __NERDS__, FRONTEND __HACKERS__, AND __CREATIVES__. -OPEN AN iSSUE, OR COME SAY HELLO ON [discord](https://discord.gg/JaNc4Nk5xw). YOU CAN ALSO CONTACT US AT `echo -n 'dGhlQGNvbXBpbG9yZHMuaG91c2U=' | base64 --decode`. +OPEN AN [iSSUE](https://github.com/invisageable/zo/issues), A [DiSCUSSiON](https://github.com/invisageable/zo/discussions), OR COME SAY HELLO ON [discord](https://discord.gg/JaNc4Nk5xw). YOU CAN ALSO CONTACT US AT `echo -n 'dGhlQGNvbXBpbG9yZHMuaG91c2U=' | base64 --decode`. OPEN A [DiSCUSSiON](https://github.com/invisageable/zo/discussions), iF YOU NEED MORE iNFO. diff --git a/apps/site/src/content/initiation/en/098-error-messages.md b/apps/site/src/content/initiation/en/098-error-messages.md index 6a7b3f46..7d7c195f 100644 --- a/apps/site/src/content/initiation/en/098-error-messages.md +++ b/apps/site/src/content/initiation/en/098-error-messages.md @@ -24,6 +24,29 @@ By default the compiler renders a human snippet to stderr — the offending line │ ╰── incompatible type `int` here ``` +## warnings + +Not every diagnostic stops the build. Warnings point at code that compiles but breaks a convention — an unused variable, unreachable code, or a name that does not follow zo's naming rules: + + - `struct`, `enum`, `type`, and generic names are PascalCase. + - `val` constants are SCREAMING_SNAKE_CASE. + - everything else — `imu`/`mut` bindings, `fun` names and arguments, struct fields, `abstract` functions — is snake_case. + +Each naming warning carries the convention-correct rename as its help, so the fix is always one copy-paste away: + + ```text + [E0355] Warning • Name is not snake_case + ╭─[ counter.zo:2:7 ] + │ + 2 │ imu MyCount := 1; + │ ───┬─── + │ ╰───── expected a snake_case name + │ + │ Help • rename it to `my_count` + ``` + +A leading underscore opts a binding out (`_unused`), and digits never need a separator (`r0`, `grid2`, `MAX2` are all fine). The program builds and runs regardless — warnings inform, errors stop. + ## machine formats An agent reads text differently than you do — it never skims and it is never overwhelmed by length. So zo offers two machine formats that carry the *full* diagnostic, not a terse summary. Both stream to stdout, leaving stderr for you. diff --git a/apps/site/src/content/initiation/en/100-faq.md b/apps/site/src/content/initiation/en/100-faq.md index a75c97a1..6a168374 100644 --- a/apps/site/src/content/initiation/en/100-faq.md +++ b/apps/site/src/content/initiation/en/100-faq.md @@ -2,6 +2,10 @@ ## basics +**How do I pronounce the name?** + +zo is pronounced `/zuː/` just like "zoo". + **I'm new to zo. Where should I start?** The quick way is to do the [initiation](https://zo.compilords.house/initiation) to get a full understanding of specific concept. @@ -20,6 +24,12 @@ There are some extensions available depending of your preferences: - [VS Code](https://github.com/invisageable/zo/tree/main/crates/compiler/zo-vscode) +## usage + +**Can I use zo for servers?** + +... + ## comptime **How does zo achieve sub-second compilation speeds?** @@ -76,3 +86,11 @@ zo run app.zo --target watchos --device "Apple Watch Ultra 3 (49mm)" ``` If a device name does not match the target platform, the error lists every device on your machine able to run the app. + +## comparison + +**How does zo compare to Rust?** + +zo is faster at build time, if you care about your feedback loop, zo can definetely be the best choice. Also regarding the friction, zo adopt simplicity — no borrow checker, no lifetime. You'll get a better concurrent system à la Go, Swift or Erlang (in a sense). + +In terms of runtime, Rust is faster than zo. We planned to improve our runtime performance to be at least close to C (clang). \ No newline at end of file diff --git a/crates/compiler/zo-checker/Cargo.toml b/crates/compiler/zo-checker/Cargo.toml new file mode 100644 index 00000000..d416bc80 --- /dev/null +++ b/crates/compiler/zo-checker/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "zo-checker" +version.workspace = true +edition.workspace = true + +[lib] +doctest = false + +[dependencies] +# internal:sources. +swisskit-core = { workspace = true } + +# internal:crates. +zo-error = { workspace = true } +zo-reporter = { workspace = true } +zo-span = { workspace = true } diff --git a/crates/compiler/zo-checker/src/checker.rs b/crates/compiler/zo-checker/src/checker.rs new file mode 100644 index 00000000..ac30d2fc --- /dev/null +++ b/crates/compiler/zo-checker/src/checker.rs @@ -0,0 +1,44 @@ +//! The checker pilot — the one entry point the executor drives. + +pub mod name_checker; + +use name_checker::NameChecker; + +use zo_span::Span; + +/// Pilots every individual checker. +/// +/// @note — the executor owns one and forwards declaration events +/// through these methods; each sub-checker decides whether a warning +/// is due and reports it through `zo-reporter`'s warning channel. +#[derive(Debug, Default)] +pub struct Checker { + /// Naming-convention checks — PascalCase types, + /// SCREAMING_SNAKE_CASE constants, snake_case bindings. + name_checker: NameChecker, +} + +impl Checker { + /// Creates a new [`Checker`] instance. + pub fn new() -> Self { + Self::default() + } + + /// Checks a `struct`/`enum`/`type`/generic name — PascalCase. + pub fn check_type_name(&self, name: &str, span: Span, file_id: u16) { + self.name_checker.check_type_name(name, span, file_id); + } + + /// Checks a `val` constant name — SCREAMING_SNAKE_CASE. + pub fn check_constant_name(&self, name: &str, span: Span, file_id: u16) { + self.name_checker.check_constant_name(name, span, file_id); + } + + /// Checks a binding-position name — snake_case. + /// + /// @note — binding positions: `imu`/`mut`, `fun` names and + /// arguments, struct fields, `abstract` functions. + pub fn check_binding_name(&self, name: &str, span: Span, file_id: u16) { + self.name_checker.check_binding_name(name, span, file_id); + } +} diff --git a/crates/compiler/zo-checker/src/checker/name_checker.rs b/crates/compiler/zo-checker/src/checker/name_checker.rs new file mode 100644 index 00000000..c4266b0a --- /dev/null +++ b/crates/compiler/zo-checker/src/checker/name_checker.rs @@ -0,0 +1,64 @@ +//! Naming-convention checks. + +use zo_error::{Error, ErrorKind}; +use zo_reporter::report_error_with_rename; +use zo_span::Span; + +use swisskit_core::{is, to}; + +/// Warns when a declared name breaks its site's naming convention. +/// +/// @note — types are PascalCase, `val` constants are +/// SCREAMING_SNAKE_CASE, every other binding is snake_case. Each +/// warning carries the convention-correct rename as its fix. +#[derive(Debug, Default)] +pub struct NameChecker; + +impl NameChecker { + /// Checks a type-position name against PascalCase. + pub fn check_type_name(&self, name: &str, span: Span, file_id: u16) { + let Some(name) = checkable(name) else { return }; + + if !is!(pascal name) { + report_error_with_rename( + Error::with_file(ErrorKind::NonPascalCaseName, span, file_id), + &to!(pascal name), + ); + } + } + + /// Checks a `val` constant name against SCREAMING_SNAKE_CASE. + pub fn check_constant_name(&self, name: &str, span: Span, file_id: u16) { + let Some(name) = checkable(name) else { return }; + + if !is!(snake_screaming name) { + report_error_with_rename( + Error::with_file(ErrorKind::NonScreamingCaseName, span, file_id), + &to!(snake_screaming name), + ); + } + } + + /// Checks a binding-position name against snake_case. + pub fn check_binding_name(&self, name: &str, span: Span, file_id: u16) { + let Some(name) = checkable(name) else { return }; + + if !is!(snake name) { + report_error_with_rename( + Error::with_file(ErrorKind::NonSnakeCaseName, span, file_id), + &to!(snake name), + ); + } + } +} + +/// The convention-relevant part of a name; `None` opts it out. +/// +/// @note — leading underscores (the deliberate-unused marker) and +/// the generic `$` sigil carry no case, so they are stripped; a name +/// of only those characters (`_`, `__`) is exempt. +fn checkable(name: &str) -> Option<&str> { + let name = name.trim_start_matches(['_', '$']); + + if name.is_empty() { None } else { Some(name) } +} diff --git a/crates/compiler/zo-checker/src/lib.rs b/crates/compiler/zo-checker/src/lib.rs new file mode 100644 index 00000000..1c395809 --- /dev/null +++ b/crates/compiler/zo-checker/src/lib.rs @@ -0,0 +1,14 @@ +//! Convention checks the executor runs as declarations execute. +//! +//! [`Checker`] pilots every individual checker (naming today). The +//! executor owns one and forwards each declared name to it, so check +//! implementations live here instead of inside the execution loop. +//! Checks report through `zo-reporter`'s warning channel — they never +//! stop compilation. + +pub mod checker; + +pub use checker::Checker; + +#[cfg(test)] +mod tests; diff --git a/crates/compiler/zo-checker/src/tests.rs b/crates/compiler/zo-checker/src/tests.rs new file mode 100644 index 00000000..261c36a5 --- /dev/null +++ b/crates/compiler/zo-checker/src/tests.rs @@ -0,0 +1,6 @@ +//! ```sh +//! cargo test -p zo-checker +//! ``` + +pub(crate) mod common; +pub(crate) mod naming; diff --git a/crates/compiler/zo-checker/src/tests/common.rs b/crates/compiler/zo-checker/src/tests/common.rs new file mode 100644 index 00000000..07149d51 --- /dev/null +++ b/crates/compiler/zo-checker/src/tests/common.rs @@ -0,0 +1,24 @@ +//! Shared helpers for checker tests. +//! +//! The reporter is thread-local, so every test drains it after +//! driving the checker — no cross-test interference as long as each +//! test clears what it reports. + +use zo_error::ErrorKind; +use zo_reporter::{Detail, collect_diagnostics}; + +/// The rename detail attached to the single buffered warning — +/// `None` when nothing was reported. +pub(crate) fn drained_rename() -> Option<(ErrorKind, String)> { + let (errors, details) = collect_diagnostics(); + + let rename = details.iter().find_map(|(error, detail)| match detail { + Detail::Rename(name) => Some((error.kind(), name.to_string())), + _ => None, + }); + + match rename { + Some(rename) => Some(rename), + None => errors.first().map(|error| (error.kind(), String::new())), + } +} diff --git a/crates/compiler/zo-checker/src/tests/naming.rs b/crates/compiler/zo-checker/src/tests/naming.rs new file mode 100644 index 00000000..fe2f839c --- /dev/null +++ b/crates/compiler/zo-checker/src/tests/naming.rs @@ -0,0 +1,137 @@ +//! ```sh +//! cargo test -p zo-checker --lib tests::naming +//! ``` + +use super::common::drained_rename; + +use crate::Checker; + +use zo_error::{ErrorKind, Severity, severity}; +use zo_reporter::clear_errors; +use zo_span::Span; + +#[test] +fn type_name_must_be_pascal_case() { + clear_errors(); + + let checker = Checker::new(); + + checker.check_type_name("my_point", Span::ZERO, 0); + + let (kind, rename) = drained_rename().unwrap(); + + assert_eq!(kind, ErrorKind::NonPascalCaseName); + assert_eq!(rename, "MyPoint"); +} + +#[test] +fn pascal_case_type_name_is_clean() { + clear_errors(); + + let checker = Checker::new(); + + checker.check_type_name("MyPoint", Span::ZERO, 0); + checker.check_type_name("Point", Span::ZERO, 0); + + assert!(drained_rename().is_none()); +} + +#[test] +fn single_letter_generic_is_clean() { + clear_errors(); + + let checker = Checker::new(); + + checker.check_type_name("T", Span::ZERO, 0); + checker.check_type_name("$T", Span::ZERO, 0); + + assert!(drained_rename().is_none()); +} + +#[test] +fn constant_name_must_be_screaming_case() { + clear_errors(); + + let checker = Checker::new(); + + checker.check_constant_name("max_size", Span::ZERO, 0); + + let (kind, rename) = drained_rename().unwrap(); + + assert_eq!(kind, ErrorKind::NonScreamingCaseName); + assert_eq!(rename, "MAX_SIZE"); +} + +#[test] +fn screaming_case_constant_is_clean() { + clear_errors(); + + let checker = Checker::new(); + + checker.check_constant_name("MAX_SIZE", Span::ZERO, 0); + checker.check_constant_name("PI", Span::ZERO, 0); + + assert!(drained_rename().is_none()); +} + +#[test] +fn binding_name_must_be_snake_case() { + clear_errors(); + + let checker = Checker::new(); + + checker.check_binding_name("myCount", Span::ZERO, 0); + + let (kind, rename) = drained_rename().unwrap(); + + assert_eq!(kind, ErrorKind::NonSnakeCaseName); + assert_eq!(rename, "my_count"); +} + +#[test] +fn snake_case_binding_is_clean() { + clear_errors(); + + let checker = Checker::new(); + + checker.check_binding_name("my_count", Span::ZERO, 0); + checker.check_binding_name("x", Span::ZERO, 0); + + assert!(drained_rename().is_none()); +} + +#[test] +fn digits_need_no_separator() { + // inflector treats every digit as a word boundary (`r0` → + // `r_0`); the swisskit predicates must not — `r0`, `MAX2`, + // and `Vec2` are idiomatic in their conventions. + clear_errors(); + + let checker = Checker::new(); + + checker.check_binding_name("r0", Span::ZERO, 0); + checker.check_binding_name("grid2", Span::ZERO, 0); + checker.check_constant_name("MAX2", Span::ZERO, 0); + checker.check_type_name("Vec2", Span::ZERO, 0); + + assert!(drained_rename().is_none()); +} + +#[test] +fn underscore_prefix_opts_out() { + clear_errors(); + + let checker = Checker::new(); + + checker.check_binding_name("_", Span::ZERO, 0); + checker.check_binding_name("_unused", Span::ZERO, 0); + + assert!(drained_rename().is_none()); +} + +#[test] +fn naming_warnings_never_block_compilation() { + assert_eq!(severity(ErrorKind::NonPascalCaseName), Severity::Warning); + assert_eq!(severity(ErrorKind::NonScreamingCaseName), Severity::Warning); + assert_eq!(severity(ErrorKind::NonSnakeCaseName), Severity::Warning); +} diff --git a/crates/compiler/zo-error/src/error.rs b/crates/compiler/zo-error/src/error.rs index f957d2dc..3d08a287 100644 --- a/crates/compiler/zo-error/src/error.rs +++ b/crates/compiler/zo-error/src/error.rs @@ -220,7 +220,10 @@ pub const fn severity(kind: ErrorKind) -> Severity { match kind { ErrorKind::UnusedVariable | ErrorKind::UnusedFunction - | ErrorKind::UnreachableCode => Severity::Warning, + | ErrorKind::UnreachableCode + | ErrorKind::NonPascalCaseName + | ErrorKind::NonScreamingCaseName + | ErrorKind::NonSnakeCaseName => Severity::Warning, ErrorKind::DeadCodeEliminated | ErrorKind::UnreachableMatchArm => { Severity::Note } @@ -558,4 +561,16 @@ pub enum ErrorKind { /// `test fun foo() -> int { }` — test functions must /// return unit. TestFnMustReturnUnit, + + // Naming-convention warnings — emitted by `zo-checker` + // through the executor as declarations execute. + /// A type-position name (`struct`, `enum`, `type`, generic + /// param) that is not PascalCase. + NonPascalCaseName, + /// A `val` constant name that is not SCREAMING_SNAKE_CASE. + NonScreamingCaseName, + /// A binding-position name (`imu`/`mut`, `fun` name and + /// arguments, struct fields, `abstract` functions) that is + /// not snake_case. + NonSnakeCaseName, } diff --git a/crates/compiler/zo-error/src/id_registry.rs b/crates/compiler/zo-error/src/id_registry.rs index 8ee76b50..afbf07c9 100644 --- a/crates/compiler/zo-error/src/id_registry.rs +++ b/crates/compiler/zo-error/src/id_registry.rs @@ -184,6 +184,9 @@ const fn entry(kind: ErrorKind) -> (&'static str, u16) { ("abstract-inheritance-unsupported", 348) } ErrorKind::AbstractNotDynSafe => ("abstract-not-dyn-safe", 349), + ErrorKind::NonPascalCaseName => ("non-pascal-case-name", 353), + ErrorKind::NonScreamingCaseName => ("non-screaming-case-name", 354), + ErrorKind::NonSnakeCaseName => ("non-snake-case-name", 355), // --- Constants & arithmetic (E0500 .. E0599) --- ErrorKind::DivisionByZero => ("division-by-zero", 500), diff --git a/crates/compiler/zo-executor/Cargo.toml b/crates/compiler/zo-executor/Cargo.toml index 476e7322..8b8306d3 100644 --- a/crates/compiler/zo-executor/Cargo.toml +++ b/crates/compiler/zo-executor/Cargo.toml @@ -10,6 +10,7 @@ doctest = false # internal:sources. # internal:crates. +zo-checker = { workspace = true } zo-constant-folding = { workspace = true } zo-constant-propagation = { workspace = true } zo-dce = { workspace = true } diff --git a/crates/compiler/zo-executor/src/executor.rs b/crates/compiler/zo-executor/src/executor.rs index 7496f060..ff36364f 100644 --- a/crates/compiler/zo-executor/src/executor.rs +++ b/crates/compiler/zo-executor/src/executor.rs @@ -1,3 +1,4 @@ +use zo_checker::Checker; use zo_constant_folding::{ConstFold, FoldResult, Operand}; use zo_error::{Error, ErrorKind}; use zo_interner::{ @@ -527,6 +528,10 @@ pub struct Executor<'a> { /// `Error::with_file` so the renderer resolves the /// span against the correct source text. current_file_id: u16, + /// Convention checks (naming today) run as declarations + /// execute — implementations live in `zo-checker` so the + /// execution loop stays free of lint logic. + checker: Checker, /// Attributes (`%% name [= literal | (arg)].`) the /// parser emitted just before the next item. Drained /// and applied when the next `FunDef` / `StructDef` / @@ -955,6 +960,7 @@ impl<'a> Executor<'a> { source_dir: None, source_path: None, current_file_id: 0xFFFF, + checker: Checker::new(), pending_attributes: Vec::new(), global_constants: Vec::new(), pack_constants: HashMap::default(), @@ -8806,6 +8812,13 @@ impl<'a> Executor<'a> { // Keep nested functions bare in every context, matching // how they already behave at top level. let raw_name = name.unwrap(); + + self.checker.check_binding_name( + self.interner.get(raw_name), + self.pending_fn_name_span, + self.current_file_id, + ); + let is_nested = self.current_function.is_some(); let name = if !is_nested && (self.apply_context.is_some() || !self.pack_context.is_empty()) @@ -8861,6 +8874,12 @@ impl<'a> Executor<'a> { self.type_params.push((sym, var)); + self.checker.check_type_name( + self.interner.get(sym), + self.tree.spans[idx], + self.current_file_id, + ); + // Parse bound(s): `$T: Abstract` or // `$T: Eq + Show + Ord`. Loops `+`-separated // idents so multi-bound is accepted by the same @@ -9006,6 +9025,12 @@ impl<'a> Executor<'a> { Token::Ident => { // Get parameter name if let Some(NodeValue::Symbol(param_name)) = self.node_value(idx) { + self.checker.check_binding_name( + self.interner.get(param_name), + self.tree.spans[idx], + self.current_file_id, + ); + idx += 1; // Next should be the type (no colon token). @@ -9612,6 +9637,18 @@ impl<'a> Executor<'a> { if tok == Token::Ident && let Some(NodeValue::Symbol(sym)) = self.node_value(i) { + // A struct pattern's names reference the struct's + // fields (already checked at the declaration), so + // warning here would report the same field twice. + // Tuple/array patterns mint fresh bindings — check. + if opener != Token::LBrace { + self.checker.check_binding_name( + self.interner.get(sym), + self.tree.spans[i], + self.current_file_id, + ); + } + names.push(sym); } @@ -9688,6 +9725,20 @@ impl<'a> Executor<'a> { }); if let Some(name) = name { + if is_constant { + self.checker.check_constant_name( + self.interner.get(name), + self.tree.spans[idx + 1], + self.current_file_id, + ); + } else { + self.checker.check_binding_name( + self.interner.get(name), + self.tree.spans[idx + 1], + self.current_file_id, + ); + } + let pubness = if self.is_pub(idx) { Pubness::Yes } else { @@ -11040,6 +11091,12 @@ impl<'a> Executor<'a> { let tpl_name = self.get_var_name(start_idx, end_idx); if let Some(name) = tpl_name { + self.checker.check_binding_name( + self.interner.get(name), + self.tree.spans[start_idx + 1], + self.current_file_id, + ); + self.pending_var_name = Some(name); } @@ -11411,6 +11468,12 @@ impl<'a> Executor<'a> { } }; + self.checker.check_type_name( + self.interner.get(name), + self.tree.spans[start_idx + 1], + self.current_file_id, + ); + let pubness = if self.is_pub(start_idx) { Pubness::Yes } else { @@ -11696,6 +11759,12 @@ impl<'a> Executor<'a> { }); if let Some(fname) = fname { + self.checker.check_binding_name( + self.interner.get(fname), + self.tree.spans[idx], + self.current_file_id, + ); + // Find field index. let field_idx = field_defs.iter().position(|f| f.name == fname); @@ -11948,6 +12017,12 @@ impl<'a> Executor<'a> { None => return, }; + self.checker.check_type_name( + self.interner.get(name), + self.tree.spans[start_idx + 1], + self.current_file_id, + ); + // Save the outer generic-param scope before scanning // for `<$T, ...>`. Each `$T` mints a fresh inference // var so the body resolution sees `$T` as that var @@ -12065,6 +12140,12 @@ impl<'a> Executor<'a> { } }; + self.checker.check_type_name( + self.interner.get(name), + self.tree.spans[start_idx + 1], + self.current_file_id, + ); + let pubness = if self.is_pub(start_idx) { Pubness::Yes } else { @@ -12134,6 +12215,12 @@ impl<'a> Executor<'a> { }); if let Some(fname) = fname { + self.checker.check_binding_name( + self.interner.get(fname), + field_span, + self.current_file_id, + ); + idx += 1; // Skip colon between name and type. @@ -14163,6 +14250,15 @@ impl<'a> Executor<'a> { NodeValue::Symbol(s) => Some(s), _ => None, }); + + if let Some(sym) = sym { + self.checker.check_binding_name( + self.interner.get(sym), + self.tree.spans[idx], + self.current_file_id, + ); + } + idx += 1; sym } else { diff --git a/crates/compiler/zo-executor/src/tests.rs b/crates/compiler/zo-executor/src/tests.rs index 7451ef5b..e3c88e60 100644 --- a/crates/compiler/zo-executor/src/tests.rs +++ b/crates/compiler/zo-executor/src/tests.rs @@ -18,6 +18,7 @@ pub(crate) mod generics; pub(crate) mod interpolation; pub(crate) mod matching; pub(crate) mod modules; +pub(crate) mod naming; pub(crate) mod str_slicing; pub(crate) mod structs; pub(crate) mod styles; diff --git a/crates/compiler/zo-executor/src/tests/common.rs b/crates/compiler/zo-executor/src/tests/common.rs index 65f99162..d68beaee 100644 --- a/crates/compiler/zo-executor/src/tests/common.rs +++ b/crates/compiler/zo-executor/src/tests/common.rs @@ -131,7 +131,10 @@ pub(crate) fn assert_no_errors(source: &str) { assert!( errors.is_empty(), "Expected no errors, but got: {:?}", - errors.iter().map(|e| e.kind()).collect::>() + errors + .iter() + .map(|e| (e.kind(), span_text(source, e.span()))) + .collect::>() ); } diff --git a/crates/compiler/zo-executor/src/tests/naming.rs b/crates/compiler/zo-executor/src/tests/naming.rs new file mode 100644 index 00000000..b5ba9ce6 --- /dev/null +++ b/crates/compiler/zo-executor/src/tests/naming.rs @@ -0,0 +1,208 @@ +//! ```sh +//! cargo test -p zo-executor --lib tests::naming +//! ``` +//! +//! Naming-convention warnings — one test per declaration site the +//! executor forwards to `zo-checker`. + +use super::common::{assert_execution_error, assert_no_errors}; + +use zo_error::ErrorKind; + +#[test] +fn warns_on_camel_case_imu_binding() { + assert_execution_error( + r#"fun main() { + imu myCount := 1; + + showln("{myCount}"); +}"#, + ErrorKind::NonSnakeCaseName, + ); +} + +#[test] +fn warns_on_pascal_case_mut_binding() { + assert_execution_error( + r#"fun main() { + mut Total := 0; + + Total += 1; + showln("{Total}"); +}"#, + ErrorKind::NonSnakeCaseName, + ); +} + +#[test] +fn warns_on_lowercase_val_constant() { + assert_execution_error( + r#"val max_size: int = 64; + +fun main() { + showln("{max_size}"); +}"#, + ErrorKind::NonScreamingCaseName, + ); +} + +#[test] +fn warns_on_pascal_case_function_name() { + assert_execution_error( + r#"fun Compute() -> int { 1 } + +fun main() { + showln("{Compute()}"); +}"#, + ErrorKind::NonSnakeCaseName, + ); +} + +#[test] +fn warns_on_camel_case_function_argument() { + assert_execution_error( + r#"fun double(theValue: int) -> int { theValue * 2 } + +fun main() { + showln("{double(2)}"); +}"#, + ErrorKind::NonSnakeCaseName, + ); +} + +#[test] +fn warns_on_snake_case_struct_name() { + assert_execution_error( + r#"struct my_point { + x: int, +} + +fun main() { + imu p := my_point { x = 1 }; + + showln("{p.x}"); +}"#, + ErrorKind::NonPascalCaseName, + ); +} + +#[test] +fn warns_on_camel_case_struct_field() { + assert_execution_error( + r#"struct Point { + posX: int, +} + +fun main() { + imu p := Point { posX = 1 }; + + showln("{p.posX}"); +}"#, + ErrorKind::NonSnakeCaseName, + ); +} + +#[test] +fn warns_on_snake_case_enum_name() { + assert_execution_error( + r#"enum traffic_light { + Red, + Green, +} + +fun main() { + imu light := traffic_light::Red; + + showln("ok"); +}"#, + ErrorKind::NonPascalCaseName, + ); +} + +#[test] +fn warns_on_snake_case_type_alias() { + assert_execution_error( + r#"type idx = int; + +fun main() { + imu i: idx = 3; + + showln("{i}"); +}"#, + ErrorKind::NonPascalCaseName, + ); +} + +#[test] +fn warns_on_snake_case_generic_param() { + assert_execution_error( + r#"fun identity<$item>(x: $item) -> $item { x } + +fun main() { + showln("{identity(1)}"); +}"#, + ErrorKind::NonPascalCaseName, + ); +} + +#[test] +fn warns_on_pascal_case_abstract_function() { + assert_execution_error( + r#"abstract Display { + fun Show(self) -> str; +} + +fun main() { + showln("ok"); +}"#, + ErrorKind::NonSnakeCaseName, + ); +} + +#[test] +fn warns_on_camel_case_tuple_pattern_binding() { + assert_execution_error( + r#"fun main() { + imu (firstItem, b) := (1, 2); + + showln("{firstItem} {b}"); +}"#, + ErrorKind::NonSnakeCaseName, + ); +} + +#[test] +fn convention_following_program_is_clean() { + // No builtin calls — this harness drives the bare executor + // without the module preload, so `showln` itself would resolve + // as undefined. Declarations are what this test is about. + assert_no_errors( + r#"val MAX_SIZE: int = 64; + +type Idx = int; + +struct Point { + x: int, + pos_y: int, +} + +enum TrafficLight { + Red, + Green, +} + +abstract Display { + fun render(self) -> str; +} + +fun identity<$T>(value: $T) -> $T { value } + +fun main() { + imu point := Point { x = 1, pos_y = 2 }; + imu r0 := identity(3); + mut total := 0; + + total += point.x + point.pos_y + r0 + MAX_SIZE; +}"#, + ); +} diff --git a/crates/compiler/zo-reporter/src/aggregator.rs b/crates/compiler/zo-reporter/src/aggregator.rs index 0a47ba4d..af5ec7c2 100644 --- a/crates/compiler/zo-reporter/src/aggregator.rs +++ b/crates/compiler/zo-reporter/src/aggregator.rs @@ -211,6 +211,9 @@ impl ErrorAggregator { | ErrorKind::UnreachableCode | ErrorKind::UnusedVariable | ErrorKind::UnusedFunction + | ErrorKind::NonPascalCaseName + | ErrorKind::NonScreamingCaseName + | ErrorKind::NonSnakeCaseName | ErrorKind::UninitializedVariable | ErrorKind::InvalidSelfReference | ErrorKind::InvalidTypeAnnotation => { diff --git a/crates/compiler/zo-reporter/src/collector.rs b/crates/compiler/zo-reporter/src/collector.rs index 1e3d6819..8d8c2611 100644 --- a/crates/compiler/zo-reporter/src/collector.rs +++ b/crates/compiler/zo-reporter/src/collector.rs @@ -36,6 +36,9 @@ pub enum Detail { /// Closest in-scope name for an undefined name (a typo) — /// `count` for `cont`. Suggestion(Box), + /// The convention-correct rendering of a wrongly-cased name — + /// `MyStruct` for `my_struct`. Yields a replace fix. + Rename(Box), /// A call passed the wrong number of arguments. Carries the /// callee name, the expected/given counts, and the callee's /// rendered signature for the help. @@ -97,7 +100,7 @@ impl Detail { Detail::ArgCount { expected, given, .. } => format!("expected {expected} arguments, found {given}"), - Detail::Suggestion(_) => return None, + Detail::Suggestion(_) | Detail::Rename(_) => return None, }) } @@ -123,6 +126,7 @@ impl Detail { pub fn help(&self) -> Option { match self { Detail::Suggestion(name) => Some(format!("did you mean `{name}`?")), + Detail::Rename(name) => Some(format!("rename it to `{name}`")), Detail::ArgCount { callee, signature, .. } @@ -273,6 +277,12 @@ pub fn report_error_with_suggestion(error: Error, name: &str) -> bool { report_error_with_detail(error, Detail::Suggestion(name.into())) } +/// Reports a naming-convention warning carrying the +/// convention-correct rename as its fix. +pub fn report_error_with_rename(error: Error, name: &str) -> bool { + report_error_with_detail(error, Detail::Rename(name.into())) +} + /// Reports an error and attaches dynamic detail. pub fn report_error_with_detail(error: Error, detail: Detail) -> bool { REPORTER diff --git a/crates/compiler/zo-reporter/src/json.rs b/crates/compiler/zo-reporter/src/json.rs index e4a7bbee..dc0cce60 100644 --- a/crates/compiler/zo-reporter/src/json.rs +++ b/crates/compiler/zo-reporter/src/json.rs @@ -212,6 +212,20 @@ fn encode( })); } } + Some(Detail::Rename(name)) => { + obj.insert("suggestion".into(), json!(&**name)); + + // Append a machine-applicable fix: rename the + // wrongly-cased name to the convention-correct form. + if let Some(Value::Array(fixes)) = obj.get_mut("fixes") { + fixes.push(json!({ + "kind": FixKind::Replace.as_str(), + "text": &**name, + "description": format!("rename to `{name}`"), + "span": span_json(filename, span.start, span.end()), + })); + } + } Some(Detail::ArgCount { callee, expected, diff --git a/crates/compiler/zo-reporter/src/lib.rs b/crates/compiler/zo-reporter/src/lib.rs index 72e2824a..de1ad92a 100644 --- a/crates/compiler/zo-reporter/src/lib.rs +++ b/crates/compiler/zo-reporter/src/lib.rs @@ -17,8 +17,8 @@ pub use aggregator::{ErrorAggregator, Phase, PhaseErrors}; pub use collector::{ Detail, TyNames, clear_errors, collect_diagnostics, collect_errors, error_count, report_error, report_error_with_detail, - report_error_with_suggestion, report_error_with_types, total_count, - warning_count, + report_error_with_rename, report_error_with_suggestion, + report_error_with_types, total_count, warning_count, }; pub use format::DiagnosticFormat; pub use render::{ErrorRenderer, RenderConfig, render_errors_to_stderr}; diff --git a/crates/compiler/zo-reporter/src/render.rs b/crates/compiler/zo-reporter/src/render.rs index 2307e227..dbc41899 100644 --- a/crates/compiler/zo-reporter/src/render.rs +++ b/crates/compiler/zo-reporter/src/render.rs @@ -403,6 +403,11 @@ pub(crate) fn error_message(kind: ErrorKind) -> &'static str { ErrorKind::UnreachableCode => "Unreachable code", ErrorKind::UnusedVariable => "Unused variable", ErrorKind::UnusedFunction => "Unused function", + ErrorKind::NonPascalCaseName => "Type name is not PascalCase", + ErrorKind::NonScreamingCaseName => { + "Constant name is not SCREAMING_SNAKE_CASE" + } + ErrorKind::NonSnakeCaseName => "Name is not snake_case", ErrorKind::UninitializedVariable => "Uninitialized variable", ErrorKind::InvalidSelfReference => "Invalid `self` reference", ErrorKind::InvalidTypeAnnotation => "Invalid type annotation", @@ -592,6 +597,9 @@ fn error_label(kind: ErrorKind) -> &'static str { ErrorKind::UnreachableCode => "this code will never execute", ErrorKind::UnusedVariable => "variable is never used", ErrorKind::UnusedFunction => "function is never called", + ErrorKind::NonPascalCaseName => "expected a PascalCase name", + ErrorKind::NonScreamingCaseName => "expected a SCREAMING_SNAKE_CASE name", + ErrorKind::NonSnakeCaseName => "expected a snake_case name", ErrorKind::UninitializedVariable => "used before initialization", ErrorKind::InvalidSelfReference => "`self` used outside of `apply` block", ErrorKind::InvalidTypeAnnotation => "invalid type here", diff --git a/crates/compiler/zo-reporter/src/xml.rs b/crates/compiler/zo-reporter/src/xml.rs index f9a7ebf1..f4829806 100644 --- a/crates/compiler/zo-reporter/src/xml.rs +++ b/crates/compiler/zo-reporter/src/xml.rs @@ -197,7 +197,9 @@ fn encode_fixes( detail: Option<&Detail>, ) { let suggestion = match detail { - Some(Detail::Suggestion(name)) => Some(&**name), + Some(Detail::Suggestion(name)) | Some(Detail::Rename(name)) => { + Some(&**name) + } _ => None, }; @@ -354,7 +356,7 @@ fn encode_detail(buf: &mut Buffer, detail: Option<&Detail>) { text_element(buf, 2, "primary_type", &names.primary); text_element(buf, 2, "secondary_type", &names.secondary); } - Some(Detail::Suggestion(name)) => { + Some(Detail::Suggestion(name)) | Some(Detail::Rename(name)) => { text_element(buf, 2, "suggestion", name); } Some(Detail::ArgCount { diff --git a/crates/compiler/zo-tests/programming/naming_convention_warns.zo b/crates/compiler/zo-tests/programming/naming_convention_warns.zo new file mode 100644 index 00000000..afd32665 --- /dev/null +++ b/crates/compiler/zo-tests/programming/naming_convention_warns.zo @@ -0,0 +1,17 @@ +-- tests-run-pass: naming-convention warnings are non-fatal. +-- `badPoint` (struct, not PascalCase), `posX` (field, not +-- snake_case), and `MyCount` (binding, not snake_case) each +-- draw a warning with a rename help; the program still +-- compiles and runs. +-- @cmd — zo run naming_convention_warns.zo + +struct badPoint { + posX: int, +} + +fun main() { + imu MyCount := 1; + imu p := badPoint { posX = MyCount }; + + check(p.posX == 1); +} diff --git a/notes/references.md b/notes/references.md index 1a651706..2c824bf4 100644 --- a/notes/references.md +++ b/notes/references.md @@ -27,6 +27,9 @@ THiS DOCUMENT CONTAiNS ALL MY REFERENCES TO BE ABLE TO BUiLD zo. - **Syntax Across Languages** - https://rigaux.org/language-study/syntax-across-languages — Pascal Rigaux (..) + - **Concrete syntax matters, actually** + - https://www.youtube.com/watch?v=kQjrcSMYpaA — Slim Lim (2026) + ## tokenizer. - **Beating the Fastest Lexer Generator in Rust** diff --git a/sources/crafter/swisskit-core/src/case/strcase/snakecase.rs b/sources/crafter/swisskit-core/src/case/strcase/snakecase.rs index 671e70a4..1f98f8bc 100644 --- a/sources/crafter/swisskit-core/src/case/strcase/snakecase.rs +++ b/sources/crafter/swisskit-core/src/case/strcase/snakecase.rs @@ -3,17 +3,30 @@ use inflector::cases::snakecase; /// Checks if a text follows the snake case naming convention. /// +/// A direct byte scan rather than inflector's +/// `text == to_snake_case(text)` round-trip: inflector treats every +/// digit as a word boundary, so the round-trip turns `r0` into `r_0` +/// and rejects it — but digits never need a separator in snake_case +/// (`r0`, `grid2` are idiomatic). +/// /// #### examples. /// /// ``` /// use swisskit::case::strcase::snakecase; /// /// assert!(snakecase::is_snake_case("foo_bar")); +/// assert!(snakecase::is_snake_case("r0")); /// assert!(!snakecase::is_snake_case("foo-bar")); +/// assert!(!snakecase::is_snake_case("fooBar")); /// ``` #[inline] pub fn is_snake_case(text: impl AsRef) -> bool { - snakecase::is_snake_case(text.as_ref()) + let text = text.as_ref(); + + !text.is_empty() + && text + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_') } /// Checks if a text follows the snake case naming convention. @@ -32,17 +45,27 @@ pub fn to_snake_case(text: impl AsRef) -> String { /// Checks if a text follows the snake screaming case naming convention. /// +/// A direct byte scan for the same reason as [`is_snake_case`] — +/// inflector's digit word boundary rejects `MAX2`. +/// /// #### examples. /// /// ``` /// use swisskit::case::strcase::snakecase; /// /// assert!(snakecase::is_snake_screaming_case("BAR_FOO")); +/// assert!(snakecase::is_snake_screaming_case("MAX2")); /// assert!(!snakecase::is_snake_screaming_case("bar-foo")); +/// assert!(!snakecase::is_snake_screaming_case("Bar_Foo")); /// ``` #[inline] pub fn is_snake_screaming_case(text: impl AsRef) -> bool { - screamingsnakecase::is_screaming_snake_case(text.as_ref()) + let text = text.as_ref(); + + !text.is_empty() + && text + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') } /// Checks if a text follows the snake screaming case naming convention.