Skip to content

Instantly share code, notes, and snippets.

@crabmusket
Last active Nov 1, 2019
Embed
What would you like to do?

deno_env_file.ts

Use this snippet to parse, modify and render environment files.

import { EnvFile } from "https://gist.githubusercontent.com/crabmusket/e10effe4800691bb15543ff264518e76/raw/deno_env_file.ts";

let content = `
KEY=value
SOMETHING_ELSE=cool
`;

let file = new EnvFile(content);
console.log('KEY = ' + file.get('KEY'));
file.set('SOMETHING_ELSE', 'not cool');
file.set('NEW_VAR', 'new value');
console.log(file.serialise());

Will print:

KEY = value

KEY=value
SOMETHING_ELSE=not cool
NEW_VAR=new value
/**
* EnvFile represents an environment file of the type you often see with 'dotenv'
* type libraries and 12-factor apps. You can use this class to read and manipulate
* env files.
*/
export class EnvFile {
/**
* Create a new EnvFile. If you pass `content`, it will be `load`ed.
*/
constructor(content?: string) {
this._lines = [];
this._variables = {};
if (content) {
this.load(content);
}
}
protected _lines: Line[];
protected _variables: Record<string, Declaration[]>;
/**
* Parse and store the env file from `content` into this EnvFile. Overwrites
* all existing data.
*/
load(content: string) {
this._lines = _parse(content);
this._variables = _createLookup(
this._lines.map(l => l.content).filter(isDeclaration)
);
function isDeclaration(v: Comment | Declaration | null): v is Declaration {
return v && v.type === "declaration";
}
}
/**
* Serialise the data into an env file. The transformation is not completely
* idempotent; if your keys have spaces around them, these spaces will be removed.
* See `removeSpacesAroundVariables` test.
* If a line doesn't parse as a comment or a key-value pair, it will be
* reproduced verbatim.
*/
serialise(): string {
return this._lines.map(_serialiseLine).join("\n");
}
/**
* Get the value of a variable.
* @return the value, or null if the file does not contain the variable.
*/
get(variable: string): string | null {
let decls = this._variables[variable];
if (!decls || !decls.length) {
return null;
}
// TODO: should this behave differently?
return decls[0].value;
}
/**
* This signals that a variable was appended to the env file by `set`. See `set`
* below.
*/
static readonly Appended: "appended" = "appended";
/**
* This signals that a variable was edited in the env file by `set`, not appended.
* See `set` below.
*/
static readonly Edited: "edited" = "edited";
/**
* This either updates the existing value of the given `variable` to `value`,
* or if the variable isn't already in the file, appends it to the end.
*/
set(
variable: string,
value: string
): typeof EnvFile.Appended | typeof EnvFile.Edited {
if (this.edit(variable, value)) {
return EnvFile.Edited;
}
this.append(variable, value);
return EnvFile.Appended;
}
/**
* Update the value of an existing variable in the file. Usually you'll want
* to use `set`.
* @return true if the value was updated, false if that variable is not in the file
*/
edit(variable: string, value: string): boolean {
let decls = this._variables[variable];
if (!decls || !decls.length) {
return false;
}
for (let decl of decls) {
decl.value = value;
}
return true;
}
/**
* Append a key=value pair to the file. This can create duplicate values
* within the same file, so be a bit careful. Usually you should be using
* `set`.
*/
append(variable: string, value: string) {
let content: Declaration = {
type: "declaration",
variable,
value,
quote: null
};
this._lines.push({
number: this._lines[this._lines.length - 1].number + 1,
rawContent: variable + "=" + value,
content
});
if (variable in this._variables) {
this._variables[variable].push(content);
} else {
this._variables[variable] = [content];
}
}
}
/*********************************************/
/* INTERNALS */
/*********************************************/
type Line = {
number: number;
rawContent: string;
content: Declaration | Comment | null;
};
type Declaration = {
type: "declaration";
variable: string;
value: string;
quote: string | null;
};
type Comment = {
type: "comment";
value: string;
};
function _parse(content: string): Line[] {
let number = 0;
return content.split("\n").map((line, i) => {
return {
number: i,
rawContent: line,
content: _parseContent(line)
};
});
}
function _parseContent(rawLine: string): Comment | Declaration | null {
if (rawLine[0] === "#") {
return {
type: "comment",
value: rawLine.slice(1)
};
}
let parsedVariable = _parseVariable(rawLine);
if (!parsedVariable) {
return null;
}
let [variable, value, quote] = parsedVariable;
return {
type: "declaration",
variable,
value,
quote
};
}
function _parseVariable(
rawLine: string
): [string, string, string | null] | null {
let firstEquals = rawLine.indexOf("=");
if (firstEquals < 1) {
return null;
}
let variable = rawLine.substring(0, firstEquals).trim();
let value = rawLine.substring(firstEquals + 1);
let quote = null;
if (
value[0] === '"' ||
(value[0] === "'" && value[value.length - 1] === value[0])
) {
quote = value[0];
value = value.substring(1, value.length - 1);
}
return [variable, value, quote];
}
function _serialiseLine(l: Line): string {
if (!l.content) {
return l.rawContent;
}
if (l.content.type === "comment") {
return "#" + l.content.value;
}
if (l.content.quote) {
return (
l.content.variable +
"=" +
l.content.quote +
l.content.value +
l.content.quote
);
}
return l.content.variable + "=" + l.content.value;
}
function _createLookup(
variables: Declaration[]
): Record<string, Declaration[]> {
let all = {};
for (let variable of variables) {
if (!(variable.variable in all)) {
all[variable.variable] = [];
}
all[variable.variable].push(variable);
}
return all;
}
import { test, runIfMain } from "https://deno.land/std/testing/mod.ts";
import { assertEquals } from "https://deno.land/std/testing/asserts.ts";
import { EnvFile } from "./deno_env_file.ts";
test(function simpleRoundTrip() {
let content = `KEY1=value1
KEY2=value2
KEY3=value3`;
assertEquals(content, new EnvFile(content).serialise());
});
test(function quotingRoundTrip() {
let content = `KEY1='value1'\nKEY2=val'ue2\nKEY3="value3"`;
assertEquals(content, new EnvFile(content).serialise());
});
test(function commentRoundTrip() {
let content = `# this is a comment\nKEY1='value1'\nKEY2=value2\n#this is also a comment\nKEY3="value3"`;
assertEquals(content, new EnvFile(content).serialise());
});
test(function blankLinesRoundTrip() {
let content = `
KEY1='value1'
KEY2=value2
#this is also a comment
KEY3="value3"`;
assertEquals(content, new EnvFile(content).serialise());
});
test(function badRoundTrip() {
let content = `KEY1=value1
blah this isn't a line
.`;
assertEquals(content, new EnvFile(content).serialise());
});
test(function removeSpacesAroundVariables() {
let content = " KEY =value";
let file = new EnvFile(content);
let result = file.edit("KEY", "other");
assertEquals(true, result);
assertEquals("KEY=other", file.serialise());
});
test(function maintainSpacesInValues() {
let content = "KEY= val ue ";
let file = new EnvFile(content);
assertEquals(" val ue ", file.get("KEY"));
assertEquals("KEY= val ue ", file.serialise());
});
test(function simpleGet() {
let content = `KEY1=value1\nKEY2=value2\nKEY3=value3`;
let file = new EnvFile(content);
assertEquals("value1", file.get("KEY1"));
assertEquals("value3", file.get("KEY3"));
assertEquals(null, file.get("KEY4"));
assertEquals(null, file.get("KEY"));
assertEquals(null, file.get(""));
});
test(function getValueThatLooksLikeComment() {
let content = "KEY=value # this is not a comment";
assertEquals(
"value # this is not a comment",
new EnvFile(content).get("KEY")
);
});
test(function editValue() {
let content = "KEY=value";
let file = new EnvFile(content);
file.edit("KEY", "other");
assertEquals("KEY=other", file.serialise());
});
test(function editValueThatLooksLikeComment() {
let content = "KEY=value # this is not a comment";
let file = new EnvFile(content);
let result = file.edit("KEY", "other");
assertEquals(true, result);
assertEquals("KEY=other", file.serialise());
});
test(function editQuotedValue() {
let content = `KEY1="value1"\nKEY2='value2'`;
let file = new EnvFile(content);
file.edit("KEY1", "other1");
file.edit("KEY2", "other2");
assertEquals(`KEY1="other1"\nKEY2='other2'`, file.serialise());
});
test(function dontEditComments() {
let content = "#KEY=value";
let file = new EnvFile(content);
let result = file.edit("KEY", "other");
assertEquals(false, result);
assertEquals(content, file.serialise());
});
test(function appendSimple() {
let content = "KEY1=value1";
let file = new EnvFile(content);
file.append("KEY2", "value2");
assertEquals("KEY1=value1\nKEY2=value2", file.serialise());
});
test(function appendExisting() {
let content = "KEY1=value1";
let file = new EnvFile(content);
file.append("KEY1", "value2");
assertEquals("KEY1=value1\nKEY1=value2", file.serialise());
});
test(function setNew() {
let content = "KEY1=value1";
let file = new EnvFile(content);
let result = file.set("KEY2", "value2");
assertEquals(EnvFile.Appended, result);
assertEquals("KEY1=value1\nKEY2=value2", file.serialise());
});
test(function setExisting() {
let content = "KEY1=value1";
let file = new EnvFile(content);
let result = file.set("KEY1", "value2");
assertEquals(EnvFile.Edited, result);
assertEquals("KEY1=value2", file.serialise());
});
runIfMain(import.meta);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment