diff --git a/.changeset/emmet-and-completion-placement.md b/.changeset/emmet-and-completion-placement.md index a99643a..a2e55f0 100644 --- a/.changeset/emmet-and-completion-placement.md +++ b/.changeset/emmet-and-completion-placement.md @@ -2,4 +2,4 @@ '@styled/typescript-styled-plugin': patch --- -Emmet no longer offers whole declarations where none fit, such as inside a value: typing `display: fl` used to offer `float: left;`. Completions no longer appear inside CSS comments and strings, and an empty template now offers property suggestions. +Emmet no longer offers whole declarations where none fit, such as inside a value: typing `display: fl` used to offer `float: left;`. Completions no longer appear inside CSS comments and strings, and an empty template now offers property suggestions. Emmet suggestions now also appear on a line that starts after a lone carriage return, a line continuation, or a line separator inside a string. diff --git a/.changeset/multiline-interpolations.md b/.changeset/multiline-interpolations.md new file mode 100644 index 0000000..f2ce11f --- /dev/null +++ b/.changeset/multiline-interpolations.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +Interpolations written across several lines are now checked the same as when written on one line, removing false errors and wrong completions when one sits in a property name, an at-rule such as `@media` or `@keyframes`, a `url()`, a quoted string, or a comment. An interpolation inside `url()` followed by more text, such as `url(#${id}-grad)`, no longer reports a false error either. 1.0.1 reported these errors as well. diff --git a/.changeset/supported-hosts-and-typescript-floor.md b/.changeset/supported-hosts-and-typescript-floor.md index 562169f..ba356fd 100644 --- a/.changeset/supported-hosts-and-typescript-floor.md +++ b/.changeset/supported-hosts-and-typescript-floor.md @@ -2,4 +2,4 @@ '@styled/typescript-styled-plugin': minor --- -On TypeScript older than 5.0, the plugin now logs a clear message and leaves the editor's TypeScript features working, instead of crashing on activation. +On TypeScript older than 5.0, which the plugin does not support, the TypeScript server log now says that TypeScript 5.0 or newer is required, instead of showing an unexplained activation error. diff --git a/docs/architecture.md b/docs/architecture.md index c561e5a..d5a4491 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,11 +12,12 @@ Spec for how the plugin turns a tsserver request inside a tagged template into a ## Substitution invariants - Output length equals input length, always. Every template offset is also a virtual-text offset (plus the wrapper length). -- Every fill keeps each line terminator of its placeholder where it stands and writes its characters over the others in order (`$a\n:0` for a mixin broken right after `${`, which the parser reads as whitespace before the `:`), except the hex fill, whose `000` covers the placeholder's first three characters so the digits stay on one line. A placeholder shorter than a branch's fill gets the next fill in that branch's chain (below), ending at the plain fill: the replacement character over every character but line terminators. Position mapping must not depend on the substituted text's line breaks. -- Cost is linear in template length for any number or arrangement of placeholders (many on one line included), and builds no per-character tables over the whole text: the document is rebuilt after every edit, so a whole-text pass is paid per keystroke. Forward scans are lazy and shared across placeholders (each kind of scan remembers the range it last covered, so a later query inside that range reuses the answer; the selector list scan below and the search for a comment's closing `*/` do the same). The scans that must know what is code (the selector-shape search, the selector list scan, and the running statement boundaries that set where each placeholder's statement starts) read the text's characters directly and step over its non-code runs (comments, strings, escapes, and unquoted url() arguments) whole, each scan keeping its own place in one list of those runs, since each placeholder lies past the previous one. One forward walk with the boundary scanner finds the list, stopping only where the scanner can act (a quote, `/`, a backslash, `)`, or a `u` before `r` or a backslash), so a text with few such characters, the common case, costs about one search more than plain code. The selector list scan reads a line's last code character back over that line's trailing whitespace and comments only. The running line facts (whether the line holds non-whitespace before a placeholder, and its latest line break or statement boundary, where a line break or non-whitespace character inside a comment or string still counts) jump from one line break or statement boundary to the next and read each stretch between for non-whitespace with one search. The statement rule's look-back past comments jumps between the characters that can open a comment or string instead of visiting every character; the at-rule condition test looks back over at most one gap and one word, and reads each statement's leading whitespace and comments once. The JavaScript escape replacement below is one pass over the masked text, or one search when the text holds no backslash. A template with no placeholders is returned unchanged. `corepack yarn test:scaling` guards the linear bound; a before-and-after benchmark guards the constant factor. +- Every fill writes its characters over every character of its placeholder, line terminators included, in order (`$a:0` plus spaces for a mixin broken across lines when a `;` follows and the statement rule holds): at runtime the interpolated value holds none of the source's line breaks, and a kept one would split a name, an at-rule prelude, a string, a `url(...)` token, or a `//` comment. A placeholder shorter than a branch's fill gets the next fill in that branch's chain (below), ending at the plain fill: the replacement character over every character. Position mapping never depends on the substituted text's line breaks (the line map in the "Virtual document" section reads the raw text; Emmet, which reads lines from text, gets the line view in the "Completions" section). +- Solid fill: a placeholder that starts inside a comment (`/* */` or `//`), a string, or an unquoted `url(...)` argument run fills with `x`, and no branch below applies to it; content there is opaque to the CSS service, and another branch's fill (block position's whitespace, the hex fill's `000` inside `url(#...)`) would change the token. A run that starts with a backslash does not count: an escape, whose backslash may take the placeholder's first character into the escape's identifier at code. An unquoted `url(...)` argument whose function name starts with an escape (`\75 rl(`) also starts with a backslash and is excluded with the escapes, since the run list does not tell the two apart; a placeholder there gets its branch's fill. Whether a placeholder starts inside a run depends only on the text before it. A `//` comment holding a multi-line interpolation continues through it: the solid fill leaves no line terminator inside the placeholder, so the CSS service reads the comment as one unbroken, multi-line comment, matching runtime, where the interpolated value holds no line break either. +- Cost is linear in template length for any number or arrangement of placeholders (many on one line included), and builds no per-character tables over the whole text: the document is rebuilt after every edit, so a whole-text pass is paid per keystroke. Forward scans are lazy and shared across placeholders (each kind of scan remembers the range it last covered, so a later query inside that range reuses the answer; the selector list scan below and the search for a comment's closing `*/` do the same). The scans that must know what is code (the selector-shape search, the selector list scan, and the running statement boundaries that set where each placeholder's statement starts) read the text's characters directly and step over its non-code runs (comments, strings, escapes, and unquoted url() arguments) whole, each scan keeping its own place in one list of those runs, since each placeholder lies past the previous one. One forward walk with the boundary scanner finds the list, stopping only where the scanner can act (a quote, `/`, a backslash, `)`, or a `u` before `r` or a backslash), so a text with few such characters, the common case, costs about one search more than plain code. The selector list scan reads a line's last code character back over that line's trailing whitespace and comments only. The running line facts (whether the line holds non-whitespace before a placeholder, and its latest line break or statement boundary, where a line break or non-whitespace character inside a comment or string still counts) jump from one line break or statement boundary to the next and read each stretch between for non-whitespace with one search. The statement rule's look-back past comments jumps between the characters that can open a comment or string instead of visiting every character; the at-rule condition test looks back over at most one gap and one word, and reads each statement's leading whitespace and comments once. The JavaScript escape replacement below is one pass over the masked text, or one search when the text holds no backslash. The masked text and its non-code runs are built once, and the solid fill is decided by one walk of the placeholders against those runs. A template with no placeholders is returned unchanged. `corepack yarn test:scaling` guards the linear bound; a before-and-after benchmark guards the constant factor. - Placeholder spans: every scan walks them in ascending order, clamped into the template, with overlapping spans merged into their union (spans that only touch stay separate, as adjacent placeholders do) and empty spans dropped (they substitute nothing). The decorator supplies spans in that shape already; a span list from `./api`'s `getSubstitutions` in any other order, overlapping, empty, or out of range is normalized once, with one copy and one sort. The output depends only on span positions, so the same spans in any order give the same output. - Each branch of `getSubstitution` targets one placeholder shape (property value, whole declaration, property name, selector, at-rule condition, hex color); comments in `src/template/template-substitutions.ts` show each shape. -- Masked text: the template text with every placeholder as x's (its line terminators kept) and JavaScript escapes replaced as the virtual document replaces them (the "Virtual document" section), so a JavaScript-escaped quote, `;`, or brace reads as the character styled-components receives. Every rule below reads the masked text; the output keeps the raw text between placeholders. +- Masked text: the template text with every placeholder as x's over its whole length (so a line fact never starts a new line inside a placeholder) and JavaScript escapes replaced as the virtual document replaces them (the "Virtual document" section), so a JavaScript-escaped quote, `;`, or brace reads as the character styled-components receives. Every rule below reads the masked text; the output keeps the raw text between placeholders. - Name characters, for the rules below: ASCII letters, digits, `-`, `_`, and every UTF-16 code unit from U+0080 up except U+2028 and U+2029, which are line terminators, in the masked text (where adjacent placeholders form one name). This is `vscode-css-languageservice` 6.3.10's `_identChar`, so a non-ASCII space such as U+00A0 is a name character, as the CSS scanner reads it. The same set serves every name reader in the "Virtual document" section: the boundary scanner, the `@layer` name, and the `url` function name in the JavaScript escape replacement. - Block position (a placeholder standing in for whole declarations, such as a mixin or a `css` fragment) is filled with whitespace, or, when a `;` follows it, with the dummy declaration `$a:0` (then `a:0`, then whitespace, for a shorter placeholder) if the statement rule's look-back below holds and with x's otherwise (nothing before it to anchor a declaration to). A placeholder is in block position when either rule holds: - Statement rule: the last significant character before it in the masked text, across lines and past comments (a string counts as one significant character at its opening quote, an escape at its backslash, an unquoted `url(...)` argument at its `(`), is `;`, `{`, `}`, or the template start, or is the end of a preceding placeholder that the statement rule itself placed in block position (so `${a} ${b}` on one line is two mixins, and so is `color: red; /* note */ ${a}`); and the next non-whitespace character after it is none of `{`, `:`, `,`, `&`, `.`, `#`, `[`, `>`, `+`, `~`, `*`, `%` (a rule body, a property name, a selector list, a selector continuation, or a percentage such as the keyframe selector `${step}% {`). A placeholder inside a comment or string never passes this rule. @@ -82,6 +83,7 @@ Spec for how the plugin turns a tsserver request inside a tagged template into a - No declaration can start: the last structural code character before the caret (`;`, `{`, `}`, or `:`) is `:` (a value, a selector with a pseudo-class such as `&:hover m10 {`, or a media feature such as `@media (min-width: m10)`), or the statement that character ends, past whitespace and comments, starts with `@` (an at-rule prelude such as `@media m10 {`). The value wrapper of a value-shaped `css` fragment counts, so its whole text is a value. Emmet items that expand to a whole declaration (their label holds a `:`, such as `float: left;` for `fl`) are dropped, because accepting one inserts a declaration where none fits. Value expansions (`#121212` for `#12`, `!important`) stay. - Anywhere else, including an empty template and a line typed above a nested rule (`m10` before `&:hover {`, where a declaration can start): every source above. - Cost: the structural search runs only when an Emmet item expands to a declaration, and costs at most two passes over the text before the caret (one in the common case, where the nearest structural character is code), plus the whitespace and comments that open the caret's statement. +- Emmet line view: `@vscode/emmet-helper` 2.11.0 reads the caret's line from the document text between `\n` characters (`getCurrentLine`) and indexes it by the caret's character, while positions follow the template's own lines (the line map in the "Virtual document" section). A template line can end without a `\n` in the virtual text: under a fill, under an escape stand-in (a line continuation), at a lone `\r`, or at a U+2028 or U+2029 kept inside a string. So Emmet gets a fresh document built over the template text with `\n` written at the end of every template line: the same length as the substituted text, and a document whose own lines are exactly the template's lines, so its line-based reads (`getText`, `getLineRange`, `lineCount`) and its position mapping (`offsetAt`, `positionAt`) all agree with each other; the view is the document itself when every template line already ends in `\n`. Every other consumer reads positions through the document's own mapping. - Nested at-rules: `vscode-css-languageservice` offers no at-rules inside a style rule (CSS mode returns none; SCSS mode returns only Sass directives, which the SCSS filter drops). When the caret ends or sits inside an at-keyword (`@` and the name characters around the caret, the set in the "Substitution invariants" section, as the CSS scanner reads one: `@`, `@me`, `@mé`) that is code (not inside a comment or string) and whose preceding significant character in the template, past comments, is `;`, `{`, `}`, or the template start, the list adds the at-rules valid nested in a style rule (`@media`, `@supports`, `@container`, `@layer`, `@scope`, `@starting-style`) and the ones styled-components hoists to the top level (`@keyframes`, `@property`, `@font-face`, `@font-palette-values`, `@page`, `@counter-style`), skipping a label the list already has. - Inclusion rule for hoisted at-rules: one the styled-components v7 emitter writes at the stylesheet's top level, outside the component's selector, through either of its two branches in `packages/styled-components/src/parser/emit-web.ts` (an undocumented internal): a name in `DECL_BODY_AT_RULES`, whose body `emitAtRule` writes as bare declarations, or a keyframes name, which the parser reads into its own keyframes node (`isKeyframesName`, `packages/styled-components/src/parser/atRuleNames.ts`) and `emitKeyframes` writes as keyframe blocks of declarations; whose body v7 keeps as the author wrote it; and that the CSS language service completes at the top level. Applied to each candidate: - `@font-face`, `@page`, `@property`, `@counter-style`, `@font-palette-values`: the declaration branch; bodies of declarations, kept; completed. Included. diff --git a/scripts/compare-release-cases.ts b/scripts/compare-release-cases.ts index 6552bcb..34371bb 100644 --- a/scripts/compare-release-cases.ts +++ b/scripts/compare-release-cases.ts @@ -236,6 +236,26 @@ const cases: CompareCase[] = [ 'line continuation inside a url()', 'const A = styled.div`background: url(foo\\\nbar.png);\ncolor: red;`', ), + diagnosticsCase( + 'placeholder spanning a line break inside an unquoted url() argument', + 'declare const props: { image: string }\nconst A = styled.div`background: url(${\n props.image\n});`', + ), + diagnosticsCase( + 'placeholder inside a url() after "#" with text after it', + 'declare const id: string\nconst A = styled.div`fill: url(#${id}-grad);`', + ), + diagnosticsCase( + 'placeholder spanning a line break inside a quoted string', + 'declare const props: { label: string }\nconst A = styled.div`content: "${\n props.label\n}";`', + ), + diagnosticsCase( + 'placeholder spanning a line break inside a line comment', + 'declare const note: string\nconst A = styled.div`// ${\n note\n} here\ncolor: red;`', + ), + diagnosticsCase( + 'placeholder spanning a line break as an @media prelude', + 'declare const query: string\nconst A = styled.div`@media ${\n query\n} {\n color: red;\n}`', + ), diagnosticsCase( 'property name placeholder before "&" on the same line', 'declare const s: string\nconst A = styled.div`padding-${s}: 0; &:hover { color: red; }`', diff --git a/src/features/completions.ts b/src/features/completions.ts index 2aa8ee8..effb074 100644 --- a/src/features/completions.ts +++ b/src/features/completions.ts @@ -140,9 +140,13 @@ export class CompletionsFeature { this.virtualDocumentSessionProvider.getParsedDocument(context) const virtualPosition = this.virtualDocumentProvider.toVirtualDocPosition(position) const configuration = this.getConfiguration() + const templateStart = this.virtualDocumentProvider.toVirtualDocOffset(0, context) const emmetItems = - this.emmetCompletionProvider.doComplete(document, virtualPosition, configuration.emmet) - ?.items ?? [] + this.emmetCompletionProvider.doComplete( + withTemplateLineBreaks(document, lineMap, templateStart), + virtualPosition, + configuration.emmet, + )?.items ?? [] const text = document.getText() const caretOffset = document.offsetAt(virtualPosition) const caretPlacement = findCaretPlacement( @@ -160,11 +164,7 @@ export class CompletionsFeature { this.scssLanguageService.doComplete(document, virtualPosition, stylesheet).items, ), ] - const atKeywordRange = findStatementAtKeywordRange( - text, - caretOffset, - this.virtualDocumentProvider.toVirtualDocOffset(0, context), - ) + const atKeywordRange = findStatementAtKeywordRange(text, caretOffset, templateStart) if (atKeywordRange) { const labels = new Set(items.map((item) => item.label)) const range = { @@ -347,6 +347,37 @@ function startsWithAtKeyword(text: string, from: number, to: number): boolean { return false } +/** + * `document` with "\n" written over the last character of every template line, for Emmet, which + * reads the caret's line from the text between "\n" characters (`getCurrentLine`, + * @vscode/emmet-helper) while positions follow the template's own lines (docs/architecture.md, + * Completions). An overwrite, not an insert, so the view keeps the document's length and offsets; + * the overwritten character is always the template's line terminator, a fill character, or a space + * an escape stand-in wrote, never CSS the user typed. `document` itself when every template line + * already ends in "\n" there. + */ +export function withTemplateLineBreaks( + document: TextDocument, + { lineStarts }: TemplateLineMap, + templateStart: number, +): TextDocument { + const text = document.getText() + let aligned = '' + let cursor = 0 + for (let line = 1; line < lineStarts.length; line++) { + const lineEnd = templateStart + lineStarts[line] - 1 + if (text[lineEnd] !== '\n') { + aligned += `${text.slice(cursor, lineEnd)}\n` + cursor = lineEnd + 1 + } + } + if (cursor === 0) { + return document + } + aligned += text.slice(cursor) + return TextDocument.create(document.uri, document.languageId, document.version, aligned) +} + /** * An Emmet expansion that is a whole declaration (`float: left;`) rather than a value (`#121212`, * `!important`). Emmet labels an item with its expanded text, so the property's ":" marks it. diff --git a/src/template/template-substitutions.ts b/src/template/template-substitutions.ts index 49d4976..2cc36dd 100644 --- a/src/template/template-substitutions.ts +++ b/src/template/template-substitutions.ts @@ -36,8 +36,7 @@ export function getTemplateSubstitutions( * styled-components receives (docs/architecture.md, Virtual document); the output keeps the * raw text between placeholders, which the virtual document replaces the same way. */ - const syntaxText = replaceJavaScriptEscapes(maskSubstitutions(templateText, spans)) - const nonCodeRuns = findNonCodeRuns(syntaxText) + const { nonCodeRuns, solidSpans, text: syntaxText } = buildSyntaxText(templateText, spans) const boundaryScanner = createSyntaxBoundaryScanner(syntaxText, nonCodeRuns) const codeLookback = createCodeLookback(syntaxText, 0) const findLineConditionKeyword = createConditionKeywordFinder(syntaxText) @@ -47,7 +46,16 @@ export function getTemplateSubstitutions( let lastOffset = 0 let previousAtStatementBoundary = false - for (const span of spans) { + for (let spanIndex = 0; spanIndex < spans.length; spanIndex++) { + const span = spans[spanIndex] + substitutedParts.push(templateText.slice(lastOffset, span.start)) + if (solidSpans?.[spanIndex]) { + substitutedParts.push(SOLID_FILL.repeat(span.end - span.start)) + previousAtStatementBoundary = false + lastSpanStart = span.start + lastOffset = span.end + continue + } advanceScanState(scanState, syntaxText, span.start) const onlyWhitespaceSinceLineStart = !scanState.sawNonWhitespace const previousSignificantOffset = codeLookback.previousSignificant(span.start) @@ -88,7 +96,6 @@ export function getTemplateSubstitutions( followsStatementBoundary && !STATEMENT_SLOT_EXCLUDED_NEXT.has(nextSignificant ?? '') const gapStart = skipWhitespaceBackward(syntaxText, span.start, lastOffset) - substitutedParts.push(templateText.slice(lastOffset, span.start)) substitutedParts.push( getSubstitution({ atRuleCondition: conditionKeyword @@ -359,6 +366,55 @@ function createNonWhitespaceScanner(text: string, runs: NonCodeRuns): (from: num } } +/** The solid fill's one character (docs/architecture.md, substitution invariants). */ +const SOLID_FILL = 'x' + +interface SyntaxText { + readonly nonCodeRuns: NonCodeRuns + /** Indexed like the spans: true for a placeholder that takes the solid fill. Undefined when none does. */ + readonly solidSpans: readonly boolean[] | undefined + readonly text: string +} + +/** + * The masked text with JavaScript escapes replaced, its non-code runs, and which placeholders take + * the solid fill (docs/architecture.md, substitution invariants). Every placeholder is masked as + * "x" over its whole length, so the text before a placeholder, which alone decides whether the + * placeholder starts inside a run, is already final when the runs are found: one build, one pass. + */ +function buildSyntaxText(templateText: string, spans: ReadonlyArray): SyntaxText { + const text = replaceJavaScriptEscapes(maskSubstitutions(templateText, spans)) + const nonCodeRuns = findNonCodeRuns(text) + return { nonCodeRuns, solidSpans: findSolidSpans(text, nonCodeRuns, spans), text } +} + +/** + * Which spans start inside a comment, string, or unquoted url() argument run of `text`, not an + * escape: a run starting with a backslash, whose escape may take the placeholder's first character + * (an unquoted url() argument whose function name starts with an escape, `\75 rl(`, starts with one + * too). Spans and runs both ascend, so the run index only moves forward. Undefined when no span + * does. + */ +function findSolidSpans( + text: string, + { ends, starts }: NonCodeRuns, + spans: ReadonlyArray, +): readonly boolean[] | undefined { + let solidSpans: boolean[] | undefined + let run = 0 + for (let index = 0; index < spans.length && run < ends.length; index++) { + const { start } = spans[index] + while (run < ends.length && ends[run] <= start) { + run++ + } + if (run < ends.length && starts[run] <= start && text[starts[run]] !== '\\') { + solidSpans ??= [] + solidSpans[index] = true + } + } + return solidSpans +} + /** * Property-name position is a ":" at `colonPosition` without the selector shape; selector position * is a ":" with it: starting at least one character after that ":", the nearest stop at code @@ -605,7 +661,7 @@ function getSubstitution(context: { plainFill() ) } - return fillPlaceholder(placeholderText, 'x') + return fillPlaceholder(placeholderText, SOLID_FILL) } /** @@ -618,12 +674,12 @@ function getSubstitution(context: { */ if (context.isPropertyNamePosition) { if (isCustomPropertyName(context.syntaxTextSinceBoundary)) { - return fillPlaceholder(placeholderText, 'x') + return fillPlaceholder(placeholderText, SOLID_FILL) } if (context.isJoinedToName) { return ( wrapPlaceholder(placeholderText, { close: '}', open: '#{x' }) ?? - fillPlaceholder(placeholderText, 'x') + fillPlaceholder(placeholderText, SOLID_FILL) ) } return wrapPlaceholder(placeholderText, { open: '$a', padding: 'x' }) ?? plainFill() @@ -638,7 +694,7 @@ function getSubstitution(context: { */ if (context.isSelectorPosition) { return context.isJoinedToSelectorName - ? fillPlaceholder(placeholderText, 'x') + ? fillPlaceholder(placeholderText, SOLID_FILL) : (wrapPlaceholder(placeholderText, { open: '&' }) ?? plainFill()) } @@ -658,14 +714,10 @@ function getSubstitution(context: { /** * Placeholder used as a hex color value, right after "#", for example `color: #${1};`. Replaced - * with "000" (a valid 3-digit hex color) plus padding. The three digits must stay on one line to - * read as a hex color, so they cover the placeholder's first three characters even when a line - * terminator is among them. + * with "000" (a valid 3-digit hex color) plus padding. */ if (context.followsHash) { - return placeholderText.length < HEX_FILL.length - ? plainFill() - : HEX_FILL + fillPlaceholder(placeholderText.slice(HEX_FILL.length), ' ') + return wrapPlaceholder(placeholderText, { open: HEX_FILL }) ?? plainFill() } /** Ordinary property value, for example `color: ${'red'};`. */ @@ -755,33 +807,22 @@ function isNormalized(textLength: number, spans: ReadonlyArray * Builds the masked text from slices of `templateText` between spans plus a masked copy of each * span's own characters, so a long template with few placeholders pays for the placeholders' * length plus one slice per gap between them, never a per-character rewrite of the whole text. - * `spans` come from normalizeSpans. + * `spans` come from normalizeSpans. Every span gets the solid fill. */ function maskSubstitutions(templateText: string, spans: ReadonlyArray): string { let result = '' let cursor = 0 - for (const span of spans) { - result += templateText.slice(cursor, span.start) - result += fillPlaceholder(templateText.slice(span.start, span.end), 'x') - cursor = span.end + for (const { end, start } of spans) { + result += templateText.slice(cursor, start) + SOLID_FILL.repeat(end - start) + cursor = end } result += templateText.slice(cursor) return result } -/** - * Replaces every UTF-16 code unit of `placeholderText` with `replacementCharacter`, except a line - * terminator, which is kept so a multi-line placeholder keeps its line count (docs/architecture.md, - * substitution invariants). Indexed per code unit, not per code point (`for...of` would merge a - * surrogate pair into one iteration step and lose a unit of output length). - */ +/** `replacementCharacter` over every UTF-16 code unit of `placeholderText`, line terminators included. */ function fillPlaceholder(placeholderText: string, replacementCharacter: string): string { - let result = '' - for (let index = 0; index < placeholderText.length; index++) { - const character = placeholderText[index] - result += isLineTerminator(character) ? character : replacementCharacter - } - return result + return replacementCharacter.repeat(placeholderText.length) } /** @@ -795,35 +836,15 @@ function isCustomPropertyName(syntaxTextSinceBoundary: string): boolean { } /** - * Same-length stand-in for `placeholderText`: `open`, then `padding` repeated, then `close`, - * written in order over its characters other than line terminators, which stay where they are. - * Undefined when the placeholder has fewer such characters than `open` and `close` together. + * Same-length stand-in for `placeholderText`: `open`, then `padding` repeated, then `close`. + * Undefined when the placeholder is shorter than `open` and `close` together. */ function wrapPlaceholder( placeholderText: string, { close = '', open, padding = ' ' }: { close?: string; open: string; padding?: string }, ): string | undefined { - let slotCount = 0 - for (let index = 0; index < placeholderText.length; index++) { - slotCount += isLineTerminator(placeholderText[index]) ? 0 : 1 - } - const closeStart = slotCount - close.length - if (closeStart < open.length) { - return undefined - } - let result = '' - let slot = 0 - for (let index = 0; index < placeholderText.length; index++) { - const character = placeholderText[index] - if (isLineTerminator(character)) { - result += character - continue - } - result += - slot < open.length ? open[slot] : slot >= closeStart ? close[slot - closeStart] : padding - slot++ - } - return result + const paddingLength = placeholderText.length - open.length - close.length + return paddingLength < 0 ? undefined : open + padding.repeat(paddingLength) + close } /** A character right before a selector placeholder that makes the placeholder part of a name: `.${a}`, `#${a}`, `&${a}`, `:${a}`. */ diff --git a/test/performance/scaling-check.ts b/test/performance/scaling-check.ts index 35c48b4..ff88458 100644 --- a/test/performance/scaling-check.ts +++ b/test/performance/scaling-check.ts @@ -333,6 +333,49 @@ function buildStatementPlaceholderCase(count: number): TextCase { }) } +const MULTI_LINE_PLACEHOLDER = '${\n a\n}' +const SOLID_MULTI_LINE_FILL = 'x'.repeat(MULTI_LINE_PLACEHOLDER.length) + +/** `count` copies of `unit`, each MULTI_LINE_PLACEHOLDER in it a span. */ +function repeatMultiLineUnit(count: number, unit: string, expectedUnit: string): TextCase { + const text = unit.repeat(count) + return { + expected: expectedUnit.repeat(count), + spans: offsetsOf(text, MULTI_LINE_PLACEHOLDER).map((start) => ({ + end: start + MULTI_LINE_PLACEHOLDER.length, + start, + })), + text, + } +} + +/** + * A multi-line placeholder inside a quoted string, then another inside an unquoted url() argument, + * repeated: every placeholder takes the solid fill, found by one walk of the spans against the + * runs, never a search per placeholder. + */ +function buildStringAndUrlMultilinePlaceholderCase(count: number): TextCase { + return repeatMultiLineUnit( + count, + `content: "${MULTI_LINE_PLACEHOLDER}"; background: url(${MULTI_LINE_PLACEHOLDER}); `, + `content: "${SOLID_MULTI_LINE_FILL}"; background: url(${SOLID_MULTI_LINE_FILL}); `, + ) +} + +/** + * A multi-line placeholder inside a quoted string, then one at code as a mixin on the same line + * (whitespace, where a solid fill would be x's), repeated: solid and classified placeholders + * alternate, so the walk of the spans against the runs must keep its place between them instead of + * starting over per placeholder. + */ +function buildStringAndCodeMultilinePlaceholderCase(count: number): TextCase { + return repeatMultiLineUnit( + count, + `content: "${MULTI_LINE_PLACEHOLDER}"; ${MULTI_LINE_PLACEHOLDER} `, + `content: "${SOLID_MULTI_LINE_FILL}"; ${' '.repeat(MULTI_LINE_PLACEHOLDER.length)} `, + ) +} + interface TextCheckCase { readonly build: (count: number) => TextCase readonly label: string @@ -506,6 +549,14 @@ const substitutionCases: readonly TextCheckCase[] = [ }, label: 'block-position placeholders, spans given in reverse order', }, + { + build: buildStringAndUrlMultilinePlaceholderCase, + label: 'multi-line placeholders inside strings and url() arguments', + }, + { + build: buildStringAndCodeMultilinePlaceholderCase, + label: 'multi-line placeholders alternating between strings and code', + }, ] /** Each case is timed through replaceJavaScriptEscapes with its text; spans are unused. */ @@ -751,6 +802,33 @@ function defineCompletionCheck({ }) } +/** + * `ruleCount` rules, each holding a multi-line placeholder as a margin value, then `color:` on its + * own last line. Every placeholder loses its own line breaks in the substituted virtual document + * (docs/architecture.md, substitution invariants), unlike createLargeTemplate's completion cases + * above, which hold no placeholder at all: withTemplateLineBreaks must realign many lines here, so + * its early return (every template line already ends in "\n") never applies, unlike those cases. + */ +function createManyMultilinePlaceholdersTemplate(ruleCount: number): { + spans: TemplateSpan[] + text: string +} { + const unit = `.rule- { margin: ${MULTI_LINE_PLACEHOLDER}; color: red; }\n` + const text = unit.repeat(ruleCount) + 'color:' + return { + spans: offsetsOf(text, MULTI_LINE_PLACEHOLDER).map((start) => ({ + end: start + MULTI_LINE_PLACEHOLDER.length, + start, + })), + text, + } +} + +const manyMultilinePlaceholdersContextForSize = cachedBySize((size) => { + const { spans, text } = createManyMultilinePlaceholdersTemplate(size) + return createTemplateContext(text, spans) +}) + /** Inside the first rule's selector `.rule-0` of a diagnostics template. */ const HOVER_POSITION: ts.LineAndCharacter = { character: 1, line: 0 } const HOVERED_SELECTOR = '.rule-0' @@ -820,6 +898,21 @@ const checks: readonly ScalingCheck[] = [ }, }), ...completionCases.map(defineCompletionCheck), + defineCheck({ + n: COMPLETIONS_CHECK_N, + name: 'completions on a large template with many multi-line placeholders', + run: (size) => completionsAtEnd(manyMultilinePlaceholdersContextForSize(size)), + verify(completions) { + const names = entryNames(completions) + if (names.includes('red')) { + return undefined + } + return { + expected: 'a "red" entry among the color value completions', + received: `${names.length} entries, first ${JSON.stringify(names.slice(0, 5))}`, + } + }, + }), defineCheck({ n: DIAGNOSTICS_TEMPLATE_CHECK_N, name: 'code fixes', diff --git a/test/unit/completions.test.ts b/test/unit/completions.test.ts new file mode 100644 index 0000000..ed953b9 --- /dev/null +++ b/test/unit/completions.test.ts @@ -0,0 +1,75 @@ +import type { TemplateContext } from 'typescript-template-language-service-decorator' +import * as ts from 'typescript/lib/tsserverlibrary.js' +import { assert, describe, it } from 'vitest' + +import { withTemplateLineBreaks } from '../../src/features/completions' +import { getTemplateSubstitutions } from '../../src/template/template-substitutions' +import { LINE_SEPARATOR } from '../../src/virtual-document/css-code-scanner' +import { StyledVirtualDocumentProvider } from '../../src/virtual-document/styled-virtual-document-provider' +import { createTemplateLineMap } from '../../src/virtual-document/template-line-map' +import { createTemplateContext } from './create-template-context' + +describe('withTemplateLineBreaks', () => { + /** + * A view whose line-shaped reads (getLineRange, lineCount) came from the un-aligned document + * while getText and position mapping came from the aligned text would disagree with itself: this + * pins the real invariant instead (docs/architecture.md, Completions, "Emmet line view"). + */ + it.each([ + ['a multi-line placeholder', 'margin: ${\n a\n}; color: #12'], + ['a lone carriage return', 'color: red;\rcolor: #12'], + ['a line separator inside a string', `content: "a${LINE_SEPARATOR}b"; color: #12`], + ])( + 'gives a view whose length, positions, and line ranges agree with each other, for %s', + (_description, rawText) => { + const context = createSubstitutingContext(rawText) + const provider = new StyledVirtualDocumentProvider(ts) + const document = provider.createVirtualDocument(context) + const lineMap = createTemplateLineMap(context) + const templateStart = provider.toVirtualDocOffset(0, context) + + const view = withTemplateLineBreaks(document, lineMap, templateStart) + + assert.notStrictEqual(view, document, 'each row needs a realigned view, not the early return') + assert.strictEqual(view.getText().length, document.getText().length) + + for (let offset = 0; offset <= rawText.length; offset++) { + const virtualPosition = provider.toVirtualDocPosition(context.toPosition(offset)) + assert.strictEqual( + view.offsetAt(virtualPosition), + document.offsetAt(virtualPosition), + `template offset ${offset}`, + ) + } + + /** The lines Emmet's getCurrentLine sees; getLineRange stops before a "\r" of a "\r\n" too. */ + const emmetLines = view.getText().split('\n') + for (let line = 0; line < view.lineCount; line++) { + assert.strictEqual( + view.getText(view.getLineRange(line)), + emmetLines[line].replace(/\r$/, ''), + `line ${line}`, + ) + } + }, + ) +}) + +/** `rawText` with every `${...}` placeholder actually substituted, unlike create-template-context's fixed `text`. */ +function createSubstitutingContext(rawText: string): TemplateContext { + const base = createTemplateContext(rawText, 'styled.div') + const spans = Array.from(rawText.matchAll(/\$\{[^}]*\}/g), (match) => ({ + end: match.index + match[0].length, + start: match.index, + })) + let text: string | undefined + return { + ...base, + get text() { + if (text === undefined) { + text = spans.length > 0 ? getTemplateSubstitutions(rawText, spans) : rawText + } + return text + }, + } +} diff --git a/test/unit/template-language-service.test.ts b/test/unit/template-language-service.test.ts index 9903882..eccab52 100644 --- a/test/unit/template-language-service.test.ts +++ b/test/unit/template-language-service.test.ts @@ -26,6 +26,7 @@ import { import { StyledTemplateLanguageService } from '../../src/template-language-service' import { getTemplateSubstitutions } from '../../src/template/template-substitutions' import { pluginIdentity } from '../../src/tsserver/plugin-identity' +import { LINE_SEPARATOR } from '../../src/virtual-document/css-code-scanner' import { StyledVirtualDocumentProvider, VirtualDocumentProvider, @@ -548,6 +549,28 @@ describe('StyledTemplateLanguageService', () => { }, ) + /** + * Emmet reads the caret's line from the document text between "\n" characters, while the + * caret's position follows the template's own lines (docs/architecture.md, Completions). + */ + it.each([ + ['the last line of a multi-line placeholder', 'margin: ${\n a\n}; color: #12'], + ['a line started by a lone carriage return', 'color: red;\rcolor: #12'], + [ + 'a line started by a line separator inside a string', + `content: "a${LINE_SEPARATOR}b"; color: #12`, + ], + ['a line started by a line continuation inside a string', 'content: "a\\\nb"; color: #12'], + ])('should offer an Emmet value expansion on %s', (_description, text) => { + const context = createSubstitutingContext(text, 'styled.div', { count: 0 }) + + const names = createService() + .getCompletionsAtPosition(context, context.toPosition(text.length)) + .entries.map((entry) => entry.name) + + assert.include(names, '#121212') + }) + it.each([ [ 'after a selector colon, with a rule body after it', @@ -1138,6 +1161,100 @@ describe('StyledTemplateLanguageService', () => { assert.strictEqual(diagnostics[0]?.length, 1) }) + it('should report no false errors for a placeholder spanning a line break inside an unquoted url() argument (#13), against the real CSS language service', () => { + /** + * getTemplateSubstitutions must not leave the placeholder's own line breaks in a url() token: + * a line break is not valid there, so the CSS service would otherwise report "), semi-colon, + * and rule-or-selector" errors past it, the exact multi-line url() shape a comment on issue + * #13 reports (the issue itself reports a plain multi-line property value). + */ + const text = [ + '&.editField {', + ' background-image: url(${({ theme }) =>', + ' base_icon_URL +', + ' theme.tintedGlassHex(theme.foreground, theme.background).slice(1) +', + " '/pencil--v1.png'});", + ' }', + ' colr: red;', + ].join('\n') + + assertOnlyColrControl(text) + }) + + it.each([ + ['a quoted url() argument', 'background: url("${\n p.image\n}"); colr: red;'], + ['a double-quoted string', 'content: "${\n p.label\n}"; colr: red;'], + ['a single-quoted string', "content: '${\n p.label\n}'; colr: red;"], + [ + 'a string followed by a later value placeholder', + 'content: "${\n p.label\n}";\ncolor: ${c};\ncolr: red;', + ], + [ + 'a string followed by a later block mixin', + 'content: "${\n p.label\n}";\n${mixin}\ncolr: red;', + ], + [ + 'a string followed by a mixin on the same line', + 'content: "${\n p.label\n}"; ${mixin}\ncolr: red;', + ], + ['a string holding two placeholders', 'content: "${\n a\n} ${\n b\n}";\ncolr: red;'], + ['a string followed by another string', 'content: "${\n a\n}" "${\n b\n}";\ncolr: red;'], + ['a line comment', '// ${\n a\n} note\ncolr: red;'], + ])( + 'should report only the colr control for a multi-line placeholder in %s (#13), against the real CSS language service', + (_description, text) => { + assertOnlyColrControl(text) + }, + ) + + it.each([ + ['a value, with the control on its last line', 'color: ${\n a\n}; colr: red;'], + ['a url() argument after "#"', 'fill: url(#${\n id\n});\ncolr: red;'], + [ + 'a url() argument after "#", with a longer expression', + 'fill: url(#${\n props.gradientId\n});\ncolr: red;', + ], + [ + 'a url() argument after "#" with text after the placeholder', + 'fill: url(#${id}-grad);\ncolr: red;', + ], + [ + 'a url() argument after "#" with a longer expression and text after the placeholder', + 'mask: url(#${p => p.id}_mask);\ncolr: red;', + ], + ['a url() argument after its own line break', 'background: url(\n ${\n a\n}\n);\ncolr: red;'], + ['a url() argument that starts a value line', 'background:\nurl(\n ${\n a\n});\ncolr: red;'], + [ + 'a string followed by a multi-line mixin on the same line', + 'content: "${\n a\n}"; ${\n mixin\n};\ncolr: red;', + ], + [ + 'an @media prelude', + '@media ${({ theme }) =>\n theme.breakpoints.md} {\n color: red;\n}\ncolr: red;', + ], + [ + 'an @media feature value', + '@media (min-width: ${({ theme }) =>\n theme.md}px) {\n color: red;\n}\ncolr: red;', + ], + [ + 'an @media prelude before "and"', + '@media ${\n a\n} and (min-width: 1px) {\n color: red;\n}\ncolr: red;', + ], + ['an @keyframes name', '@keyframes ${\n a\n} {\n from { color: red; }\n}\ncolr: red;'], + ['an @container prelude', '@container ${\n a\n} {\n color: red;\n}\ncolr: red;'], + ['an @layer statement', '@layer ${\n a\n};\ncolr: red;'], + [ + 'a property name', + '${({ theme }) =>\n theme.rtl ? "margin-left" : "margin-right"}: 4px;\ncolr: red;', + ], + ['a custom property name', '--v-${\n a\n}: 1px;\ncolr: red;'], + ])( + 'should report only the colr control for a placeholder in %s, against the real CSS language service', + (_description, text) => { + assertOnlyColrControl(text) + }, + ) + it.each([ ['a CSS-escaped quote, which opens no string', 'content: \\\\"x; }'], ['a CSS-escaped "/", which opens no comment', 'content: a\\\\//x; }'], @@ -2619,6 +2736,21 @@ function createSubstitutingContext( } } +/** + * Asserts that the real CSS language service reports exactly the misspelled `colr` property: the + * positive control that the rest of the template was still validated, at its template offset. + */ +function assertOnlyColrControl(text: string) { + const context = createSubstitutingContext(text, 'styled.div', { count: 0 }) + + const diagnostics = createService().getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics.map(({ length, messageText, start }) => ({ length, messageText, start })), + [{ length: 4, messageText: "Unknown property: 'colr'", start: text.indexOf('colr') }], + ) +} + function createService() { return new StyledTemplateLanguageService( ts, diff --git a/test/unit/template-substitutions.test.ts b/test/unit/template-substitutions.test.ts index 6ae79c9..8494366 100644 --- a/test/unit/template-substitutions.test.ts +++ b/test/unit/template-substitutions.test.ts @@ -110,54 +110,31 @@ describe('substituter', () => { ) }) - it('should preserve line breaks inside a multiline placeholder', () => { - const value = ['color: ${', ' color', '};'].join('\n') - - assert.deepEqual( - getTemplateSubstitutions(value, [ - { start: value.indexOf('${'), end: value.indexOf('}') + 1 }, - ]), - ['color: xx', 'xxxxxxx', 'x;'].join('\n'), - ) - }) - - it('should preserve CRLF line endings inside a multiline placeholder', () => { - const value = ['color: ${', ' color', '};'].join('\r\n') - - assert.deepEqual( - getTemplateSubstitutions(value, [ - { start: value.indexOf('${'), end: value.indexOf('}') + 1 }, - ]), - ['color: xx', 'xxxxxxx', 'x;'].join('\r\n'), - ) - }) - + /** + * At runtime the interpolated value holds none of the placeholder's source line breaks, so every + * fill writes over them too (docs/architecture.md, substitution invariants). + */ it.each([ - ['line separator', '\u2028'], - ['paragraph separator', '\u2029'], - ])('should preserve the Unicode %s inside a multiline placeholder', (_description, separator) => { - const value = `color: \${${separator} color${separator}};` - - const result = getTemplateSubstitutions(value, [ - { start: value.indexOf('${'), end: value.indexOf('}') + 1 }, - ]) + ['a line feed', '\n', 'color: xxxxxxxxxxxx;'], + ['a CRLF', '\r\n', 'color: xxxxxxxxxxxxxx;'], + ['a carriage return', '\r', 'color: xxxxxxxxxxxx;'], + ['a line separator', '\u2028', 'color: xxxxxxxxxxxx;'], + ['a paragraph separator', '\u2029', 'color: xxxxxxxxxxxx;'], + ])( + 'should fill %s inside a multi-line value placeholder with the fill character', + (_description, lineTerminator, expected) => { + const value = `color: \${${lineTerminator} color${lineTerminator}};` - assert.strictEqual(result, `color: xx${separator}xxxxxxx${separator}x;`) - assert.strictEqual(result.length, value.length) - }) + assert.strictEqual(performSubstitutions(value), expected) + }, + ) it('should substitute placeholders after a multiline placeholder in their own context', () => { const value = ['color: ${', ' color', '};', 'width: ${10}%;'].join('\n') - const firstStart = value.indexOf('${') - const firstEnd = value.indexOf('}') + 1 - const secondStart = value.indexOf('${', firstEnd) - assert.deepEqual( - getTemplateSubstitutions(value, [ - { start: firstStart, end: firstEnd }, - { start: secondStart, end: value.indexOf('}', secondStart) + 1 }, - ]), - ['color: xx', 'xxxxxxx', 'x;', 'width: 00000%;'].join('\n'), + assert.strictEqual( + performSubstitutions(value), + ['color: xxxxxxxxxxxx;', 'width: 00000%;'].join('\n'), ) }) @@ -197,10 +174,13 @@ describe('substituter', () => { ) }) - it('should preserve CRLF line endings for dynamic declaration names and values (#25)', () => { - const value = ['${varName}: ${', ' value', '};'].join('\r\n') + it('should keep the CRLF after a multi-line value placeholder following a dynamic declaration name (#25)', () => { + const value = ['${varName}: ${', ' value', '};', 'color: red;'].join('\r\n') - assert.deepEqual(performSubstitutions(value), ['$axxxxxxxx: xx', 'xxxxxxx', 'x;'].join('\r\n')) + assert.deepEqual( + performSubstitutions(value), + ['$axxxxxxxx: xxxxxxxxxxxxxx;', 'color: red;'].join('\r\n'), + ) }) it('should bound syntax masking to the template text', () => { @@ -340,28 +320,26 @@ describe('substituter', () => { ) }) - it('should keep every line terminator of a multi-line placeholder used as a mixin', () => { - /** The dummy declaration is written around the line break, which the parser reads as whitespace. */ + it('should write the dummy declaration of a multi-line mixin placeholder over its line breaks', () => { const value = ['${', ' m', '};'].join('\n') - assert.strictEqual(performSubstitutions(value), '$a\n:0 \n ;') + assert.strictEqual(performSubstitutions(value), '$a:0 ;') }) - it('should keep every line terminator of a multi-line placeholder used as a selector', () => { + it('should write the selector fill of a multi-line placeholder over its line breaks', () => { const value = ['${', ' B', '}:hover & {', ' color: red;', '}'].join('\n') assert.strictEqual( performSubstitutions(value), - ['& ', ' ', ' :hover & {', ' color: red;', '}'].join('\n'), + ['& :hover & {', ' color: red;', '}'].join('\n'), ) }) it.each([ - /** "000" stays on one line to read as a hex color, so it covers a line break among the first three characters. */ - ['right after "${"', ['color: #${', ' x', '};'], 'color: #000 \n ;'], - ['after the first line', ['color: #${x', ' }', ';'], 'color: #000\n \n;'], + ['right after "${"', ['color: #${', ' x', '};'], 'color: #000 ;'], + ['after the first line', ['color: #${x', ' }', ';'], 'color: #000 \n;'], ])( - 'should keep the line terminators of a multi-line hex color placeholder broken %s past its "000"', + 'should write the hex color fill of a multi-line placeholder broken %s over its line breaks', (_description, lines, expected) => { assert.strictEqual(performSubstitutions(lines.join('\n')), expected) }, @@ -624,10 +602,10 @@ describe('substituter', () => { assert.strictEqual(performSubstitutions(value), '#{x} b: 1px;') }) - it('should keep the line terminators of a multi-line placeholder joined to a property name', () => { + it('should write the Sass interpolation of a multi-line placeholder joined to a property name over its line breaks', () => { const value = 'padding-${\n s\n}: 4px;' - assert.strictEqual(performSubstitutions(value), 'padding-#{\nx \n}: 4px;') + assert.strictEqual(performSubstitutions(value), 'padding-#{x }: 4px;') }) it('should x-fill a placeholder joined to a custom property name, before or after', () => { @@ -725,10 +703,10 @@ describe('substituter', () => { }, ) - it('should keep the line terminators of a multi-line at-rule condition placeholder', () => { + it('should write the condition fill of a multi-line at-rule condition placeholder over its line breaks', () => { const value = '@media screen and ${\n q\n} { color: red; }' - assert.strictEqual(performSubstitutions(value), '@media screen and (x\n \n) { color: red; }') + assert.strictEqual(performSubstitutions(value), '@media screen and (x ) { color: red; }') }) it.each([ @@ -1033,11 +1011,14 @@ describe('substituter', () => { ) it.each([ - ['an unterminated block comment', 'a: 0 /* \n ${m}', 'a: 0 /* \n '], - ['an unterminated block comment, after a ";" in it', 'a: 0 /* ;\n ${m}', 'a: 0 /* ;\n '], - ])('should start a new line at a line break inside %s', (_description, value, expected) => { - assert.strictEqual(performSubstitutions(value), expected) - }) + ['an unterminated block comment', 'a: 0 /* \n ${m}', 'a: 0 /* \n xxxx'], + ['an unterminated block comment, after a ";" in it', 'a: 0 /* ;\n ${m}', 'a: 0 /* ;\n xxxx'], + ])( + 'should fill a placeholder on its own line inside %s with x', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) it.each([ ['a double-quoted string holding "{"', '${A}: "a{b";', '$axx: "a{b";'], @@ -1166,6 +1147,93 @@ describe('substituter', () => { }, ) + /** + * At runtime the interpolated value holds none of the placeholder's source line breaks, so one + * that starts inside a comment, string, or unquoted url() argument fills its whole length with + * "x", line terminators included, in the masked text as in the output (docs/architecture.md, + * substitution invariants). + */ + it.each([ + [ + 'an unquoted url() argument, across a line feed', + 'background: url(${\n x\n});', + 'background: url(xxxxxxxx);', + ], + [ + 'an unquoted url() argument, across a CRLF', + 'background: url(${\r\n x\r\n});', + 'background: url(xxxxxxxxxx);', + ], + [ + 'an unquoted url() argument, across a line separator', + 'background: url(${\u2028 x\u2028});', + 'background: url(xxxxxxxx);', + ], + [ + 'an unquoted url() argument, across a paragraph separator', + 'background: url(${\u2029 x\u2029});', + 'background: url(xxxxxxxx);', + ], + [ + 'a url-prefix() argument', + 'background: url-prefix(${\n x\n});', + 'background: url-prefix(xxxxxxxx);', + ], + ['a url() argument after "#"', 'fill: url(#${\n id\n});', 'fill: url(#xxxxxxxxx);'], + ['a url() argument after "#", on one line', 'fill: url(#${id});', 'fill: url(#xxxxx);'], + [ + 'a url() argument after its own line break', + 'background: url(\n ${\n x\n}\n);', + 'background: url(\n xxxxxxxx\n);', + ], + ['a quoted url() argument', 'background: url("${\n x\n}");', 'background: url("xxxxxxxx");'], + ])( + 'should fill a placeholder inside %s with x over its whole length (#13)', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) + + it.each([ + ['a double-quoted string', 'content: "${\n x\n}";', 'content: "xxxxxxxx";'], + ['a single-quoted string', "content: '${\n x\n}';", "content: 'xxxxxxxx';"], + [ + 'a string holding two placeholders', + 'content: "${\n a\n} ${\n b\n}";', + 'content: "xxxxxxxx xxxxxxxx";', + ], + [ + 'a string before a mixin on its line, which stays a mixin', + 'content: "${\n a\n}"; ${mixin}\ncolor: red;', + 'content: "xxxxxxxx"; \ncolor: red;', + ], + ['a line comment', '// ${\n a\n} note\ncolor: red;', '// xxxxxxxx note\ncolor: red;'], + ['a block comment', '/* ${\n x\n} */\ncolor: red;', '/* xxxxxxxx */\ncolor: red;'], + ])( + 'should fill a placeholder inside %s with x over its whole length', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) + + it.each([ + [ + 'an escaped run, whose backslash may escape the placeholder itself', + '\\${\n p\n}: 1px;', + '\\$axxxxxx: 1px;', + ], + [ + 'code, on a line of its own', + 'a: b;\n${\n m\n}\ncolor: red;', + 'a: b;\n \ncolor: red;', + ], + ])( + 'should give a placeholder in %s its usual fill, not the solid fill', + (_description, value, expected) => { + assert.strictEqual(performSubstitutions(value), expected) + }, + ) + it('should replace placeholder used in child selector (#75)', () => { assert.deepEqual( performSubstitutions(