Skip to main content

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 `a‍b` 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}