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 tables, authored in blue (`gen/kigou.b`) and rendered into Rust by the
46/// `sabi` bidama. Edit the blue file and regenerate; `kigou-tables-fresh` in
47/// blue's flake fails when this generated module is stale.
48#[rustfmt::skip]
49mod tables;
50pub use tables::{OPERATOR_ALIASES, WELCOME};
51
52/// The ASCII operator a character spells, if it spells one.
53#[must_use]
54pub fn operator_alias(ch: char) -> Option<&'static str> {
55    OPERATOR_ALIASES
56        .iter()
57        .find(|(c, _, _)| *c == ch)
58        .map(|(_, op, _)| *op)
59}
60
61/// What blue does with `ch`.
62///
63/// ASCII is not this function's business — the lexer handles it directly, and
64/// routing it here would put a table lookup in front of every byte of every
65/// ordinary program.
66#[must_use]
67pub fn classify(ch: char) -> Class {
68    if let Some(op) = operator_alias(ch) {
69        return Class::Operator(op);
70    }
71    // A control character or an unpaired combining mark cannot begin anything
72    // and is almost always an invisible paste artefact — the class of bug that
73    // costs an hour because the source *looks* correct.
74    if ch.is_control() || ch.is_whitespace() || ch == '\u{feff}' {
75        return Class::Reject;
76    }
77    Class::Word
78}
79
80/// Everything in the catalog, for `blue kigou` and for documentation.
81///
82/// One function rather than two exported tables, so a caller cannot render
83/// half the catalog and believe it is the whole thing.
84#[must_use]
85pub fn catalog() -> Vec<(char, String, Class)> {
86    let mut out: Vec<(char, String, Class)> = OPERATOR_ALIASES
87        .iter()
88        .map(|(c, op, why)| (*c, format!("{why} — reads as `{op}`"), classify(*c)))
89        .collect();
90    out.extend(
91        WELCOME
92            .iter()
93            .map(|(c, why)| (*c, (*why).to_owned(), classify(*c))),
94    );
95    out
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn an_alias_lexes_to_the_same_program_as_its_ascii_spelling() {
104        // The property that makes an alias safe: not "equivalent", identical.
105        for (ch, ascii, why) in OPERATOR_ALIASES {
106            // `!` is prefix-only. Written infix, `a ! b` used to parse as TWO
107            // statements, `a` and `!b`, on both sides alike — so this test
108            // compared two identical misparses and passed. Statements now end
109            // at the end of the line, which is what exposed it.
110            let shape = |op: &str| {
111                if *ascii == "!" {
112                    format!("def f(a, b)\n  a && {op}b\nend")
113                } else {
114                    format!("def f(a, b)\n  a {op} b\nend")
115                }
116            };
117            let fancy = crate::parse_program(&shape(&ch.to_string()));
118            let plain = crate::parse_program(&shape(ascii));
119            let plain = plain.unwrap_or_else(|e| panic!("ascii `{ascii}` must parse: {e}"));
120            let fancy = fancy.unwrap_or_else(|e| panic!("`{ch}` ({why}) must parse: {e}"));
121            assert_eq!(
122                format!("{fancy:?}"),
123                format!("{plain:?}"),
124                "`{ch}` and `{ascii}` produced DIFFERENT trees — an alias that \
125                 means something other than the operator it looks like is a trap"
126            );
127        }
128    }
129
130    #[test]
131    fn welcomed_symbols_are_usable_as_names() {
132        for (ch, why) in WELCOME {
133            assert_eq!(
134                classify(*ch),
135                Class::Word,
136                "`{ch}` ({why}) is in WELCOME but does not classify as part of \
137                 an identifier"
138            );
139            let src = format!("def {ch}(n)\n  n\nend\n{ch}(1)");
140            assert!(
141                crate::parse_program(&src).is_ok(),
142                "`{ch}` ({why}) is catalogued as a usable name but will not parse"
143            );
144        }
145    }
146
147    #[test]
148    fn the_two_tables_do_not_overlap() {
149        // A character that is both an operator and a name is ambiguous, and
150        // the tables are hand-maintained, so this is the check that keeps a
151        // later addition from creating one.
152        for (ch, _) in WELCOME {
153            assert!(
154                operator_alias(*ch).is_none(),
155                "`{ch}` appears in BOTH the operator aliases and the welcome \
156                 list — it cannot be an operator and a name at once"
157            );
158        }
159    }
160
161    #[test]
162    fn invisible_characters_are_rejected_rather_than_named() {
163        // A zero-width space pasted into source is the bug that costs an hour,
164        // because the source looks right.
165        assert_eq!(classify('\u{00a0}'), Class::Reject, "no-break space");
166        assert_eq!(classify('\u{0007}'), Class::Reject, "control character");
167    }
168
169    /// `elsif` regression — it used to vanish silently.
170    ///
171    /// Lives here rather than in the parser tests because this file's demo
172    /// program is what exposed it: a clamp written with `elsif` returned its
173    /// input unchanged, and the alias work was briefly suspected before the
174    /// same failure reproduced in pure ASCII.
175    #[test]
176    fn an_elsif_arm_is_reachable() {
177        let src = "def k(a)\n  if a < 1\n    1\n  elsif a < 5\n    2\n  else\n    3\n  end\nend";
178        let forms = crate::parse_program(src).expect("elsif must parse");
179        let text = format!("{forms:?}");
180        // Three arms means a NESTED if in the else position. A swallowed
181        // `elsif` produces exactly one `if` and quietly drops the middle arm.
182        assert!(
183            text.matches("Symbol(\"if\")").count() >= 2,
184            "the elsif arm did not become a nested if — it was swallowed, and \
185             a swallowed arm returns the else value with no error: {text}"
186        );
187    }
188
189    #[test]
190    fn the_catalog_is_whole() {
191        let c = catalog();
192        assert_eq!(
193            c.len(),
194            OPERATOR_ALIASES.len() + WELCOME.len(),
195            "catalog() dropped entries — a partial catalog read as a whole one \
196             is how a reader concludes a character is unsupported"
197        );
198    }
199}