1use 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#[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 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 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 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
136fn 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 return !matches!(*s, ")" | "]" | "}" | "{" | ";" | "++" | "--");
166 }
167 TokenKind::StrLit(_)
168 | TokenKind::Template(_)
169 | TokenKind::Regex(_)
170 | TokenKind::Preproc(_) => return false,
171 }
172 }
173 false
175}
176
177fn regex_is_expected(prev_tokens: &[Token<'_>], last_close: BraceKind) -> bool {
182 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 if *s == "}" {
210 return last_close == BraceKind::Block;
211 }
212 return !matches!(*s, ")" | "]" | "++" | "--");
215 }
216 TokenKind::StrLit(_)
217 | TokenKind::Template(_)
218 | TokenKind::Regex(_)
219 | TokenKind::Preproc(_) => return false,
220 }
221 }
222 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 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 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 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 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 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 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 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 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 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 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}