Skip to content

Instantly share code, notes, and snippets.

@Josh-Cena
Last active September 13, 2022 20:38
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Josh-Cena/cc67a9e9f386bf4f0b790e789f9d3944 to your computer and use it in GitHub Desktop.
Save Josh-Cena/cc67a9e9f386bf4f0b790e789f9d3944 to your computer and use it in GitHub Desktop.
declare global {
interface StringConstructor {
dedent<A extends unknown[], R>(tag: (template: TemplateStringsArray, ...substitutions: A) => R): (template: TemplateStringsArray, ...substitutions: A) => string;
dedent(template: TemplateStringsArray, ...substitutions: unknown[]): string;
}
}
// @ts-expect-error: deliberate narrow
String.dedent = (() => {
const GlobalDedentRegistry = new WeakMap<object, TemplateStringsArray>();
type LineRec = {
string: string;
newline: string;
lineEndsWithSubstitution: boolean;
};
return function (templateOrFn: unknown, ...substitutions: unknown[]) {
if (typeof templateOrFn === "function") {
const tag = templateOrFn;
return function (this: unknown, template: unknown, ...substitutions: unknown[]) {
const R = this;
const dedented = dedentTemplateStringsArray(template);
return Reflect.apply(tag, R, [dedented, ...substitutions]);
}
} else if (Array.isArray(templateOrFn)) {
const template = templateOrFn;
const dedented = dedentTemplateStringsArray(template);
return cookTemplateLiteralsArray(dedented, ...substitutions);
} else {
throw new TypeError("String.dedent's first argument must be a function or an array");
}
}
function dedentTemplateStringsArray(template: unknown): TemplateStringsArray {
const t = toObject(template);
if (GlobalDedentRegistry.has(t)) {
return GlobalDedentRegistry.get(t)!;
}
const raw = dedentStringsArray((t as TemplateStringsArray).raw);
// @ts-expect-error: maybe this is TS bug that TemplateStringsArray doesn't extend (string | undefined)[]...
const cookedArr = cookStrings(raw) as TemplateStringsArray;
const rawArr = raw;
Object.defineProperty(cookedArr, "raw", { value: rawArr });
Object.freeze(rawArr);
Object.freeze(cookedArr);
GlobalDedentRegistry.set(t, cookedArr);
return cookedArr;
}
function dedentStringsArray(template: unknown): string[] {
const t = toObject(template);
const len = lengthOfArrayLike(t);
if (len === 0)
// Well-formed template strings arrays always contain at least 1 string.
throw new TypeError("String.dedent called with empty array");
const blocks = splitTemplateIntoBlockLines(t, len);
emptyWhiteSpaceLines(blocks);
removeOpeningAndClosingLines(blocks, len);
const common = determineCommonLeadingIndentation(blocks);
const count = common.length;
const dedented: string[] = [];
for (const lines of blocks) {
// lines is not empty, because `splitTemplateIntoBlockLines` guarantees there is at least 1 line per block.
let out = "";
let index = 0;
for (const line of lines) {
// The first line of each block does not need to be trimmed. It's either the opening line, or it's the continuation of a line after a substitution.
const c = index === 0 ? 0 : count;
const strLen = line.string.length;
const trimmed = line.string.substring(c, strLen);
out = out + trimmed + line.newline;
index++;
}
dedented.push(out);
}
return dedented;
}
function cookStrings(raw: string[]): (string | undefined)[] {
const cooked: (string | undefined)[] = [];
for (const str of raw) {
let c: string | undefined;
try {
c = eval(`"${str}"`) as string;
} catch {}
cooked.push(c);
}
return cooked;
}
function splitTemplateIntoBlockLines(template: object, len: number): LineRec[][] {
const blocks: LineRec[][] = [];
for (let index = 0; index < len; index++) {
const str = (template as Record<number, unknown>)[index];
if (typeof str === "string") {
const lines: LineRec[] = [];
const strLen = str.length;
let start = 0;
let i = 0;
while (i < strLen) {
const c = str[i];
let n = 1;
if (c === "\r") {
const next = str[i + 1];
if (next === "\n") n = 2;
}
if ([0x000A, 0x000D, 0x2028, 0x2029].includes(c.charCodeAt(0))) {
const substr = str.substring(start, i);
const newline = str.substring(i, i + n);
lines.push({ string: substr, newline, lineEndsWithSubstitution: false });
start = i + n;
}
i += n;
}
const tail = str.substring(start, strLen);
const lineEndsWithSubstitution = index + 1 < len;
lines.push({ string: tail, newline: "", lineEndsWithSubstitution });
blocks.push(lines);
} else {
throw new TypeError("String.dedent called with non-string element");
}
}
return blocks;
}
function emptyWhiteSpaceLines(blocks: LineRec[][]): void {
for (const lines of blocks) {
const lineCount = lines.length;
// We start i at 1 because the first line of every block is either (a) the opening line which must be empty or (b) the continuation of a line directly after a template substitution. Neither can be the start of a content line.
for (let i = 1; i < lineCount; i++) {
const line = lines[i];
if (!line.lineEndsWithSubstitution && isAllWhiteSpace(line.string)) {
// Lines which contain only whitespace are emptied in the output. Their trailing newline is not removed, so the line is maintained.
line.string = "";
}
}
}
}
function removeOpeningAndClosingLines(blocks: LineRec[][], len: number) {
// blocks contains at least 1 element, because we know the length of the template strings array is at least 1.
const firstBlock = blocks[0];
// firstBlock is not empty, because SplitTemplateIntoBlockLines guarantees there is at least 1 line per block.
let lineCount = firstBlock.length;
if (lineCount === 1)
// The opening line is required to contain a trailing newline, and checking that there are at least 2 elements in lines ensures it. If it does not, either the opening line and the closing line are the same line, or the opening line contains a substitution.
throw new TypeError("Opening line of String.dedent must have a new line.");
const openingLine = firstBlock[0];
if (openingLine.string !== "")
// The opening line must not contain code units besides the trailing newline.
throw new TypeError("Opening line of String.dedent must only have a new line.");
// Setting openingLine.[[Newline]] removes the opening line from the output.
openingLine.newline = "";
const lastBlock = blocks[len - 1];
// lastBlock is not empty, because SplitTemplateIntoBlockLines guarantees there is at least 1 line per block.
lineCount = lastBlock.length;
if (lineCount === 1)
// The closing line is required to be preceded by a newline, and checking that there are at least 2 elements in lines ensures it. If it does not, either the opening line and the closing line are the same line, or the closing line contains a substitution.
throw new TypeError("Closing line of String.dedent must be preceded by a new line.");
const closingLine = lastBlock[lineCount - 1];
if (!isAllWhiteSpace(closingLine.string))
// The closing line may only contain whitespace.
throw new TypeError("Closing line may only contain whitespace.");
const preceding = lastBlock[lineCount - 2];
// Setting closingLine.[[String]] and preceding.[[Newline]] removes the closing line from the output.
closingLine.string = "";
preceding.newline = "";
}
function determineCommonLeadingIndentation(blocks: LineRec[][]): string {
let common!: string;
for (const lines of blocks) {
const lineCount = lines.length;
// We start i at 1 because because the first line of every block is either (a) the opening line which must be empty or (b) the continuation of a line directly after a template substitution. Neither can be the start of a content line.
for (let i = 1; i < lineCount; i++) {
const line = lines[i];
// Lines which contain substitutions are considered when finding the common indentation. Lines which contain only whitespace have already been emptied.
if (line.lineEndsWithSubstitution || line.string !== "") {
const leading = leadingWhiteSpaceSubstring(line.string);
common = common === undefined ? leading : longestMatchingLeadingSubstring(common, leading);
}
}
}
// common is not empty, because SplitTemplateIntoBlockLines guarantees there is at least 1 line per block, and we know the length of the template strings array is at least 1.
return common;
}
function leadingWhiteSpaceSubstring(str: string): string {
const len = str.length;
for (let i = 0; i < len; i++) {
const c = str[i];
if (!/\s/.test(c)) return str.substring(0, i);
}
return str;
}
function isAllWhiteSpace(str: string): boolean {
const trimmed = str.trimStart();
return trimmed === "";
}
function longestMatchingLeadingSubstring(a: string, b: string): string {
const aLen = a.length;
const bLen = b.length;
const len = Math.min(aLen, bLen);
for (let i = 0; i < len; i++) {
const aChar = a[i];
const bChar = b[i];
if (aChar !== bChar) return a.substring(0, i);
}
return a.substring(0, len);
}
function cookTemplateLiteralsArray(template: unknown, ...substitutions: unknown[]): string {
const numberOfSubstitutions = substitutions.length;
const cooked = toObject(template);
const literalSegments = lengthOfArrayLike(cooked);
if (literalSegments <= 0) return "";
const stringElements: string[] = [];
for (let nextIndex = 0;; nextIndex++) {
const nextVal = (cooked as Record<number, unknown>)[nextIndex];
if (typeof nextVal === "undefined") throw new TypeError("Cannot cook template with illegal characters");
const nextSeg = `${nextVal}`;
stringElements.push(nextSeg);
if (nextIndex + 1 === literalSegments) {
return stringElements.join("");
}
const next = nextIndex < numberOfSubstitutions ? substitutions[nextIndex] : "";
const nextSub = `${next}`;
stringElements.push(nextSub);
}
}
function toObject(o: unknown): object {
if (typeof o === "undefined" || o === null) {
throw new TypeError("Cannot convert undefined or null to object");
}
return Object(o);
}
function lengthOfArrayLike(o: object): number {
const { length } = o as { length: unknown };
const len = toIntegerOrInfinity(length);
if (len <= 0) return 0;
return Math.min(len, 2 ** 53 - 1);
}
function toIntegerOrInfinity(n: unknown): number {
const number = +(n as number);
if (Number.isNaN(number) || number === 0) return 0;
if (!Number.isFinite(number)) return number;
let integer = Math.floor(Math.abs(number));
if (number < -0) integer = -integer;
return integer;
}
})();
export {};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment