Skip to main content

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}