196 lines
5.4 KiB
TypeScript
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;
|
|
}
|