diff --git a/src/modules/editor/lib/dotenv.test.ts b/src/modules/editor/lib/dotenv.test.ts new file mode 100644 index 000000000..66e2a6823 --- /dev/null +++ b/src/modules/editor/lib/dotenv.test.ts @@ -0,0 +1,76 @@ +import { StringStream } from "@codemirror/language"; +import { describe, expect, it } from "vitest"; +import { dotenv } from "./dotenv"; + +function tokenizeLine(line: string): Array<[string, string]> { + const state = dotenv.startState?.(2); + if (!state) throw new Error("dotenv parser has no start state"); + + const stream = new StringStream(line, 2, 2); + const tokens: Array<[string, string]> = []; + + while (!stream.eol()) { + stream.start = stream.pos; + const style = dotenv.token(stream, state); + if (stream.pos === stream.start) { + throw new Error(`Parser did not advance at ${stream.pos}`); + } + if (style) tokens.push([stream.current(), style]); + } + + return tokens; +} + +describe("dotenv tokenizer", () => { + it("highlights exports, keys, values, and comments", () => { + expect(tokenizeLine("export API_URL=https://example.com # public")).toEqual( + [ + ["export", "keyword"], + ["API_URL", "variableName.definition"], + ["=", "operator"], + ["https://example.com", "string"], + ["# public", "comment"], + ], + ); + }); + + it("highlights variables inside double quoted values", () => { + const variableRef = "$" + "{DB_HOST}"; + + expect( + tokenizeLine(`DATABASE_URL="postgres://${variableRef}:5432/app"`), + ).toEqual([ + ["DATABASE_URL", "variableName.definition"], + ["=", "operator"], + ['"', "string"], + ["postgres://", "string"], + [variableRef, "variableName.special"], + [":5432/app", "string"], + ['"', "string"], + ]); + }); + + it("keeps hashes inside unquoted values", () => { + expect(tokenizeLine("PASSWORD=abc#123")).toEqual([ + ["PASSWORD", "variableName.definition"], + ["=", "operator"], + ["abc#123", "string"], + ]); + }); + + it("keeps hashes at the start of unquoted values", () => { + expect(tokenizeLine("KEY=#hash")).toEqual([ + ["KEY", "variableName.definition"], + ["=", "operator"], + ["#hash", "string"], + ]); + }); + + it("highlights comments after empty values", () => { + expect(tokenizeLine("KEY= #comment")).toEqual([ + ["KEY", "variableName.definition"], + ["=", "operator"], + ["#comment", "comment"], + ]); + }); +}); diff --git a/src/modules/editor/lib/dotenv.ts b/src/modules/editor/lib/dotenv.ts new file mode 100644 index 000000000..0adb3d1c1 --- /dev/null +++ b/src/modules/editor/lib/dotenv.ts @@ -0,0 +1,146 @@ +import type { StreamParser, StringStream } from "@codemirror/language"; + +type DotenvState = { + mode: "start" | "afterExport" | "afterKey" | "value"; + quote: "'" | '"' | null; + valueStarted: boolean; + afterValueSpace: boolean; +}; + +function resetLine(state: DotenvState): void { + state.mode = "start"; + state.quote = null; + state.valueStarted = false; + state.afterValueSpace = false; +} + +function tokenVariable(stream: StringStream): string | null { + if (stream.next() !== "$") return null; + + if (stream.eat("{")) { + stream.eatWhile(/[A-Za-z0-9_]/); + stream.eat("}"); + return "variableName.special"; + } + + if (!stream.eat(/[A-Za-z_]/)) return "operator"; + stream.eatWhile(/[A-Za-z0-9_]/); + return "variableName.special"; +} + +function tokenQuotedValue( + stream: StringStream, + state: DotenvState, +): string | null { + if (stream.peek() === state.quote) { + stream.next(); + state.quote = null; + state.mode = "value"; + return "string"; + } + + if (state.quote === '"' && stream.peek() === "$") { + return tokenVariable(stream); + } + + while (!stream.eol()) { + const ch = stream.peek(); + if (ch === state.quote || (state.quote === '"' && ch === "$")) break; + stream.next(); + } + + return "string"; +} + +export const dotenv: StreamParser = { + name: "dotenv", + + startState() { + return { + mode: "start", + quote: null, + valueStarted: false, + afterValueSpace: false, + }; + }, + + token(stream, state) { + if (stream.sol()) resetLine(state); + if (stream.eol()) return null; + + if (state.quote) return tokenQuotedValue(stream, state); + + if (stream.eatSpace()) { + if (state.mode === "value") { + state.afterValueSpace = true; + } + return null; + } + + if (state.mode !== "value" && stream.peek() === "#") { + stream.skipToEnd(); + return "comment"; + } + + if ( + state.mode === "start" && + stream.match(/^export(?=\s+[A-Za-z_])/, true) + ) { + state.mode = "afterExport"; + return "keyword"; + } + + if (state.mode === "start" || state.mode === "afterExport") { + if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/, true)) { + state.mode = "afterKey"; + return "variableName.definition"; + } + + stream.skipToEnd(); + return "invalid"; + } + + if (state.mode === "afterKey") { + if (stream.eat("=")) { + state.mode = "value"; + return "operator"; + } + + stream.skipToEnd(); + return "invalid"; + } + + if (stream.peek() === "'" || stream.peek() === '"') { + state.quote = stream.next() as "'" | '"'; + state.valueStarted = true; + state.afterValueSpace = false; + return "string"; + } + + if (stream.peek() === "$") { + state.valueStarted = true; + state.afterValueSpace = false; + return tokenVariable(stream); + } + + if (state.afterValueSpace && stream.peek() === "#") { + stream.skipToEnd(); + return "comment"; + } + + while (!stream.eol()) { + const ch = stream.peek(); + if (ch === "$" || /\s/.test(ch ?? "")) break; + stream.next(); + } + + state.valueStarted = true; + state.afterValueSpace = false; + return "string"; + }, + + languageData: { + closeBrackets: { brackets: ["'", '"'] }, + commentTokens: { line: "#" }, + }, +}; diff --git a/src/modules/editor/lib/languageResolver.ts b/src/modules/editor/lib/languageResolver.ts index 2d4a67920..188755c19 100644 --- a/src/modules/editor/lib/languageResolver.ts +++ b/src/modules/editor/lib/languageResolver.ts @@ -6,6 +6,9 @@ type LanguageLoader = () => Promise; const rubyLoader: LanguageLoader = () => import("@codemirror/legacy-modes/mode/ruby").then((m) => m.ruby); +const dotenvLoader: LanguageLoader = () => + import("./dotenv").then((m) => m.dotenv); + const jsonLoader: LanguageLoader = () => import("@codemirror/lang-json").then((m) => m.json()); @@ -53,6 +56,7 @@ const loaders: Record = { rs: () => import("@codemirror/lang-rust").then((m) => m.rust()), go: () => import("@codemirror/lang-go").then((m) => m.go()), py: () => import("@codemirror/lang-python").then((m) => m.python()), + env: dotenvLoader, json: jsonLoader, jsonc: jsonLoader, json5: jsonLoader, @@ -134,6 +138,11 @@ const filenameOverrides: Record = { brewfile: rubyLoader, }; +function filenameLoader(base: string): LanguageLoader | undefined { + if (base === ".env" || base.startsWith(".env.")) return dotenvLoader; + return filenameOverrides[base]; +} + function extOf(name: string): string | null { const lower = name.toLowerCase(); const dot = lower.lastIndexOf("."); @@ -154,7 +163,7 @@ const cache = new Map(); function cacheKey(filename: string): string | null { const lower = filename.toLowerCase(); const base = lower.split("/").pop() ?? lower; - if (filenameOverrides[base]) return `name:${base}`; + if (filenameLoader(base)) return `name:${base}`; const ext = extOf(base); return ext ? `ext:${ext}` : null; } @@ -174,7 +183,7 @@ export async function resolveLanguage( const lower = filename.toLowerCase(); const base = lower.split("/").pop() ?? lower; - const loader = filenameOverrides[base] ?? loaders[extOf(base) ?? ""]; + const loader = filenameLoader(base) ?? loaders[extOf(base) ?? ""]; if (!loader) { cache.set(key, null); return null;