Skip to main content

brief/minify/
c_cpp.rs

1//! C and C++ minifier.
2//!
3//! Distinguishing features:
4//!
5//! - Preprocessor directives (`#include`, `#define`, …) are
6//!   line-sensitive — the C preprocessor reads input line-by-line and
7//!   directives must end at a newline (or be continued via `\<nl>`). We
8//!   tokenize each `#`-line as a single `Preproc` token and the emitter
9//!   places it on its own line.
10//! - String prefixes: `L"…"`, `u8"…"`, `u"…"`, `U"…"` (wide / UTF
11//!   literals).
12//! - C++ raw strings `R"delim(…)delim"` (and prefixed forms like
13//!   `LR"delim(…)delim"`, `u8R"…"`, etc.) — only when `is_cpp` is true.
14//! - Block comments do **not** nest.
15//!
16//! Strategy: aggressive (Strategy A) **between** preprocessor lines.
17//! Around `#`-lines we still emit a leading and trailing newline.
18//!
19//! `is_cpp` toggles the C++-only features (raw strings). For C tags
20//! (`c`, `h`) it's false; for C++ tags (`cpp`, `c++`, `cc`, `cxx`,
21//! `hpp`, `hxx`) it's true.
22
23use super::c_common::{Token, TokenKind, safe_block_comment};
24use super::{MinifyError, MinifyOptions, MinifyOutput, MinifyWarning};
25
26pub fn minify(
27    source: &str,
28    opts: &MinifyOptions,
29    is_cpp: bool,
30) -> Result<MinifyOutput, MinifyError> {
31    let toks = tokenize(source, is_cpp)?;
32    emit(&toks, opts.keep_comments)
33}
34
35fn tokenize(src: &str, is_cpp: bool) -> Result<Vec<Token<'_>>, MinifyError> {
36    let bytes = src.as_bytes();
37    let mut out: Vec<Token<'_>> = Vec::new();
38    let mut i = 0usize;
39    let mut at_line_start = true;
40    while i < bytes.len() {
41        let c = bytes[i];
42        if matches!(c, b' ' | b'\t' | b'\r') {
43            i += 1;
44            continue;
45        }
46        if c == b'\n' {
47            out.push(Token::new(TokenKind::Newline));
48            i += 1;
49            at_line_start = true;
50            continue;
51        }
52        // Preprocessor directive: `#` at start of line (after optional
53        // whitespace) — already consumed above. Captures everything up to
54        // the newline, plus any backslash-newline continuations.
55        if at_line_start && c == b'#' {
56            let start = i;
57            let mut j = i;
58            while j < bytes.len() {
59                if bytes[j] == b'\\' && peek(bytes, j + 1) == Some(b'\n') {
60                    j += 2;
61                    continue;
62                }
63                if bytes[j] == b'\\'
64                    && peek(bytes, j + 1) == Some(b'\r')
65                    && peek(bytes, j + 2) == Some(b'\n')
66                {
67                    j += 3;
68                    continue;
69                }
70                if bytes[j] == b'\n' {
71                    break;
72                }
73                j += 1;
74            }
75            out.push(Token::new(TokenKind::Preproc(&src[start..j])));
76            i = j;
77            // Don't consume the newline — let the main loop handle it so
78            // the emitter sees a Newline after the Preproc.
79            at_line_start = false;
80            continue;
81        }
82        at_line_start = false;
83        if c == b'/' && peek(bytes, i + 1) == Some(b'/') {
84            // Translation phase 2 splices `\<nl>` before comments are
85            // recognized, so a line comment ending in `\` swallows the next
86            // line too. Missing this would resurrect commented-out code.
87            let start = i + 2;
88            let mut j = start;
89            while j < bytes.len() {
90                if bytes[j] == b'\\' && peek(bytes, j + 1) == Some(b'\n') {
91                    j += 2;
92                    continue;
93                }
94                if bytes[j] == b'\\'
95                    && peek(bytes, j + 1) == Some(b'\r')
96                    && peek(bytes, j + 2) == Some(b'\n')
97                {
98                    j += 3;
99                    continue;
100                }
101                if bytes[j] == b'\n' {
102                    break;
103                }
104                j += 1;
105            }
106            out.push(Token::new(TokenKind::LineComment(&src[start..j])));
107            i = j;
108            continue;
109        }
110        if c == b'/' && peek(bytes, i + 1) == Some(b'*') {
111            let body_start = i + 2;
112            let mut j = body_start;
113            let mut found = false;
114            while j + 1 < bytes.len() {
115                if bytes[j] == b'*' && bytes[j + 1] == b'/' {
116                    found = true;
117                    break;
118                }
119                j += 1;
120            }
121            if !found {
122                return Err(MinifyError::new("unterminated /* */ block comment"));
123            }
124            out.push(Token::new(TokenKind::BlockComment(&src[body_start..j])));
125            i = j + 2;
126            continue;
127        }
128        // String / raw-string detection. C++ raw strings use the `R"d(…)d"`
129        // form; combinations include `LR`, `uR`, `UR`, `u8R`. Plain strings
130        // can be prefixed `L`, `u`, `u8`, `U`.
131        if let Some(n) = try_scan_string(src, i, is_cpp)? {
132            out.push(Token::new(TokenKind::StrLit(&src[i..i + n])));
133            i += n;
134            continue;
135        }
136        if c == b'\'' {
137            let n = scan_char_literal(src, i)?;
138            out.push(Token::new(TokenKind::StrLit(&src[i..i + n])));
139            i += n;
140            continue;
141        }
142        if is_word_start(src, i) {
143            let n = scan_word(src, i);
144            out.push(Token::new(TokenKind::Word(&src[i..i + n])));
145            i += n;
146            continue;
147        }
148        let n = scan_multi_punct(bytes, i);
149        out.push(Token::new(TokenKind::Punct(&src[i..i + n])));
150        i += n;
151    }
152    Ok(out)
153}
154
155fn emit(tokens: &[Token<'_>], keep_comments: bool) -> Result<MinifyOutput, MinifyError> {
156    let mut out = String::new();
157    let mut warnings: Vec<MinifyWarning> = Vec::new();
158    let mut prev_emit_last: Option<char> = None;
159    let mut last_was_preproc = false;
160    for tok in tokens {
161        match &tok.kind {
162            TokenKind::Newline => {
163                // Newlines are always discarded UNLESS the previous emitted
164                // token was a preprocessor line, in which case we keep one
165                // terminating newline so `#include <…>` is on its own line.
166                if last_was_preproc && !out.ends_with('\n') {
167                    out.push('\n');
168                    prev_emit_last = None;
169                    last_was_preproc = false;
170                }
171            }
172            TokenKind::LineComment(body) => {
173                if !keep_comments {
174                    continue;
175                }
176                let block = safe_block_comment(body);
177                push_with_space(&mut out, &mut prev_emit_last, &block);
178                warnings.push(MinifyWarning::LineCommentConverted);
179            }
180            TokenKind::BlockComment(body) => {
181                if !keep_comments {
182                    continue;
183                }
184                let block = safe_block_comment(body);
185                push_with_space(&mut out, &mut prev_emit_last, &block);
186            }
187            TokenKind::Word(s)
188            | TokenKind::Punct(s)
189            | TokenKind::StrLit(s)
190            | TokenKind::Template(s)
191            | TokenKind::Regex(s) => {
192                push_with_space(&mut out, &mut prev_emit_last, s);
193                last_was_preproc = false;
194            }
195            TokenKind::Preproc(s) => {
196                if !out.is_empty() && !out.ends_with('\n') {
197                    out.push('\n');
198                }
199                out.push_str(s);
200                prev_emit_last = None;
201                last_was_preproc = true;
202            }
203        }
204    }
205    if last_was_preproc && !out.ends_with('\n') {
206        out.push('\n');
207    }
208    Ok(MinifyOutput {
209        body: out,
210        warnings,
211    })
212}
213
214fn push_with_space(out: &mut String, prev_emit_last: &mut Option<char>, s: &str) {
215    if s.is_empty() {
216        return;
217    }
218    use super::c_common::needs_space;
219    if let Some(prev) = *prev_emit_last {
220        if let Some(next) = s.chars().next() {
221            if needs_space(prev, next) {
222                out.push(' ');
223            }
224        }
225    }
226    out.push_str(s);
227    *prev_emit_last = s.chars().next_back();
228}
229
230fn try_scan_string(src: &str, i: usize, is_cpp: bool) -> Result<Option<usize>, MinifyError> {
231    let bytes = src.as_bytes();
232    // Possible prefixes: `L`, `u`, `u8`, `U`. Each may also be combined
233    // with `R` for C++ raw strings (`LR`, `uR`, `u8R`, `UR`).
234    let mut p = i;
235    let mut had_prefix = false;
236    // u8 first
237    if peek(bytes, p) == Some(b'u') && peek(bytes, p + 1) == Some(b'8') {
238        // Could be u8" or u8R" — but only commit if a quote/R follows.
239        let after = p + 2;
240        if peek(bytes, after) == Some(b'"')
241            || (is_cpp && peek(bytes, after) == Some(b'R') && peek(bytes, after + 1) == Some(b'"'))
242        {
243            p = after;
244            had_prefix = true;
245        }
246    } else if matches!(peek(bytes, p), Some(b'L') | Some(b'u') | Some(b'U')) {
247        let after = p + 1;
248        if peek(bytes, after) == Some(b'"')
249            || (is_cpp && peek(bytes, after) == Some(b'R') && peek(bytes, after + 1) == Some(b'"'))
250        {
251            p = after;
252            had_prefix = true;
253        }
254    }
255    let raw = is_cpp && peek(bytes, p) == Some(b'R') && peek(bytes, p + 1) == Some(b'"');
256    if raw {
257        // R"delim(…)delim"
258        p += 1; // skip R
259        debug_assert_eq!(bytes[p], b'"');
260        let delim_start = p + 1;
261        let mut j = delim_start;
262        while j < bytes.len() && bytes[j] != b'(' {
263            j += 1;
264        }
265        if j >= bytes.len() {
266            return Err(MinifyError::new("malformed raw string"));
267        }
268        let delim = &bytes[delim_start..j];
269        let body_start = j + 1;
270        // find `)delim"`
271        let mut k = body_start;
272        loop {
273            if k >= bytes.len() {
274                return Err(MinifyError::new("unterminated raw string"));
275            }
276            if bytes[k] == b')' && k + 1 + delim.len() < bytes.len() {
277                if &bytes[k + 1..k + 1 + delim.len()] == delim
278                    && bytes.get(k + 1 + delim.len()) == Some(&b'"')
279                {
280                    let total = k + 1 + delim.len() + 1 - i;
281                    return Ok(Some(total));
282                }
283            }
284            k += 1;
285        }
286    }
287    if peek(bytes, p) == Some(b'"') {
288        let n = scan_dq_string(src, p)?;
289        return Ok(Some(p + n - i));
290    }
291    if had_prefix {
292        // We thought we had a prefix but no quote followed — back out.
293        return Ok(None);
294    }
295    Ok(None)
296}
297
298fn scan_dq_string(src: &str, i: usize) -> Result<usize, MinifyError> {
299    let bytes = src.as_bytes();
300    debug_assert_eq!(bytes[i], b'"');
301    let mut j = i + 1;
302    while j < bytes.len() {
303        match bytes[j] {
304            b'\\' => j += 2,
305            b'"' => return Ok(j + 1 - i),
306            b'\n' => return Err(MinifyError::new("newline in string literal")),
307            _ => j += 1,
308        }
309    }
310    Err(MinifyError::new("unterminated string literal"))
311}
312
313fn scan_char_literal(src: &str, i: usize) -> Result<usize, MinifyError> {
314    let bytes = src.as_bytes();
315    debug_assert_eq!(bytes[i], b'\'');
316    let mut j = i + 1;
317    while j < bytes.len() {
318        if bytes[j] == b'\\' {
319            j += 2;
320            continue;
321        }
322        if bytes[j] == b'\'' {
323            return Ok(j + 1 - i);
324        }
325        if bytes[j] == b'\n' {
326            return Err(MinifyError::new("newline in char literal"));
327        }
328        j += 1;
329    }
330    Err(MinifyError::new("unterminated char literal"))
331}
332
333fn is_word_start(src: &str, i: usize) -> bool {
334    let c = char_at(src, i);
335    c.is_alphabetic() || c == '_' || c.is_ascii_digit()
336}
337
338fn scan_word(src: &str, i: usize) -> usize {
339    let bytes = src.as_bytes();
340    let mut j = i;
341    while j < bytes.len() {
342        let c = char_at(src, j);
343        if c.is_alphanumeric() || c == '_' {
344            j += c.len_utf8();
345            continue;
346        }
347        if c == '.' {
348            let next = peek(bytes, j + 1);
349            if matches!(next, Some(b'0'..=b'9')) && j > i {
350                j += 1;
351                continue;
352            }
353        }
354        break;
355    }
356    j - i
357}
358
359fn scan_multi_punct(bytes: &[u8], i: usize) -> usize {
360    let three = bytes
361        .get(i..i + 3)
362        .map(|s| std::str::from_utf8(s).unwrap_or(""))
363        .unwrap_or("");
364    let two = bytes
365        .get(i..i + 2)
366        .map(|s| std::str::from_utf8(s).unwrap_or(""))
367        .unwrap_or("");
368    if matches!(three, "<<=" | ">>=" | "..." | "->*") {
369        return 3;
370    }
371    if matches!(
372        two,
373        "->" | "::"
374            | "=="
375            | "!="
376            | "<="
377            | ">="
378            | "&&"
379            | "||"
380            | "<<"
381            | ">>"
382            | "+="
383            | "-="
384            | "*="
385            | "/="
386            | "%="
387            | "&="
388            | "|="
389            | "^="
390            | "++"
391            | "--"
392            | ".*"
393    ) {
394        return 2;
395    }
396    let c = char_at(unsafe { std::str::from_utf8_unchecked(bytes) }, i);
397    c.len_utf8()
398}
399
400fn peek(bytes: &[u8], i: usize) -> Option<u8> {
401    bytes.get(i).copied()
402}
403
404fn char_at(src: &str, i: usize) -> char {
405    src[i..].chars().next().unwrap_or('\0')
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    fn min_c(s: &str) -> String {
413        minify(s, &MinifyOptions::default(), false).unwrap().body
414    }
415    fn min_cpp(s: &str) -> String {
416        minify(s, &MinifyOptions::default(), true).unwrap().body
417    }
418
419    #[test]
420    fn c_basic() {
421        let src = "int main() {\n    return 0;\n}\n";
422        assert_eq!(min_c(src), "int main(){return 0;}");
423    }
424
425    #[test]
426    fn c_preprocessor_kept_on_own_line() {
427        let src = "#include <stdio.h>\nint main() { return 0; }\n";
428        let out = min_c(src);
429        assert!(
430            out.starts_with("#include <stdio.h>\n"),
431            "preproc on own line: {:?}",
432            out
433        );
434        assert!(out.contains("int main(){return 0;}"));
435    }
436
437    #[test]
438    fn c_multiple_preprocessor_lines() {
439        let src = "#include <stdio.h>\n#include <stdlib.h>\nint x;\n";
440        let out = min_c(src);
441        assert_eq!(out, "#include <stdio.h>\n#include <stdlib.h>\nint x;");
442    }
443
444    #[test]
445    fn c_define_with_continuation() {
446        let src = "#define FOO(x) \\\n    do { x; } while (0)\nint y = 1;\n";
447        let out = min_c(src);
448        // The whole `#define` line, including its `\<nl>` continuations,
449        // is one Preproc token; the next `int y = 1;` is on a new line.
450        assert!(out.starts_with("#define FOO(x) \\\n    do { x; } while (0)\n"));
451        assert!(out.ends_with("int y=1;"));
452    }
453
454    #[test]
455    fn c_strips_line_comment() {
456        let src = "// hi\nint x;\n";
457        assert_eq!(min_c(src), "int x;");
458    }
459
460    #[test]
461    fn c_strips_block_comment() {
462        let src = "/* hi */ int x;\n";
463        assert_eq!(min_c(src), "int x;");
464    }
465
466    #[test]
467    fn cpp_template_double_close() {
468        // C++11+ parsers correctly disambiguate `>>` in template contexts
469        // from the right-shift operator, so collapsing the source `>> ` to
470        // `>>` is safe. The lexer emits `>>` as one Punct because the
471        // source already had them adjacent.
472        let src = "vector<vector<int>> v;";
473        let out = min_cpp(src);
474        assert_eq!(out, "vector<vector<int>>v;");
475    }
476
477    #[test]
478    fn cpp_template_with_space_at_close() {
479        // If the source separated the closing `> >` with a space, the
480        // lexer sees them as two separate Puncts; the emitter then injects
481        // a space because `>` `>` is in the dangerous-pair table.
482        let src = "vector<vector<int> > v;";
483        let out = min_cpp(src);
484        assert!(out.contains("> >"), "got: {}", out);
485    }
486
487    #[test]
488    fn cpp_raw_string() {
489        let src = r#"const char* s = R"x(hi)x";"#;
490        let out = min_cpp(src);
491        assert!(out.contains(r#"R"x(hi)x""#), "got: {}", out);
492    }
493
494    #[test]
495    fn cpp_wide_string() {
496        let src = "const wchar_t* s = L\"hi\";";
497        let out = min_cpp(src);
498        assert!(out.contains("L\"hi\""));
499    }
500
501    #[test]
502    fn cpp_u8_string() {
503        let src = "const char* s = u8\"hi\";";
504        let out = min_cpp(src);
505        assert!(out.contains("u8\"hi\""));
506    }
507
508    #[test]
509    fn cpp_arrow_member() {
510        let src = "p->x = 1;";
511        let out = min_cpp(src);
512        assert_eq!(out, "p->x=1;");
513    }
514
515    #[test]
516    fn cpp_scope_resolution() {
517        let src = "std::string s;";
518        let out = min_cpp(src);
519        assert_eq!(out, "std::string s;");
520    }
521
522    #[test]
523    fn c_keep_comments() {
524        let src = "// hi\nint x;\n";
525        let r = minify(
526            src,
527            &MinifyOptions {
528                keep_comments: true,
529            },
530            false,
531        )
532        .unwrap();
533        assert!(r.body.starts_with("/* hi*/"));
534        assert_eq!(r.warnings.len(), 1);
535    }
536
537    #[test]
538    fn c_line_comment_continuation_stays_dead() {
539        // `\` at the end of a `//` comment splices the next line into the
540        // comment (translation phase 2); `int y = 2;` is dead code and must
541        // not be resurrected.
542        let src = "int x = 1; // note \\\nint y = 2;\nint z = 3;\n";
543        let out = min_c(src);
544        assert!(!out.contains("int y=2;"), "dead code resurrected: {}", out);
545        assert!(out.contains("int x=1;"), "got: {}", out);
546        assert!(out.contains("int z=3;"), "got: {}", out);
547    }
548
549    #[test]
550    fn c_keep_comments_defuses_close_marker() {
551        // A `*/` inside a line comment must not close the spliced block
552        // comment early and leak the tail as live tokens.
553        let src = "// see */ marker\nint x;\n";
554        let r = minify(
555            src,
556            &MinifyOptions {
557                keep_comments: true,
558            },
559            false,
560        )
561        .unwrap();
562        let after_comment = r.body.split("*/").nth(1).unwrap_or("");
563        assert!(
564            !after_comment.contains("marker"),
565            "comment text leaked as live code: {}",
566            r.body
567        );
568    }
569
570    #[test]
571    fn c_unterminated_block_comment() {
572        assert!(minify("/* unterminated", &MinifyOptions::default(), false).is_err());
573    }
574
575    #[test]
576    fn c_unterminated_string() {
577        assert!(minify("char* s = \"oops", &MinifyOptions::default(), false).is_err());
578    }
579}