1import { getFiletypeFromFileName } from "@pierre/diffs";
2import type { ProjectContentMatch } from "@t3tools/contracts";
3import { memo, Suspense, use, useMemo, type CSSProperties } from "react";
4
5import { resolveDiffThemeName } from "~/lib/diffRendering";
6import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting";
7
8import { RenderErrorBoundary } from "../RenderErrorBoundary";
9
10interface Range {
11 readonly start: number;
12 readonly end: number;
13}
14
15interface CodeToken {
16 readonly content: string;
17 readonly offset: number;
18 readonly color?: string;
19 readonly fontStyle?: number;
20}
21
22interface Segment {
23 readonly content: string;
24 readonly isMatch: boolean;
25 readonly start: number;
26 readonly end: number;
27 readonly token: CodeToken;
28}
29
30function normalizeRanges(match: ProjectContentMatch): Range[] {
31 const ranges = match.matchRanges
32 .map((range) => ({
33 start: Math.max(0, Math.min(match.lineContent.length, range.start)),
34 end: Math.max(0, Math.min(match.lineContent.length, range.end)),
35 }))
36 .filter((range) => range.end > range.start)
37 .toSorted((left, right) => left.start - right.start);
38
39 const merged: Array<{ start: number; end: number }> = [];
40 for (const range of ranges) {
41 const previous = merged.at(-1);
42 if (previous && range.start <= previous.end) {
43 previous.end = Math.max(previous.end, range.end);
44 } else {
45 merged.push({ ...range });
46 }
47 }
48 return merged;
49}
50
51function splitToken(line: string, token: CodeToken, ranges: ReadonlyArray<Range>): Segment[] {
52 const segments: Segment[] = [];
53 const tokenEnd = token.offset + token.content.length;
54 let cursor = token.offset;
55
56 for (const range of ranges) {
57 if (range.end <= cursor) continue;
58 if (range.start >= tokenEnd) break;
59
60 const matchStart = Math.max(cursor, range.start);
61 if (matchStart > cursor) {
62 segments.push({
63 content: line.slice(cursor, matchStart),
64 isMatch: false,
65 start: cursor,
66 end: matchStart,
67 token,
68 });
69 }
70
71 const matchEnd = Math.min(tokenEnd, range.end);
72 segments.push({
73 content: line.slice(matchStart, matchEnd),
74 isMatch: true,
75 start: matchStart,
76 end: matchEnd,
77 token,
78 });
79 cursor = matchEnd;
80 }
81
82 if (cursor < tokenEnd) {
83 segments.push({
84 content: line.slice(cursor, tokenEnd),
85 isMatch: false,
86 start: cursor,
87 end: tokenEnd,
88 token,
89 });
90 }
91 return segments;
92}
93
94function tokenStyle(token: CodeToken): CSSProperties {
95 const fontStyle = token.fontStyle ?? 0;
96 return {
97 ...(token.color ? { color: token.color } : {}),
98 ...(fontStyle & 1 ? { fontStyle: "italic" } : {}),
99 ...(fontStyle & 2 ? { fontWeight: 700 } : {}),
100 ...(fontStyle & 4 ? { textDecoration: "underline" } : {}),
101 };
102}
103
104function HighlightedTokens(props: {
105 readonly line: string;
106 readonly ranges: ReadonlyArray<Range>;
107 readonly tokens: ReadonlyArray<CodeToken>;
108}) {
109 return props.tokens
110 .flatMap((token) => splitToken(props.line, token, props.ranges))
111 .map((segment) =>
112 segment.isMatch ? (
113 <mark
114 className="rounded-xs bg-primary/25 text-inherit"
115 key={`${segment.start}:${segment.end}:match`}
116 style={tokenStyle(segment.token)}
117 >
118 {segment.content}
119 </mark>
120 ) : (
121 <span key={`${segment.start}:${segment.end}:code`} style={tokenStyle(segment.token)}>
122 {segment.content}
123 </span>
124 ),
125 );
126}
127
128function SyntaxHighlightedTokens(props: {
129 readonly line: string;
130 readonly language: string;
131 readonly ranges: ReadonlyArray<Range>;
132 readonly theme: "light" | "dark";
133}) {
134 const highlighter = use(getSyntaxHighlighterPromise(props.language));
135 const tokens = useMemo(() => {
136 try {
137 return highlighter.codeToTokens(props.line, {
138 lang: props.language,
139 theme: resolveDiffThemeName(props.theme),
140 }).tokens[0];
141 } catch {
142 return undefined;
143 }
144 }, [highlighter, props.language, props.line, props.theme]);
145
146 return tokens ? (
147 <HighlightedTokens line={props.line} ranges={props.ranges} tokens={tokens} />
148 ) : (
149 <HighlightedTokens
150 line={props.line}
151 ranges={props.ranges}
152 tokens={[{ content: props.line, offset: 0 }]}
153 />
154 );
155}
156
157export const HighlightedSearchLine = memo(function HighlightedSearchLine(props: {
158 readonly match: ProjectContentMatch;
159 readonly path: string;
160 readonly theme: "light" | "dark";
161}) {
162 const ranges = useMemo(() => normalizeRanges(props.match), [props.match]);
163 const fallback = (
164 <HighlightedTokens
165 line={props.match.lineContent}
166 ranges={ranges}
167 tokens={[{ content: props.match.lineContent, offset: 0 }]}
168 />
169 );
170
171 return (
172 <RenderErrorBoundary fallback={fallback}>
173 <Suspense fallback={fallback}>
174 <SyntaxHighlightedTokens
175 line={props.match.lineContent}
176 language={getFiletypeFromFileName(props.path)}
177 ranges={ranges}
178 theme={props.theme}
179 />
180 </Suspense>
181 </RenderErrorBoundary>
182 );
183});