mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Built-in syntax highlighting for .env files (#34542)
* feat(editor): built-in syntax highlighting for .env files
.env, .env.* and *.env files now open as a dedicated dotenv language with
comment, key, export, value, quoted-string and ${VAR} colouring instead of
INI. .envrc opens as shell, since it is sourced as a shell script.
* fix(mobile): map dotenv to the ini grammar for mobile highlighting
* fix(editor): keep quotes after the first dotenv value character literal
This commit is contained in:
@@ -10,28 +10,28 @@ import type { MobileDiffLine } from './mobile-diff-lines'
|
||||
|
||||
describe('mobile file syntax highlighting', () => {
|
||||
it.each([
|
||||
['.env', 'ini'],
|
||||
['.env.local', 'ini'],
|
||||
['.env.development', 'ini'],
|
||||
['.env.production', 'ini'],
|
||||
['.env.functions.local', 'ini'],
|
||||
['.env.staging', 'ini'],
|
||||
['.env.test.example', 'ini'],
|
||||
['config/.env.development.local', 'ini'],
|
||||
['.ENV', 'ini'],
|
||||
['.ENV.STAGING', 'ini'],
|
||||
['C:\\repo\\.EnV.FUNCTIONS.LOCAL', 'ini'],
|
||||
['\\\\server\\share\\.env.test.example', 'ini'],
|
||||
['.env', 'dotenv'],
|
||||
['.env.local', 'dotenv'],
|
||||
['.env.development', 'dotenv'],
|
||||
['.env.production', 'dotenv'],
|
||||
['.env.functions.local', 'dotenv'],
|
||||
['.env.staging', 'dotenv'],
|
||||
['.env.test.example', 'dotenv'],
|
||||
['config/.env.development.local', 'dotenv'],
|
||||
['.ENV', 'dotenv'],
|
||||
['.ENV.STAGING', 'dotenv'],
|
||||
['C:\\repo\\.EnV.FUNCTIONS.LOCAL', 'dotenv'],
|
||||
['\\\\server\\share\\.env.test.example', 'dotenv'],
|
||||
['.env.sh', 'shell'],
|
||||
['.ENV.SH', 'shell'],
|
||||
['.env.json', 'json'],
|
||||
['.env.local.ts', 'typescript'],
|
||||
['.env/CMakeLists.txt', 'cmake'],
|
||||
['C:\\repo\\.env.local\\Dockerfile', 'dockerfile'],
|
||||
['.envrc', 'plaintext'],
|
||||
['.envrc', 'shell'],
|
||||
['.environment', 'plaintext'],
|
||||
['env.staging', 'plaintext'],
|
||||
['dev.env', 'plaintext'],
|
||||
['dev.env', 'dotenv'],
|
||||
['other.env.local', 'plaintext'],
|
||||
['..env.local', 'plaintext'],
|
||||
['.env.staging/readme', 'plaintext'],
|
||||
@@ -43,7 +43,7 @@ describe('mobile file syntax highlighting', () => {
|
||||
|
||||
it('keeps a mobile language preference ahead of the dotenv fallback', () => {
|
||||
expect(detectMobileFileLanguage('.env.staging', ' JSON ')).toBe('json')
|
||||
expect(detectMobileFileLanguage('.env.staging', ' plaintext ')).toBe('ini')
|
||||
expect(detectMobileFileLanguage('.env.staging', ' plaintext ')).toBe('dotenv')
|
||||
})
|
||||
|
||||
it('highlights dotenv file and diff contents with the bundled ini grammar', () => {
|
||||
|
||||
@@ -52,7 +52,9 @@ const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
yml: 'yaml',
|
||||
'cuda-cpp': 'cpp',
|
||||
'glimmer-js': 'javascript',
|
||||
'glimmer-ts': 'typescript'
|
||||
'glimmer-ts': 'typescript',
|
||||
// lowlight has no dotenv grammar; ini covers KEY=value and # comments.
|
||||
dotenv: 'ini'
|
||||
}
|
||||
|
||||
export function resolveMobileSyntaxLanguage(filePath: string, preferredLanguage?: string): string {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { tokenizeMonarchDocument, tokenTypeAt } from './monarch-tokenizer-test-harness'
|
||||
import {
|
||||
DOTENV_LANGUAGE_ID,
|
||||
dotenvLanguageConfiguration,
|
||||
dotenvMonarchLanguage,
|
||||
registerDotenvLanguage
|
||||
} from './register-dotenv'
|
||||
|
||||
function tokenizeDotenv(source: string) {
|
||||
return tokenizeMonarchDocument(DOTENV_LANGUAGE_ID, dotenvMonarchLanguage, source)
|
||||
}
|
||||
|
||||
function createMonacoMock(existingLanguageIds: string[] = []) {
|
||||
return {
|
||||
languages: {
|
||||
getLanguages: vi.fn(() => existingLanguageIds.map((id) => ({ id }))),
|
||||
register: vi.fn(),
|
||||
setLanguageConfiguration: vi.fn(),
|
||||
setMonarchTokensProvider: vi.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('registerDotenvLanguage', () => {
|
||||
it('registers the dotenv language with a Monarch tokenizer', () => {
|
||||
const monaco = createMonacoMock()
|
||||
|
||||
registerDotenvLanguage(monaco)
|
||||
|
||||
expect(monaco.languages.register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: DOTENV_LANGUAGE_ID, filenames: ['.env'] })
|
||||
)
|
||||
expect(monaco.languages.setLanguageConfiguration).toHaveBeenCalledWith(
|
||||
DOTENV_LANGUAGE_ID,
|
||||
dotenvLanguageConfiguration
|
||||
)
|
||||
expect(monaco.languages.setMonarchTokensProvider).toHaveBeenCalledWith(
|
||||
DOTENV_LANGUAGE_ID,
|
||||
dotenvMonarchLanguage
|
||||
)
|
||||
})
|
||||
|
||||
it('is idempotent when the language already exists', () => {
|
||||
const monaco = createMonacoMock([DOTENV_LANGUAGE_ID])
|
||||
|
||||
registerDotenvLanguage(monaco)
|
||||
|
||||
expect(monaco.languages.register).not.toHaveBeenCalled()
|
||||
expect(monaco.languages.setMonarchTokensProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dotenv tokenization', () => {
|
||||
it('colours comments, keys, the equals sign and values', () => {
|
||||
const [comment, assignment] = tokenizeDotenv('# Database\nDB_HOST=localhost')
|
||||
|
||||
expect(tokenTypeAt(comment, 0)).toBe('comment')
|
||||
expect(tokenTypeAt(assignment, 0)).toBe('type.identifier')
|
||||
expect(tokenTypeAt(assignment, 7)).toBe('delimiter')
|
||||
expect(tokenTypeAt(assignment, 8)).toBe('string')
|
||||
})
|
||||
|
||||
it('colours the export prefix', () => {
|
||||
const [line] = tokenizeDotenv('export API_KEY=abc')
|
||||
|
||||
expect(tokenTypeAt(line, 0)).toBe('keyword')
|
||||
expect(tokenTypeAt(line, 7)).toBe('type.identifier')
|
||||
expect(tokenTypeAt(line, 15)).toBe('string')
|
||||
})
|
||||
|
||||
it('colours quoted values, escapes and interpolation', () => {
|
||||
const [double, single, bare] = tokenizeDotenv(
|
||||
'URL="https://${HOST}:$PORT\\n"\nRAW=\'${HOST}\'\nPATH=$HOME/bin'
|
||||
)
|
||||
|
||||
expect(tokenTypeAt(double, 4)).toBe('string')
|
||||
expect(tokenTypeAt(double, 13)).toBe('variable')
|
||||
expect(tokenTypeAt(double, 21)).toBe('variable')
|
||||
expect(tokenTypeAt(double, 26)).toBe('string.escape')
|
||||
// Single quotes are literal.
|
||||
expect(tokenTypeAt(single, 5)).toBe('string')
|
||||
expect(tokenTypeAt(bare, 5)).toBe('variable')
|
||||
expect(tokenTypeAt(bare, 10)).toBe('string')
|
||||
})
|
||||
|
||||
it('colours whole-value numbers and booleans, but not inside text', () => {
|
||||
const [port, debug, version, label] = tokenizeDotenv(
|
||||
'PORT=3000\nDEBUG=true # on\nNAME=v1 true\nLABEL=red true'
|
||||
)
|
||||
|
||||
expect(tokenTypeAt(port, 5)).toBe('number')
|
||||
expect(tokenTypeAt(debug, 6)).toBe('keyword')
|
||||
expect(tokenTypeAt(debug, 11)).toBe('comment')
|
||||
expect(tokenTypeAt(version, 5)).toBe('string')
|
||||
expect(tokenTypeAt(label, 10)).toBe('string')
|
||||
})
|
||||
|
||||
it('keeps a quote after the first value character literal', () => {
|
||||
const [password, next] = tokenizeDotenv('PASSWORD=abc"def\nNEXT=1')
|
||||
|
||||
expect(tokenTypeAt(password, 12)).toBe('string')
|
||||
expect(tokenTypeAt(next, 0)).toBe('type.identifier')
|
||||
})
|
||||
|
||||
it('keeps a # without leading space inside the value', () => {
|
||||
const [line] = tokenizeDotenv('COLOR=#fff')
|
||||
|
||||
expect(tokenTypeAt(line, 6)).toBe('string')
|
||||
})
|
||||
|
||||
it('ends an unquoted value at the end of its line', () => {
|
||||
const [, next] = tokenizeDotenv('A=1 2 3\nB=2')
|
||||
|
||||
expect(tokenTypeAt(next, 0)).toBe('type.identifier')
|
||||
})
|
||||
|
||||
it('lets a double-quoted value span lines, then resumes keys', () => {
|
||||
const [, middle, , after] = tokenizeDotenv('KEY="-----BEGIN\nMIIB==\n-----END"\nNEXT=1')
|
||||
|
||||
expect(tokenTypeAt(middle, 0)).toBe('string')
|
||||
expect(tokenTypeAt(after, 0)).toBe('type.identifier')
|
||||
})
|
||||
|
||||
it('treats a blank line after a value as a fresh line', () => {
|
||||
const [, , after] = tokenizeDotenv('A=1\n\n# note')
|
||||
|
||||
expect(tokenTypeAt(after, 0)).toBe('comment')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import type * as Monaco from 'monaco-editor'
|
||||
|
||||
type DotenvMonaco = {
|
||||
languages: Pick<
|
||||
typeof Monaco.languages,
|
||||
'getLanguages' | 'register' | 'setLanguageConfiguration' | 'setMonarchTokensProvider'
|
||||
>
|
||||
}
|
||||
|
||||
export const DOTENV_LANGUAGE_ID = 'dotenv'
|
||||
|
||||
const INTERPOLATION = /\$\{[^}]*\}|\$[A-Za-z_]\w*/
|
||||
|
||||
export const dotenvLanguageConfiguration: Monaco.languages.LanguageConfiguration = {
|
||||
comments: { lineComment: '#' },
|
||||
// Pair colouring would otherwise recolour the braces of ${VAR}.
|
||||
brackets: [],
|
||||
colorizedBracketPairs: [],
|
||||
autoClosingPairs: [
|
||||
{ open: '"', close: '"', notIn: ['string'] },
|
||||
{ open: "'", close: "'", notIn: ['string'] }
|
||||
],
|
||||
surroundingPairs: [
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" }
|
||||
]
|
||||
}
|
||||
|
||||
export const dotenvMonarchLanguage: Monaco.languages.IMonarchLanguage = {
|
||||
defaultToken: '',
|
||||
tokenPostfix: '.dotenv',
|
||||
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/^\s*#.*$/, 'comment'],
|
||||
[/^(\s*)(export)(\s+)/, ['white', 'keyword', 'white']],
|
||||
[/[A-Za-z_][\w.-]*(?=\s*=)/, 'type.identifier'],
|
||||
[/=/, 'delimiter', '@value'],
|
||||
[/\s+/, 'white']
|
||||
],
|
||||
|
||||
// Quotes open a string only as the first character; later quotes are literal.
|
||||
value: [
|
||||
[/^/, '', '@pop'],
|
||||
[/\s+#.*$/, 'comment'],
|
||||
[/"/, { token: 'string', switchTo: '@doubleQuoted' }],
|
||||
[/'/, { token: 'string', switchTo: '@singleQuoted' }],
|
||||
[/(?:true|false)(?=\s+#|\s*$)/, { token: 'keyword', switchTo: '@unquoted' }],
|
||||
[/-?\d+(?:\.\d+)?(?=\s+#|\s*$)/, { token: 'number', switchTo: '@unquoted' }],
|
||||
[/(?=\S)/, { token: '', switchTo: '@unquoted' }],
|
||||
[/\s+/, 'string']
|
||||
],
|
||||
|
||||
// Unquoted values end at the line; only quoted values may span lines.
|
||||
unquoted: [
|
||||
[/^/, '', '@pop'],
|
||||
[/\s+#.*$/, 'comment'],
|
||||
[INTERPOLATION, 'variable'],
|
||||
[/[^\s#$]+|[#$]/, 'string'],
|
||||
[/\s+/, 'string']
|
||||
],
|
||||
|
||||
doubleQuoted: [
|
||||
[/[^"\\$]+/, 'string'],
|
||||
[/\\./, 'string.escape'],
|
||||
[INTERPOLATION, 'variable'],
|
||||
[/[\\$]/, 'string'],
|
||||
[/"/, { token: 'string', switchTo: '@unquoted' }]
|
||||
],
|
||||
|
||||
// Single quotes are literal: no escapes or interpolation.
|
||||
singleQuoted: [
|
||||
[/[^']+/, 'string'],
|
||||
[/'/, { token: 'string', switchTo: '@unquoted' }]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function registerDotenvLanguage(monaco: DotenvMonaco): void {
|
||||
const languageAlreadyRegistered = monaco.languages
|
||||
.getLanguages()
|
||||
.some((language) => language.id === DOTENV_LANGUAGE_ID)
|
||||
if (languageAlreadyRegistered) {
|
||||
return
|
||||
}
|
||||
|
||||
monaco.languages.register({
|
||||
id: DOTENV_LANGUAGE_ID,
|
||||
extensions: ['.env'],
|
||||
filenames: ['.env'],
|
||||
aliases: ['Dotenv', 'dotenv', 'env']
|
||||
})
|
||||
monaco.languages.setLanguageConfiguration(DOTENV_LANGUAGE_ID, dotenvLanguageConfiguration)
|
||||
monaco.languages.setMonarchTokensProvider(DOTENV_LANGUAGE_ID, dotenvMonarchLanguage)
|
||||
}
|
||||
@@ -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 { registerCudaLanguage } from './monaco-languages/register-cuda'
|
||||
import { registerDotenvLanguage } from './monaco-languages/register-dotenv'
|
||||
import { registerGlimmerLanguages } from './monaco-languages/register-glimmer'
|
||||
import { registerGroovyLanguage } from './monaco-languages/register-groovy'
|
||||
import { registerJsonlLanguage } from './monaco-languages/register-jsonl'
|
||||
@@ -82,6 +83,7 @@ runMonacoSetupSteps([
|
||||
['Zig language registration', () => registerZigLanguage(monaco)],
|
||||
['Groovy language registration', () => registerGroovyLanguage(monaco)],
|
||||
['JSONL language registration', () => registerJsonlLanguage(monaco)],
|
||||
['dotenv language registration', () => registerDotenvLanguage(monaco)],
|
||||
['Quarto language registration', () => registerQuartoLanguage(monaco)],
|
||||
['Markdown language registration', () => registerMarkdownLanguage(monaco)],
|
||||
['shell Markdown alias registration', () => registerShellMarkdownAliases(monaco)],
|
||||
|
||||
@@ -221,28 +221,31 @@ describe('detectLanguage', () => {
|
||||
expect(detectLanguage('notes/scratch.unknownext')).toBe('plaintext')
|
||||
})
|
||||
it.each([
|
||||
['.env', 'ini'],
|
||||
['.env.local', 'ini'],
|
||||
['.env.development', 'ini'],
|
||||
['.env.production', 'ini'],
|
||||
['.env.functions.local', 'ini'],
|
||||
['.env.staging', 'ini'],
|
||||
['.env.test.example', 'ini'],
|
||||
['config/.env.development.local', 'ini'],
|
||||
['.ENV', 'ini'],
|
||||
['.ENV.STAGING', 'ini'],
|
||||
['C:\\repo\\.EnV.FUNCTIONS.LOCAL', 'ini'],
|
||||
['\\\\server\\share\\.env.test.example', 'ini'],
|
||||
['.env', 'dotenv'],
|
||||
['.env.local', 'dotenv'],
|
||||
['.env.development', 'dotenv'],
|
||||
['.env.production', 'dotenv'],
|
||||
['.env.functions.local', 'dotenv'],
|
||||
['.env.staging', 'dotenv'],
|
||||
['.env.test.example', 'dotenv'],
|
||||
['config/.env.development.local', 'dotenv'],
|
||||
['.ENV', 'dotenv'],
|
||||
['.ENV.STAGING', 'dotenv'],
|
||||
['C:\\repo\\.EnV.FUNCTIONS.LOCAL', 'dotenv'],
|
||||
['\\\\server\\share\\.env.test.example', 'dotenv'],
|
||||
['.env.sh', 'shell'],
|
||||
['.ENV.SH', 'shell'],
|
||||
['.env.json', 'json'],
|
||||
['.env.local.ts', 'typescript'],
|
||||
['.env/CMakeLists.txt', 'cmake'],
|
||||
['C:\\repo\\.env.local\\Dockerfile', 'dockerfile'],
|
||||
['.envrc', 'plaintext'],
|
||||
['.envrc', 'shell'],
|
||||
['.ENVRC', 'shell'],
|
||||
['.environment', 'plaintext'],
|
||||
['env.staging', 'plaintext'],
|
||||
['dev.env', 'plaintext'],
|
||||
['dev.env', 'dotenv'],
|
||||
['config/prod.ENV', 'dotenv'],
|
||||
['C:\\repo\\staging.env', 'dotenv'],
|
||||
['other.env.local', 'plaintext'],
|
||||
['..env.local', 'plaintext'],
|
||||
['.env.staging/readme', 'plaintext'],
|
||||
|
||||
@@ -24,6 +24,7 @@ const EXT_TO_LANGUAGE: Record<string, string> = {
|
||||
'.jsonc': 'json',
|
||||
// Dedicated jsonl coloring avoids whole-document JSON diagnostics flagging every record after the first.
|
||||
'.jsonl': 'jsonl',
|
||||
'.env': 'dotenv',
|
||||
'.ipynb': 'notebook',
|
||||
'.md': 'markdown',
|
||||
'.mdx': 'markdown',
|
||||
@@ -136,10 +137,12 @@ const FILENAME_TO_LANGUAGE: Record<string, string> = {
|
||||
'.gitignore': 'ini',
|
||||
'.gitattributes': 'ini',
|
||||
'.editorconfig': 'ini',
|
||||
'.env': 'ini',
|
||||
'.env.local': 'ini',
|
||||
'.env.development': 'ini',
|
||||
'.env.production': 'ini',
|
||||
'.env': 'dotenv',
|
||||
'.env.local': 'dotenv',
|
||||
'.env.development': 'dotenv',
|
||||
'.env.production': 'dotenv',
|
||||
// .envrc is sourced as a shell script.
|
||||
'.envrc': 'shell',
|
||||
'.bashrc': 'shell',
|
||||
'.bash_profile': 'shell',
|
||||
'.bash_login': 'shell',
|
||||
@@ -207,13 +210,13 @@ export function detectLanguage(filePath: string, content?: string): string {
|
||||
|
||||
const ext = extname(filename).toLowerCase()
|
||||
const lowerName = filename.toLowerCase()
|
||||
// Scoped dotenv names fall back to INI only when no specific extension matches.
|
||||
// Scoped dotenv names fall back to dotenv only when no specific extension matches.
|
||||
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')
|
||||
(lowerName === '.env' || lowerName.startsWith('.env.') ? 'dotenv' : 'plaintext')
|
||||
// Why: only extensionless names fall back to the shebang; unmapped extensions like .txt stay plaintext.
|
||||
if (language === 'plaintext' && ext === '' && content !== undefined) {
|
||||
return detectShebangLanguage(content) ?? 'plaintext'
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('Monaco filename detection', () => {
|
||||
['file.typ', 'typst'],
|
||||
['file.nim', 'nim'],
|
||||
['file.h', 'c'],
|
||||
['.env.staging', 'ini'],
|
||||
['.env.staging', 'dotenv'],
|
||||
['file.unknown', 'plaintext'],
|
||||
['folder.sol/README', 'plaintext'],
|
||||
['folder.sol\\README', 'plaintext'],
|
||||
|
||||
Reference in New Issue
Block a user