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() {
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 let fancy = crate::parse_program(&format!("def f(a, b)\n a {ch} b\nend"));
107 let plain = crate::parse_program(&format!("def f(a, b)\n a {ascii} b\nend"));
108 let plain = plain.unwrap_or_else(|e| panic!("ascii `{ascii}` must parse: {e}"));
109 let fancy = fancy.unwrap_or_else(|e| panic!("`{ch}` ({why}) must parse: {e}"));
110 assert_eq!(
111 format!("{fancy:?}"),
112 format!("{plain:?}"),
113 "`{ch}` and `{ascii}` produced DIFFERENT trees — an alias that \
114 means something other than the operator it looks like is a trap"
115 );
116 }
117 }
118
119 #[test]
120 fn welcomed_symbols_are_usable_as_names() {
121 for (ch, why) in WELCOME {
122 assert_eq!(
123 classify(*ch),
124 Class::Word,
125 "`{ch}` ({why}) is in WELCOME but does not classify as part of \
126 an identifier"
127 );
128 let src = format!("def {ch}(n)\n n\nend\n{ch}(1)");
129 assert!(
130 crate::parse_program(&src).is_ok(),
131 "`{ch}` ({why}) is catalogued as a usable name but will not parse"
132 );
133 }
134 }
135
136 #[test]
137 fn the_two_tables_do_not_overlap() {
138 // A character that is both an operator and a name is ambiguous, and
139 // the tables are hand-maintained, so this is the check that keeps a
140 // later addition from creating one.
141 for (ch, _) in WELCOME {
142 assert!(
143 operator_alias(*ch).is_none(),
144 "`{ch}` appears in BOTH the operator aliases and the welcome \
145 list — it cannot be an operator and a name at once"
146 );
147 }
148 }
149
150 #[test]
151 fn invisible_characters_are_rejected_rather_than_named() {
152 // A zero-width space pasted into source is the bug that costs an hour,
153 // because the source looks right.
154 assert_eq!(classify('\u{00a0}'), Class::Reject, "no-break space");
155 assert_eq!(classify('\u{0007}'), Class::Reject, "control character");
156 }
157
158 /// `elsif` regression — it used to vanish silently.
159 ///
160 /// Lives here rather than in the parser tests because this file's demo
161 /// program is what exposed it: a clamp written with `elsif` returned its
162 /// input unchanged, and the alias work was briefly suspected before the
163 /// same failure reproduced in pure ASCII.
164 #[test]
165 fn an_elsif_arm_is_reachable() {
166 let src = "def k(a)\n if a < 1\n 1\n elsif a < 5\n 2\n else\n 3\n end\nend";
167 let forms = crate::parse_program(src).expect("elsif must parse");
168 let text = format!("{forms:?}");
169 // Three arms means a NESTED if in the else position. A swallowed
170 // `elsif` produces exactly one `if` and quietly drops the middle arm.
171 assert!(
172 text.matches("Symbol(\"if\")").count() >= 2,
173 "the elsif arm did not become a nested if — it was swallowed, and \
174 a swallowed arm returns the else value with no error: {text}"
175 );
176 }
177
178 #[test]
179 fn the_catalog_is_whole() {
180 let c = catalog();
181 assert_eq!(
182 c.len(),
183 OPERATOR_ALIASES.len() + WELCOME.len(),
184 "catalog() dropped entries — a partial catalog read as a whole one \
185 is how a reader concludes a character is unsupported"
186 );
187 }
188}