Last active
September 13, 2022 20:38
-
-
Save Josh-Cena/cc67a9e9f386bf4f0b790e789f9d3944 to your computer and use it in GitHub Desktop.
Faithful implementation of https://github.com/tc39/proposal-string-dedent/commit/7bfcc448579b6c3ee5f8a00c487f54e5d5ad9552
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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