+301
@@ -0,0 +1,301 @@
|
||||
/******************************************************************************
|
||||
* Copyright 2022 TypeFox GmbH
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the MIT License, which is available in the project root.
|
||||
******************************************************************************/
|
||||
import { NEWLINE_REGEXP } from '../utils/regexp-utils.js';
|
||||
import { CompositeGeneratorNode, isGeneratorNode, traceToNode } from './generator-node.js';
|
||||
import { findIndentation } from './template-string.js';
|
||||
/**
|
||||
* A tag function that attaches the template's content to a {@link CompositeGeneratorNode}.
|
||||
*
|
||||
* This is done segment by segment, and static template portions as well as substitutions
|
||||
* are added individually to the returned {@link CompositeGeneratorNode}.
|
||||
* At that common leading indentation of all the template's static parts is trimmed,
|
||||
* whereas additional indentations of particular lines within that static parts as well as
|
||||
* any line breaks and indentation within the substitutions are kept.
|
||||
*
|
||||
* For the sake of good readability and good composability of results of this function like
|
||||
* in the following example, the subsequent rule is applied.
|
||||
*
|
||||
* ```ts
|
||||
* expandToNode`
|
||||
* This is the beginning of something
|
||||
*
|
||||
* ${foo.bar ? expandToNode`
|
||||
* bla bla bla ${foo.bar}
|
||||
*
|
||||
* `: undefined}
|
||||
* end of something
|
||||
* `
|
||||
* ```
|
||||
*
|
||||
* Rule:
|
||||
* In case of a multiline template
|
||||
* 1. the content of the first line including its terminating line break is ignored,
|
||||
* if and only if it is empty or contains whitespace only.
|
||||
* 2. the content of the last line including its preceding line break (last line within the template excluding the trailing backtick) is ignored,
|
||||
* if and only if it is empty or contains whitespace only, and the whitespace in-between the last line break and the trailing backtick
|
||||
* is a real prefix of the common indentation among all none-empty lines of the template, i.e.,
|
||||
* it is shorter than the common indentation that is trimmed during processing the template provided that common indentation is identifiable.
|
||||
*
|
||||
* Thus, the results of all of the following invocations are identical and equal to `generatedContent`.
|
||||
* ```ts
|
||||
* expandToNode`generatedContent`
|
||||
* expandToNode`
|
||||
* generatedContent`
|
||||
* expandToNode`
|
||||
* generatedContent
|
||||
* `
|
||||
* expandToNode`
|
||||
* generatedContent
|
||||
* `
|
||||
* ```
|
||||
* In contrast, the results of the following invocations are equal to `generatedContent\n` (or `generatedContent\r\n` on MS Windows)
|
||||
* ```ts
|
||||
* expandToNode`generatedContent
|
||||
* `
|
||||
* expandToNode`
|
||||
* generatedContent
|
||||
* `
|
||||
* ```
|
||||
*
|
||||
* In addition, a third rule is applied while processing line breaks:
|
||||
* If a line's last substitution contributes `undefined` or an object of type {@link GeneratorNode},
|
||||
* the subsequent line break will be appended via {@link CompositeGeneratorNode.appendNewLineIfNotEmpty}.
|
||||
* Hence, if all other segments of that line contribute whitespace characters only,
|
||||
* the entire line will be omitted while rendering the desired output.
|
||||
* Otherwise, linebreaks will be added via {@link CompositeGeneratorNode.appendNewLine}.
|
||||
* That holds in particular, if the last substitution contributes an empty string. In consequence,
|
||||
* adding `${''}` to the end of a line consisting of whitespace and substitutions only
|
||||
* enforces the line break to be rendered, no matter what the substitutions actually contribute.
|
||||
*
|
||||
* @param staticParts the static parts of a tagged template literal
|
||||
* @param substitutions the variable parts of a tagged template literal
|
||||
* @returns a 'CompositeGeneratorNode' containing the particular aligned lines
|
||||
* after resolving and inserting the substitutions into the given parts
|
||||
*/
|
||||
export function expandToNode(staticParts, ...substitutions) {
|
||||
// first part: determine the common indentation of all the template lines with the substitutions being ignored
|
||||
const templateProps = findIndentationAndTemplateStructure(staticParts);
|
||||
// 2nd part: for all the static template parts: split them and inject a NEW_LINE marker where line breaks shall be a present in the final result,
|
||||
// and create a flatten list of strings, NEW_LINE marker occurrences, and substitutions
|
||||
const splitAndMerged = splitTemplateLinesAndMergeWithSubstitutions(staticParts, substitutions, templateProps);
|
||||
// eventually, inject indentation nodes and append the segments to final desired composite generator node
|
||||
return composeFinalGeneratorNode(splitAndMerged);
|
||||
}
|
||||
// implementation:
|
||||
export function expandTracedToNode(source, property, index) {
|
||||
return (staticParts, ...substitutions) => {
|
||||
return traceToNode(source, property, index)(expandToNode(staticParts, ...substitutions));
|
||||
};
|
||||
}
|
||||
// implementation:
|
||||
export function expandTracedToNodeIf(condition, source, property, index) {
|
||||
return condition ? expandTracedToNode((typeof source === 'function' ? source() : source), property, index) : () => undefined;
|
||||
}
|
||||
function findIndentationAndTemplateStructure(staticParts) {
|
||||
const lines = staticParts.join('_').split(NEWLINE_REGEXP);
|
||||
const omitFirstLine = lines.length > 1 && lines[0].trim().length === 0;
|
||||
const omitLastLine = omitFirstLine && lines.length > 1 && lines[lines.length - 1].trim().length === 0;
|
||||
if (lines.length === 1 || lines.length !== 0 && lines[0].trim().length !== 0 || lines.length === 2 && lines[1].trim().length === 0) {
|
||||
// for cases of non-adjusted templates like
|
||||
// const n1 = expandToNode` `;
|
||||
// const n2 = expandToNode` something `;
|
||||
// const n3 = expandToNode` something
|
||||
// `;
|
||||
// ... consider the indentation to be empty, and all the leading whitespace to be relevant, except for the last (empty) line of n3!
|
||||
return {
|
||||
indentation: 0, //''
|
||||
omitFirstLine,
|
||||
omitLastLine,
|
||||
trimLastLine: lines.length !== 1 && lines[lines.length - 1].trim().length === 0
|
||||
};
|
||||
}
|
||||
else {
|
||||
// otherwise:
|
||||
// for cases of non-adjusted templates like
|
||||
// const n4 = expandToNode` abc
|
||||
// def `;
|
||||
// const n5 = expandToNode`<maybe with some WS here>
|
||||
// abc
|
||||
// def`;
|
||||
// const n6 = expandToNode`<maybe with some WS here>
|
||||
// abc
|
||||
// def
|
||||
// `;
|
||||
// ... the indentation shall be determined by the non-empty lines, excluding the last line if it contains whitespace only
|
||||
// if we have a multi-line template and the first line is empty, see n5, n6
|
||||
// ignore the first line;
|
||||
let sliced = omitFirstLine ? lines.slice(1) : lines;
|
||||
// if there're more than one line remaining and the last one only contains WS, see n6,
|
||||
// ignore the last line
|
||||
sliced = omitLastLine ? sliced.slice(0, sliced.length - 1) : sliced;
|
||||
// ignore empty lines during indentation calculation, as linting rules might forbid lines containing just whitespace
|
||||
sliced = sliced.filter(e => e.length !== 0);
|
||||
const indentation = findIndentation(sliced);
|
||||
return {
|
||||
indentation,
|
||||
omitFirstLine,
|
||||
// in the subsequent steps omit the last line only if it is empty or if it only contains whitespace of which the common indentation is not a valid prefix;
|
||||
// in other words: keep the last line if it matches the common indentation (and maybe contains non-whitespace), a non-match may be due to mistaken usage of tabs and spaces
|
||||
omitLastLine: omitLastLine && (lines[lines.length - 1].length < indentation || !lines[lines.length - 1].startsWith(sliced[0].substring(0, indentation)))
|
||||
};
|
||||
}
|
||||
}
|
||||
function splitTemplateLinesAndMergeWithSubstitutions(staticParts, substitutions, { indentation, omitFirstLine, omitLastLine, trimLastLine }) {
|
||||
const splitAndMerged = [];
|
||||
staticParts.forEach((part, i) => {
|
||||
splitAndMerged.push(...part.split(NEWLINE_REGEXP).map((e, j) => j === 0 || e.length < indentation ? e : e.substring(indentation)).reduce(
|
||||
// treat the particular (potentially multiple) lines of the <i>th template segment (part),
|
||||
// s.t. all the effective lines are collected and separated by the NEWLINE node
|
||||
// note: different reduce functions are provided for the initial template segment vs. the remaining segments
|
||||
i === 0
|
||||
? (result, line, j) =>
|
||||
// special handling of the initial template segment, which may contain line-breaks;
|
||||
// suppresses the injection of unintended NEWLINE indicators for templates like
|
||||
// expandToNode`
|
||||
// someText
|
||||
// ${something}
|
||||
// `
|
||||
j === 0
|
||||
? (omitFirstLine // for templates with empty first lines like above (expandToNode`\n ...`)
|
||||
? [] // skip adding the initial line
|
||||
: [line] // take the initial line if non-empty
|
||||
)
|
||||
: (j === 1 && result.length === 0 // when looking on the 2nd line in case the first line (in the first segment) is skipped ('result' is still empty)
|
||||
? [line] // skip the insertion of the NEWLINE marker and just return the current line
|
||||
: result.concat(NEWLINE, line) // otherwise append the NEWLINE marker and the current line
|
||||
)
|
||||
: (result, line, j) =>
|
||||
// handling of the remaining template segments
|
||||
j === 0 ? [line] : result.concat(NEWLINE, line) // except for the first line in the current segment prepend each line with NEWLINE
|
||||
, [] // start with an empty array
|
||||
).filter(e => !(typeof e === 'string' && e.length === 0) // drop empty strings, they don't contribute anything but might confuse subsequent processing
|
||||
).concat(
|
||||
// append the corresponding substitution after each segment (part),
|
||||
// note that 'substitutions[i]' will be undefined for the last segment
|
||||
isGeneratorNode(substitutions[i])
|
||||
// if the substitution is a generator node, take it as it is
|
||||
? substitutions[i]
|
||||
: substitutions[i] !== undefined
|
||||
// if the substitution is something else, convert it to a string and wrap it;
|
||||
// allows us below to distinguish template strings from substitution (esp. empty) ones
|
||||
? { content: String(substitutions[i]) }
|
||||
: i < substitutions.length
|
||||
// if 'substitutions[i]' is undefined and we are treating a substitution "in the middle"
|
||||
// we found a substitution that is assumed to not contribute anything on purpose!
|
||||
? UNDEFINED_SEGMENT // add a corresponding marker, see below for details on the rational
|
||||
: [] /* don't concat anything as we passed behind the last substitution, since 'i' enumerates the indices of 'staticParts',
|
||||
but 'substitutions' has one entry less and 'substitutions[staticParts.length -1 ]' will always be undefined */));
|
||||
});
|
||||
// for templates like
|
||||
// expandToNode`
|
||||
// someText
|
||||
// `
|
||||
// TODO add more documentation here
|
||||
const splitAndMergedLength = splitAndMerged.length;
|
||||
const lastItem = splitAndMergedLength !== 0 ? splitAndMerged[splitAndMergedLength - 1] : undefined;
|
||||
if ((omitLastLine || trimLastLine) && typeof lastItem === 'string' && lastItem.trim().length === 0) {
|
||||
if (omitFirstLine && splitAndMergedLength !== 1 && splitAndMerged[splitAndMergedLength - 2] === NEWLINE) {
|
||||
return splitAndMerged.slice(0, splitAndMergedLength - 2);
|
||||
}
|
||||
else {
|
||||
return splitAndMerged.slice(0, splitAndMergedLength - 1);
|
||||
}
|
||||
}
|
||||
else {
|
||||
return splitAndMerged;
|
||||
}
|
||||
}
|
||||
const NEWLINE = { isNewLine: true };
|
||||
const UNDEFINED_SEGMENT = { isUndefinedSegment: true };
|
||||
const isNewLineMarker = (nl) => nl === NEWLINE;
|
||||
const isUndefinedSegmentMarker = (us) => us === UNDEFINED_SEGMENT;
|
||||
const isSubstitutionWrapper = (s) => s.content !== undefined;
|
||||
function composeFinalGeneratorNode(splitAndMerged) {
|
||||
// in order to properly handle the indentation of nested multi-line substitutions,
|
||||
// track the length of static (string) parts per line and wrap the substitution(s) in indentation nodes, if needed
|
||||
//
|
||||
// of course, this only works nicely if a multi-line substitution is preceded by static string parts on the same line only;
|
||||
// in case of dynamic content (with a potentially unknown length) followed by a multi-line substitution
|
||||
// the latter's indentation cannot be determined properly...
|
||||
const result = splitAndMerged.reduce((res, segment, i) => isUndefinedSegmentMarker(segment)
|
||||
// ignore all occurrences of UNDEFINED_SEGMENT, they are just in there for the below test
|
||||
// of 'isNewLineMarker(splitAndMerged[i-1])' not to evaluate to 'truthy' in case of consecutive lines
|
||||
// with no actual content in templates like
|
||||
// expandToNode`
|
||||
// Foo
|
||||
// ${undefined} <<----- here
|
||||
// ${undefined} <<----- and here
|
||||
//
|
||||
// Bar
|
||||
// `
|
||||
? res
|
||||
: isNewLineMarker(segment)
|
||||
? {
|
||||
// in case of a newLine marker append an 'ifNotEmpty' newLine by default, but
|
||||
// append an unconditional newLine if and only if:
|
||||
// * the template starts with the current line break, i.e. the first line is empty
|
||||
// * the current newLine marker directly follows another one, i.e. the current line is empty
|
||||
// * the current newline marker directly follows a substitution contributing a string (or some non-GeneratorNode being converted to a string)
|
||||
// * the current newline marker directly follows a (template static) string that
|
||||
// * is the initial token of the template
|
||||
// * is the initial token of the line, maybe just indentation
|
||||
// * follows a a substitution contributing a string (or some non-GeneratorNode being converted to a string), maybe is just irrelevant trailing whitespace
|
||||
// in particular do _not_ append an unconditional newLine if the last substitution of a line contributes 'undefined' or an instance of 'GeneratorNode'
|
||||
// which may be a newline itself or be empty or (transitively) contain a trailing newline itself
|
||||
// node: i === 0
|
||||
// || isNewLineMarker(splitAndMerged[i - 1]) || isSubstitutionWrapper(splitAndMerged[i - 1]) /* implies: typeof content === 'string', esp. !undefined */
|
||||
// || typeof splitAndMerged[i - 1] === 'string' && (
|
||||
// i === 1 || isNewLineMarker(splitAndMerged[i - 2]) || isSubstitutionWrapper(splitAndMerged[i - 2]) /* implies: typeof content === 'string', esp. !undefined */
|
||||
// )
|
||||
// ? res.node.appendNewLine() : res.node.appendNewLineIfNotEmpty()
|
||||
//
|
||||
// UPDATE cs: inverting the logic leads to the following, I hope I didn't miss anything:
|
||||
// in case of a newLine marker append an unconditional newLine by default, but
|
||||
// append an 'ifNotEmpty' newLine if and only if:
|
||||
// * the template doesn't start with a newLine marker and
|
||||
// * the current newline marker directly follows a substitution contributing an `undefined` or an instance of 'GeneratorNode', or
|
||||
// * the current newline marker directly follows a (template static) string (containing potentially unintended trailing whitespace)
|
||||
// that in turn directly follows a substitution contributing an `undefined` or an instance of 'GeneratorNode'
|
||||
node: i !== 0 && (isUndefinedSegmentMarker(splitAndMerged[i - 1]) || isGeneratorNode(splitAndMerged[i - 1]))
|
||||
|| i > 1 && typeof splitAndMerged[i - 1] === 'string' && (isUndefinedSegmentMarker(splitAndMerged[i - 2]) || isGeneratorNode(splitAndMerged[i - 2]))
|
||||
? res.node.appendNewLineIfNotEmpty() : res.node.appendNewLine()
|
||||
} : (() => {
|
||||
// the indentation handling is supposed to handle use cases like
|
||||
// bla bla bla {
|
||||
// ${foo(bar)}
|
||||
// }
|
||||
// and
|
||||
// bla bla bla {
|
||||
// return ${foo(bar)}
|
||||
// }
|
||||
// assuming that ${foo(bar)} yields a multiline result;
|
||||
// the whitespace between 'return' and '${foo(bar)}' shall not add to the indentation of '${foo(bar)}'s result!
|
||||
const indent = (i === 0 || isNewLineMarker(splitAndMerged[i - 1])) && typeof segment === 'string' && segment.length !== 0 ? segment.substring(0, segment.length - segment.trimStart().length) : '';
|
||||
const content = isSubstitutionWrapper(segment) ? segment.content : segment;
|
||||
let indented;
|
||||
return {
|
||||
node: res.indented
|
||||
// in case an indentNode has been registered earlier for the current line,
|
||||
// just return 'node' without manipulation, the current segment will be added to the indentNode
|
||||
? res.node
|
||||
// otherwise (no indentNode is registered by now)...
|
||||
: indent.length !== 0
|
||||
// in case an indentation has been identified add a non-immediate indentNode to 'node' and
|
||||
// add the current segment (containing its the indentation) to that indentNode,
|
||||
// and keep the indentNode in a local variable 'indented' for registering below,
|
||||
// and return 'node'
|
||||
? res.node.indent({ indentation: indent, indentImmediately: false, indentedChildren: ind => indented = ind.append(content) })
|
||||
// otherwise just add the content to 'node' and return it
|
||||
: res.node.append(content),
|
||||
indented:
|
||||
// if an indentNode has been created in this cycle, just register it,
|
||||
// otherwise check for a earlier registered indentNode and add the current segment to that one
|
||||
indented ?? res.indented?.append(content),
|
||||
};
|
||||
})(), { node: new CompositeGeneratorNode() });
|
||||
return result.node;
|
||||
}
|
||||
//# sourceMappingURL=template-node.js.map
|
||||
Reference in New Issue
Block a user