diff --git a/.changeset/quiet-rules-interpolate.md b/.changeset/quiet-rules-interpolate.md new file mode 100644 index 0000000..b9b3d71 --- /dev/null +++ b/.changeset/quiet-rules-interpolate.md @@ -0,0 +1,5 @@ +--- +'@styled/typescript-styled-plugin': patch +--- + +Avoid reporting styled rules whose bodies contain a template interpolation as empty while preserving diagnostics for genuinely empty rules. diff --git a/README.md b/README.md index a9abf3b..4463c3e 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,20 @@ properties, and Emmet completions. ## Editor Integration +The plugin can be loaded in two ways: + +- **Project-local:** Install it in the project and add it to + `compilerOptions.plugins` in `tsconfig.json`. +- **Editor-global:** Install or bundle it once, then configure the editor's + TypeScript host to load it for every project. This adds no dependency or + configuration to each project. + +The available loading modes depend on the editor integration below. + ### With VS Code +**Loading mode: editor-global, with a project-local alternative.** + Install the [VS Code Styled Components extension](https://github.com/styled-components/vscode-styled-components). It bundles this plugin and works with VS Code's bundled TypeScript version without installing anything else. The extension depends on `^1.0.0`, so a new @@ -78,6 +90,8 @@ use. ### With Sublime Text +**Loading mode: project-local.** + This plugin works with the [Sublime TypeScript plugin](https://github.com/Microsoft/TypeScript-Sublime-Plugin). Complete the [Quick Start](#quick-start), then point Sublime at the workspace TypeScript version by setting @@ -94,6 +108,8 @@ its bundled Node runtime; that runtime must meet the requirements above. ### With Neovim +**Loading mode: editor-global, with a project-local alternative.** + Neovim talks to tsserver through a language server that wraps it. Two of them load tsserver plugins without a `plugins` entry in `tsconfig.json`: install the plugin globally, then point the server at npm's global folder, which @@ -147,8 +163,70 @@ have the server use the workspace TypeScript version (for vtsls, set `vtsls.autoUseWorkspaceTsdk` to `true`). The tsserver host must meet the requirements above. +### With Helix + +**Loading mode: editor-global, with a project-local alternative.** + +Helix uses +[typescript-language-server](https://github.com/typescript-language-server/typescript-language-server) +for TypeScript and JavaScript by default. Install the server, TypeScript, and +this plugin globally, then print npm's global package folder: + +```bash +npm install --global typescript-language-server typescript@6 @styled/typescript-styled-plugin +npm root -g +``` + +Add the following to `~/.config/helix/languages.toml`, replacing +`/usr/local/lib/node_modules` with the exact output of `npm root -g`: + +```toml +[language-server.typescript-language-server.config] +hostInfo = "helix" + +[[language-server.typescript-language-server.config.plugins]] +name = "@styled/typescript-styled-plugin" +location = "/usr/local/lib/node_modules" + +[language-server.typescript-language-server.config.typescript.inlayHints] +includeInlayEnumMemberValueHints = true +includeInlayFunctionLikeReturnTypeHints = true +includeInlayFunctionParameterTypeHints = true +includeInlayParameterNameHints = "all" +includeInlayParameterNameHintsWhenArgumentMatchesName = true +includeInlayPropertyDeclarationTypeHints = true +includeInlayVariableTypeHints = true + +[language-server.typescript-language-server.config.javascript.inlayHints] +includeInlayEnumMemberValueHints = true +includeInlayFunctionLikeReturnTypeHints = true +includeInlayFunctionParameterTypeHints = true +includeInlayParameterNameHints = "all" +includeInlayParameterNameHintsWhenArgumentMatchesName = true +includeInlayPropertyDeclarationTypeHints = true +includeInlayVariableTypeHints = true +``` + +Keep the complete `config` table shown above so adding the plugin does not drop +Helix's built-in initialization options. For this global setup, use the exact +`npm root -g` output as `location`. Run `hx --health typescript` to confirm that +Helix finds `typescript-language-server`. The setup and host requirements were +verified with Helix 25.07.1 and `typescript-language-server` 6.0.1; see the +[host notes](docs/tsserver-host.md) for the version-specific details, and +validate them against the installed versions. + +To change [plugin settings](docs/usage.md), add the plugin's entry to +`compilerOptions.plugins` in `tsconfig.json` as in the +[Quick Start](#quick-start); the global plugin registration still supplies the +implementation. To use a copy installed in the project instead, install +`typescript-language-server` globally, then complete the Quick Start in the +project. No `plugins` entry in Helix's `languages.toml` is needed for this +project-local setup. + ### With Visual Studio +**Loading mode: project-local.** + This setup path requires validation against the installed Visual Studio TypeScript Server host and runtime. Complete the [Quick Start](#quick-start) in the project, then confirm Visual Studio loads the workspace TypeScript SDK. Its diff --git a/docs/architecture.md b/docs/architecture.md index d5a4491..1e777f5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,11 +100,12 @@ Spec for how the plugin turns a tsserver request inside a tagged template into a - Validation: the SCSS service's `doValidation` over the virtual document, intersected with the value reading for a single-identifier `css` fragment (the "Virtual document" section), memoized by `RawValidationCache` (the "Caching" section). With `validate: false`: no diagnostics and no code fixes. - Shown list (`DiagnosticsFeature.getShownDiagnostics`), the one list diagnostics and code fixes both read: each raw diagnostic mapped to template offsets under the diagnostics rule in "Mapping back to the template" (the "Virtual document" section: clamped at the closing wrapper, dropped inside the opening one, widened to whole escape runs), then the stray closing brace rule below. +- Empty rules: with the built-in virtual-document provider, an `emptyRules` lint diagnostic is dropped when the rule body contains a template interpolation. The interpolation may produce declarations at runtime, so the plugin cannot prove that rule empty. The diagnostic stays for a genuinely empty body, including when an interpolation appears only in the selector. Rule-body braces are found in the template's CSS text after JavaScript escapes are cooked, so a JavaScript escape can contribute a structural brace. The boundary scanner ignores braces in comments, strings, CSS escapes, and unquoted `url(...)` arguments; an unclosed body extends to the template end while the user is editing. A custom provider owns its document's structure, so its diagnostics are not filtered against the template text. - Translation: the diagnostic's own `code` when numeric, `CSS_DIAGNOSTIC_CODE` (9999) otherwise; severity Error or none as Error, Warning as Warning, Information and Hint as Message; `source` is the plugin identity. - Stray closing brace: `css-ruleorselectorexpected` ("at-rule or selector expected") at the template end reports the wrapper's own closing brace, which the template's content closed early. - Causes: a stray `}`, or an unattached `;`. An empty template reports nothing. - Always kept: never dropped because another diagnostic exists in the template (an unrelated lint warning, or one from the brace's own cascade). - - Re-anchored to the stray `}`: the first `}` in the template's CSS text (`getTemplateCssText`) with no matching opener, read with the boundary scanner, so a `}` in a string, comment, escape, or unquoted `url(...)` argument does not count; no placeholder fill holds a brace. The span is that one character. + - Re-anchored to the stray `}`: the first `}` in the template's CSS text (`getTemplateCssText`) with no matching opener, read after JavaScript escapes are cooked, so a JavaScript escape can contribute a structural brace. The boundary scanner ignores a `}` in a string, comment, CSS escape, or unquoted `url(...)` argument; no placeholder fill holds a brace. The span is that one character. - No stray `}` (an unattached `;`): stays at the template end, zero length. - The same code anywhere else in the template is a real diagnostic and stays where reported. Other end-of-template diagnostics (`} expected`, `property value expected`) carry other codes and stay. diff --git a/docs/tsserver-host.md b/docs/tsserver-host.md index 213d35f..53aaaf4 100644 --- a/docs/tsserver-host.md +++ b/docs/tsserver-host.md @@ -1,6 +1,6 @@ # tsserver host knowledge -Verified facts about the environment this plugin runs in. Dated 2026-09-26; each item names the version it was checked against. Re-verify before relying on an item older than a few months. +Verified facts about the environment this plugin runs in. Updated 2026-09-28; each item names the version it was checked against. Re-verify before relying on an item older than a few months. ## How tsserver loads a plugin @@ -10,13 +10,14 @@ Verified facts about the environment this plugin runs in. Dated 2026-09-26; each - `typesVersions` (`package.json`) applies to this same classic-resolution code path, not only to a consumer's own type lookup: `tryResolveJSModuleWorker` shares the "resolve `main` or a subpath" logic with the types resolver, so a `typesVersions` entry keyed on the same relative path as `main` (here, `lib/index` or `lib/index.js`) redirects the plugin loader's own runtime `require()` to the declaration file instead of the implementation, which then throws `SyntaxError: Cannot use import statement outside a module` trying to execute it. Measured on TypeScript 5.0 by adding and removing such an entry against a real tsserver. A `typesVersions` entry for any OTHER subpath (one `main` itself never resolves to, such as `./api`, `./lib/api`, or `./lib/api.js` here) does not collide, since the plugin loader never requests those paths. - The configuration a plugin receives at creation (`PluginCreateInfo.config`) is the whole tsconfig `plugins` entry, `name` included. A plugin an editor loads globally (`--globalPlugins`, as VS Code does for an extension's `typescriptServerPlugins`) receives `{ name, global: true }`, and a `configurePlugin` payload that arrives before the project loads replaces the entry, with `name` set on it. A later `configurePlugin` payload reaches `onConfigurationChanged` as sent. Checked in TypeScript 5.0.4 (`lib/tsserver.js`) and 6.0.3 (`lib/typescript.js`): `enableGlobalPlugins`, `endEnablePlugin`, `enableProxy`, `onPluginConfigurationChanged`. - A plugin that fails to load or activate never surfaces to the user: the only trace is the tsserver log. An empty completion response from tsserver also reports `success: false`, so harnesses must read the log to distinguish "plugin skipped" from "no results". +- Helix plugin setup (Helix 25.07.1 and `typescript-language-server` 6.0.1, checked 2026-09-28): Helix merges `languages.toml` through three table levels, so a user-provided `language-server.typescript-language-server.config` replaces that server's built-in `config` table. A plugin example must repeat the built-in `hostInfo` and TypeScript/JavaScript inlay-hint settings to preserve them. `typescript-language-server` passes each initialization plugin's `location` directly to tsserver through `--pluginProbeLocations`; its accepted `location` forms resolve under the classic Node10 rules above. ## TypeScript versions (2026-09-26) - `typescript@latest` on npm is 7.0.2, the native compiler. The package ships no `tsserver` and has no plugin API; VS Code's TypeScript 7 mode does not load `typescriptServerPlugins`. Plugins run only on TypeScript 5.x and 6.x hosts; an older host can still load the plugin module itself without erroring, since this plugin's own version gate (below) returns the host's language service untouched rather than activating. - VS Code stable bundles TypeScript 6 (`npm:@typescript/typescript6`). - Floor for this plugin: TypeScript 5.0 (`major >= 5`, `isSupportedTypeScriptVersion`, `src/tsserver/tsserver-plugin.ts`). 1.0.1 gates on `major >= 3`, but `typescript-template-language-service-decorator` 2.3.2 (the last release) binds `languageService.getSupportedCodeFixes` whenever the template service passed to it implements the same method, regardless of host version; that instance method exists on `ts.LanguageService` only from TypeScript 5.0 (confirmed via `ts.createLanguageService(...).getSupportedCodeFixes` returning `undefined` on 3.9, 4.0, 4.4, and 4.9, and a function from 5.0), so binding it on an older host throws `TypeError: Cannot read properties of undefined (reading 'bind')` before any feature activates. Reproduced against a real tsserver on TypeScript 3.9, 4.0, 4.4, and 4.9 with the published decorator's own logged message: "Plugin activation failed: TypeError: Cannot read properties of undefined (reading 'bind')". 1.0.1 crashes identically on these versions today, since a fresh install resolves the same final decorator release. This plugin's own gate never constructs the decorator below TypeScript 5.0, logging "Unsupported TypeScript version ... TypeScript 5.0 or newer required" and returning the host's own language service untouched instead of crashing there. -- Line terminators: `ts.computeLineStarts` starts a new line after `\r\n`, a lone `\n`, a lone `\r`, U+2028, and U+2029, and not after U+0085. Checked on TypeScript 4.9.5, 5.0.4, 5.9.3, and 6.0.3 (`computeLineStarts("a\r\nb\nc\rd
e
f\u0085g")` returns `[0,3,5,7,9,11]` on each). +- Line terminators: `ts.computeLineStarts` starts a new line after `\r\n`, a lone `\n`, a lone `\r`, U+2028, and U+2029, and not after U+0085. Checked on TypeScript 4.9.5, 5.0.4, 5.9.3, and 6.0.3 (`computeLineStarts("a\r\nb\nc\rd\u2028e\u2029f\u0085g")` returns `[0,3,5,7,9,11]` on each). - A declaration file using a string export name (`export { x as 'module.exports' }`) fails to parse (TS1003) on TypeScript 5.0 through 5.5; 5.6 accepts it. This package no longer emits that syntax anywhere (the root entry's declaration uses `export =`, parseable since TypeScript's earliest CommonJS-module support); kept here as the reason a future root-entry declaration should avoid the string-export-name form. ## Node runtime for the tsserver plugin and the `./api` subpath @@ -26,7 +27,7 @@ Verified facts about the environment this plugin runs in. Dated 2026-09-26; each - `./api` is a real dual build: `import` gets `lib/esm/api.mjs`, `require` gets `lib/esm/api.cjs`, a genuine CommonJS bundle. Measured with the packed tarball's `./api`, both `require()` and `import()` succeed on Node 14.21.3, 16.20.2, 18.20.5, 20.18.1, 22.12.0, and 24.11.0 (the same Node floor as the package root, with no `require(esm)` dependency). - `require()` of an ES module throws `ERR_REQUIRE_ASYNC_MODULE` if the module graph uses top-level `await`. Keep the `lib/esm/api.mjs` bundle free of it. - `export { value as 'module.exports' }` sets what `require()` of an ES module returns; named exports are then invisible to CommonJS callers. Not used by this package (see the TypeScript-versions note above); documented here because `require(esm)` interop depends on it. -- Host runtimes: VS Code and Cursor run tsserver on their Electron Node (24.x in 2026). `typescript-language-server` requires Node 22.22 or newer. `@vtsls/language-server` declares Node 18 or newer, so a vtsls user on an older system Node cannot load the plugin. `typescript.tsserver.nodePath` in VS Code substitutes the user's Node. +- Host runtimes: VS Code and Cursor run tsserver on their Electron Node (24.x in 2026). `typescript-language-server` 6.0.1 requires Node 22.22.2 or newer. `@vtsls/language-server` declares Node 18 or newer, so a vtsls user on an older system Node cannot load the plugin. `typescript.tsserver.nodePath` in VS Code substitutes the user's Node. ## Dependencies diff --git a/docs/usage.md b/docs/usage.md index ea58117..6bdb640 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -43,8 +43,8 @@ Add the plugin to the project `tsconfig.json` or `jsconfig.json`: ``` The editor must be configured to use that workspace TypeScript SDK, or, in -Neovim, load the plugin as a global plugin. VS Code, Sublime Text, Neovim, and -Visual Studio setup paths are described in the +Neovim or Helix, load the plugin as a global plugin. VS Code, Sublime Text, +Neovim, Helix, and Visual Studio setup paths are described in the [README](../README.md#editor-integration); each requires validation against the actual editor's tsserver host and Node runtime. @@ -206,7 +206,7 @@ settings do not cause a host failure. | `compatibleVendorPrefixes` | Missing related vendor-prefixed properties. | `ignore` | | `vendorPrefix` | Vendor-prefixed properties without a standard equivalent. | `warning` | | `duplicateProperties` | Duplicate style declarations. | `ignore` | -| `emptyRules` | Empty rulesets. | `ignore` | +| `emptyRules` | Provably empty rulesets; body interpolations are not reported. | `ignore` | | `importStatement` | `@import` statements. | `ignore` | | `boxModel` | Width or height used with padding or borders. | `ignore` | | `universalSelector` | Universal selectors. | `ignore` | diff --git a/src/features/css-diagnostic-code.ts b/src/features/css-diagnostic-code.ts index 7279af4..d2e8f2b 100644 --- a/src/features/css-diagnostic-code.ts +++ b/src/features/css-diagnostic-code.ts @@ -1,5 +1,8 @@ export const CSS_DIAGNOSTIC_CODE = 9999 +/** vscode-css-languageservice's stable lint-rule id for an empty ruleset. */ +export const EMPTY_RULESET_DIAGNOSTIC_CODE = 'emptyRules' + /** * vscode-css-languageservice's stable parse-error id for "at-rule or selector expected" * (ParseError.RuleOrSelectorExpected, cssErrors.ts). At the template end it reports the wrapper's diff --git a/src/features/diagnostics.ts b/src/features/diagnostics.ts index 69de72c..d3cf558 100644 --- a/src/features/diagnostics.ts +++ b/src/features/diagnostics.ts @@ -19,6 +19,7 @@ import { import type { VirtualDocumentSessionProvider } from '../virtual-document/virtual-document-session-provider.ts' import { CSS_DIAGNOSTIC_CODE, + EMPTY_RULESET_DIAGNOSTIC_CODE, RULE_OR_SELECTOR_EXPECTED_DIAGNOSTIC_CODE, } from './css-diagnostic-code.ts' import type { ScssLanguageService } from './styles-language-services.ts' @@ -164,6 +165,15 @@ export class DiagnosticsFeature { valueDiagnostics.some((other) => isSameDiagnostic(diagnostic, other)), ) } + if (styledProvider) { + diagnostics = filterEmptyRulesWithInterpolations( + this.typescript, + context, + diagnostics, + lineMap, + styledProvider, + ) + } if (cacheKey !== undefined) { this.validationCache.set(cacheKey, diagnostics) } @@ -171,6 +181,131 @@ export class DiagnosticsFeature { } } +interface RuleBody { + readonly end: number + readonly start: number +} + +interface OpenRuleBody { + end?: number + readonly start: number +} + +/** + * Drops empty-rules lint findings for rules whose bodies hold a template interpolation: + * styled-components can turn that interpolation into declarations at runtime, so the rule is not + * provably empty. The filter runs before validation caching, preserving cache-hit laziness. + */ +function filterEmptyRulesWithInterpolations( + typescript: typeof ts, + context: TemplateContext, + diagnostics: Diagnostic[], + lineMap: TemplateLineMap, + virtualDocumentProvider: VirtualDocumentProvider, +): Diagnostic[] { + if (!diagnostics.some(({ code }) => code === EMPTY_RULESET_DIAGNOSTIC_CODE)) { + return diagnostics + } + const interpolationSpans = getInterpolationSpans(typescript, context) + if (interpolationSpans.length === 0) { + return diagnostics + } + const cssText = getTemplateCssText(context) + const ruleBodies = findRuleBodies(cssText) + return diagnostics.filter((diagnostic) => { + if (diagnostic.code !== EMPTY_RULESET_DIAGNOSTIC_CODE) { + return true + } + const selectorSpan = fromVirtualDocSpan(virtualDocumentProvider, diagnostic.range, lineMap) + const body = selectorSpan && findNextRuleBody(ruleBodies, selectorSpan.end) + if (!body) { + return true + } + return !containsInterpolation(body, interpolationSpans) + }) +} + +function getInterpolationSpans( + typescript: typeof ts, + context: TemplateContext, +): readonly TemplateSpan[] { + const { node } = context + if (!typescript.isTemplateExpression(node)) { + return [] + } + const templateStart = node.getStart() + 1 + let start = node.head.end - templateStart - 2 + return node.templateSpans.map(({ literal }) => { + const end = literal.getStart() - templateStart + 1 + const span = { end, start } + start = literal.getEnd() - templateStart - 2 + return span + }) +} + +function findRuleBodies(text: string): readonly RuleBody[] { + const state = createCssCodeScanState() + const bodies: OpenRuleBody[] = [] + const openBodies: OpenRuleBody[] = [] + for (let index = 0; index < text.length;) { + const end = nonCodeEnd(text, index, state) + if (end !== -1) { + index = end + continue + } + const character = text[index] + if (!state.url) { + if (character === '{') { + const body = { start: index + 1 } + bodies.push(body) + openBodies.push(body) + } else if (character === '}') { + const body = openBodies.pop() + if (body) { + body.end = index + } + } + } + index++ + } + for (const body of openBodies) { + body.end = text.length + } + return bodies.filter((body): body is RuleBody => body.end !== undefined) +} + +function findNextRuleBody(bodies: readonly RuleBody[], selectorEnd: number): RuleBody | undefined { + let low = 0 + let high = bodies.length + while (low < high) { + const middle = (low + high) >>> 1 + if (bodies[middle].start <= selectorEnd) { + low = middle + 1 + } else { + high = middle + } + } + return bodies[low] +} + +function containsInterpolation( + body: RuleBody, + interpolationSpans: readonly TemplateSpan[], +): boolean { + let low = 0 + let high = interpolationSpans.length + while (low < high) { + const middle = (low + high) >>> 1 + if (interpolationSpans[middle].start < body.start) { + low = middle + 1 + } else { + high = middle + } + } + const interpolation = interpolationSpans[low] + return interpolation !== undefined && interpolation.end <= body.end +} + /** * The stray closing brace (docs/architecture.md, "Diagnostics"): the first "}" in the template's CSS * text with no matching opener, read with the boundary scanner, or undefined when every "}" is diff --git a/test/e2e/scenarios/plugin-lifecycle.test.ts b/test/e2e/scenarios/plugin-lifecycle.test.ts index a7ce2ac..763e6d5 100644 --- a/test/e2e/scenarios/plugin-lifecycle.test.ts +++ b/test/e2e/scenarios/plugin-lifecycle.test.ts @@ -186,6 +186,25 @@ describe.concurrent('Plugin lifecycle', () => { ]) }) + it('should not report an empty ruleset when its content is an interpolation (#4)', async (context) => { + const server = startServer(context) + const source = mark( + [ + 'declare const mixin: string;', + 'const interpolated = css`⟨interpolated⟩a⟨/interpolated⟩ { ${mixin} }`;', + 'const empty = css`⟨empty⟩b⟨/empty⟩ {}`;', + ].join('\n'), + ) + const file = server.open(source.text) + + await server.configurePlugin({ lint: { emptyRules: 'error' } }) + const diagnostics = pluginDiagnostics(await server.request('semanticDiagnosticsSync', { file })) + + assert.deepEqual(spansAndText(diagnostics), [ + { ...source.range('empty'), text: 'Do not use empty rulesets' }, + ]) + }) + it('should apply and reset valid CSS properties through plugin configuration', async (context) => { const server = startServer(context) const source = mark('const q = css`⟨property⟩brand-tone⟨/property⟩: red;`') diff --git a/test/performance/scaling-check-cli.ts b/test/performance/scaling-check-cli.ts index b864d96..714b26c 100644 --- a/test/performance/scaling-check-cli.ts +++ b/test/performance/scaling-check-cli.ts @@ -29,6 +29,18 @@ export interface NamedCheck { readonly name: string } +/** + * Keeps the smallest finite positive CPU-time sample. A coarse clock may report zero for a short + * run; zero represents "not measured yet" until a later sample is usable. A size whose every + * sample is unusable therefore remains zero and is reported as a broken probe. + */ +export function selectMinimumPositiveSample(currentMs: number, sampleMs: number): number { + if (Number.isFinite(sampleMs) && sampleMs > 0) { + return currentMs > 0 ? Math.min(currentMs, sampleMs) : sampleMs + } + return currentMs === Infinity ? 0 : currentMs +} + export interface SelectedCheck { readonly check: Check /** The check's position in `allChecks`, unchanged by filtering: a worker loads the same full list and looks the check up by this index. */ diff --git a/test/performance/scaling-check.ts b/test/performance/scaling-check.ts index ff88458..870f85d 100644 --- a/test/performance/scaling-check.ts +++ b/test/performance/scaling-check.ts @@ -35,6 +35,7 @@ import { createProgressLocationTracker, parseFilterArgument, selectChecks, + selectMinimumPositiveSample, type StageOrLineProgress, } from './scaling-check-cli.ts' import { @@ -50,12 +51,12 @@ import { /** * Guardrail against superlinear regressions in a hot operation. Times each check at N and 4N, - * taking the minimum of RUNS_PER_SIZE runs per size to filter scheduler and GC noise, then fails - * when time(4N)/time(N) exceeds SCALING_THRESHOLD. At 4x the input a linear operation's ratio lands - * near 4 (its fixed per-call cost pulls it lower) and a quadratic one's near 16. Every check's N is - * sized so a quadratic regression injected into its path lands at 10 or more (SUBSTITUTION_CHECK_N - * and the sizes after it), so 7 gives a linear operation's noisy readings 75% headroom above 4 while - * staying clearly under where a real regression lands. + * taking the minimum positive sample from RUNS_PER_SIZE runs per size to filter scheduler, GC, and + * coarse-clock noise, then fails when time(4N)/time(N) exceeds SCALING_THRESHOLD. At 4x the input a + * linear operation's ratio lands near 4 (its fixed per-call cost pulls it lower) and a quadratic + * one's near 16. Every check's N is sized so a quadratic regression injected into its path lands at + * 10 or more (SUBSTITUTION_CHECK_N and the sizes after it), so 7 gives a linear operation's noisy + * readings 75% headroom above 4 while staying clearly under where a real regression lands. */ const SCALING_THRESHOLD = 7 /** @@ -160,8 +161,8 @@ interface CheckResult { /** * Every timing is main-thread CPU time, not wall-clock time. With more runnable threads than cores, * the scheduler preempts the process every few milliseconds: a short N sample often runs - * uninterrupted while a 4N sample several times longer almost never does, so the minimum over - * several wall-clock samples inflates only the 4N side and pushes a linear operation past + * uninterrupted while a 4N sample several times longer almost never does, so the minimum positive + * sample over several wall-clock readings inflates only the 4N side and pushes a linear operation past * SCALING_THRESHOLD. Time spent preempted never counts as CPU time. */ function elapsedCpuMs(start: NodeJS.CpuUsage): number { @@ -666,9 +667,25 @@ function createLayerDiagnosticsTemplate(layerCount: number): string { ).join('\n') } +function createInterpolatedEmptyRulesContext(rulePairCount: number): TemplateContext { + const placeholder = '${mixin}' + const text = Array.from( + { length: rulePairCount }, + (_, index) => `.interpolated-${index} { ${placeholder} }\n.empty-${index} {}`, + ).join('\n') + return createTemplateContext( + text, + offsetsOf(text, placeholder).map((start) => ({ + end: start + placeholder.length, + start, + })), + ) +} + const MISSPELLED_PROPERTY = 'colr' const diagnosticsContextForSize = contextForSize(createDiagnosticsTemplate) const foldingContextForSize = contextForSize(createFoldingTemplate) +const interpolatedEmptyRulesContextForSize = cachedBySize(createInterpolatedEmptyRulesContext) interface DiagnosticsCheckCase { readonly context: (size: number) => TemplateContext @@ -858,6 +875,19 @@ const checks: readonly ScalingCheck[] = [ ), ), ...diagnosticsCases.map(defineDiagnosticsCheck), + defineCheck({ + n: DIAGNOSTICS_TEMPLATE_CHECK_N, + name: 'diagnostics (many interpolated and genuinely empty rules)', + run: (size) => + createTemplateLanguageService({ lint: { emptyRules: 'error' } }).getSemanticDiagnostics( + interpolatedEmptyRulesContextForSize(size), + ), + verify: (diagnostics, size) => + compareOffsets( + offsetsOf(interpolatedEmptyRulesContextForSize(size).rawText, '.empty-'), + diagnostics.map((diagnostic) => diagnostic.start), + ), + }), defineCheck({ n: FOLDING_CHECK_N, name: 'folding (many spans)', @@ -997,8 +1027,10 @@ interface AttemptTiming { } /** - * Alternates N and 4N samples, keeping each size's minimum and first mismatch (verification runs - * inside `measure`, after its clock stops). Alternating spreads a burst of machine load across both + * Alternates N and 4N samples, keeping each size's minimum positive sample and first mismatch + * (verification runs inside `measure`, after its clock stops). A coarse CPU clock can report zero + * for a short sample; zero is kept only until that size produces a positive reading, and an all-zero + * side still fails as an unmeasurable probe. Alternating spreads a burst of machine load across both * sides of the ratio; timing every N sample before every 4N sample lets one burst inflate a single * side. Reports each sample's size and attempt before taking it, so a check stopped at its deadline * or heap cap names where it was. @@ -1016,10 +1048,14 @@ function timeInterleaved( stage: `size ${timing.size}, run ${run} of ${RUNS_PER_SIZE}, ${attemptLabel}`, }) const measurement = check.measure(timing.size) - timing.bestMs = Math.min(timing.bestMs, measurement.elapsedMs) + timing.bestMs = selectMinimumPositiveSample(timing.bestMs, measurement.elapsedMs) timing.mismatch ??= measurement.mismatch } - if (run >= EARLY_EXIT_MIN_RUNS && at4N.bestMs > EARLY_EXIT_RATIO * atN.bestMs) { + if ( + run >= EARLY_EXIT_MIN_RUNS && + atN.bestMs > 0 && + at4N.bestMs > EARLY_EXIT_RATIO * atN.bestMs + ) { return { at4N, atN, runs: run } } } @@ -1054,9 +1090,10 @@ function runTimedCheck(check: ScalingCheck, reportProgress: ReportProgress): Che } } + const hasUnmeasurableTiming = atN.bestMs === 0 || at4N.bestMs === 0 const ratio = at4N.bestMs / atN.bestMs - const isSuperlinear = ratio > SCALING_THRESHOLD - const isImplausible = ratio < MIN_PLAUSIBLE_RATIO + const isSuperlinear = !hasUnmeasurableTiming && ratio > SCALING_THRESHOLD + const isImplausible = hasUnmeasurableTiming || ratio < MIN_PLAUSIBLE_RATIO const reading = `${check.name}: N=${check.n} ${atN.bestMs.toFixed(3)}ms, 4N=${at4N.size} ` + `${at4N.bestMs.toFixed(3)}ms, ratio=${ratio.toFixed(2)} (threshold ${SCALING_THRESHOLD}, ` + @@ -1078,7 +1115,13 @@ function runTimedCheck(check: ScalingCheck, reportProgress: ReportProgress): Che reportProgress({ line: `retry ${attempt + 1}/${RATIO_RETRY_COUNT} ${reading}: ` + - `${isSuperlinear ? 'above the threshold' : 'below the floor'}, measuring again`, + `${ + hasUnmeasurableTiming + ? 'one size produced no positive CPU-time sample' + : isSuperlinear + ? 'above the threshold' + : 'below the floor' + }, measuring again`, }) } } @@ -1396,11 +1439,12 @@ async function main() { if (outcomes.includes('probe-broken')) { console.error( 'Scaling check failed: at least one probe measured nothing, so its reading cannot be ' + - `trusted. Either a hot operation took less than ${MIN_PLAUSIBLE_RATIO}x as long at 4N as at ` + - `N on all ${RATIO_RETRY_COUNT + 1} attempts (the timed call is not doing work that grows ` + - 'with its input: a cache shared across service instances, or an early return; find what ' + - 'short-circuits it), or the retained-heap guardrail produced no valid reading or read ' + - 'below its floor (a validation cache that keeps nothing). The FAIL line above names which.', + 'trusted. Either a timed size produced no finite positive CPU-time sample, a hot operation ' + + `took less than ${MIN_PLAUSIBLE_RATIO}x as long at 4N as at N on all ` + + `${RATIO_RETRY_COUNT + 1} attempts (the timed call is not doing work that grows with its ` + + 'input: a cache shared across service instances, or an early return; find what short-circuits ' + + 'it), or the retained-heap guardrail produced no valid reading or read below its floor (a ' + + 'validation cache that keeps nothing). The FAIL line above names which.', ) } if (outcomes.includes('over-limit')) { diff --git a/test/performance/template-language-service-fixture.ts b/test/performance/template-language-service-fixture.ts index 18c23d7..7af218f 100644 --- a/test/performance/template-language-service-fixture.ts +++ b/test/performance/template-language-service-fixture.ts @@ -1,7 +1,10 @@ import type { TemplateContext } from 'typescript-template-language-service-decorator' import * as ts from 'typescript/lib/tsserverlibrary.js' -import { PluginConfigurationManager } from '../../src/configuration/plugin-configuration.ts' +import { + PluginConfigurationManager, + type StyledPluginConfigurationInput, +} from '../../src/configuration/plugin-configuration.ts' import { StyledTemplateLanguageService } from '../../src/template-language-service.ts' import { getTemplateSubstitutions } from '../../src/template/template-substitutions.ts' import { StyledVirtualDocumentProvider } from '../../src/virtual-document/styled-virtual-document-provider.ts' @@ -12,10 +15,16 @@ export interface TemplateSpan { readonly start: number } -export function createTemplateLanguageService(): StyledTemplateLanguageService { +export function createTemplateLanguageService( + configuration?: StyledPluginConfigurationInput, +): StyledTemplateLanguageService { + const configurationManager = new PluginConfigurationManager() + if (configuration !== undefined) { + configurationManager.updateFromPluginConfig(configuration) + } return new StyledTemplateLanguageService( ts, - new PluginConfigurationManager(), + configurationManager, new StyledVirtualDocumentProvider(ts), ) } diff --git a/test/unit/scaling-check-cli.test.ts b/test/unit/scaling-check-cli.test.ts index f93782c..1e57123 100644 --- a/test/unit/scaling-check-cli.test.ts +++ b/test/unit/scaling-check-cli.test.ts @@ -4,6 +4,7 @@ import { createProgressLocationTracker, parseFilterArgument, selectChecks, + selectMinimumPositiveSample, } from '../../test/performance/scaling-check-cli.ts' describe('parseFilterArgument', () => { @@ -65,6 +66,26 @@ describe('selectChecks', () => { }) }) +describe('selectMinimumPositiveSample', () => { + it('replaces an unusable first sample with the first finite positive sample', () => { + assert.strictEqual(selectMinimumPositiveSample(Infinity, 0), 0) + assert.strictEqual(selectMinimumPositiveSample(0, 4), 4) + }) + + it('keeps the smallest finite positive sample and ignores later unusable samples', () => { + assert.strictEqual(selectMinimumPositiveSample(4, 3), 3) + assert.strictEqual(selectMinimumPositiveSample(3, 0), 3) + assert.strictEqual(selectMinimumPositiveSample(3, Number.NaN), 3) + assert.strictEqual(selectMinimumPositiveSample(3, Infinity), 3) + }) + + it('leaves a size with no usable samples marked as unmeasurable', () => { + assert.strictEqual(selectMinimumPositiveSample(Infinity, Number.NaN), 0) + assert.strictEqual(selectMinimumPositiveSample(0, Infinity), 0) + assert.strictEqual(selectMinimumPositiveSample(0, -1), 0) + }) +}) + describe('createProgressLocationTracker', () => { it('reports "before its first sample" before any progress arrives', () => { const tracker = createProgressLocationTracker() diff --git a/test/unit/template-language-service.test.ts b/test/unit/template-language-service.test.ts index eccab52..6149fa4 100644 --- a/test/unit/template-language-service.test.ts +++ b/test/unit/template-language-service.test.ts @@ -26,7 +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 { LINE_SEPARATOR, PARAGRAPH_SEPARATOR } from '../../src/virtual-document/css-code-scanner' import { StyledVirtualDocumentProvider, VirtualDocumentProvider, @@ -878,6 +878,115 @@ describe('StyledTemplateLanguageService', () => { ) }) + it('should omit empty-rules diagnostics only for rules whose body contains an interpolation (#4)', () => { + const manager = new PluginConfigurationManager() + manager.updateFromPluginConfig({ lint: { emptyRules: 'error' } }) + const service = new StyledTemplateLanguageService( + ts, + manager, + new StyledVirtualDocumentProvider(ts), + ) + const text = [ + '${outside}', + '${selector} {}', + 'a { ${mixin} }', + 'b { /* } */ ${mixin} }', + 'multiline { ${() => {', + ' return mixin', + '}} }', + 'crlf { ${() => {\r\n return mixin\r\n}} }', + `unicode { ${'${() => {'}${LINE_SEPARATOR}return mixin${PARAGRAPH_SEPARATOR}}} }`, + 'c { content: "}"; }', + 'd {}', + ].join('\n') + const context = createSubstitutingContext(text, 'css', { count: 0 }) + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics.map(({ length, messageText, start }) => ({ length, messageText, start })), + [ + { + length: '${selector}'.length, + messageText: 'Do not use empty rulesets', + start: text.indexOf('${selector}'), + }, + { + length: 1, + messageText: 'Do not use empty rulesets', + start: text.indexOf('d {}'), + }, + ], + ) + }) + + it('should associate interpolations with the innermost empty rule body (#4)', () => { + const manager = new PluginConfigurationManager() + manager.updateFromPluginConfig({ lint: { emptyRules: 'error' } }) + const service = new StyledTemplateLanguageService( + ts, + manager, + new StyledVirtualDocumentProvider(ts), + ) + const text = 'a { b { ${mixin} } c {} }' + const context = createSubstitutingContext(text, 'css', { count: 0 }) + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics.map(({ length, messageText, start }) => ({ length, messageText, start })), + [ + { + length: 1, + messageText: 'Do not use empty rulesets', + start: text.indexOf('c {}'), + }, + ], + ) + }) + + it('should omit an empty-rules diagnostic for an unclosed rule body containing an interpolation (#4)', () => { + const manager = new PluginConfigurationManager() + manager.updateFromPluginConfig({ lint: { emptyRules: 'error' } }) + const service = new StyledTemplateLanguageService( + ts, + manager, + new StyledVirtualDocumentProvider(ts), + ) + const text = 'empty {}\nunclosed { ${mixin}' + const context = createSubstitutingContext(text, 'css', { count: 0 }) + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics + .filter(({ messageText }) => messageText === 'Do not use empty rulesets') + .map(({ length, start }) => ({ length, start })), + [{ length: 'empty'.length, start: text.indexOf('empty') }], + ) + assert.isTrue(diagnostics.some(({ messageText }) => messageText === '} expected')) + }) + + it('should preserve empty-rules diagnostics from a custom virtual-document provider (#4)', () => { + const manager = new PluginConfigurationManager() + manager.updateFromPluginConfig({ lint: { emptyRules: 'error' } }) + const provider: VirtualDocumentProvider = { + ...createCustomVirtualDocumentProvider('', ''), + createVirtualDocument() { + return TextDocument.create('untitled://custom.scss', 'scss', 1, 'a {}') + }, + } + const service = new StyledTemplateLanguageService(ts, manager, provider) + const context = createSubstitutingContext('a { ${mixin} }', 'css', { count: 0 }) + + const diagnostics = service.getSemanticDiagnostics(context) + + assert.deepEqual( + diagnostics.map(({ length, messageText, start }) => ({ length, messageText, start })), + [{ length: 1, messageText: 'Do not use empty rulesets', start: 0 }], + ) + }) + it('should keep an end-of-template "at-rule or selector expected" diagnostic alongside the same message at a real position', () => { /** * vscode-css-languageservice reports ParseError.RuleOrSelectorExpected (code