diff --git a/lib/src/scan/extract.test.ts b/lib/src/scan/extract.test.ts new file mode 100644 index 0000000..341a73d --- /dev/null +++ b/lib/src/scan/extract.test.ts @@ -0,0 +1,47 @@ +import { assignOccurrenceIndexes } from "./extract"; + +const hit = (value: string, line: number, column = 1) => ({ + value, + location: { line, column }, +}); + +describe("assignOccurrenceIndexes", () => { + test("numbers repeats in source order", () => { + const hits = [hit("Save", 40, 8), hit("Cancel", 12), hit("Save", 9)]; + + expect(assignOccurrenceIndexes(hits)).toEqual([1, 0, 0]); + }); + + test("numbers repeats regardless of the order hits arrive in", () => { + const sourceOrder = [hit("Save", 9), hit("Save", 22), hit("Save", 40)]; + const kindGrouped = [hit("Save", 40), hit("Save", 9), hit("Save", 22)]; + + expect(assignOccurrenceIndexes(sourceOrder)).toEqual([0, 1, 2]); + expect(assignOccurrenceIndexes(kindGrouped)).toEqual([2, 0, 1]); + }); + + test("is stable when lines shift", () => { + const before = [hit("Save", 9), hit("Save", 40, 8)]; + const after = [hit("Save", 11), hit("Save", 42, 8)]; + + expect(assignOccurrenceIndexes(after)).toEqual( + assignOccurrenceIndexes(before) + ); + }); + + test("counts each distinct value separately", () => { + const hits = [hit("Save", 1), hit("Cancel", 2), hit("Save", 3)]; + + expect(assignOccurrenceIndexes(hits)).toEqual([0, 0, 1]); + }); + + test("orders hits on the same line by column", () => { + const hits = [hit("Save", 5, 30), hit("Save", 5, 10)]; + + expect(assignOccurrenceIndexes(hits)).toEqual([1, 0]); + }); + + test("returns an empty result for no hits", () => { + expect(assignOccurrenceIndexes([])).toEqual([]); + }); +}); diff --git a/lib/src/scan/extract.ts b/lib/src/scan/extract.ts index fdde4f0..49d4288 100644 --- a/lib/src/scan/extract.ts +++ b/lib/src/scan/extract.ts @@ -2,14 +2,15 @@ import fs from "fs/promises"; import { globby } from "globby"; import path from "path"; +import { createHash } from "crypto"; +import type { FileDiscoveryStats } from "./lang/file-discovery"; +import type { ExtractedHit } from "./lang/types"; +import { shouldEmit } from "./rules"; import { DittoScanDetectionKindSchema, type DittoScanCandidate, type DittoScanDetectionKind, } from "./types"; -import { createHash } from "crypto"; -import type { FileDiscoveryStats } from "./lang/file-discovery"; -import { shouldEmit } from "./rules"; import { walkCodebase } from "./walk"; export interface DittoScanExtractOptions { @@ -231,6 +232,29 @@ export function makeCandidateId( .slice(0, 12); } +export function assignOccurrenceIndexes( + hits: readonly Pick[] +): number[] { + const sourceOrder = hits + .map((_, index) => index) + .sort((a, b) => { + const left = hits[a].location; + const right = hits[b].location; + return left.line - right.line || left.column - right.column || a - b; + }); + + const seen = new Map(); + const indexes = new Array(hits.length); + + for (const index of sourceOrder) { + const count = seen.get(hits[index].value) ?? 0; + indexes[index] = count; + seen.set(hits[index].value, count + 1); + } + + return indexes; +} + export async function runExtract( opts: DittoScanExtractOptions ): Promise { @@ -266,8 +290,10 @@ export async function runExtract( const lines = file.source.split(/\r?\n/); - for (const hit of hits) { - if (!shouldEmit(hit.value, hit.context)) continue; + const emitted = hits.filter((hit) => shouldEmit(hit.value, hit.context)); + const occurrenceIndexes = assignOccurrenceIndexes(emitted); + + for (const [index, hit] of emitted.entries()) { const candidate: DittoScanCandidate = { id: makeCandidateId( file.relPath, @@ -282,6 +308,7 @@ export async function runExtract( line: hit.location.line, column: hit.location.column, }, + occurrence_index: occurrenceIndexes[index], language: file.languageLabel, locale_key: hit.localeKey ?? file.localeKey, i18n_key: hit.i18nKey ?? null, diff --git a/lib/src/scan/types.ts b/lib/src/scan/types.ts index c58dee3..e3503e0 100644 --- a/lib/src/scan/types.ts +++ b/lib/src/scan/types.ts @@ -85,6 +85,7 @@ export const DittoScanCandidateSchema = z.object({ line: z.number().int().positive(), column: z.number().int().positive(), }), + occurrence_index: z.number().int().nonnegative(), language: z.string(), // Locale key derived from the file's path when the candidate comes from a // per-locale i18n resource file admitted by i18n file discovery (e.g. "en"