mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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']))
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user