Files
envsitter/src/dotenv/document.ts
2026-01-13 18:20:37 +01:00

196 lines
5.4 KiB
TypeScript

export type DotenvIssue = {
line: number;
column: number;
message: string;
};
export type DotenvParsedAssignment = {
line: number;
raw: string;
leadingWhitespace: string;
exported: boolean;
key: string;
keyColumn: number;
beforeEqWhitespace: string;
afterEqRaw: string;
quote: 'none' | 'single' | 'double';
value: string;
};
export type DotenvLine =
| { kind: 'blank'; line: number; raw: string }
| { kind: 'comment'; line: number; raw: string }
| ({ kind: 'assignment' } & DotenvParsedAssignment)
| { kind: 'unknown'; line: number; raw: string };
export type DotenvDocument = {
lines: DotenvLine[];
issues: DotenvIssue[];
endsWithNewline: boolean;
};
function isWhitespace(char: string): boolean {
return /\s/.test(char);
}
function isValidKeyChar(char: string): boolean {
return /[A-Za-z0-9_]/.test(char);
}
function stripInlineComment(unquotedValue: string): string {
for (let i = 0; i < unquotedValue.length; i++) {
const c = unquotedValue[i];
if (c === '#') {
const prev = i > 0 ? (unquotedValue[i - 1] ?? '') : '';
if (prev === '' || /\s/.test(prev)) return unquotedValue.slice(0, i);
}
}
return unquotedValue;
}
function unescapeDoubleQuoted(value: string): string {
let out = '';
for (let i = 0; i < value.length; i++) {
const c = value[i];
if (c !== '\\') {
out += c;
continue;
}
const next = value[i + 1];
if (next === undefined) {
out += '\\';
continue;
}
i++;
if (next === 'n') out += '\n';
else if (next === 'r') out += '\r';
else if (next === 't') out += '\t';
else out += next;
}
return out;
}
function parseValueDetailed(afterEqRaw: string, line: number, issues: DotenvIssue[]): { value: string; quote: 'none' | 'single' | 'double' } {
const trimmed = afterEqRaw.trimStart();
if (!trimmed) return { value: '', quote: 'none' };
const first = trimmed[0];
if (first === "'") {
const end = trimmed.indexOf("'", 1);
if (end === -1) {
issues.push({ line, column: 1, message: 'Unterminated single-quoted value' });
return { value: trimmed.slice(1), quote: 'single' };
}
return { value: trimmed.slice(1, end), quote: 'single' };
}
if (first === '"') {
let end = 1;
for (; end < trimmed.length; end++) {
const c = trimmed[end];
if (c === '"' && trimmed[end - 1] !== '\\') break;
}
if (end >= trimmed.length || trimmed[end] !== '"') {
issues.push({ line, column: 1, message: 'Unterminated double-quoted value' });
return { value: unescapeDoubleQuoted(trimmed.slice(1)), quote: 'double' };
}
return { value: unescapeDoubleQuoted(trimmed.slice(1, end)), quote: 'double' };
}
return { value: stripInlineComment(trimmed).trimEnd(), quote: 'none' };
}
function parseAssignmentLine(raw: string, line: number, issues: DotenvIssue[]): DotenvParsedAssignment | undefined {
let cursor = 0;
while (cursor < raw.length && isWhitespace(raw[cursor] ?? '')) cursor++;
const leadingWhitespace = raw.slice(0, cursor);
let exported = false;
const exportStart = cursor;
if (raw.slice(cursor).startsWith('export')) {
const afterExport = raw[cursor + 'export'.length] ?? '';
if (isWhitespace(afterExport)) {
exported = true;
cursor += 'export'.length;
while (cursor < raw.length && isWhitespace(raw[cursor] ?? '')) cursor++;
} else {
cursor = exportStart;
}
}
const keyStart = cursor;
while (cursor < raw.length && isValidKeyChar(raw[cursor] ?? '')) cursor++;
const key = raw.slice(keyStart, cursor);
if (!key) {
issues.push({ line, column: keyStart + 1, message: 'Invalid key name' });
return undefined;
}
const next = raw[cursor] ?? '';
if (next && !isWhitespace(next) && next !== '=') {
issues.push({ line, column: cursor + 1, message: 'Invalid key name' });
return undefined;
}
const wsStart = cursor;
while (cursor < raw.length && isWhitespace(raw[cursor] ?? '')) cursor++;
const beforeEqWhitespace = raw.slice(wsStart, cursor);
if ((raw[cursor] ?? '') !== '=') {
issues.push({ line, column: cursor + 1, message: 'Missing = in assignment' });
return undefined;
}
const afterEqRaw = raw.slice(cursor + 1);
const { value, quote } = parseValueDetailed(afterEqRaw, line, issues);
return {
line,
raw,
leadingWhitespace,
exported,
key,
keyColumn: keyStart + 1,
beforeEqWhitespace,
afterEqRaw,
value,
quote
};
}
export function parseDotenvDocument(contents: string): DotenvDocument {
const issues: DotenvIssue[] = [];
const endsWithNewline = contents.endsWith('\n');
const split = contents.split(/\r?\n/);
if (endsWithNewline) split.pop();
const lines: DotenvLine[] = [];
for (let i = 0; i < split.length; i++) {
const lineNumber = i + 1;
const raw = split[i] ?? '';
const trimmed = raw.trim();
if (!trimmed) {
lines.push({ kind: 'blank', line: lineNumber, raw });
continue;
}
if (raw.trimStart().startsWith('#')) {
lines.push({ kind: 'comment', line: lineNumber, raw });
continue;
}
const parsed = parseAssignmentLine(raw, lineNumber, issues);
if (parsed) lines.push({ kind: 'assignment', ...parsed });
else lines.push({ kind: 'unknown', line: lineNumber, raw });
}
return { lines, issues, endsWithNewline };
}
export function stringifyDotenvDocument(doc: DotenvDocument): string {
const out = doc.lines.map((l) => l.raw).join('\n');
return doc.endsWithNewline ? `${out}\n` : out;
}