feat(git-diff): add Lezer-based syntax highlighting
- Add @lezer/highlight and language parsers (JS/TS, JSON, CSS, HTML, Python, Markdown) - Create syntax-highlighter utility that parses code with Lezer and returns highlight tokens - Integrate highlighting into diff view with color-coded tokens - Keywords (purple), strings (green), numbers (orange), functions (blue), types (yellow), etc. - Highlighting respects add/remove line backgrounds 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
4c8a79f288
commit
a187f26e1b
Generated
+97
@@ -5139,6 +5139,95 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/common": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/css": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/highlight": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/html": {
|
||||||
|
"version": "1.3.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||||
|
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/javascript": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.1.3",
|
||||||
|
"@lezer/lr": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/json": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/lr": {
|
||||||
|
"version": "1.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.6.tgz",
|
||||||
|
"integrity": "sha512-u42yGuGBsHgodm86lwi0HAtUTNSs23yl9RoaI5em90B+OGm9/XuWkNiJ46sKkCgp8Tp4zgoBQbepcshfKLhFdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/markdown": {
|
||||||
|
"version": "1.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.2.tgz",
|
||||||
|
"integrity": "sha512-iNSdKrIK0FfOjVPVpV0fu7OykdncYpEzf4vkG9szFf60ql/ObZShoVbM9u1tgkogDOmubms1CyoNS2/unOXWNw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/highlight": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/python": {
|
||||||
|
"version": "1.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
|
||||||
|
"integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.20.2",
|
"version": "1.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.2.tgz",
|
||||||
@@ -21149,6 +21238,14 @@
|
|||||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/css": "^1.3.0",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
|
"@lezer/html": "^1.3.13",
|
||||||
|
"@lezer/javascript": "^1.5.4",
|
||||||
|
"@lezer/json": "^1.0.3",
|
||||||
|
"@lezer/markdown": "^1.6.2",
|
||||||
|
"@lezer/python": "^1.1.18",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
|
|||||||
@@ -18,6 +18,14 @@
|
|||||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/css": "^1.3.0",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
|
"@lezer/html": "^1.3.13",
|
||||||
|
"@lezer/javascript": "^1.5.4",
|
||||||
|
"@lezer/json": "^1.0.3",
|
||||||
|
"@lezer/markdown": "^1.6.2",
|
||||||
|
"@lezer/python": "^1.1.18",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { View, Text, ActivityIndicator, Pressable, RefreshControl } from "react-native";
|
import { View, Text, ActivityIndicator, Pressable, RefreshControl } from "react-native";
|
||||||
import { ScrollView } from "react-native-gesture-handler";
|
import { ScrollView } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronRight } from "lucide-react-native";
|
import { ChevronRight } from "lucide-react-native";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useGitDiffQuery } from "@/hooks/use-git-diff-query";
|
import { useGitDiffQuery } from "@/hooks/use-git-diff-query";
|
||||||
|
import {
|
||||||
|
highlightCode,
|
||||||
|
isLanguageSupported,
|
||||||
|
type HighlightToken,
|
||||||
|
type HighlightStyle,
|
||||||
|
} from "@/utils/syntax-highlighter";
|
||||||
|
|
||||||
|
interface DiffLine {
|
||||||
|
type: "add" | "remove" | "context" | "header";
|
||||||
|
content: string;
|
||||||
|
tokens?: HighlightToken[];
|
||||||
|
}
|
||||||
|
|
||||||
interface ParsedDiffFile {
|
interface ParsedDiffFile {
|
||||||
path: string;
|
path: string;
|
||||||
isNew: boolean;
|
isNew: boolean;
|
||||||
additions: number;
|
additions: number;
|
||||||
deletions: number;
|
deletions: number;
|
||||||
lines: Array<{
|
lines: DiffLine[];
|
||||||
type: "add" | "remove" | "context" | "header";
|
|
||||||
content: string;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDiff(diffText: string): ParsedDiffFile[] {
|
function parseDiff(diffText: string): ParsedDiffFile[] {
|
||||||
@@ -45,7 +54,7 @@ function parseDiff(diffText: string): ParsedDiffFile[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedLines: ParsedDiffFile["lines"] = [];
|
const parsedLines: DiffLine[] = [];
|
||||||
let additions = 0;
|
let additions = 0;
|
||||||
let deletions = 0;
|
let deletions = 0;
|
||||||
|
|
||||||
@@ -82,6 +91,119 @@ function parseDiff(diffText: string): ParsedDiffFile[] {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyHighlighting(files: ParsedDiffFile[]): ParsedDiffFile[] {
|
||||||
|
return files.map((file) => {
|
||||||
|
if (!isLanguageSupported(file.path)) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all non-header lines to build the "file content" for highlighting
|
||||||
|
// We need to build separate content for add/context and remove/context
|
||||||
|
// to properly highlight each side of the diff
|
||||||
|
const addContextLines: Array<{ index: number; content: string }> = [];
|
||||||
|
const removeLines: Array<{ index: number; content: string }> = [];
|
||||||
|
|
||||||
|
file.lines.forEach((line, index) => {
|
||||||
|
if (line.type === "add" || line.type === "context") {
|
||||||
|
addContextLines.push({ index, content: line.content });
|
||||||
|
}
|
||||||
|
if (line.type === "remove") {
|
||||||
|
removeLines.push({ index, content: line.content });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight the "new" file content (additions + context)
|
||||||
|
const addContextCode = addContextLines.map((l) => l.content).join("\n");
|
||||||
|
const addContextHighlighted = highlightCode(addContextCode, file.path);
|
||||||
|
|
||||||
|
// Highlight the "old" file content (removals only, context already covered)
|
||||||
|
const removeCode = removeLines.map((l) => l.content).join("\n");
|
||||||
|
const removeHighlighted = highlightCode(removeCode, file.path);
|
||||||
|
|
||||||
|
// Map highlighted tokens back to diff lines
|
||||||
|
const newLines = [...file.lines];
|
||||||
|
|
||||||
|
addContextLines.forEach((item, highlightIndex) => {
|
||||||
|
if (addContextHighlighted[highlightIndex]) {
|
||||||
|
newLines[item.index] = {
|
||||||
|
...newLines[item.index],
|
||||||
|
tokens: addContextHighlighted[highlightIndex],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
removeLines.forEach((item, highlightIndex) => {
|
||||||
|
if (removeHighlighted[highlightIndex]) {
|
||||||
|
newLines[item.index] = {
|
||||||
|
...newLines[item.index],
|
||||||
|
tokens: removeHighlighted[highlightIndex],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...file, lines: newLines };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HighlightedTextProps {
|
||||||
|
tokens: HighlightToken[];
|
||||||
|
baseStyle: HighlightStyle | null;
|
||||||
|
lineType: "add" | "remove" | "context" | "header";
|
||||||
|
}
|
||||||
|
|
||||||
|
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
// Get color for a highlight style, respecting the line type
|
||||||
|
const getTokenColor = (style: HighlightStyle | null): string => {
|
||||||
|
// For add/remove lines, use appropriate base colors
|
||||||
|
const baseColor =
|
||||||
|
lineType === "add"
|
||||||
|
? theme.colors.palette.green[200]
|
||||||
|
: lineType === "remove"
|
||||||
|
? theme.colors.palette.red[200]
|
||||||
|
: theme.colors.mutedForeground;
|
||||||
|
|
||||||
|
if (!style) return baseColor;
|
||||||
|
|
||||||
|
// Define highlight colors - these work on both light and dark backgrounds
|
||||||
|
const highlightColors: Record<HighlightStyle, string> = {
|
||||||
|
keyword: theme.colors.palette.purple[500],
|
||||||
|
comment: theme.colors.mutedForeground,
|
||||||
|
string: theme.colors.palette.green[400],
|
||||||
|
number: theme.colors.palette.orange[500],
|
||||||
|
literal: theme.colors.palette.orange[500],
|
||||||
|
function: theme.colors.palette.blue[400],
|
||||||
|
definition: theme.colors.palette.blue[400],
|
||||||
|
class: theme.colors.palette.yellow[400],
|
||||||
|
type: theme.colors.palette.yellow[400],
|
||||||
|
tag: theme.colors.palette.red[500],
|
||||||
|
attribute: theme.colors.palette.purple[500],
|
||||||
|
property: theme.colors.palette.blue[400],
|
||||||
|
variable: baseColor,
|
||||||
|
operator: baseColor,
|
||||||
|
punctuation: baseColor,
|
||||||
|
regexp: theme.colors.palette.green[400],
|
||||||
|
escape: theme.colors.palette.orange[500],
|
||||||
|
meta: theme.colors.mutedForeground,
|
||||||
|
heading: theme.colors.palette.blue[400],
|
||||||
|
link: theme.colors.palette.blue[400],
|
||||||
|
};
|
||||||
|
|
||||||
|
return highlightColors[style] ?? baseColor;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text style={styles.diffLineText}>
|
||||||
|
{tokens.map((token, index) => (
|
||||||
|
<Text key={index} style={{ color: getTokenColor(token.style) }}>
|
||||||
|
{token.text}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface DiffFileSectionProps {
|
interface DiffFileSectionProps {
|
||||||
file: ParsedDiffFile;
|
file: ParsedDiffFile;
|
||||||
defaultExpanded?: boolean;
|
defaultExpanded?: boolean;
|
||||||
@@ -143,17 +265,25 @@ function DiffFileSection({ file, defaultExpanded = true }: DiffFileSectionProps)
|
|||||||
line.type === "context" && styles.contextLineContainer,
|
line.type === "context" && styles.contextLineContainer,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text
|
{line.tokens && line.type !== "header" ? (
|
||||||
style={[
|
<HighlightedText
|
||||||
styles.diffLineText,
|
tokens={line.tokens}
|
||||||
line.type === "add" && styles.addLineText,
|
baseStyle={null}
|
||||||
line.type === "remove" && styles.removeLineText,
|
lineType={line.type}
|
||||||
line.type === "header" && styles.headerLineText,
|
/>
|
||||||
line.type === "context" && styles.contextLineText,
|
) : (
|
||||||
]}
|
<Text
|
||||||
>
|
style={[
|
||||||
{line.content || " "}
|
styles.diffLineText,
|
||||||
</Text>
|
line.type === "add" && styles.addLineText,
|
||||||
|
line.type === "remove" && styles.removeLineText,
|
||||||
|
line.type === "header" && styles.headerLineText,
|
||||||
|
line.type === "context" && styles.contextLineText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{line.content || " "}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -178,6 +308,12 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
state.sessions[serverId]?.agents?.get(agentId)
|
state.sessions[serverId]?.agents?.get(agentId)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const highlightedFiles = useMemo(() => {
|
||||||
|
if (isError || !diff) return [];
|
||||||
|
const parsed = parseDiff(diff);
|
||||||
|
return applyHighlighting(parsed);
|
||||||
|
}, [diff, isError]);
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
@@ -186,8 +322,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedFiles = isError || !diff ? [] : parseDiff(diff);
|
const hasChanges = highlightedFiles.length > 0;
|
||||||
const hasChanges = parsedFiles.length > 0;
|
|
||||||
const errorMessage = isError && error instanceof Error ? error.message : null;
|
const errorMessage = isError && error instanceof Error ? error.message : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -217,7 +352,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
<Text style={styles.emptyText}>No changes</Text>
|
<Text style={styles.emptyText}>No changes</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
parsedFiles.map((file, fileIndex) => (
|
highlightedFiles.map((file, fileIndex) => (
|
||||||
<DiffFileSection key={fileIndex} file={file} />
|
<DiffFileSection key={fileIndex} file={file} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { highlightTree, tagHighlighter, tags } from "@lezer/highlight";
|
||||||
|
import { parser as jsParser } from "@lezer/javascript";
|
||||||
|
import { parser as jsonParser } from "@lezer/json";
|
||||||
|
import { parser as cssParser } from "@lezer/css";
|
||||||
|
import { parser as htmlParser } from "@lezer/html";
|
||||||
|
import { parser as pythonParser } from "@lezer/python";
|
||||||
|
import { parser as markdownParser } from "@lezer/markdown";
|
||||||
|
import type { Parser } from "@lezer/common";
|
||||||
|
|
||||||
|
// Map file extensions to parsers
|
||||||
|
const parsersByExtension: Record<string, Parser> = {
|
||||||
|
// JavaScript/TypeScript
|
||||||
|
js: jsParser,
|
||||||
|
jsx: jsParser.configure({ dialect: "jsx" }),
|
||||||
|
ts: jsParser.configure({ dialect: "ts" }),
|
||||||
|
tsx: jsParser.configure({ dialect: "tsx jsx" }),
|
||||||
|
mjs: jsParser,
|
||||||
|
cjs: jsParser,
|
||||||
|
// JSON
|
||||||
|
json: jsonParser,
|
||||||
|
// CSS
|
||||||
|
css: cssParser,
|
||||||
|
scss: cssParser,
|
||||||
|
// HTML
|
||||||
|
html: htmlParser,
|
||||||
|
htm: htmlParser,
|
||||||
|
// Python
|
||||||
|
py: pythonParser,
|
||||||
|
// Markdown
|
||||||
|
md: markdownParser,
|
||||||
|
mdx: markdownParser,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HighlightStyle =
|
||||||
|
| "keyword"
|
||||||
|
| "comment"
|
||||||
|
| "string"
|
||||||
|
| "number"
|
||||||
|
| "literal"
|
||||||
|
| "function"
|
||||||
|
| "definition"
|
||||||
|
| "class"
|
||||||
|
| "type"
|
||||||
|
| "tag"
|
||||||
|
| "attribute"
|
||||||
|
| "property"
|
||||||
|
| "variable"
|
||||||
|
| "operator"
|
||||||
|
| "punctuation"
|
||||||
|
| "regexp"
|
||||||
|
| "escape"
|
||||||
|
| "meta"
|
||||||
|
| "heading"
|
||||||
|
| "link";
|
||||||
|
|
||||||
|
export interface HighlightToken {
|
||||||
|
text: string;
|
||||||
|
style: HighlightStyle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create highlighter using tagHighlighter
|
||||||
|
const highlighter = tagHighlighter([
|
||||||
|
{ tag: tags.keyword, class: "keyword" },
|
||||||
|
{ tag: tags.controlKeyword, class: "keyword" },
|
||||||
|
{ tag: tags.operatorKeyword, class: "keyword" },
|
||||||
|
{ tag: tags.definitionKeyword, class: "keyword" },
|
||||||
|
{ tag: tags.moduleKeyword, class: "keyword" },
|
||||||
|
{ tag: tags.comment, class: "comment" },
|
||||||
|
{ tag: tags.lineComment, class: "comment" },
|
||||||
|
{ tag: tags.blockComment, class: "comment" },
|
||||||
|
{ tag: tags.docComment, class: "comment" },
|
||||||
|
{ tag: tags.string, class: "string" },
|
||||||
|
{ tag: tags.special(tags.string), class: "string" },
|
||||||
|
{ tag: tags.number, class: "number" },
|
||||||
|
{ tag: tags.integer, class: "number" },
|
||||||
|
{ tag: tags.float, class: "number" },
|
||||||
|
{ tag: tags.bool, class: "literal" },
|
||||||
|
{ tag: tags.null, class: "literal" },
|
||||||
|
{ tag: tags.function(tags.variableName), class: "function" },
|
||||||
|
{ tag: tags.function(tags.propertyName), class: "function" },
|
||||||
|
{ tag: tags.definition(tags.variableName), class: "definition" },
|
||||||
|
{ tag: tags.definition(tags.propertyName), class: "definition" },
|
||||||
|
{ tag: tags.definition(tags.function(tags.variableName)), class: "definition" },
|
||||||
|
{ tag: tags.className, class: "class" },
|
||||||
|
{ tag: tags.definition(tags.className), class: "class" },
|
||||||
|
{ tag: tags.typeName, class: "type" },
|
||||||
|
{ tag: tags.tagName, class: "tag" },
|
||||||
|
{ tag: tags.attributeName, class: "attribute" },
|
||||||
|
{ tag: tags.attributeValue, class: "string" },
|
||||||
|
{ tag: tags.propertyName, class: "property" },
|
||||||
|
{ tag: tags.variableName, class: "variable" },
|
||||||
|
{ tag: tags.local(tags.variableName), class: "variable" },
|
||||||
|
{ tag: tags.special(tags.variableName), class: "variable" },
|
||||||
|
{ tag: tags.operator, class: "operator" },
|
||||||
|
{ tag: tags.punctuation, class: "punctuation" },
|
||||||
|
{ tag: tags.bracket, class: "punctuation" },
|
||||||
|
{ tag: tags.separator, class: "punctuation" },
|
||||||
|
{ tag: tags.regexp, class: "regexp" },
|
||||||
|
{ tag: tags.escape, class: "escape" },
|
||||||
|
{ tag: tags.meta, class: "meta" },
|
||||||
|
{ tag: tags.heading, class: "heading" },
|
||||||
|
{ tag: tags.link, class: "link" },
|
||||||
|
{ tag: tags.url, class: "link" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
function getParserForFile(filename: string): Parser | null {
|
||||||
|
const ext = filename.split(".").pop()?.toLowerCase();
|
||||||
|
if (!ext) return null;
|
||||||
|
return parsersByExtension[ext] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightCode(code: string, filename: string): HighlightToken[][] {
|
||||||
|
const parser = getParserForFile(filename);
|
||||||
|
|
||||||
|
if (!parser) {
|
||||||
|
// No parser available, return unhighlighted lines
|
||||||
|
return code.split("\n").map((line) => [{ text: line, style: null }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = parser.parse(code);
|
||||||
|
const lines = code.split("\n");
|
||||||
|
const result: HighlightToken[][] = [];
|
||||||
|
|
||||||
|
// Initialize with unhighlighted content
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
result.push([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a map of character positions to styles
|
||||||
|
const styleMap: Array<HighlightStyle | null> = new Array(code.length).fill(null);
|
||||||
|
|
||||||
|
// Use highlightTree to populate the style map
|
||||||
|
highlightTree(tree, highlighter, (from, to, classes) => {
|
||||||
|
for (let i = from; i < to && i < styleMap.length; i++) {
|
||||||
|
styleMap[i] = classes as HighlightStyle;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert style map to tokens per line
|
||||||
|
let pos = 0;
|
||||||
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
||||||
|
const line = lines[lineIndex];
|
||||||
|
|
||||||
|
if (line.length === 0) {
|
||||||
|
result[lineIndex].push({ text: "", style: null });
|
||||||
|
pos++; // skip newline
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentToken: HighlightToken = { text: "", style: styleMap[pos] };
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const charStyle = styleMap[pos + i];
|
||||||
|
if (charStyle === currentToken.style) {
|
||||||
|
currentToken.text += line[i];
|
||||||
|
} else {
|
||||||
|
if (currentToken.text) {
|
||||||
|
result[lineIndex].push(currentToken);
|
||||||
|
}
|
||||||
|
currentToken = { text: line[i], style: charStyle };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentToken.text) {
|
||||||
|
result[lineIndex].push(currentToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
pos += line.length + 1; // +1 for newline
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightLine(line: string, filename: string): HighlightToken[] {
|
||||||
|
const result = highlightCode(line, filename);
|
||||||
|
return result[0] ?? [{ text: line, style: null }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSupportedExtensions(): string[] {
|
||||||
|
return Object.keys(parsersByExtension);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLanguageSupported(filename: string): boolean {
|
||||||
|
return getParserForFile(filename) !== null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user