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