Skip to content

Commit 2ac36fb

Browse files
authored
feat: support eslint directives and inline configs (#16)
Closes #9
1 parent b233df9 commit 2ac36fb

4 files changed

Lines changed: 315 additions & 14 deletions

File tree

.changeset/poor-keys-sniff.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'eslint-plugin-php': minor
3+
---
4+
5+
Support eslint directives and inline configs

src/language/__tests__/php-source-code.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,128 @@ describe('PHPSourceCode', () => {
5656
// Assert.
5757
expect(offset).toBe(52);
5858
});
59+
60+
it('should return inline configs', () => {
61+
// Arrange.
62+
const code = `<?php
63+
/* Valid rule config comments: */
64+
/* eslint php/test-rule: error */
65+
/* eslint php/test-rule: [1] */
66+
/* eslint php/test-rule: [1, { option: 2 }] */
67+
68+
/* Invalid rule config comments: */
69+
/* eslint php/test-rule: [error */
70+
/* eslint php/test-rule: [1, { allow: ["foo"] ] */
71+
`;
72+
73+
const sourceCode = createSourceCode(code);
74+
const allComments = sourceCode.comments;
75+
76+
// Act.
77+
const config = sourceCode.applyInlineConfig();
78+
79+
// Assert.
80+
expect(config.configs).toStrictEqual([
81+
{
82+
config: {
83+
rules: {
84+
'php/test-rule': 'error',
85+
},
86+
},
87+
loc: allComments[1]?.loc,
88+
},
89+
{
90+
config: {
91+
rules: {
92+
'php/test-rule': [1],
93+
},
94+
},
95+
loc: allComments[2]?.loc,
96+
},
97+
{
98+
config: {
99+
rules: {
100+
'php/test-rule': [1, { option: 2 }],
101+
},
102+
},
103+
loc: allComments[3]?.loc,
104+
},
105+
]);
106+
107+
expect(config.problems).toHaveLength(2);
108+
expect(config.problems[0]?.loc).toStrictEqual(allComments[5]?.loc);
109+
expect(config.problems[1]?.loc).toStrictEqual(allComments[6]?.loc);
110+
});
111+
112+
it('should return disable directives', () => {
113+
// Arrange.
114+
const code = `<?php
115+
/* Valid directives: */
116+
/* eslint-disable php/test-rule -- ok here */
117+
/* eslint-enable */
118+
/* eslint-disable-next-line php/test-rule */
119+
/* eslint-disable-line php/test-rule -- ok here */
120+
// eslint-disable-line php/test-rule -- ok here
121+
122+
/* Invalid directive: */
123+
/* eslint-disable-line php/test-rule
124+
*/
125+
126+
/* Not disable directive: */
127+
/* eslint-disable- */
128+
// eslint-disable-
129+
`;
130+
131+
const sourceCode = createSourceCode(code);
132+
const allComments = sourceCode.comments;
133+
134+
// Act.
135+
const { directives, problems } = sourceCode.getDisableDirectives();
136+
137+
// Assert.
138+
// eslint-disable-next-line @typescript-eslint/no-misused-spread -- Intentional for easier equality check.
139+
expect(directives.map((d) => ({ ...d }))).toStrictEqual([
140+
{
141+
type: 'disable',
142+
value: 'php/test-rule',
143+
justification: 'ok here',
144+
node: allComments[1],
145+
},
146+
{
147+
type: 'enable',
148+
value: '',
149+
justification: '',
150+
node: allComments[2],
151+
},
152+
{
153+
type: 'disable-next-line',
154+
value: 'php/test-rule',
155+
justification: '',
156+
node: allComments[3],
157+
},
158+
{
159+
type: 'disable-line',
160+
value: 'php/test-rule',
161+
justification: 'ok here',
162+
node: allComments[4],
163+
},
164+
{
165+
type: 'disable-line',
166+
value: 'php/test-rule',
167+
justification: 'ok here',
168+
node: allComments[5],
169+
},
170+
]);
171+
172+
expect(problems).toStrictEqual([
173+
{
174+
ruleId: null,
175+
loc: allComments[7]?.loc,
176+
message:
177+
'eslint-disable-line comment should not span multiple lines.',
178+
},
179+
]);
180+
});
59181
});
60182

61183
function createSourceCode(code: string) {

src/language/php-source-code.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,17 @@
1-
import type { Location, Node, Program } from 'php-parser';
1+
import type { Location, Node } from 'php-parser';
22
import { simpleTraverse } from '../utils/simple-traverse';
33
import {
44
type SourceRange,
55
type TraversalStep,
6-
TextSourceCodeBase,
76
VisitNodeStep,
87
} from '@eslint/plugin-kit';
9-
import { Position } from '@eslint/core';
8+
import type { Position } from '@eslint/core';
109
import { LINE_START } from './php-language';
10+
import { TextSourceCodeWithComments } from './text-source-code-with-comments';
1111

12-
export class PHPSourceCode extends TextSourceCodeBase {
12+
export class PHPSourceCode extends TextSourceCodeWithComments {
1313
#parents = new WeakMap<Node, Node>();
1414

15-
public override ast: Program;
16-
public override text: string;
17-
18-
constructor({ ast, text }: { ast: Program; text: string }) {
19-
super({ ast, text });
20-
21-
this.ast = ast;
22-
this.text = text;
23-
}
24-
2515
override getParent(node: Node) {
2616
return this.#parents.get(node);
2717
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import type {
2+
DirectiveType,
3+
FileProblem,
4+
RulesConfig,
5+
SourceLocation,
6+
} from '@eslint/core';
7+
import {
8+
ConfigCommentParser,
9+
Directive,
10+
TextSourceCodeBase,
11+
} from '@eslint/plugin-kit';
12+
import type { Comment, Location, Program } from 'php-parser';
13+
14+
type NormalizedComment = {
15+
value: string;
16+
loc: Location | null;
17+
};
18+
19+
type InlineConfigElement = {
20+
config: { rules: RulesConfig };
21+
loc: SourceLocation;
22+
};
23+
24+
const INLINE_CONFIG =
25+
/^\s*(?:eslint(?:-enable|-disable(?:(?:-next)?-line)?)?)(?:\s|$)/u;
26+
27+
const ESLINT_DIRECTIVES = [
28+
'eslint-disable',
29+
'eslint-enable',
30+
'eslint-disable-next-line',
31+
'eslint-disable-line',
32+
];
33+
34+
const commentParser = new ConfigCommentParser();
35+
36+
/**
37+
* This class is heavily inspired by `@eslint/json` & `@eslint/css`:
38+
*
39+
* @see https://github.com/eslint/json/blob/42cca7789/src/languages/json-source-code.js
40+
* @see https://github.com/eslint/css/blob/fa391df2c/src/languages/css-source-code.js
41+
*/
42+
export abstract class TextSourceCodeWithComments extends TextSourceCodeBase {
43+
#inlineConfigComments: NormalizedComment[] | null = null;
44+
45+
public override ast: Program;
46+
public override text: string;
47+
public comments: NormalizedComment[];
48+
49+
constructor({ ast, text }: { ast: Program; text: string }) {
50+
super({ ast, text });
51+
52+
this.ast = ast;
53+
this.text = text;
54+
this.comments = this.normalizeComments(ast.comments ?? []);
55+
}
56+
57+
private normalizeComments(comments: Comment[]): NormalizedComment[] {
58+
return comments.reduce<NormalizedComment[]>((acc, comment) => {
59+
if (!comment.loc) {
60+
return acc;
61+
}
62+
63+
if (comment.kind === 'commentblock') {
64+
acc.push({
65+
value: comment.value
66+
.replace(/^\/\*\s*(.*?)\s*\*\/$/gm, '$1')
67+
.trim(),
68+
loc: comment.loc,
69+
});
70+
}
71+
72+
if (comment.kind === 'commentline') {
73+
// `php-parser` sets the end line wrong.
74+
comment.loc.end.line = comment.loc.start.line;
75+
76+
acc.push({
77+
value: comment.value.replace(/^\/\/\s*/gm, '').trim(),
78+
loc: comment.loc,
79+
});
80+
}
81+
82+
return acc;
83+
}, []);
84+
}
85+
86+
getInlineConfigNodes() {
87+
if (!this.#inlineConfigComments) {
88+
this.#inlineConfigComments = this.comments.filter((comment) =>
89+
INLINE_CONFIG.test(comment.value),
90+
);
91+
}
92+
93+
return this.#inlineConfigComments;
94+
}
95+
96+
getDisableDirectives() {
97+
const problems: FileProblem[] = [];
98+
const directives: Directive[] = [];
99+
100+
this.getInlineConfigNodes().forEach((comment) => {
101+
const parsedComment = commentParser.parseDirective(comment.value);
102+
103+
if (!parsedComment || !comment.loc) {
104+
return;
105+
}
106+
107+
const { label, value, justification } = parsedComment;
108+
109+
// `eslint-disable-line` directives are not allowed to span multiple lines as it would be confusing to which lines they apply
110+
if (
111+
label === 'eslint-disable-line' &&
112+
comment.loc.start.line !== comment.loc.end.line
113+
) {
114+
const message = `${label} comment should not span multiple lines.`;
115+
116+
problems.push({
117+
ruleId: null,
118+
message,
119+
loc: comment.loc,
120+
});
121+
122+
return;
123+
}
124+
125+
if (ESLINT_DIRECTIVES.includes(label)) {
126+
const directiveType = label.replace(/^eslint-/, '');
127+
128+
directives.push(
129+
new Directive({
130+
type: directiveType as DirectiveType,
131+
node: comment,
132+
value,
133+
justification,
134+
}),
135+
);
136+
}
137+
});
138+
139+
return { problems, directives };
140+
}
141+
142+
applyInlineConfig() {
143+
const problems: FileProblem[] = [];
144+
const configs: InlineConfigElement[] = [];
145+
146+
this.getInlineConfigNodes().forEach((comment) => {
147+
const parsedComment = commentParser.parseDirective(comment.value);
148+
149+
if (!parsedComment || !comment.loc) {
150+
return;
151+
}
152+
153+
const { label, value } = parsedComment;
154+
155+
if (label !== 'eslint') {
156+
return;
157+
}
158+
159+
const parseResult = commentParser.parseJSONLikeConfig(value);
160+
161+
if (parseResult.ok) {
162+
configs.push({
163+
config: {
164+
rules: parseResult.config,
165+
},
166+
loc: comment.loc,
167+
});
168+
169+
return;
170+
}
171+
172+
problems.push({
173+
ruleId: null,
174+
message: parseResult.error.message,
175+
loc: comment.loc,
176+
});
177+
});
178+
179+
return {
180+
configs,
181+
problems,
182+
};
183+
}
184+
}

0 commit comments

Comments
 (0)