Skip to main content

brief/minify/
c_common.rs

1//! Shared building blocks for the C-family minifiers (Rust, Go, JS/TS,
2//! Java, C/C++, SQL). Each language module produces a `Vec<Token>` with its
3//! own lexer; a shared emitter consumes the stream to produce a minified
4//! string with minimal-but-safe whitespace.
5
6#![allow(dead_code)]
7
8use super::{MinifyError, MinifyOutput, MinifyWarning};
9
10#[derive(Debug, Clone)]
11pub enum TokenKind<'a> {
12    /// Identifier, keyword, or numeric literal — anything that requires a
13    /// word-boundary against another adjacent word.
14    Word(&'a str),
15    /// Operator or punctuation. May be 1+ characters; multi-character forms
16    /// like `===`, `??`, `=>`, `->`, `::` are emitted as a single Punct so
17    /// the dangerous-pair table doesn't have to pull them apart again.
18    Punct(&'a str),
19    /// String/char literal — emitted verbatim, including delimiters.
20    StrLit(&'a str),
21    /// `// …` line comment body (without the leading `//` or trailing
22    /// newline). The minifier emits or drops based on `keep_comments`.
23    LineComment(&'a str),
24    /// `/* … */` block comment body (without the surrounding delimiters).
25    BlockComment(&'a str),
26    /// JS template literal `` `…` `` — verbatim, including backticks.
27    Template(&'a str),
28    /// JS regex literal `/…/flags` — verbatim.
29    Regex(&'a str),
30    /// C/C++ preprocessor line, including the leading `#` and trailing line
31    /// continuations. Emitted on its own line.
32    Preproc(&'a str),
33    /// Significant newline (used by Strategy B emitters to preserve ASI).
34    Newline,
35}
36
37#[derive(Debug, Clone)]
38pub struct Token<'a> {
39    pub kind: TokenKind<'a>,
40}
41
42impl<'a> Token<'a> {
43    pub fn new(kind: TokenKind<'a>) -> Self {
44        Token { kind }
45    }
46}
47
48/// True if `c` is a "word" character — the kind of glyph that, if pressed
49/// against another word character with no whitespace, would form a different
50/// token. ASCII alnum, `_`, `$`. Non-ASCII identifiers in Rust/Java are
51/// allowed; we treat all alphabetic chars as words regardless of script.
52pub fn is_word_char(c: char) -> bool {
53    c.is_alphanumeric() || c == '_' || c == '$'
54}
55
56/// True if removing whitespace between two adjacent characters would change
57/// the token stream (form a multi-character operator, comment marker, or
58/// merge two words). The caller passes the last char of the previously
59/// emitted token and the first char of the next token.
60pub fn needs_space(prev: char, next: char) -> bool {
61    if is_word_char(prev) && is_word_char(next) {
62        return true;
63    }
64    // The pairs that, if joined, become a different lexical token.
65    matches!(
66        (prev, next),
67        ('+', '+')
68            | ('-', '-')
69            | ('<', '<')
70            | ('>', '>')
71            | ('*', '*')
72            | ('/', '/')
73            | ('/', '*')
74            | ('*', '/')
75            | (':', ':')
76            | ('&', '&')
77            | ('|', '|')
78            | ('=', '=')
79            | ('!', '=')
80            | ('<', '=')
81            | ('>', '=')
82            | ('+', '=')
83            | ('-', '=')
84            | ('*', '=')
85            | ('/', '=')
86            | ('%', '=')
87            | ('&', '=')
88            | ('|', '=')
89            | ('^', '=')
90            | ('-', '>')
91            | ('=', '>')
92            | ('?', '?')
93            | ('?', '.')
94            | ('.', '.')
95    )
96}
97
98fn last_char(s: &str) -> Option<char> {
99    s.chars().next_back()
100}
101fn first_char(s: &str) -> Option<char> {
102    s.chars().next()
103}
104
105/// Wrap a comment body in `/* … */`, defusing any `*/` inside it. A line
106/// comment like `// see */ marker` would otherwise close the spliced block
107/// comment early and leak the tail as live tokens.
108pub fn safe_block_comment(body: &str) -> String {
109    format!("/*{}*/", body.replace("*/", "* /"))
110}
111
112/// Emit a token stream stripping all whitespace and (default) all comments.
113/// Used by Rust, Java, SQL.
114pub fn emit_aggressive(
115    tokens: &[Token<'_>],
116    opts_keep_comments: bool,
117) -> Result<MinifyOutput, MinifyError> {
118    let mut out = String::new();
119    let mut warnings: Vec<MinifyWarning> = Vec::new();
120    let mut prev_emit_last: Option<char> = None;
121    for tok in tokens {
122        match &tok.kind {
123            TokenKind::Newline => {}
124            TokenKind::LineComment(body) => {
125                if !opts_keep_comments {
126                    continue;
127                }
128                let block = safe_block_comment(body);
129                push_with_space(&mut out, &mut prev_emit_last, &block);
130                warnings.push(MinifyWarning::LineCommentConverted);
131            }
132            TokenKind::BlockComment(body) => {
133                if !opts_keep_comments {
134                    continue;
135                }
136                let block = safe_block_comment(body);
137                push_with_space(&mut out, &mut prev_emit_last, &block);
138            }
139            TokenKind::Word(s)
140            | TokenKind::Punct(s)
141            | TokenKind::StrLit(s)
142            | TokenKind::Template(s)
143            | TokenKind::Regex(s) => {
144                push_with_space(&mut out, &mut prev_emit_last, s);
145            }
146            TokenKind::Preproc(s) => {
147                if !out.is_empty() && !out.ends_with('\n') {
148                    out.push('\n');
149                }
150                out.push_str(s);
151                if !s.ends_with('\n') {
152                    out.push('\n');
153                }
154                prev_emit_last = None;
155            }
156        }
157    }
158    Ok(MinifyOutput {
159        body: out,
160        warnings,
161    })
162}
163
164/// Emit a token stream preserving newlines (so JS/TS/Go ASI behavior is
165/// preserved). Horizontal whitespace and comments are still stripped.
166pub fn emit_conservative(
167    tokens: &[Token<'_>],
168    opts_keep_comments: bool,
169) -> Result<MinifyOutput, MinifyError> {
170    let mut out = String::new();
171    let mut warnings: Vec<MinifyWarning> = Vec::new();
172    let mut prev_emit_last: Option<char> = None;
173    for tok in tokens {
174        match &tok.kind {
175            TokenKind::Newline => {
176                // Collapse runs of newlines down to one — leading newlines
177                // from prior comments shouldn't pile up.
178                if !out.ends_with('\n') {
179                    out.push('\n');
180                }
181                prev_emit_last = None;
182            }
183            TokenKind::LineComment(body) => {
184                if !opts_keep_comments {
185                    continue;
186                }
187                let block = safe_block_comment(body);
188                push_with_space(&mut out, &mut prev_emit_last, &block);
189                warnings.push(MinifyWarning::LineCommentConverted);
190            }
191            TokenKind::BlockComment(body) => {
192                if !opts_keep_comments {
193                    continue;
194                }
195                let block = safe_block_comment(body);
196                push_with_space(&mut out, &mut prev_emit_last, &block);
197            }
198            TokenKind::Word(s)
199            | TokenKind::Punct(s)
200            | TokenKind::StrLit(s)
201            | TokenKind::Template(s)
202            | TokenKind::Regex(s) => {
203                push_with_space(&mut out, &mut prev_emit_last, s);
204            }
205            TokenKind::Preproc(s) => {
206                // Preprocessor isn't really a JS/Go thing but for symmetry:
207                if !out.is_empty() && !out.ends_with('\n') {
208                    out.push('\n');
209                }
210                out.push_str(s);
211                if !s.ends_with('\n') {
212                    out.push('\n');
213                }
214                prev_emit_last = None;
215            }
216        }
217    }
218    Ok(MinifyOutput {
219        body: out,
220        warnings,
221    })
222}
223
224fn push_with_space(out: &mut String, prev_emit_last: &mut Option<char>, s: &str) {
225    if s.is_empty() {
226        return;
227    }
228    if let Some(prev) = *prev_emit_last {
229        if let Some(next) = first_char(s) {
230            if needs_space(prev, next) {
231                out.push(' ');
232            }
233        }
234    }
235    out.push_str(s);
236    *prev_emit_last = last_char(s);
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn word_word_needs_space() {
245        assert!(needs_space('a', 'b'));
246        assert!(needs_space('1', 'x'));
247        assert!(needs_space('_', 'a'));
248    }
249
250    #[test]
251    fn word_punct_no_space() {
252        assert!(!needs_space('a', '('));
253        assert!(!needs_space('1', ';'));
254        assert!(!needs_space(')', '{'));
255    }
256
257    #[test]
258    fn dangerous_pairs_need_space() {
259        assert!(needs_space('+', '+'));
260        assert!(needs_space('-', '-'));
261        assert!(needs_space('/', '/'));
262        assert!(needs_space('/', '*'));
263        assert!(needs_space('=', '='));
264        assert!(needs_space('!', '='));
265        assert!(needs_space('<', '='));
266        assert!(needs_space(':', ':'));
267        assert!(needs_space('&', '&'));
268        assert!(needs_space('|', '|'));
269        assert!(needs_space('-', '>'));
270        assert!(needs_space('=', '>'));
271        assert!(needs_space('.', '.'));
272    }
273
274    #[test]
275    fn safe_punct_pairs_no_space() {
276        assert!(!needs_space('(', '{'));
277        assert!(!needs_space(',', ' '));
278        assert!(!needs_space(';', '}'));
279        assert!(!needs_space(')', ';'));
280        assert!(!needs_space('+', 'a'));
281        assert!(!needs_space('a', ')'));
282    }
283}