Skip to content
Merged
2 changes: 1 addition & 1 deletion .changeset/emmet-and-completion-placement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions .changeset/multiline-interpolations.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .changeset/supported-hosts-and-typescript-floor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 5 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions scripts/compare-release-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }`',
Expand Down
Loading