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 12908d0341,
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 <davidwis000@gmail.com>

* 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 <davidwis000@gmail.com>

* 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 <davidwis000@gmail.com>
This commit is contained in:
Neil
2026-10-24 17:48:38 -07:00
committed by GitHub
co-authored by davidwis
parent 6017bc99b4
commit b5f9e06164
8 changed files with 339 additions and 5 deletions
@@ -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<typeof Monaco.languages.registerTokensProviderFactory>[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)
})
})
@@ -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<LanguageRegistration> {
const { default: grammars } = await import('shiki/langs/zig.mjs')
return grammars[0]
}
export function registerZigLanguage(monaco: Pick<typeof Monaco, 'languages'>): 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
})
}
@@ -4,7 +4,7 @@ import type {
TextMateGrammarLoader
} from './textmate-token-provider'
type MonacoModule = typeof Monaco
type MonacoModule = Pick<typeof Monaco, 'languages'>
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<TextMateTokenProviderModule>
}
@@ -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
}
@@ -14,6 +14,7 @@ export type TextMateGrammarLoader = () => Promise<LanguageRegistration>
export type TextMateTokensProviderOptions = {
loadGrammar: TextMateGrammarLoader
mapTokenScopes?: (scopes: readonly string[]) => string
}
let editorCorePromise: Promise<TextMateCore> | 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
)
}
@@ -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']))
})
})
+2
View File
@@ -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)],
@@ -54,6 +54,7 @@ const EXT_TO_LANGUAGE: Record<string, string> = {
'.svg': 'xml',
'.py': 'python',
'.rs': 'rust',
'.zig': 'zig',
'.go': 'go',
'.java': 'java',
'.kt': 'kotlin',
+144
View File
@@ -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<void> {
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)
})