diff --git a/packages/web/src/app/(app)/components/lightweightCodeHighlighter.test.ts b/packages/web/src/app/(app)/components/lightweightCodeHighlighter.test.ts new file mode 100644 index 000000000..6ef2db128 --- /dev/null +++ b/packages/web/src/app/(app)/components/lightweightCodeHighlighter.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { SourceRange } from '@/features/search'; +import { computeLineHighlightRanges } from './lightweightCodeHighlighter'; + +describe('computeLineHighlightRanges', () => { + it('returns empty array when highlightRanges is undefined or empty', () => { + expect(computeLineHighlightRanges(undefined, 1, 20)).toEqual([]); + expect(computeLineHighlightRanges([], 1, 20)).toEqual([]); + }); + + it('correctly maps a single-line range on the target line and ignores other lines', () => { + const ranges: SourceRange[] = [ + { + start: { lineNumber: 2, column: 5, byteOffset: 4 }, + end: { lineNumber: 2, column: 12, byteOffset: 11 }, + }, + ]; + + // Before target line + expect(computeLineHighlightRanges(ranges, 1, 30)).toEqual([]); + + // On target line: column 5 (0-indexed 4) to column 12 (0-indexed 11) + expect(computeLineHighlightRanges(ranges, 2, 30)).toEqual([ + { from: 4, to: 11 }, + ]); + + // After target line + expect(computeLineHighlightRanges(ranges, 3, 30)).toEqual([]); + }); + + it('correctly splits multi-line ranges across start, intermediate, and end lines', () => { + const multilineRange: SourceRange = { + start: { lineNumber: 1, column: 15, byteOffset: 14 }, + end: { lineNumber: 3, column: 10, byteOffset: 50 }, + }; + + const line1 = 'const handleOrder = async ('; // length 27 + const line2 = ' userId: string,'; // length 19 + const line3 = ' items: CartItem[]'; // length 21 + const line4 = ');'; // length 2 + + // Line 1: Starts at column 15 (0-indexed 14) and highlights to end of line + expect(computeLineHighlightRanges([multilineRange], 1, line1.length)).toEqual([ + { from: 14, to: line1.length }, + ]); + + // Line 2 (intermediate line): Highlights entire line (0 to length) + expect(computeLineHighlightRanges([multilineRange], 2, line2.length)).toEqual([ + { from: 0, to: line2.length }, + ]); + + // Line 3 (end line): Highlights from beginning (0) to end column (0-indexed 9) + expect(computeLineHighlightRanges([multilineRange], 3, line3.length)).toEqual([ + { from: 0, to: 9 }, + ]); + + // Line 4: Outside range + expect(computeLineHighlightRanges([multilineRange], 4, line4.length)).toEqual([]); + }); + + it('clamps end column to lineLength if end column exceeds line length', () => { + const range: SourceRange = { + start: { lineNumber: 1, column: 1, byteOffset: 0 }, + end: { lineNumber: 1, column: 50, byteOffset: 49 }, + }; + + expect(computeLineHighlightRanges([range], 1, 20)).toEqual([ + { from: 0, to: 20 }, + ]); + }); + + it('filters out invalid or empty ranges where to <= from', () => { + const emptyRange: SourceRange = { + start: { lineNumber: 1, column: 5, byteOffset: 4 }, + end: { lineNumber: 1, column: 5, byteOffset: 4 }, + }; + + expect(computeLineHighlightRanges([emptyRange], 1, 20)).toEqual([]); + }); + + it('handles multiple ranges on the same line', () => { + const ranges: SourceRange[] = [ + { + start: { lineNumber: 1, column: 1, byteOffset: 0 }, + end: { lineNumber: 1, column: 5, byteOffset: 4 }, + }, + { + start: { lineNumber: 1, column: 10, byteOffset: 9 }, + end: { lineNumber: 1, column: 15, byteOffset: 14 }, + }, + ]; + + expect(computeLineHighlightRanges(ranges, 1, 30)).toEqual([ + { from: 0, to: 4 }, + { from: 9, to: 14 }, + ]); + }); +}); diff --git a/packages/web/src/app/(app)/components/lightweightCodeHighlighter.tsx b/packages/web/src/app/(app)/components/lightweightCodeHighlighter.tsx index cbbe416d6..2eae32787 100644 --- a/packages/web/src/app/(app)/components/lightweightCodeHighlighter.tsx +++ b/packages/web/src/app/(app)/components/lightweightCodeHighlighter.tsx @@ -30,6 +30,37 @@ const MAX_NUMBER_OF_CHARACTER_PER_LINE = 1000; * * Inspired by: https://github.com/craftzdog/react-codemirror-runmode */ +/** + * Calculates 0-based character ranges to highlight on a specific line from 1-based SourceRanges, + * handling single-line, start, intermediate, and end lines of multi-line ranges. + */ +export const computeLineHighlightRanges = ( + highlightRanges: SourceRange[] | undefined, + lineNumber: number, + lineLength: number, +): { from: number; to: number }[] => { + if (!highlightRanges || highlightRanges.length === 0) { + return []; + } + + return highlightRanges + .filter( + (range) => + lineNumber >= range.start.lineNumber && + lineNumber <= range.end.lineNumber, + ) + .map((range) => { + const isStart = range.start.lineNumber === lineNumber; + const isEnd = range.end.lineNumber === lineNumber; + + const from = isStart ? Math.max(0, range.start.column - 1) : 0; + const to = isEnd ? Math.min(lineLength, range.end.column - 1) : lineLength; + + return { from, to }; + }) + .filter((range) => range.to > range.from); +}; + export const LightweightCodeHighlighter = memo((props: LightweightCodeHighlighter) => { const { language, @@ -64,13 +95,11 @@ export const LightweightCodeHighlighter = memo((prop .map(async (line, index) => { const lineNumber = index + lineNumbersOffset; - // @todo: we will need to handle the case where a range spans multiple lines. - const ranges = highlightRanges?.filter(range => { - return range.start.lineNumber === lineNumber || range.end.lineNumber === lineNumber; - }).map(range => ({ - from: range.start.column - 1, - to: range.end.column - 1, - })); + const ranges = computeLineHighlightRanges( + highlightRanges, + lineNumber, + line.length, + ); const snippets = await highlightCode( language,