mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
fix(editor): enable Lean syntax highlighting (#31930)
* fix(editor): enable Lean syntax highlighting * fix(editor): enable Lean syntax highlighting Adapt #31930 onto current main and retain its bundled Lean grammar repair. Register the lazy TextMate provider and cover detection, proof syntax, nested comments, and bounded tokenization. * test(editor): verify Lean highlighting in the full app --------- Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import type * as Monaco from 'monaco-editor'
|
||||
import type { LanguageRegistration } from 'shiki/core'
|
||||
import { registerTextMateLanguage } from './textmate-language-registration'
|
||||
|
||||
export async function loadLeanTextMateGrammar(): Promise<LanguageRegistration> {
|
||||
const { default: grammars } = await import('shiki/langs/lean.mjs')
|
||||
const grammar = grammars[0]
|
||||
const repository = Object.assign({}, grammar.repository)
|
||||
// TextMate drops this rule when its only pattern includes the unbundled Lean Markdown grammar.
|
||||
repository.dashComment = { ...repository.dashComment, patterns: [] }
|
||||
return { ...grammar, repository }
|
||||
}
|
||||
|
||||
export function registerLeanLanguage(monaco: typeof Monaco): void {
|
||||
registerTextMateLanguage(monaco, {
|
||||
language: {
|
||||
id: 'lean',
|
||||
extensions: ['.lean'],
|
||||
aliases: ['Lean 4', 'Lean', 'lean', 'lean4']
|
||||
},
|
||||
loadGrammar: loadLeanTextMateGrammar
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { loadNodeOniguruma } from '../syntax-highlighting/oniguruma-test-harness'
|
||||
import { loadLeanTextMateGrammar } from './register-lean'
|
||||
import { loadNimTextMateGrammar } from './register-nim'
|
||||
import { loadTypstTextMateGrammar } from './register-typst'
|
||||
import { createTextMateTokensProvider } from './textmate-token-provider'
|
||||
@@ -7,6 +8,33 @@ import { createTextMateTokensProvider } from './textmate-token-provider'
|
||||
vi.mock('../syntax-highlighting/oniguruma', () => ({ loadOniguruma: loadNodeOniguruma }))
|
||||
|
||||
describe('createTextMateTokensProvider', () => {
|
||||
it('tokenizes Lean definitions, proof keywords, Unicode binders, strings, and comments', async () => {
|
||||
const provider = await createTextMateTokensProvider({ loadGrammar: loadLeanTextMateGrammar })
|
||||
const scopesOf = (line: string) =>
|
||||
provider.tokenize(line, provider.getInitialState()).tokens.map((token) => token.scopes)
|
||||
|
||||
expect(scopesOf('theorem identity (p : Prop) : p → p := by')).toEqual(
|
||||
expect.arrayContaining(['keyword.other.definitioncommand.lean4', 'storage.type.lean4'])
|
||||
)
|
||||
expect(scopesOf(' show p from h')).toContain('keyword.other.lean4')
|
||||
expect(scopesOf('#check ∀ (α : Type), α → α')).toContain('storage.type.lean4')
|
||||
expect(scopesOf('#eval "hello"')).toContain('string.quoted.double.lean4')
|
||||
expect(scopesOf('-- proof note')).toContain('comment.line.double-dash.lean4')
|
||||
})
|
||||
|
||||
it('carries nested Lean block comments across lines and resumes code after both closers', async () => {
|
||||
const provider = await createTextMateTokensProvider({ loadGrammar: loadLeanTextMateGrammar })
|
||||
let state = provider.tokenize('/- outer /- inner', provider.getInitialState()).endState
|
||||
state = provider.tokenize('-/ still outer', state).endState
|
||||
const hidden = provider.tokenize('theorem hidden : True := by trivial', state)
|
||||
expect(hidden.tokens.map((token) => token.scopes)).toEqual(['comment.block.lean4'])
|
||||
const closed = provider.tokenize('-/', hidden.endState)
|
||||
const line = 'def answer : Nat := 42'
|
||||
const after = provider.tokenize(line, closed.endState)
|
||||
expect(after.tokens).toEqual(provider.tokenize(line, provider.getInitialState()).tokens)
|
||||
expect(after.tokens.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('tokenizes Nim with the vendored TextMate grammar', async () => {
|
||||
const provider = await createTextMateTokensProvider({ loadGrammar: loadNimTextMateGrammar })
|
||||
|
||||
@@ -114,6 +142,7 @@ describe('createTextMateTokensProvider', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Lean', loadLeanTextMateGrammar, '/-'.repeat(40000), 'def value : Nat := 42'],
|
||||
['Nim', loadNimTextMateGrammar, 'abcdefghij'.repeat(8000), 'proc greet() = discard'],
|
||||
['Typst', loadTypstTextMateGrammar, '#let x = 12pt; *b* $x^2$ '.repeat(2000), '#let y = 2']
|
||||
])(
|
||||
|
||||
@@ -10,6 +10,7 @@ import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
|
||||
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
|
||||
import { registerAstroLanguage } from './monaco-languages/register-astro'
|
||||
import { registerJsonlLanguage } from './monaco-languages/register-jsonl'
|
||||
import { registerLeanLanguage } from './monaco-languages/register-lean'
|
||||
import { registerNimLanguage } from './monaco-languages/register-nim'
|
||||
import { registerQuartoLanguage } from './monaco-languages/register-quarto'
|
||||
import { registerShellMarkdownAliases } from './monaco-languages/register-shell-markdown-aliases'
|
||||
@@ -84,6 +85,7 @@ runMonacoSetupSteps([
|
||||
['Svelte language registration', () => registerSvelteLanguage(monaco)],
|
||||
['Astro language registration', () => registerAstroLanguage(monaco)],
|
||||
['Nim language registration', () => registerNimLanguage(monaco)],
|
||||
['Lean language registration', () => registerLeanLanguage(monaco)],
|
||||
['Typst language registration', () => registerTypstLanguage(monaco)],
|
||||
['JSONL language registration', () => registerJsonlLanguage(monaco)],
|
||||
['Quarto language registration', () => registerQuartoLanguage(monaco)],
|
||||
|
||||
@@ -20,6 +20,23 @@ describe('detectLanguage', () => {
|
||||
expect(detectLanguage('packages/app.nimble')).toBe('nim')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'Main.lean',
|
||||
'lakefile.lean',
|
||||
'C:\\proofs\\MAIN.LEAN',
|
||||
'\\\\server\\share\\Main.lean',
|
||||
'/home/user/folder workspace/Proof.LeAn'
|
||||
])('maps Lean source %s to the lean language id', (filePath) => {
|
||||
expect(detectLanguage(filePath)).toBe('lean')
|
||||
})
|
||||
|
||||
it.each(['Main.lean.bak', 'Main.leanx', 'proofs.lean/README', 'proofs.lean\\README'])(
|
||||
'keeps non-Lean file %s on plaintext',
|
||||
(filePath) => {
|
||||
expect(detectLanguage(filePath)).toBe('plaintext')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
'documents/report.typ',
|
||||
'C:\\documents\\REPORT.TYP',
|
||||
|
||||
@@ -112,6 +112,7 @@ const EXT_TO_LANGUAGE: Record<string, string> = {
|
||||
'.nim': 'nim',
|
||||
'.nims': 'nim',
|
||||
'.nimble': 'nim',
|
||||
'.lean': 'lean',
|
||||
'.typ': 'typst',
|
||||
'.tf': 'hcl',
|
||||
'.hcl': 'hcl',
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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',
|
||||
'theorem identity (p : Prop) : p → p := by',
|
||||
' intro h',
|
||||
' exact h',
|
||||
'#check ∀ (α : Type), α → α',
|
||||
'#eval "hello"',
|
||||
'/- outer /- inner',
|
||||
'-/ still outer',
|
||||
'theorem hidden : True := by trivial',
|
||||
'-/',
|
||||
'def answer : Nat := 42',
|
||||
''
|
||||
].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('theorem identity (p : Prop) : p → p := by', 'theorem')
|
||||
const type = color('theorem identity (p : Prop) : p → p := by', 'Prop')
|
||||
const unicode = color('#check ∀ (α : Type), α → α', '∀')
|
||||
const comment = color('-- proof note', 'proof note')
|
||||
const string = color('#eval "hello"', 'hello')
|
||||
const hidden = color('theorem hidden : True := by trivial', 'theorem')
|
||||
const resumed = color('def answer : Nat := 42', 'def')
|
||||
return (
|
||||
[keyword, type, unicode, comment, string, hidden, resumed].every(Boolean) &&
|
||||
new Set([keyword, type, comment, string]).size >= 4 &&
|
||||
hidden === comment &&
|
||||
resumed === keyword
|
||||
)
|
||||
},
|
||||
{ timeout: 25_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
test('renders lean source and source-control diffs in both themes', async ({
|
||||
orcaPage,
|
||||
electronApp,
|
||||
testRepoPath,
|
||||
registerPostElectronShutdownCleanup
|
||||
}, testInfo) => {
|
||||
const fixture = createGoldenWorktree(testRepoPath, 'lean-highlighting')
|
||||
registerPostElectronShutdownCleanup(async () => cleanupGoldenWorktree(testRepoPath, fixture))
|
||||
writeFileSync(path.join(fixture.worktreePath, 'Main.lean'), 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.lean']) {
|
||||
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