;
+
+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 {
+
+ hi
+
+}
+`;
+
+ 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.js b/stylelint-ember-scoped-css/src/syntax.js
new file mode 100644
index 00000000..0bfc3f0e
--- /dev/null
+++ b/stylelint-ember-scoped-css/src/syntax.js
@@ -0,0 +1,289 @@
+import { preprocess as parseTemplate } from '@glimmer/syntax';
+
+import { Transformer } from 'content-tag-utils';
+import {
+ getLangAttribute,
+ hasScopedAttribute,
+} from 'ember-scoped-css/__private_do_not_use_are_you_serious__/style-tag';
+import postcss from 'postcss';
+
+import { loadParser, parserPackage } from './parsers.js';
+
+const CSS_SYNTAX = { parse: postcss.parse, stringify: postcss.stringify };
+
+/**
+ * parse and stringify both resolve through here, so `--fix` writes a block
+ * back with the syntax that read it.
+ *
+ * @param {string | null} lang lowercased `lang`, or null for plain CSS
+ * @returns {{ parse: Function, stringify: Function } | null} null when the
+ * dialect's parser is not installed
+ */
+function syntaxForLang(lang) {
+ if (lang === null || parserPackage(lang) === null) return CSS_SYNTAX;
+
+ return loadParser(lang);
+}
+
+/**
+ * @param {import('postcss').Input} input the whole .gts
+ * @param {string} lang
+ * @param {number} line
+ * @param {number} column
+ * @returns {Error}
+ */
+function missingParserError(input, lang, line, column) {
+ const pkg = parserPackage(lang);
+
+ return input.error(
+ `lang="${lang}" needs the ${pkg} package, which is not installed. Run: npm add -D ${pkg}`,
+ line,
+ column,
+ );
+}
+
+/**
+ * A .gts that does not parse has no styles. Glint already reports that error,
+ * so it is not a CSS problem and must not abort the stylelint run.
+ *
+ * @param {string} source
+ * @returns {Array<{ start: number, end: number }>} each template's contents,
+ * as indices into `source`
+ */
+function findTemplates(source) {
+ let transformer;
+
+ try {
+ transformer = new Transformer(source);
+ } catch (error) {
+ // A TypeError is our bug or a parser API change, not bad source.
+ if (error instanceof TypeError) throw error;
+
+ return [];
+ }
+
+ /** @type {Array<{ start: number, end: number }>} */
+ const templates = [];
+
+ transformer.each((_contents, { start, end }) => {
+ templates.push({ start, end });
+ });
+
+ return templates;
+}
+
+/**
+ * @param {string} contents the body of a single `
`
+ * @returns {import('@glimmer/syntax').ASTv1.Template | null}
+ */
+function parseTemplateContents(contents) {
+ try {
+ return parseTemplate(contents);
+ } catch (error) {
+ if (error instanceof TypeError) throw error;
+
+ return null;
+ }
+}
+
+/**
+ * @param {string} source contents of a .gts/.gjs file
+ * @returns {Array<{ start: number, end: number, lang: string | null }>}
+ */
+function findStyleBlocks(source) {
+ const blocks = [];
+
+ for (const { start: contentsStart, end: contentsEnd } of findTemplates(
+ source,
+ )) {
+ const ast = parseTemplateContents(source.slice(contentsStart, contentsEnd));
+
+ if (!ast) continue;
+
+ for (const node of ast.body) {
+ // The build decides what counts as scoped, so its helper decides here too.
+ if (!hasScopedAttribute(node)) continue;
+
+ const lang = getLangAttribute(node)?.toLowerCase() ?? null;
+
+ // `
+
+`;
+
+ 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.missing-parser.test.ts b/stylelint-ember-scoped-css/src/syntax.missing-parser.test.ts
new file mode 100644
index 00000000..c70d19d1
--- /dev/null
+++ b/stylelint-ember-scoped-css/src/syntax.missing-parser.test.ts
@@ -0,0 +1,61 @@
+import stylelint from 'stylelint';
+import { describe, expect, it, vi } from 'vitest';
+
+import * as syntax from './syntax.js';
+
+vi.mock('./parsers.js', async (importOriginal) => {
+ const parsers = await importOriginal
();
+
+ return {
+ ...parsers,
+ loadParser: (lang: string) =>
+ lang === 'less' ? null : parsers.loadParser(lang),
+ };
+});
+
+async function lint(code: string) {
+ return stylelint.lint({
+ code,
+ codeFilename: 'demo.gts',
+ customSyntax: syntax,
+ config: { rules: { 'color-no-hex': true } },
+ });
+}
+
+describe('a dialect whose parser is not installed', () => {
+ it('reports the package to install at the
+
+`;
+
+ const { results } = await lint(code);
+
+ expect(results[0]?.warnings).toEqual([
+ expect.objectContaining({
+ line: 3,
+ rule: 'CssSyntaxError',
+ text: expect.stringContaining('npm add -D postcss-less'),
+ }),
+ ]);
+ });
+
+ it('still lints the other dialects', async () => {
+ const code = `
+
+
+`;
+
+ const { results } = await lint(code);
+
+ expect(results[0]?.warnings).toEqual([
+ expect.objectContaining({ line: 4, 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 = `
+ x
+
+
+
+`;
+
+ 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 hi
\n\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 \r\n\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 {
+
+ hi
+
+
+
+}
+`;
+
+export async function lint(
+ code: string,
+ rules: stylelint.Config['rules'],
+ fix = false,
+) {
+ return stylelint.lint({
+ code,
+ codeFilename: 'demo.gts',
+ customSyntax: syntax,
+ config: { rules },
+ fix,
+ });
+}