diff --git a/mobile/src/session/mobile-file-syntax.test.ts b/mobile/src/session/mobile-file-syntax.test.ts index 1928c9c6578..414963e41b6 100644 --- a/mobile/src/session/mobile-file-syntax.test.ts +++ b/mobile/src/session/mobile-file-syntax.test.ts @@ -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', () => { diff --git a/mobile/src/session/mobile-file-syntax.ts b/mobile/src/session/mobile-file-syntax.ts index 4156291b2f4..27cead001c9 100644 --- a/mobile/src/session/mobile-file-syntax.ts +++ b/mobile/src/session/mobile-file-syntax.ts @@ -52,7 +52,9 @@ const LANGUAGE_ALIASES: Record = { 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 { diff --git a/src/renderer/src/lib/monaco-languages/register-dotenv.test.ts b/src/renderer/src/lib/monaco-languages/register-dotenv.test.ts new file mode 100644 index 00000000000..246ee86e475 --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/register-dotenv.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/lib/monaco-languages/register-dotenv.ts b/src/renderer/src/lib/monaco-languages/register-dotenv.ts new file mode 100644 index 00000000000..3ceffe80995 --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/register-dotenv.ts @@ -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) +} diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts index afa2497cb2b..b1bcbf286c5 100644 --- a/src/renderer/src/lib/monaco-setup.ts +++ b/src/renderer/src/lib/monaco-setup.ts @@ -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)], diff --git a/src/shared/editor-language/editor-language-detect.test.ts b/src/shared/editor-language/editor-language-detect.test.ts index 6267ba77d31..806eb10207c 100644 --- a/src/shared/editor-language/editor-language-detect.test.ts +++ b/src/shared/editor-language/editor-language-detect.test.ts @@ -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'], diff --git a/src/shared/editor-language/editor-language-detect.ts b/src/shared/editor-language/editor-language-detect.ts index b6fcb720db7..020809c57f0 100644 --- a/src/shared/editor-language/editor-language-detect.ts +++ b/src/shared/editor-language/editor-language-detect.ts @@ -24,6 +24,7 @@ const EXT_TO_LANGUAGE: Record = { '.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 = { '.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' diff --git a/src/shared/editor-language/monaco-filename-language.test.ts b/src/shared/editor-language/monaco-filename-language.test.ts index 1f8c4e708b5..3e8a76abf94 100644 --- a/src/shared/editor-language/monaco-filename-language.test.ts +++ b/src/shared/editor-language/monaco-filename-language.test.ts @@ -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'],