use std::str::FromStr;
use cpd_core::hash::hash_token;
use cpd_core::models::{DetectionToken, Token, TokenKind};
use crate::markdown::tokens_to_detection;
#[derive(Debug, Clone)]
pub struct TokenMap {
pub format: String,
pub tokens: Vec<DetectionToken>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
#[default]
Mild,
Weak,
Strict,
}
impl FromStr for Mode {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"weak" => Ok(Self::Weak),
"strict" => Ok(Self::Strict),
_ => Ok(Self::Mild),
}
}
}
#[derive(Debug, Clone)]
pub struct TokenizeOptions {
pub mode: Mode,
pub ignore_case: bool,
pub ignore_ranges: Vec<[usize; 2]>,
pub ignore_identifiers: bool,
pub ignore_literals: bool,
pub ignore_annotations: bool,
pub code_ignore_regexes: Vec<regex::Regex>,
pub strip_types_formats: std::collections::HashSet<String>,
}
impl TokenizeOptions {
pub fn new(mode: Mode) -> Self {
Self {
mode,
ignore_case: false,
ignore_identifiers: false,
ignore_literals: false,
ignore_annotations: false,
ignore_ranges: Vec::new(),
code_ignore_regexes: Vec::new(),
strip_types_formats: std::collections::HashSet::new(),
}
}
}
pub fn tokenize_format_to_detection(
format: &str,
source: &str,
options: &TokenizeOptions,
) -> Vec<DetectionToken> {
let raw = match format {
"javascript" | "typescript" | "jsx" | "tsx" => {
if should_strip_types(format, options) {
crate::javascript::tokenize_js_stripped(source, format)
} else {
crate::javascript::tokenize_js(source, format)
}
}
"vue" | "svelte" | "astro" => crate::sfc::tokenize_sfc(source, format, options.mode),
"markdown" | "md" => crate::generic::tokenize_generic(source, format),
_ => crate::generic::tokenize_generic(source, format),
};
tokens_to_detection(raw, options)
}
fn should_strip_types(format: &str, options: &TokenizeOptions) -> bool {
matches!(format, "typescript" | "tsx") && options.strip_types_formats.contains(format)
}
pub fn code_ignore_ranges(source: &str, regexes: &[regex::Regex]) -> Vec<[usize; 2]> {
let mut ranges = Vec::new();
for re in regexes {
for m in re.find_iter(source) {
ranges.push([m.start(), m.end()]);
}
}
ranges
}
#[allow(clippy::too_many_arguments)]
#[inline]
pub fn push_token(
tokens: &mut Vec<DetectionToken>,
kind: TokenKind,
value: &str,
byte_start: usize,
byte_end: usize,
start: cpd_core::models::Location,
end: cpd_core::models::Location,
options: &TokenizeOptions,
) {
if kind == TokenKind::Ignore {
return;
}
if options
.ignore_ranges
.iter()
.any(|[rs, re]| byte_start < *re && byte_end > *rs)
{
return;
}
match options.mode {
Mode::Mild => {
if kind == TokenKind::Whitespace {
return;
}
}
Mode::Weak => {
if matches!(
kind,
TokenKind::Whitespace | TokenKind::Comment | TokenKind::BlockComment
) {
return;
}
}
Mode::Strict => {} }
let raw_hash = hash_token(kind.discriminant(), value, options.ignore_case);
let hash = match normalized_value(&kind, value, options) {
Some(placeholder) => hash_token(kind.discriminant(), placeholder, false),
None => raw_hash,
};
tokens.push(DetectionToken {
hash,
raw_hash,
start,
end,
range: [byte_start, byte_end],
});
}
#[inline]
fn normalized_value(
kind: &TokenKind,
value: &str,
options: &TokenizeOptions,
) -> Option<&'static str> {
match kind {
TokenKind::Identifier if options.ignore_identifiers => {
if is_common_keyword(value) {
None
} else {
Some("$id")
}
}
TokenKind::Literal if options.ignore_literals => literal_placeholder(value),
_ => None,
}
}
fn literal_placeholder(value: &str) -> Option<&'static str> {
let bytes = value.as_bytes();
let first = *bytes.first()?;
if matches!(first, b'"' | b'\'' | b'`') {
return Some("$str");
}
if first.is_ascii_digit() {
return Some("$num");
}
if first == b'.' && bytes.get(1).is_some_and(u8::is_ascii_digit) {
return Some("$num");
}
if first.is_ascii_alphabetic() || first == b'@' {
let quote_at = bytes
.iter()
.take(4)
.position(|b| matches!(b, b'"' | b'\'' | b'`'));
if quote_at.is_some() {
return Some("$str");
}
}
None
}
static COMMON_KEYWORDS: &[&str] = &[
"abstract",
"and",
"as",
"assert",
"async",
"await",
"begin",
"break",
"case",
"catch",
"class",
"const",
"continue",
"def",
"default",
"defer",
"del",
"do",
"elif",
"else",
"elsif",
"end",
"enum",
"except",
"export",
"extends",
"extern",
"false",
"final",
"finally",
"fn",
"for",
"foreach",
"from",
"func",
"function",
"global",
"go",
"goto",
"if",
"impl",
"implements",
"import",
"in",
"inline",
"instanceof",
"interface",
"is",
"lambda",
"let",
"loop",
"match",
"mod",
"module",
"mut",
"namespace",
"new",
"nil",
"none",
"not",
"null",
"or",
"override",
"package",
"pass",
"private",
"protected",
"pub",
"public",
"raise",
"record",
"ref",
"require",
"rescue",
"return",
"sealed",
"select",
"self",
"sizeof",
"static",
"struct",
"super",
"switch",
"then",
"this",
"throw",
"throws",
"trait",
"true",
"try",
"type",
"typedef",
"typeof",
"undefined",
"union",
"unless",
"unsafe",
"until",
"use",
"using",
"var",
"virtual",
"void",
"volatile",
"when",
"where",
"while",
"with",
"yield",
];
pub fn is_common_keyword(word: &str) -> bool {
COMMON_KEYWORDS.binary_search(&word).is_ok()
}
pub fn strips_annotations(format: &str) -> bool {
matches!(
format,
"javascript"
| "typescript"
| "jsx"
| "tsx"
| "java"
| "kotlin"
| "scala"
| "groovy"
| "python"
| "dart"
| "swift"
)
}
fn mark_annotations(tokens: &[Token]) -> Vec<bool> {
let n = tokens.len();
let mut i = 0;
let mut flags: Vec<bool> = Vec::new();
while i < n {
let starts_annotation = tokens[i].value == "@"
&& tokens[i].kind != TokenKind::Ignore
&& tokens
.get(i + 1)
.is_some_and(|t| t.kind == TokenKind::Identifier && !is_common_keyword(&t.value));
if !starts_annotation {
i += 1;
continue;
}
let start = i;
let mut j = i + 2;
while j + 1 < n && tokens[j].value == "." && tokens[j + 1].kind == TokenKind::Identifier {
j += 2;
}
let mut k = j;
while k < n && tokens[k].kind == TokenKind::Whitespace {
k += 1;
}
if k < n && tokens[k].value == "(" {
let mut depth = 0usize;
j = k;
while j < n {
match tokens[j].value.as_str() {
"(" => depth += 1,
")" => {
depth -= 1;
if depth == 0 {
j += 1;
break;
}
}
_ => {}
}
j += 1;
}
}
if flags.is_empty() {
flags = vec![false; n];
}
for flag in &mut flags[start..j] {
*flag = true;
}
i = j;
}
flags
}
pub fn tokenize(format: &str, source: &str, mode: Mode) -> Vec<Token> {
let raw = dispatch_tokenizer(format, source, mode);
raw.into_iter().filter(|t| keep_token(t, mode)).collect()
}
fn keep_token(token: &Token, mode: Mode) -> bool {
if token.kind == TokenKind::Ignore {
return false;
}
match mode {
Mode::Mild => !matches!(token.kind, TokenKind::Whitespace),
Mode::Weak => !matches!(
token.kind,
TokenKind::Whitespace | TokenKind::Comment | TokenKind::BlockComment
),
Mode::Strict => true,
}
}
pub fn tokenize_to_detection(
format: &str,
source: &str,
options: &TokenizeOptions,
) -> Vec<DetectionToken> {
let raw = if should_strip_types(format, options) {
crate::javascript::tokenize_js_stripped(source, format)
} else {
dispatch_tokenizer(format, source, options.mode)
};
let annotation = if options.ignore_annotations && strips_annotations(format) {
mark_annotations(&raw)
} else {
Vec::new()
};
let mut detection: Vec<DetectionToken> = Vec::with_capacity(raw.len());
for (i, t) in raw.into_iter().enumerate() {
if annotation.get(i).copied().unwrap_or(false) {
if let Some(prev) = detection.last_mut() {
let h = hash_token(t.kind.discriminant(), &t.value, false);
prev.raw_hash = prev.raw_hash.rotate_left(7) ^ h;
}
continue;
}
let byte_start = t.start.offset as usize;
let byte_end = t.end.offset as usize;
push_token(
&mut detection,
t.kind,
&t.value,
byte_start,
byte_end,
t.start,
t.end,
options,
);
}
detection
}
fn dispatch_tokenizer(format: &str, source: &str, mode: Mode) -> Vec<Token> {
match format {
"javascript" | "typescript" | "jsx" | "tsx" => {
crate::javascript::tokenize_js(source, format)
}
"vue" | "svelte" | "astro" => crate::sfc::tokenize_sfc(source, format, mode),
"razor" => crate::razor::tokenize_razor(source, mode),
"markdown" | "md" => crate::markdown::tokenize_markdown(source, mode),
_ => crate::generic::tokenize_generic(source, format),
}
}
pub fn tokenize_to_detection_maps(
format: &str,
source: &str,
options: &TokenizeOptions,
) -> Vec<TokenMap> {
match format {
"markdown" | "md" => crate::markdown::tokenize_markdown_maps(source, options),
"vue" | "svelte" | "astro" => crate::sfc::tokenize_sfc_maps(source, format, options),
"razor" => crate::razor::tokenize_razor_maps(source, options),
_ => {
let tokens = tokenize_to_detection(format, source, options);
vec![TokenMap {
format: format.to_string(),
tokens,
}]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mode_from_str_defaults_to_mild() {
assert_eq!("unknown".parse::<Mode>().unwrap(), Mode::Mild);
assert_eq!("mild".parse::<Mode>().unwrap(), Mode::Mild);
}
#[test]
fn mode_from_str_weak() {
assert_eq!("weak".parse::<Mode>().unwrap(), Mode::Weak);
}
#[test]
fn mode_from_str_strict() {
assert_eq!("strict".parse::<Mode>().unwrap(), Mode::Strict);
}
#[test]
fn tokenize_to_detection_returns_detection_tokens() {
let opts = TokenizeOptions::new(Mode::Mild);
let tokens = tokenize_to_detection("javascript", "function hello() { return 42; }", &opts);
assert!(
!tokens.is_empty(),
"must produce DetectionTokens for valid JS"
);
}
#[test]
fn tokenize_to_detection_mild_excludes_whitespace() {
let opts = TokenizeOptions::new(Mode::Mild);
let mild = tokenize_to_detection("javascript", "a b c", &opts);
let strict =
tokenize_to_detection("javascript", "a b c", &TokenizeOptions::new(Mode::Strict));
let _ = (mild, strict);
}
#[test]
fn push_token_drops_ignore_kind() {
let mut tokens = Vec::new();
let loc = cpd_core::models::Location {
line: 1,
column: 0,
offset: 0,
};
let opts = TokenizeOptions::new(Mode::Mild);
push_token(
&mut tokens,
TokenKind::Ignore,
"secret",
0,
6,
loc.clone(),
loc,
&opts,
);
assert!(tokens.is_empty(), "Ignore-kind tokens must be dropped");
}
#[test]
fn push_token_drops_whitespace_in_mild_mode() {
let mut tokens = Vec::new();
let loc = cpd_core::models::Location {
line: 1,
column: 0,
offset: 0,
};
let opts = TokenizeOptions::new(Mode::Mild);
push_token(
&mut tokens,
TokenKind::Whitespace,
" ",
0,
1,
loc.clone(),
loc,
&opts,
);
assert!(tokens.is_empty(), "Whitespace must be dropped in Mild mode");
}
#[test]
fn push_token_keeps_whitespace_in_strict_mode() {
let mut tokens = Vec::new();
let loc = cpd_core::models::Location {
line: 1,
column: 0,
offset: 0,
};
let opts = TokenizeOptions::new(Mode::Strict);
push_token(
&mut tokens,
TokenKind::Whitespace,
" ",
0,
1,
loc.clone(),
loc,
&opts,
);
assert_eq!(tokens.len(), 1, "Whitespace must be kept in Strict mode");
}
#[test]
fn push_token_drops_comment_in_weak_mode() {
let mut tokens = Vec::new();
let loc = cpd_core::models::Location {
line: 1,
column: 0,
offset: 0,
};
let opts = TokenizeOptions::new(Mode::Weak);
push_token(
&mut tokens,
TokenKind::Comment,
"// note",
0,
7,
loc.clone(),
loc,
&opts,
);
assert!(tokens.is_empty(), "Comment must be dropped in Weak mode");
}
fn det(source: &str, format: &str, opts: &TokenizeOptions) -> Vec<DetectionToken> {
tokenize_to_detection(format, source, opts)
}
fn hashes(tokens: &[DetectionToken]) -> Vec<u64> {
tokens.iter().map(|t| t.hash).collect()
}
#[test]
fn default_options_keep_raw_hash_equal_to_hash() {
let opts = TokenizeOptions::new(Mode::Mild);
let tokens = det("function a(x) { return x + 1; }", "javascript", &opts);
assert!(!tokens.is_empty());
assert!(tokens.iter().all(|t| t.raw_hash == t.hash));
}
#[test]
fn ignore_identifiers_matches_renamed_code_and_keeps_keywords() {
let mut opts = TokenizeOptions::new(Mode::Mild);
opts.ignore_identifiers = true;
let a = det("function a(x) { return x + 1; }", "javascript", &opts);
let b = det("function b(y) { return y + 1; }", "javascript", &opts);
assert_eq!(
hashes(&a),
hashes(&b),
"renamed identifiers must hash alike"
);
assert_ne!(
a.iter().map(|t| t.raw_hash).collect::<Vec<_>>(),
b.iter().map(|t| t.raw_hash).collect::<Vec<_>>()
);
let c = det("function a(x) { throw x + 1; }", "javascript", &opts);
assert_ne!(hashes(&a), hashes(&c));
}
#[test]
fn ignore_identifiers_keeps_common_keywords_in_generic_languages() {
let mut opts = TokenizeOptions::new(Mode::Mild);
opts.ignore_identifiers = true;
let a = det("if x:\n return y\n", "python", &opts);
let b = det("if p:\n return q\n", "python", &opts);
let c = det("while x:\n return y\n", "python", &opts);
assert_eq!(hashes(&a), hashes(&b));
assert_ne!(
hashes(&a),
hashes(&c),
"`if` and `while` are keywords, not identifiers"
);
assert!(is_common_keyword("return"));
assert!(!is_common_keyword("total"));
}
#[test]
fn ignore_literals_folds_strings_and_numbers_separately() {
let mut opts = TokenizeOptions::new(Mode::Mild);
opts.ignore_literals = true;
let a = det("const a = 10; const b = 'x';", "javascript", &opts);
let b = det("const a = 25; const b = \"yy\";", "javascript", &opts);
assert_eq!(hashes(&a), hashes(&b));
let c = det("const a = 'ten'; const b = 'x';", "javascript", &opts);
assert_ne!(hashes(&a), hashes(&c), "a string is not a number");
assert_eq!(literal_placeholder("42"), Some("$num"));
assert_eq!(literal_placeholder(".5"), Some("$num"));
assert_eq!(literal_placeholder("\"s\""), Some("$str"));
assert_eq!(literal_placeholder("r'raw'"), Some("$str"));
assert_eq!(literal_placeholder("true"), None);
}
#[test]
fn ignore_annotations_drops_decorators_in_listed_formats_only() {
let mut opts = TokenizeOptions::new(Mode::Mild);
opts.ignore_annotations = true;
let plain = det("class A { m() { return 1; } }", "typescript", &opts);
let decorated = det(
"@Component({ selector: 'a' })\nclass A { @Input() m() { return 1; } }",
"typescript",
&opts,
);
assert_eq!(hashes(&plain), hashes(&decorated));
assert!(decorated.iter().any(|t| t.raw_hash != t.hash));
let java_a = det(
"class A {\n @Override\n int f() { return 1; }\n}",
"java",
&opts,
);
let java_b = det(
"class A {\n @Deprecated\n int f() { return 1; }\n}",
"java",
&opts,
);
assert_eq!(hashes(&java_a), hashes(&java_b));
assert_ne!(
java_a.iter().map(|t| t.raw_hash).collect::<Vec<_>>(),
java_b.iter().map(|t| t.raw_hash).collect::<Vec<_>>(),
"different annotations must leave different raw hashes"
);
let decl = "public @interface Marker {\n String value() default \"\";\n}";
let stripped = det(decl, "java", &opts);
let untouched = det(decl, "java", &TokenizeOptions::new(Mode::Mild));
assert_eq!(hashes(&stripped), hashes(&untouched));
let ruby = det("@count = 1", "ruby", &opts);
assert!(strips_annotations("kotlin"));
assert!(!strips_annotations("ruby"));
assert_eq!(
ruby.len(),
det("@count = 1", "ruby", &TokenizeOptions::new(Mode::Mild)).len()
);
}
#[test]
fn push_token_ignore_case_folds_hash() {
let mut t1 = Vec::new();
let mut t2 = Vec::new();
let loc = cpd_core::models::Location {
line: 1,
column: 0,
offset: 0,
};
let mut opts = TokenizeOptions::new(Mode::Mild);
opts.ignore_case = true;
push_token(
&mut t1,
TokenKind::Identifier,
"Hello",
0,
5,
loc.clone(),
loc.clone(),
&opts,
);
push_token(
&mut t2,
TokenKind::Identifier,
"hello",
0,
5,
loc.clone(),
loc,
&opts,
);
assert_eq!(t1[0].hash, t2[0].hash, "ignore_case must fold case in hash");
}
#[test]
fn push_token_code_ignore_range_skips_overlapping_token() {
let mut tokens = Vec::new();
let loc = cpd_core::models::Location {
line: 1,
column: 0,
offset: 0,
};
let mut opts = TokenizeOptions::new(Mode::Mild);
opts.ignore_ranges = vec![[3, 18]];
push_token(
&mut tokens,
TokenKind::Identifier,
"foo",
0,
3,
loc.clone(),
loc.clone(),
&opts,
);
push_token(
&mut tokens,
TokenKind::Comment,
"// cpd-disable",
3,
18,
loc.clone(),
loc,
&opts,
);
assert_eq!(tokens.len(), 1, "only the non-matching token should remain");
assert_eq!(tokens[0].range, [0, 3]);
}
#[test]
fn push_token_code_ignore_range_no_overlap_keeps_all() {
let mut tokens = Vec::new();
let loc = cpd_core::models::Location {
line: 1,
column: 0,
offset: 0,
};
let mut opts = TokenizeOptions::new(Mode::Mild);
opts.ignore_ranges = vec![[100, 120]];
push_token(
&mut tokens,
TokenKind::Identifier,
"foo",
0,
3,
loc.clone(),
loc.clone(),
&opts,
);
push_token(
&mut tokens,
TokenKind::Identifier,
"bar",
3,
6,
loc.clone(),
loc,
&opts,
);
assert_eq!(
tokens.len(),
2,
"both tokens should remain when range doesn't overlap"
);
}
#[test]
fn code_ignore_ranges_computes_from_source_text() {
let source = "import foo from 'bar';\nconst x = 1;";
let re = regex::Regex::new(r"import\s+\w+\s+from").unwrap();
let ranges = code_ignore_ranges(source, &[re]);
assert_eq!(ranges.len(), 1, "should find one regex match");
assert_eq!(ranges[0], [0, 15]);
}
#[test]
fn code_ignore_ranges_multiple_patterns() {
let source = "// MIT License\nfunction foo() {}\n// Copyright";
let re1 = regex::Regex::new(r"//\s*MIT\s+License").unwrap();
let re2 = regex::Regex::new(r"//\s*Copyright").unwrap();
let ranges = code_ignore_ranges(source, &[re1, re2]);
assert_eq!(ranges.len(), 2, "should find two regex matches");
}
#[test]
fn code_ignore_ranges_empty_regexes() {
let source = "function foo() {}";
let ranges = code_ignore_ranges(source, &[]);
assert!(ranges.is_empty(), "no regexes means no ranges");
}
#[test]
fn tokenize_to_detection_with_code_ignore_ranges_skips_imports() {
let source = "import * from 'lodash';\nconst x = 1;";
let regexes = vec![regex::Regex::new(r"import\s+\*\s+from").unwrap()];
let ranges = code_ignore_ranges(source, ®exes);
assert!(!ranges.is_empty(), "should find regex match in source");
let mut opts = TokenizeOptions::new(Mode::Mild);
opts.ignore_ranges = ranges;
let tokens = tokenize_to_detection("javascript", source, &opts);
let has_const = tokens.iter().any(|t| {
t.range[0] >= 24
});
assert!(
has_const,
"tokens after the import line should still be present"
);
}
#[test]
fn code_ignore_ranges_multi_token_match() {
let source = "import * from 'lodash';\nconst result = 42;";
let re = regex::Regex::new(r"import\s+.*?\s+from").unwrap();
let ranges = code_ignore_ranges(source, &[re]);
assert_eq!(
ranges.len(),
1,
"should find one regex match spanning import statement"
);
assert!(ranges[0][0] == 0, "match should start at beginning");
assert!(ranges[0][1] > 0, "match should have non-zero end");
}
}