Skip to main content

oxc_syntax/
identifier.rs

1use unicode_id_start::{is_id_continue_unicode, is_id_start_unicode};
2
3use crate::line_terminator::{CR, LF, LS, PS};
4
5pub const EOF: char = '\0';
6
7// 11.1 Unicode Format-Control Characters
8
9/// U+200C ZERO WIDTH NON-JOINER, abbreviated in the spec as `<ZWNJ>`.
10/// Specially permitted in identifiers.
11pub const ZWNJ: char = '\u{200c}';
12
13/// U+200D ZERO WIDTH JOINER, abbreviated as `<ZWJ>`.
14/// Specially permitted in identifiers.
15pub const ZWJ: char = '\u{200d}';
16
17/// U+FEFF ZERO WIDTH NO-BREAK SPACE, abbreviated `<ZWNBSP>`.
18/// Considered a whitespace character in JS.
19pub const ZWNBSP: char = '\u{feff}';
20
21// 11.2 White Space
22/// U+0009 CHARACTER TABULATION, abbreviated `<TAB>`.
23pub const TAB: char = '\u{9}';
24
25/// U+000B VERTICAL TAB, abbreviated `<VT>`.
26pub const VT: char = '\u{b}';
27
28/// U+000C FORM FEED, abbreviated `<FF>`.
29pub const FF: char = '\u{c}';
30
31/// U+0020 SPACE, abbreviated `<SP>`.
32pub const SP: char = '\u{20}';
33
34/// U+00A0 NON-BREAKING SPACE, abbreviated `<NBSP>`.
35pub const NBSP: char = '\u{a0}';
36
37// U+0085 NEXT LINE, abbreviated `<NEL>`.
38const NEL: char = '\u{85}';
39
40const OGHAM_SPACE_MARK: char = '\u{1680}';
41
42const EN_QUAD: char = '\u{2000}';
43
44// U+200B ZERO WIDTH SPACE, abbreviated `<ZWSP>`.
45const ZWSP: char = '\u{200b}';
46
47// Narrow NO-BREAK SPACE, abbreviated `<NNBSP>`.
48const NNBSP: char = '\u{202f}';
49
50// U+205F MEDIUM MATHEMATICAL SPACE, abbreviated `<MMSP>`.
51const MMSP: char = '\u{205f}';
52
53const IDEOGRAPHIC_SPACE: char = '\u{3000}';
54
55fn is_unicode_space_separator(c: char) -> bool {
56    // is_whitespace matches Unicode `White_Space` property
57    // exclude the characters that are included in `White_Space`, but not `Space_Separator`
58    // <https://util.unicode.org/UnicodeJsps/list-unicodeset.jsp?a=%5Cp%7BWhite_Space%7D%26%5CP%7BGeneral_Category%3DSpace_Separator%7D>
59    c.is_whitespace() && !matches!(c, TAB | LF | VT | FF | CR | NEL | LS | PS)
60}
61
62pub fn is_white_space(c: char) -> bool {
63    matches!(c, TAB | VT | FF | ZWNBSP) || is_unicode_space_separator(c)
64}
65
66// https://eslint.org/docs/latest/rules/no-irregular-whitespace#rule-details
67#[rustfmt::skip]
68pub fn is_irregular_whitespace(c: char) -> bool {
69    matches!(c,
70        VT | FF | NBSP | ZWNBSP | NEL | OGHAM_SPACE_MARK
71        | EN_QUAD..=ZWSP | NNBSP | MMSP | IDEOGRAPHIC_SPACE
72    )
73}
74
75// https://github.com/microsoft/TypeScript/blob/b8e4ed8aeb0b228f544c5736908c31f136a9f7e3/src/compiler/scanner.ts#L556
76pub fn is_white_space_single_line(c: char) -> bool {
77    // Note: nextLine is in the Zs space, and should be considered to be a whitespace.
78    // It is explicitly not a line-break as it isn't in the exact set specified by EcmaScript.
79    matches!(c, SP | TAB) || is_irregular_whitespace(c)
80}
81
82const ID_START: u8 = 1;
83const ID_CONTINUE: u8 = 2;
84
85#[repr(C, align(64))]
86pub struct Align64<T>(pub(crate) T);
87
88// Packed: ID_START | ID_CONTINUE per ASCII byte.
89// `a`-`z`, `A`-`Z`, `$`, `_` get ID_START | ID_CONTINUE (3).
90// `0`-`9` get ID_CONTINUE only (2).
91#[rustfmt::skip]
92pub static ASCII_ID_FLAGS: Align64<[u8; 128]> = Align64([
93//  0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  //
94    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0
95    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 1
96    0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 2  $ = 3
97    2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 0, 0, 0, // 3  0-9 = 2
98    0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, // 4  A-Z = 3
99    3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 3, // 5  _ = 3
100    0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, // 6  a-z = 3
101    3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, // 7
102]);
103
104/// Section 12.7 Detect `IdentifierStartChar`
105#[inline]
106pub fn is_identifier_start(c: char) -> bool {
107    if c.is_ascii() {
108        return is_identifier_start_ascii(c);
109    }
110    is_identifier_start_unicode(c)
111}
112
113#[inline]
114pub fn is_identifier_start_ascii(c: char) -> bool {
115    ASCII_ID_FLAGS.0[c as usize] & ID_START != 0
116}
117
118#[inline]
119pub fn is_identifier_start_unicode(c: char) -> bool {
120    is_id_start_unicode(c)
121}
122
123/// Section 12.7 Detect `IdentifierPartChar`
124/// NOTE 2: The nonterminal `IdentifierPart` derives _ via `UnicodeIDContinue`.
125#[inline]
126pub fn is_identifier_part(c: char) -> bool {
127    if c.is_ascii() {
128        return is_identifier_part_ascii(c);
129    }
130    is_identifier_part_unicode(c)
131}
132
133#[inline]
134pub fn is_identifier_part_ascii(c: char) -> bool {
135    ASCII_ID_FLAGS.0[c as usize] & ID_CONTINUE != 0
136}
137
138#[inline]
139pub fn is_identifier_part_unicode(c: char) -> bool {
140    is_id_continue_unicode(c) || c == ZWNJ || c == ZWJ
141}
142
143/// U+30FB KATAKANA MIDDLE DOT
144const KATAKANA_MIDDLE_DOT: char = '・';
145/// U+FF65 HALFWIDTH KATAKANA MIDDLE DOT
146const HALFWIDTH_KATAKANA_MIDDLE_DOT: char = '・';
147
148/// Determine if a string is a valid JS identifier.
149pub fn is_identifier_name(name: &str) -> bool {
150    is_identifier_name_impl::<false>(name)
151}
152
153/// `is_identifier_name` patched with KATAKANA MIDDLE DOT and HALFWIDTH KATAKANA MIDDLE DOT.
154///
155/// Otherwise `({ 'x・': 0 })` gets converted to `({ x・: 0 })`, which breaks in Unicode 4.1 to
156/// 15.
157///
158/// <https://github.com/oxc-project/unicode-id-start/pull/3>
159pub fn is_identifier_name_patched(name: &str) -> bool {
160    is_identifier_name_impl::<true>(name)
161}
162
163fn is_identifier_name_impl<const PATCHED: bool>(name: &str) -> bool {
164    // This function contains a fast path for ASCII (common case), iterating over bytes and using
165    // the cheap `is_identifier_start_ascii` and `is_identifier_part_ascii` to test bytes.
166    // Only if a Unicode char is found, fall back to iterating over `char`s, and using the more
167    // expensive `is_identifier_start_unicode` and `is_identifier_part`.
168    // As a further optimization, we test if bytes are ASCII in blocks of 8 or 4 bytes, rather than 1 by 1.
169
170    // Get first byte. Exit if empty string.
171    let bytes = name.as_bytes();
172    let Some(&first_byte) = bytes.first() else { return false };
173
174    let mut chars = if first_byte.is_ascii() {
175        // First byte is ASCII
176        if ASCII_ID_FLAGS.0[first_byte as usize] & ID_START == 0 {
177            return false;
178        }
179
180        let mut index = 1;
181        'outer: loop {
182            // Check blocks of 8 bytes, then 4 bytes, then single bytes
183            let bytes_remaining = bytes.len() - index;
184            if bytes_remaining >= 8 {
185                // Process block of 8 bytes.
186                // Check that next 8 bytes are all ASCII.
187                // SAFETY: We checked above that there are at least 8 bytes to read starting at `index`
188                #[expect(clippy::cast_ptr_alignment)]
189                let next8_as_u64 = unsafe {
190                    let ptr = bytes.as_ptr().add(index).cast::<u64>();
191                    ptr.read_unaligned()
192                };
193                let high_bits = next8_as_u64 & 0x8080_8080_8080_8080;
194                if high_bits != 0 {
195                    // Some chars in this block are non-ASCII
196                    break;
197                }
198
199                let next8 = next8_as_u64.to_ne_bytes();
200                for b in next8 {
201                    if ASCII_ID_FLAGS.0[b as usize] & ID_CONTINUE == 0 {
202                        return false;
203                    }
204                }
205
206                index += 8;
207            } else if bytes_remaining >= 4 {
208                // Process block of 4 bytes.
209                // Check that next 4 bytes are all ASCII.
210                // SAFETY: We checked above that there are at least 4 bytes to read starting at `index`
211                #[expect(clippy::cast_ptr_alignment)]
212                let next4_as_u32 = unsafe {
213                    let ptr = bytes.as_ptr().add(index).cast::<u32>();
214                    ptr.read_unaligned()
215                };
216                let high_bits = next4_as_u32 & 0x8080_8080;
217                if high_bits != 0 {
218                    // Some chars in this block are non-ASCII
219                    break;
220                }
221
222                let next4 = next4_as_u32.to_ne_bytes();
223                for b in next4 {
224                    if ASCII_ID_FLAGS.0[b as usize] & ID_CONTINUE == 0 {
225                        return false;
226                    }
227                }
228
229                index += 4;
230            } else {
231                loop {
232                    let Some(&b) = bytes.get(index) else {
233                        // We got to the end with no non-identifier chars found
234                        return true;
235                    };
236
237                    if b.is_ascii() {
238                        if ASCII_ID_FLAGS.0[b as usize] & ID_CONTINUE == 0 {
239                            return false;
240                        }
241                    } else {
242                        // Unicode byte found
243                        break 'outer;
244                    }
245
246                    index += 1;
247                }
248            }
249        }
250
251        // Unicode byte found - search rest of string (from this byte onwards) as Unicode
252        name[index..].chars()
253    } else {
254        // First char is Unicode.
255        // NB: `unwrap()` cannot fail because we already checked the string is not empty.
256        let mut chars = name.chars();
257        let first_char = chars.next().unwrap();
258        if !is_identifier_start_unicode(first_char) {
259            return false;
260        }
261        // Search rest of string as Unicode
262        chars
263    };
264
265    // A Unicode char was found - search rest of string as Unicode
266    if PATCHED {
267        chars.all(|c| {
268            is_identifier_part(c) && c != KATAKANA_MIDDLE_DOT && c != HALFWIDTH_KATAKANA_MIDDLE_DOT
269        })
270    } else {
271        chars.all(is_identifier_part)
272    }
273}
274
275#[test]
276fn is_identifier_name_true() {
277    let cases = [
278        // 1 char ASCII
279        "a",
280        "z",
281        "A",
282        "Z",
283        "_",
284        "$",
285        // 1 char Unicode
286        "µ", // 2 bytes
287        "ख", // 3 bytes
288        "𐀀", // 4 bytes
289        // Multiple chars ASCII
290        "az",
291        "AZ",
292        "_a",
293        "$Z",
294        "a0",
295        "A9",
296        "_0",
297        "$9",
298        "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$",
299        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_$",
300        "_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$",
301        "$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_",
302        // Multiple chars Unicode
303        "µख𐀀",
304        // ASCII + Unicode, starting with ASCII
305        "AµBखC𐀀D",
306        // ASCII + Unicode, starting with Unicode
307        "µAखB𐀀",
308    ];
309
310    for str in cases {
311        assert!(is_identifier_name(str));
312    }
313}
314
315#[test]
316fn is_identifier_name_false() {
317    let cases = [
318        // Empty string
319        "",
320        // 1 char ASCII
321        "0",
322        "9",
323        "-",
324        "~",
325        "+",
326        // 1 char Unicode
327        "£", // 2 bytes
328        "৸", // 3 bytes
329        "𐄬", // 4 bytes
330        // Multiple chars ASCII
331        "0a",
332        "9a",
333        "-a",
334        "+a",
335        "a-Z",
336        "A+z",
337        "a-",
338        "a+",
339        // Multiple chars Unicode
340        "£৸𐄬",
341        // ASCII + Unicode, starting with ASCII
342        "A£",
343        "A৸",
344        "A𐄬",
345        "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$abc£",
346        "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$abc৸",
347        "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$abc𐄬",
348        "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$abc£abcdefghijklmnopqrstuvwxyz",
349        "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$abc৸abcdefghijklmnopqrstuvwxyz",
350        "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$abc𐄬abcdefghijklmnopqrstuvwxyz",
351        // ASCII + Unicode, starting with Unicode
352        "£A",
353        "৸A",
354        "𐄬A",
355    ];
356
357    for str in cases {
358        assert!(!is_identifier_name(str));
359    }
360}
361
362#[test]
363fn is_identifier_name_patched_rejects_katakana_dots() {
364    // Katakana middle dots are valid identifier parts per Unicode 15+,
365    // but we reject them in the patched version for compat with Unicode 4.1-15.
366    // U+30FB KATAKANA MIDDLE DOT
367    assert!(is_identifier_name("x\u{30FB}"));
368    assert!(!is_identifier_name_patched("x\u{30FB}"));
369    // U+FF65 HALFWIDTH KATAKANA MIDDLE DOT
370    assert!(is_identifier_name("x\u{FF65}"));
371    assert!(!is_identifier_name_patched("x\u{FF65}"));
372    // As start character (neither is a valid start, so both should reject)
373    assert!(!is_identifier_name("\u{30FB}"));
374    assert!(!is_identifier_name_patched("\u{30FB}"));
375    // Normal identifiers still work
376    assert!(is_identifier_name_patched("foo"));
377    assert!(is_identifier_name_patched("_bar"));
378    assert!(is_identifier_name_patched("$baz"));
379    assert!(is_identifier_name_patched("µ"));
380    // Empty string rejected
381    assert!(!is_identifier_name_patched(""));
382}