matching.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. import { normalizeUnicode } from './codec';
  2. import type {
  3. LineComparator,
  4. MatchComparatorName,
  5. MatchHit,
  6. RescueResult,
  7. SeekHit,
  8. } from './types';
  9. type NamedComparator = {
  10. name: MatchComparatorName;
  11. exact: boolean;
  12. same: LineComparator;
  13. };
  14. export type PreparedAutoRescueTarget = {
  15. exact: string;
  16. unicode: string;
  17. trimEnd: string;
  18. unicodeTrimEnd: string;
  19. };
  20. export function equalExact(a: string, b: string): boolean {
  21. return a === b;
  22. }
  23. export function equalUnicodeExact(a: string, b: string): boolean {
  24. return normalizeUnicode(a) === normalizeUnicode(b);
  25. }
  26. export function equalTrimEnd(a: string, b: string): boolean {
  27. return a.trimEnd() === b.trimEnd();
  28. }
  29. export function equalUnicodeTrimEnd(a: string, b: string): boolean {
  30. return normalizeUnicode(a.trimEnd()) === normalizeUnicode(b.trimEnd());
  31. }
  32. export function equalTrim(a: string, b: string): boolean {
  33. return a.trim() === b.trim();
  34. }
  35. export function equalUnicodeTrim(a: string, b: string): boolean {
  36. return normalizeUnicode(a.trim()) === normalizeUnicode(b.trim());
  37. }
  38. const autoRescueComparatorEntries: NamedComparator[] = [
  39. { name: 'exact', exact: true, same: equalExact },
  40. { name: 'unicode', exact: false, same: equalUnicodeExact },
  41. { name: 'trim-end', exact: false, same: equalTrimEnd },
  42. {
  43. name: 'unicode-trim-end',
  44. exact: false,
  45. same: equalUnicodeTrimEnd,
  46. },
  47. ];
  48. const comparatorEntries: NamedComparator[] = [
  49. ...autoRescueComparatorEntries,
  50. { name: 'trim', exact: false, same: equalTrim },
  51. { name: 'unicode-trim', exact: false, same: equalUnicodeTrim },
  52. ];
  53. const MAX_LCS_CHUNK_LINES = 48;
  54. const MAX_LCS_CANDIDATES = 64;
  55. export const autoRescueComparators: LineComparator[] =
  56. autoRescueComparatorEntries.map((entry) => entry.same);
  57. export function prepareAutoRescueTarget(
  58. target: string,
  59. ): PreparedAutoRescueTarget {
  60. const trimEnd = target.trimEnd();
  61. const unicode = normalizeUnicode(target);
  62. return {
  63. exact: target,
  64. unicode,
  65. trimEnd,
  66. unicodeTrimEnd: trimEnd === target ? unicode : normalizeUnicode(trimEnd),
  67. };
  68. }
  69. export function matchPreparedAutoRescueComparator(
  70. candidate: string,
  71. target: PreparedAutoRescueTarget,
  72. ): MatchComparatorName | undefined {
  73. if (candidate === target.exact) {
  74. return 'exact';
  75. }
  76. const unicode = normalizeUnicode(candidate);
  77. if (unicode === target.unicode) {
  78. return 'unicode';
  79. }
  80. const trimEnd = candidate.trimEnd();
  81. if (trimEnd === target.trimEnd) {
  82. return 'trim-end';
  83. }
  84. const unicodeTrimEnd =
  85. trimEnd === candidate ? unicode : normalizeUnicode(trimEnd);
  86. if (unicodeTrimEnd === target.unicodeTrimEnd) {
  87. return 'unicode-trim-end';
  88. }
  89. return undefined;
  90. }
  91. // Full-trim comparators remain available as explicit utilities, but stay out
  92. // of automatic canonicalization because they can cross indentation levels and
  93. // rescue semantically unsafe patches.
  94. export const permissiveComparators: LineComparator[] = comparatorEntries.map(
  95. (entry) => entry.same,
  96. );
  97. function tryMatch(
  98. lines: string[],
  99. pattern: string[],
  100. start: number,
  101. comparator: NamedComparator,
  102. eof: boolean,
  103. ): SeekHit | undefined {
  104. if (eof) {
  105. const at = lines.length - pattern.length;
  106. if (at >= start) {
  107. let ok = true;
  108. for (let index = 0; index < pattern.length; index += 1) {
  109. if (!comparator.same(lines[at + index], pattern[index])) {
  110. ok = false;
  111. break;
  112. }
  113. }
  114. if (ok) {
  115. return {
  116. index: at,
  117. comparator: comparator.name,
  118. exact: comparator.exact,
  119. };
  120. }
  121. }
  122. }
  123. for (let index = start; index <= lines.length - pattern.length; index += 1) {
  124. let ok = true;
  125. for (let inner = 0; inner < pattern.length; inner += 1) {
  126. if (!comparator.same(lines[index + inner], pattern[inner])) {
  127. ok = false;
  128. break;
  129. }
  130. }
  131. if (ok) {
  132. return {
  133. index,
  134. comparator: comparator.name,
  135. exact: comparator.exact,
  136. };
  137. }
  138. }
  139. return undefined;
  140. }
  141. export function seekMatch(
  142. lines: string[],
  143. pattern: string[],
  144. start: number,
  145. eof = false,
  146. ): SeekHit | undefined {
  147. if (pattern.length === 0) {
  148. return undefined;
  149. }
  150. for (const comparator of autoRescueComparatorEntries) {
  151. const hit = tryMatch(lines, pattern, start, comparator, eof);
  152. if (hit) {
  153. return hit;
  154. }
  155. }
  156. return undefined;
  157. }
  158. export function seek(
  159. lines: string[],
  160. pattern: string[],
  161. start: number,
  162. eof = false,
  163. ): number {
  164. return seekMatch(lines, pattern, start, eof)?.index ?? -1;
  165. }
  166. export function list(
  167. lines: string[],
  168. pattern: string[],
  169. start: number,
  170. same: LineComparator,
  171. ): number[] {
  172. if (pattern.length === 0) {
  173. return [];
  174. }
  175. const out: number[] = [];
  176. for (let index = start; index <= lines.length - pattern.length; index += 1) {
  177. let ok = true;
  178. for (let inner = 0; inner < pattern.length; inner += 1) {
  179. if (!same(lines[index + inner], pattern[inner])) {
  180. ok = false;
  181. break;
  182. }
  183. }
  184. if (ok) {
  185. out.push(index);
  186. }
  187. }
  188. return out;
  189. }
  190. function lowerBound(values: number[], target: number): number {
  191. let low = 0;
  192. let high = values.length;
  193. while (low < high) {
  194. const middle = Math.floor((low + high) / 2);
  195. if (values[middle] < target) {
  196. low = middle + 1;
  197. continue;
  198. }
  199. high = middle;
  200. }
  201. return low;
  202. }
  203. export function sameRescueLine(a: string, b: string): boolean {
  204. return equalExact(a, b) || equalUnicodeExact(a, b);
  205. }
  206. export function prefix(old_lines: string[], new_lines: string[]): number {
  207. let index = 0;
  208. while (
  209. index < old_lines.length &&
  210. index < new_lines.length &&
  211. sameRescueLine(old_lines[index], new_lines[index])
  212. ) {
  213. index += 1;
  214. }
  215. return index;
  216. }
  217. export function suffix(
  218. old_lines: string[],
  219. new_lines: string[],
  220. prefixLength: number,
  221. ): number {
  222. let index = 0;
  223. while (
  224. old_lines.length - index - 1 >= prefixLength &&
  225. new_lines.length - index - 1 >= prefixLength &&
  226. sameRescueLine(
  227. old_lines[old_lines.length - index - 1],
  228. new_lines[new_lines.length - index - 1],
  229. )
  230. ) {
  231. index += 1;
  232. }
  233. return index;
  234. }
  235. export function rescueByPrefixSuffix(
  236. lines: string[],
  237. old_lines: string[],
  238. new_lines: string[],
  239. start: number,
  240. ): RescueResult {
  241. const prefixLength = prefix(old_lines, new_lines);
  242. const suffixLength = suffix(old_lines, new_lines, prefixLength);
  243. if (prefixLength === 0 || suffixLength === 0) {
  244. return { kind: 'miss' };
  245. }
  246. const left = old_lines.slice(0, prefixLength);
  247. const right = old_lines.slice(old_lines.length - suffixLength);
  248. const middle = new_lines.slice(prefixLength, new_lines.length - suffixLength);
  249. if (left.length === 1 && right.length === 1) {
  250. const { leftHits, rightHits } = collectOneLinePrefixSuffixHits(
  251. lines,
  252. left[0],
  253. right[0],
  254. start,
  255. );
  256. return resolvePrefixSuffixHits(leftHits, rightHits, left.length, middle);
  257. }
  258. const hits = new Set<string>();
  259. let hit: MatchHit | undefined;
  260. for (const same of autoRescueComparators) {
  261. const leftHits = list(lines, left, start, same);
  262. if (leftHits.length === 0) {
  263. continue;
  264. }
  265. const rightHits = list(lines, right, leftHits[0] + left.length, same);
  266. if (rightHits.length === 0) {
  267. continue;
  268. }
  269. for (const leftIndex of leftHits) {
  270. const from = leftIndex + left.length;
  271. for (
  272. let index = lowerBound(rightHits, from);
  273. index < rightHits.length;
  274. index += 1
  275. ) {
  276. const rightIndex = rightHits[index];
  277. const key = `${from}:${rightIndex}`;
  278. if (!hits.has(key)) {
  279. hits.add(key);
  280. hit = {
  281. start: from,
  282. del: rightIndex - from,
  283. add: [...middle],
  284. };
  285. }
  286. if (hits.size > 1) {
  287. return { kind: 'ambiguous', phase: 'prefix_suffix' };
  288. }
  289. }
  290. }
  291. }
  292. if (!hit) {
  293. return { kind: 'miss' };
  294. }
  295. return { kind: 'match', hit };
  296. }
  297. function collectOneLinePrefixSuffixHits(
  298. lines: string[],
  299. left: string,
  300. right: string,
  301. start: number,
  302. ): { leftHits: number[]; rightHits: number[] } {
  303. const leftTarget = prepareAutoRescueTarget(left);
  304. const rightTarget = prepareAutoRescueTarget(right);
  305. const leftHits: number[] = [];
  306. const rightHits: number[] = [];
  307. // The one-line prefix/suffix fast path intentionally compares at the
  308. // broadest safe automatic level. This preserves exact/unicode/trim-end
  309. // behavior while avoiding multiple full scans for the common one-line edge
  310. // case. Full-trim remains excluded from automatic rescue.
  311. for (let index = start; index < lines.length; index += 1) {
  312. const line = prepareAutoRescueTarget(lines[index]);
  313. if (line.unicodeTrimEnd === leftTarget.unicodeTrimEnd) {
  314. leftHits.push(index);
  315. }
  316. if (index > start && line.unicodeTrimEnd === rightTarget.unicodeTrimEnd) {
  317. rightHits.push(index);
  318. }
  319. }
  320. return { leftHits, rightHits };
  321. }
  322. function resolvePrefixSuffixHits(
  323. leftHits: number[],
  324. rightHits: number[],
  325. leftLength: number,
  326. middle: string[],
  327. ): RescueResult {
  328. if (leftHits.length === 0 || rightHits.length === 0) {
  329. return { kind: 'miss' };
  330. }
  331. const hits = new Set<string>();
  332. let hit: MatchHit | undefined;
  333. for (const leftIndex of leftHits) {
  334. const from = leftIndex + leftLength;
  335. for (
  336. let index = lowerBound(rightHits, from);
  337. index < rightHits.length;
  338. index += 1
  339. ) {
  340. const rightIndex = rightHits[index];
  341. const key = `${from}:${rightIndex}`;
  342. if (!hits.has(key)) {
  343. hits.add(key);
  344. hit = {
  345. start: from,
  346. del: rightIndex - from,
  347. add: [...middle],
  348. };
  349. }
  350. if (hits.size > 1) {
  351. return { kind: 'ambiguous', phase: 'prefix_suffix' };
  352. }
  353. }
  354. }
  355. if (!hit) {
  356. return { kind: 'miss' };
  357. }
  358. return { kind: 'match', hit };
  359. }
  360. export function score(a: string[], b: string[]): number {
  361. const normalizedA = a.map(normalizeLcsLine);
  362. const normalizedB = b.map(normalizeLcsLine);
  363. let previous = Array<number>(b.length + 1).fill(0);
  364. for (let i = 1; i <= a.length; i += 1) {
  365. const current = Array<number>(b.length + 1).fill(0);
  366. for (let j = 1; j <= b.length; j += 1) {
  367. current[j] =
  368. normalizedA[i - 1] === normalizedB[j - 1]
  369. ? previous[j - 1] + 1
  370. : Math.max(previous[j], current[j - 1]);
  371. }
  372. previous = current;
  373. }
  374. return previous[b.length];
  375. }
  376. function normalizeLcsLine(line: string): string {
  377. return normalizeUnicode(line).trim();
  378. }
  379. function countLcsUpperBound(a: string[], b: string[]): number {
  380. const counts = new Map<string, number>();
  381. for (const line of a) {
  382. const key = normalizeLcsLine(line);
  383. counts.set(key, (counts.get(key) ?? 0) + 1);
  384. }
  385. let shared = 0;
  386. for (const line of b) {
  387. const key = normalizeLcsLine(line);
  388. const available = counts.get(key) ?? 0;
  389. if (available === 0) {
  390. continue;
  391. }
  392. shared += 1;
  393. if (available === 1) {
  394. counts.delete(key);
  395. continue;
  396. }
  397. counts.set(key, available - 1);
  398. }
  399. return shared;
  400. }
  401. function collectBorderAnchoredStarts(
  402. lines: string[],
  403. oldLines: string[],
  404. start: number,
  405. ): number[] {
  406. if (oldLines.length === 0) {
  407. return [];
  408. }
  409. const candidates: number[] = [];
  410. const firstLine = prepareAutoRescueTarget(oldLines[0]);
  411. const lastLine = prepareAutoRescueTarget(oldLines[oldLines.length - 1]);
  412. // LCS keeps its current scoring, but only competes across windows whose
  413. // edges pass safe comparators. Ignoring full-trim here prevents automatic
  414. // rescue from changing indentation depth in format-sensitive files.
  415. const lastOffset = oldLines.length - 1;
  416. const maxStart = lines.length - oldLines.length;
  417. for (let index = start; index <= maxStart; index += 1) {
  418. const end = index + lastOffset;
  419. if (
  420. matchPreparedAutoRescueComparator(lines[index], firstLine) === undefined
  421. ) {
  422. continue;
  423. }
  424. if (
  425. oldLines.length === 1 ||
  426. matchPreparedAutoRescueComparator(lines[end], lastLine) !== undefined
  427. ) {
  428. candidates.push(index);
  429. }
  430. }
  431. return candidates;
  432. }
  433. export function rescueByLcs(
  434. lines: string[],
  435. old_lines: string[],
  436. new_lines: string[],
  437. start: number,
  438. ): RescueResult {
  439. if (old_lines.length === 0 || lines.length === 0) {
  440. return { kind: 'miss' };
  441. }
  442. if (old_lines.length > MAX_LCS_CHUNK_LINES) {
  443. return { kind: 'miss' };
  444. }
  445. const needed =
  446. old_lines.length <= 2
  447. ? old_lines.length
  448. : Math.max(2, Math.ceil(old_lines.length * 0.7));
  449. const candidates = collectBorderAnchoredStarts(lines, old_lines, start);
  450. if (candidates.length === 0 || candidates.length > MAX_LCS_CANDIDATES) {
  451. return { kind: 'miss' };
  452. }
  453. let best: MatchHit | undefined;
  454. let bestScore = 0;
  455. let ties = 0;
  456. for (const index of candidates) {
  457. const window = lines.slice(index, index + old_lines.length);
  458. if (countLcsUpperBound(old_lines, window) < needed) {
  459. continue;
  460. }
  461. const current = score(old_lines, window);
  462. if (current > bestScore) {
  463. bestScore = current;
  464. ties = 1;
  465. best = {
  466. start: index,
  467. del: old_lines.length,
  468. add: [...new_lines],
  469. };
  470. continue;
  471. }
  472. if (current === bestScore && current > 0) {
  473. ties += 1;
  474. }
  475. }
  476. if (!best || bestScore < needed) {
  477. return { kind: 'miss' };
  478. }
  479. if (ties > 1) {
  480. return { kind: 'ambiguous', phase: 'lcs' };
  481. }
  482. return { kind: 'match', hit: best };
  483. }