diff --git a/src/renderer/src/lib/monaco-languages/groovy-highlighting.test.ts b/src/renderer/src/lib/monaco-languages/groovy-highlighting.test.ts new file mode 100644 index 00000000000..7139154ecac --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/groovy-highlighting.test.ts @@ -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') +}) diff --git a/src/renderer/src/lib/monaco-languages/register-groovy.ts b/src/renderer/src/lib/monaco-languages/register-groovy.ts new file mode 100644 index 00000000000..999e6fa620e --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/register-groovy.ts @@ -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 { + const { default: grammars } = await import('shiki/langs/groovy.mjs') + return grammars[0] +} + +export function registerGroovyLanguage(monaco: Pick): 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 + }) +} diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts index 03c1d2a2dfd..afa2497cb2b 100644 --- a/src/renderer/src/lib/monaco-setup.ts +++ b/src/renderer/src/lib/monaco-setup.ts @@ -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)], diff --git a/src/shared/editor-language/editor-language-detect.test.ts b/src/shared/editor-language/editor-language-detect.test.ts index da44f30501c..6267ba77d31 100644 --- a/src/shared/editor-language/editor-language-detect.test.ts +++ b/src/shared/editor-language/editor-language-detect.test.ts @@ -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'], diff --git a/src/shared/editor-language/editor-language-detect.ts b/src/shared/editor-language/editor-language-detect.ts index 77838f5e447..b6fcb720db7 100644 --- a/src/shared/editor-language/editor-language-detect.ts +++ b/src/shared/editor-language/editor-language-detect.ts @@ -91,6 +91,12 @@ const EXT_TO_LANGUAGE: Record = { '.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 = { const FILENAME_TO_LANGUAGE: Record = { Dockerfile: 'dockerfile', + Jenkinsfile: 'groovy', Makefile: 'makefile', 'CMakeLists.txt': 'cmake', '.gitignore': 'ini', @@ -172,7 +179,8 @@ const SHEBANG_INTERPRETER_TO_LANGUAGE: Record = { 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) {