From d8fc7bf3eb9b1d7002809c2a0e7c6715c9f2fad9 Mon Sep 17 00:00:00 2001 From: mathieudutour Date: Fri, 14 Nov 2025 15:00:11 +0100 Subject: [PATCH 1/4] Add prefer-common-shortcut rule --- docs/rules/prefer-common-shortcut.md | 52 +++++ lib/index.ts | 3 + lib/rules/prefer-common-shortcut.ts | 323 +++++++++++++++++++++++++++ tests/prefer-common-shortcut.test.ts | 85 +++++++ 4 files changed, 463 insertions(+) create mode 100644 docs/rules/prefer-common-shortcut.md create mode 100644 lib/rules/prefer-common-shortcut.ts create mode 100644 tests/prefer-common-shortcut.test.ts diff --git a/docs/rules/prefer-common-shortcut.md b/docs/rules/prefer-common-shortcut.md new file mode 100644 index 0000000..45a735d --- /dev/null +++ b/docs/rules/prefer-common-shortcut.md @@ -0,0 +1,52 @@ +# Prefer Common Shortcuts (`@raycast/prefer-common-shortcut`) + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +When a React component uses a `shortcut` prop with a literal object that matches a +well-known shortcut, prefer using `Keyboard.Shortcut.Common.*` from `@raycast/api`. + +This keeps shortcuts consistent across platforms and makes intent explicit. + +## Rule Details + +This rule inspects JSX attributes named `shortcut` and looks for object literals in either of these forms: + +- Single form: `{ modifiers: ["cmd"], key: "s" }` +- Platform form: `{ macOS: { modifiers: ["cmd"], key: "s" }, Windows: { modifiers: ["ctrl"], key: "s" } }` + +If the value equals a known common shortcut, the rule suggests replacing it with the corresponding +`Keyboard.Shortcut.Common.Name` and will also add `Keyboard` to your `@raycast/api` import or create +one if missing. + +### Examples + +Incorrect code: + +```tsx + +``` + +```tsx + +``` + +Correct code: + +```tsx +import { Keyboard } from "@raycast/api"; + +; +``` + +```tsx + +``` + +Note that the rule only flags "single" (non-platform) object literals when the common shortcut is exactly the same on macOS and Windows. For platform-specific differences, use the `platform` object form in your code or the corresponding `Keyboard.Shortcut.Common.*` reference. diff --git a/lib/index.ts b/lib/index.ts index c0930a0..4a7e132 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -4,6 +4,7 @@ import path from "path"; import preferEllipis from "./rules/prefer-ellipsis"; import preferPlaceholders from "./rules/prefer-placeholders"; import preferTitleCase from "./rules/prefer-title-case"; +import preferCommonShortcut from "./rules/prefer-common-shortcut"; const pkg = JSON.parse( fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8") @@ -19,6 +20,7 @@ const plugin = { "prefer-ellipsis": preferEllipis, "prefer-title-case": preferTitleCase, "prefer-placeholders": preferPlaceholders, + "prefer-common-shortcut": preferCommonShortcut, }, }; @@ -31,6 +33,7 @@ Object.assign(plugin.configs, { rules: { "@raycast/prefer-ellipsis": "warn", "@raycast/prefer-title-case": "warn", + "@raycast/prefer-common-shortcut": "warn", }, }, ], diff --git a/lib/rules/prefer-common-shortcut.ts b/lib/rules/prefer-common-shortcut.ts new file mode 100644 index 0000000..e6670bf --- /dev/null +++ b/lib/rules/prefer-common-shortcut.ts @@ -0,0 +1,323 @@ +import { AST_NODE_TYPES, TSESLint, TSESTree } from "@typescript-eslint/utils"; + +import { createRule } from "../utils"; +import { RuleFix } from "@typescript-eslint/utils/dist/ts-eslint"; + +type SimpleShortcut = { modifiers: string[]; key: string }; + +type CommonShortcut = { + name: string; + macOS: SimpleShortcut; + Windows: SimpleShortcut; +}; + +// Source of truth copied from @raycast/api Keyboard.Shortcut.Common +const COMMON_SHORTCUTS: CommonShortcut[] = [ + { + name: "Copy", + macOS: { modifiers: ["cmd", "shift"], key: "c" }, + Windows: { modifiers: ["ctrl", "shift"], key: "c" }, + }, + { + name: "CopyDeeplink", + macOS: { modifiers: ["cmd", "shift"], key: "c" }, + Windows: { modifiers: ["ctrl", "shift"], key: "c" }, + }, + { + name: "CopyName", + macOS: { modifiers: ["cmd", "opt"], key: "c" }, + Windows: { modifiers: ["ctrl", "alt"], key: "c" }, + }, + { + name: "CopyPath", + macOS: { modifiers: ["cmd", "ctrl"], key: "c" }, + Windows: { modifiers: ["alt", "shift"], key: "c" }, + }, + { + name: "Save", + macOS: { modifiers: ["cmd"], key: "s" }, + Windows: { modifiers: ["ctrl"], key: "s" }, + }, + { + name: "Duplicate", + macOS: { modifiers: ["cmd", "shift"], key: "s" }, + Windows: { modifiers: ["ctrl", "shift"], key: "s" }, + }, + { + name: "Edit", + macOS: { modifiers: ["cmd"], key: "e" }, + Windows: { modifiers: ["ctrl"], key: "e" }, + }, + { + name: "MoveDown", + macOS: { modifiers: ["cmd", "shift"], key: "arrowDown" }, + Windows: { modifiers: ["ctrl", "shift"], key: "arrowDown" }, + }, + { + name: "MoveUp", + macOS: { modifiers: ["cmd", "shift"], key: "arrowUp" }, + Windows: { modifiers: ["ctrl", "shift"], key: "arrowUp" }, + }, + { + name: "New", + macOS: { modifiers: ["cmd"], key: "n" }, + Windows: { modifiers: ["ctrl"], key: "n" }, + }, + { + name: "Open", + macOS: { modifiers: ["cmd"], key: "o" }, + Windows: { modifiers: ["ctrl"], key: "o" }, + }, + { + name: "OpenWith", + macOS: { modifiers: ["cmd", "shift"], key: "o" }, + Windows: { modifiers: ["ctrl", "shift"], key: "o" }, + }, + { + name: "Pin", + macOS: { modifiers: ["cmd"], key: "." }, + Windows: { modifiers: ["ctrl"], key: "." }, + }, + { + name: "Refresh", + macOS: { modifiers: ["cmd"], key: "r" }, + Windows: { modifiers: ["ctrl"], key: "r" }, + }, + { + name: "Remove", + macOS: { modifiers: ["ctrl"], key: "d" }, + Windows: { modifiers: ["ctrl"], key: "d" }, + }, + { + name: "RemoveAll", + macOS: { modifiers: ["ctrl", "shift"], key: "d" }, + Windows: { modifiers: ["ctrl", "shift"], key: "d" }, + }, + { + name: "ToggleQuickLook", + macOS: { modifiers: ["cmd"], key: "y" }, + Windows: { modifiers: ["ctrl"], key: "y" }, + }, +]; + +function eq(a: string[], b: string[]) { + if (a.length !== b.length) return false; + const as = [...a].sort(); + const bs = [...b].sort(); + return as.every((v, i) => v === bs[i]); +} + +function normalizeSimpleShortcut( + node: TSESTree.ObjectExpression +): SimpleShortcut | null { + const props = new Map(); + for (const p of node.properties) { + if ( + p.type === AST_NODE_TYPES.Property && + p.key.type === AST_NODE_TYPES.Identifier + ) { + props.set(p.key.name, p); + } + } + + const modifiersProp = props.get("modifiers"); + const keyProp = props.get("key"); + if (!modifiersProp || !keyProp) return null; + + // modifiers: ["cmd", "shift"] + if ( + modifiersProp.value.type !== AST_NODE_TYPES.ArrayExpression || + keyProp.value.type !== AST_NODE_TYPES.Literal || + typeof keyProp.value.value !== "string" + ) { + return null; + } + + const modifiers: string[] = []; + for (const el of modifiersProp.value.elements) { + if (!el) continue; + if (el.type !== AST_NODE_TYPES.Literal || typeof el.value !== "string") + return null; + modifiers.push(el.value); + } + + return { modifiers, key: keyProp.value.value }; +} + +function parseShortcutValue( + node: TSESTree.ObjectExpression +): + | { kind: "single"; value: SimpleShortcut } + | { kind: "platform"; macOS: SimpleShortcut; Windows: SimpleShortcut } + | null { + // Detect platform-specific keys (Windows/windows and macOS) + const map = new Map(); + for (const p of node.properties) { + if ( + p.type === AST_NODE_TYPES.Property && + (p.key.type === AST_NODE_TYPES.Identifier || + p.key.type === AST_NODE_TYPES.Literal) + ) { + const k = + p.key.type === AST_NODE_TYPES.Identifier + ? p.key.name + : String(p.key.value); + if (p.value.type === AST_NODE_TYPES.ObjectExpression) { + map.set(k, p.value); + } + } + } + + const mac = map.get("macOS"); + const win = map.get("Windows") ?? map.get("windows"); + if (mac && win) { + const macS = normalizeSimpleShortcut(mac); + const winS = normalizeSimpleShortcut(win); + if (macS && winS) return { kind: "platform", macOS: macS, Windows: winS }; + return null; + } + + // Otherwise, try simple form + const simple = normalizeSimpleShortcut(node); + if (simple) return { kind: "single", value: simple }; + return null; +} + +function findMatchingCommon( + sc: + | { kind: "single"; value: SimpleShortcut } + | { kind: "platform"; macOS: SimpleShortcut; Windows: SimpleShortcut } +): CommonShortcut | null { + if (sc.kind === "platform") { + for (const c of COMMON_SHORTCUTS) { + if ( + c.macOS.key === sc.macOS.key && + c.Windows.key === sc.Windows.key && + eq(c.macOS.modifiers, sc.macOS.modifiers) && + eq(c.Windows.modifiers, sc.Windows.modifiers) + ) { + return c; + } + } + return null; + } + + // For single-form, match either the macOS or Windows definition of a common shortcut + for (const c of COMMON_SHORTCUTS) { + const macMatch = + c.macOS.key === sc.value.key && eq(c.macOS.modifiers, sc.value.modifiers); + const winMatch = + c.Windows.key === sc.value.key && + eq(c.Windows.modifiers, sc.value.modifiers); + if (macMatch || winMatch) return c; + } + return null; +} + +function ensureKeyboardImportFix( + fixer: TSESLint.RuleFixer, + program: TSESTree.Program +) { + // Find an import from "@raycast/api" + const imports = program.body.filter( + (n): n is TSESTree.ImportDeclaration => + n.type === AST_NODE_TYPES.ImportDeclaration + ); + const apiImport = imports.find((i) => i.source.value === "@raycast/api"); + + if (!apiImport) { + // Insert a new import at top + return fixer.insertTextBefore( + (program.body[0] as TSESTree.Node) ?? program, + `import { Keyboard } from "@raycast/api";\n` + ); + } + + // If already has Keyboard named import, do nothing + const hasKeyboard = apiImport.specifiers.some( + (s) => + s.type === AST_NODE_TYPES.ImportSpecifier && + s.imported.type === AST_NODE_TYPES.Identifier && + s.imported.name === "Keyboard" + ); + if (hasKeyboard) return null; + + // If it's a named import, add Keyboard + const lastSpecifier = apiImport.specifiers[apiImport.specifiers.length - 1]; + if (lastSpecifier && lastSpecifier.type === AST_NODE_TYPES.ImportSpecifier) { + return fixer.insertTextAfter(lastSpecifier, `, Keyboard`); + } + + // If it's a default or namespace import, add a named import group + // Transform: import api from "@raycast/api"; -> import api, { Keyboard } from "@raycast/api"; + // Insert after the default/namespace specifier if present + const spec = apiImport.specifiers[0]; + if (spec) { + return fixer.insertTextAfter(spec, `, { Keyboard }`); + } + // No specifiers (e.g., `import "@raycast/api";`), add a new import above + return fixer.insertTextBefore( + apiImport, + `import { Keyboard } from "@raycast/api";\n` + ); +} + +export default createRule({ + name: "prefer-common-shortcut", + meta: { + type: "suggestion", + docs: { + description: + "Warn when a literal shortcut matches a common one; prefer Keyboard.Shortcut.Common.* from @raycast/api.", + }, + fixable: "code", + schema: [], + messages: { + useCommon: + "This shortcut matches Common.{{name}}. Prefer using Keyboard.Shortcut.Common.{{name}} from @raycast/api.", + }, + }, + defaultOptions: [], + create(context) { + return { + JSXAttribute(node) { + if ( + node.name.type === AST_NODE_TYPES.JSXIdentifier && + node.name.name === "shortcut" && + node.value && + node.value.type === AST_NODE_TYPES.JSXExpressionContainer && + node.value.expression.type === AST_NODE_TYPES.ObjectExpression + ) { + const shortcutAst = node.value.expression; + const parsed = parseShortcutValue(shortcutAst); + if (!parsed) return; + + const match = findMatchingCommon(parsed); + if (!match) return; + + // Provide a fix to replace object with Keyboard.Shortcut.Common. and ensure import + context.report({ + node: node.value, + messageId: "useCommon", + data: { name: match.name }, + fix: (fixer) => { + const fixes = [] as RuleFix[]; + // Replace object literal + fixes.push( + fixer.replaceText( + node.value as any, + `{Keyboard.Shortcut.Common.${match.name}}` + ) + ); + // Ensure import + const program = context.sourceCode.ast; + const importFix = ensureKeyboardImportFix(fixer, program); + if (importFix) fixes.push(importFix); + return fixes; + }, + }); + } + }, + }; + }, +}); diff --git a/tests/prefer-common-shortcut.test.ts b/tests/prefer-common-shortcut.test.ts new file mode 100644 index 0000000..8bc417b --- /dev/null +++ b/tests/prefer-common-shortcut.test.ts @@ -0,0 +1,85 @@ +// @ts-ignore +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../lib/rules/prefer-common-shortcut"; + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { jsx: true }, + ecmaVersion: 2020, + sourceType: "module", + }, + }, +}); + +ruleTester.run("prefer-common-shortcut", rule, { + valid: [ + // Already using Common + { + code: ` + import { Keyboard } from "@raycast/api"; + const C = () => ; + `, + }, + // Not a shortcut attribute + { code: `` }, + // Non-literal expression + { code: `` }, + // Single-form that doesn't match any 'same across platforms' common shortcut + { + code: ``, + }, + ], + invalid: [ + // Platform specific form + { + code: ` + const C = () => ( + + ); + `, + errors: [{ messageId: "useCommon" }], + output: ` + import { Keyboard } from "@raycast/api"; +const C = () => ( + + ); + `, + }, + // Simple form matching a cross-platform identical common shortcut (Remove) + { + code: ` + import { Keyboard } from "@raycast/api"; + const C = () => ; + `, + errors: [{ messageId: "useCommon" }], + output: ` + import { Keyboard } from "@raycast/api"; + const C = () => ; + `, + }, + // Adds named import when there is an existing import without Keyboard + { + code: ` + import { Icon } from "@raycast/api"; + const C = () => ; + `, + errors: [{ messageId: "useCommon" }], + output: ` + import { Icon, Keyboard } from "@raycast/api"; + const C = () => ; + `, + }, + // Creates a new import if none exists + { + code: ` + const C = () => ; + `, + errors: [{ messageId: "useCommon" }], + output: ` + import { Keyboard } from "@raycast/api"; +const C = () => ; + `, + }, + ], +}); From 8b8bf898b9d187aece4afc3b322a698eb5733113 Mon Sep 17 00:00:00 2001 From: mathieudutour Date: Fri, 14 Nov 2025 15:10:09 +0100 Subject: [PATCH 2/4] Add no-reserved-shortcut rule --- docs/rules/no-reserved-shortcut.md | 60 ++++++++++++++ lib/index.ts | 3 + lib/rules/no-reserved-shortcut.ts | 123 +++++++++++++++++++++++++++++ tests/no-reserved-shortcut.test.ts | 44 +++++++++++ 4 files changed, 230 insertions(+) create mode 100644 docs/rules/no-reserved-shortcut.md create mode 100644 lib/rules/no-reserved-shortcut.ts create mode 100644 tests/no-reserved-shortcut.test.ts diff --git a/docs/rules/no-reserved-shortcut.md b/docs/rules/no-reserved-shortcut.md new file mode 100644 index 0000000..9f7e964 --- /dev/null +++ b/docs/rules/no-reserved-shortcut.md @@ -0,0 +1,60 @@ +# No Reserved Shortcut (`@raycast/no-reserved-shortcut`) + + + +Warns when you define a literal shortcut that conflicts with one of Raycast's reserved shortcuts. Reserved shortcuts are used internally by the Raycast UI and reusing them in extensions may create confusing experiences. + +## Rule Details + +This rule inspects JSX `shortcut` attributes with a direct object literal of the simple form: + +```tsx + +``` + +Platform form objects (with `macOS` / `Windows`) and dynamically built shortcuts are ignored. + +### Examples of incorrect code + +```tsx + +``` + +```tsx + +``` + +### Examples of correct code + +```tsx + +``` + +```tsx + +``` + +## Reserved Shortcuts Checked + +- CloseWindow: cmd + w +- Delete: delete +- DeleteForward: deleteForward +- DeleteLineBackward: cmd + delete +- DeleteWordBackward: opt + delete +- GoBack: escape +- OpenActionPanel: cmd + k +- OpenPreferences: cmd + , +- OpenSearchBarDropdown: cmd + p +- OpenSearchBarLink: shift + cmd + / +- PrimaryAction: enter +- Quit: cmd + q +- ReturnToRoot: cmd + escape +- SecondaryAction: cmd + enter +- SelectAll: cmd + a + +Note: The rule does not attempt an autofix; choose an alternate shortcut instead. diff --git a/lib/index.ts b/lib/index.ts index 4a7e132..a1580c3 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -5,6 +5,7 @@ import preferEllipis from "./rules/prefer-ellipsis"; import preferPlaceholders from "./rules/prefer-placeholders"; import preferTitleCase from "./rules/prefer-title-case"; import preferCommonShortcut from "./rules/prefer-common-shortcut"; +import noReservedShortcut from "./rules/no-reserved-shortcut"; const pkg = JSON.parse( fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8") @@ -21,6 +22,7 @@ const plugin = { "prefer-title-case": preferTitleCase, "prefer-placeholders": preferPlaceholders, "prefer-common-shortcut": preferCommonShortcut, + "no-reserved-shortcut": noReservedShortcut, }, }; @@ -34,6 +36,7 @@ Object.assign(plugin.configs, { "@raycast/prefer-ellipsis": "warn", "@raycast/prefer-title-case": "warn", "@raycast/prefer-common-shortcut": "warn", + "@raycast/no-reserved-shortcut": "warn", }, }, ], diff --git a/lib/rules/no-reserved-shortcut.ts b/lib/rules/no-reserved-shortcut.ts new file mode 100644 index 0000000..42101f8 --- /dev/null +++ b/lib/rules/no-reserved-shortcut.ts @@ -0,0 +1,123 @@ +import { AST_NODE_TYPES, TSESTree } from "@typescript-eslint/utils"; +import { createRule } from "../utils"; + +type SimpleShortcut = { modifiers: string[]; key: string }; + +interface ReservedShortcutDefinition extends SimpleShortcut { + name: string; +} + +// Source list copied from @raycast/api Keyboard.Shortcut.Reserved +const RESERVED: ReservedShortcutDefinition[] = [ + { name: "CloseWindow", modifiers: ["cmd"], key: "w" }, + { name: "Delete", modifiers: [], key: "delete" }, + { name: "DeleteForward", modifiers: [], key: "deleteForward" }, + { name: "DeleteLineBackward", modifiers: ["cmd"], key: "delete" }, + { name: "DeleteWordBackward", modifiers: ["opt"], key: "delete" }, + { name: "GoBack", modifiers: [], key: "escape" }, + { name: "OpenActionPanel", modifiers: ["cmd"], key: "k" }, + { name: "OpenPreferences", modifiers: ["cmd"], key: "," }, + { name: "OpenSearchBarDropdown", modifiers: ["cmd"], key: "p" }, + { name: "OpenSearchBarLink", modifiers: ["shift", "cmd"], key: "/" }, + { name: "PrimaryAction", modifiers: [], key: "enter" }, + { name: "Quit", modifiers: ["cmd"], key: "q" }, + { name: "ReturnToRoot", modifiers: ["cmd"], key: "escape" }, + { name: "SecondaryAction", modifiers: ["cmd"], key: "enter" }, + { name: "SelectAll", modifiers: ["cmd"], key: "a" }, +]; + +function parseSimpleShortcut( + node: TSESTree.ObjectExpression +): SimpleShortcut | null { + const props = new Map(); + for (const p of node.properties) { + if ( + p.type === AST_NODE_TYPES.Property && + p.key.type === AST_NODE_TYPES.Identifier + ) { + const name = p.key.name; + if (name !== "modifiers" && name !== "key") { + // Extra property -> not a pure simple shortcut literal + return null; + } + props.set(name, p); + } else { + // Spread or other patterns => ignore + return null; + } + } + const modifiersProp = props.get("modifiers"); + const keyProp = props.get("key"); + if (!keyProp || !modifiersProp) return null; + // Must have exactly two properties + if (props.size !== 2) return null; + if ( + modifiersProp.value.type !== AST_NODE_TYPES.ArrayExpression || + keyProp.value.type !== AST_NODE_TYPES.Literal || + typeof keyProp.value.value !== "string" + ) { + return null; + } + const modifiers: string[] = []; + for (const el of modifiersProp.value.elements) { + if (!el) continue; + if (el.type !== AST_NODE_TYPES.Literal || typeof el.value !== "string") + return null; + modifiers.push(el.value); + } + return { modifiers, key: keyProp.value.value }; +} + +function matchesReserved( + sc: SimpleShortcut +): ReservedShortcutDefinition | null { + for (const r of RESERVED) { + if (r.key !== sc.key) continue; + if (r.modifiers.length !== sc.modifiers.length) continue; + const a = [...r.modifiers].sort(); + const b = [...sc.modifiers].sort(); + if (a.every((v, i) => v === b[i])) return r; + } + return null; +} + +export default createRule({ + name: "no-reserved-shortcut", + meta: { + type: "suggestion", + docs: { + description: + "Warn when a shortcut prop defines a literal reserved shortcut that Raycast uses internally.", + }, + schema: [], + messages: { + reserved: + "Shortcut literal matches reserved shortcut '{{name}}'. Choose a different shortcut to avoid conflicts.", + }, + }, + defaultOptions: [], + create(context) { + return { + JSXAttribute(node) { + if ( + node.name.type === AST_NODE_TYPES.JSXIdentifier && + node.name.name === "shortcut" && + node.value && + node.value.type === AST_NODE_TYPES.JSXExpressionContainer && + node.value.expression.type === AST_NODE_TYPES.ObjectExpression + ) { + const obj = node.value.expression; + const simple = parseSimpleShortcut(obj); + if (!simple) return; // Ignore platform form or dynamic shapes + const match = matchesReserved(simple); + if (!match) return; + context.report({ + node: node.value, + messageId: "reserved", + data: { name: match.name }, + }); + } + }, + }; + }, +}); diff --git a/tests/no-reserved-shortcut.test.ts b/tests/no-reserved-shortcut.test.ts new file mode 100644 index 0000000..d90cff9 --- /dev/null +++ b/tests/no-reserved-shortcut.test.ts @@ -0,0 +1,44 @@ +// @ts-ignore +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../lib/rules/no-reserved-shortcut"; + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { jsx: true }, + ecmaVersion: 2020, + sourceType: "module", + }, + }, +}); + +ruleTester.run("no-reserved-shortcut", rule, { + valid: [ + { code: `` }, // not reserved + { + code: ``, + }, // extra prop -> ignored + { + code: ``, + }, // platform form ignored + { code: `` }, + ], + invalid: [ + { + code: ``, + errors: [{ messageId: "reserved" }], + }, + { + code: ``, + errors: [{ messageId: "reserved" }], + }, + { + code: ``, + errors: [{ messageId: "reserved" }], + }, + { + code: ``, + errors: [{ messageId: "reserved" }], + }, + ], +}); From a6d5eaecff5a4906511552637a2dd68b79afe325 Mon Sep 17 00:00:00 2001 From: mathieudutour Date: Fri, 14 Nov 2025 16:29:35 +0100 Subject: [PATCH 3/4] Add no-ambiguous-platform-shortcut rule --- docs/rules/no-ambiguous-platform-shortcut.md | 49 +++++++ lib/index.ts | 3 + lib/rules/no-ambiguous-platform-shortcut.ts | 142 +++++++++++++++++++ lib/rules/no-reserved-shortcut.ts | 4 +- lib/rules/prefer-common-shortcut.ts | 2 +- tests/fixtures/multi-platform/package.json | 7 + tests/fixtures/multi-platform/src/.gitkeep | 0 tests/fixtures/single-platform/package.json | 6 + tests/fixtures/single-platform/src/.gitkeep | 0 tests/no-ambiguous-platform-shortcut.test.ts | 68 +++++++++ 10 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 docs/rules/no-ambiguous-platform-shortcut.md create mode 100644 lib/rules/no-ambiguous-platform-shortcut.ts create mode 100644 tests/fixtures/multi-platform/package.json create mode 100644 tests/fixtures/multi-platform/src/.gitkeep create mode 100644 tests/fixtures/single-platform/package.json create mode 100644 tests/fixtures/single-platform/src/.gitkeep create mode 100644 tests/no-ambiguous-platform-shortcut.test.ts diff --git a/docs/rules/no-ambiguous-platform-shortcut.md b/docs/rules/no-ambiguous-platform-shortcut.md new file mode 100644 index 0000000..83f2702 --- /dev/null +++ b/docs/rules/no-ambiguous-platform-shortcut.md @@ -0,0 +1,49 @@ +# No Ambiguous Platform Shortcut (`@raycast/no-ambiguous-platform-shortcut`) + + + +Warns when a single-form shortcut literal (one modifiers/key object) uses only `cmd` or only `ctrl` in an extension that targets multiple platforms. In multi-platform extensions, you should either provide platform-specific shortcuts (with `macOS`/`Windows` sections) or include both modifiers to ensure parity. + +## Rule Details + +This rule checks JSX attributes named `shortcut`. It only inspects literal objects of the simple form: + +```tsx + +``` + +Platform-specific shortcut objects (those with `macOS`/`Windows`) and dynamic expressions are ignored. + +The rule first locates the closest `package.json` to the file being linted. If the package declares a `platforms` array with more than one value, the rule activates and warns about ambiguous shortcuts. + +### Examples of incorrect code + +```tsx + +``` + +```tsx + +``` + +### Examples of correct code + +```tsx + +``` + +```tsx + +``` + +```tsx +// Single-platform extension ⇒ rule does not apply + +``` + +> ℹ️ The rule only warns; it does not provide an autofix. Choose platform-specific shortcuts or include both modifiers manually. diff --git a/lib/index.ts b/lib/index.ts index a1580c3..30ffe85 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -6,6 +6,7 @@ import preferPlaceholders from "./rules/prefer-placeholders"; import preferTitleCase from "./rules/prefer-title-case"; import preferCommonShortcut from "./rules/prefer-common-shortcut"; import noReservedShortcut from "./rules/no-reserved-shortcut"; +import noAmbiguousPlatformShortcut from "./rules/no-ambiguous-platform-shortcut"; const pkg = JSON.parse( fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8") @@ -23,6 +24,7 @@ const plugin = { "prefer-placeholders": preferPlaceholders, "prefer-common-shortcut": preferCommonShortcut, "no-reserved-shortcut": noReservedShortcut, + "no-ambiguous-platform-shortcut": noAmbiguousPlatformShortcut, }, }; @@ -37,6 +39,7 @@ Object.assign(plugin.configs, { "@raycast/prefer-title-case": "warn", "@raycast/prefer-common-shortcut": "warn", "@raycast/no-reserved-shortcut": "warn", + "@raycast/no-ambiguous-platform-shortcut": "warn", }, }, ], diff --git a/lib/rules/no-ambiguous-platform-shortcut.ts b/lib/rules/no-ambiguous-platform-shortcut.ts new file mode 100644 index 0000000..fe310cf --- /dev/null +++ b/lib/rules/no-ambiguous-platform-shortcut.ts @@ -0,0 +1,142 @@ +import fs from "fs"; +import path from "path"; + +import { AST_NODE_TYPES, TSESLint, TSESTree } from "@typescript-eslint/utils"; + +import { createRule } from "../utils"; + +type SimpleShortcut = { modifiers: string[]; key: string }; + +const packagePlatformsCache = new Map(); + +function hasMultiPlatformConfig(filename: string | undefined): boolean { + if (!filename || filename.startsWith("<")) { + return false; + } + + let dir = path.dirname(filename); + while (true) { + const pkgPath = path.join(dir, "package.json"); + if (packagePlatformsCache.has(pkgPath)) { + return packagePlatformsCache.get(pkgPath)!; + } + + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + const platforms = pkg?.platforms; + const multi = Array.isArray(platforms) && platforms.length > 1; + packagePlatformsCache.set(pkgPath, multi); + return multi; + } catch { + packagePlatformsCache.set(pkgPath, false); + return false; + } + } + + const parent = path.dirname(dir); + if (parent === dir) { + break; + } + dir = parent; + } + + return false; +} + +function parseSimpleShortcut( + node: TSESTree.ObjectExpression +): SimpleShortcut | null { + const props = new Map(); + for (const prop of node.properties) { + if ( + prop.type !== AST_NODE_TYPES.Property || + prop.key.type !== AST_NODE_TYPES.Identifier + ) { + return null; + } + if (prop.key.name !== "modifiers" && prop.key.name !== "key") { + return null; + } + props.set(prop.key.name, prop); + } + + if (props.size !== 2) { + return null; + } + + const modifiersProp = props.get("modifiers"); + const keyProp = props.get("key"); + if (!modifiersProp || !keyProp) return null; + if ( + modifiersProp.value.type !== AST_NODE_TYPES.ArrayExpression || + keyProp.value.type !== AST_NODE_TYPES.Literal || + typeof keyProp.value.value !== "string" + ) { + return null; + } + + const modifiers: string[] = []; + for (const el of modifiersProp.value.elements) { + if (!el) continue; + if (el.type !== AST_NODE_TYPES.Literal || typeof el.value !== "string") { + return null; + } + modifiers.push(el.value); + } + + return { modifiers, key: keyProp.value.value }; +} + +function isAmbiguous(modifiers: string[]) { + const hasCmd = modifiers.includes("cmd"); + const hasCtrl = modifiers.includes("ctrl"); + return (hasCmd || hasCtrl) && !(hasCmd && hasCtrl); +} + +export default createRule({ + name: "no-ambiguous-platform-shortcut", + meta: { + type: "problem", + docs: { + description: + "Warn when a shortcut is ambiguous in cross-platform extensions.", + }, + schema: [], + messages: { + ambiguous: + "This shortcut is ambiguous across platforms. Provide platform-specific shortcuts.", + }, + }, + defaultOptions: [], + create(context) { + const hasMultiPlatform = hasMultiPlatformConfig( + (context as TSESLint.RuleContext).filename + ); + + if (!hasMultiPlatform) { + return {}; + } + + return { + JSXAttribute(node) { + if ( + node.name.type === AST_NODE_TYPES.JSXIdentifier && + node.name.name === "shortcut" && + node.value && + node.value.type === AST_NODE_TYPES.JSXExpressionContainer && + node.value.expression.type === AST_NODE_TYPES.ObjectExpression + ) { + const simple = parseSimpleShortcut(node.value.expression); + if (!simple) return; + if (!isAmbiguous(simple.modifiers)) return; + + context.report({ + node: node.value, + messageId: "ambiguous", + }); + } + }, + }; + }, +}); diff --git a/lib/rules/no-reserved-shortcut.ts b/lib/rules/no-reserved-shortcut.ts index 42101f8..465912e 100644 --- a/lib/rules/no-reserved-shortcut.ts +++ b/lib/rules/no-reserved-shortcut.ts @@ -87,12 +87,12 @@ export default createRule({ type: "suggestion", docs: { description: - "Warn when a shortcut prop defines a literal reserved shortcut that Raycast uses internally.", + "Warn when a shortcut prop defines a reserved shortcut that Raycast uses.", }, schema: [], messages: { reserved: - "Shortcut literal matches reserved shortcut '{{name}}'. Choose a different shortcut to avoid conflicts.", + "Shortcut matches reserved shortcut '{{name}}' and will be ignored by Raycast.", }, }, defaultOptions: [], diff --git a/lib/rules/prefer-common-shortcut.ts b/lib/rules/prefer-common-shortcut.ts index e6670bf..ce866f6 100644 --- a/lib/rules/prefer-common-shortcut.ts +++ b/lib/rules/prefer-common-shortcut.ts @@ -268,7 +268,7 @@ export default createRule({ type: "suggestion", docs: { description: - "Warn when a literal shortcut matches a common one; prefer Keyboard.Shortcut.Common.* from @raycast/api.", + "Warn when a shortcut matches a common one; prefer Keyboard.Shortcut.Common.* from @raycast/api.", }, fixable: "code", schema: [], diff --git a/tests/fixtures/multi-platform/package.json b/tests/fixtures/multi-platform/package.json new file mode 100644 index 0000000..62f5d30 --- /dev/null +++ b/tests/fixtures/multi-platform/package.json @@ -0,0 +1,7 @@ +{ + "name": "multi-platform-extension", + "platforms": [ + "macOS", + "windows" + ] +} diff --git a/tests/fixtures/multi-platform/src/.gitkeep b/tests/fixtures/multi-platform/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/single-platform/package.json b/tests/fixtures/single-platform/package.json new file mode 100644 index 0000000..996c6b3 --- /dev/null +++ b/tests/fixtures/single-platform/package.json @@ -0,0 +1,6 @@ +{ + "name": "single-platform-extension", + "platforms": [ + "macOS" + ] +} diff --git a/tests/fixtures/single-platform/src/.gitkeep b/tests/fixtures/single-platform/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/no-ambiguous-platform-shortcut.test.ts b/tests/no-ambiguous-platform-shortcut.test.ts new file mode 100644 index 0000000..1d1b672 --- /dev/null +++ b/tests/no-ambiguous-platform-shortcut.test.ts @@ -0,0 +1,68 @@ +import path from "path"; + +// @ts-ignore +import { RuleTester } from "@typescript-eslint/rule-tester"; +import rule from "../lib/rules/no-ambiguous-platform-shortcut"; + +const multiPlatformFile = path.join( + __dirname, + "fixtures", + "multi-platform", + "src", + "command.tsx" +); + +const singlePlatformFile = path.join( + __dirname, + "fixtures", + "single-platform", + "src", + "command.tsx" +); + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { jsx: true }, + ecmaVersion: 2020, + sourceType: "module", + }, + }, +}); + +ruleTester.run("no-ambiguous-platform-shortcut", rule, { + valid: [ + // Not multi-platform file (single platform config) + { + filename: singlePlatformFile, + code: ``, + }, + // Multi-platform but includes both modifiers => not ambiguous + { + filename: multiPlatformFile, + code: ``, + }, + // Multi-platform but platform-specific object literal (ignored) + { + filename: multiPlatformFile, + code: ``, + }, + // Non-shortcut attribute + { + filename: multiPlatformFile, + code: ``, + }, + ], + invalid: [ + { + filename: multiPlatformFile, + code: ``, + errors: [{ messageId: "ambiguous" }], + }, + { + filename: multiPlatformFile, + code: ``, + errors: [{ messageId: "ambiguous" }], + }, + ], +}); From 1435c2b89d6cd9320314ef7a90d3bf2c3bf296c7 Mon Sep 17 00:00:00 2001 From: mathieudutour Date: Fri, 14 Nov 2025 17:07:49 +0100 Subject: [PATCH 4/4] fix --- README.md | 13 ++++++++----- docs/rules/no-ambiguous-platform-shortcut.md | 2 +- docs/rules/no-reserved-shortcut.md | 2 +- docs/rules/prefer-common-shortcut.md | 2 +- lib/rules/no-ambiguous-platform-shortcut.ts | 4 +--- lib/rules/prefer-common-shortcut.ts | 2 +- package.json | 3 ++- 7 files changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 183e02d..15d7ae0 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,13 @@ You'll find below a summary of all the rules included in our ESLint plugin. 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix). -| Name | Description | 🔧 | -| :------------------------------------------------------- | :---------------------------------- | :- | -| [prefer-ellipsis](docs/rules/prefer-ellipsis.md) | Prefer Ellipsis Character | 🔧 | -| [prefer-placeholders](docs/rules/prefer-placeholders.md) | Prefer Placeholders for Text Fields | | -| [prefer-title-case](docs/rules/prefer-title-case.md) | Prefer Title Case | 🔧 | +| Name                           | Description | 🔧 | +| :----------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------- | :- | +| [no-ambiguous-platform-shortcut](docs/rules/no-ambiguous-platform-shortcut.md) | Warn when a shortcut is ambiguous in cross-platform extensions. | | +| [no-reserved-shortcut](docs/rules/no-reserved-shortcut.md) | Warn when a shortcut prop defines a reserved shortcut that Raycast uses. | | +| [prefer-common-shortcut](docs/rules/prefer-common-shortcut.md) | Warn when a shortcut matches a common one; prefer Keyboard.Shortcut.Common.* from @raycast/api. | 🔧 | +| [prefer-ellipsis](docs/rules/prefer-ellipsis.md) | Prefer Ellipsis Character | 🔧 | +| [prefer-placeholders](docs/rules/prefer-placeholders.md) | Prefer Placeholders for Text Fields | | +| [prefer-title-case](docs/rules/prefer-title-case.md) | Prefer Title Case | 🔧 | diff --git a/docs/rules/no-ambiguous-platform-shortcut.md b/docs/rules/no-ambiguous-platform-shortcut.md index 83f2702..7ad5377 100644 --- a/docs/rules/no-ambiguous-platform-shortcut.md +++ b/docs/rules/no-ambiguous-platform-shortcut.md @@ -1,4 +1,4 @@ -# No Ambiguous Platform Shortcut (`@raycast/no-ambiguous-platform-shortcut`) +# Warn when a shortcut is ambiguous in cross-platform extensions (`@raycast/no-ambiguous-platform-shortcut`) diff --git a/docs/rules/no-reserved-shortcut.md b/docs/rules/no-reserved-shortcut.md index 9f7e964..d1f7e3f 100644 --- a/docs/rules/no-reserved-shortcut.md +++ b/docs/rules/no-reserved-shortcut.md @@ -1,4 +1,4 @@ -# No Reserved Shortcut (`@raycast/no-reserved-shortcut`) +# Warn when a shortcut prop defines a reserved shortcut that Raycast uses (`@raycast/no-reserved-shortcut`) diff --git a/docs/rules/prefer-common-shortcut.md b/docs/rules/prefer-common-shortcut.md index 45a735d..18e4fa1 100644 --- a/docs/rules/prefer-common-shortcut.md +++ b/docs/rules/prefer-common-shortcut.md @@ -1,4 +1,4 @@ -# Prefer Common Shortcuts (`@raycast/prefer-common-shortcut`) +# Warn when a shortcut matches a common one; prefer Keyboard.Shortcut.Common.* from @raycast/api (`@raycast/prefer-common-shortcut`) 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). diff --git a/lib/rules/no-ambiguous-platform-shortcut.ts b/lib/rules/no-ambiguous-platform-shortcut.ts index fe310cf..87b1eef 100644 --- a/lib/rules/no-ambiguous-platform-shortcut.ts +++ b/lib/rules/no-ambiguous-platform-shortcut.ts @@ -110,9 +110,7 @@ export default createRule({ }, defaultOptions: [], create(context) { - const hasMultiPlatform = hasMultiPlatformConfig( - (context as TSESLint.RuleContext).filename - ); + const hasMultiPlatform = hasMultiPlatformConfig(context.filename); if (!hasMultiPlatform) { return {}; diff --git a/lib/rules/prefer-common-shortcut.ts b/lib/rules/prefer-common-shortcut.ts index ce866f6..9988468 100644 --- a/lib/rules/prefer-common-shortcut.ts +++ b/lib/rules/prefer-common-shortcut.ts @@ -305,7 +305,7 @@ export default createRule({ // Replace object literal fixes.push( fixer.replaceText( - node.value as any, + node.value as TSESTree.Node, `{Keyboard.Shortcut.Common.${match.name}}` ) ); diff --git a/package.json b/package.json index d1ece79..b25de86 100644 --- a/package.json +++ b/package.json @@ -48,5 +48,6 @@ }, "peerDependencies": { "eslint": ">=8.23.0" - } + }, + "packageManager": "pnpm@9.15.0+sha512.76e2379760a4328ec4415815bcd6628dee727af3779aaa4c914e3944156c4299921a89f976381ee107d41f12cfa4b66681ca9c718f0668fa0831ed4c6d8ba56c" }