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}