Add dotenv file operations
This commit is contained in:
195
src/dotenv/document.ts
Normal file
195
src/dotenv/document.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user