praxis_syntax/ident.rs
1//! The one identifier character class for the whole workspace (§4.1).
2//!
3//! §4.1 allows Unicode identifiers, and the lexer, the input parser's
4//! capture-name splitter and the debugger all have to agree about which scalars
5//! they are — so the class is stated once, here, and never re-derived.
6//!
7//! `praxis-syntax` depends only on `praxis-source`, so every front-end crate
8//! can reach these predicates.
9
10/// Whether `c` may start an identifier (§4.1).
11///
12/// XID-Start plus `_`, matching Rust and UAX #31's `Default Identifier`.
13#[inline]
14pub fn is_ident_start(c: char) -> bool {
15 c == '_' || unicode_ident::is_xid_start(c)
16}
17
18/// Whether `c` may continue an identifier (§4.1).
19///
20/// XID-Continue plus `_`. Note XID-Continue already includes the ASCII digits,
21/// so `x9` continues as one identifier while `9x` does not start one.
22#[inline]
23pub fn is_ident_continue(c: char) -> bool {
24 c == '_' || unicode_ident::is_xid_continue(c)
25}
26
27/// Whether the whole of `s` is a well-formed identifier: non-empty, starting
28/// with [`is_ident_start`] and continuing with [`is_ident_continue`].
29///
30/// This is the predicate a *consumer* of a name should use — a name that the
31/// lexer would not have produced must be rejected, never rewritten into a
32/// different name (rewriting is not injective and silently merges distinct
33/// symbols).
34pub fn is_ident(s: &str) -> bool {
35 let mut chars = s.chars();
36 match chars.next() {
37 None => false,
38 Some(first) if !is_ident_start(first) => false,
39 Some(_) => chars.all(is_ident_continue),
40 }
41}
42
43/// The length in bytes of the identifier run at the start of `s`, or `0` when
44/// `s` does not start one.
45///
46/// The scanning counterpart to [`is_ident`]: the same character class, measuring
47/// a prefix instead of judging a whole string. This is the predicate a
48/// *scanner* wants, and it lives here for the same reason the class does —
49/// every place that re-derived the run around the class is another place the
50/// rule can drift.
51///
52/// A run is a run of *scalars*, so a caller holding bytes must decode first; a
53/// stray UTF-8 continuation byte is not an identifier continuation.
54pub fn ident_run_len(s: &str) -> usize {
55 let mut chars = s.char_indices();
56 match chars.next() {
57 Some((_, first)) if is_ident_start(first) => chars
58 .find(|(_, c)| !is_ident_continue(*c))
59 .map_or(s.len(), |(i, _)| i),
60 _ => 0,
61 }
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67
68 #[test]
69 fn ascii_identifiers_are_accepted() {
70 assert!(is_ident("x"));
71 assert!(is_ident("_"));
72 assert!(is_ident("_x9"));
73 assert!(is_ident("snake_case_9"));
74 }
75
76 #[test]
77 fn a_digit_may_continue_but_not_start() {
78 assert!(is_ident_continue('9'));
79 assert!(!is_ident_start('9'));
80 assert!(is_ident("x9"));
81 assert!(!is_ident("9x"));
82 }
83
84 /// §4.1: "Unicode identifiers are allowed".
85 #[test]
86 fn unicode_scalars_may_start_and_continue_an_identifier() {
87 assert!(is_ident("λ"));
88 assert!(is_ident("Ünicode"));
89 assert!(is_ident("δx"));
90 assert!(is_ident("日本語"));
91 }
92
93 /// A scalar outside the class is not an identifier, whatever its encoding:
94 /// an ASCII symbol, an arrow and an emoji are all outside XID-Start, and so
95 /// are the empty string and a name with a space in it.
96 #[test]
97 fn non_letter_scalars_are_not_identifiers() {
98 assert!(!is_ident("+"));
99 assert!(!is_ident("→"));
100 assert!(!is_ident("🦀"));
101 assert!(!is_ident(""));
102 assert!(!is_ident("a b"));
103 }
104
105 #[test]
106 fn a_run_ends_at_the_first_character_outside_the_class() {
107 assert_eq!(ident_run_len("x9 rest"), 2);
108 assert_eq!(ident_run_len("snake_case_9("), 12);
109 assert_eq!(ident_run_len("x"), 1);
110 }
111
112 /// The run is measured in bytes but scanned in scalars, so a multi-byte
113 /// scalar contributes its whole encoding and never a prefix of it.
114 #[test]
115 fn a_run_is_measured_in_bytes_over_whole_scalars() {
116 assert_eq!(ident_run_len("δx+"), 3);
117 assert_eq!(ident_run_len("日本語"), 9);
118 assert_eq!(ident_run_len("λ→"), 2);
119 }
120
121 #[test]
122 fn a_run_that_does_not_start_one_is_zero() {
123 assert_eq!(ident_run_len(""), 0);
124 assert_eq!(ident_run_len("9x"), 0);
125 assert_eq!(ident_run_len("+x"), 0);
126 assert_eq!(ident_run_len("\u{0301}x"), 0);
127 }
128
129 /// [`ident_run_len`] and [`is_ident`] are the same rule seen from two
130 /// sides: a whole string is an identifier exactly when the run covers it.
131 #[test]
132 fn a_full_length_run_agrees_with_is_ident() {
133 for s in ["x", "_x9", "Ünicode", "9x", "", "a b", "🦀"] {
134 assert_eq!(is_ident(s), !s.is_empty() && ident_run_len(s) == s.len());
135 }
136 }
137
138 /// A combining mark continues a name but cannot begin one, so `e\u{0301}`
139 /// is an identifier and a bare combining acute is not.
140 #[test]
141 fn a_combining_mark_may_continue_but_not_start() {
142 assert!(is_ident_continue('\u{0301}'));
143 assert!(!is_ident_start('\u{0301}'));
144 assert!(is_ident("e\u{0301}"));
145 assert!(!is_ident("\u{0301}"));
146 }
147}