mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
feat(editor): highlight Groovy files and Jenkinsfiles (#26792)
Register Groovy as a lazily loaded TextMate language using Shiki's bundled grammar, and map .groovy/.gvy/.gy/.gsh/.gradle, Jenkinsfile, Jenkinsfile.* and *.Jenkinsfile (plus the groovy shebang) to it. Token scopes are mapped onto ids the stock Monaco themes already style. Fixes #25635 Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { 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 }))
|
||||
|
||||
it('highlights a Jenkinsfile with token types the stock Monaco themes style', async () => {
|
||||
const language = detectLanguage('ci/Jenkinsfile')
|
||||
const lines = [
|
||||
"@Library('shared') _",
|
||||
'def count = 42 // build',
|
||||
'sh "make ${params.TARGET}"',
|
||||
'env.OK = count instanceof Integer'
|
||||
]
|
||||
const source = lines.join('\n')
|
||||
await monaco.editor.colorize(source, language, {})
|
||||
const tokens = monaco.editor.tokenize(source, language)
|
||||
const typeAt = (line: number, column: number): string | undefined =>
|
||||
tokens[line].findLast((token) => token.offset <= column)?.type
|
||||
const typeOf = (line: number, text: string) => typeAt(line, lines[line].indexOf(text))
|
||||
|
||||
expect(typeOf(0, '@Library')).toBe('annotation.groovy')
|
||||
expect(typeOf(0, "'")).toBe('string.quoted.single.groovy')
|
||||
expect(typeOf(1, 'def')).toBe('keyword.groovy')
|
||||
expect(typeOf(1, '42')).toBe('number.groovy')
|
||||
expect(typeOf(1, '//')).toBe('comment.line.double-slash.groovy')
|
||||
expect(typeOf(2, '"')).toBe('string.quoted.double.groovy')
|
||||
expect(typeOf(2, 'params')).toBe('source.groovy')
|
||||
expect(typeAt(2, lines[2].length - 1)).toBe('string.quoted.double.groovy')
|
||||
expect(typeOf(3, '.')).toBe('source.groovy')
|
||||
expect(typeOf(3, '=')).toBe('source.groovy')
|
||||
expect(typeOf(3, 'instanceof')).toBe('keyword.operator.instanceof.groovy')
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import type * as Monaco from 'monaco-editor'
|
||||
import type { LanguageRegistration } from 'shiki/core'
|
||||
import { registerTextMateLanguage } from './textmate-language-registration'
|
||||
|
||||
function mapGroovyTokenScope(scope: string): string | undefined {
|
||||
if (scope.startsWith('storage.type.annotation.')) {
|
||||
return 'annotation.groovy'
|
||||
}
|
||||
if (scope.startsWith('constant.numeric.')) {
|
||||
return 'number.groovy'
|
||||
}
|
||||
if (scope === 'storage.type.groovy' || scope.startsWith('entity.')) {
|
||||
return 'type.groovy'
|
||||
}
|
||||
if (scope.startsWith('storage.')) {
|
||||
return 'keyword.groovy'
|
||||
}
|
||||
// Symbolic operators stay plain text like VS Code; only as/in/instanceof read as keywords.
|
||||
if (
|
||||
scope.startsWith('keyword.operator.') &&
|
||||
!/^keyword\.operator\.(?:as|in|instanceof)\./.test(scope)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return /^(?:comment|string|keyword|constant|variable|invalid)\./.test(scope) ? scope : undefined
|
||||
}
|
||||
|
||||
// Why: stock Monaco themes style none of storage.*, and quote/comment punctuation must inherit its parent's color.
|
||||
function mapGroovyTokenScopes(scopes: readonly string[]): string {
|
||||
// Embedded ${} code stays out of its enclosing string's color.
|
||||
for (let index = scopes.length - 1; index >= 0 && !scopes[index].startsWith('source.'); index--) {
|
||||
const mapped = mapGroovyTokenScope(scopes[index])
|
||||
if (mapped) {
|
||||
return mapped
|
||||
}
|
||||
}
|
||||
return 'source.groovy'
|
||||
}
|
||||
|
||||
async function loadGroovyTextMateGrammar(): Promise<LanguageRegistration> {
|
||||
const { default: grammars } = await import('shiki/langs/groovy.mjs')
|
||||
return grammars[0]
|
||||
}
|
||||
|
||||
export function registerGroovyLanguage(monaco: Pick<typeof Monaco, 'languages'>): void {
|
||||
registerTextMateLanguage(monaco, {
|
||||
language: {
|
||||
id: 'groovy',
|
||||
extensions: ['.groovy', '.gvy', '.gy', '.gsh', '.gradle', '.jenkinsfile'],
|
||||
filenames: ['Jenkinsfile'],
|
||||
aliases: ['Groovy', 'groovy']
|
||||
},
|
||||
configuration: {
|
||||
comments: { lineComment: '//', blockComment: ['/*', '*/'] },
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['[', ']'],
|
||||
['(', ')']
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"', notIn: ['string', 'comment'] },
|
||||
{ open: "'", close: "'", notIn: ['string', 'comment'] }
|
||||
]
|
||||
},
|
||||
loadGrammar: loadGroovyTextMateGrammar,
|
||||
mapTokenScopes: mapGroovyTokenScopes
|
||||
})
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
|
||||
import { registerAstroLanguage } from './monaco-languages/register-astro'
|
||||
import { registerCudaLanguage } from './monaco-languages/register-cuda'
|
||||
import { registerGlimmerLanguages } from './monaco-languages/register-glimmer'
|
||||
import { registerGroovyLanguage } from './monaco-languages/register-groovy'
|
||||
import { registerJsonlLanguage } from './monaco-languages/register-jsonl'
|
||||
import { registerLeanLanguage } from './monaco-languages/register-lean'
|
||||
import { registerMarkdownLanguage } from './monaco-languages/register-markdown'
|
||||
@@ -79,6 +80,7 @@ runMonacoSetupSteps([
|
||||
['Lean language registration', () => registerLeanLanguage(monaco)],
|
||||
['Typst language registration', () => registerTypstLanguage(monaco)],
|
||||
['Zig language registration', () => registerZigLanguage(monaco)],
|
||||
['Groovy language registration', () => registerGroovyLanguage(monaco)],
|
||||
['JSONL language registration', () => registerJsonlLanguage(monaco)],
|
||||
['Quarto language registration', () => registerQuartoLanguage(monaco)],
|
||||
['Markdown language registration', () => registerMarkdownLanguage(monaco)],
|
||||
|
||||
@@ -20,6 +20,28 @@ describe('detectLanguage', () => {
|
||||
expect(detectLanguage('C:\\app\\components\\HELLO.GTS')).toBe('glimmer-ts')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'src/Build.groovy',
|
||||
'scripts/deploy.gvy',
|
||||
'scripts/deploy.gy',
|
||||
'scripts/shell.gsh',
|
||||
'C:\\repo\\settings.GRADLE',
|
||||
'Jenkinsfile',
|
||||
'C:\\repo\\jenkinsfile',
|
||||
'ci/Jenkinsfile.release',
|
||||
'ci/deploy.Jenkinsfile'
|
||||
])('maps Groovy source %s to the groovy language id', (filePath) => {
|
||||
expect(detectLanguage(filePath)).toBe('groovy')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Jenkinsfile.md', 'markdown'],
|
||||
['build.gradle.kts', 'kotlin'],
|
||||
['MyJenkinsfileNotes', 'plaintext']
|
||||
])('keeps %s off Groovy', (filePath, language) => {
|
||||
expect(detectLanguage(filePath)).toBe(language)
|
||||
})
|
||||
|
||||
it('maps Nim files to the nim language id', () => {
|
||||
expect(detectLanguage('src/main.nim')).toBe('nim')
|
||||
expect(detectLanguage('tasks/build.nims')).toBe('nim')
|
||||
@@ -268,6 +290,7 @@ describe('detectLanguage', () => {
|
||||
['#!C:\\Python\\python.exe\n', 'python'],
|
||||
['#!/usr/bin/env python3.12', 'python'],
|
||||
['#!/usr/bin/ruby\r\nputs 1', 'ruby'],
|
||||
['#!/usr/bin/env groovy\n', 'groovy'],
|
||||
['#!/usr/bin/env awk', 'plaintext'],
|
||||
['#!/usr/bin/env toString', 'plaintext'],
|
||||
['#!/usr/bin/__proto__', 'plaintext'],
|
||||
|
||||
@@ -91,6 +91,12 @@ const EXT_TO_LANGUAGE: Record<string, string> = {
|
||||
'.r': 'r',
|
||||
'.make': 'makefile',
|
||||
'.scala': 'scala',
|
||||
'.groovy': 'groovy',
|
||||
'.gvy': 'groovy',
|
||||
'.gy': 'groovy',
|
||||
'.gsh': 'groovy',
|
||||
'.gradle': 'groovy',
|
||||
'.jenkinsfile': 'groovy',
|
||||
'.dart': 'dart',
|
||||
'.ex': 'elixir',
|
||||
'.exs': 'elixir',
|
||||
@@ -124,6 +130,7 @@ const EXT_TO_LANGUAGE: Record<string, string> = {
|
||||
|
||||
const FILENAME_TO_LANGUAGE: Record<string, string> = {
|
||||
Dockerfile: 'dockerfile',
|
||||
Jenkinsfile: 'groovy',
|
||||
Makefile: 'makefile',
|
||||
'CMakeLists.txt': 'cmake',
|
||||
'.gitignore': 'ini',
|
||||
@@ -172,7 +179,8 @@ const SHEBANG_INTERPRETER_TO_LANGUAGE: Record<string, string> = {
|
||||
lua: 'lua',
|
||||
luajit: 'lua',
|
||||
pwsh: 'powershell',
|
||||
powershell: 'powershell'
|
||||
powershell: 'powershell',
|
||||
groovy: 'groovy'
|
||||
}
|
||||
|
||||
function detectShebangLanguage(content: string): string | null {
|
||||
@@ -203,6 +211,8 @@ export function detectLanguage(filePath: string, content?: string): string {
|
||||
const language =
|
||||
EXT_TO_LANGUAGE[ext] ??
|
||||
detectMonacoFilenameLanguage(filename) ??
|
||||
// Pipeline variants like Jenkinsfile.release; a known extension (Jenkinsfile.md) still wins.
|
||||
(lowerName.startsWith('jenkinsfile.') ? 'groovy' : undefined) ??
|
||||
(lowerName === '.env' || lowerName.startsWith('.env.') ? 'ini' : 'plaintext')
|
||||
// Why: only extensionless names fall back to the shebang; unmapped extensions like .txt stay plaintext.
|
||||
if (language === 'plaintext' && ext === '' && content !== undefined) {
|
||||
|
||||
Reference in New Issue
Block a user