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