kaish_kernel/name.rs
1//! What may spell a variable name.
2//!
3//! A name is an identifier under [UAX #31] — `XID_Start` then `XID_Continue`,
4//! plus `_` — widened with emoji, and closed against characters that do not
5//! show themselves.
6//!
7//! The rule behind all three parts is that a reader must be able to see what
8//! the name is. `café` and `名前` are visible. `😁` is visible. A non-breaking
9//! space is not: `a\u{a0}b` renders as `a b` and is one name that looks like
10//! two words. A zero-width space is worse — `a\u{200b}b` renders as `ab` and is
11//! a different variable from `ab`. A right-to-left override reorders the text
12//! around it, so the source shows an order the parser does not see. Each of
13//! those is rejected, and the error names the character.
14//!
15//! A name that mixes scripts is a different problem: every character shows
16//! itself, and the name still reads as something it is not. `PАTH` — with
17//! CYRILLIC CAPITAL LETTER A where Latin `A` belongs — binds a second variable
18//! and leaves `$PATH` alone. That one is a warning, not a refusal ([`mixed_script`]),
19//! because refusing it would refuse `変数x` and every other name a writing
20//! system spells in two scripts.
21//!
22//! [UAX #31]: https://www.unicode.org/reports/tr31/
23//! [UAX #39]: https://www.unicode.org/reports/tr39/
24
25use std::fmt;
26
27use unicode_script::{Script, UnicodeScript};
28use unicode_security::mixed_script::AugmentedScriptSet;
29use unicode_security::skeleton;
30
31/// Why a name was refused, carrying the character that caused it.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct NameError {
34 /// The offending character.
35 pub ch: char,
36 /// What class it fell into.
37 pub kind: NameErrorKind,
38}
39
40/// The class of character that made a name unreadable.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum NameErrorKind {
44 /// Whitespace: the name looks like more than one word.
45 Whitespace,
46 /// A format or bidi control: the name does not render as it parses.
47 Invisible,
48 /// Not an identifier character in any script, and not an emoji.
49 NotAnIdentifier,
50 /// ASCII punctuation a *word* may hold but a name may not, because it does
51 /// not read back through every spelling of a reference.
52 AmbiguousAscii,
53 /// A dot, which reads as collection access rather than as part of a name.
54 DottedName,
55}
56
57impl fmt::Display for NameError {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 if self.kind == NameErrorKind::DottedName {
60 return write!(
61 f,
62 "variable name contains `.` (U+002E) — write `name[key]` for collection \
63 access, or quote the word to use it as a literal string"
64 );
65 }
66 if self.kind == NameErrorKind::AmbiguousAscii {
67 return write!(
68 f,
69 "variable name contains `{}` (U+{:04X}) — an ASCII name is letters, \
70 digits, and `_`; quote the word to use it as a literal string",
71 self.ch, self.ch as u32
72 );
73 }
74 let what = match self.kind {
75 NameErrorKind::Whitespace => "whitespace",
76 NameErrorKind::Invisible => "an invisible character",
77 NameErrorKind::AmbiguousAscii | NameErrorKind::DottedName => {
78 unreachable!("handled above")
79 }
80 NameErrorKind::NotAnIdentifier => "a character that is not a letter, digit, or emoji",
81 };
82 write!(
83 f,
84 "variable name contains {what} (U+{:04X}) — quote the word to use it \
85 as a literal string",
86 self.ch as u32
87 )
88 }
89}
90
91/// Format and bidirectional controls that change how text renders without
92/// occupying a column. Listed rather than derived from the `Cf` category so
93/// the set is reviewable, and so the two emoji joiners below can be excluded
94/// from it deliberately.
95const INVISIBLE: &[char] = &[
96 '\u{00ad}', // SOFT HYPHEN
97 '\u{061c}', // ARABIC LETTER MARK
98 '\u{180e}', // MONGOLIAN VOWEL SEPARATOR
99 '\u{200b}', // ZERO WIDTH SPACE
100 '\u{200c}', // ZERO WIDTH NON-JOINER
101 '\u{200e}', // LEFT-TO-RIGHT MARK
102 '\u{200f}', // RIGHT-TO-LEFT MARK
103 '\u{2028}', // LINE SEPARATOR
104 '\u{2029}', // PARAGRAPH SEPARATOR
105 '\u{202a}', // LEFT-TO-RIGHT EMBEDDING
106 '\u{202b}', // RIGHT-TO-LEFT EMBEDDING
107 '\u{202c}', // POP DIRECTIONAL FORMATTING
108 '\u{202d}', // LEFT-TO-RIGHT OVERRIDE
109 '\u{202e}', // RIGHT-TO-LEFT OVERRIDE
110 '\u{2060}', // WORD JOINER
111 '\u{2066}', // LEFT-TO-RIGHT ISOLATE
112 '\u{2067}', // RIGHT-TO-LEFT ISOLATE
113 '\u{2068}', // FIRST STRONG ISOLATE
114 '\u{2069}', // POP DIRECTIONAL ISOLATE
115 '\u{feff}', // ZERO WIDTH NO-BREAK SPACE
116];
117
118/// ZERO WIDTH JOINER — invisible alone, but it is what fuses `👨` and `👩`
119/// into one glyph. Permitted only between emoji (see [`validate`]), which is
120/// the only place it earns its keep.
121const ZWJ: char = '\u{200d}';
122
123/// Variation selectors 15 and 16, which pick the text or emoji rendering of
124/// the character before them. Like [`ZWJ`], only meaningful after an emoji.
125const VARIATION_SELECTORS: [char; 2] = ['\u{fe0e}', '\u{fe0f}'];
126
127/// Emoji, as the blocks that are predominantly pictographic.
128///
129/// A range list rather than the Unicode `Emoji` property, because the question
130/// here is only "a picture a reader can see" versus "a control they cannot",
131/// and that boundary does not move with the emoji spec. Blocks that are mostly
132/// typography or mathematics are deliberately out even where they hold a few
133/// characters with emoji presentation — the Arrows block would otherwise make
134/// `a→b` a legal name, and Miscellaneous Technical would admit `⌘`. The cost
135/// is that `⌚` and `⏰` are not name characters; the benefit is that the rule
136/// can be stated in one sentence.
137fn is_emoji(c: char) -> bool {
138 matches!(c as u32,
139 0x1F000..=0x1FAFF // pictographs, faces, flags, supplemental, extended-A
140 | 0x2600..=0x27BF // miscellaneous symbols and dingbats
141 | 0x2B00..=0x2BFF // stars and heavy shapes
142 )
143}
144
145/// May this character begin a name?
146pub fn is_name_start(c: char) -> bool {
147 c == '_' || unicode_ident::is_xid_start(c) || is_emoji(c)
148}
149
150/// May this character continue a name? Joiners are accepted here and checked
151/// for context by [`validate`] — a character class alone cannot see what came
152/// before it.
153pub fn is_name_continue(c: char) -> bool {
154 unicode_ident::is_xid_continue(c)
155 || is_emoji(c)
156 || c == ZWJ
157 || VARIATION_SELECTORS.contains(&c)
158}
159
160/// Check a whole name, returning the first character that makes it unreadable.
161///
162/// Runs over the name rather than per character because the joiners are only
163/// legitimate after an emoji: `👨👩` is one glyph, while `ab` renders as `ab`
164/// and is a different variable from `ab`.
165pub fn validate(name: &str) -> Result<(), NameError> {
166 // `${$}` and `${?}` are the braced spellings of the session identifier and
167 // the last exit code, and their name is literally that one character. They
168 // are the *only* two: every other special parameter (`$@`, `$#`, `$0`-`$9`)
169 // is its own token and never reaches this function.
170 //
171 // Listed rather than derived as "any single punctuation character". That
172 // wider rule looked equivalent — no assignment can create such a name,
173 // because the `Ident` token cannot start with punctuation — but the runtime
174 // doors do not take names from `Ident`: `read .`, `read @`, and `read -`
175 // are ordinary argument words, and each bound a variable no read could
176 // reach. The narrow list has no such hole.
177 if name == "$" || name == "?" {
178 return Ok(());
179 }
180
181 let mut previous: Option<char> = None;
182 for (i, c) in name.chars().enumerate() {
183 if c.is_ascii() {
184 // An ASCII name is letters, digits, and `_`. The `Ident` token
185 // admits `-`, `@`, `.`, and `#` so that words, paths, hostnames,
186 // and ids keep them, but none of the four reads back through every
187 // spelling of a reference — `$a-b` reads `$a` and then the literal
188 // `-b`, and `a:b` has no read spelling at all. A name that binds
189 // one way and cannot be read another is the silent write this rule
190 // removes.
191 //
192 // `.` and `#` are refused here too, not left to the validator. The
193 // validator writes a better message — it knows the exact spelling
194 // to suggest — but it only ever sees an assignment, and `read`,
195 // `unset`, `push`, and `scatter --as` take a name at runtime with
196 // no validator pass in front of them. Leaving the two characters
197 // out left `read a.b` binding a name no read could reach, which is
198 // the whole defect.
199 if c == '.' {
200 return Err(NameError { ch: c, kind: NameErrorKind::DottedName });
201 }
202 if !(c.is_ascii_alphanumeric() || c == '_') {
203 return Err(NameError { ch: c, kind: NameErrorKind::AmbiguousAscii });
204 }
205 previous = Some(c);
206 continue;
207 }
208 if c.is_whitespace() {
209 return Err(NameError { ch: c, kind: NameErrorKind::Whitespace });
210 }
211 if INVISIBLE.contains(&c) {
212 return Err(NameError { ch: c, kind: NameErrorKind::Invisible });
213 }
214 if c == ZWJ || VARIATION_SELECTORS.contains(&c) {
215 // Only after an emoji, and never leading.
216 match previous {
217 Some(p) if is_emoji(p) => {}
218 _ => return Err(NameError { ch: c, kind: NameErrorKind::Invisible }),
219 }
220 previous = Some(c);
221 continue;
222 }
223 let legal = if i == 0 { is_name_start(c) } else { is_name_continue(c) };
224 if !legal {
225 return Err(NameError { ch: c, kind: NameErrorKind::NotAnIdentifier });
226 }
227 previous = Some(c);
228 }
229 Ok(())
230}
231
232/// A name spelled in more than one script, and the character that shows it.
233///
234/// Reported by [`mixed_script`], and a warning at every door — a mixed-script
235/// name still binds.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct MixedScript {
238 /// The name as written.
239 pub name: String,
240 /// The first character that does not belong to the name's own script.
241 pub ch: char,
242 /// The script the rest of the name is written in.
243 pub script: &'static str,
244 /// The script [`MixedScript::ch`] belongs to.
245 pub other_script: &'static str,
246 /// The all-ASCII spelling the name reads as, from [UAX #39]'s confusables
247 /// data. `None` when the plain reading is itself not ASCII — `Ωmega`
248 /// reduces to `Ωrnega`, which teaches nothing.
249 ///
250 /// [UAX #39]: https://www.unicode.org/reports/tr39/
251 pub reads_as: Option<String>,
252}
253
254impl MixedScript {
255 /// What to do about it. Pairs with the message as a suggestion.
256 pub fn suggestion(&self) -> String {
257 match &self.reads_as {
258 Some(plain) => format!("write the name in one script, e.g. `{plain}`"),
259 None => "write the name in one script".to_string(),
260 }
261 }
262}
263
264impl fmt::Display for MixedScript {
265 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266 write!(f, "`{}` mixes {} and {}", self.name, self.script, self.other_script)?;
267 if let Some(plain) = &self.reads_as {
268 write!(f, " and reads as `{plain}`")?;
269 }
270 write!(
271 f,
272 // "names", not "binds": this message reaches `unset` too, which
273 // removes a variable rather than creating one.
274 " — `{}` (U+{:04X}) is {}, so this names a different variable",
275 self.ch, self.ch as u32, self.other_script
276 )
277 }
278}
279
280/// Is this name spelled in more than one script?
281///
282/// The rule is [UAX #39]'s Highly Restrictive profile: a name whose characters
283/// resolve to one script is fine, and so are the three script sets a writing
284/// system needs — Latin with Japanese, with Chinese, or with Korean. `café`,
285/// `名前`, `переменная`, and `変数x` all pass. `PАTH` does not.
286///
287/// Separate from [`validate`] on purpose. `validate` returns an `Err` its
288/// callers refuse on, and this is a warning: the name binds either way.
289///
290/// [UAX #39]: https://www.unicode.org/reports/tr39/
291pub fn mixed_script(name: &str) -> Option<MixedScript> {
292 // `Common` and `Inherited` characters — ASCII digits, `_`, emoji, and the
293 // joiners — intersect every script, so they leave the arithmetic alone
294 // without being named here. A character with no script at all (an
295 // unassigned code point inside an emoji block) carries no evidence either
296 // way and is skipped; folding it in would empty every set and report every
297 // emoji name.
298 let mut resolved = AugmentedScriptSet::default();
299 let mut without_latin = AugmentedScriptSet::default();
300 for c in name.chars() {
301 let set = AugmentedScriptSet::for_char(c);
302 if set.is_empty() {
303 continue;
304 }
305 resolved.intersect_with(set);
306 if !set.base.contains_script(Script::Latin) {
307 without_latin.intersect_with(set);
308 }
309 }
310 // One script covers the name.
311 if !resolved.is_empty() {
312 return None;
313 }
314 // Latin beside Japanese, Chinese, or Korean — the augmented sets Highly
315 // Restrictive admits, and the reason this is not simply "one script".
316 if without_latin.jpan || without_latin.hanb || without_latin.kore {
317 return None;
318 }
319
320 // Name the character that stands out rather than the one that happens to
321 // break a left-to-right intersection: in `Аbc` the Cyrillic letter comes
322 // first, and blaming `b` would point at the characters spelled correctly.
323 let spelled: Vec<(char, Script)> = name
324 .chars()
325 .map(|c| (c, c.script()))
326 .filter(|(_, s)| !matches!(s, Script::Common | Script::Inherited | Script::Unknown))
327 .collect();
328 let mut tally: Vec<(Script, usize)> = Vec::new();
329 for (_, s) in &spelled {
330 match tally.iter_mut().find(|(t, _)| t == s) {
331 Some(entry) => entry.1 += 1,
332 None => tally.push((*s, 1)),
333 }
334 }
335 // An empty resolved set with fewer than two scripts present is not
336 // reachable — one script always resolves to itself — so a `None` here
337 // would be a bug in the walk above rather than a name to report.
338 let (mut main_script, mut best) = *tally.first()?;
339 for &(script, count) in &tally[1..] {
340 if count > best {
341 main_script = script;
342 best = count;
343 }
344 }
345 let (ch, other) = spelled.into_iter().find(|&(_, s)| s != main_script)?;
346
347 let plain: String = skeleton(name).collect();
348 let reads_as = (plain.is_ascii() && plain != name).then_some(plain);
349
350 Some(MixedScript {
351 name: name.to_string(),
352 ch,
353 script: main_script.full_name(),
354 other_script: other.full_name(),
355 reads_as,
356 })
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn visible_names_in_any_script_are_accepted() {
365 for name in ["v", "_x", "café", "名前", "Ω", "переменная", "x1", "😁", "x😁", "👨\u{200d}👩", "❤\u{fe0f}"] {
366 assert!(validate(name).is_ok(), "{name:?} should be a legal name");
367 }
368 }
369
370 #[test]
371 fn whitespace_that_looks_like_a_word_break_is_refused() {
372 for (name, ch) in [("a\u{a0}b", '\u{a0}'), ("a\u{3000}b", '\u{3000}')] {
373 let err = validate(name).expect_err("should be refused");
374 assert_eq!(err.ch, ch);
375 assert_eq!(err.kind, NameErrorKind::Whitespace);
376 }
377 }
378
379 #[test]
380 fn invisible_characters_are_refused() {
381 for name in ["a\u{200b}b", "a\u{202e}b", "a\u{200c}b", "a\u{feff}b", "a\u{ad}b"] {
382 let err = validate(name).expect_err("{name:?} should be refused");
383 assert_eq!(err.kind, NameErrorKind::Invisible, "for {name:?}");
384 }
385 }
386
387 /// The joiners are the one deliberate exception, and it is narrow: they
388 /// carry an emoji sequence and nothing else.
389 #[test]
390 fn joiners_are_refused_away_from_emoji() {
391 for name in ["a\u{200d}b", "\u{200d}x", "a\u{fe0f}"] {
392 let err = validate(name).expect_err("should be refused");
393 assert_eq!(err.kind, NameErrorKind::Invisible, "for {name:?}");
394 }
395 }
396
397 /// Typography and mathematics are not names, even when the block next
398 /// door is full of emoji.
399 #[test]
400 fn punctuation_and_symbols_are_not_identifiers() {
401 for name in ["a«b", "a→b", "a⌘b", "a▪b"] {
402 assert!(validate(name).is_err(), "{name:?} should be refused");
403 }
404 }
405
406 /// Every name kaish accepts today is spelled in one script, and stays
407 /// quiet. `Common` and `Inherited` characters — digits, `_`, emoji, and
408 /// the joiners — must drop out of the rule on their own; if this goes red,
409 /// the arithmetic is wrong, not the list.
410 #[test]
411 fn single_script_names_are_not_mixed() {
412 for name in [
413 "v", "_x", "x1", "café", "名前", "Ω", "переменная", "😁", "x😁", "👨\u{200d}👩",
414 "❤\u{fe0f}", "$", "?",
415 ] {
416 assert_eq!(mixed_script(name), None, "{name:?} is one script");
417 }
418 }
419
420 /// Latin beside Han, Hiragana, or Katakana is a writing system, not a
421 /// confusable — UAX #39's Highly Restrictive profile admits it.
422 #[test]
423 fn latin_with_japanese_is_not_mixed() {
424 for name in ["変数x", "x変数", "カタカナ1", "名前_v2"] {
425 assert_eq!(mixed_script(name), None, "{name:?} is Highly Restrictive");
426 }
427 }
428
429 /// The defect this rule exists for.
430 #[test]
431 fn latin_with_cyrillic_is_mixed() {
432 let found = mixed_script("PАTH").expect("PАTH mixes scripts");
433 assert_eq!(found.ch, '\u{0410}');
434 assert_eq!(found.script, "Latin");
435 assert_eq!(found.other_script, "Cyrillic");
436 assert_eq!(found.reads_as.as_deref(), Some("PATH"));
437
438 let text = found.to_string();
439 assert!(text.contains("U+0410"), "got: {text}");
440 assert!(text.contains("Cyrillic"), "got: {text}");
441 assert!(text.contains("`PATH`"), "got: {text}");
442 }
443
444 /// The odd character out is named even when it comes first — `Аbc` is
445 /// three correct letters and one wrong one, not the other way round.
446 #[test]
447 fn the_minority_script_is_the_one_named() {
448 let found = mixed_script("Аbc").expect("Аbc mixes scripts");
449 assert_eq!(found.ch, '\u{0410}');
450 assert_eq!(found.other_script, "Cyrillic");
451 }
452
453 /// Greek beside Latin mixes too, and its plain reading is noise
454 /// (`Ωmega` reduces to `Ωrnega`), so the message leaves it out.
455 #[test]
456 fn latin_with_greek_is_mixed_without_a_plain_reading() {
457 let found = mixed_script("Ωmega").expect("Ωmega mixes scripts");
458 assert_eq!(found.ch, 'Ω');
459 assert_eq!(found.other_script, "Greek");
460 assert_eq!(found.reads_as, None);
461 assert!(!found.to_string().contains("reads as"), "{found}");
462 }
463
464 /// A mixed-script name is still a name — this rule never refuses.
465 #[test]
466 fn a_mixed_script_name_still_validates() {
467 assert!(validate("PАTH").is_ok());
468 }
469
470 /// The message has to name the character, since the whole problem is that
471 /// the reader cannot see it.
472 #[test]
473 fn the_message_names_the_codepoint() {
474 let err = validate("a\u{a0}b").expect_err("refused");
475 let text = err.to_string();
476 assert!(text.contains("U+00A0"), "got: {text}");
477 assert!(text.contains("quote"), "the message must say what to do: {text}");
478 }
479}