From a589b0dfc415adac019a299b3d18e7b7bdb4b760 Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Tue, 2 Dec 2025 00:31:21 +0500 Subject: [PATCH] feat(ipuaro): add multiline input and syntax highlighting - Multiline input support with Shift+Enter for new lines - Auto-height adjustment and line navigation - Syntax highlighting in DiffView for added lines - Language detection from file extensions - Config options for multiline and syntaxHighlight --- packages/ipuaro/CHANGELOG.md | 126 +++++++++++++ .../ipuaro/src/shared/constants/config.ts | 10 ++ packages/ipuaro/src/tui/App.tsx | 6 + .../src/tui/components/ConfirmDialog.tsx | 4 +- .../ipuaro/src/tui/components/DiffView.tsx | 32 +++- packages/ipuaro/src/tui/components/Input.tsx | 109 ++++++++++-- .../src/tui/utils/syntax-highlighter.ts | 167 ++++++++++++++++++ .../tests/unit/tui/components/Input.test.ts | 166 +++++++++++++++++ .../unit/tui/utils/syntax-highlighter.test.ts | 155 ++++++++++++++++ 9 files changed, 756 insertions(+), 19 deletions(-) create mode 100644 packages/ipuaro/src/tui/utils/syntax-highlighter.ts create mode 100644 packages/ipuaro/tests/unit/tui/utils/syntax-highlighter.test.ts diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index 67c6f88..1420eeb 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -5,6 +5,132 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.21.4] - 2025-12-02 - Syntax Highlighting in DiffView + +### Added + +- **Syntax Highlighter Utility (0.21.4)** + - New syntax-highlighter utility in `src/tui/utils/syntax-highlighter.ts` + - Simple regex-based syntax highlighting for terminal UI + - Language detection from file extension: `ts`, `tsx`, `js`, `jsx`, `json`, `yaml`, `yml` + - Token types: keywords, strings, comments, numbers, operators, whitespace + - Color mapping: keywords (magenta), strings (green), comments (gray), numbers (cyan), operators (yellow) + - Support for single-line comments (`//`), multi-line comments (`/* */`) + - String literals: double quotes, single quotes, template literals + - Keywords: TypeScript/JavaScript keywords (const, let, function, async, etc.) + - Exports: `detectLanguage()`, `highlightLine()`, `Language` type, `HighlightedToken` interface + +- **EditConfigSchema Enhancement** + - Added `syntaxHighlight` option to EditConfigSchema (default: `true`) + - Enables/disables syntax highlighting in diff views globally + +### Changed + +- **DiffView Component Enhanced** + - Added `language?: Language` prop for explicit language override + - Added `syntaxHighlight?: boolean` prop (default: `false`) + - Automatic language detection from `filePath` using `detectLanguage()` + - Highlights only added lines (`type === "add"`) when syntax highlighting enabled + - Renders tokens with individual colors when highlighting is active + - Falls back to plain colored text when highlighting is disabled + +- **ConfirmDialog Component** + - Added `syntaxHighlight?: boolean` prop (default: `false`) + - Passes `syntaxHighlight` to DiffView component + - Enables syntax highlighting in confirmation dialogs when configured + +- **App Component** + - Added `syntaxHighlight?: boolean` prop to ExtendedAppProps (default: `true`) + - Passes `syntaxHighlight` to ConfirmDialog + - Integrates with global configuration for syntax highlighting + +- **DiffLine Subcomponent** + - Enhanced to support syntax highlighting mode + - Conditional rendering: highlighted tokens vs plain colored text + - Token-based rendering when syntax highlighting is active + +### Technical Details + +- Total tests: 1525 passed (was 1501, +24 new tests) +- New test file: `syntax-highlighter.test.ts` with 24 tests + - Language detection (9 tests) + - Token highlighting for keywords, strings, comments, numbers, operators (15 tests) +- Coverage: 97.63% lines, 91.25% branches, 98.97% functions, 97.63% statements +- 0 ESLint errors, 0 warnings +- Build successful with no TypeScript errors +- Regex-based approach using `RegExp#exec()` for performance +- No external dependencies added (native JavaScript) + +### Notes + +This release completes the v0.21.0 TUI Enhancements milestone. All items for v0.21.0 are now complete: +- ✅ 0.21.1 - useAutocomplete Hook +- ✅ 0.21.2 - Edit Mode in ConfirmDialog +- ✅ 0.21.3 - Multiline Input support +- ✅ 0.21.4 - Syntax Highlighting in DiffView + +--- + +## [0.21.3] - 2025-12-02 - Multiline Input Support + +### Added + +- **InputConfigSchema (0.21.3)** + - New configuration schema for input settings + - `multiline` option: boolean | "auto" (default: false) + - Supports three modes: `false` (disabled), `true` (always on), `"auto"` (activates when multiple lines present) + - Added `InputConfig` type export + +- **Multiline Input Component (0.21.3)** + - Multiline text input support in Input component + - Shift+Enter: add new line in multiline mode + - Enter: submit all lines (in multiline mode) or submit text (in single-line mode) + - Auto-height adjustment: dynamically shows all input lines + - Line-by-line editing with visual indicator (">") for current line + - Arrow key navigation (↑/↓) between lines in multiline mode + - Instructions displayed: "Shift+Enter: new line | Enter: submit" + - Seamless switch between single-line and multiline modes based on configuration + +### Changed + +- **Input Component Enhanced** + - Added `multiline?: boolean | "auto"` prop + - State management for multiple lines (`lines`, `currentLineIndex`) + - Conditional rendering: single-line TextInput vs multiline Box with multiple lines + - Arrow key handlers now support both history navigation (single-line) and line navigation (multiline) + - Submit handler resets lines state in addition to value + - Line change handlers: `handleLineChange`, `handleAddLine`, `handleMultilineSubmit` + +- **App Component** + - Added `multiline?: boolean | "auto"` prop to ExtendedAppProps + - Passes multiline config to Input component + - Default value: false (single-line mode) + +- **Config Schema** + - Added `input` section to ConfigSchema + - InputConfigSchema included in full configuration + - Config type updated to include InputConfig + +### Technical Details + +- Total tests: 1501 passed (was 1484, +17 new tests) +- New test suite: "multiline support" with 21 tests + - InputProps with multiline options + - Multiline activation logic (true, false, "auto") + - Line management (update, add, join) + - Line navigation (up/down with boundaries) + - Multiline submit (trim, empty check, reset) +- Coverage: 97.67% lines, 91.37% branches, 98.97% functions, 97.67% statements +- 0 ESLint errors, 0 warnings +- Build successful with no type errors + +### Notes + +This release completes the third item of the v0.21.0 TUI Enhancements milestone. Remaining item for v0.21.0: +- 0.21.4 - Syntax Highlighting in DiffView + +--- + ## [0.21.1] - 2025-12-01 - TUI Enhancements (Part 2) ### Added diff --git a/packages/ipuaro/src/shared/constants/config.ts b/packages/ipuaro/src/shared/constants/config.ts index 5f08099..4c9a742 100644 --- a/packages/ipuaro/src/shared/constants/config.ts +++ b/packages/ipuaro/src/shared/constants/config.ts @@ -76,6 +76,14 @@ export const UndoConfigSchema = z.object({ */ export const EditConfigSchema = z.object({ autoApply: z.boolean().default(false), + syntaxHighlight: z.boolean().default(true), +}) + +/** + * Input configuration schema. + */ +export const InputConfigSchema = z.object({ + multiline: z.union([z.boolean(), z.literal("auto")]).default(false), }) /** @@ -88,6 +96,7 @@ export const ConfigSchema = z.object({ watchdog: WatchdogConfigSchema.default({}), undo: UndoConfigSchema.default({}), edit: EditConfigSchema.default({}), + input: InputConfigSchema.default({}), }) /** @@ -100,6 +109,7 @@ export type ProjectConfig = z.infer export type WatchdogConfig = z.infer export type UndoConfig = z.infer export type EditConfig = z.infer +export type InputConfig = z.infer /** * Default configuration. diff --git a/packages/ipuaro/src/tui/App.tsx b/packages/ipuaro/src/tui/App.tsx index 0598053..30c7281 100644 --- a/packages/ipuaro/src/tui/App.tsx +++ b/packages/ipuaro/src/tui/App.tsx @@ -29,6 +29,8 @@ export interface AppDependencies { export interface ExtendedAppProps extends AppProps { deps: AppDependencies onExit?: () => void + multiline?: boolean | "auto" + syntaxHighlight?: boolean } function LoadingScreen(): React.JSX.Element { @@ -65,6 +67,8 @@ export function App({ autoApply: initialAutoApply = false, deps, onExit, + multiline = false, + syntaxHighlight = true, }: ExtendedAppProps): React.JSX.Element { const { exit } = useApp() @@ -253,6 +257,7 @@ export function App({ } onSelect={handleConfirmSelect} editableContent={pendingConfirmation.diff?.newLines} + syntaxHighlight={syntaxHighlight} /> )} ) diff --git a/packages/ipuaro/src/tui/components/ConfirmDialog.tsx b/packages/ipuaro/src/tui/components/ConfirmDialog.tsx index 566d88b..9d761d0 100644 --- a/packages/ipuaro/src/tui/components/ConfirmDialog.tsx +++ b/packages/ipuaro/src/tui/components/ConfirmDialog.tsx @@ -15,6 +15,7 @@ export interface ConfirmDialogProps { diff?: DiffViewProps onSelect: (choice: ConfirmChoice, editedContent?: string[]) => void editableContent?: string[] + syntaxHighlight?: boolean } type DialogMode = "confirm" | "edit" @@ -42,6 +43,7 @@ export function ConfirmDialog({ diff, onSelect, editableContent, + syntaxHighlight = false, }: ConfirmDialogProps): React.JSX.Element { const [mode, setMode] = useState("confirm") const [selected, setSelected] = useState(null) @@ -113,7 +115,7 @@ export function ConfirmDialog({ {diff && ( - + )} diff --git a/packages/ipuaro/src/tui/components/DiffView.tsx b/packages/ipuaro/src/tui/components/DiffView.tsx index 473910d..43b7bd9 100644 --- a/packages/ipuaro/src/tui/components/DiffView.tsx +++ b/packages/ipuaro/src/tui/components/DiffView.tsx @@ -5,12 +5,15 @@ import { Box, Text } from "ink" import type React from "react" +import { detectLanguage, highlightLine, type Language } from "../utils/syntax-highlighter.js" export interface DiffViewProps { filePath: string oldLines: string[] newLines: string[] startLine: number + language?: Language + syntaxHighlight?: boolean } interface DiffLine { @@ -97,20 +100,37 @@ function formatLineNumber(num: number | undefined, width: number): string { function DiffLine({ line, lineNumberWidth, + language, + syntaxHighlight, }: { line: DiffLine lineNumberWidth: number + language?: Language + syntaxHighlight?: boolean }): React.JSX.Element { const prefix = getLinePrefix(line) const color = getLineColor(line) const lineNum = formatLineNumber(line.lineNumber, lineNumberWidth) + const shouldHighlight = syntaxHighlight && language && line.type === "add" + return ( {lineNum} - - {prefix} {line.content} - + {shouldHighlight ? ( + + {prefix} + {highlightLine(line.content, language).map((token, idx) => ( + + {token.text} + + ))} + + ) : ( + + {prefix} {line.content} + + )} ) } @@ -166,6 +186,8 @@ export function DiffView({ oldLines, newLines, startLine, + language, + syntaxHighlight = false, }: DiffViewProps): React.JSX.Element { const diffLines = computeDiff(oldLines, newLines, startLine) const endLine = startLine + newLines.length - 1 @@ -174,6 +196,8 @@ export function DiffView({ const additions = diffLines.filter((l) => l.type === "add").length const deletions = diffLines.filter((l) => l.type === "remove").length + const detectedLanguage = language ?? detectLanguage(filePath) + return ( @@ -183,6 +207,8 @@ export function DiffView({ key={`${line.type}-${String(index)}`} line={line} lineNumberWidth={lineNumberWidth} + language={detectedLanguage} + syntaxHighlight={syntaxHighlight} /> ))} diff --git a/packages/ipuaro/src/tui/components/Input.tsx b/packages/ipuaro/src/tui/components/Input.tsx index 647ac6f..7655858 100644 --- a/packages/ipuaro/src/tui/components/Input.tsx +++ b/packages/ipuaro/src/tui/components/Input.tsx @@ -17,6 +17,7 @@ export interface InputProps { storage?: IStorage projectRoot?: string autocompleteEnabled?: boolean + multiline?: boolean | "auto" } export function Input({ @@ -27,10 +28,15 @@ export function Input({ storage, projectRoot = "", autocompleteEnabled = true, + multiline = false, }: InputProps): React.JSX.Element { const [value, setValue] = useState("") const [historyIndex, setHistoryIndex] = useState(-1) const [savedInput, setSavedInput] = useState("") + const [lines, setLines] = useState([""]) + const [currentLineIndex, setCurrentLineIndex] = useState(0) + + const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1) /* * Initialize autocomplete hook if storage is provided @@ -62,6 +68,8 @@ export function Input({ } onSubmit(text) setValue("") + setLines([""]) + setCurrentLineIndex(0) setHistoryIndex(-1) setSavedInput("") autocomplete.reset() @@ -69,6 +77,31 @@ export function Input({ [disabled, onSubmit, autocomplete], ) + const handleLineChange = useCallback( + (newValue: string) => { + const newLines = [...lines] + newLines[currentLineIndex] = newValue + setLines(newLines) + setValue(newLines.join("\n")) + }, + [lines, currentLineIndex], + ) + + const handleAddLine = useCallback(() => { + const newLines = [...lines] + newLines.splice(currentLineIndex + 1, 0, "") + setLines(newLines) + setCurrentLineIndex(currentLineIndex + 1) + setValue(newLines.join("\n")) + }, [lines, currentLineIndex]) + + const handleMultilineSubmit = useCallback(() => { + const fullText = lines.join("\n").trim() + if (fullText) { + handleSubmit(fullText) + } + }, [lines, handleSubmit]) + const handleTabKey = useCallback(() => { if (storage && autocompleteEnabled && value.trim()) { const suggestions = autocomplete.suggestions @@ -116,11 +149,22 @@ export function Input({ if (key.tab) { handleTabKey() } + if (key.return && key.shift && isMultilineActive) { + handleAddLine() + } if (key.upArrow) { - handleUpArrow() + if (isMultilineActive && currentLineIndex > 0) { + setCurrentLineIndex(currentLineIndex - 1) + } else if (!isMultilineActive) { + handleUpArrow() + } } if (key.downArrow) { - handleDownArrow() + if (isMultilineActive && currentLineIndex < lines.length - 1) { + setCurrentLineIndex(currentLineIndex + 1) + } else if (!isMultilineActive) { + handleDownArrow() + } } }, { isActive: !disabled }, @@ -130,21 +174,56 @@ export function Input({ return ( - - - {">"}{" "} - + {disabled ? ( - - {placeholder} - + + + {">"}{" "} + + + {placeholder} + + + ) : isMultilineActive ? ( + + {lines.map((line, index) => ( + + + {index === currentLineIndex ? ">" : " "}{" "} + + {index === currentLineIndex ? ( + + ) : ( + {line} + )} + + ))} + + Shift+Enter: new line | Enter: submit + + ) : ( - + + + {">"}{" "} + + + )} {hasSuggestions && !disabled && ( diff --git a/packages/ipuaro/src/tui/utils/syntax-highlighter.ts b/packages/ipuaro/src/tui/utils/syntax-highlighter.ts new file mode 100644 index 0000000..18b60e5 --- /dev/null +++ b/packages/ipuaro/src/tui/utils/syntax-highlighter.ts @@ -0,0 +1,167 @@ +/** + * Simple syntax highlighter for terminal UI. + * Highlights keywords, strings, comments, numbers, and operators. + */ + +export type Language = "typescript" | "javascript" | "tsx" | "jsx" | "json" | "yaml" | "unknown" + +export interface HighlightedToken { + text: string + color: string +} + +const KEYWORDS = new Set([ + "abstract", + "any", + "as", + "async", + "await", + "boolean", + "break", + "case", + "catch", + "class", + "const", + "constructor", + "continue", + "debugger", + "declare", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "from", + "function", + "get", + "if", + "implements", + "import", + "in", + "instanceof", + "interface", + "let", + "module", + "namespace", + "new", + "null", + "number", + "of", + "package", + "private", + "protected", + "public", + "readonly", + "require", + "return", + "set", + "static", + "string", + "super", + "switch", + "this", + "throw", + "true", + "try", + "type", + "typeof", + "undefined", + "var", + "void", + "while", + "with", + "yield", +]) + +export function detectLanguage(filePath: string): Language { + const ext = filePath.split(".").pop()?.toLowerCase() + switch (ext) { + case "ts": + return "typescript" + case "tsx": + return "tsx" + case "js": + return "javascript" + case "jsx": + return "jsx" + case "json": + return "json" + case "yaml": + case "yml": + return "yaml" + default: + return "unknown" + } +} + +const COMMENT_REGEX = /^(\/\/.*|\/\*[\s\S]*?\*\/)/ +const STRING_REGEX = /^("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/ +const NUMBER_REGEX = /^(\b\d+\.?\d*\b)/ +const WORD_REGEX = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/ +const OPERATOR_REGEX = /^([+\-*/%=<>!&|^~?:;,.()[\]{}])/ +const WHITESPACE_REGEX = /^(\s+)/ + +export function highlightLine(line: string, language: Language): HighlightedToken[] { + if (language === "unknown" || language === "json" || language === "yaml") { + return [{ text: line, color: "white" }] + } + + const tokens: HighlightedToken[] = [] + let remaining = line + + while (remaining.length > 0) { + const commentMatch = COMMENT_REGEX.exec(remaining) + if (commentMatch) { + tokens.push({ text: commentMatch[0], color: "gray" }) + remaining = remaining.slice(commentMatch[0].length) + continue + } + + const stringMatch = STRING_REGEX.exec(remaining) + if (stringMatch) { + tokens.push({ text: stringMatch[0], color: "green" }) + remaining = remaining.slice(stringMatch[0].length) + continue + } + + const numberMatch = NUMBER_REGEX.exec(remaining) + if (numberMatch) { + tokens.push({ text: numberMatch[0], color: "cyan" }) + remaining = remaining.slice(numberMatch[0].length) + continue + } + + const wordMatch = WORD_REGEX.exec(remaining) + if (wordMatch) { + const word = wordMatch[0] + const color = KEYWORDS.has(word) ? "magenta" : "white" + tokens.push({ text: word, color }) + remaining = remaining.slice(word.length) + continue + } + + const operatorMatch = OPERATOR_REGEX.exec(remaining) + if (operatorMatch) { + tokens.push({ text: operatorMatch[0], color: "yellow" }) + remaining = remaining.slice(operatorMatch[0].length) + continue + } + + const whitespaceMatch = WHITESPACE_REGEX.exec(remaining) + if (whitespaceMatch) { + tokens.push({ text: whitespaceMatch[0], color: "white" }) + remaining = remaining.slice(whitespaceMatch[0].length) + continue + } + + tokens.push({ text: remaining[0] ?? "", color: "white" }) + remaining = remaining.slice(1) + } + + return tokens +} diff --git a/packages/ipuaro/tests/unit/tui/components/Input.test.ts b/packages/ipuaro/tests/unit/tui/components/Input.test.ts index c27957a..841c309 100644 --- a/packages/ipuaro/tests/unit/tui/components/Input.test.ts +++ b/packages/ipuaro/tests/unit/tui/components/Input.test.ts @@ -181,4 +181,170 @@ describe("Input", () => { expect(savedInput).toBe("") }) }) + + describe("multiline support", () => { + describe("InputProps with multiline", () => { + it("should accept multiline as boolean", () => { + const props: InputProps = { + onSubmit: vi.fn(), + history: [], + disabled: false, + multiline: true, + } + expect(props.multiline).toBe(true) + }) + + it("should accept multiline as 'auto'", () => { + const props: InputProps = { + onSubmit: vi.fn(), + history: [], + disabled: false, + multiline: "auto", + } + expect(props.multiline).toBe("auto") + }) + + it("should have multiline false by default", () => { + const props: InputProps = { + onSubmit: vi.fn(), + history: [], + disabled: false, + } + expect(props.multiline).toBeUndefined() + }) + }) + + describe("multiline activation logic", () => { + it("should be active when multiline is true", () => { + const multiline = true + const lines = ["single line"] + const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1) + expect(isMultilineActive).toBe(true) + }) + + it("should not be active when multiline is false", () => { + const multiline = false + const lines = ["line1", "line2"] + const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1) + expect(isMultilineActive).toBe(false) + }) + + it("should be active in auto mode with multiple lines", () => { + const multiline = "auto" + const lines = ["line1", "line2"] + const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1) + expect(isMultilineActive).toBe(true) + }) + + it("should not be active in auto mode with single line", () => { + const multiline = "auto" + const lines = ["single line"] + const isMultilineActive = multiline === true || (multiline === "auto" && lines.length > 1) + expect(isMultilineActive).toBe(false) + }) + }) + + describe("line management", () => { + it("should update current line on change", () => { + const lines = ["first", "second", "third"] + const currentLineIndex = 1 + const newValue = "updated second" + + const newLines = [...lines] + newLines[currentLineIndex] = newValue + + expect(newLines).toEqual(["first", "updated second", "third"]) + expect(newLines.join("\n")).toBe("first\nupdated second\nthird") + }) + + it("should add new line at current position", () => { + const lines = ["first", "second"] + const currentLineIndex = 0 + + const newLines = [...lines] + newLines.splice(currentLineIndex + 1, 0, "") + + expect(newLines).toEqual(["first", "", "second"]) + }) + + it("should join lines with newline for submit", () => { + const lines = ["line 1", "line 2", "line 3"] + const fullText = lines.join("\n") + expect(fullText).toBe("line 1\nline 2\nline 3") + }) + }) + + describe("line navigation", () => { + it("should navigate up in multiline mode", () => { + const lines = ["line1", "line2", "line3"] + let currentLineIndex = 2 + + currentLineIndex = currentLineIndex - 1 + expect(currentLineIndex).toBe(1) + + currentLineIndex = currentLineIndex - 1 + expect(currentLineIndex).toBe(0) + }) + + it("should not navigate up past first line", () => { + const lines = ["line1", "line2"] + const currentLineIndex = 0 + const isMultilineActive = true + + const canNavigateUp = isMultilineActive && currentLineIndex > 0 + expect(canNavigateUp).toBe(false) + }) + + it("should navigate down in multiline mode", () => { + const lines = ["line1", "line2", "line3"] + let currentLineIndex = 0 + + currentLineIndex = currentLineIndex + 1 + expect(currentLineIndex).toBe(1) + + currentLineIndex = currentLineIndex + 1 + expect(currentLineIndex).toBe(2) + }) + + it("should not navigate down past last line", () => { + const lines = ["line1", "line2"] + const currentLineIndex = 1 + const isMultilineActive = true + + const canNavigateDown = isMultilineActive && currentLineIndex < lines.length - 1 + expect(canNavigateDown).toBe(false) + }) + }) + + describe("multiline submit", () => { + it("should submit trimmed multiline text", () => { + const lines = ["line 1", "line 2", "line 3"] + const fullText = lines.join("\n").trim() + expect(fullText).toBe("line 1\nline 2\nline 3") + }) + + it("should not submit empty multiline text", () => { + const onSubmit = vi.fn() + const lines = ["", "", ""] + const fullText = lines.join("\n").trim() + + if (fullText) { + onSubmit(fullText) + } + + expect(onSubmit).not.toHaveBeenCalled() + }) + + it("should reset lines after submit", () => { + let lines = ["line1", "line2"] + let currentLineIndex = 1 + + lines = [""] + currentLineIndex = 0 + + expect(lines).toEqual([""]) + expect(currentLineIndex).toBe(0) + }) + }) + }) }) diff --git a/packages/ipuaro/tests/unit/tui/utils/syntax-highlighter.test.ts b/packages/ipuaro/tests/unit/tui/utils/syntax-highlighter.test.ts new file mode 100644 index 0000000..edf019e --- /dev/null +++ b/packages/ipuaro/tests/unit/tui/utils/syntax-highlighter.test.ts @@ -0,0 +1,155 @@ +/** + * Tests for syntax-highlighter utility. + */ + +import { describe, expect, it } from "vitest" +import { detectLanguage, highlightLine } from "../../../../src/tui/utils/syntax-highlighter.js" + +describe("syntax-highlighter", () => { + describe("detectLanguage", () => { + it("should detect typescript from .ts extension", () => { + expect(detectLanguage("src/index.ts")).toBe("typescript") + }) + + it("should detect tsx from .tsx extension", () => { + expect(detectLanguage("src/Component.tsx")).toBe("tsx") + }) + + it("should detect javascript from .js extension", () => { + expect(detectLanguage("dist/bundle.js")).toBe("javascript") + }) + + it("should detect jsx from .jsx extension", () => { + expect(detectLanguage("src/App.jsx")).toBe("jsx") + }) + + it("should detect json from .json extension", () => { + expect(detectLanguage("package.json")).toBe("json") + }) + + it("should detect yaml from .yaml extension", () => { + expect(detectLanguage("config.yaml")).toBe("yaml") + }) + + it("should detect yaml from .yml extension", () => { + expect(detectLanguage("config.yml")).toBe("yaml") + }) + + it("should return unknown for unsupported extensions", () => { + expect(detectLanguage("image.png")).toBe("unknown") + expect(detectLanguage("file")).toBe("unknown") + }) + + it("should handle case insensitive extensions", () => { + expect(detectLanguage("FILE.TS")).toBe("typescript") + expect(detectLanguage("FILE.JSX")).toBe("jsx") + }) + }) + + describe("highlightLine", () => { + describe("unknown language", () => { + it("should return plain text for unknown language", () => { + const tokens = highlightLine("hello world", "unknown") + expect(tokens).toEqual([{ text: "hello world", color: "white" }]) + }) + }) + + describe("json language", () => { + it("should return plain text for json", () => { + const tokens = highlightLine('{"key": "value"}', "json") + expect(tokens).toEqual([{ text: '{"key": "value"}', color: "white" }]) + }) + }) + + describe("yaml language", () => { + it("should return plain text for yaml", () => { + const tokens = highlightLine("key: value", "yaml") + expect(tokens).toEqual([{ text: "key: value", color: "white" }]) + }) + }) + + describe("typescript/javascript highlighting", () => { + it("should highlight keywords", () => { + const tokens = highlightLine("const x = 10", "typescript") + expect(tokens[0]).toEqual({ text: "const", color: "magenta" }) + expect(tokens.find((t) => t.text === "x")).toEqual({ text: "x", color: "white" }) + }) + + it("should highlight strings with double quotes", () => { + const tokens = highlightLine('const s = "hello"', "typescript") + expect(tokens.find((t) => t.text === '"hello"')).toEqual({ + text: '"hello"', + color: "green", + }) + }) + + it("should highlight strings with single quotes", () => { + const tokens = highlightLine("const s = 'hello'", "typescript") + expect(tokens.find((t) => t.text === "'hello'")).toEqual({ + text: "'hello'", + color: "green", + }) + }) + + it("should highlight template literals", () => { + const tokens = highlightLine("const s = `hello`", "typescript") + expect(tokens.find((t) => t.text === "`hello`")).toEqual({ + text: "`hello`", + color: "green", + }) + }) + + it("should highlight numbers", () => { + const tokens = highlightLine("const n = 42", "typescript") + expect(tokens.find((t) => t.text === "42")).toEqual({ text: "42", color: "cyan" }) + }) + + it("should highlight single-line comments", () => { + const tokens = highlightLine("// this is a comment", "typescript") + expect(tokens[0]).toEqual({ text: "// this is a comment", color: "gray" }) + }) + + it("should highlight multi-line comments", () => { + const tokens = highlightLine("/* comment */", "typescript") + expect(tokens[0]).toEqual({ text: "/* comment */", color: "gray" }) + }) + + it("should highlight operators", () => { + const tokens = highlightLine("x + y = z", "typescript") + expect(tokens.find((t) => t.text === "+")).toEqual({ text: "+", color: "yellow" }) + expect(tokens.find((t) => t.text === "=")).toEqual({ text: "=", color: "yellow" }) + }) + + it("should highlight parentheses and brackets", () => { + const tokens = highlightLine("foo(bar[0])", "typescript") + expect(tokens.find((t) => t.text === "(")).toEqual({ text: "(", color: "yellow" }) + expect(tokens.find((t) => t.text === "[")).toEqual({ text: "[", color: "yellow" }) + expect(tokens.find((t) => t.text === "]")).toEqual({ text: "]", color: "yellow" }) + expect(tokens.find((t) => t.text === ")")).toEqual({ text: ")", color: "yellow" }) + }) + + it("should handle mixed content", () => { + const tokens = highlightLine('const x = "test" + 42', "typescript") + expect(tokens.find((t) => t.text === "const")).toEqual({ + text: "const", + color: "magenta", + }) + expect(tokens.find((t) => t.text === '"test"')).toEqual({ + text: '"test"', + color: "green", + }) + expect(tokens.find((t) => t.text === "42")).toEqual({ text: "42", color: "cyan" }) + }) + + it("should preserve whitespace", () => { + const tokens = highlightLine(" const x = 10 ", "typescript") + expect(tokens[0]).toEqual({ text: " ", color: "white" }) + }) + + it("should handle empty lines", () => { + const tokens = highlightLine("", "typescript") + expect(tokens).toEqual([]) + }) + }) + }) +})