Skip to main content

bop/
naming.rs

1//! Identifier classification: the shared rules every engine and the
2//! parser consult to sort a name into naming buckets.
3//!
4//! # Rules
5//!
6//! Strip any leading underscores to get the "core":
7//!
8//! | core                                              | kind           |
9//! |---------------------------------------------------|----------------|
10//! | empty                                             | `Wildcard`     |
11//! | starts uppercase, **all** uppercase/digit/`_`     | `Constant`     |
12//! | starts uppercase, has any lowercase               | `Type`         |
13//! | starts lowercase or digit                         | `Value`        |
14//!
15//! So `FOO` and `HTTP2` classify as `Constant`, `Foo` and `HttpClient`
16//! classify as `Type`, and `foo` / `_bar` / `_1` classify as `Value`.
17//!
18//! **Two important rules at declaration sites:**
19//!
20//! - `struct` / `enum` / enum variant accept **either** `Type` or
21//!   `Constant` — the bare "starts with a capital letter" rule.
22//!   Single-letter variants like `enum Dir { N, E, S, W }` are fine.
23//! - `const` accepts **only** `Constant` — the name must be all
24//!   uppercase. `const Pi = 3.14` is rejected.
25//!
26//! Leading underscores mark "private by convention" — they don't
27//! change the classification. `_foo` is a `Value`, `_Foo` is a
28//! `Type`, `_FOO` is a `Constant`. Glob imports skip names that
29//! start with an underscore; explicit selective or aliased imports
30//! still expose them.
31
32#[cfg(feature = "no_std")]
33use alloc::{format, string::String};
34
35/// The shape bucket an identifier string belongs to.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum IdentKind {
38    /// Binding name: `let` / `fn` / param / field / method / alias
39    /// / module path segment / `for-in` var / pattern binding.
40    Value,
41    /// Type name that *isn't* pure-uppercase — `Foo`, `Http`,
42    /// `HttpClient`, `MyStruct`, `_Internal`. Accepted at
43    /// struct/enum/variant sites.
44    Type,
45    /// Pure-uppercase name — either a `const` declaration or a
46    /// valid type name that happens to be all caps (`FOO`, `X`,
47    /// `HTTP`, `Dir::N`). Accepted at struct/enum/variant sites
48    /// *and* at `const` sites. Assigned-to in a source expression
49    /// means "reassigning a constant" and is refused at parse
50    /// time.
51    Constant,
52    /// Pure-underscore identifier (`_`, `__`) — match wildcard
53    /// and "explicitly discard" let (`let _ = foo()`).
54    Wildcard,
55}
56
57/// Classify `name` into its shape bucket. See the module docs.
58pub fn classify(name: &str) -> IdentKind {
59    let core = name.trim_start_matches('_');
60    if core.is_empty() {
61        return IdentKind::Wildcard;
62    }
63    let first = core.as_bytes()[0];
64    if first.is_ascii_uppercase() {
65        if core.bytes().any(|b| b.is_ascii_lowercase()) {
66            IdentKind::Type
67        } else {
68            IdentKind::Constant
69        }
70    } else {
71        // Lowercase letter or digit.
72        IdentKind::Value
73    }
74}
75
76/// `true` if the identifier has a leading underscore and isn't a
77/// pure-underscore wildcard — the "private by convention" marker.
78/// Glob imports skip these so a module's internals stay private
79/// unless the caller asks explicitly.
80pub fn is_private(name: &str) -> bool {
81    name.starts_with('_') && !name.trim_start_matches('_').is_empty()
82}
83
84/// Does this name's shape suit a binding site (`let`, `fn`,
85/// param, field, alias, `for-in`, match binding)? Wildcards
86/// count — `let _ = foo()` is legal.
87pub fn is_value_name(name: &str) -> bool {
88    matches!(classify(name), IdentKind::Value | IdentKind::Wildcard)
89}
90
91/// Does this name's shape suit a type site (`struct`, `enum`,
92/// enum variant)? Both `Type` and `Constant` shapes pass — the
93/// rule is "starts with an uppercase letter."
94pub fn is_type_name(name: &str) -> bool {
95    matches!(classify(name), IdentKind::Type | IdentKind::Constant)
96}
97
98/// Does this name's shape suit a `const` site? Only pure-
99/// uppercase names pass — `const Foo = 1` is rejected.
100pub fn is_constant_name(name: &str) -> bool {
101    matches!(classify(name), IdentKind::Constant)
102}
103
104/// Human-readable label for error messages.
105pub fn kind_label(kind: IdentKind) -> &'static str {
106    match kind {
107        IdentKind::Value => "value",
108        IdentKind::Type => "type",
109        IdentKind::Constant => "constant",
110        IdentKind::Wildcard => "wildcard",
111    }
112}
113
114/// Build a "did you mean?" hint for a mis-shaped identifier at a
115/// specific kind of site. The suggestion is best-effort — users
116/// can always pick a different name — but most of the time the
117/// obvious case transform produces something usable.
118pub fn hint_for(expected_kind: &str, actual: &str) -> String {
119    match expected_kind {
120        "value" => {
121            let is_all_upper = actual.chars().all(|c| !c.is_ascii_lowercase());
122            if is_all_upper && actual.chars().any(|c| c.is_ascii_alphabetic()) {
123                return format!(
124                    "names bound by `let` / `fn` / params start with a lowercase letter. \
125                     Did you mean to declare a constant? (`const {} = ...`)",
126                    actual
127                );
128            }
129            format!(
130                "names bound by `let` / `fn` / params start with a lowercase letter. \
131                 Try `{}`?",
132                lowercase_first(actual)
133            )
134        }
135        "type" => {
136            format!(
137                "type names start with an uppercase letter. Try `{}`?",
138                capitalize_first(actual)
139            )
140        }
141        "constant" => {
142            format!(
143                "`const` names are SCREAMING_SNAKE_CASE (all uppercase). Try `{}`?",
144                all_caps(actual)
145            )
146        }
147        _ => String::new(),
148    }
149}
150
151fn capitalize_first(s: &str) -> String {
152    let mut out = String::with_capacity(s.len());
153    let mut first = true;
154    for c in s.chars() {
155        if first {
156            out.extend(c.to_uppercase());
157            first = false;
158        } else {
159            out.push(c);
160        }
161    }
162    out
163}
164
165fn lowercase_first(s: &str) -> String {
166    let mut out = String::with_capacity(s.len());
167    let mut first = true;
168    for c in s.chars() {
169        if first {
170            out.extend(c.to_lowercase());
171            first = false;
172        } else {
173            out.push(c);
174        }
175    }
176    out
177}
178
179fn all_caps(s: &str) -> String {
180    s.chars().flat_map(|c| c.to_uppercase()).collect()
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn value_shapes() {
189        for name in [
190            "foo", "my_var", "camelCase", "doTheThing", "_foo", "_bar", "__baz", "x1", "_1",
191        ] {
192            assert_eq!(classify(name), IdentKind::Value, "{name} should be Value");
193        }
194    }
195
196    #[test]
197    fn type_shapes_are_pascal_case() {
198        for name in [
199            "Entity", "Result", "Ok", "Err", "HttpClient", "_Internal", "Foo", "BarBaz",
200        ] {
201            assert_eq!(classify(name), IdentKind::Type, "{name} should be Type");
202        }
203    }
204
205    #[test]
206    fn constant_shapes_are_all_caps() {
207        for name in [
208            "PI",
209            "MAX_SIZE",
210            "HTTP_PORT",
211            "HTTP",
212            "_DEBUG",
213            "__RESERVED",
214            "X",
215            "X2",
216            "X_Y",
217            "N",
218        ] {
219            assert_eq!(
220                classify(name),
221                IdentKind::Constant,
222                "{name} should be Constant"
223            );
224        }
225    }
226
227    #[test]
228    fn wildcards() {
229        assert_eq!(classify("_"), IdentKind::Wildcard);
230        assert_eq!(classify("__"), IdentKind::Wildcard);
231    }
232
233    #[test]
234    fn type_sites_accept_both_type_and_constant_shapes() {
235        // The parser's `is_type_name` accepts both so `enum Dir { N, E, S, W }`
236        // and short-acronym types like `HTTP` are legal even though
237        // classify() separates them.
238        assert!(is_type_name("Foo"));
239        assert!(is_type_name("FOO"));
240        assert!(is_type_name("N"));
241        assert!(!is_type_name("foo"));
242        assert!(!is_type_name("_"));
243    }
244
245    #[test]
246    fn constant_sites_require_all_caps() {
247        assert!(is_constant_name("PI"));
248        assert!(is_constant_name("MAX"));
249        assert!(!is_constant_name("Pi"));
250        assert!(!is_constant_name("pi"));
251    }
252
253    #[test]
254    fn privacy_marker() {
255        assert!(is_private("_foo"));
256        assert!(is_private("_Foo"));
257        assert!(is_private("_FOO"));
258        assert!(!is_private("foo"));
259        assert!(!is_private("Foo"));
260        assert!(!is_private("FOO"));
261        assert!(!is_private("_")); // wildcard, not private
262    }
263}