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:
Neil
2026-10-28 13:40:03 -07:00
committed by GitHub
parent 6c54b4fac5
commit 7b3fb541f4
8 changed files with 272 additions and 37 deletions
+15 -15
View File
@@ -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', () => {
+3 -1
View File
@@ -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)
}
+2
View File
@@ -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'],