diff --git a/README.md b/README.md index 98bcd0f..54022d9 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Then configure the rules you want to use under the rules section. | [commands](docs/rules/commands.md) | Command guidelines | ✅ | | | [detach-leaves](docs/rules/detach-leaves.md) | Don't detach leaves in onunload. | ✅ | 🔧 | | [hardcoded-config-path](docs/rules/hardcoded-config-path.md) | test | ✅ | | +| [no-sample-code](docs/rules/no-sample-code.md) | Disallow sample code snippets from the Obsidian plugin template. | ✅ | 🔧 | | [no-tfile-tfolder-cast](docs/rules/no-tfile-tfolder-cast.md) | Disallow type casting to TFile or TFolder, suggesting instanceof checks instead. | ✅ | | | [no-view-references-in-plugin](docs/rules/no-view-references-in-plugin.md) | Disallow storing references to custom views directly in the plugin, which can cause memory leaks. | ✅ | | | [object-assign](docs/rules/object-assign.md) | Object.assign with two parameters instead of 3. | ✅ | | diff --git a/docs/rules/no-sample-code.md b/docs/rules/no-sample-code.md new file mode 100644 index 0000000..d6fae40 --- /dev/null +++ b/docs/rules/no-sample-code.md @@ -0,0 +1,7 @@ +# Disallow sample code snippets from the Obsidian plugin template (`obsidianmd/no-sample-code`) + +💼 This rule is enabled in the ✅ `recommended` config. + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + diff --git a/eslint.config.mjs b/eslint.config.mjs index 1f3ba0a..e34888b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,7 @@ import commands from "./lib/rules/commands.ts"; import detachLeaves from "./lib/rules/detachLeaves.ts"; import hardcodedConfigPath from "./lib/rules/hardcodedConfigPath.ts"; +import noSampleCode from "./lib/rules/noSampleCode.ts"; import noTFileTFolderCast from "./lib/rules/noTFileTFolderCast.ts"; import noViewReferencesInPlugin from "./lib/rules/noViewReferencesInPlugin.ts"; import objectAssign from "./lib/rules/objectAssign.ts"; @@ -27,6 +28,7 @@ export default [ commands: commands, "detach-leaves": detachLeaves, "hardcoded-config-path": hardcodedConfigPath, + "no-sample-code": noSampleCode, "no-tfile-tfolder-cast": noTFileTFolderCast, "no-view-references-in-plugin": noViewReferencesInPlugin, "object-assign": objectAssign, @@ -42,6 +44,7 @@ export default [ "obsidianmd/commands": "error", "obsidianmd/detach-leaves": "error", "obsidianmd/hardcoded-config-path": "error", + "obsidianmd/no-sample-code": "error", "obsidianmd/no-tfile-tfolder-cast": "error", "obsidianmd/no-view-references-in-plugin": "error", "obsidianmd/object-assign": "error", diff --git a/lib/index.ts b/lib/index.ts index fc11b05..726af9c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,6 +1,7 @@ import commands from "./rules/commands.js"; import detachLeaves from "./rules/detachLeaves.js"; import hardcodedConfigPath from "./rules/hardcodedConfigPath.js"; +import noSampleCode from "./rules/noSampleCode.js"; import noTFileTFolderCast from "./rules/noTFileTFolderCast.js"; import noViewReferencesInPlugin from "./rules/noViewReferencesInPlugin.js"; import objectAssign from "./rules/objectAssign.js"; @@ -20,6 +21,7 @@ export default { commands: commands, "detach-leaves": detachLeaves, "hardcoded-config-path": hardcodedConfigPath, + "no-sample-code": noSampleCode, "no-tfile-tfolder-cast": noTFileTFolderCast, "no-view-references-in-plugin": noViewReferencesInPlugin, "object-assign": objectAssign, @@ -146,6 +148,7 @@ export default { "obsidianmd/commands": "error", "obsidianmd/detach-leaves": "error", "obsidianmd/hardcoded-config-path": "error", + "obsidianmd/no-sample-code": "error", "obsidianmd/no-tfile-tfolder-cast": "error", "obsidianmd/no-view-references-in-plugin": "error", "obsidianmd/object-assign": "error", diff --git a/lib/rules/noSampleCode.ts b/lib/rules/noSampleCode.ts new file mode 100644 index 0000000..8591806 --- /dev/null +++ b/lib/rules/noSampleCode.ts @@ -0,0 +1,149 @@ +import { TSESLint, TSESTree } from "@typescript-eslint/utils"; + +/** + * Checks if a node is the sample `registerInterval` call. + * `this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));` + */ +function isSampleIntervalCall(node: TSESTree.CallExpression): boolean { + // Check for `this.registerInterval(...)` + if ( + node.callee.type !== "MemberExpression" || + node.callee.object.type !== "ThisExpression" || + node.callee.property.type !== "Identifier" || + node.callee.property.name !== "registerInterval" + ) { + return false; + } + + // Check for `window.setInterval(...)` as the first argument + const setIntervalCall = node.arguments[0]; + if ( + !setIntervalCall || + setIntervalCall.type !== "CallExpression" || + setIntervalCall.callee.type !== "MemberExpression" || + (setIntervalCall.callee.object as TSESTree.Identifier)?.name !== + "window" || + (setIntervalCall.callee.property as TSESTree.Identifier)?.name !== + "setInterval" + ) { + return false; + } + + // Check for `() => console.log('setInterval')` as the first argument to setInterval + const callback = setIntervalCall.arguments[0]; + if ( + !callback || + callback.type !== "ArrowFunctionExpression" || + callback.body.type !== "CallExpression" || + (callback.body.callee as TSESTree.MemberExpression)?.property.type !== + "Identifier" || + ( + (callback.body.callee as TSESTree.MemberExpression) + ?.property as TSESTree.Identifier + )?.name !== "log" || + (callback.body.arguments[0] as TSESTree.Literal)?.value !== + "setInterval" + ) { + return false; + } + + return true; +} + +/** + * Checks if a node is the sample `registerDomEvent` call. + * `this.registerDomEvent(document, 'click', (evt: MouseEvent) => { console.log('click', evt); });` + */ +function isSampleDomEventCall(node: TSESTree.CallExpression): boolean { + // Check for `this.registerDomEvent(...)` + if ( + node.callee.type !== "MemberExpression" || + node.callee.object.type !== "ThisExpression" || + node.callee.property.type !== "Identifier" || + node.callee.property.name !== "registerDomEvent" + ) { + return false; + } + + // Check for `document` and `'click'` as the first two arguments + if ( + (node.arguments[0] as TSESTree.Identifier)?.name !== "document" || + (node.arguments[1] as TSESTree.Literal)?.value !== "click" + ) { + return false; + } + + // Check for the specific callback function + const callback = node.arguments[2]; + if ( + !callback || + callback.type !== "ArrowFunctionExpression" || + callback.body.type !== "BlockStatement" + ) { + return false; + } + + const firstStatement = callback.body.body[0]; + if ( + !firstStatement || + firstStatement.type !== "ExpressionStatement" || + firstStatement.expression.type !== "CallExpression" || + (firstStatement.expression.callee as TSESTree.MemberExpression) + ?.property.type !== "Identifier" || + ( + (firstStatement.expression.callee as TSESTree.MemberExpression) + ?.property as TSESTree.Identifier + )?.name !== "log" || + (firstStatement.expression.arguments[0] as TSESTree.Literal)?.value !== + "click" + ) { + return false; + } + + return true; +} + +export default { + name: "no-sample-code", + meta: { + type: "problem" as const, + docs: { + description: + "Disallow sample code snippets from the Obsidian plugin template.", + recommended: true, + }, + schema: [], + messages: { + removeSampleInterval: + "Remove the sample `registerInterval` call from the plugin template.", + removeSampleDomEvent: + "Remove the sample `registerDomEvent` call from the plugin template.", + }, + fixable: "code" as const, + }, + defaultOptions: [], + create( + context: TSESLint.RuleContext< + "removeSampleInterval" | "removeSampleDomEvent", + [] + >, + ) { + return { + CallExpression(node: TSESTree.CallExpression) { + if (isSampleIntervalCall(node)) { + context.report({ + node, + messageId: "removeSampleInterval", + fix: (fixer) => fixer.remove(node.parent), // Remove the entire ExpressionStatement + }); + } else if (isSampleDomEventCall(node)) { + context.report({ + node, + messageId: "removeSampleDomEvent", + fix: (fixer) => fixer.remove(node.parent), // Remove the entire ExpressionStatement + }); + } + }, + }; + }, +}; diff --git a/tests/all-rules.test.ts b/tests/all-rules.test.ts index 231d049..de2766a 100644 --- a/tests/all-rules.test.ts +++ b/tests/all-rules.test.ts @@ -10,4 +10,5 @@ import "./hardcodedConfigPath.test"; import "./vaultIterate.test"; import "./detachLeaves.test"; import "./noTFileTFolderCast.test"; +import "./noSampleCode.test"; import "./noViewReferencesInPlugin.test"; diff --git a/tests/noSampleCode.test.ts b/tests/noSampleCode.test.ts new file mode 100644 index 0000000..00f521b --- /dev/null +++ b/tests/noSampleCode.test.ts @@ -0,0 +1,33 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import noSampleCode from "../lib/rules/noSampleCode.js"; + +const ruleTester = new RuleTester(); + +ruleTester.run("no-sample-code", noSampleCode, { + valid: [ + // Valid: A different, legitimate registerInterval call + { + code: "this.registerInterval(window.setInterval(() => this.doSomething(), 1000));", + }, + // Valid: A different, legitimate registerDomEvent call + { + code: "this.registerDomEvent(this.containerEl, 'click', () => this.onClick());", + }, + ], + invalid: [ + // Invalid: The sample setInterval call + { + code: "this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));", + errors: [{ messageId: "removeSampleInterval" }], + output: "", // The auto-fix should remove the entire line + }, + // Invalid: The sample registerDomEvent call + { + code: `this.registerDomEvent(document, 'click', (evt: MouseEvent) => { + console.log('click', evt); + });`, + errors: [{ messageId: "removeSampleDomEvent" }], + output: "", // The auto-fix should remove the entire block + }, + ], +});