From c5586c78bbfa86a461786ec4fafdeadc035a0b47 Mon Sep 17 00:00:00 2001 From: Ryan Mark Date: Wed, 2 Sep 2026 14:25:07 -0700 Subject: [PATCH 01/22] Lint inline + +} +`; + +async function lint( + code: string, + rules: stylelint.Config['rules'], + fix = false, +) { + return stylelint.lint({ + code, + codeFilename: 'demo.gts', + customSyntax: syntax, + config: { rules }, + fix, + }); +} + +describe('positions', () => { + it('reports a warning at the line and column it occupies in the .gts', async () => { + const { results } = await lint(component, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 9, column: 16, rule: 'color-no-hex' }), + ]); + }); +}); + +describe('which blocks are exposed', () => { + it('offsets the column when the CSS starts on the same line as the tag', async () => { + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 2, column: 29 }), + ]); + }); + + it('ignores a + +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([]); + }); +}); + +describe('preprocessed blocks', () => { + it('does not lint a + +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([]); + }); + + it('does not fail the file when lang="sass" uses indented syntax postcss cannot parse', async () => { + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.parseErrors).toEqual([]); + expect(results[0]?.warnings).toEqual([]); + }); +}); + +describe('malformed input', () => { + it('does not abort the lint run when a template cannot be parsed', async () => { + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([]); + }); + + it('still lints the other templates in a file with one bad template', async () => { + const code = `export const Broken = ; + +export const Fine = ; +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 5, rule: 'color-no-hex' }), + ]); + }); +}); + +describe('what counts as a style block', () => { + it('ignores a style block written in a plain JS string outside any template', async () => { + const code = `const SNIPPET = ''; + +export default class Demo { + +} +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([]); + }); + + it('lints a block in every template in the file', async () => { + const code = `export const One = ; + +export const Two = ; +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings.map((w) => w.line)).toEqual([3, 9]); + }); + + it('keeps positions correct when multi-byte characters precede the template', async () => { + // content-tag reports byte offsets. Enough non-ASCII ahead of the template + // and the byte/char gap slices the + +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 6, column: 17 }), + ]); + }); +}); + +describe('--fix', () => { + it('rewrites the CSS and leaves the surrounding component byte-for-byte', async () => { + const { code } = await lint( + component, + { 'color-hex-length': 'long' }, + true, + ); + + expect(code).toBe(component.replace('#fff', '#ffffff')); + }); + + it('returns a component with no style blocks unchanged', async () => { + const source = `import Component from '@glimmer/component'; + +export default class Demo extends Component { + +} +`; + + const { code } = await lint(source, { 'color-hex-length': 'long' }, true); + + expect(code).toBe(source); + }); + + it('preserves a skipped lang block while fixing a plain block in the same file', async () => { + const source = ` +`; + + const { code } = await lint(source, { 'color-hex-length': 'long' }, true); + + expect(code).toBe( + source.replace('.b { color: #fff; }', '.b { color: #ffffff; }'), + ); + }); + + it('preserves the source between two fixed blocks', async () => { + const source = `export const One = ; + +export const Two = ; +`; + + const { code } = await lint(source, { 'color-hex-length': 'long' }, true); + + expect(code).toBe( + source.replace('#aaa', '#aaaaaa').replace('#bbb', '#bbbbbb'), + ); + }); +}); + +describe('with the ember-scoped-css rules', () => { + it('applies the shipped config to an inline + +} +`; + + const { results } = await stylelint.lint({ + code, + codeFilename: 'demo.gts', + customSyntax: syntax, + config: sharedConfig, + }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ + line: 12, + column: 7, + rule: 'ember-scoped-css/no-unscopable-class-attribute-selectors', + }), + ]); + }); +}); From d5c232cf69fc243d0610d73d94298b9ba842a321 Mon Sep 17 00:00:00 2001 From: Ryan Mark Date: Wed, 2 Sep 2026 14:39:26 -0700 Subject: [PATCH 02/22] Make the syntax's parsers optional peer dependencies A project that only uses this package's rules should not install a parser it never runs. content-tag and @glimmer/syntax move to optional peers, so a non-adopter's install is unchanged and adopters add them alongside the customSyntax setting. They are require()d lazily rather than imported, so opting in without them reports one line naming both packages and the install command, instead of a bare ERR_MODULE_NOT_FOUND stack naming whichever failed to resolve first. Verified against a packed tarball rather than a workspace link, since a link resolves the package's own devDependencies and hides the missing peers. Co-Authored-By: Claude Opus 5 (1M context) --- stylelint-ember-scoped-css/README.md | 18 +++++--- stylelint-ember-scoped-css/package.json | 16 +++++-- stylelint-ember-scoped-css/pnpm-lock.yaml | 9 ++-- stylelint-ember-scoped-css/src/syntax.js | 42 +++++++++++++++++-- stylelint-ember-scoped-css/src/syntax.test.ts | 26 ++++++++++++ 5 files changed, 93 insertions(+), 18 deletions(-) diff --git a/stylelint-ember-scoped-css/README.md b/stylelint-ember-scoped-css/README.md index 6bd6f5be..540ed962 100644 --- a/stylelint-ember-scoped-css/README.md +++ b/stylelint-ember-scoped-css/README.md @@ -52,8 +52,15 @@ By default stylelint only sees `.css` files, so a component that keeps all of its CSS in an inline ` + +`; + + it('does not report a CSS error for a block containing a mustache', async () => { + // + + +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 6, rule: 'color-no-hex' }), + ]); + }); +}); + +describe('byte order mark', () => { + it('lints a component saved with a UTF-8 BOM', async () => { + const body = ` +`; + + const { results } = await lint(`\uFEFF${body}`, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 3, rule: 'color-no-hex' }), + ]); + }); + + it('round-trips a BOM component under --fix', async () => { + const source = `\uFEFF +`; + + const { code } = await lint(source, { 'color-hex-length': 'long' }, true); + + expect(code).toBe(source.replace('#fff', '#ffffff')); + }); +}); + +describe('CSS syntax errors', () => { + it('reports the error at the line it occupies in the .gts', async () => { + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + const [warning] = results[0]!.warnings; + + // The unclosed @media is on line 5 of the .gts. + expect(warning).toEqual( + expect.objectContaining({ + line: 5, + text: expect.stringContaining('Unclosed'), + }), + ); + }); +}); + +describe('lang attributes the build treats as plain CSS', () => { + it.each([ + ['an empty lang value', ' + +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 3, rule: 'color-no-hex' }), + ]); + }); +}); + +describe('document source', () => { + it('sets source on a component with no style blocks', () => { + const source = '\n'; + + const doc = syntax.parse(source); + + expect(doc.source?.input.css).toBe(source); + }); +}); From 05457105884fc7a3d5bee950600332a561987630 Mon Sep 17 00:00:00 2001 From: Ryan Mark Date: Fri, 4 Sep 2026 11:59:17 -0700 Subject: [PATCH 04/22] Ship the syntax's parsers as dependencies rather than peers The parsers are ours to call, not the host's to provide, so making them optional peers pushed a second install step onto every consumer who opted in. The rest of the repo already draws the line this way: ember-scoped-css depends on @babel/parser and ember-template-recast outright, and reserves peers for what the host owns (webpack, ember-template-lint). postcss and stylelint stay peers for that reason. Static imports replace loadParsers and its createRequire seam, which only existed to name both packages in one message when a consumer had installed neither. content-tag stays ranged at ^3.1.1 || ^4.0.0 so a consumer already on v4 dedupes to one copy of the wasm parser instead of installing a second, and so toStringRange's dual byte-offset handling stays exercised rather than becoming dead code. The suite passes against 3.1.3, 4.0.0 and 4.2.0. Co-Authored-By: Claude Opus 5 (1M context) --- stylelint-ember-scoped-css/README.md | 19 +++------ stylelint-ember-scoped-css/package.json | 16 ++----- stylelint-ember-scoped-css/pnpm-lock.yaml | 14 +++---- stylelint-ember-scoped-css/src/syntax.js | 42 ++----------------- stylelint-ember-scoped-css/src/syntax.test.ts | 27 +----------- 5 files changed, 19 insertions(+), 99 deletions(-) diff --git a/stylelint-ember-scoped-css/README.md b/stylelint-ember-scoped-css/README.md index e83c3c9b..d48bfbdc 100644 --- a/stylelint-ember-scoped-css/README.md +++ b/stylelint-ember-scoped-css/README.md @@ -52,15 +52,8 @@ By default stylelint only sees `.css` files, so a component that keeps all of its CSS in an inline ` + +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([]); + }, + ); +}); + describe('lang attributes the build treats as plain CSS', () => { it.each([ ['an empty lang value', ' `; const { results } = await lint(code, { 'color-no-hex': true }); - expect(results[0]?.warnings).toEqual([]); + expect(results[0]?.parseErrors).toEqual([]); + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 4, column: 13, rule: 'color-no-hex' }), + ]); + }); + + it('lints a + +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.parseErrors).toEqual([]); + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 3, column: 17, rule: 'color-no-hex' }), + ]); + }); + + it('lints an indented + +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.parseErrors).toEqual([]); + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 4, column: 14, rule: 'color-no-hex' }), + ]); }); - it('does not fail the file when lang="sass" uses indented syntax postcss cannot parse', async () => { + it('does not fail the file when lang="sass" uses indented syntax no parser round-trips', async () => { const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 6, column: 17 }), + ]); + }); +}); + +describe('lang attributes the build treats as plain CSS', () => { + it.each([ + ['an empty lang value', ' + +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 3, rule: 'color-no-hex' }), + ]); + }); +}); + +describe('interpolated blocks', () => { + const interpolated = ` +`; + + it('does not report a CSS error for a block containing a mustache', async () => { + // + + +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 6, rule: 'color-no-hex' }), + ]); + }); +}); + +describe('malformed input', () => { + it('does not abort the lint run when a template cannot be parsed', async () => { + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([]); + }); + + it('still lints the other templates in a file with one bad template', async () => { + const code = `export const Broken = ; + +export const Fine = ; +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 5, rule: 'color-no-hex' }), + ]); + }); +}); diff --git a/stylelint-ember-scoped-css/src/syntax.config.test.ts b/stylelint-ember-scoped-css/src/syntax.config.test.ts new file mode 100644 index 00000000..f9e3e41b --- /dev/null +++ b/stylelint-ember-scoped-css/src/syntax.config.test.ts @@ -0,0 +1,43 @@ +import stylelint from 'stylelint'; +import { describe, expect, it } from 'vitest'; + +import sharedConfig from './config.js'; +import * as syntax from './syntax.js'; + +describe('with the ember-scoped-css rules', () => { + it('applies the shipped config to an inline + +} +`; + + const { results } = await stylelint.lint({ + code, + codeFilename: 'demo.gts', + customSyntax: syntax, + config: sharedConfig, + }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ + line: 12, + column: 7, + rule: 'ember-scoped-css/no-unscopable-class-attribute-selectors', + }), + ]); + }); +}); diff --git a/stylelint-ember-scoped-css/src/syntax.fix.test.ts b/stylelint-ember-scoped-css/src/syntax.fix.test.ts new file mode 100644 index 00000000..da0d53ae --- /dev/null +++ b/stylelint-ember-scoped-css/src/syntax.fix.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { component, lint } from './syntax.test-helpers.js'; + +describe('--fix', () => { + it('rewrites the CSS and leaves the surrounding component byte-for-byte', async () => { + const { code } = await lint( + component, + { 'color-hex-length': 'long' }, + true, + ); + + expect(code).toBe(component.replace('#fff', '#ffffff')); + }); + + it('returns a component with no style blocks unchanged', async () => { + const source = `import Component from '@glimmer/component'; + +export default class Demo extends Component { + +} +`; + + const { code } = await lint(source, { 'color-hex-length': 'long' }, true); + + expect(code).toBe(source); + }); + + it('preserves the source between two fixed blocks', async () => { + const source = `export const One = ; + +export const Two = ; +`; + + const { code } = await lint(source, { 'color-hex-length': 'long' }, true); + + expect(code).toBe( + source.replace('#aaa', '#aaaaaa').replace('#bbb', '#bbbbbb'), + ); + }); +}); diff --git a/stylelint-ember-scoped-css/src/syntax.less.test.ts b/stylelint-ember-scoped-css/src/syntax.less.test.ts new file mode 100644 index 00000000..0301000e --- /dev/null +++ b/stylelint-ember-scoped-css/src/syntax.less.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { lint } from './syntax.test-helpers.js'; + +describe('lang="less"', () => { + it('lints a + +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.parseErrors).toEqual([]); + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 3, column: 17, rule: 'color-no-hex' }), + ]); + }); +}); diff --git a/stylelint-ember-scoped-css/src/syntax.positions.test.ts b/stylelint-ember-scoped-css/src/syntax.positions.test.ts new file mode 100644 index 00000000..0e528830 --- /dev/null +++ b/stylelint-ember-scoped-css/src/syntax.positions.test.ts @@ -0,0 +1,153 @@ +import stylelint from 'stylelint'; +import { describe, expect, it } from 'vitest'; + +import * as syntax from './syntax.js'; +import { component, lint } from './syntax.test-helpers.js'; + +describe('positions', () => { + it('reports a warning at the line and column it occupies in the .gts', async () => { + const { results } = await lint(component, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 9, column: 16, rule: 'color-no-hex' }), + ]); + }); +}); + +describe('fix offsets', () => { + it('reports a fix range that indexes the whole .gts, not the block', async () => { + const { results } = await stylelint.lint({ + code: component, + codeFilename: 'demo.gts', + customSyntax: syntax, + computeEditInfo: true, + config: { rules: { 'color-hex-length': 'long' } }, + }); + + const { fix } = results[0]!.warnings[0]!; + const [start, end] = fix!.range; + const applied = + component.slice(0, start) + fix!.text + component.slice(end); + + // What an editor's "fix this problem" action produces must match --fix. + expect(applied).toBe(component.replace('#fff', '#ffffff')); + }); + + it('produces the corrected component when an editor applies the range', async () => { + const { results } = await stylelint.lint({ + code: component, + codeFilename: 'demo.gts', + customSyntax: syntax, + computeEditInfo: true, + config: { rules: { 'color-hex-length': 'long' } }, + }); + + const fix = results[0]?.warnings[0]?.fix; + const [start, end] = fix?.range ?? [0, 0]; + const applied = + component.slice(0, start) + (fix?.text ?? '') + component.slice(end); + + // An LSP "fix this problem" action must land on the hex and nowhere else. + expect(applied).toBe(component.replace('#fff', '#ffffff')); + }); +}); + +describe('CSS syntax errors', () => { + it('reports the error at the line it occupies in the .gts', async () => { + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + const [warning] = results[0]!.warnings; + + expect(warning).toEqual( + expect.objectContaining({ + line: 5, + text: expect.stringContaining('Unclosed'), + }), + ); + }); +}); + +describe('byte order mark', () => { + it('lints a component saved with a UTF-8 BOM', async () => { + const body = ` +`; + + const { results } = await lint(`\uFEFF${body}`, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 3, rule: 'color-no-hex' }), + ]); + }); + + it('lints a BOM component whose template ends without trailing space', async () => { + // The window ends on the `>` of , so a window one short drops the + // block with no error. The fixtures above end in whitespace and cannot + // catch that. + const code = + '\uFEFF\n'; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.parseErrors).toEqual([]); + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 1, column: 34, rule: 'color-no-hex' }), + ]); + }); + + it('drops the BOM under --fix', async () => { + const body = ` +`; + + const { code } = await lint( + `\uFEFF${body}`, + { 'color-hex-length': 'long' }, + true, + ); + + expect(code).toBe(body.replace('#fff', '#ffffff')); + }); +}); + +describe('block input', () => { + it('gives a block an input that its own offsets index into', () => { + const doc = syntax.parse(component); + const root = doc.nodes[0]; + const rule = root?.first; + const decl = rule?.type === 'rule' ? rule.first : undefined; + + const input = root?.source?.input.css ?? ''; + const start = decl?.source?.start?.offset ?? 0; + const end = decl?.source?.end?.offset ?? 0; + + // postcss slices the input by these absolute offsets to find a `word`, so + // a block-only input gives the wrong position. + expect(input.slice(start, end)).toBe('color: #fff;'); + }); +}); + +describe('document source', () => { + it('sets source on a component with no style blocks', () => { + const source = '\n'; + + const doc = syntax.parse(source); + + expect(doc.source?.input.css).toBe(source); + }); +}); diff --git a/stylelint-ember-scoped-css/src/syntax.sass.test.ts b/stylelint-ember-scoped-css/src/syntax.sass.test.ts new file mode 100644 index 00000000..a383575c --- /dev/null +++ b/stylelint-ember-scoped-css/src/syntax.sass.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; + +import { lint } from './syntax.test-helpers.js'; + +describe('lang="sass"', () => { + it('lints an indented + +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.parseErrors).toEqual([]); + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 3, column: 13, rule: 'color-no-hex' }), + expect.objectContaining({ line: 6, column: 19, rule: 'color-no-hex' }), + ]); + }); + + it('reads modern indented Sass constructs', async () => { + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.parseErrors).toEqual([]); + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 12, rule: 'color-no-hex' }), + expect.objectContaining({ line: 15, rule: 'color-no-hex' }), + ]); + }); + + it('fixes a sass block and a plain block in the same file', async () => { + const source = ` +`; + + const { code } = await lint(source, { 'color-hex-length': 'long' }, true); + + expect(code).toBe(source.replaceAll('#fff', '#ffffff')); + }); +}); + +describe('indented sass edge cases', () => { + it('round-trips blank lines with trailing whitespace and CRLF under --fix', async () => { + const source = + '\r\n'; + + const { code } = await lint(source, { 'color-hex-length': 'long' }, true); + + expect(code).toBe(source.replace('#fff', '#ffffff')); + }); + + it('reads a block whose first line shares the tag line', async () => { + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.parseErrors).toEqual([]); + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 3, column: 14, rule: 'color-no-hex' }), + ]); + }); + + it('reports the =mixin and +include shorthand as a syntax error', async () => { + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ + line: 3, + rule: 'CssSyntaxError', + text: expect.stringContaining('@mixin'), + }), + ]); + }); + + it('reports a line indented less than the first line at its .gts line', async () => { + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 5, rule: 'CssSyntaxError' }), + ]); + }); + + it('reports a sugarss error at its .gts line and column', async () => { + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 5, column: 6, rule: 'CssSyntaxError' }), + ]); + }); +}); diff --git a/stylelint-ember-scoped-css/src/syntax.scss.test.ts b/stylelint-ember-scoped-css/src/syntax.scss.test.ts new file mode 100644 index 00000000..04807232 --- /dev/null +++ b/stylelint-ember-scoped-css/src/syntax.scss.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { lint } from './syntax.test-helpers.js'; + +describe('lang="scss"', () => { + // postcss's own parser rejects this body, so a pass proves the dialect + // parser ran. + + it('reads scss the default parser would report an error on', async () => { + // postcss's own parser folds a `//` comment into the next selector, so only + // the scss parser keeps this clean. + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.parseErrors).toEqual([]); + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 4, column: 13, rule: 'color-no-hex' }), + ]); + }); + + it('matches lang case-insensitively', async () => { + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.parseErrors).toEqual([]); + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 4, rule: 'color-no-hex' }), + ]); + }); + + it('fixes a scss block and a plain block in the same file', async () => { + const source = ` +`; + + const { code } = await lint(source, { 'color-hex-length': 'long' }, true); + + // The `//` comment survives only if this block is written back out by the + // scss stringifier rather than postcss's. + expect(code).toBe(source.replaceAll('#fff;', '#ffffff;')); + }); +}); diff --git a/stylelint-ember-scoped-css/src/syntax.stylus.test.ts b/stylelint-ember-scoped-css/src/syntax.stylus.test.ts new file mode 100644 index 00000000..9f3d5c37 --- /dev/null +++ b/stylelint-ember-scoped-css/src/syntax.stylus.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { lint } from './syntax.test-helpers.js'; + +describe('lang="styl" and lang="stylus"', () => { + // postcss's own parser rejects this body, so a pass proves the dialect + // parser ran. + + it('lints an indented + +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.parseErrors).toEqual([]); + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 4, column: 14, rule: 'color-no-hex' }), + ]); + }); + + it('reads lang="styl" with the same parser as lang="stylus"', async () => { + const code = ` +`; + + const { results } = await lint(code, { 'color-no-hex': true }); + + expect(results[0]?.parseErrors).toEqual([]); + expect(results[0]?.warnings).toEqual([ + expect.objectContaining({ line: 4, rule: 'color-no-hex' }), + ]); + }); +}); diff --git a/stylelint-ember-scoped-css/src/syntax.test-helpers.ts b/stylelint-ember-scoped-css/src/syntax.test-helpers.ts new file mode 100644 index 00000000..1d5e0c19 --- /dev/null +++ b/stylelint-ember-scoped-css/src/syntax.test-helpers.ts @@ -0,0 +1,32 @@ +import stylelint from 'stylelint'; + +import * as syntax from './syntax.js'; + +export const component = `import Component from '@glimmer/component'; + +export default class Demo extends Component { + +} +`; + +export async function lint( + code: string, + rules: stylelint.Config['rules'], + fix = false, +) { + return stylelint.lint({ + code, + codeFilename: 'demo.gts', + customSyntax: syntax, + config: { rules }, + fix, + }); +} diff --git a/stylelint-ember-scoped-css/src/syntax.test.ts b/stylelint-ember-scoped-css/src/syntax.test.ts deleted file mode 100644 index 281bbbc5..00000000 --- a/stylelint-ember-scoped-css/src/syntax.test.ts +++ /dev/null @@ -1,719 +0,0 @@ -import stylelint from 'stylelint'; -import { describe, expect, it } from 'vitest'; - -import sharedConfig from './config.js'; -import * as syntax from './syntax.js'; - -const component = `import Component from '@glimmer/component'; - -export default class Demo extends Component { - -} -`; - -async function lint( - code: string, - rules: stylelint.Config['rules'], - fix = false, -) { - return stylelint.lint({ - code, - codeFilename: 'demo.gts', - customSyntax: syntax, - config: { rules }, - fix, - }); -} - -describe('positions', () => { - it('reports a warning at the line and column it occupies in the .gts', async () => { - const { results } = await lint(component, { 'color-no-hex': true }); - - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 9, column: 16, rule: 'color-no-hex' }), - ]); - }); -}); - -describe('which blocks are exposed', () => { - it('offsets the column when the CSS starts on the same line as the tag', async () => { - const code = ` -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 2, column: 29 }), - ]); - }); - - it('ignores a - -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.warnings).toEqual([]); - }); -}); - -describe('preprocessed blocks', () => { - it('reads scss the default parser would report an error on', async () => { - // postcss's own parser folds a `//` comment into the next selector, so only - // the scss parser keeps this clean. - const code = ` -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.parseErrors).toEqual([]); - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 4, column: 13, rule: 'color-no-hex' }), - ]); - }); - - it('lints a - -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.parseErrors).toEqual([]); - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 3, column: 17, rule: 'color-no-hex' }), - ]); - }); - - it('lints an indented - -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.parseErrors).toEqual([]); - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 4, column: 14, rule: 'color-no-hex' }), - ]); - }); - - it('lints an indented - -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.parseErrors).toEqual([]); - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 3, column: 13, rule: 'color-no-hex' }), - expect.objectContaining({ line: 6, column: 19, rule: 'color-no-hex' }), - ]); - }); -}); - -describe('malformed input', () => { - it('does not abort the lint run when a template cannot be parsed', async () => { - const code = ` -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.warnings).toEqual([]); - }); - - it('still lints the other templates in a file with one bad template', async () => { - const code = `export const Broken = ; - -export const Fine = ; -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 5, rule: 'color-no-hex' }), - ]); - }); -}); - -describe('what counts as a style block', () => { - it('ignores a style block written in a plain JS string outside any template', async () => { - const code = `const SNIPPET = ''; - -export default class Demo { - -} -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.warnings).toEqual([]); - }); - - it('lints a block in every template in the file', async () => { - const code = `export const One = ; - -export const Two = ; -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.warnings.map((w) => w.line)).toEqual([3, 9]); - }); - - it('keeps positions correct when multi-byte characters precede the template', async () => { - // content-tag reports byte offsets, so enough non-ASCII before the template - // would slice the - -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 6, column: 17 }), - ]); - }); -}); - -describe('--fix', () => { - it('rewrites the CSS and leaves the surrounding component byte-for-byte', async () => { - const { code } = await lint( - component, - { 'color-hex-length': 'long' }, - true, - ); - - expect(code).toBe(component.replace('#fff', '#ffffff')); - }); - - it('returns a component with no style blocks unchanged', async () => { - const source = `import Component from '@glimmer/component'; - -export default class Demo extends Component { - -} -`; - - const { code } = await lint(source, { 'color-hex-length': 'long' }, true); - - expect(code).toBe(source); - }); - - it('fixes a sass block and a plain block in the same file', async () => { - const source = ` -`; - - const { code } = await lint(source, { 'color-hex-length': 'long' }, true); - - expect(code).toBe(source.replaceAll('#fff', '#ffffff')); - }); - - it('fixes a scss block and a plain block in the same file', async () => { - const source = ` -`; - - const { code } = await lint(source, { 'color-hex-length': 'long' }, true); - - // The `//` comment survives only if this block is written back out by the - // scss stringifier rather than postcss's. - expect(code).toBe(source.replaceAll('#fff;', '#ffffff;')); - }); - - it('preserves the source between two fixed blocks', async () => { - const source = `export const One = ; - -export const Two = ; -`; - - const { code } = await lint(source, { 'color-hex-length': 'long' }, true); - - expect(code).toBe( - source.replace('#aaa', '#aaaaaa').replace('#bbb', '#bbbbbb'), - ); - }); -}); - -describe('with the ember-scoped-css rules', () => { - it('applies the shipped config to an inline - -} -`; - - const { results } = await stylelint.lint({ - code, - codeFilename: 'demo.gts', - customSyntax: syntax, - config: sharedConfig, - }); - - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ - line: 12, - column: 7, - rule: 'ember-scoped-css/no-unscopable-class-attribute-selectors', - }), - ]); - }); -}); - -describe('fix offsets', () => { - it('reports a fix range that indexes the whole .gts, not the block', async () => { - const { results } = await stylelint.lint({ - code: component, - codeFilename: 'demo.gts', - customSyntax: syntax, - computeEditInfo: true, - config: { rules: { 'color-hex-length': 'long' } }, - }); - - const { fix } = results[0]!.warnings[0]!; - const [start, end] = fix!.range; - const applied = - component.slice(0, start) + fix!.text + component.slice(end); - - // What an editor's "fix this problem" action produces must match --fix. - expect(applied).toBe(component.replace('#fff', '#ffffff')); - }); - - it('produces the corrected component when an editor applies the range', async () => { - const { results } = await stylelint.lint({ - code: component, - codeFilename: 'demo.gts', - customSyntax: syntax, - computeEditInfo: true, - config: { rules: { 'color-hex-length': 'long' } }, - }); - - const fix = results[0]?.warnings[0]?.fix; - const [start, end] = fix?.range ?? [0, 0]; - const applied = - component.slice(0, start) + (fix?.text ?? '') + component.slice(end); - - // An LSP "fix this problem" action must land on the hex and nowhere else. - expect(applied).toBe(component.replace('#fff', '#ffffff')); - }); -}); - -describe('interpolated blocks', () => { - const interpolated = ` -`; - - it('does not report a CSS error for a block containing a mustache', async () => { - // - - -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 6, rule: 'color-no-hex' }), - ]); - }); -}); - -describe('byte order mark', () => { - it('lints a component saved with a UTF-8 BOM', async () => { - const body = ` -`; - - const { results } = await lint(`\uFEFF${body}`, { 'color-no-hex': true }); - - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 3, rule: 'color-no-hex' }), - ]); - }); - - it('lints a BOM component whose template ends without trailing space', async () => { - // The window ends on the `>` of , so a window one short drops the - // block with no error. The fixtures above end in whitespace and cannot - // catch that. - const code = - '\uFEFF\n'; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.parseErrors).toEqual([]); - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 1, column: 34, rule: 'color-no-hex' }), - ]); - }); - - it('drops the BOM under --fix', async () => { - const body = ` -`; - - const { code } = await lint( - `\uFEFF${body}`, - { 'color-hex-length': 'long' }, - true, - ); - - expect(code).toBe(body.replace('#fff', '#ffffff')); - }); -}); - -describe('CSS syntax errors', () => { - it('reports the error at the line it occupies in the .gts', async () => { - const code = ` -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - const [warning] = results[0]!.warnings; - - expect(warning).toEqual( - expect.objectContaining({ - line: 5, - text: expect.stringContaining('Unclosed'), - }), - ); - }); -}); - -describe('lang attributes naming a preprocessor dialect', () => { - // postcss's own parser rejects each body below, so a pass proves the dialect - // parser ran. - it('matches lang case-insensitively', async () => { - const code = ` -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.parseErrors).toEqual([]); - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 4, rule: 'color-no-hex' }), - ]); - }); - - it('reads lang="styl" with the same parser as lang="stylus"', async () => { - const code = ` -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.parseErrors).toEqual([]); - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 4, rule: 'color-no-hex' }), - ]); - }); - - it('reads modern indented Sass constructs', async () => { - const code = ` -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.parseErrors).toEqual([]); - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 12, rule: 'color-no-hex' }), - expect.objectContaining({ line: 15, rule: 'color-no-hex' }), - ]); - }); -}); - -describe('indented sass edge cases', () => { - it('round-trips blank lines with trailing whitespace and CRLF under --fix', async () => { - const source = - '\r\n'; - - const { code } = await lint(source, { 'color-hex-length': 'long' }, true); - - expect(code).toBe(source.replace('#fff', '#ffffff')); - }); - - it('reads a block whose first line shares the tag line', async () => { - const code = ` -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.parseErrors).toEqual([]); - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 3, column: 14, rule: 'color-no-hex' }), - ]); - }); - - it('reports the =mixin and +include shorthand as a syntax error', async () => { - const code = ` -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ - line: 3, - rule: 'CssSyntaxError', - text: expect.stringContaining('@mixin'), - }), - ]); - }); - - it('reports a line indented less than the first line at its .gts line', async () => { - const code = ` -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 5, rule: 'CssSyntaxError' }), - ]); - }); - - it('reports a sugarss error at its .gts line and column', async () => { - const code = ` -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 5, column: 6, rule: 'CssSyntaxError' }), - ]); - }); -}); - -describe('lang attributes the build treats as plain CSS', () => { - it.each([ - ['an empty lang value', ' - -`; - - const { results } = await lint(code, { 'color-no-hex': true }); - - expect(results[0]?.warnings).toEqual([ - expect.objectContaining({ line: 3, rule: 'color-no-hex' }), - ]); - }); -}); - -describe('block input', () => { - it('gives a block an input that its own offsets index into', () => { - const doc = syntax.parse(component); - const root = doc.nodes[0]; - const rule = root?.first; - const decl = rule?.type === 'rule' ? rule.first : undefined; - - const input = root?.source?.input.css ?? ''; - const start = decl?.source?.start?.offset ?? 0; - const end = decl?.source?.end?.offset ?? 0; - - // postcss slices the input by these absolute offsets to find a `word`, so - // a block-only input gives the wrong position. - expect(input.slice(start, end)).toBe('color: #fff;'); - }); -}); - -describe('document source', () => { - it('sets source on a component with no style blocks', () => { - const source = '\n'; - - const doc = syntax.parse(source); - - expect(doc.source?.input.css).toBe(source); - }); -});