diff --git a/src/renderer/src/lib/monaco-languages/register-zig.test.ts b/src/renderer/src/lib/monaco-languages/register-zig.test.ts new file mode 100644 index 00000000000..5f7c79de870 --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/register-zig.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import * as Monaco from 'monaco-editor' +import { loadNodeOniguruma } from '../syntax-highlighting/oniguruma-test-harness' +import { registerZigLanguage } from './register-zig' + +const grammarLoads = vi.hoisted(() => vi.fn()) +vi.mock('shiki/langs/zig.mjs', async (importOriginal) => { + grammarLoads() + return importOriginal() +}) +vi.mock('../syntax-highlighting/oniguruma', () => ({ loadOniguruma: loadNodeOniguruma })) + +type Factory = Parameters[1] + +describe('Zig lazy provider activation', () => { + it('loads once on demand, preserves tokenizer state, and bounds long lines', async () => { + const registered: Monaco.languages.ILanguageExtensionPoint[] = [] + let tokensFactory: Factory | undefined + const languages = { + ...Monaco.languages, + getLanguages: () => registered, + register: vi.fn((language: Monaco.languages.ILanguageExtensionPoint) => { + registered.push(language) + return { dispose: vi.fn() } + }), + setLanguageConfiguration: vi.fn(() => ({ dispose: vi.fn() })), + registerTokensProviderFactory: vi.fn((_id: string, factory: Factory) => { + tokensFactory = factory + return { dispose: vi.fn() } + }) + } + registerZigLanguage({ languages }) + expect(grammarLoads).not.toHaveBeenCalled() + if (!tokensFactory) { + throw new Error('Zig provider factory was not registered') + } + const factory: Factory = tokensFactory + const pending = factory.create() + expect(factory.create()).toBe(pending) + const provider = await pending + if (!provider || !('tokenize' in provider)) { + throw new Error('Expected Zig line tokenizer') + } + expect(grammarLoads).toHaveBeenCalledTimes(1) + registerZigLanguage({ languages }) + expect(languages.register).toHaveBeenCalledTimes(1) + + const opening = provider.tokenize('const text = \\\\fn // literal', provider.getInitialState()) + expect(opening.tokens.map((token) => token.scopes)).toContain('string.multiline.zig') + const clone = opening.endState.clone() + expect(clone.equals(opening.endState)).toBe(true) + const after = provider.tokenize('pub fn after() void {}', clone) + expect(after.tokens).toEqual( + provider.tokenize('pub fn after() void {}', provider.getInitialState()).tokens + ) + expect(after.tokens.map((token) => token.scopes)).toContain('keyword.zig') + const edited = provider.tokenize('pub fn other() void {}', provider.getInitialState()) + expect(edited.endState.equals(after.endState)).toBe(true) + + const started = performance.now() + const skipped = provider.tokenize('x'.repeat(1001), provider.getInitialState()) + const tail = provider.tokenize('pub fn after() void {}', skipped.endState) + expect(performance.now() - started).toBeLessThan(1000) + expect(skipped.tokens).toEqual([{ startIndex: 0, scopes: 'source.zig' }]) + expect(tail.tokens).toEqual(skipped.tokens) + expect(tail.endState.equals(skipped.endState)).toBe(true) + expect(after.endState.equals(skipped.endState)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/monaco-languages/register-zig.ts b/src/renderer/src/lib/monaco-languages/register-zig.ts new file mode 100644 index 00000000000..70d44c3d6a1 --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/register-zig.ts @@ -0,0 +1,51 @@ +import type * as Monaco from 'monaco-editor' +import type { LanguageRegistration } from 'shiki/core' +import { registerTextMateLanguage } from './textmate-language-registration' + +function mapZigTokenScopes(scopes: readonly string[]): string { + const scope = scopes.at(-1) ?? 'source.zig' + if (scope.startsWith('constant.numeric.')) { + return 'number.zig' + } + if (scope.startsWith('constant.character.escape.')) { + return 'string.escape.zig' + } + if (scope.startsWith('storage.type.')) { + return 'keyword.zig' + } + if (scope.startsWith('keyword.type.')) { + return 'type.zig' + } + if (scope.startsWith('support.function.') || scope.startsWith('entity.name.')) { + return 'type.zig' + } + return scope +} + +export async function loadZigTextMateGrammar(): Promise { + const { default: grammars } = await import('shiki/langs/zig.mjs') + return grammars[0] +} + +export function registerZigLanguage(monaco: Pick): void { + registerTextMateLanguage(monaco, { + language: { id: 'zig', extensions: ['.zig'], aliases: ['Zig', 'zig'] }, + configuration: { + comments: { lineComment: '//' }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'] + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"', notIn: ['string', 'comment'] }, + { open: "'", close: "'", notIn: ['string', 'comment'] } + ] + }, + loadGrammar: loadZigTextMateGrammar, + mapTokenScopes: mapZigTokenScopes + }) +} diff --git a/src/renderer/src/lib/monaco-languages/textmate-language-registration.ts b/src/renderer/src/lib/monaco-languages/textmate-language-registration.ts index c1088a54379..d32651e7be7 100644 --- a/src/renderer/src/lib/monaco-languages/textmate-language-registration.ts +++ b/src/renderer/src/lib/monaco-languages/textmate-language-registration.ts @@ -4,7 +4,7 @@ import type { TextMateGrammarLoader } from './textmate-token-provider' -type MonacoModule = typeof Monaco +type MonacoModule = Pick type TextMateTokensProvider = Monaco.languages.TokensProvider type TextMateTokenProviderModule = { createTextMateTokensProvider: typeof createTextMateTokensProviderType @@ -14,6 +14,7 @@ export type TextMateLanguageRegistration = { language: Monaco.languages.ILanguageExtensionPoint configuration?: Monaco.languages.LanguageConfiguration loadGrammar: TextMateGrammarLoader + mapTokenScopes?: (scopes: readonly string[]) => string loadProviderModule?: () => Promise } @@ -45,7 +46,10 @@ export function registerTextMateLanguage( tokensProviderPromise ??= ( registration.loadProviderModule ?? loadDefaultProviderModule )().then(({ createTextMateTokensProvider }) => - createTextMateTokensProvider({ loadGrammar: registration.loadGrammar }) + createTextMateTokensProvider({ + loadGrammar: registration.loadGrammar, + ...(registration.mapTokenScopes ? { mapTokenScopes: registration.mapTokenScopes } : {}) + }) ) return tokensProviderPromise } diff --git a/src/renderer/src/lib/monaco-languages/textmate-token-provider.ts b/src/renderer/src/lib/monaco-languages/textmate-token-provider.ts index 3838ec85206..9150bb97e45 100644 --- a/src/renderer/src/lib/monaco-languages/textmate-token-provider.ts +++ b/src/renderer/src/lib/monaco-languages/textmate-token-provider.ts @@ -14,6 +14,7 @@ export type TextMateGrammarLoader = () => Promise export type TextMateTokensProviderOptions = { loadGrammar: TextMateGrammarLoader + mapTokenScopes?: (scopes: readonly string[]) => string } let editorCorePromise: Promise | undefined @@ -49,7 +50,8 @@ class TextMateTokenizerState implements Monaco.languages.IState { function createTokensProvider( { textmate }: TextMateCore, grammar: Grammar, - fallbackScopeName: string + fallbackScopeName: string, + mapTokenScopes?: (scopes: readonly string[]) => string ): TextMateTokensProvider { const plain = (state: TextMateTokenizerState): Monaco.languages.ILineTokens => ({ endState: state, @@ -78,7 +80,7 @@ function createTokensProvider( startIndex: token.startIndex, // Why: Monaco themes match a single token scope; TextMate returns a // scope stack, and the final entry is the most specific reusable one. - scopes: token.scopes.at(-1) ?? fallbackScopeName + scopes: mapTokenScopes?.(token.scopes) ?? token.scopes.at(-1) ?? fallbackScopeName })) } } @@ -93,6 +95,7 @@ export async function createTextMateTokensProvider( return createTokensProvider( editorCore, editorCore.core.getLanguage(registration.name), - registration.scopeName + registration.scopeName, + options.mapTokenScopes ) } diff --git a/src/renderer/src/lib/monaco-languages/zig-highlighting.test.ts b/src/renderer/src/lib/monaco-languages/zig-highlighting.test.ts new file mode 100644 index 00000000000..bc0e867096c --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/zig-highlighting.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment happy-dom +import { describe, expect, it, vi } from 'vitest' +import { loadNodeOniguruma } from '../syntax-highlighting/oniguruma-test-harness' +import { monaco } from '../monaco-setup' +import { detectLanguage } from '../../../../shared/editor-language/editor-language-detect' + +vi.mock('../syntax-highlighting/oniguruma', () => ({ loadOniguruma: loadNodeOniguruma })) + +describe('Zig highlighting through production Monaco setup', () => { + it.each(['main.zig', 'C:\\folder workspace\\MAIN.ZIG'])( + 'recognizes and highlights %s', + async (filename) => { + const language = detectLanguage(filename) + expect(language).toBe('zig') + const source = + 'pub fn main() void {\nconst answer: u32 = 42;\nconst text = "// literal"; // comment\n}' + await monaco.editor.colorize(source, language, {}) + const tokens = monaco.editor.tokenize(source, language) + expect(tokens[0].map((token) => token.type)).toContain('keyword.zig') + expect(tokens[1].map((token) => token.type)).toEqual( + expect.arrayContaining(['type.zig', 'number.zig']) + ) + expect(tokens[2].map((token) => token.type)).toEqual( + expect.arrayContaining(['string.quoted.double.zig', 'comment.line.double-slash.zig']) + ) + } + ) + + it('highlights builtins, escapes, characters and multiline strings without leaking state', async () => { + const source = [ + 'const std = @import("std");', + "const char: u8 = 'x';", + 'const text = "escaped \\n";', + 'const multiline = \\\\pub fn text // literal', + ' \\\\second line', + ' ;', + 'pub fn after() void {}' + ].join('\n') + await monaco.editor.colorize(source, 'zig', {}) + const tokens = monaco.editor.tokenize(source, 'zig') + expect(tokens[0].map((token) => token.type)).toContain('type.zig') + expect(tokens[1].map((token) => token.type)).toContain('string.quoted.single.zig') + expect(tokens[2].map((token) => token.type)).toContain('string.escape.zig') + expect(tokens[3].map((token) => token.type)).toContain('string.multiline.zig') + expect(tokens[4].map((token) => token.type)).toContain('string.multiline.zig') + expect(tokens[6].map((token) => token.type)).toContain('keyword.zig') + expect(tokens[6].map((token) => token.type)).not.toContain('string.multiline.zig') + }) + + it('keeps unrelated languages and unclaimed Zig object notation unchanged', async () => { + expect(detectLanguage('build.zig.zon')).toBe('plaintext') + expect(detectLanguage('main.rs')).toBe('rust') + expect(detectLanguage('main.c')).toBe('c') + await monaco.editor.colorize('const answer = 42', 'typescript', {}) + expect( + monaco.editor.tokenize('const answer = 42', 'typescript')[0].map((token) => token.type) + ).toEqual(expect.arrayContaining(['keyword.ts', 'number.ts'])) + }) +}) diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts index 49cad5673e0..97ee05bc5d8 100644 --- a/src/renderer/src/lib/monaco-setup.ts +++ b/src/renderer/src/lib/monaco-setup.ts @@ -17,6 +17,7 @@ import { registerShellMarkdownAliases } from './monaco-languages/register-shell- import { registerSvelteLanguage } from './monaco-languages/register-svelte' import { registerTypstLanguage } from './monaco-languages/register-typst' import { registerVueLanguage } from './monaco-languages/register-vue' +import { registerZigLanguage } from './monaco-languages/register-zig' import { installMonacoDelayerCancellationGuard } from './monaco-delayer-cancellation-guard' import { installMonacoDiffEditorDisposalGuard } from './monaco-diff-editor-disposal' import { installMonacoPeekReferencesPreviewOptions } from './monaco-peek-preview-options' @@ -87,6 +88,7 @@ runMonacoSetupSteps([ ['Nim language registration', () => registerNimLanguage(monaco)], ['Lean language registration', () => registerLeanLanguage(monaco)], ['Typst language registration', () => registerTypstLanguage(monaco)], + ['Zig language registration', () => registerZigLanguage(monaco)], ['JSONL language registration', () => registerJsonlLanguage(monaco)], ['Quarto language registration', () => registerQuartoLanguage(monaco)], ['shell Markdown alias registration', () => registerShellMarkdownAliases(monaco)], diff --git a/src/shared/editor-language/editor-language-detect.ts b/src/shared/editor-language/editor-language-detect.ts index d7365669e35..088e3bf8f1e 100644 --- a/src/shared/editor-language/editor-language-detect.ts +++ b/src/shared/editor-language/editor-language-detect.ts @@ -54,6 +54,7 @@ const EXT_TO_LANGUAGE: Record = { '.svg': 'xml', '.py': 'python', '.rs': 'rust', + '.zig': 'zig', '.go': 'go', '.java': 'java', '.kt': 'kotlin', diff --git a/tests/e2e/editor-zig-highlighting.spec.ts b/tests/e2e/editor-zig-highlighting.spec.ts new file mode 100644 index 00000000000..66d5273077b --- /dev/null +++ b/tests/e2e/editor-zig-highlighting.spec.ts @@ -0,0 +1,144 @@ +import { writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Locator } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + cleanupGoldenWorktree, + createGoldenWorktree, + openGoldenSourceControl +} from './helpers/golden-source-control' +import { waitForSessionReady } from './helpers/store' + +const source = [ + '// proof note', + 'const std = @import("std");', + 'pub fn main() void {', + ' const answer: u32 = 42;', + ' std.debug.print("hello {d}\\n", .{answer});', + '}', + '' +].join('\n') + +async function paintedTokens(editor: Locator) { + return editor.locator('.view-lines:not(.line-delete)').evaluate((lines) => + [...lines.querySelectorAll('.view-line')].map((line) => ({ + text: (line.textContent ?? '').replaceAll('\u00a0', ' '), + tokens: [...line.querySelectorAll('span[class*="mtk"]')].map((token) => ({ + text: (token.textContent ?? '').replaceAll('\u00a0', ' '), + className: token.className, + color: getComputedStyle(token).color + })) + })) + ) +} + +async function expectHighlighted(editor: Locator): Promise { + await expect(editor).toBeVisible() + await expect(editor.locator('.view-lines:not(.line-delete)')).toContainText('// proof note') + await expect + .poll( + async () => { + const lines = await paintedTokens(editor) + const color = (lineText: string, text: string) => + lines + .find((line) => line.text === lineText) + ?.tokens.find((token) => token.text.includes(text))?.color + const keyword = color('pub fn main() void {', 'pub') + const type = color(' const answer: u32 = 42;', 'u32') + const number = color(' const answer: u32 = 42;', '42') + const comment = color('// proof note', 'proof note') + const string = color(' std.debug.print("hello {d}\\n", .{answer});', 'hello') + return ( + [keyword, type, number, comment, string].every(Boolean) && + new Set([keyword, type, number, comment, string]).size >= 4 + ) + }, + { timeout: 25_000 } + ) + .toBe(true) +} + +test('renders Zig source and source-control diffs in both themes', async ({ + orcaPage, + electronApp, + testRepoPath, + registerPostElectronShutdownCleanup +}, testInfo) => { + const fixture = createGoldenWorktree(testRepoPath, 'zig-highlighting') + registerPostElectronShutdownCleanup(async () => cleanupGoldenWorktree(testRepoPath, fixture)) + writeFileSync(path.join(fixture.worktreePath, 'main.zig'), source) + await waitForSessionReady(orcaPage) + await openGoldenSourceControl(orcaPage, testRepoPath, fixture) + const identity = await electronApp.evaluate(({ BrowserWindow }) => ({ + cwd: process.cwd(), + windows: BrowserWindow.getAllWindows().map((window) => ({ + visible: window.isVisible(), + focused: window.isFocused() + })) + })) + expect(identity.cwd).toBe(process.cwd()) + expect(identity.windows.length).toBeGreaterThan(0) + expect(identity.windows.every((window) => !window.visible && !window.focused)).toBe(true) + await testInfo.attach('hidden-instance-identity', { + body: JSON.stringify(identity, null, 2), + contentType: 'application/json' + }) + + for (const filename of ['main.zig']) { + await orcaPage.evaluate(() => window.__store?.getState().showRightSidebarFiles()) + await orcaPage + .locator('button[data-explorer-draggable="true"]') + .filter({ has: orcaPage.getByText(filename, { exact: true }) }) + .click() + await expect + .poll(() => orcaPage.evaluate(() => window.__monacoEditorE2E?.filePath)) + .toBe(path.join(fixture.worktreePath, filename)) + await expect + .poll(() => orcaPage.evaluate(() => window.__monacoEditorE2E?.snapshot().valueLength)) + .toBe(source.length) + + for (const theme of ['light', 'dark'] as const) { + await orcaPage.evaluate( + (theme) => window.__store?.getState().updateSettings({ theme }), + theme + ) + const editor = orcaPage + .locator(theme === 'light' ? '.monaco-editor.vs' : '.monaco-editor.vs-dark') + .first() + await expectHighlighted(editor) + await testInfo.attach(`${filename}-source-${theme}-tokens`, { + body: JSON.stringify(await paintedTokens(editor), null, 2), + contentType: 'application/json' + }) + await orcaPage.screenshot({ path: testInfo.outputPath(`${filename}-source-${theme}.png`) }) + } + + await orcaPage.getByRole('button', { name: /Source Control/ }).click() + await orcaPage + .locator('[data-testid="source-control-entry"]') + .filter({ hasText: filename }) + .click() + for (const theme of ['light', 'dark'] as const) { + await orcaPage.evaluate( + (theme) => window.__store?.getState().updateSettings({ theme }), + theme + ) + const editor = orcaPage.locator( + theme === 'light' + ? '.modified-in-monaco-diff-editor.vs' + : '.modified-in-monaco-diff-editor.vs-dark' + ) + await expectHighlighted(editor) + await testInfo.attach(`${filename}-diff-${theme}-tokens`, { + body: JSON.stringify(await paintedTokens(editor), null, 2), + contentType: 'application/json' + }) + await orcaPage.screenshot({ path: testInfo.outputPath(`${filename}-diff-${theme}.png`) }) + } + } + expect( + await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible() && !window.isFocused()) + ) + ).toBe(true) +})