Skip to content

Commit b58d9ed

Browse files
committed
fix: TypeScript undefined issue
1 parent 18fe271 commit b58d9ed

8 files changed

Lines changed: 309 additions & 5 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
- name: Setup pnpm
2121
uses: pnpm/action-setup@v4
2222
with:
23-
version: latest
23+
version: 10
2424

2525
- name: Setup Node.js
2626
uses: actions/setup-node@v4

src/diff-stats.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { DiffLine, DiffStats } from "./types.js";
2+
3+
/**
4+
* Compute summary statistics from a DiffLine array.
5+
*
6+
* @param lines - Output from diff()
7+
* @returns DiffStats — { added, removed, unchanged }
8+
*
9+
* @example
10+
* const lines = diff(a, b);
11+
* const stats = diffStats(lines);
12+
* // { added: 2, removed: 1, unchanged: 5 }
13+
*/
14+
export function diffStats(lines: DiffLine[]): DiffStats {
15+
return lines.reduce<DiffStats>(
16+
(acc, line) => {
17+
acc[line.type]++;
18+
return acc;
19+
},
20+
{ added: 0, removed: 0, unchanged: 0 },
21+
);
22+
}

src/diff.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { DiffLine } from "./types.js";
2+
3+
function lcs(a: string[], b: string[]): number[][] {
4+
const m = a.length;
5+
const n = b.length;
6+
const dp: number[][] = Array.from({ length: m + 1 }, () =>
7+
new Array(n + 1).fill(0),
8+
);
9+
for (let i = 1; i <= m; i++) {
10+
const row = dp[i]!;
11+
const prevRow = dp[i - 1]!;
12+
for (let j = 1; j <= n; j++) {
13+
row[j] =
14+
a[i - 1] === b[j - 1]
15+
? prevRow[j - 1]! + 1
16+
: Math.max(prevRow[j]!, row[j - 1]!);
17+
}
18+
}
19+
return dp;
20+
}
21+
22+
/**
23+
* Compute a line-by-line diff between two strings.
24+
* Returns an array of DiffLine objects with type and content.
25+
*
26+
* @param a - Original string
27+
* @param b - Modified string
28+
* @returns DiffLine[]
29+
*
30+
* @example
31+
* const lines = diff("hello\nworld", "hello\nearth");
32+
* // [
33+
* // { type: "unchanged", content: "hello" },
34+
* // { type: "removed", content: "world" },
35+
* // { type: "added", content: "earth" },
36+
* // ]
37+
*/
38+
export function diff(a: string, b: string): DiffLine[] {
39+
const aLines = a === "" ? [] : a.split("\n");
40+
const bLines = b === "" ? [] : b.split("\n");
41+
const dp = lcs(aLines, bLines);
42+
43+
const result: DiffLine[] = [];
44+
let i = aLines.length;
45+
let j = bLines.length;
46+
47+
while (i > 0 || j > 0) {
48+
if (i > 0 && j > 0 && aLines[i - 1] === bLines[j - 1]) {
49+
result.unshift({ type: "unchanged", content: aLines[i - 1]! });
50+
i--;
51+
j--;
52+
} else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) {
53+
result.unshift({ type: "added", content: bLines[j - 1]! });
54+
j--;
55+
} else {
56+
result.unshift({ type: "removed", content: aLines[i - 1]! });
57+
i--;
58+
}
59+
}
60+
61+
return result;
62+
}

src/format-diff.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type { DiffLine, DiffOptions } from "./types.js";
2+
3+
interface Chunk {
4+
start: number;
5+
end: number;
6+
}
7+
8+
/**
9+
* Group a DiffLine array into chunks of changed lines
10+
* surrounded by unchanged context lines.
11+
*
12+
* @param lines - Output from diff()
13+
* @param options - DiffOptions (context: number of surrounding lines, default 3)
14+
* @returns DiffLine[][] — array of chunks, each chunk is a DiffLine[]
15+
*
16+
* @example
17+
* const lines = diff(a, b);
18+
* const chunks = formatDiff(lines, { context: 3 });
19+
* chunks.forEach(chunk => chunk.forEach(line => console.log(line)));
20+
*/
21+
export function formatDiff(
22+
lines: DiffLine[],
23+
options: DiffOptions = {},
24+
): DiffLine[][] {
25+
const context = options.context ?? 3;
26+
27+
if (context < 0) {
28+
throw new RangeError("context must be >= 0");
29+
}
30+
31+
const chunks: Chunk[] = [];
32+
33+
for (let i = 0; i < lines.length; i++) {
34+
if (lines[i]!.type !== "unchanged") {
35+
const start = Math.max(0, i - context);
36+
const end = Math.min(lines.length - 1, i + context);
37+
chunks.push({ start, end });
38+
}
39+
}
40+
41+
// merge overlapping / adjacent chunks
42+
const merged: Chunk[] = [];
43+
for (const chunk of chunks) {
44+
if (
45+
merged.length > 0 &&
46+
chunk.start <= merged[merged.length - 1]!.end + 1
47+
) {
48+
merged[merged.length - 1]!.end = Math.max(
49+
merged[merged.length - 1]!.end!,
50+
chunk.end,
51+
);
52+
} else {
53+
merged.push({ ...chunk });
54+
}
55+
}
56+
57+
return merged.map(({ start, end }) => lines.slice(start, end + 1));
58+
}

src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,8 @@
55
* @website https://prasetia.me
66
* @license MIT
77
*/
8+
9+
export { diff } from "./diff.js";
10+
export { formatDiff } from "./format-diff.js";
11+
export { diffStats } from "./diff-stats.js";
12+
export type { DiffLine, DiffType, DiffOptions, DiffStats } from "./types.js";

src/types.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1-
/**
2-
* Configuration options.
3-
*/
1+
export type DiffType = "added" | "removed" | "unchanged";
2+
3+
export interface DiffLine {
4+
type: DiffType;
5+
content: string;
6+
}
7+
8+
export interface DiffOptions {
9+
context?: number;
10+
}
11+
12+
export interface DiffStats {
13+
added: number;
14+
removed: number;
15+
unchanged: number;
16+
}

tests/index.test.ts

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { describe, it, expect } from "vitest";
2+
import { diff, formatDiff, diffStats } from "../src/index.js";
3+
4+
// ── diff() ────────────────────────────────────────────────────────────────────
5+
6+
describe("diff()", () => {
7+
it("returns empty array for identical strings", () => {
8+
const lines = diff("hello\nworld", "hello\nworld");
9+
expect(lines.every((l) => l.type === "unchanged")).toBe(true);
10+
});
11+
12+
it("detects added lines", () => {
13+
const lines = diff("hello", "hello\nworld");
14+
expect(lines).toContainEqual({ type: "added", content: "world" });
15+
});
16+
17+
it("detects removed lines", () => {
18+
const lines = diff("hello\nworld", "hello");
19+
expect(lines).toContainEqual({ type: "removed", content: "world" });
20+
});
21+
22+
it("detects changed lines as remove + add", () => {
23+
const lines = diff("hello\nworld", "hello\nearth");
24+
expect(lines).toContainEqual({ type: "removed", content: "world" });
25+
expect(lines).toContainEqual({ type: "added", content: "earth" });
26+
});
27+
28+
it("handles empty original string", () => {
29+
const lines = diff("", "hello\nworld");
30+
expect(lines.every((l) => l.type === "added")).toBe(true);
31+
});
32+
33+
it("handles empty modified string", () => {
34+
const lines = diff("hello\nworld", "");
35+
expect(lines.every((l) => l.type === "removed")).toBe(true);
36+
});
37+
38+
it("handles both empty strings", () => {
39+
const lines = diff("", "");
40+
expect(lines).toEqual([]);
41+
});
42+
43+
it("preserves line order", () => {
44+
const lines = diff("a\nb\nc", "a\nx\nc");
45+
expect(lines[0]).toEqual({ type: "unchanged", content: "a" });
46+
expect(lines[lines.length - 1]).toEqual({
47+
type: "unchanged",
48+
content: "c",
49+
});
50+
});
51+
52+
it("handles single line change", () => {
53+
const lines = diff("foo", "bar");
54+
expect(lines).toContainEqual({ type: "removed", content: "foo" });
55+
expect(lines).toContainEqual({ type: "added", content: "bar" });
56+
});
57+
58+
it("handles multiline with multiple changes", () => {
59+
const a = "a\nb\nc\nd\ne";
60+
const b = "a\nB\nc\nD\ne";
61+
const lines = diff(a, b);
62+
const changed = lines.filter((l) => l.type !== "unchanged");
63+
expect(changed.length).toBe(4);
64+
});
65+
});
66+
67+
// ── formatDiff() ──────────────────────────────────────────────────────────────
68+
69+
describe("formatDiff()", () => {
70+
it("returns empty array when no changes", () => {
71+
const lines = diff("hello\nworld", "hello\nworld");
72+
expect(formatDiff(lines)).toEqual([]);
73+
});
74+
75+
it("returns one chunk for a single change", () => {
76+
const lines = diff("a\nb\nc", "a\nx\nc");
77+
const chunks = formatDiff(lines, { context: 1 });
78+
expect(chunks.length).toBe(1);
79+
});
80+
81+
it("respects context: 0", () => {
82+
const lines = diff("a\nb\nc", "a\nx\nc");
83+
const chunks = formatDiff(lines, { context: 0 });
84+
expect(chunks[0].every((l) => l.type !== "unchanged")).toBe(true);
85+
});
86+
87+
it("merges adjacent chunks", () => {
88+
const a = "1\n2\n3\n4\n5";
89+
const b = "1\nX\n3\nX\n5";
90+
const chunks = formatDiff(diff(a, b), { context: 0 });
91+
// both changes are close — with context 1 they should merge
92+
const chunksCtx1 = formatDiff(diff(a, b), { context: 1 });
93+
expect(chunksCtx1.length).toBeLessThanOrEqual(chunks.length);
94+
});
95+
96+
it("uses default context of 3", () => {
97+
const lines = diff("a\nb\nc\nd\ne\nf\ng", "a\nb\nc\nX\ne\nf\ng");
98+
const chunks = formatDiff(lines);
99+
// should include 3 lines of context around the change
100+
expect(chunks[0].length).toBeGreaterThanOrEqual(3);
101+
});
102+
103+
it("throws on negative context", () => {
104+
const lines = diff("a", "b");
105+
expect(() => formatDiff(lines, { context: -1 })).toThrow(RangeError);
106+
});
107+
108+
it("does not exceed array bounds with large context", () => {
109+
const lines = diff("a\nb", "a\nx");
110+
expect(() => formatDiff(lines, { context: 100 })).not.toThrow();
111+
});
112+
});
113+
114+
// ── diffStats() ───────────────────────────────────────────────────────────────
115+
116+
describe("diffStats()", () => {
117+
it("returns all unchanged for identical strings", () => {
118+
const stats = diffStats(diff("hello\nworld", "hello\nworld"));
119+
expect(stats.added).toBe(0);
120+
expect(stats.removed).toBe(0);
121+
expect(stats.unchanged).toBe(2);
122+
});
123+
124+
it("counts added lines correctly", () => {
125+
const stats = diffStats(diff("a", "a\nb\nc"));
126+
expect(stats.added).toBe(2);
127+
});
128+
129+
it("counts removed lines correctly", () => {
130+
const stats = diffStats(diff("a\nb\nc", "a"));
131+
expect(stats.removed).toBe(2);
132+
});
133+
134+
it("counts mixed changes correctly", () => {
135+
const stats = diffStats(diff("a\nb\nc", "a\nx\nc"));
136+
expect(stats.added).toBe(1);
137+
expect(stats.removed).toBe(1);
138+
expect(stats.unchanged).toBe(2);
139+
});
140+
141+
it("returns zeros for empty input", () => {
142+
const stats = diffStats([]);
143+
expect(stats).toEqual({ added: 0, removed: 0, unchanged: 0 });
144+
});
145+
});

tests/test.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)