Skip to content

Commit 5151cca

Browse files
committed
fix(edit): replace segment-only NFKC mapper with full composition grouping
The mapLineOffsetThroughNfkc function previously only grouped base characters with following combining marks (\p{M}). NFKC can also compose adjacent non-mark code points (e.g., Hangul Compatibility Jamo U+3131 + U+314F compose to U+AC00). This caused offset miscalculation when such sequences appeared before a fuzzy-matched region. Replace the combining-mark-only grouping with a greedy approach that extends each segment as long as the next code point participates in NFKC composition (i.e., normalizing the extended segment differs from normalizing each part separately). Add a Hangul Jamo regression test that verifies correct offset mapping when Compatibility Jamo compose under NFKC. Signed-off-by: Sebastien Tardif <[email protected]>
1 parent 17ee761 commit 5151cca

2 files changed

Lines changed: 41 additions & 9 deletions

File tree

src/agents/sessions/tools/edit-diff.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,30 @@ describe("applyEditsToNormalizedContent", () => {
194194
expect(lines[1]).toBe("footer");
195195
});
196196

197+
it("fuzzy match handles Hangul Jamo composition (non-combining-mark NFKC merge)", () => {
198+
// Hangul Compatibility Jamo ㄱ (U+3131) + ㅏ (U+314F) compose to 가
199+
// under NFKC (2 code points -> 1), but ㅏ is NOT a combining mark
200+
// (\p{M}), so the old segment-only mapper that only grouped base +
201+
// combining marks would treat them as separate segments and miscount
202+
// the NFKC offsets.
203+
const content = [
204+
"\u3131\u314F X = \u2018val\u2019;",
205+
"footer",
206+
].join("\n");
207+
208+
// Smart quotes trigger fuzzy matching; ㄱㅏ -> 가 shifts positions
209+
const result = applyEditsToNormalizedContent(
210+
normalizeToLF(content),
211+
[{ oldText: "X = 'val';", newText: "Y = 'new';" }],
212+
"test.ts",
213+
);
214+
215+
const lines = result.newContent.split("\n");
216+
// X must be consumed (replaced); Hangul Jamo preserved
217+
expect(lines[0]).toBe("\u3131\u314F Y = 'new';");
218+
expect(lines[1]).toBe("footer");
219+
});
220+
197221
it("baseContent is always the original content", () => {
198222
const content = "line with smart\u2019s\nline with trailing ";
199223

src/agents/sessions/tools/edit-diff.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,9 @@ function getNoChangeError(path: string, totalEdits: number): EditNoChangeError {
206206
* line. The other fuzzy normalizations (trimEnd, smart quotes, dashes,
207207
* special spaces) are same-length replacements that do not shift positions.
208208
*
209-
* This function groups each base character with its following combining marks
210-
* and normalizes the complete segment, so cross-code-point NFKC composition
211-
* is handled correctly.
209+
* This function groups code points into composition blocks by checking
210+
* whether adjacent code points interact under NFKC (combining marks,
211+
* Hangul Jamo L+V+T composition, and any other cross-code-point merges).
212212
*/
213213
function mapLineOffsetThroughNfkc(origLine: string, normOffset: number, snapToEnd = false): number {
214214
const nfkcLine = origLine.normalize("NFKC");
@@ -220,9 +220,10 @@ function mapLineOffsetThroughNfkc(origLine: string, normOffset: number, snapToEn
220220
return Math.min(normOffset, origLine.length);
221221
}
222222

223-
// Walk through original code points, grouping each base character with its
224-
// following combining marks. Normalize each complete group with NFKC to
225-
// find how many normalized code units it produces.
223+
// Walk through original code points, grouping each base character with
224+
// all following code points that participate in NFKC composition with it.
225+
// This handles combining marks, Hangul Jamo (L+V+T → syllable), and any
226+
// other cross-code-point compositions that NFKC performs.
226227
let origPos = 0;
227228
let nfkcPos = 0;
228229
const codePoints = Array.from(origLine);
@@ -233,12 +234,19 @@ function mapLineOffsetThroughNfkc(origLine: string, normOffset: number, snapToEn
233234
return origPos;
234235
}
235236

236-
// Collect base code point + any following combining marks as one segment.
237+
// Greedily extend the segment as long as the next code point
238+
// participates in NFKC composition with the current segment.
237239
let segment = codePoints[i];
238240
let segOrigLen = codePoints[i].length;
239241
i++;
240-
while (i < codePoints.length && /^\p{M}/u.test(codePoints[i])) {
241-
segment += codePoints[i];
242+
while (i < codePoints.length) {
243+
const extended = segment + codePoints[i];
244+
const extNfkc = extended.normalize("NFKC");
245+
const separateNfkc = segment.normalize("NFKC") + codePoints[i].normalize("NFKC");
246+
if (extNfkc === separateNfkc) {
247+
break; // next code point does not compose with segment
248+
}
249+
segment = extended;
242250
segOrigLen += codePoints[i].length;
243251
i++;
244252
}

0 commit comments

Comments
 (0)