diff --git a/.changeset/two-adults-sleep.md b/.changeset/two-adults-sleep.md new file mode 100644 index 0000000..a56e7f5 --- /dev/null +++ b/.changeset/two-adults-sleep.md @@ -0,0 +1,5 @@ +--- +'eslint-plugin-php': minor +--- + +Add `require-visibility` rule diff --git a/@types/php-parser.d.ts b/@types/php-parser.d.ts new file mode 100644 index 0000000..e3fe8f9 --- /dev/null +++ b/@types/php-parser.d.ts @@ -0,0 +1,16 @@ +declare module 'php-parser' { + // There is a typing issue in `php-parser` that `name` is typed as `string`. + class Constant extends Node { + name: string | Identifier; + value: Node | string | number | boolean | null; + } + + class Property extends Statement { + name: string | Identifier; + value: Node | null; + readonly: boolean; + nullable: boolean; + type: Identifier | Identifier[] | null; + attrGroups: AttrGroup[]; + } +} diff --git a/README.md b/README.md index 67c9933..09f54f7 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,12 @@ export default defineConfig([ ## Available Rules -| Rule ID | Description | Fixable? | -| ---------------------- | ------------------------------------- | -------- | -| `php/eqeqeq` | Require the use of `===` and `!==` | ❌ | -| `php/no-array-keyword` | Disallow the use of the array keyword | ✅ | +🔧 - Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/use/command-line-interface#--fix). + +💡 - Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions). + +| Rule ID | Description | 🔧 | 💡 | +| ------------------------ | --------------------------------------------------- | --- | --- | +| `php/eqeqeq` | Require the use of `===` and `!==` | | | +| `php/no-array-keyword` | Disallow the use of the array keyword | 🔧 | | +| `php/require-visibility` | Require visibility for class methods and properties | | 💡 | diff --git a/package-lock.json b/package-lock.json index 4822918..635f347 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "eslint-plugin-php", - "version": "0.0.0", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eslint-plugin-php", - "version": "0.0.0", + "version": "0.0.2", "license": "MIT", "dependencies": { "@eslint/core": "^0.13.0", diff --git a/src/index.ts b/src/index.ts index 027e048..ac570d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { PHPLanguage } from './language/php-language'; import { eqeqeq } from './rules/eqeqeq'; import { noArrayKeyword } from './rules/no-array-keyword'; +import { requireVisibility } from './rules/require-visibility'; const plugin = { meta: { @@ -15,6 +16,7 @@ const plugin = { rules: { eqeqeq, 'no-array-keyword': noArrayKeyword, + 'require-visibility': requireVisibility, }, } satisfies ESLint.Plugin; diff --git a/src/rules/__tests__/require-visibility.test.ts b/src/rules/__tests__/require-visibility.test.ts new file mode 100644 index 0000000..d7ba097 --- /dev/null +++ b/src/rules/__tests__/require-visibility.test.ts @@ -0,0 +1,154 @@ +import { RuleTester, type Rule } from 'eslint'; +import php from '../../index'; +import { requireVisibility, visibilityOptions } from '../require-visibility'; + +const ruleTester = new RuleTester({ + plugins: { + php, + }, + language: 'php/php', +}); + +// TODO: Fix the types. +ruleTester.run( + 'require-visibility', + requireVisibility as unknown as Rule.RuleModule, + { + valid: [ + ' ({ + messageId: 'addVisibility', + data: { visibility }, + output: ` ({ + messageId: 'addVisibility', + data: { visibility }, + output: ` ({ + messageId: 'addVisibility', + data: { visibility }, + output: ` ({ + messageId: 'addVisibility', + data: { visibility }, + output: `({ + meta: { + type: 'layout', + fixable: 'code', + hasSuggestions: true, + docs: { + description: 'Require visibility for class methods and properties', + }, + messages: { + requireVisibilityForMethod: + "Visibility must be declared on method '{{name}}'.", + + requireVisibilityForClassConstant: + "Visibility must be declared on class constant '{{name}}'.", + + requireVisibilityForClassConstants: + "Visibility must be declared on class constants '{{name}}'.", + + requireVisibilityForProperty: + "Visibility must be declared on property '{{name}}'.", + + requireVisibilityForProperties: + "Visibility must be declared on properties '{{name}}'.", + + addVisibility: "Add '{{visibility}}' visibility.", + }, + schema: [], + }, + + create(context) { + return { + 'method[visibility=""]'(_node) { + const node = _node as Method; + + context.report({ + node, + messageId: 'requireVisibilityForMethod', + data: { + name: + typeof node.name === 'string' + ? node.name + : node.name.name, + }, + suggest: visibilityOptions.map((visibility) => ({ + messageId: 'addVisibility', + data: { visibility }, + fix(fixer) { + const nodeText = context.sourceCode.getText(node); + + return fixer.replaceText( + node, + `${visibility} ${nodeText}`, + ); + }, + })), + }); + }, + + 'classconstant[visibility=""]'(_node) { + const node = _node as ClassConstant; + + const constKeywordLoc = context.sourceCode.findClosestKeyword( + node, + 'const', + ); + + if (!constKeywordLoc) { + return; + } + + const constantsNames = extractNames(node.constants); + + context.report({ + node, + loc: { + start: constKeywordLoc.start, + end: context.sourceCode.getLoc(node).end, + }, + messageId: + constantsNames.length === 1 + ? 'requireVisibilityForClassConstant' + : 'requireVisibilityForClassConstants', + data: { + name: constantsNames.join(', '), + }, + suggest: visibilityOptions.map((visibility) => ({ + messageId: 'addVisibility', + data: { visibility }, + fix(fixer) { + return fixer.insertTextBeforeRange( + [ + constKeywordLoc.start.offset, + constKeywordLoc.end.offset, + ], + `${visibility} `, + ); + }, + })), + }); + }, + + 'propertystatement[visibility=""]'(_node) { + const node = _node as PropertyStatement; + + const propertiesNames = extractNames(node.properties); + + context.report({ + node, + messageId: + propertiesNames.length === 1 + ? 'requireVisibilityForProperty' + : 'requireVisibilityForProperties', + data: { + name: propertiesNames.join(', '), + }, + suggest: visibilityOptions.map((visibility) => ({ + messageId: 'addVisibility', + data: { visibility }, + fix: (fixer) => { + const nodeText = context.sourceCode.getText(node); + + return fixer.replaceText( + node, + `${visibility} ${nodeText}`, + ); + }, + })), + }); + }, + }; + }, +}); diff --git a/src/utils/extract-names.ts b/src/utils/extract-names.ts new file mode 100644 index 0000000..184fa8a --- /dev/null +++ b/src/utils/extract-names.ts @@ -0,0 +1,11 @@ +import { Identifier } from 'php-parser'; + +type Nameable = { + name: string | Identifier; +}; + +export function extractNames(names: Nameable[]) { + return names.map(({ name }) => + typeof name === 'string' ? name : name.name, + ); +} diff --git a/tsconfig.json b/tsconfig.json index 8390ab5..a399416 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "module": "preserve", "noEmit": true, - "lib": ["ESNext", "dom", "dom.iterable"] + "lib": ["ESNext", "dom", "dom.iterable"], + "typeRoots": ["./node_modules/@types", "./@types"] } }