codehelion_core/engine/normalize.rs
1//! Scope-local token normalization for Type-2 clone matching.
2//!
3//! Type-2 clones differ from their siblings only by consistently renamed
4//! identifiers and changed literal values. Matching them requires a normal
5//! form in which those differences disappear while everything that identifies
6//! what the code *does* — keywords, operators, called APIs, paths, field
7//! names — is preserved.
8//!
9//! Normalization is scoped: identifier numbering restarts for every slice
10//! passed to [`normalize`], so a fragment's normal form depends only on the
11//! fragment's own content, never on what precedes it in the enclosing
12//! function. Scope-local first-occurrence numbering also makes the identifier
13//! bijection of a Type-2 match consistent by construction: two slices
14//! normalize equal exactly when a one-to-one rename maps one onto the other.
15//!
16//! # Preservation rules
17//!
18//! An identifier keeps its text (is not renamed) when it looks like an
19//! external name rather than a local binding:
20//!
21//! - it starts with an uppercase letter (types, enum variants, traits),
22//! - it is adjacent to `::` (a path segment),
23//! - it follows `.` or `->` (a member access: method, field, or a named
24//! return type after Rust's `->`),
25//! - it precedes `!` (a macro invocation).
26//!
27//! These are lexical heuristics; a local binding that happens to match one
28//! (say, a closure named like a method) is preserved conservatively, trading
29//! a little recall for not conflating different APIs.
30//!
31//! ## What the member-access rule costs, measured
32//!
33//! The third rule is the one that gives up the most, and it earns it. Dropping
34//! it alone — still preserving types, paths and macro names — was run against
35//! the labelled corpora: seventy groups appear that this mode did not report
36//! before. Reading them, most are the families the labels already call
37//! something other than duplication: exhaustive match tables dispatching to one
38//! method per variant, forwarding split by a compile-time flag, option parsers
39//! reading one named field per line, and operations mirrored over a start and
40//! an end. Those all have one shape and differ only in which member they name,
41//! which is exactly what this rule refuses to look past.
42//!
43//! It does lose real clones. Functions that walk a container by different link
44//! fields — first versus last, next versus previous — are labelled clones of
45//! each other, and this mode does not report them because `->next` and
46//! `->prev` survive normalization as different text. Structural mode reports
47//! all of them in one group, because its features read shape and token kinds
48//! and never read identifiers at all. That is the division the two modes are
49//! for: this one is the cheap screen and pays for its speed in recall.
50//!
51//! A caution about the figure that measurement produces. The labels rule on
52//! about a seventh of what this mode reports, and that seventh is the part
53//! Structural also flagged — so it is where the genuine clones concentrate.
54//! Judged precision therefore *rises* when the rule is dropped, while what
55//! actually arrives is mostly the boilerplate above. The judged share of a
56//! biased sample is not this mode's precision.
57
58use std::collections::{BTreeMap, BTreeSet};
59
60use crate::frontend::{LiteralKind, Token, TokenKind};
61
62/// Literal-normalization strategy.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum LiteralNorm {
65 /// Keep literal values distinct; only identifiers are renamed.
66 Preserve,
67 /// Collapse literals by category (integer, float, string, char, bool).
68 Category,
69 /// Collapse every literal to a single placeholder.
70 #[default]
71 Full,
72}
73
74/// The normalized payload of one token.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub enum NormAtom<'a> {
77 /// A scope-local name, numbered by first occurrence within the slice.
78 Renamed(u32),
79 /// Text preserved verbatim (keywords, operators, external names).
80 Text(&'a str),
81 /// A literal placeholder; the payload is its category class, or a single
82 /// shared class under [`LiteralNorm::Full`].
83 Literal(u8),
84}
85
86/// A normalized token: the lexical kind tag plus the normalized payload.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
88pub struct NormToken<'a> {
89 /// Stable one-byte kind tag (see [`TokenKind::tag`]).
90 pub tag: u8,
91 /// Normalized payload.
92 pub atom: NormAtom<'a>,
93}
94
95/// Class byte for a literal under the given strategy.
96const fn literal_class(kind: LiteralKind, mode: LiteralNorm) -> u8 {
97 match mode {
98 // `Preserve` never reaches here; `Full` folds every category together.
99 LiteralNorm::Preserve | LiteralNorm::Full => 0,
100 LiteralNorm::Category => match kind {
101 LiteralKind::Integer => 1,
102 LiteralKind::Float => 2,
103 LiteralKind::String => 3,
104 LiteralKind::Char => 4,
105 LiteralKind::Bool => 5,
106 },
107 }
108}
109
110/// The token's text when it is punctuation.
111fn punct_text(token: &Token) -> Option<&str> {
112 (token.kind == TokenKind::Punctuation).then_some(token.text.as_str())
113}
114
115/// What a compiler resolved the names in a file to, by the byte each name
116/// starts at.
117///
118/// The preservation rules above are lexical guesses at a question a compiler
119/// answers outright, and they are wrong in both directions: a local closure
120/// named like a method is preserved when it should be renamed, and a
121/// lowercase free function from another crate is renamed when it should be
122/// preserved. Where a compiler has spoken, its answer replaces the guess —
123/// in both directions, because correcting only the misses would leave the
124/// over-preservation the guess causes, which is the half that costs recall.
125///
126/// Byte offsets rather than indices: the resolution is about a file, and a
127/// fragment is a slice of one. A token's own start byte survives being sliced
128/// out; its position in the slice does not.
129#[derive(Debug, Clone, Default, PartialEq, Eq)]
130pub struct Resolution {
131 external: BTreeSet<usize>,
132 local: BTreeSet<usize>,
133}
134
135impl Resolution {
136 /// Nothing resolved yet.
137 #[must_use]
138 pub fn new() -> Self {
139 Self::default()
140 }
141
142 /// Record that the name starting at `start_byte` was resolved, and whether
143 /// its definition is outside the code being scanned.
144 pub fn insert(&mut self, start_byte: usize, external: bool) {
145 if external {
146 self.local.remove(&start_byte);
147 self.external.insert(start_byte);
148 } else {
149 self.external.remove(&start_byte);
150 self.local.insert(start_byte);
151 }
152 }
153
154 /// Whether nothing was resolved, in which case normalizing with this is
155 /// the same as normalizing without it.
156 #[must_use]
157 pub fn is_empty(&self) -> bool {
158 self.external.is_empty() && self.local.is_empty()
159 }
160
161 /// What was resolved about the name starting at `start_byte`, or `None`
162 /// when nothing was.
163 fn verdict(&self, start_byte: usize) -> Option<bool> {
164 if self.external.contains(&start_byte) {
165 Some(true)
166 } else if self.local.contains(&start_byte) {
167 Some(false)
168 } else {
169 None
170 }
171 }
172}
173
174/// Whether the identifier at `i` is preserved rather than renamed.
175///
176/// A compiler's answer wins where there is one; the lexical rules are what
177/// remains when nothing resolved this name.
178fn is_preserved(tokens: &[Token], i: usize, resolved: Option<&Resolution>) -> bool {
179 if let Some(verdict) = resolved.and_then(|r| r.verdict(tokens[i].span.start_byte)) {
180 return verdict;
181 }
182 if tokens[i]
183 .text
184 .chars()
185 .next()
186 .is_some_and(char::is_uppercase)
187 {
188 return true;
189 }
190 let prev = i
191 .checked_sub(1)
192 .and_then(|p| tokens.get(p))
193 .and_then(punct_text);
194 let next = tokens.get(i + 1).and_then(punct_text);
195 matches!(prev, Some("::" | "." | "->")) || matches!(next, Some("::" | "!"))
196}
197
198/// Normalize `tokens` as one scope.
199///
200/// Neighbour context for the preservation rules is taken from within the
201/// slice only, so identical slice content always produces an identical normal
202/// form regardless of its surroundings.
203#[must_use]
204pub fn normalize(tokens: &[Token], literals: LiteralNorm) -> Vec<NormToken<'_>> {
205 let mut out = Vec::new();
206 normalize_into(tokens, literals, &mut out);
207 out
208}
209
210/// [`normalize`] into a caller-owned buffer.
211///
212/// The buffer is cleared first. Callers normalizing millions of fragments
213/// reuse one buffer instead of allocating a vector per fragment.
214pub fn normalize_into<'a>(
215 tokens: &'a [Token],
216 literals: LiteralNorm,
217 out: &mut Vec<NormToken<'a>>,
218) {
219 normalize_resolved_into(tokens, literals, None, out);
220}
221
222/// [`normalize_into`] with what a compiler resolved about the names.
223///
224/// Passing `None` is the modes that run no compiler, and produces exactly
225/// what [`normalize_into`] does. Passing a [`Resolution`] produces a different
226/// normal form for the same tokens, which is why the analysis mode is part of
227/// every fingerprint's context: the two are not comparable and must never
228/// merge on an equal hash.
229pub fn normalize_resolved_into<'a>(
230 tokens: &'a [Token],
231 literals: LiteralNorm,
232 resolved: Option<&Resolution>,
233 out: &mut Vec<NormToken<'a>>,
234) {
235 out.clear();
236 let mut names: BTreeMap<&str, u32> = BTreeMap::new();
237 out.extend(tokens.iter().enumerate().map(|(i, token)| {
238 let atom = match token.kind {
239 TokenKind::Identifier if is_preserved(tokens, i, resolved) => {
240 NormAtom::Text(&token.text)
241 }
242 TokenKind::Identifier | TokenKind::Lifetime => {
243 let next = u32::try_from(names.len()).unwrap_or(u32::MAX);
244 let n = *names.entry(token.text.as_str()).or_insert(next);
245 NormAtom::Renamed(n)
246 }
247 TokenKind::Literal(kind) => match literals {
248 LiteralNorm::Preserve => NormAtom::Text(&token.text),
249 mode => NormAtom::Literal(literal_class(kind, mode)),
250 },
251 _ => NormAtom::Text(&token.text),
252 };
253 NormToken {
254 tag: token.kind.tag(),
255 atom,
256 }
257 }));
258}
259
260#[cfg(test)]
261#[allow(clippy::expect_used, clippy::unwrap_used)]
262mod tests {
263 use super::*;
264 use crate::frontend::SourceSpan;
265
266 /// Build a token stream from `(kind, text)` pairs with dummy spans.
267 fn toks(spec: &[(TokenKind, &str)]) -> Vec<Token> {
268 spec.iter()
269 .map(|(kind, text)| Token {
270 kind: *kind,
271 text: (*text).into(),
272 span: SourceSpan {
273 start_byte: 0,
274 end_byte: 0,
275 start_line: 1,
276 start_column: 1,
277 },
278 })
279 .collect()
280 }
281
282 use TokenKind::{Identifier as Id, Keyword as Kw, Punctuation as Pu};
283 const INT: TokenKind = TokenKind::Literal(LiteralKind::Integer);
284 const FLT: TokenKind = TokenKind::Literal(LiteralKind::Float);
285
286 #[test]
287 fn consistent_renames_normalize_equal() {
288 // `let total = a + b ; total` vs `let sum = x + y ; sum`
289 let a = toks(&[
290 (Kw, "let"),
291 (Id, "total"),
292 (Pu, "="),
293 (Id, "a"),
294 (Pu, "+"),
295 (Id, "b"),
296 (Pu, ";"),
297 (Id, "total"),
298 ]);
299 let b = toks(&[
300 (Kw, "let"),
301 (Id, "sum"),
302 (Pu, "="),
303 (Id, "x"),
304 (Pu, "+"),
305 (Id, "y"),
306 (Pu, ";"),
307 (Id, "sum"),
308 ]);
309 assert_eq!(
310 normalize(&a, LiteralNorm::Full),
311 normalize(&b, LiteralNorm::Full)
312 );
313 }
314
315 #[test]
316 fn inconsistent_renames_do_not_normalize_equal() {
317 // `a + a + b` vs `x + y + y`: numbering 0,0,1 vs 0,1,1.
318 let a = toks(&[(Id, "a"), (Pu, "+"), (Id, "a"), (Pu, "+"), (Id, "b")]);
319 let b = toks(&[(Id, "x"), (Pu, "+"), (Id, "y"), (Pu, "+"), (Id, "y")]);
320 assert_ne!(
321 normalize(&a, LiteralNorm::Full),
322 normalize(&b, LiteralNorm::Full)
323 );
324 }
325
326 #[test]
327 fn literal_modes_control_literal_equality() {
328 let a = toks(&[(Id, "x"), (Pu, "+"), (INT, "1")]);
329 let b = toks(&[(Id, "y"), (Pu, "+"), (INT, "2")]);
330 let c = toks(&[(Id, "z"), (Pu, "+"), (FLT, "2.0")]);
331 // Full: any literal matches any literal.
332 assert_eq!(
333 normalize(&a, LiteralNorm::Full),
334 normalize(&b, LiteralNorm::Full)
335 );
336 assert_eq!(
337 normalize(&a, LiteralNorm::Full),
338 normalize(&c, LiteralNorm::Full)
339 );
340 // Category: same category matches, different category does not.
341 assert_eq!(
342 normalize(&a, LiteralNorm::Category),
343 normalize(&b, LiteralNorm::Category)
344 );
345 assert_ne!(
346 normalize(&a, LiteralNorm::Category),
347 normalize(&c, LiteralNorm::Category)
348 );
349 // Preserve: different values do not match.
350 assert_ne!(
351 normalize(&a, LiteralNorm::Preserve),
352 normalize(&b, LiteralNorm::Preserve)
353 );
354 }
355
356 #[test]
357 fn method_and_path_names_are_preserved() {
358 // `foo.len()` vs `bar.len()`: receiver renamed, method preserved.
359 let len_a = toks(&[(Id, "foo"), (Pu, "."), (Id, "len"), (Pu, "("), (Pu, ")")]);
360 let len_b = toks(&[(Id, "bar"), (Pu, "."), (Id, "len"), (Pu, "("), (Pu, ")")]);
361 assert_eq!(
362 normalize(&len_a, LiteralNorm::Full),
363 normalize(&len_b, LiteralNorm::Full)
364 );
365 // `foo.len()` vs `foo.count()`: different methods must not match.
366 let count = toks(&[(Id, "foo"), (Pu, "."), (Id, "count"), (Pu, "("), (Pu, ")")]);
367 assert_ne!(
368 normalize(&len_a, LiteralNorm::Full),
369 normalize(&count, LiteralNorm::Full)
370 );
371 // `std::mem::swap` vs `std::mem::take`: path tails must not match.
372 let swap = toks(&[
373 (Id, "std"),
374 (Pu, "::"),
375 (Id, "mem"),
376 (Pu, "::"),
377 (Id, "swap"),
378 ]);
379 let take = toks(&[
380 (Id, "std"),
381 (Pu, "::"),
382 (Id, "mem"),
383 (Pu, "::"),
384 (Id, "take"),
385 ]);
386 assert_ne!(
387 normalize(&swap, LiteralNorm::Full),
388 normalize(&take, LiteralNorm::Full)
389 );
390 }
391
392 #[test]
393 fn arrow_member_names_are_preserved() {
394 // `p->next` vs `q->next`: pointer renamed, member preserved.
395 let a = toks(&[(Id, "p"), (Pu, "->"), (Id, "next")]);
396 let b = toks(&[(Id, "q"), (Pu, "->"), (Id, "next")]);
397 assert_eq!(
398 normalize(&a, LiteralNorm::Full),
399 normalize(&b, LiteralNorm::Full)
400 );
401 // `p->next` vs `p->prev`: different members must not match.
402 let c = toks(&[(Id, "p"), (Pu, "->"), (Id, "prev")]);
403 assert_ne!(
404 normalize(&a, LiteralNorm::Full),
405 normalize(&c, LiteralNorm::Full)
406 );
407 }
408
409 #[test]
410 fn macro_names_and_uppercase_names_are_preserved() {
411 let a = toks(&[(Id, "println"), (Pu, "!"), (Pu, "("), (Pu, ")")]);
412 let b = toks(&[(Id, "eprintln"), (Pu, "!"), (Pu, "("), (Pu, ")")]);
413 assert_ne!(
414 normalize(&a, LiteralNorm::Full),
415 normalize(&b, LiteralNorm::Full)
416 );
417 // `Some(x)` vs `Ok(x)`: variants preserved, payload renamed.
418 let c = toks(&[(Id, "Some"), (Pu, "("), (Id, "x"), (Pu, ")")]);
419 let d = toks(&[(Id, "Ok"), (Pu, "("), (Id, "x"), (Pu, ")")]);
420 assert_ne!(
421 normalize(&c, LiteralNorm::Full),
422 normalize(&d, LiteralNorm::Full)
423 );
424 }
425
426 #[test]
427 fn normal_form_is_context_independent() {
428 // The same sub-slice normalizes identically inside different hosts.
429 let host_a = toks(&[
430 (Id, "extra"),
431 (Pu, ";"),
432 (Kw, "let"),
433 (Id, "v"),
434 (Pu, "="),
435 (INT, "1"),
436 (Pu, ";"),
437 ]);
438 let host_b = toks(&[
439 (Id, "p"),
440 (Pu, "+"),
441 (Id, "q"),
442 (Pu, ";"),
443 (Kw, "let"),
444 (Id, "v"),
445 (Pu, "="),
446 (INT, "1"),
447 (Pu, ";"),
448 ]);
449 let a = normalize(&host_a[2..], LiteralNorm::Full);
450 let b = normalize(&host_b[4..], LiteralNorm::Full);
451 assert_eq!(a, b);
452 }
453
454 #[test]
455 fn keywords_and_punctuation_pass_through() {
456 let a = toks(&[(Kw, "return"), (Pu, ";")]);
457 let n = normalize(&a, LiteralNorm::Full);
458 assert_eq!(n[0].atom, NormAtom::Text("return"));
459 assert_eq!(n[1].atom, NormAtom::Text(";"));
460 }
461
462 /// The same tokens, laid out at distinct byte offsets so a resolution can
463 /// name one of them.
464 fn placed(spec: &[(TokenKind, &str)]) -> Vec<Token> {
465 let mut at = 0;
466 spec.iter()
467 .map(|(kind, text)| {
468 let start = at;
469 at += text.len() + 1;
470 Token {
471 kind: *kind,
472 text: (*text).into(),
473 span: SourceSpan {
474 start_byte: start,
475 end_byte: start + text.len(),
476 start_line: 1,
477 start_column: u32::try_from(start).unwrap() + 1,
478 },
479 }
480 })
481 .collect()
482 }
483
484 fn normalized<'a>(tokens: &'a [Token], resolved: Option<&Resolution>) -> Vec<NormToken<'a>> {
485 let mut out = Vec::new();
486 normalize_resolved_into(tokens, LiteralNorm::Full, resolved, &mut out);
487 out
488 }
489
490 /// The lexical rules preserve a lowercase name only next to `::`, `.`,
491 /// `->` or `!`. A free function imported from another crate is none of
492 /// those, and renaming it lets two fragments that call different libraries
493 /// normalize equal. A compiler knows better.
494 #[test]
495 fn a_name_the_rules_would_rename_is_preserved_when_it_is_resolved_external() {
496 let tokens = placed(&[(Id, "encode"), (Pu, "("), (Id, "value"), (Pu, ")")]);
497 assert_eq!(normalized(&tokens, None)[0].atom, NormAtom::Renamed(0));
498
499 let mut resolution = Resolution::new();
500 resolution.insert(tokens[0].span.start_byte, true);
501 let resolved = normalized(&tokens, Some(&resolution));
502 assert_eq!(resolved[0].atom, NormAtom::Text("encode"));
503 // The name beside it was not resolved, so the rules still decide it.
504 assert_eq!(resolved[2].atom, NormAtom::Renamed(0));
505 }
506
507 /// And the other direction, which is the half that costs recall: the rules
508 /// preserve anything capitalised or reached through a member access, so a
509 /// local binding shaped like one is never renamed and two fragments that
510 /// differ only in that name stop matching.
511 #[test]
512 fn a_name_the_rules_would_preserve_is_renamed_when_it_is_resolved_local() {
513 let tokens = placed(&[(Id, "Buffer"), (Pu, "."), (Id, "len")]);
514 let guessed = normalized(&tokens, None);
515 assert_eq!(guessed[0].atom, NormAtom::Text("Buffer"));
516 assert_eq!(guessed[2].atom, NormAtom::Text("len"));
517
518 let mut resolution = Resolution::new();
519 resolution.insert(tokens[0].span.start_byte, false);
520 let resolved = normalized(&tokens, Some(&resolution));
521 assert_eq!(resolved[0].atom, NormAtom::Renamed(0));
522 assert_eq!(resolved[2].atom, NormAtom::Text("len"));
523 }
524
525 /// Two fragments a compiler resolved the same way normalize equal even
526 /// where the lexical rules keep them apart — which is the point of asking a
527 /// compiler at all.
528 ///
529 /// This is the shape the member-access rule is known to lose: two walks
530 /// over one container that differ only in which link field they follow are
531 /// copies of each other, and the rules read `.next` and `.prev` as
532 /// different code because they cannot tell a field from an API.
533 #[test]
534 fn two_fragments_resolved_alike_normalize_alike() {
535 let a = placed(&[(Id, "node"), (Pu, "."), (Id, "next")]);
536 let b = placed(&[(Id, "node"), (Pu, "."), (Id, "prev")]);
537 assert_ne!(normalized(&a, None), normalized(&b, None));
538
539 let mut ra = Resolution::new();
540 ra.insert(a[2].span.start_byte, false);
541 let mut rb = Resolution::new();
542 rb.insert(b[2].span.start_byte, false);
543 assert_eq!(normalized(&a, Some(&ra)), normalized(&b, Some(&rb)));
544 }
545
546 /// A resolution that says nothing changes nothing, so the modes that run no
547 /// compiler are unaffected by the path existing.
548 #[test]
549 fn an_empty_resolution_normalizes_exactly_as_no_resolution_does() {
550 let tokens = placed(&[(Id, "Value"), (Pu, "::"), (Id, "from"), (Id, "x")]);
551 let empty = Resolution::new();
552 assert!(empty.is_empty());
553 assert_eq!(normalized(&tokens, Some(&empty)), normalized(&tokens, None));
554 }
555
556 /// The last word about one name wins; a resolution cannot hold both
557 /// answers about the same place at once.
558 #[test]
559 fn resolving_one_name_twice_keeps_the_later_answer() {
560 let tokens = placed(&[(Id, "encode")]);
561 let mut resolution = Resolution::new();
562 resolution.insert(tokens[0].span.start_byte, true);
563 resolution.insert(tokens[0].span.start_byte, false);
564 assert_eq!(
565 normalized(&tokens, Some(&resolution))[0].atom,
566 NormAtom::Renamed(0)
567 );
568 }
569}