Skip to main content

blue_lang_syntax/
kigou.rs

1//! `kigou` (記号) — the character library: which symbols blue understands.
2//!
3//! The lexer's UTF-8 layer decodes a character; this decides what it *means*.
4//! Three answers, and every non-ASCII character gets exactly one:
5//!
6//! | class | example | blue treats it as |
7//! |---|---|---|
8//! | [`Class::Operator`] | `≠` `≤` `×` `∧` | the ASCII operator it spells |
9//! | [`Class::Word`] | `λ` `∀` `→` `文` | part of an identifier |
10//! | [`Class::Reject`] | a bare combining mark, a control char | a lex error |
11//!
12//! ## Why an operator table rather than "symbols are operators"
13//!
14//! Because most beautiful symbols are not operators, they are *names*. `λ` is
15//! what a reader wants to call a lambda, `∀` is a good name for a
16//! universal-quantifier helper, `∇` for a gradient. If every symbol lexed as an
17//! operator, none of those could be a function name and the library would be
18//! poorer for it.
19//!
20//! So the table is a **curated allow-list of aliases**: a symbol is an operator
21//! only if it is the standard typographic spelling of one blue already has.
22//! `≠` is genuinely how mathematics writes `!=`; `∀` is not how it writes
23//! anything blue has. Everything not on the list is a name, which is the
24//! permissive default and the one that gives the language its range.
25//!
26//! ## The aliases carry no new semantics
27//!
28//! Each alias lexes to the *identical token* as its ASCII spelling, so `a ≠ b`
29//! and `a != b` are the same program — not equivalent programs, the same one.
30//! That is deliberate: a symbol that meant something subtly different from the
31//! operator it looks like would be a trap, and the whole value of `≤` is that
32//! a reader already knows what it does.
33
34/// What blue does with a character.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Class {
37    /// Spells an existing blue operator.
38    Operator(&'static str),
39    /// May appear in an identifier.
40    Word,
41    /// Not legal in source outside a string or comment.
42    Reject,
43}
44
45/// The typographic spellings of blue's operators.
46///
47/// Curated, not generated: every row is a symbol that mathematics or ordinary
48/// typography already uses for exactly this operator, so a reader needs no
49/// lookup. A symbol whose meaning would have to be *taught* belongs in an
50/// identifier instead, where the author names it.
51pub const OPERATOR_ALIASES: &[(char, &str, &str)] = &[
52    ('≠', "!=", "not equal"),
53    ('≤', "<=", "less than or equal"),
54    ('≥', ">=", "greater than or equal"),
55    ('×', "*", "multiplication"),
56    ('÷', "/", "division"),
57    ('−', "-", "minus sign (U+2212, not the ASCII hyphen)"),
58    ('∧', "&&", "logical and"),
59    ('∨', "||", "logical or"),
60    ('¬', "!", "logical not"),
61    ('≡', "==", "identical to"),
62];
63
64/// The symbols blue explicitly welcomes inside identifiers.
65///
66/// Not exhaustive — [`classify`] admits any alphabetic character and any
67/// symbol not on the operator list — but *named*, because a catalog a reader
68/// can scan is worth more than a rule they have to infer. These are the ones
69/// worth reaching for.
70pub const WELCOME: &[(char, &str)] = &[
71    (
72        'λ',
73        "lambda — the traditional name for an anonymous function",
74    ),
75    ('∀', "for all"),
76    ('∃', "there exists"),
77    ('∈', "element of"),
78    ('∉', "not an element of"),
79    ('∅', "the empty set"),
80    ('∪', "union"),
81    ('∩', "intersection"),
82    ('⊆', "subset of"),
83    ('∘', "function composition"),
84    ('∑', "sum"),
85    ('∏', "product"),
86    ('√', "square root"),
87    ('∞', "infinity"),
88    ('∂', "partial derivative"),
89    ('∇', "gradient / nabla"),
90    ('∫', "integral"),
91    ('→', "maps to / implies"),
92    ('←', "assigned from"),
93    ('↔', "if and only if"),
94    ('⇒', "implies"),
95    ('⊤', "top / true"),
96    ('⊥', "bottom / false"),
97    ('⊢', "proves / entails"),
98    ('π', "pi"),
99    ('α', "alpha"),
100    ('β', "beta"),
101    ('γ', "gamma"),
102    ('δ', "delta"),
103    ('ε', "epsilon"),
104    ('θ', "theta"),
105    ('μ', "mu"),
106    ('σ', "sigma"),
107    ('φ', "phi"),
108    ('ω', "omega"),
109    ('ℕ', "the naturals"),
110    ('ℤ', "the integers"),
111    ('ℚ', "the rationals"),
112    ('ℝ', "the reals"),
113    ('ℂ', "the complex numbers"),
114];
115
116/// The ASCII operator a character spells, if it spells one.
117#[must_use]
118pub fn operator_alias(ch: char) -> Option<&'static str> {
119    OPERATOR_ALIASES
120        .iter()
121        .find(|(c, _, _)| *c == ch)
122        .map(|(_, op, _)| *op)
123}
124
125/// What blue does with `ch`.
126///
127/// ASCII is not this function's business — the lexer handles it directly, and
128/// routing it here would put a table lookup in front of every byte of every
129/// ordinary program.
130#[must_use]
131pub fn classify(ch: char) -> Class {
132    if let Some(op) = operator_alias(ch) {
133        return Class::Operator(op);
134    }
135    // A control character or an unpaired combining mark cannot begin anything
136    // and is almost always an invisible paste artefact — the class of bug that
137    // costs an hour because the source *looks* correct.
138    if ch.is_control() || ch.is_whitespace() {
139        return Class::Reject;
140    }
141    Class::Word
142}
143
144/// Everything in the catalog, for `blue kigou` and for documentation.
145///
146/// One function rather than two exported tables, so a caller cannot render
147/// half the catalog and believe it is the whole thing.
148#[must_use]
149pub fn catalog() -> Vec<(char, String, Class)> {
150    let mut out: Vec<(char, String, Class)> = OPERATOR_ALIASES
151        .iter()
152        .map(|(c, op, why)| (*c, format!("{why} — reads as `{op}`"), classify(*c)))
153        .collect();
154    out.extend(
155        WELCOME
156            .iter()
157            .map(|(c, why)| (*c, (*why).to_owned(), classify(*c))),
158    );
159    out
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn an_alias_lexes_to_the_same_program_as_its_ascii_spelling() {
168        // The property that makes an alias safe: not "equivalent", identical.
169        for (ch, ascii, why) in OPERATOR_ALIASES {
170            let fancy = crate::parse_program(&format!("def f(a, b)\n  a {ch} b\nend"));
171            let plain = crate::parse_program(&format!("def f(a, b)\n  a {ascii} b\nend"));
172            let plain = plain.unwrap_or_else(|e| panic!("ascii `{ascii}` must parse: {e}"));
173            let fancy = fancy.unwrap_or_else(|e| panic!("`{ch}` ({why}) must parse: {e}"));
174            assert_eq!(
175                format!("{fancy:?}"),
176                format!("{plain:?}"),
177                "`{ch}` and `{ascii}` produced DIFFERENT trees — an alias that \
178                 means something other than the operator it looks like is a trap"
179            );
180        }
181    }
182
183    #[test]
184    fn welcomed_symbols_are_usable_as_names() {
185        for (ch, why) in WELCOME {
186            assert_eq!(
187                classify(*ch),
188                Class::Word,
189                "`{ch}` ({why}) is in WELCOME but does not classify as part of \
190                 an identifier"
191            );
192            let src = format!("def {ch}(n)\n  n\nend\n{ch}(1)");
193            assert!(
194                crate::parse_program(&src).is_ok(),
195                "`{ch}` ({why}) is catalogued as a usable name but will not parse"
196            );
197        }
198    }
199
200    #[test]
201    fn the_two_tables_do_not_overlap() {
202        // A character that is both an operator and a name is ambiguous, and
203        // the tables are hand-maintained, so this is the check that keeps a
204        // later addition from creating one.
205        for (ch, _) in WELCOME {
206            assert!(
207                operator_alias(*ch).is_none(),
208                "`{ch}` appears in BOTH the operator aliases and the welcome \
209                 list — it cannot be an operator and a name at once"
210            );
211        }
212    }
213
214    #[test]
215    fn invisible_characters_are_rejected_rather_than_named() {
216        // A zero-width space pasted into source is the bug that costs an hour,
217        // because the source looks right.
218        assert_eq!(classify('\u{00a0}'), Class::Reject, "no-break space");
219        assert_eq!(classify('\u{0007}'), Class::Reject, "control character");
220    }
221
222    /// `elsif` regression — it used to vanish silently.
223    ///
224    /// Lives here rather than in the parser tests because this file's demo
225    /// program is what exposed it: a clamp written with `elsif` returned its
226    /// input unchanged, and the alias work was briefly suspected before the
227    /// same failure reproduced in pure ASCII.
228    #[test]
229    fn an_elsif_arm_is_reachable() {
230        let src = "def k(a)\n  if a < 1\n    1\n  elsif a < 5\n    2\n  else\n    3\n  end\nend";
231        let forms = crate::parse_program(src).expect("elsif must parse");
232        let text = format!("{forms:?}");
233        // Three arms means a NESTED if in the else position. A swallowed
234        // `elsif` produces exactly one `if` and quietly drops the middle arm.
235        assert!(
236            text.matches("Symbol(\"if\")").count() >= 2,
237            "the elsif arm did not become a nested if — it was swallowed, and \
238             a swallowed arm returns the else value with no error: {text}"
239        );
240    }
241
242    #[test]
243    fn the_catalog_is_whole() {
244        let c = catalog();
245        assert_eq!(
246            c.len(),
247            OPERATOR_ALIASES.len() + WELCOME.len(),
248            "catalog() dropped entries — a partial catalog read as a whole one \
249             is how a reader concludes a character is unsupported"
250        );
251    }
252}