cpd_tokenizer/
javascript.rs1use std::panic::{AssertUnwindSafe, catch_unwind};
5use std::path::Path;
6
7use oxc_allocator::Allocator;
8use oxc_parser::{Kind, Parser, config::TokensParserConfig};
9use oxc_span::SourceType;
10
11use cpd_core::models::{Token, TokenKind};
12
13use crate::line_index::LineIndex;
14
15mod fallback {
18 use super::{LineIndex, find_ignore_ranges, in_ignore};
19 use cpd_core::models::{Token, TokenKind};
20
21 pub fn tokenize(source: &str, _format: &str) -> Vec<Token> {
23 let ignore_ranges = find_ignore_ranges(source);
24 let bytes = source.as_bytes();
25 let line_index = LineIndex::new(bytes);
26 let mut tokens = Vec::new();
27 let mut i = 0;
28 while i < bytes.len() {
29 let ch = match source[i..].chars().next() {
30 Some(c) => c,
31 None => break,
32 };
33 if ch.is_whitespace() {
34 i += ch.len_utf8();
35 continue;
36 }
37 if ch.is_alphanumeric() || ch == '_' || ch == '$' {
38 let start = i;
39 while i < bytes.len() {
40 let c = source[i..].chars().next().unwrap_or('\0');
41 if c.is_alphanumeric() || c == '_' || c == '$' {
42 i += c.len_utf8();
43 } else {
44 break;
45 }
46 }
47 let kind = if in_ignore(start, i, &ignore_ranges) {
48 TokenKind::Ignore
49 } else {
50 TokenKind::Other
51 };
52 tokens.push(Token {
53 kind,
54 value: source[start..i].to_string(),
55 start: line_index.location(start),
56 end: line_index.location(i),
57 });
58 } else {
59 let start = i;
60 i += ch.len_utf8();
61 let kind = if in_ignore(start, i, &ignore_ranges) {
62 TokenKind::Ignore
63 } else {
64 TokenKind::Other
65 };
66 tokens.push(Token {
67 kind,
68 value: ch.to_string(),
69 start: line_index.location(start),
70 end: line_index.location(i),
71 });
72 }
73 }
74 tokens
75 }
76}
77
78fn find_ignore_ranges(source: &str) -> Vec<[usize; 2]> {
81 let mut ranges = Vec::new();
82 let mut start: Option<usize> = None;
83 let bytes = source.as_bytes();
84 let mut i = 0;
85 while i < bytes.len() {
86 if i + 1 < bytes.len() && bytes[i] == b'/' {
87 let end = if bytes[i + 1] == b'/' {
88 bytes[i..]
89 .iter()
90 .position(|&b| b == b'\n')
91 .map(|p| i + p)
92 .unwrap_or(bytes.len())
93 } else if bytes[i + 1] == b'*' {
94 bytes[i..]
95 .windows(2)
96 .position(|w| w == b"*/")
97 .map(|p| i + p + 2)
98 .unwrap_or(bytes.len())
99 } else {
100 i += 1;
101 continue;
102 };
103 let comment_text = &source[i..end];
104 if comment_text.contains("jscpd:ignore-start") {
105 start = Some(end);
106 } else if comment_text.contains("jscpd:ignore-end") {
107 if let Some(s) = start.take() {
108 ranges.push([s, i]);
109 }
110 }
111 i = end;
112 continue;
113 }
114 i += 1;
115 }
116 ranges
117}
118
119fn in_ignore(offset: usize, end: usize, ranges: &[[usize; 2]]) -> bool {
120 ranges.iter().any(|[rs, re]| offset < *re && end > *rs)
121}
122
123const fn map_kind(kind: Kind) -> TokenKind {
124 if matches!(kind, Kind::Ident) {
125 return TokenKind::Identifier;
126 }
127 if kind.is_any_keyword() {
128 return TokenKind::Keyword;
129 }
130 if kind.is_literal() {
131 return TokenKind::Literal;
132 }
133 if kind.is_assignment_operator() {
134 return TokenKind::Operator;
135 }
136 if kind.is_binary_operator()
137 || kind.is_logical_operator()
138 || kind.is_unary_operator()
139 || kind.is_update_operator()
140 {
141 return TokenKind::Operator;
142 }
143 match kind {
144 Kind::Arrow => TokenKind::Operator,
145 Kind::Semicolon
146 | Kind::Comma
147 | Kind::Dot
148 | Kind::Dot3
149 | Kind::Colon
150 | Kind::LParen
151 | Kind::RParen
152 | Kind::LCurly
153 | Kind::RCurly
154 | Kind::LBrack
155 | Kind::RBrack
156 | Kind::At => TokenKind::Punctuation,
157 Kind::QuestionDot => TokenKind::Punctuation,
158 _ => TokenKind::Other,
159 }
160}
161
162fn source_type_for_format(format: &str) -> SourceType {
163 let filename = match format {
164 "typescript" => "input.ts",
165 "tsx" => "input.tsx",
166 _ => "input.jsx", };
168 SourceType::from_path(Path::new(filename)).unwrap_or_default()
169}
170
171pub fn tokenize_js(source: &str, format: &str) -> Vec<Token> {
175 tokenize_js_impl(source, format, false)
176}
177
178pub fn tokenize_js_stripped(source: &str, format: &str) -> Vec<Token> {
185 tokenize_js_impl(source, format, true)
186}
187
188fn tokenize_js_impl(source: &str, format: &str, strip_types: bool) -> Vec<Token> {
189 if source.is_empty() {
190 return Vec::new();
191 }
192
193 match catch_unwind(AssertUnwindSafe(|| {
194 parse_with_oxc(source, format, strip_types)
195 })) {
196 Ok(Some(tokens)) => tokens,
197 Ok(None) => {
198 log::debug!("cpd-tokenizer: OXC parse errors in {format} source, using fallback");
199 fallback::tokenize(source, format)
200 }
201 Err(_) => {
202 log::debug!("cpd-tokenizer: OXC panicked on {format} source, using fallback");
203 fallback::tokenize(source, format)
204 }
205 }
206}
207
208fn parse_with_oxc(source: &str, format: &str, strip_types: bool) -> Option<Vec<Token>> {
209 let allocator = Allocator::new();
210 let source_type = source_type_for_format(format);
211
212 let parser_return = Parser::new(&allocator, source, source_type)
213 .with_config(TokensParserConfig)
214 .parse();
215
216 if !parser_return.errors.is_empty() {
217 return None;
218 }
219
220 let erasable = if strip_types {
221 crate::ts_strip::collect_erasable_spans(&parser_return.program, source)
222 } else {
223 crate::ts_strip::ErasableSpans::default()
224 };
225
226 let ignore_ranges = find_ignore_ranges(source);
227 let bytes = source.as_bytes();
228 let line_index = LineIndex::new(bytes);
230
231 let tokens = parser_return
232 .tokens
233 .into_iter()
234 .filter_map(|token| {
235 let start = (token.start() as usize).min(source.len());
236 let end = (token.end() as usize).min(source.len());
237 if start >= end {
238 return None;
239 }
240 let kind = token.kind();
241 if matches!(kind, Kind::Eof | Kind::Undetermined | Kind::Skip) {
242 return None;
243 }
244 let value = &source[start..end];
245 if !erasable.is_empty()
246 && (erasable.intersects_range(start as u32, end as u32)
247 || erasable.is_modifier_token(start as u32, end as u32, value))
248 {
249 return None;
250 }
251 let token_kind = if in_ignore(start, end, &ignore_ranges) {
252 TokenKind::Ignore
253 } else {
254 map_kind(kind)
255 };
256
257 Some(Token {
258 kind: token_kind,
259 value: value.to_string(),
260 start: line_index.location(start),
261 end: line_index.location(end),
262 })
263 })
264 .collect::<Vec<Token>>();
265
266 Some(tokens)
267}
268
269#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn valid_js_produces_tokens() {
276 let tokens = tokenize_js("function hello() { return 42; }", "javascript");
277 assert!(!tokens.is_empty(), "valid JS must produce tokens");
278 }
279
280 #[test]
281 fn typescript_produces_tokens() {
282 let tokens = tokenize_js("const x: number = 5;", "typescript");
283 assert!(!tokens.is_empty());
284 }
285
286 #[test]
287 fn malformed_js_does_not_panic() {
288 let result = std::panic::catch_unwind(|| tokenize_js("let x = {{{", "javascript"));
289 assert!(result.is_ok(), "malformed JS must not panic");
290 }
291
292 #[test]
293 fn empty_source_returns_empty() {
294 let tokens = tokenize_js("", "javascript");
295 drop(tokens);
296 }
297
298 #[test]
299 fn ignore_region_tokens_marked_as_ignore() {
300 let source = r#"
301const a = 1;
302// jscpd:ignore-start
303const b = 2;
304// jscpd:ignore-end
305const c = 3;
306"#;
307 let tokens = tokenize_js(source, "javascript");
308 let has_ignore = tokens
309 .iter()
310 .any(|t| t.kind == cpd_core::models::TokenKind::Ignore);
311 assert!(has_ignore, "tokens in ignore region must be marked Ignore");
312 }
313
314 #[test]
315 fn jsx_produces_tokens() {
316 let tokens = tokenize_js("const el = <div>hello</div>;", "jsx");
317 assert!(!tokens.is_empty());
318 }
319
320 #[test]
321 fn tsx_with_type_annotation() {
322 let tokens = tokenize_js("const fn = (x: React.FC): void => {};", "tsx");
323 assert!(!tokens.is_empty());
324 }
325
326 #[test]
327 fn multiline_location_uses_binary_search() {
328 let source = "const a = 1;\nconst b = 2;\nconst c = 3;";
329 let tokens = tokenize_js(source, "javascript");
330 let b_token = tokens.iter().find(|t| t.value == "b");
332 assert!(b_token.is_some(), "must find token b");
333 assert_eq!(b_token.unwrap().start.line, 2, "b must be on line 2");
334 }
335}