Skip to content

Commit

Permalink
Add experimental support for post processors (#7568)
Browse files Browse the repository at this point in the history
Co-authored-by: Masafumi Koba <473530+ybiquitous@users.noreply.github.com>
  • Loading branch information
YuanboXue-Amber and ybiquitous committed Apr 12, 2024
1 parent a5d0cf8 commit 6f6abba
Show file tree
Hide file tree
Showing 14 changed files with 604 additions and 4 deletions.
5 changes: 5 additions & 0 deletions .changeset/neat-walls-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"stylelint": minor
---

Added: experimental support for post processors
66 changes: 66 additions & 0 deletions docs/user-guide/configure.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,72 @@ Patterns are applied against the file path relative to the directory of the conf

Overrides have higher precedence than regular configurations. Multiple overrides within the same config are applied in order. That is, the last override block in a config file always has the highest precedence.

## `processors`

> [!WARNING]
> This is an experimental feature. The API may change in the future.
>
> This `processors` property was [removed in 15.0.0](../migration-guide/to-15.md#removed-processors-configuration-property), but has revived for post-processing. Note that this is different from the previous behavior.
Processors are functions that hook into Stylelint's pipeline.
Currently, processors contains only two properties: a string `name` and a function `postprocess`. `postprocess` runs after all rules have been evaluated. This function receives the `result` object of the linting process and can modify it.

For example, you can use a processor to remap the result location. Below processor expands the warning location for 'color-no-hex' rule to the entire CSS declaration. A warning for a hex color in a rule like `a { color: #111; }` would originally point to the hex color itself (e.g., line 1, columns 12-16). After processing, the warning will encompass the entire declaration (e.g., line 1, columns 5-16).

```json
{
"rules": { "color-no-hex": true },
"processors": ["path/to/my-processor.js"]
}
```

```js
// my-processor.js

/** @type {import("stylelint").Processor} */
export default function myProcessor() {
return {
name: "remap-color-no-hex",

postprocess(result, root) {
const updatedWarnings = result.warnings.map((warning) => {
if (warning.rule !== "color-no-hex") {
return warning;
}

let updatedWarning = { ...warning };

root?.walk((node) => {
const { start, end } = node.source;

if (
node.type === "decl" &&
start.line <= warning.line &&
end.line >= warning.endLine &&
start.column <= warning.column &&
end.column >= warning.endColumn
) {
updatedWarning = {
...updatedWarning,
line: start.line,
endLine: end.line,
column: start.column,
endColumn: end.column
};

return false;
}
});

return updatedWarning;
});

result.warnings = updatedWarnings;
}
};
}
```
## `defaultSeverity`
You can set the default severity level for all rules that do not have a severity specified in their secondary options. For example, you can set the default severity to `"warning"`:
Expand Down
30 changes: 30 additions & 0 deletions lib/__tests__/applyOverrides.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,36 @@ describe('single matching override', () => {

expect(applied).toEqual(expectedConfig);
});

test('with processors', () => {
const config = {
processors: ['stylelint-processor1'],
rules: {
'block-no-empty': true,
},
overrides: [
{
files: ['*.module.css'],
processors: ['stylelint-processor2'],
rules: {
'color-no-hex': true,
},
},
],
};

const expectedConfig = {
processors: ['stylelint-processor1', 'stylelint-processor2'],
rules: {
'block-no-empty': true,
'color-no-hex': true,
},
};

const applied = applyOverrides(config, __dirname, path.join(__dirname, 'style.module.css'));

expect(applied).toEqual(expectedConfig);
});
});

describe('two matching overrides', () => {
Expand Down
3 changes: 3 additions & 0 deletions lib/__tests__/fixtures/postprocess-empty.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function emptyProcessor() {
return {};
}
11 changes: 11 additions & 0 deletions lib/__tests__/fixtures/postprocess-remap-location.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function remapLocationProcessor() {
return {
name: 'remap-location',

postprocess(result) {
result.warnings.forEach((warning) => {
warning.endLine = warning.endLine === undefined ? warning.line : warning.line + 5;
});
},
};
}
6 changes: 6 additions & 0 deletions lib/__tests__/fixtures/postprocess-unknown-function.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default function nameOnlyProcessor() {
return {
name: 'processor-with-unknown-function',
unknownFunction: () => {},
};
}
34 changes: 34 additions & 0 deletions lib/__tests__/fixtures/postprocess-update-text.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export default function updateRuleText() {
return {
name: 'update-rule-text',

postprocess(result, root) {
const updatedWarnings = result.warnings.map((warning) => {
let updatedWarning = { ...warning };

// Find the rule that the warning is on and update the warning text to include it
root?.walk((node) => {
const { start, end } = node.source;

if (
start.line <= warning.line &&
end.line >= warning.endLine &&
start.column <= warning.column &&
end.column + 1 >= warning.endColumn
) {
updatedWarning = {
...updatedWarning,
text: `${updatedWarning.text} on rule \`${node.toString().trim()}\``,
};

return false;
}
});

return updatedWarning;
});

result.warnings = updatedWarnings;
},
};
}
215 changes: 215 additions & 0 deletions lib/__tests__/processors.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { fileURLToPath } from 'node:url';

import safeChdir from '../testUtils/safeChdir.mjs';
import standalone from '../standalone.mjs';

const fixturesPath = fileURLToPath(new URL('./fixtures', import.meta.url));

describe('postprocess transforms result', () => {
let results;

beforeAll(async () => {
const data = await standalone({
code: 'a {}\nb { color: pink }\n',
config: {
extends: './config-block-no-empty',
processors: ['./postprocess-remap-location.mjs'],
},
configBasedir: fixturesPath,
});

results = data.results;
});

it('number of results', () => {
expect(results).toHaveLength(1);
});

it('number of warnings', () => {
expect(results[0].warnings).toHaveLength(1);
});

it('warning rule', () => {
expect(results[0].warnings[0].rule).toBe('block-no-empty');
});

it('warning line', () => {
expect(results[0].warnings[0].line).toBe(1);
});

it('successfully modified warning end line', () => {
expect(results[0].warnings[0].endLine).toBe(6);
});
});

describe('multiple processors', () => {
let results;

beforeAll(async () => {
const data = await standalone({
code: 'a {}\nb { color: pink }\n',
config: {
extends: './config-block-no-empty',
processors: ['./postprocess-update-text.mjs', './postprocess-remap-location.mjs'],
},
configBasedir: fixturesPath,
});

results = data.results;
});

it('number of results', () => {
expect(results).toHaveLength(1);
});

it('number of warnings', () => {
expect(results[0].warnings).toHaveLength(1);
});

it('warning rule', () => {
expect(results[0].warnings[0].rule).toBe('block-no-empty');
});

it('warning line', () => {
expect(results[0].warnings[0].line).toBe(1);
});

it('successfully modified warning end line', () => {
expect(results[0].warnings[0].endLine).toBe(6);
});

it('successfully modified warning text', () => {
expect(results[0].warnings[0].text).toBe(
'Unexpected empty block (block-no-empty) on rule `a {}`',
);
});
});

describe('loading processors (and extend) from process.cwd', () => {
safeChdir(new URL('.', import.meta.url));

it('works', async () => {
const { results } = await standalone({
code: 'a {}\nb { color: pink }\n',
config: {
extends: './fixtures/config-block-no-empty',
processors: [
'./fixtures/postprocess-update-text.mjs',
'./fixtures/postprocess-remap-location.mjs',
],
},
});

expect(results[0].warnings[0].line).toBe(1);
expect(results[0].warnings[0].endLine).toBe(6);
expect(results[0].warnings[0].text).toBe(
'Unexpected empty block (block-no-empty) on rule `a {}`',
);
});
});

describe('loading processors (and extend) from options.cwd', () => {
it('works', async () => {
const { results } = await standalone({
code: 'a {}\nb { color: pink }\n',
config: {
extends: './fixtures/config-block-no-empty',
processors: [
'./fixtures/postprocess-update-text.mjs',
'./fixtures/postprocess-remap-location.mjs',
],
},
cwd: fileURLToPath(new URL('.', import.meta.url)),
});

expect(results[0].warnings[0].line).toBe(1);
expect(results[0].warnings[0].endLine).toBe(6);
expect(results[0].warnings[0].text).toBe(
'Unexpected empty block (block-no-empty) on rule `a {}`',
);
});
});

describe('processor gets to modify result on CssSyntaxError', () => {
let results;

beforeAll(async () => {
const data = await standalone({
code: "a {}\nb { color: 'pink }\n",
config: {
rules: { 'block-no-empty': true },
processors: ['./postprocess-remap-location.mjs'],
},
configBasedir: fixturesPath,
});

results = data.results;
});

it('CssSyntaxError occurred', () => {
expect(results[0].warnings).toHaveLength(1);
expect(results[0].warnings[0].rule).toBe('CssSyntaxError');
});

it('successfully modified warning end line', () => {
expect(results[0].warnings[0].endLine).toBe(2);
});
});

describe('throw error when processor is not a function', () => {
it('works', () => {
return expect(
standalone({
code: 'a { color: pink }\n',
config: {
processors: ['./config-without-rules.json'],
},
configBasedir: fixturesPath,
}),
).rejects.toThrow(
expect.objectContaining({
message: expect.stringContaining('config-without-rules.json" must be a function'),
}),
);
});
});

describe('throw error when processor does not have name', () => {
it('works', () => {
return expect(
standalone({
code: 'a { color: pink }\n',
config: {
processors: ['./postprocess-empty.mjs'],
},
configBasedir: fixturesPath,
}),
).rejects.toThrow(
expect.objectContaining({
message: expect.stringContaining(
'postprocess-empty.mjs" must return an object with the "name" property',
),
}),
);
});
});

describe('throw error when processor does not contain postprocess', () => {
it('works', () => {
return expect(
standalone({
code: 'a { color: pink }\n',
config: {
processors: ['./postprocess-unknown-function.mjs'],
},
configBasedir: fixturesPath,
}),
).rejects.toThrow(
expect.objectContaining({
message: expect.stringContaining(
'postprocess-unknown-function.mjs" must return an object with the "postprocess" property',
),
}),
);
});
});

0 comments on commit 6f6abba

Please sign in to comment.