Skip to content

Commit 3479200

Browse files
Copilotnzakas
andauthored
feat: Allow function-based customSyntax options (#357)
* Initial plan * Allow function customSyntax options Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Address code review feedback Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Add documentation for function-based customSyntax Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Remove redundant conversion logic and fix linting errors Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Run prettier to format files Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com>
1 parent 16f32bc commit 3479200

3 files changed

Lines changed: 172 additions & 6 deletions

File tree

README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,9 @@ Setting `tolerant` to `true` is necessary if you are using custom syntax, such a
186186

187187
The CSS lexer comes prebuilt with a set of known syntax for CSS that is used in rules like `no-invalid-properties` to validate CSS code. While this works for most cases, there may be cases when you want to define your own extensions to CSS, and this can be done using the `customSyntax` language option.
188188

189-
The `customSyntax` option is an object that uses the [CSSTree format](https://github.com/csstree/csstree/blob/master/data/patch.json) for defining custom syntax, which allows you to specify at-rules, properties, and some types. For example, suppose you'd like to define a custom at-rule that looks like this:
189+
The `customSyntax` option accepts either an object or a function:
190+
191+
**Object-based syntax**: An object that uses the [CSSTree format](https://github.com/csstree/csstree/blob/master/data/patch.json) for defining custom syntax, which allows you to specify at-rules, properties, and some types. For example, suppose you'd like to define a custom at-rule that looks like this:
190192

191193
```css
192194
@my-at-rule "hello world!";
@@ -222,6 +224,37 @@ export default defineConfig([
222224
]);
223225
```
224226

227+
**Function-based syntax**: A function that receives the default CSS syntax data and returns a custom syntax configuration. This is useful when you want to extend the base syntax rather than replace it. For example:
228+
229+
```js
230+
// eslint.config.js
231+
import { defineConfig } from "eslint/config";
232+
import css from "@eslint/css";
233+
234+
export default defineConfig([
235+
{
236+
files: ["**/*.css"],
237+
plugins: {
238+
css,
239+
},
240+
language: "css/css",
241+
languageOptions: {
242+
customSyntax: defaultSyntax => ({
243+
...defaultSyntax,
244+
properties: {
245+
...defaultSyntax.properties,
246+
"-webkit-custom": "<length>",
247+
"-moz-custom": "<color>",
248+
},
249+
}),
250+
},
251+
rules: {
252+
"css/no-empty-blocks": "error",
253+
},
254+
},
255+
]);
256+
```
257+
225258
#### Configuring Tailwind Syntax
226259

227260
[Tailwind](https://tailwindcss.com) specifies some extensions to CSS that will otherwise be flagged as invalid by the rules in this plugin. To properly parse Tailwind-specific syntax, install the [`tailwind-csstree`](https://npmjs.com/package/tailwind-csstree) package:

src/languages/css-language.js

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
toPlainObject,
1515
tokenTypes,
1616
} from "@eslint/css-tree";
17+
import defaultSyntax from "@eslint/css-tree/definition-syntax-data";
1718
import { CSSSourceCode } from "./css-source-code.js";
1819
import { visitorKeys } from "./css-visitor-keys.js";
1920

@@ -28,10 +29,18 @@ import { visitorKeys } from "./css-visitor-keys.js";
2829

2930
/** @typedef {OkParseResult<StyleSheetPlain> & { comments: Comment[], lexer: Lexer }} CSSOkParseResult */
3031
/** @typedef {ParseResult<StyleSheetPlain>} CSSParseResult */
32+
/**
33+
* DefaultSyntaxConfig type representing the structure returned by @eslint/css-tree/definition-syntax-data.
34+
* This type is defined inline because it's not exported from the main @eslint/css-tree package.
35+
* @typedef {Pick<SyntaxConfig, "atrules" | "types" | "properties">} DefaultSyntaxConfig
36+
*/
37+
/**
38+
* @typedef {(defaultSyntax: DefaultSyntaxConfig) => Partial<SyntaxConfig>} SyntaxExtensionCallback
39+
*/
3140
/**
3241
* @typedef {Object} CSSLanguageOptions
3342
* @property {boolean} [tolerant] Whether to be tolerant of recoverable parsing errors.
34-
* @property {SyntaxConfig} [customSyntax] Custom syntax to use for parsing.
43+
* @property {Partial<SyntaxConfig> | SyntaxExtensionCallback} [customSyntax] Custom syntax to use for parsing.
3544
*/
3645

3746
//-----------------------------------------------------------------------------
@@ -147,11 +156,20 @@ export class CSSLanguage {
147156

148157
if ("customSyntax" in languageOptions) {
149158
if (
150-
typeof languageOptions.customSyntax !== "object" ||
159+
typeof languageOptions.customSyntax !== "object" &&
160+
typeof languageOptions.customSyntax !== "function"
161+
) {
162+
throw new TypeError(
163+
"Expected an object or function value for 'customSyntax' option.",
164+
);
165+
}
166+
167+
if (
168+
typeof languageOptions.customSyntax === "object" &&
151169
languageOptions.customSyntax === null
152170
) {
153171
throw new TypeError(
154-
"Expected an object value for 'customSyntax' option.",
172+
"Expected an object or function value for 'customSyntax' option.",
155173
);
156174
}
157175
}
@@ -167,9 +185,15 @@ export class CSSLanguage {
167185
if (!languageOptions?.customSyntax) {
168186
return languageOptions;
169187
}
188+
170189
// Shallow copy
171190
const clone = { ...languageOptions };
172191

192+
// If customSyntax is a function, call it with the default syntax to get the config object
193+
if (typeof languageOptions.customSyntax === "function") {
194+
clone.customSyntax = languageOptions.customSyntax(defaultSyntax);
195+
}
196+
173197
Object.defineProperty(clone, "toJSON", {
174198
value() {
175199
// another shallow copy
@@ -205,7 +229,11 @@ export class CSSLanguage {
205229

206230
const { tolerant } = languageOptions;
207231
const { parse, lexer } = languageOptions.customSyntax
208-
? fork(languageOptions.customSyntax)
232+
? fork(
233+
/** @type {Partial<SyntaxConfig>} */ (
234+
languageOptions.customSyntax
235+
),
236+
)
209237
: { parse: originalParse, lexer: originalLexer };
210238

211239
/*

tests/languages/css-language.test.js

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,49 @@ describe("CSSLanguage", () => {
100100

101101
assert.throws(() => {
102102
language.validateLanguageOptions({ customSyntax: null });
103-
}, /Expected an object value for 'customSyntax' option/u);
103+
}, /Expected an object or function value for 'customSyntax' option/u);
104+
});
105+
106+
it("should use function-based custom syntax when provided", () => {
107+
const language = new CSSLanguage();
108+
/**
109+
* Test helper function to create custom syntax.
110+
* @param {Object} defaultSyntax The default syntax configuration.
111+
* @returns {Object} Extended syntax configuration.
112+
*/
113+
function customSyntaxFn(defaultSyntax) {
114+
return {
115+
...defaultSyntax,
116+
properties: {
117+
...defaultSyntax.properties,
118+
"-custom-prop": "<length>",
119+
},
120+
};
121+
}
122+
123+
const languageOptions = language.normalizeLanguageOptions({
124+
customSyntax: customSyntaxFn,
125+
});
126+
127+
const result = language.parse(
128+
{
129+
body: "a { -custom-prop: 5px; }",
130+
path: "test.css",
131+
},
132+
{ languageOptions },
133+
);
134+
135+
assert.strictEqual(result.ok, true);
136+
assert.strictEqual(result.ast.type, "StyleSheet");
137+
assert.strictEqual(result.ast.children[0].type, "Rule");
138+
});
139+
140+
it("should error when invalid custom syntax type is provided", () => {
141+
const language = new CSSLanguage();
142+
143+
assert.throws(() => {
144+
language.validateLanguageOptions({ customSyntax: "string" });
145+
}, /Expected an object or function value for 'customSyntax' option/u);
104146
});
105147

106148
it("should return an error when EOF is discovered before block close", () => {
@@ -320,5 +362,68 @@ describe("CSSLanguage", () => {
320362
},
321363
});
322364
});
365+
366+
it("should convert function-based customSyntax to object", () => {
367+
const language = new CSSLanguage();
368+
/**
369+
* Test helper function to create custom syntax.
370+
* @param {Object} defaultSyntax The default syntax configuration.
371+
* @returns {Object} Extended syntax configuration.
372+
*/
373+
function customSyntaxFn(defaultSyntax) {
374+
return {
375+
...defaultSyntax,
376+
properties: {
377+
...defaultSyntax.properties,
378+
"-custom-prop": "<length>",
379+
},
380+
};
381+
}
382+
const options = { tolerant: false, customSyntax: customSyntaxFn };
383+
const normalized = language.normalizeLanguageOptions(options);
384+
385+
// Should convert the function to an object
386+
assert.strictEqual(typeof normalized.customSyntax, "object");
387+
assert.ok(normalized.customSyntax.properties);
388+
assert.strictEqual(
389+
normalized.customSyntax.properties["-custom-prop"],
390+
"<length>",
391+
);
392+
});
393+
394+
it("should serialize function-based customSyntax correctly", () => {
395+
const language = new CSSLanguage();
396+
/**
397+
* Test helper function to create custom syntax with nodes.
398+
* @param {Object} defaultSyntax The default syntax configuration.
399+
* @returns {Object} Extended syntax configuration with custom node.
400+
*/
401+
function customSyntaxFn(defaultSyntax) {
402+
return {
403+
...defaultSyntax,
404+
properties: {
405+
...defaultSyntax.properties,
406+
"-custom-prop": "<length>",
407+
},
408+
node: {
409+
CustomNode: {
410+
parse() {},
411+
},
412+
},
413+
};
414+
}
415+
const options = { tolerant: false, customSyntax: customSyntaxFn };
416+
const normalized = language.normalizeLanguageOptions(options);
417+
const json = normalized.toJSON();
418+
419+
// Should have converted function to object and functions inside to true
420+
assert.strictEqual(typeof json.customSyntax, "object");
421+
assert.ok(json.customSyntax.properties);
422+
assert.strictEqual(
423+
json.customSyntax.properties["-custom-prop"],
424+
"<length>",
425+
);
426+
assert.strictEqual(json.customSyntax.node.CustomNode.parse, true);
427+
});
323428
});
324429
});

0 commit comments

Comments
 (0)