Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/modules/editor/lib/dotenv.test.ts
Original file line number Diff line number Diff line change
@@ -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"],
]);
});
});
146 changes: 146 additions & 0 deletions src/modules/editor/lib/dotenv.ts
Original file line number Diff line number Diff line change
@@ -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<DotenvState> = {
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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: "#" },
},
};
13 changes: 11 additions & 2 deletions src/modules/editor/lib/languageResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ type LanguageLoader = () => Promise<LoaderResult>;
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());

Expand Down Expand Up @@ -53,6 +56,7 @@ const loaders: Record<string, LanguageLoader> = {
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,
Expand Down Expand Up @@ -134,6 +138,11 @@ const filenameOverrides: Record<string, LanguageLoader> = {
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(".");
Expand All @@ -154,7 +163,7 @@ const cache = new Map<string, Extension | null>();
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;
}
Expand All @@ -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;
Expand Down