diff --git a/stylelint-ember-scoped-css/README.md b/stylelint-ember-scoped-css/README.md index 752563f4..da233716 100644 --- a/stylelint-ember-scoped-css/README.md +++ b/stylelint-ember-scoped-css/README.md @@ -46,6 +46,88 @@ This adds ember-scoped-css specific rules to your existing stylelint config. } ``` +## Linting inline ` + +`; + + 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('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('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.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 ` +`; + + 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 = ` +`; + + 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, + }); +}