codehelion_core/substitution.rs
1//! What normalization erased: which name became which.
2//!
3//! Type-2 detection works by not looking at identifiers, so two occurrences
4//! that differ only in their names come out equal. That is the right thing for
5//! finding the duplication and the wrong thing for judging it, because the
6//! commonest false positive in a typed language is a set of routines the
7//! language forced apart — one per integer width, one per float width — and
8//! the only thing that says so is the very substitution normalization threw
9//! away.
10//!
11//! This keeps it. A [`Witness`] is the list of name changes that turn one
12//! occurrence into the other, with the two questions worth asking of them
13//! already answered: whether every change is the same width being swapped for
14//! another, and whether any of them changed a literal rather than a name.
15//!
16//! # How the two occurrences are lined up
17//!
18//! By the very thing normalization kept. Two tokens may stand in the same place
19//! when they are the same kind — [`TokenKind::tag`] decides that, and it is the
20//! detector's own answer to what counts as the same token once the spelling is
21//! gone. So the alignment is computed over the normalized run and the
22//! substitutions are read off the raw one: exactly the two halves of what a
23//! Type-2 match is.
24//!
25//! Where an occurrence has tokens the other does not, those are edits rather
26//! than substitutions, and [`Witness::edits`] counts them. Nothing here decides
27//! what an edit means; a rule reading a witness does.
28//!
29//! # What it does not do
30//!
31//! It gives up on a pair too large to align, because the alignment is quadratic
32//! and a bound that is never hit is a bound nobody has tested. Being unable to
33//! say is recorded as [`None`], never as an empty witness.
34
35use crate::frontend::{LiteralKind, Token, TokenKind};
36
37/// Version of the witness rules, for recording alongside the other detector
38/// versions when something acts on one.
39pub const SUBSTITUTION_VERSION: &str = "substitution-v1";
40
41/// Largest product of the two token counts an alignment is computed for.
42///
43/// The table is one `u32` per cell, so this is four megabytes at the top end,
44/// spent on a pair of four-hundred-line bodies. Above it the answer is that
45/// nobody looked, which is what [`None`] says.
46const ALIGNMENT_LIMIT: usize = 1 << 20;
47
48/// The integer widths a type is spelled with.
49///
50/// Written as digits rather than as type names on purpose. A list of type
51/// spellings is a list per language, out of date the moment a project defines
52/// `U32`; the widths are the same three-or-so tokens everywhere, and they are
53/// what the spellings are built out of — `u32`, `int32_t`, `XXH32_hash_t`.
54const WIDTHS: [&str; 5] = ["8", "16", "32", "64", "128"];
55
56/// One name replaced by another.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Change {
59 /// The text on the left-hand occurrence.
60 pub from: String,
61 /// The text on the right-hand occurrence.
62 pub to: String,
63 /// Whether the token that changed was a literal rather than a name.
64 pub literal: bool,
65}
66
67/// The substitutions that turn one occurrence into another.
68#[derive(Debug, Clone, PartialEq, Eq, Default)]
69pub struct Witness {
70 /// Each distinct change, in the order first seen.
71 pub changes: Vec<Change>,
72 /// Tokens on either side that had no counterpart in the other.
73 ///
74 /// A substitution is one token standing where another stood. These are the
75 /// rest: the statement one occurrence has and the other does not, the cast
76 /// added on one side only. Counted rather than described, because a rule
77 /// over a witness cares whether the two bodies do the same work, and that
78 /// question is answered by there being none of these.
79 pub edits: usize,
80}
81
82impl Witness {
83 /// The one width swap that explains every change, if there is one.
84 ///
85 /// `Some(("32", "64"))` says every token that differs does so by having
86 /// that width in place of the other, everywhere it occurs: `u32`/`u64`,
87 /// `read32`/`read64`, `XXH32_hash_t`/`XXH64_hash_t`. That is what a set of
88 /// routines written once per width looks like from the outside, and it is
89 /// not what a copied function looks like — a copy renamed by hand changes
90 /// names that have nothing to do with each other.
91 ///
92 /// `None` when the changes are not all one swap, and for a witness with no
93 /// changes at all: two occurrences that differ in nothing were not written
94 /// one per width, they are the same text.
95 #[must_use]
96 pub fn one_width_apart(&self) -> Option<(&'static str, &'static str)> {
97 if self.changes.is_empty() {
98 return None;
99 }
100 WIDTHS
101 .into_iter()
102 .flat_map(|from| WIDTHS.into_iter().map(move |to| (from, to)))
103 .find(|&(from, to)| {
104 from != to
105 && self.changes.iter().all(|change| {
106 change.from.contains(from) && change.from.replace(from, to) == change.to
107 })
108 })
109 }
110
111 /// Whether these two occurrences read as one routine written once per
112 /// width.
113 ///
114 /// The two questions above, asked together, which is the only way either is
115 /// worth asking. Kept here rather than at each call site so the rule the
116 /// engine acts on and the rule the corpus is measured against cannot drift
117 /// apart.
118 ///
119 /// Nothing is asked about [`Self::edits`]: a routine written for the wider
120 /// type does work the narrower one has no need of, and bounding that would
121 /// mean choosing a number no measurement over real code has supported.
122 #[must_use]
123 pub fn written_once_per_width(&self) -> bool {
124 self.one_width_apart().is_some() && !self.touches_a_literal()
125 }
126
127 /// Whether any change replaced a literal.
128 ///
129 /// A changed constant is a changed answer. Two bodies alike but for the
130 /// number they compare against are two decisions, however alike they read,
131 /// so no rule that sets duplication aside should reach them.
132 #[must_use]
133 pub fn touches_a_literal(&self) -> bool {
134 self.changes.iter().any(|change| change.literal)
135 }
136}
137
138/// The substitutions turning `left` into `right`, or `None` when the pair is
139/// too large to align.
140///
141/// The alignment is the one that pairs off the most tokens, counting an
142/// identical token for twice what a merely same-kind one is worth, so a name
143/// that did not change is never passed over in favour of one that did. Tokens
144/// left unpaired are counted in [`Witness::edits`]; paired tokens whose text
145/// differs are the substitutions.
146#[must_use]
147pub fn witness(left: &[Token], right: &[Token]) -> Option<Witness> {
148 let (rows, columns) = (left.len(), right.len());
149 if rows.checked_mul(columns)? > ALIGNMENT_LIMIT {
150 return None;
151 }
152 let stride = columns + 1;
153
154 // score[i][j] is the best total over the suffixes starting at i and j.
155 // Filled backwards so the traceback can read it forwards.
156 let mut score = vec![0u32; (rows + 1) * stride];
157 for i in (0..rows).rev() {
158 for j in (0..columns).rev() {
159 let paired = pairing(&left[i], &right[j]);
160 let best = score[(i + 1) * stride + j].max(score[i * stride + j + 1]);
161 score[i * stride + j] = if paired > 0 {
162 best.max(score[(i + 1) * stride + j + 1] + paired)
163 } else {
164 best
165 };
166 }
167 }
168
169 let mut changes: Vec<Change> = Vec::new();
170 let mut edits = 0usize;
171 let (mut i, mut j) = (0usize, 0usize);
172 while i < rows && j < columns {
173 let paired = pairing(&left[i], &right[j]);
174 let here = score[i * stride + j];
175 if paired > 0 && here == score[(i + 1) * stride + j + 1] + paired {
176 note(&mut changes, &left[i], &right[j]);
177 i += 1;
178 j += 1;
179 } else if here == score[(i + 1) * stride + j] {
180 i += 1;
181 edits += 1;
182 } else {
183 j += 1;
184 edits += 1;
185 }
186 }
187 // Whatever either run has left over was paired with nothing.
188 edits += (rows - i) + (columns - j);
189 Some(Witness { changes, edits })
190}
191
192/// What pairing these two tokens is worth: nothing unless they are the same
193/// kind, and one more when they are also the same text.
194///
195/// The two numbers are three and two rather than two and one so that the count
196/// of pairings decides first and the identical text only breaks a tie: two
197/// same-kind pairings are worth four and one identical pairing three, so
198/// nothing is ever left unpaired to keep a name intact. That order is the
199/// careful one. Reading a differing name as a substitution puts one more
200/// change in front of a rule that has to explain every one of them, where
201/// reading it as an insertion beside a deletion puts none.
202fn pairing(left: &Token, right: &Token) -> u32 {
203 if left.kind.tag() != right.kind.tag() {
204 0
205 } else if left.text == right.text {
206 3
207 } else {
208 2
209 }
210}
211
212/// Record the change between two paired tokens, unless they are the same text
213/// or the change is already known.
214fn note(changes: &mut Vec<Change>, from: &Token, to: &Token) {
215 if from.text == to.text {
216 return;
217 }
218 let change = Change {
219 from: from.text.to_string(),
220 to: to.text.to_string(),
221 literal: is_literal(from.kind) || is_literal(to.kind),
222 };
223 if !changes.contains(&change) {
224 changes.push(change);
225 }
226}
227
228const fn is_literal(kind: TokenKind) -> bool {
229 matches!(
230 kind,
231 TokenKind::Literal(
232 LiteralKind::Integer
233 | LiteralKind::Float
234 | LiteralKind::String
235 | LiteralKind::Char
236 | LiteralKind::Bool
237 )
238 )
239}
240
241#[cfg(test)]
242#[allow(clippy::unwrap_used, clippy::expect_used)]
243mod tests {
244 use super::*;
245 use crate::frontend::SourceSpan;
246
247 fn tokens(spec: &[(TokenKind, &str)]) -> Vec<Token> {
248 spec.iter()
249 .map(|&(kind, text)| Token {
250 kind,
251 text: text.into(),
252 span: SourceSpan {
253 start_byte: 0,
254 end_byte: text.len(),
255 start_line: 1,
256 start_column: 1,
257 },
258 })
259 .collect()
260 }
261
262 fn name(text: &str) -> (TokenKind, &str) {
263 (TokenKind::Identifier, text)
264 }
265
266 fn number(text: &str) -> (TokenKind, &str) {
267 (TokenKind::Literal(LiteralKind::Integer), text)
268 }
269
270 #[test]
271 fn a_token_with_no_counterpart_is_an_edit_and_not_a_change() {
272 let left = tokens(&[name("a")]);
273 let right = tokens(&[name("a"), name("b")]);
274 let witness = witness(&left, &right).unwrap();
275 assert!(witness.changes.is_empty(), "nothing was substituted");
276 assert_eq!(witness.edits, 1);
277 }
278
279 #[test]
280 fn a_pair_too_large_to_align_has_no_witness() {
281 let long = tokens(&vec![name("a"); 1100]);
282 assert_eq!(witness(&long, &long), None);
283 }
284
285 #[test]
286 fn identical_runs_witness_no_change() {
287 let left = tokens(&[name("a"), name("b")]);
288 let witness = witness(&left, &left).unwrap();
289 assert!(witness.changes.is_empty());
290 assert_eq!(witness.edits, 0);
291 // No change is not one width apart: they are the same text, which is
292 // what a verbatim copy is.
293 assert_eq!(witness.one_width_apart(), None);
294 }
295
296 #[test]
297 fn two_runs_of_one_shape_are_read_as_substitutions_throughout() {
298 // Keeping the `b` would mean leaving a token unpaired on each side. The
299 // alignment does not: same shape, both names changed, which is the
300 // reading a rule over the changes has to answer for.
301 let left = tokens(&[name("b"), name("x")]);
302 let right = tokens(&[name("y"), name("b")]);
303 let witness = witness(&left, &right).unwrap();
304 assert_eq!(witness.edits, 0);
305 assert_eq!(witness.changes.len(), 2);
306 }
307
308 #[test]
309 fn an_identical_name_settles_which_of_two_alignments_is_read() {
310 // Either `b` or `c` can be dropped to line these up. Dropping `b`
311 // leaves `c` against `c`; dropping `c` leaves `b` against `c` and
312 // invents a substitution nobody wrote.
313 let left = tokens(&[name("a"), name("b"), name("c")]);
314 let right = tokens(&[name("a"), name("c")]);
315 let witness = witness(&left, &right).unwrap();
316 assert!(witness.changes.is_empty());
317 assert_eq!(witness.edits, 1);
318 }
319
320 #[test]
321 fn a_width_swap_survives_an_edit_beside_it() {
322 // The 64-bit routine has a step the 32-bit one does not. The names are
323 // still one width apart; the extra work is an edit, and what that is
324 // worth is not this module's question.
325 let left = tokens(&[name("U32"), name("read32")]);
326 let right = tokens(&[name("U64"), name("read64"), name("finalize")]);
327 let witness = witness(&left, &right).unwrap();
328 assert_eq!(witness.one_width_apart(), Some(("32", "64")));
329 assert_eq!(witness.edits, 1);
330 }
331
332 #[test]
333 fn a_kind_that_differs_is_not_paired_off() {
334 let left = tokens(&[name("value")]);
335 let right = tokens(&[number("7")]);
336 let witness = witness(&left, &right).unwrap();
337 assert!(witness.changes.is_empty(), "a name did not become a number");
338 assert_eq!(witness.edits, 2);
339 }
340
341 #[test]
342 fn a_change_is_recorded_once_however_often_it_recurs() {
343 let left = tokens(&[name("u32"), name("u32"), name("u32")]);
344 let right = tokens(&[name("u64"), name("u64"), name("u64")]);
345 let witness = witness(&left, &right).unwrap();
346 assert_eq!(witness.changes.len(), 1);
347 }
348
349 #[test]
350 fn one_width_everywhere_is_recognised() {
351 // `U32 read32(...)` against `U64 read64(...)`: the whole difference is
352 // the width, in the type and in the names built from it.
353 let left = tokens(&[name("U32"), name("XXH_read32"), name("XXH_swap32")]);
354 let right = tokens(&[name("U64"), name("XXH_read64"), name("XXH_swap64")]);
355 assert_eq!(
356 witness(&left, &right).unwrap().one_width_apart(),
357 Some(("32", "64"))
358 );
359 }
360
361 #[test]
362 fn a_rename_that_is_not_a_width_is_not_one() {
363 // A function copied between two amalgamated libraries: one systematic
364 // rename, and nothing to do with a type.
365 let left = tokens(&[name("LZ4_isLittleEndian")]);
366 let right = tokens(&[name("XXH_isLittleEndian")]);
367 assert_eq!(witness(&left, &right).unwrap().one_width_apart(), None);
368 }
369
370 #[test]
371 fn a_width_beside_a_rename_that_is_not_one_is_not_one_either() {
372 // `long`/`short` alongside `m8Index`/`m4Index`: digits change, but not
373 // every change is that digit, so the pair was not written per width.
374 let left = tokens(&[name("long"), name("m8Index")]);
375 let right = tokens(&[name("short"), name("m4Index")]);
376 assert_eq!(witness(&left, &right).unwrap().one_width_apart(), None);
377 }
378
379 #[test]
380 fn a_changed_constant_is_visible_as_one() {
381 let left = tokens(&[name("wait"), number("24")]);
382 let right = tokens(&[name("wait"), number("1")]);
383 assert!(witness(&left, &right).unwrap().touches_a_literal());
384 }
385
386 #[test]
387 fn a_constant_that_reads_like_a_width_is_still_a_constant() {
388 // The digits alone do not say a type was involved. Two bodies that
389 // differ in a number are two answers, and a rule that sets width
390 // families aside has to be able to tell that apart from a type name.
391 let left = tokens(&[number("32")]);
392 let right = tokens(&[number("64")]);
393 let witness = witness(&left, &right).unwrap();
394 assert_eq!(witness.one_width_apart(), Some(("32", "64")));
395 assert!(witness.touches_a_literal());
396 }
397}