1const REGEX_PREFIX_KEYWORDS: &[&str] = &[
16 "return",
17 "typeof",
18 "case",
19 "in",
20 "of",
21 "new",
22 "delete",
23 "void",
24 "throw",
25 "do",
26 "else",
27 "yield",
28 "await",
29 "instanceof",
30];
31
32#[must_use]
48pub fn minify_js(js: &str) -> String {
49 let src: Vec<char> = js.chars().collect();
50 let mut m = Minifier {
51 src: &src,
52 out: String::with_capacity(js.len()),
53 gap: Gap::None,
54 after_literal: false,
55 };
56 let mut i = 0;
57 while i < src.len() {
58 i = m.step(i);
59 }
60 m.out
61}
62
63#[derive(Clone, Copy, PartialEq, Eq)]
65enum Gap {
66 None,
67 Space,
68 Newline,
69}
70
71struct Minifier<'a> {
72 src: &'a [char],
73 out: String,
74 gap: Gap,
75 after_literal: bool,
77}
78
79impl Minifier<'_> {
80 fn step(&mut self, i: usize) -> usize {
82 let c = self.src[i];
83 let next = self.src.get(i + 1).copied();
84 match c {
85 '/' if next == Some('/') => {
86 skip_while(self.src, i, |c| !is_line_break(c))
87 }
88 '/' if next == Some('*') => self.skip_block_comment(i),
89 '\'' | '"' => self.copy_literal(i, string_end(self.src, i)),
90 '`' => self.copy_literal(i, template_end(self.src, i)),
91 '/' if self.regex_allowed() => {
92 self.copy_literal(i, regex_end(self.src, i))
93 }
94 c if c.is_whitespace() => {
95 self.widen_gap(is_line_break(c));
96 i + 1
97 }
98 _ => {
99 self.flush_gap(c);
100 self.out.push(c);
101 self.after_literal = false;
102 i + 1
103 }
104 }
105 }
106
107 fn widen_gap(&mut self, line_break: bool) {
110 if line_break {
111 self.gap = Gap::Newline;
112 } else if self.gap == Gap::None {
113 self.gap = Gap::Space;
114 }
115 }
116
117 fn skip_block_comment(&mut self, i: usize) -> usize {
119 let mut j = i + 2;
120 while j < self.src.len()
121 && !(self.src[j] == '*' && self.src.get(j + 1) == Some(&'/'))
122 {
123 j += 1;
124 }
125 let breaks = self.src[i..j.min(self.src.len())]
126 .iter()
127 .any(|&c| is_line_break(c));
128 self.widen_gap(breaks);
129 (j + 2).min(self.src.len())
130 }
131
132 fn copy_literal(&mut self, start: usize, end: usize) -> usize {
133 self.flush_gap(self.src[start]);
134 self.out.extend(&self.src[start..end]);
135 self.after_literal = true;
136 end
137 }
138
139 fn flush_gap(&mut self, next: char) {
143 let gap = std::mem::replace(&mut self.gap, Gap::None);
144 let Some(prev) = self.out.chars().next_back() else {
145 return;
146 };
147 if gap == Gap::Newline
148 && self.may_end_statement()
149 && !continues_statement(next)
150 {
151 self.out.push('\n');
152 } else if gap != Gap::None && needs_separator(prev, next) {
153 self.out.push(if gap == Gap::Newline { '\n' } else { ' ' });
154 }
155 }
156
157 fn may_end_statement(&self) -> bool {
163 self.after_literal
164 || self.out.ends_with("++")
165 || self.out.ends_with("--")
166 || self
167 .out
168 .chars()
169 .next_back()
170 .is_some_and(|c| is_word(c) || matches!(c, ')' | ']' | '}'))
171 }
172
173 fn regex_allowed(&self) -> bool {
177 if self.after_literal {
178 return false;
179 }
180 match self.out.chars().next_back() {
181 None => true,
182 Some(c) if is_word(c) => {
183 REGEX_PREFIX_KEYWORDS.contains(&trailing_word(&self.out))
184 }
185 Some(')' | ']' | '}') => false,
186 Some(_) => !(self.out.ends_with("++") || self.out.ends_with("--")),
188 }
189 }
190}
191
192fn is_word(c: char) -> bool {
193 c.is_alphanumeric() || c == '_' || c == '$'
194}
195
196const fn is_line_break(c: char) -> bool {
197 matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')
198}
199
200fn needs_separator(prev: char, next: char) -> bool {
204 (is_word(prev) && is_word(next))
205 || (prev == next && matches!(prev, '+' | '-' | '/'))
206 || (prev.is_ascii_digit() && next == '.')
207}
208
209const fn continues_statement(next: char) -> bool {
215 matches!(
216 next,
217 '.' | ','
218 | ';'
219 | ')'
220 | ']'
221 | '}'
222 | '?'
223 | ':'
224 | '='
225 | '*'
226 | '%'
227 | '&'
228 | '|'
229 | '^'
230 | '<'
231 | '>'
232 )
233}
234
235fn trailing_word(out: &str) -> &str {
237 let start = out
238 .char_indices()
239 .rev()
240 .take_while(|&(_, c)| is_word(c))
241 .last()
242 .map_or(out.len(), |(i, _)| i);
243 &out[start..]
244}
245
246fn skip_while(src: &[char], i: usize, keep: impl Fn(char) -> bool) -> usize {
248 src[i..]
249 .iter()
250 .position(|&c| !keep(c))
251 .map_or(src.len(), |n| i + n)
252}
253
254const fn string_end(src: &[char], i: usize) -> usize {
257 let quote = src[i];
258 let mut j = i + 1;
259 while j < src.len() {
260 match src[j] {
261 '\\' => j += 2,
262 c if c == quote => return j + 1,
263 c if is_line_break(c) => return j,
264 _ => j += 1,
265 }
266 }
267 src.len()
268}
269
270fn template_end(src: &[char], i: usize) -> usize {
274 let mut j = i + 1;
275 while j < src.len() {
276 match src[j] {
277 '\\' => j += 2,
278 '`' => return j + 1,
279 '$' if src.get(j + 1) == Some(&'{') => {
280 j = substitution_end(src, j + 2);
281 }
282 _ => j += 1,
283 }
284 }
285 src.len()
286}
287
288fn substitution_end(src: &[char], i: usize) -> usize {
291 let mut depth = 1usize;
292 let mut j = i;
293 while j < src.len() {
294 match src[j] {
295 '{' => depth += 1,
296 '}' if depth == 1 => return j + 1,
297 '}' => depth -= 1,
298 '\'' | '"' => j = string_end(src, j) - 1,
299 '`' => j = template_end(src, j) - 1,
300 _ => {}
301 }
302 j += 1;
303 }
304 src.len()
305}
306
307const fn regex_end(src: &[char], i: usize) -> usize {
311 let mut j = i + 1;
312 let mut in_class = false;
313 while j < src.len() {
314 match src[j] {
315 '\\' => j += 1,
316 '[' => in_class = true,
317 ']' => in_class = false,
318 '/' if !in_class => return j + 1,
319 c if is_line_break(c) => return j,
320 _ => {}
321 }
322 j += 1;
323 }
324 src.len()
325}
326
327#[cfg(test)]
328#[path = "js_minify_tests.rs"]
329mod tests;