From b5f9e061645098259b5eb6d4d181ea3cf908d2c9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:48:38 -0700 Subject: [PATCH] fix(editor): highlight Zig source and diffs (#32705) * fix(editor): add bounded Zig syntax highlighting Recognize .zig files and load the existing pinned Shiki Zig grammar through the lazy TextMate provider. Map its scopes to stock Monaco theme categories while preserving the shared tokenizer state and per-line bounds. Keep .zon and formatting outside this lexical-only change. Reuse the optional scope-mapping hook reviewed in Haskell PR #25411 and its refreshed candidate 12908d03411882eaa04302d61a8643e8f9ed3eda, retaining the original contribution credit from #25315. Ship the existing grammar package MIT notice in renderer public assets for desktop and web. Requested by @thinkAfCod in issue #11313. This addresses highlighting only; it does not close the formatting portion of the issue title. Co-authored-by: davidwis * fix(build): serve Zig grammar notices through allowed assets Move the unchanged Shiki notices beneath assets/ and reference both files with relative HTML metadata links. Preserve reverse-proxy prefixes and let the existing projection and serving allowlists accept the browser client. Do not expand either allowlist. Exercise real Vite output, projection, manifest validation, and static HTTP serving at root and /orca paths. Follow the relative-assets notice strategy used by the coauthored Haskell adaptation and retain its contribution credit. Co-authored-by: davidwis * test(editor): verify Zig source and diff highlighting * chore(editor): drop Zig grammar notice assets Zig grammar comes from the existing shiki dependency (already shipped via syntax-highlighter's bundled-language loader), so no separate notice is needed. --------- Co-authored-by: davidwis --- .../lib/monaco-languages/register-zig.test.ts | 70 +++++++++ .../src/lib/monaco-languages/register-zig.ts | 51 +++++++ .../textmate-language-registration.ts | 8 +- .../textmate-token-provider.ts | 9 +- .../monaco-languages/zig-highlighting.test.ts | 59 +++++++ src/renderer/src/lib/monaco-setup.ts | 2 + .../editor-language/editor-language-detect.ts | 1 + tests/e2e/editor-zig-highlighting.spec.ts | 144 ++++++++++++++++++ 8 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/lib/monaco-languages/register-zig.test.ts create mode 100644 src/renderer/src/lib/monaco-languages/register-zig.ts create mode 100644 src/renderer/src/lib/monaco-languages/zig-highlighting.test.ts create mode 100644 tests/e2e/editor-zig-highlighting.spec.ts 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) +})