Skip to main content

brief/minify/
javascript.rs

1//! JavaScript / TypeScript minifier.
2//!
3//! The JS lexer is the most complex of the v0.3 set:
4//!
5//! - **Template literals** `` `…${expr}…${expr}…` ``. The body is
6//!   literal except inside `${…}` interpolations, which contain
7//!   arbitrary JS code (and may themselves contain template literals,
8//!   recursively). We track interpolation by scanning for `${`, then
9//!   counting `{`/`}` until the brace balance returns to zero.
10//! - **Regex literals** `/pattern/flags`. Lexically ambiguous with
11//!   division. We disambiguate via the previous-significant-token
12//!   heuristic: regex iff the previous non-whitespace, non-comment
13//!   token was a punctuator (other than `)`/`]`/`++`/`--`) or one
14//!   of the expression-position keywords (`return`, `typeof`, `in`,
15//!   `of`, `delete`, `void`, `new`, `throw`, `await`, `yield`,
16//!   `instanceof`, `case`, `do`, `else`). A previous `}` is ambiguous
17//!   on its own: a statement-block `}` is followed by regex, an
18//!   object-literal (or arrow-body) `}` by division. We track every
19//!   `{`'s kind on a stack, classified by the token before it.
20//! - **ASI**. JavaScript can implicitly insert `;` at end of certain
21//!   lines. Stripping such newlines without inserting an explicit `;`
22//!   changes semantics (`return\n{x:1}` returns undefined; `return{x:1}`
23//!   returns the object). Without a real parser we **preserve newlines**
24//!   verbatim and trust the engine's ASI rules.
25//! - TypeScript adds type syntax (`x: T`, `<T>`, `as T`) but the
26//!   tokenization is unchanged from JS, so the same lexer handles both.
27//!
28//! Strategy: conservative (Strategy B). Newlines preserved.
29
30use super::c_common::{Token, TokenKind, emit_conservative};
31use super::{MinifyError, MinifyOptions, MinifyOutput};
32
33pub fn minify(source: &str, opts: &MinifyOptions) -> Result<MinifyOutput, MinifyError> {
34    let toks = tokenize(source)?;
35    emit_conservative(&toks, opts.keep_comments)
36}
37
38/// What a `{` opened — decides whether the matching `}` ends a statement
39/// (regex may follow) or an expression (division may follow).
40#[derive(Clone, Copy, PartialEq)]
41enum BraceKind {
42    Block,
43    Object,
44}
45
46fn tokenize(src: &str) -> Result<Vec<Token<'_>>, MinifyError> {
47    let bytes = src.as_bytes();
48    let mut out: Vec<Token<'_>> = Vec::new();
49    let mut brace_stack: Vec<BraceKind> = Vec::new();
50    // Kind of the most recent `}` token. Only consulted when that `}` is
51    // still the previous significant token, so one slot is enough.
52    let mut last_close = BraceKind::Block;
53    let mut i = 0usize;
54    while i < bytes.len() {
55        let c = bytes[i];
56        if matches!(c, b' ' | b'\t' | b'\r') {
57            i += 1;
58            continue;
59        }
60        if c == b'\n' {
61            out.push(Token::new(TokenKind::Newline));
62            i += 1;
63            continue;
64        }
65        if c == b'/' && peek(bytes, i + 1) == Some(b'/') {
66            let start = i + 2;
67            let mut j = start;
68            while j < bytes.len() && bytes[j] != b'\n' {
69                j += 1;
70            }
71            out.push(Token::new(TokenKind::LineComment(&src[start..j])));
72            i = j;
73            continue;
74        }
75        if c == b'/' && peek(bytes, i + 1) == Some(b'*') {
76            let body_start = i + 2;
77            let mut j = body_start;
78            let mut found = false;
79            while j + 1 < bytes.len() {
80                if bytes[j] == b'*' && bytes[j + 1] == b'/' {
81                    found = true;
82                    break;
83                }
84                j += 1;
85            }
86            if !found {
87                return Err(MinifyError::new("unterminated /* */ block comment"));
88            }
89            out.push(Token::new(TokenKind::BlockComment(&src[body_start..j])));
90            i = j + 2;
91            continue;
92        }
93        // Regex disambiguation: a `/` may start a regex or be division.
94        if c == b'/' && regex_is_expected(&out, last_close) {
95            let n = scan_regex(src, i)?;
96            out.push(Token::new(TokenKind::Regex(&src[i..i + n])));
97            i += n;
98            continue;
99        }
100        if c == b'"' || c == b'\'' {
101            let n = scan_quoted_string(src, i, c)?;
102            out.push(Token::new(TokenKind::StrLit(&src[i..i + n])));
103            i += n;
104            continue;
105        }
106        if c == b'`' {
107            let n = scan_template(src, i)?;
108            out.push(Token::new(TokenKind::Template(&src[i..i + n])));
109            i += n;
110            continue;
111        }
112        if is_word_start(src, i) {
113            let n = scan_word(src, i);
114            out.push(Token::new(TokenKind::Word(&src[i..i + n])));
115            i += n;
116            continue;
117        }
118        let n = scan_multi_punct(bytes, i);
119        let punct = &src[i..i + n];
120        if punct == "{" {
121            brace_stack.push(if open_brace_is_object(&out) {
122                BraceKind::Object
123            } else {
124                BraceKind::Block
125            });
126        } else if punct == "}" {
127            // Unbalanced `}` (mid-snippet input): assume statement position.
128            last_close = brace_stack.pop().unwrap_or(BraceKind::Block);
129        }
130        out.push(Token::new(TokenKind::Punct(punct)));
131        i += n;
132    }
133    Ok(out)
134}
135
136/// Is a `{` here an object literal (expression) rather than a statement
137/// block? Also groups arrow bodies (`=> {`) with expressions, because their
138/// closing `}` ends an expression either way.
139fn open_brace_is_object(prev_tokens: &[Token<'_>]) -> bool {
140    for tok in prev_tokens.iter().rev() {
141        match &tok.kind {
142            TokenKind::LineComment(_) | TokenKind::BlockComment(_) | TokenKind::Newline => continue,
143            TokenKind::Word(s) => {
144                return matches!(
145                    *s,
146                    "return"
147                        | "typeof"
148                        | "in"
149                        | "of"
150                        | "delete"
151                        | "void"
152                        | "new"
153                        | "throw"
154                        | "await"
155                        | "yield"
156                        | "instanceof"
157                        | "case"
158                );
159            }
160            TokenKind::Punct(s) => {
161                // After a closed group, another brace, `;`, or postfix
162                // operator, a `{` starts a statement block (function/if/for
163                // bodies arrive via `) {`). Anywhere else — after `=`, `(`,
164                // `,`, `:`, `[`, binary operators, `=>` — it's an expression.
165                return !matches!(*s, ")" | "]" | "}" | "{" | ";" | "++" | "--");
166            }
167            TokenKind::StrLit(_)
168            | TokenKind::Template(_)
169            | TokenKind::Regex(_)
170            | TokenKind::Preproc(_) => return false,
171        }
172    }
173    // Start of source: statement position.
174    false
175}
176
177/// Heuristic: should a `/` here start a regex literal? Yes iff the previous
178/// "significant" token (skipping whitespace/comments/newlines) is one
179/// after which an expression is expected. `last_close` is the kind of the
180/// most recent `}`, used when that `}` is the previous significant token.
181fn regex_is_expected(prev_tokens: &[Token<'_>], last_close: BraceKind) -> bool {
182    // Scan backward past comments/newlines.
183    for tok in prev_tokens.iter().rev() {
184        match &tok.kind {
185            TokenKind::LineComment(_) | TokenKind::BlockComment(_) | TokenKind::Newline => continue,
186            TokenKind::Word(s) => {
187                return matches!(
188                    *s,
189                    "return"
190                        | "typeof"
191                        | "in"
192                        | "of"
193                        | "delete"
194                        | "void"
195                        | "new"
196                        | "throw"
197                        | "await"
198                        | "yield"
199                        | "instanceof"
200                        | "case"
201                        | "do"
202                        | "else"
203                );
204            }
205            TokenKind::Punct(s) => {
206                // A statement-block `}` is followed by a new statement, which
207                // may open with a regex; an object-literal/arrow-body `}`
208                // ends an expression, so `/` is division.
209                if *s == "}" {
210                    return last_close == BraceKind::Block;
211                }
212                // After `)`, `]`, `++`, `--`, an expression has ended;
213                // `/` is division. Anything else, expect regex.
214                return !matches!(*s, ")" | "]" | "++" | "--");
215            }
216            TokenKind::StrLit(_)
217            | TokenKind::Template(_)
218            | TokenKind::Regex(_)
219            | TokenKind::Preproc(_) => return false,
220        }
221    }
222    // No previous significant token: top of source. An expression at the
223    // very start could begin with a regex literal (rare but legal).
224    true
225}
226
227fn scan_regex(src: &str, i: usize) -> Result<usize, MinifyError> {
228    let bytes = src.as_bytes();
229    debug_assert_eq!(bytes[i], b'/');
230    let mut j = i + 1;
231    let mut in_class = false;
232    while j < bytes.len() {
233        match bytes[j] {
234            b'\\' => {
235                j += 2;
236                continue;
237            }
238            b'[' => {
239                in_class = true;
240                j += 1;
241            }
242            b']' if in_class => {
243                in_class = false;
244                j += 1;
245            }
246            b'/' if !in_class => {
247                // skip closing /, then flags (Latin letters)
248                j += 1;
249                while j < bytes.len() && bytes[j].is_ascii_alphabetic() {
250                    j += 1;
251                }
252                return Ok(j - i);
253            }
254            b'\n' => return Err(MinifyError::new("newline in regex literal")),
255            _ => j += 1,
256        }
257    }
258    Err(MinifyError::new("unterminated regex literal"))
259}
260
261fn scan_quoted_string(src: &str, i: usize, quote: u8) -> Result<usize, MinifyError> {
262    let bytes = src.as_bytes();
263    debug_assert_eq!(bytes[i], quote);
264    let mut j = i + 1;
265    while j < bytes.len() {
266        if bytes[j] == b'\\' {
267            // Line continuation: `\<nl>` is allowed in JS strings.
268            if peek(bytes, j + 1) == Some(b'\n') {
269                j += 2;
270                continue;
271            }
272            j += 2;
273            continue;
274        }
275        if bytes[j] == quote {
276            return Ok(j + 1 - i);
277        }
278        if bytes[j] == b'\n' {
279            return Err(MinifyError::new("newline in string literal"));
280        }
281        j += 1;
282    }
283    Err(MinifyError::new("unterminated string literal"))
284}
285
286fn scan_template(src: &str, i: usize) -> Result<usize, MinifyError> {
287    let bytes = src.as_bytes();
288    debug_assert_eq!(bytes[i], b'`');
289    let mut j = i + 1;
290    while j < bytes.len() {
291        match bytes[j] {
292            b'\\' => {
293                j += 2;
294            }
295            b'`' => return Ok(j + 1 - i),
296            b'$' if peek(bytes, j + 1) == Some(b'{') => {
297                // Skip `${`, then content until matching `}` (counting
298                // braces, accounting for nested templates/strings).
299                j += 2;
300                let mut depth = 1usize;
301                while j < bytes.len() && depth > 0 {
302                    match bytes[j] {
303                        b'{' => {
304                            depth += 1;
305                            j += 1;
306                        }
307                        b'}' => {
308                            depth -= 1;
309                            j += 1;
310                        }
311                        b'`' => {
312                            // Nested template literal — recurse.
313                            let inner = scan_template(src, j)?;
314                            j += inner;
315                        }
316                        b'"' | b'\'' => {
317                            let q = bytes[j];
318                            j += scan_quoted_string(src, j, q)?;
319                        }
320                        b'/' if peek(bytes, j + 1) == Some(b'/') => {
321                            while j < bytes.len() && bytes[j] != b'\n' {
322                                j += 1;
323                            }
324                        }
325                        b'/' if peek(bytes, j + 1) == Some(b'*') => {
326                            j += 2;
327                            while j + 1 < bytes.len() && !(bytes[j] == b'*' && bytes[j + 1] == b'/')
328                            {
329                                j += 1;
330                            }
331                            if j + 1 >= bytes.len() {
332                                return Err(MinifyError::new("unterminated /* */ inside template"));
333                            }
334                            j += 2;
335                        }
336                        b'\\' => {
337                            j += 2;
338                        }
339                        _ => j += 1,
340                    }
341                }
342                if depth != 0 {
343                    return Err(MinifyError::new("unterminated `${…}` in template"));
344                }
345            }
346            _ => j += 1,
347        }
348    }
349    Err(MinifyError::new("unterminated template literal"))
350}
351
352fn is_word_start(src: &str, i: usize) -> bool {
353    let c = char_at(src, i);
354    c.is_alphabetic() || c == '_' || c == '$' || c.is_ascii_digit()
355}
356
357fn scan_word(src: &str, i: usize) -> usize {
358    let bytes = src.as_bytes();
359    let mut j = i;
360    while j < bytes.len() {
361        let c = char_at(src, j);
362        if c.is_alphanumeric() || c == '_' || c == '$' {
363            j += c.len_utf8();
364            continue;
365        }
366        if c == '.' {
367            // Decimal: 1.5; scientific: 1e3 (handled by alnum already).
368            let next = peek(bytes, j + 1);
369            if matches!(next, Some(b'0'..=b'9')) && j > i {
370                j += 1;
371                continue;
372            }
373        }
374        break;
375    }
376    j - i
377}
378
379fn scan_multi_punct(bytes: &[u8], i: usize) -> usize {
380    let four = bytes
381        .get(i..i + 4)
382        .map(|s| std::str::from_utf8(s).unwrap_or(""))
383        .unwrap_or("");
384    let three = bytes
385        .get(i..i + 3)
386        .map(|s| std::str::from_utf8(s).unwrap_or(""))
387        .unwrap_or("");
388    let two = bytes
389        .get(i..i + 2)
390        .map(|s| std::str::from_utf8(s).unwrap_or(""))
391        .unwrap_or("");
392    if matches!(four, ">>>=") {
393        return 4;
394    }
395    if matches!(
396        three,
397        "===" | "!==" | "..." | ">>>" | "**=" | "<<=" | ">>=" | "??="
398    ) {
399        return 3;
400    }
401    if matches!(
402        two,
403        "=>" | "=="
404            | "!="
405            | "<="
406            | ">="
407            | "&&"
408            | "||"
409            | "??"
410            | "?."
411            | "++"
412            | "--"
413            | "<<"
414            | ">>"
415            | "**"
416            | "+="
417            | "-="
418            | "*="
419            | "/="
420            | "%="
421            | "&="
422            | "|="
423            | "^="
424            | "&&="
425            | "||="
426    ) {
427        return 2;
428    }
429    let c = char_at(unsafe { std::str::from_utf8_unchecked(bytes) }, i);
430    c.len_utf8()
431}
432
433fn peek(bytes: &[u8], i: usize) -> Option<u8> {
434    bytes.get(i).copied()
435}
436
437fn char_at(src: &str, i: usize) -> char {
438    src[i..].chars().next().unwrap_or('\0')
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    fn min(s: &str) -> String {
446        minify(s, &MinifyOptions::default()).unwrap().body
447    }
448
449    #[test]
450    fn basic_function() {
451        let src = "function add(a, b) {\n    return a + b;\n}\n";
452        let out = min(src);
453        // Newlines preserved (ASI), horizontal ws stripped.
454        assert_eq!(out, "function add(a,b){\nreturn a+b;\n}\n");
455    }
456
457    #[test]
458    fn strips_line_comment() {
459        let src = "// hi\nlet x = 1;\n";
460        let out = min(src);
461        assert_eq!(out, "\nlet x=1;\n");
462    }
463
464    #[test]
465    fn strips_block_comment_inline() {
466        let src = "let x = /* y */ 1;\n";
467        let out = min(src);
468        assert_eq!(out, "let x=1;\n");
469    }
470
471    #[test]
472    fn template_literal() {
473        let src = "const s = `hello, ${name}!`;\n";
474        let out = min(src);
475        assert!(out.contains("`hello, ${name}!`"), "got: {}", out);
476    }
477
478    #[test]
479    fn nested_template() {
480        let src = "const s = `a${`b${c}d`}e`;\n";
481        let out = min(src);
482        assert!(out.contains("`a${`b${c}d`}e`"), "got: {}", out);
483    }
484
485    #[test]
486    fn template_with_string_in_interpolation() {
487        let src = "const s = `${\"hi\"}`;\n";
488        let out = min(src);
489        assert!(out.contains("`${\"hi\"}`"), "got: {}", out);
490    }
491
492    #[test]
493    fn regex_literal() {
494        let src = "const re = /[a-z]+/gi;\n";
495        let out = min(src);
496        assert_eq!(out, "const re=/[a-z]+/gi;\n");
497    }
498
499    #[test]
500    fn regex_after_return() {
501        let src = "function f() { return /\\d+/.test(x); }\n";
502        let out = min(src);
503        assert!(out.contains("/\\d+/"), "got: {}", out);
504    }
505
506    #[test]
507    fn division_after_value() {
508        let src = "const x = a / b;\n";
509        let out = min(src);
510        assert_eq!(out, "const x=a/b;\n");
511    }
512
513    #[test]
514    fn division_after_paren() {
515        let src = "const x = (a + b) / c;\n";
516        let out = min(src);
517        assert_eq!(out, "const x=(a+b)/c;\n");
518    }
519
520    #[test]
521    fn return_then_object_preserves_newline() {
522        // ASI hazard: `return\n{x:1}` returns undefined. Stripping the
523        // newline would change behavior. Conservative emitter preserves.
524        let src = "function f() {\n    return\n    {x: 1};\n}\n";
525        let out = min(src);
526        assert!(
527            out.contains("return\n"),
528            "newline preserved after return: {:?}",
529            out
530        );
531    }
532
533    #[test]
534    fn arrow_function() {
535        let src = "const f = (x) => x + 1;\n";
536        let out = min(src);
537        assert_eq!(out, "const f=(x)=>x+1;\n");
538    }
539
540    #[test]
541    fn nullish_coalescing() {
542        let src = "const x = a ?? b;\n";
543        let out = min(src);
544        assert_eq!(out, "const x=a??b;\n");
545    }
546
547    #[test]
548    fn optional_chaining() {
549        let src = "const x = obj?.prop;\n";
550        let out = min(src);
551        assert_eq!(out, "const x=obj?.prop;\n");
552    }
553
554    #[test]
555    fn strict_equality() {
556        let src = "if (a === b) {}\n";
557        let out = min(src);
558        assert_eq!(out, "if(a===b){}\n");
559    }
560
561    #[test]
562    fn typescript_type_annotation() {
563        let src = "function f(x: number): string { return String(x); }\n";
564        let out = min(src);
565        // Newline-free single-line input → single-line output.
566        assert_eq!(out, "function f(x:number):string{return String(x);}\n");
567    }
568
569    #[test]
570    fn typescript_generic() {
571        let src = "function f<T>(x: T): T { return x; }\n";
572        let out = min(src);
573        assert_eq!(out, "function f<T>(x:T):T{return x;}\n");
574    }
575
576    #[test]
577    fn double_quoted_string_with_escape() {
578        let src = "const s = \"a\\\"b\";\n";
579        let out = min(src);
580        assert_eq!(out, "const s=\"a\\\"b\";\n");
581    }
582
583    #[test]
584    fn dollar_in_identifier() {
585        let src = "const $foo = 1;\n";
586        let out = min(src);
587        assert_eq!(out, "const $foo=1;\n");
588    }
589
590    #[test]
591    fn keep_comments_converts_line() {
592        let src = "// hi\nlet x = 1;\n";
593        let r = minify(
594            src,
595            &MinifyOptions {
596                keep_comments: true,
597            },
598        )
599        .unwrap();
600        assert!(r.body.contains("/* hi*/"));
601        assert_eq!(r.warnings.len(), 1);
602    }
603
604    #[test]
605    fn unterminated_string() {
606        assert!(minify("const s = \"oops", &MinifyOptions::default()).is_err());
607    }
608
609    #[test]
610    fn unterminated_template() {
611        assert!(minify("const s = `oops", &MinifyOptions::default()).is_err());
612    }
613
614    #[test]
615    fn unterminated_regex() {
616        assert!(minify("const r = /oops", &MinifyOptions::default()).is_err());
617    }
618
619    #[test]
620    fn regex_after_statement_block_brace() {
621        // A `}` closing a statement block may be followed by a regex; the
622        // whitespace inside it must survive.
623        let src = "if (c) { g() } /foo  bar/.exec(s)\n";
624        let out = min(src);
625        assert!(out.contains("/foo  bar/"), "got: {}", out);
626    }
627
628    #[test]
629    fn regex_after_else_block_brace() {
630        let src = "if (c) { g() } else { h() } /foo  bar/.exec(s)\n";
631        let out = min(src);
632        assert!(out.contains("/foo  bar/"), "got: {}", out);
633    }
634
635    #[test]
636    fn division_after_object_literal_brace() {
637        // A `}` closing an object literal ends an expression; `/` here is
638        // division and must not swallow code as a regex body.
639        let src = "const x = {a: 1} / b / c;\n";
640        let out = min(src);
641        assert_eq!(out, "const x={a:1}/b/c;\n");
642    }
643
644    #[test]
645    fn nested_blocks_and_object_braces() {
646        let src = "function f() { const o = {a: 1}; if (o) { g() } } /re  x/.test(s)\n";
647        let out = min(src);
648        assert!(out.contains("/re  x/"), "got: {}", out);
649    }
650
651    #[test]
652    fn regex_with_class() {
653        let src = "const r = /[/]/g;\n";
654        let out = min(src);
655        assert!(out.contains("/[/]/g"), "got: {}", out);
656    }
657
658    #[test]
659    fn regex_at_start_of_file() {
660        let src = "/abc/.test(s)\n";
661        let out = min(src);
662        assert!(out.starts_with("/abc/"), "got: {}", out);
663    }
664}