Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/poor-keys-sniff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'eslint-plugin-php': minor
---

Support eslint directives and inline configs
122 changes: 122 additions & 0 deletions src/language/__tests__/php-source-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,128 @@ describe('PHPSourceCode', () => {
// Assert.
expect(offset).toBe(52);
});

it('should return inline configs', () => {
// Arrange.
const code = `<?php
/* Valid rule config comments: */
/* eslint php/test-rule: error */
/* eslint php/test-rule: [1] */
/* eslint php/test-rule: [1, { option: 2 }] */

/* Invalid rule config comments: */
/* eslint php/test-rule: [error */
/* eslint php/test-rule: [1, { allow: ["foo"] ] */
`;

const sourceCode = createSourceCode(code);
const allComments = sourceCode.comments;

// Act.
const config = sourceCode.applyInlineConfig();

// Assert.
expect(config.configs).toStrictEqual([
{
config: {
rules: {
'php/test-rule': 'error',
},
},
loc: allComments[1]?.loc,
},
{
config: {
rules: {
'php/test-rule': [1],
},
},
loc: allComments[2]?.loc,
},
{
config: {
rules: {
'php/test-rule': [1, { option: 2 }],
},
},
loc: allComments[3]?.loc,
},
]);

expect(config.problems).toHaveLength(2);
expect(config.problems[0]?.loc).toStrictEqual(allComments[5]?.loc);
expect(config.problems[1]?.loc).toStrictEqual(allComments[6]?.loc);
});

it('should return disable directives', () => {
// Arrange.
const code = `<?php
/* Valid directives: */
/* eslint-disable php/test-rule -- ok here */
/* eslint-enable */
/* eslint-disable-next-line php/test-rule */
/* eslint-disable-line php/test-rule -- ok here */
// eslint-disable-line php/test-rule -- ok here

/* Invalid directive: */
/* eslint-disable-line php/test-rule
*/

/* Not disable directive: */
/* eslint-disable- */
// eslint-disable-
`;

const sourceCode = createSourceCode(code);
const allComments = sourceCode.comments;

// Act.
const { directives, problems } = sourceCode.getDisableDirectives();

// Assert.
// eslint-disable-next-line @typescript-eslint/no-misused-spread -- Intentional for easier equality check.
expect(directives.map((d) => ({ ...d }))).toStrictEqual([
{
type: 'disable',
value: 'php/test-rule',
justification: 'ok here',
node: allComments[1],
},
{
type: 'enable',
value: '',
justification: '',
node: allComments[2],
},
{
type: 'disable-next-line',
value: 'php/test-rule',
justification: '',
node: allComments[3],
},
{
type: 'disable-line',
value: 'php/test-rule',
justification: 'ok here',
node: allComments[4],
},
{
type: 'disable-line',
value: 'php/test-rule',
justification: 'ok here',
node: allComments[5],
},
]);

expect(problems).toStrictEqual([
{
ruleId: null,
loc: allComments[7]?.loc,
message:
'eslint-disable-line comment should not span multiple lines.',
},
]);
});
});

function createSourceCode(code: string) {
Expand Down
18 changes: 4 additions & 14 deletions src/language/php-source-code.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,17 @@
import type { Location, Node, Program } from 'php-parser';
import type { Location, Node } from 'php-parser';
import { simpleTraverse } from '../utils/simple-traverse';
import {
type SourceRange,
type TraversalStep,
TextSourceCodeBase,
VisitNodeStep,
} from '@eslint/plugin-kit';
import { Position } from '@eslint/core';
import type { Position } from '@eslint/core';
import { LINE_START } from './php-language';
import { TextSourceCodeWithComments } from './text-source-code-with-comments';

export class PHPSourceCode extends TextSourceCodeBase {
export class PHPSourceCode extends TextSourceCodeWithComments {
#parents = new WeakMap<Node, Node>();

public override ast: Program;
public override text: string;

constructor({ ast, text }: { ast: Program; text: string }) {
super({ ast, text });

this.ast = ast;
this.text = text;
}

override getParent(node: Node) {
return this.#parents.get(node);
}
Expand Down
184 changes: 184 additions & 0 deletions src/language/text-source-code-with-comments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import type {
DirectiveType,
FileProblem,
RulesConfig,
SourceLocation,
} from '@eslint/core';
import {
ConfigCommentParser,
Directive,
TextSourceCodeBase,
} from '@eslint/plugin-kit';
import type { Comment, Location, Program } from 'php-parser';

type NormalizedComment = {
value: string;
loc: Location | null;
};

type InlineConfigElement = {
config: { rules: RulesConfig };
loc: SourceLocation;
};

const INLINE_CONFIG =
/^\s*(?:eslint(?:-enable|-disable(?:(?:-next)?-line)?)?)(?:\s|$)/u;

const ESLINT_DIRECTIVES = [
'eslint-disable',
'eslint-enable',
'eslint-disable-next-line',
'eslint-disable-line',
];

const commentParser = new ConfigCommentParser();

/**
* This class is heavily inspired by `@eslint/json` & `@eslint/css`:
*
* @see https://github.com/eslint/json/blob/42cca7789/src/languages/json-source-code.js
* @see https://github.com/eslint/css/blob/fa391df2c/src/languages/css-source-code.js
*/
export abstract class TextSourceCodeWithComments extends TextSourceCodeBase {
#inlineConfigComments: NormalizedComment[] | null = null;

public override ast: Program;
public override text: string;
public comments: NormalizedComment[];

constructor({ ast, text }: { ast: Program; text: string }) {
super({ ast, text });

this.ast = ast;
this.text = text;
this.comments = this.normalizeComments(ast.comments ?? []);
}

private normalizeComments(comments: Comment[]): NormalizedComment[] {
return comments.reduce<NormalizedComment[]>((acc, comment) => {
if (!comment.loc) {
return acc;
}

if (comment.kind === 'commentblock') {
acc.push({
value: comment.value
.replace(/^\/\*\s*(.*?)\s*\*\/$/gm, '$1')
.trim(),
loc: comment.loc,
});
}

if (comment.kind === 'commentline') {
// `php-parser` sets the end line wrong.
comment.loc.end.line = comment.loc.start.line;

acc.push({
value: comment.value.replace(/^\/\/\s*/gm, '').trim(),
loc: comment.loc,
});
}

return acc;
}, []);
}

getInlineConfigNodes() {
if (!this.#inlineConfigComments) {
this.#inlineConfigComments = this.comments.filter((comment) =>
INLINE_CONFIG.test(comment.value),
);
}

return this.#inlineConfigComments;
}

getDisableDirectives() {
const problems: FileProblem[] = [];
const directives: Directive[] = [];

this.getInlineConfigNodes().forEach((comment) => {
const parsedComment = commentParser.parseDirective(comment.value);

if (!parsedComment || !comment.loc) {
return;
}

const { label, value, justification } = parsedComment;

// `eslint-disable-line` directives are not allowed to span multiple lines as it would be confusing to which lines they apply
if (
label === 'eslint-disable-line' &&
comment.loc.start.line !== comment.loc.end.line
) {
const message = `${label} comment should not span multiple lines.`;

problems.push({
ruleId: null,
message,
loc: comment.loc,
});

return;
}

if (ESLINT_DIRECTIVES.includes(label)) {
const directiveType = label.replace(/^eslint-/, '');

directives.push(
new Directive({
type: directiveType as DirectiveType,
node: comment,
value,
justification,
}),
);
}
});

return { problems, directives };
}

applyInlineConfig() {
const problems: FileProblem[] = [];
const configs: InlineConfigElement[] = [];

this.getInlineConfigNodes().forEach((comment) => {
const parsedComment = commentParser.parseDirective(comment.value);

if (!parsedComment || !comment.loc) {
return;
}

const { label, value } = parsedComment;

if (label !== 'eslint') {
return;
}

const parseResult = commentParser.parseJSONLikeConfig(value);

if (parseResult.ok) {
configs.push({
config: {
rules: parseResult.config,
},
loc: comment.loc,
});

return;
}

problems.push({
ruleId: null,
message: parseResult.error.message,
loc: comment.loc,
});
});

return {
configs,
problems,
};
}
}