import { describe, expect, it } from "vitest";
import {
chunkLines,
matchCount,
mergeSpansWithTokens,
type Span,
type ThemedToken
} from "#lib/components/search/chunks.ts";
/** compact view of a line: line number + its spans as "plain" / "[match]" */
const shape = (chunk: Parameters<typeof chunkLines>[0], tokens?: ThemedToken[][]) =>
chunkLines(chunk, tokens).map((l) => [
l.num,
l.spans.map((s) => (s.match ? `[${s.text}]` : s.text))
]);
describe("chunkLines", () => {
it("numbers lines from lineStart and splits each into plain/matched spans", () => {
// "main" at bytes 5-9, "main" again at 21-25
const content = "func main() {\n\tcallMain()\n}\n";
expect(shape({ content, lineStart: 12, highlights: [{ start: 5, end: 9 }] })).toEqual([
[12, ["func ", "[main]", "() {"]],
[13, ["\tcallMain()"]],
[14, ["}"]]
]);
});
it("clips a highlight that spans a newline to each line it covers", () => {
// bytes 2-8 cover "c\nde" — "b\n" is at 1-3
const content = "abc\ndef";
expect(shape({ content, lineStart: 1, highlights: [{ start: 2, end: 6 }] })).toEqual([
[1, ["ab", "[c]"]],
[2, ["[de]", "f"]]
]);
});
it("uses byte offsets, not UTF-16 indices", () => {
// "héllo wörld": h(1) é(2) l l o space w -> "wörld" starts at byte 7
const content = "héllo wörld";
expect(shape({ content, lineStart: 1, highlights: [{ start: 7, end: 13 }] })).toEqual([
[1, ["héllo ", "[wörld]"]]
]);
});
it("emits an empty span list for a blank line and no spurious trailing line", () => {
expect(shape({ content: "a\n\nb\n", lineStart: 3 })).toEqual([
[3, ["a"]],
[4, []],
[5, ["b"]]
]);
});
it("merges overlapping and adjacent highlights", () => {
const content = "abcdef";
expect(
shape({
content,
lineStart: 1,
highlights: [
{ start: 3, end: 5 },
{ start: 1, end: 3 },
{ start: 4, end: 6 }
]
})
).toEqual([[1, ["a", "[bcdef]"]]]);
});
it("applies syntax tokens to line spans", () => {
const content = "const x = 1;";
const tokens: ThemedToken[][] = [
[
{ content: "const", color: "kw" },
{ content: " ", color: "plain" },
{ content: "x", color: "ident" },
{ content: " = ", color: "punct" },
{ content: "1;", color: "num" }
]
];
const lines = chunkLines(
{ content, lineStart: 1, highlights: [{ start: 6, end: 7 }] },
tokens
);
expect(lines[0].spans).toEqual([
{ text: "const", color: "kw" },
{ text: " ", color: "plain" },
{ text: "x", match: true, color: "ident" },
{ text: " = ", color: "punct" },
{ text: "1;", color: "num" }
]);
});
});
describe("mergeSpansWithTokens", () => {
it("splits tokens across search match boundaries", () => {
const spans: Span[] = [{ text: "func " }, { text: "main", match: true }, { text: "()" }];
const tokens: ThemedToken[] = [
{ content: "func", color: "kw" },
{ content: " ", color: "plain" },
{ content: "main", color: "func" },
{ content: "()", color: "punct" }
];
expect(mergeSpansWithTokens(spans, tokens)).toEqual([
{ text: "func", color: "kw" },
{ text: " ", color: "plain" },
{ text: "main", match: true, color: "func" },
{ text: "()", color: "punct" }
]);
});
it("splits a single token when a match falls inside it", () => {
const spans: Span[] = [
{ text: 'println("hello ' },
{ text: "world", match: true },
{ text: '")' }
];
const tokens: ThemedToken[] = [
{ content: "println", color: "func" },
{ content: "(", color: "punct" },
{ content: '"hello world"', color: "str" },
{ content: ")", color: "punct" }
];
expect(mergeSpansWithTokens(spans, tokens)).toEqual([
{ text: "println", color: "func" },
{ text: "(", color: "punct" },
{ text: '"hello ', color: "str" },
{ text: "world", match: true, color: "str" },
{ text: '"', color: "str" },
{ text: ")", color: "punct" }
]);
});
it("returns original spans if tokens are missing or empty", () => {
const spans: Span[] = [{ text: "hello", match: true }];
expect(mergeSpansWithTokens(spans, undefined)).toBe(spans);
expect(mergeSpansWithTokens(spans, [])).toBe(spans);
});
});
describe("matchCount", () => {
it("sums highlights across chunks, counting a missing list as zero", () => {
expect(
matchCount([
{ content: "a", lineStart: 1, highlights: [{ start: 0, end: 1 }] },
{ content: "b", lineStart: 2 }
])
).toBe(1);
});
});