Skip to content

Commit 78d4925

Browse files
committed
fix(render): clamp child span bounds to parent in buildSpanTree
When a child entity's span extends past its parent (e.g. the last quantity in a credit card number where `dot` consumes a trailing period), the node's rendering bounds are now clamped to the parent's end. The entity data itself is unchanged — callbacks still see the original start/end — only the tree position is tightened so text is not duplicated or lost. Fixes: last number group in CC/SSN missing from annotated output.
1 parent 44f66cb commit 78d4925

2 files changed

Lines changed: 24 additions & 11 deletions

File tree

src/render.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -109,24 +109,21 @@ export function buildSpanTree(
109109
stack.pop();
110110
}
111111

112-
// Pop parents that only partially contain this entity (one-sided overlap).
113-
// Full containment (start >= parent.start AND end <= parent.end) → child.
114-
// Partial overlap (extends past parent) → independent, try a higher parent.
115-
while (
116-
stack.length > 1 &&
117-
e.end > stack[stack.length - 1].end
118-
) {
119-
stack.pop();
120-
}
121-
122112
const parent = stack[stack.length - 1];
123113

124114
// Skip if this entity starts before the parent
125115
if (e.start < parent.start) continue;
126116

117+
// Clamp rendering bounds to the parent. The entity data is unchanged —
118+
// only the tree node position is tightened so text isn't duplicated.
119+
// Example: quantity "4242." [18,23] inside credit_card [3,22]
120+
// → node renders [18,22], callback still sees entity.end === 23.
121+
const clampedEnd = Math.min(e.end, parent.end);
122+
if (clampedEnd <= e.start) continue;
123+
127124
const node: SpanNode = {
128125
start: e.start,
129-
end: e.end,
126+
end: clampedEnd,
130127
entity: e,
131128
children: [],
132129
};

tests/render.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { assertEquals } from "@std/assert";
22
import {
3+
CreditCard,
34
Duckling,
45
Email,
56
PIIParsers,
@@ -110,6 +111,21 @@ Deno.test("render: nested children text is passed pre-rendered", () => {
110111
assertEquals(ssnCall!.children, "[quantity]-[quantity]-[quantity]");
111112
});
112113

114+
Deno.test("render: CC with trailing dot — all four groups rendered inside parent", () => {
115+
// The `dot` combinator in the last quantity consumes the trailing ".",
116+
// making the child span extend past the CC parent. buildSpanTree clamps
117+
// the child's rendering bounds to the parent so all four groups appear
118+
// and the "." falls outside the CC span.
119+
const result = Duckling([Quantity.parser, CreditCard.parser]).render(
120+
"CC 4242 4242 4242 4242.",
121+
({ entity, children }) => `<${entity.kind}>${children}</${entity.kind}>`,
122+
);
123+
assertEquals(
124+
result,
125+
"CC <credit_card><quantity>4242</quantity> <quantity>4242</quantity> <quantity>4242</quantity> <quantity>4242</quantity></credit_card>.",
126+
);
127+
});
128+
113129
// ── Edge cases ──────────────────────────────────────────────────────
114130

115131
Deno.test("render: empty input", () => {

0 commit comments

Comments
 (0)