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:
Mac kingsley
2026-10-28 01:50:41 -07:00
committed by GitHub
co-authored by Neil
parent 86f9b369d8
commit 1a0ce3a55c
5 changed files with 142 additions and 1 deletions
@@ -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
})
}
+2
View File
@@ -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) {