diff --git a/apps/showcase/eslint.local.config.mjs b/apps/showcase/eslint.local.config.mjs index e41af57914..a4198cd1f2 100644 --- a/apps/showcase/eslint.local.config.mjs +++ b/apps/showcase/eslint.local.config.mjs @@ -67,6 +67,12 @@ export default [ } } } + ], + '@o3r/o3r-restriction-key-tags': [ + 'error', + { + supportedKeys: ['restriction_1', 'restriction 2'] + } ] } } diff --git a/apps/showcase/src/components/showcase/configuration/configuration-pres.config.ts b/apps/showcase/src/components/showcase/configuration/configuration-pres.config.ts index 3c8f0fca94..833462cc9d 100644 --- a/apps/showcase/src/components/showcase/configuration/configuration-pres.config.ts +++ b/apps/showcase/src/components/showcase/configuration/configuration-pres.config.ts @@ -36,6 +36,8 @@ export interface ConfigurationPresConfig extends Configuration { * @o3rWidgetParam allDestinationsDifferent true * @o3rWidgetParam atLeastOneDestinationAvailable true * @o3rWidgetParam destinationPattern "[A-Z][a-zA-Z-' ]+" + * @o3rRestrictionKey restriction_1 + * @o3rRestrictionKey "restriction 2" */ destinations: DestinationConfiguration[]; /** diff --git a/docs/linter/eslint-plugin/rules/o3r-restriction-key-tags.md b/docs/linter/eslint-plugin/rules/o3r-restriction-key-tags.md new file mode 100644 index 0000000000..6f426eee00 --- /dev/null +++ b/docs/linter/eslint-plugin/rules/o3r-restriction-key-tags.md @@ -0,0 +1,64 @@ +# @o3r/o3r-restriction-key-tags + +Ensures that `@o3rRestrictionKey` are used with correct value. + +**Warning** This rule must be configured to be used. + +## How to use + +```json +{ + "@o3r/o3r-widget-tags": [ + "error", + { + "supportedInterfaceNames": ["NestedConfiguration", "Configuration", "CustomConfigurationInterface"], + "supportedKeys": ["restriction_1", "restriction 2"] + } + ] +} +``` + +## Valid code example + +```typescript +export interface ConfigExample extends Configuration { + /** + * @o3rRestrictionKey restriction_1 + */ + prop1: string; + /** + * @o3rRestrictionKey restriction_1 + * @o3rRestrictionKey "restriction 2" + */ + prop2: string; +} +``` + +## Invalid code example + +```typescript +export interface ConfigExample extends Configuration { + /** + * @o3rRestrictionKey restriction 2 + */ + prop: string; +} +``` + +```typescript +export interface ConfigExample extends Configuration { + /** + * @o3rRestrictionKey unknownRestriction + */ + prop: string | number; +} +``` + +```typescript +export interface Example { + /** + * @o3rRestrictionKey restriction_1 + */ + prop: string; +} +``` diff --git a/packages/@o3r/components/builders/component-extractor/helpers/component/component-config.extractor.ts b/packages/@o3r/components/builders/component-extractor/helpers/component/component-config.extractor.ts index daf9416ead..f531d62e31 100644 --- a/packages/@o3r/components/builders/component-extractor/helpers/component/component-config.extractor.ts +++ b/packages/@o3r/components/builders/component-extractor/helpers/component/component-config.extractor.ts @@ -255,6 +255,7 @@ export class ComponentConfigExtractor { const res: ConfigProperty = { description: configDocInfo?.description || '', category: configDocInfo?.category, + restrictionKeys: configDocInfo?.restrictionKeys, label: configDocInfo?.label || name.replace(/([A-Z])/g, ' $1'), name, type: 'unknown', diff --git a/packages/@o3r/components/src/core/component.output.ts b/packages/@o3r/components/src/core/component.output.ts index a804368bde..63af084dc0 100644 --- a/packages/@o3r/components/src/core/component.output.ts +++ b/packages/@o3r/components/src/core/component.output.ts @@ -106,6 +106,8 @@ export interface ConfigProperty { widget?: ConfigPropertyWidget; /** If true, the CMS user must specify a value for the property */ required?: boolean; + /** Restriction keys */ + restrictionKeys?: string[]; } /** diff --git a/packages/@o3r/configuration/schemas/configuration.metadata.schema.json b/packages/@o3r/configuration/schemas/configuration.metadata.schema.json index 4324540ec7..84a66fae3a 100644 --- a/packages/@o3r/configuration/schemas/configuration.metadata.schema.json +++ b/packages/@o3r/configuration/schemas/configuration.metadata.schema.json @@ -186,6 +186,13 @@ "items": { "type": "string" } + }, + "restrictionKeys": { + "type": "array", + "description": "Restriction keys", + "items": { + "type": "string" + } } }, "allOf": [ diff --git a/packages/@o3r/eslint-config-otter/rules/typescript/jsdoc.cjs b/packages/@o3r/eslint-config-otter/rules/typescript/jsdoc.cjs index 879b3f045d..652f002931 100644 --- a/packages/@o3r/eslint-config-otter/rules/typescript/jsdoc.cjs +++ b/packages/@o3r/eslint-config-otter/rules/typescript/jsdoc.cjs @@ -20,7 +20,7 @@ module.exports = { 'jsdoc/check-tag-names': [ 'warn', { - definedTags: ['note', 'title', 'o3rCategory', 'o3rCategories', 'o3rWidget', 'o3rWidgetParam', 'o3rRequired'] + definedTags: ['note', 'title', 'o3rRestrictionKey', 'o3rCategory', 'o3rCategories', 'o3rWidget', 'o3rWidgetParam', 'o3rRequired'] } ], 'jsdoc/check-types': 'warn', diff --git a/packages/@o3r/eslint-config/src/rules/typescript/jsdoc.cjs b/packages/@o3r/eslint-config/src/rules/typescript/jsdoc.cjs index fbf6f15c11..16db99caf1 100644 --- a/packages/@o3r/eslint-config/src/rules/typescript/jsdoc.cjs +++ b/packages/@o3r/eslint-config/src/rules/typescript/jsdoc.cjs @@ -20,7 +20,7 @@ const config = [ 'jsdoc/check-tag-names': [ 'error', { - definedTags: ['note', 'title', 'o3rCategory', 'o3rCategories', 'o3rWidget', 'o3rWidgetParam', 'o3rRequired'] + definedTags: ['note', 'title', 'o3rRestrictionKey', 'o3rCategory', 'o3rCategories', 'o3rWidget', 'o3rWidgetParam', 'o3rRequired'] } ], 'jsdoc/no-defaults': 'off', diff --git a/packages/@o3r/eslint-plugin/src/index.ts b/packages/@o3r/eslint-plugin/src/index.ts index bf5bec5c0a..6851899156 100644 --- a/packages/@o3r/eslint-plugin/src/index.ts +++ b/packages/@o3r/eslint-plugin/src/index.ts @@ -5,6 +5,7 @@ import matchingConfigurationName from './rules/typescript/matching-configuration import noFolderImportForModule from './rules/typescript/no-folder-import-for-module/no-folder-import-for-module'; import noMultipleTypeConfigurationProperty from './rules/typescript/no-multiple-type-configuration-property/no-multiple-type-configuration-property'; import o3rCategoriesTags from './rules/typescript/o3r-categories-tags/o3r-categories-tags'; +import o3rRestrictionKeyTags from './rules/typescript/o3r-restriction-key-tags/o3r-restriction-key-tags'; import o3rWidgetTags from './rules/typescript/o3r-widget-tags/o3r-widget-tags'; import yarnrcPackageExtensionHarmonize from './rules/yaml/yarnrc-package-extensions-harmonize/yarnrc-package-extensions-harmonize'; @@ -18,7 +19,8 @@ module.exports = { 'matching-configuration-name': matchingConfigurationName, 'yarnrc-package-extensions-harmonize': yarnrcPackageExtensionHarmonize, 'no-multiple-type-configuration-property': noMultipleTypeConfigurationProperty, - 'o3r-categories-tags': o3rCategoriesTags + 'o3r-categories-tags': o3rCategoriesTags, + 'o3r-restriction-key-tags': o3rRestrictionKeyTags }, configs: { '@o3r/no-folder-import-for-module': 'error', diff --git a/packages/@o3r/eslint-plugin/src/rules/typescript/o3r-restriction-key-tags/o3r-restriction-key-tags.spec.ts b/packages/@o3r/eslint-plugin/src/rules/typescript/o3r-restriction-key-tags/o3r-restriction-key-tags.spec.ts new file mode 100644 index 0000000000..dff58f1e06 --- /dev/null +++ b/packages/@o3r/eslint-plugin/src/rules/typescript/o3r-restriction-key-tags/o3r-restriction-key-tags.spec.ts @@ -0,0 +1,131 @@ +import o3rRestrictionKeyRule, { + O3rRestrictionKeyTagsRuleOption, +} from './o3r-restriction-key-tags'; +const { + RuleTester +} = require('@typescript-eslint/rule-tester'); + +const ruleTester = new RuleTester(); + +const code = ` +export interface Config extends Configuration { + /** + * @o3rRestrictionKey valid + * @o3rRestrictionKey valid_with_underscore + * @o3rRestrictionKey 'valid with space (single quote)' + * @o3rRestrictionKey "valid with space (double quote)" + * @o3rRestrictionKey '"valid" with double quote inside' + * @o3rRestrictionKey "'valid' with single quote inside" + * @o3rRestrictionKey valid_with_number_1 + */ + prop: string; +} +`; + +const supportedKeys = [ + 'valid', + 'valid_with_underscore', + 'valid with space', + 'valid with space (single quote)', + 'valid with space (double quote)', + '"valid" with double quote inside', + "'valid' with single quote inside", + 'valid_with_number_1' +]; + +const options = [{ supportedKeys }] as const satisfies Readonly<[O3rRestrictionKeyTagsRuleOption]>; + +const unknownKeys = [ + `unknown_restriction`, + `"invalid quote'`, + `'another invalid quote"`, + `'unknown with single quote'`, + `"unknown with double quote"` +]; + +const getCodeFor = (key: string) => ` +export interface Config extends Configuration { + /** + * @o3rRestrictionKey ${key} + */ + prop: string; +} +`; + +const getSuggestionFor = (actualKey: string) => supportedKeys.map((supportedKey) => ({ + messageId: 'suggestUseSupportedKey', + data: { + actualKey, + supportedKey + }, + output: getCodeFor(/[^\w]/.test(supportedKey) ? `"${supportedKey}"` : supportedKey) +})); + +ruleTester.run('o3r-restriction-key-tags', o3rRestrictionKeyRule, { + valid: [ + { + code, + options + }, + { + code: ` +export interface Config extends Configuration { + /** + * Property without restriction + */ + prop: string; +}`, + options + } + ], + invalid: [ + { + code: getCodeFor('"at least one key provided"'), + options: [{}], + errors: [ + { messageId: 'noRestrictionKeyProvided' } + ] + }, + { + code: code.replace(' extends Configuration', ''), + options, + errors: [ + { + messageId: 'notInConfigurationInterface' + } + ] + }, + { + code: getCodeFor('valid with space'), + options, + output: getCodeFor(`"valid with space"`), + errors: [ + { + messageId: 'notWrapWithQuotes', + data: { + actualKey: 'valid with space' + }, + suggestions: [{ + messageId: 'suggestWrapWithQuotes', + data: { + actualKey: 'valid with space' + }, + output: getCodeFor(`"valid with space"`) + }] + } + ] + }, + ...unknownKeys.map((key) => ({ + code: getCodeFor(key), + options, + errors: [{ + messageId: 'notSupportedKey', + data: { + actualKey: key, + supportedKeys: supportedKeys.join(', ') + }, + suggestions: getSuggestionFor(key) + }] + })) + ] +}); diff --git a/packages/@o3r/eslint-plugin/src/rules/typescript/o3r-restriction-key-tags/o3r-restriction-key-tags.ts b/packages/@o3r/eslint-plugin/src/rules/typescript/o3r-restriction-key-tags/o3r-restriction-key-tags.ts new file mode 100644 index 0000000000..45b35d7364 --- /dev/null +++ b/packages/@o3r/eslint-plugin/src/rules/typescript/o3r-restriction-key-tags/o3r-restriction-key-tags.ts @@ -0,0 +1,163 @@ +import { + TSESLint, + type TSESTree, +} from '@typescript-eslint/utils'; +import { + createCommentString, + createRule, + defaultSupportedInterfaceNames, + getNodeComment, + isExtendingConfiguration, +} from '../../utils'; + +export interface O3rRestrictionKeyTagsRuleOption { + supportedInterfaceNames?: string[]; + supportedKeys?: string[]; +} + +type O3rWidgetRuleErrorId = + | 'notSupportedKey' + | 'notWrapWithQuotes' + | 'suggestWrapWithQuotes' + | 'suggestUseSupportedKey' + | 'noRestrictionKeyProvided' + | 'notInConfigurationInterface'; + +const defaultOptions: [Required] = [{ + supportedInterfaceNames: defaultSupportedInterfaceNames, + supportedKeys: [] +}]; + +export default createRule<[Readonly, ...any], O3rWidgetRuleErrorId>({ + name: 'o3r-restriction-key-tags', + meta: { + hasSuggestions: true, + fixable: 'code', + type: 'problem', + docs: { + description: 'Ensures that @o3rRestrictionKey is used with a correct value' + }, + schema: [ + { + type: 'object', + properties: { + supportedInterfaceNames: { + type: 'array', + items: { + type: 'string' + } + }, + supportedKeys: { + type: 'array', + items: { + type: 'string' + }, + minItems: 1 + } + } + } + ], + messages: { + notSupportedKey: '{{ actualKey }} is not supported. Supported restriction keys: {{ supportedKeys }}.', + suggestUseSupportedKey: '{{ actualKey }} is not supported. Replace with {{ supportedKey }}.', + notWrapWithQuotes: '`{{ actualKey }}` is not wrapped with quotes.', + suggestWrapWithQuotes: 'Wrap `{{ actualKey }}` with quotes.', + notInConfigurationInterface: '@o3rRestrictionKey can only be used in a `Configuration` interface.', + noRestrictionKeyProvided: 'You must have at least one restriction key.' + } + }, + defaultOptions, + create: (context, [options]: Readonly<[O3rRestrictionKeyTagsRuleOption, ...any]>) => { + const rule = (node: TSESTree.TSPropertySignature) => { + const { sourceCode } = context; + const comment = getNodeComment(node, sourceCode); + + if (!comment || comment.value.length === 0) { + return; + } + + const { loc, value: docText } = comment; + const supportedKeys = options.supportedKeys || defaultOptions[0].supportedKeys; + const supportedKeysSet = new Set(supportedKeys); + + if (supportedKeys.length === 0) { + return context.report({ + messageId: 'noRestrictionKeyProvided', + node, + loc + }); + } + + const actualKeys = Array.from(docText.matchAll(/@o3rRestrictionKey\s+(.*)/g)).map((match) => match[1]); + + if (actualKeys.length === 0) { + return; + } + + const interfaceDeclNode = node.parent?.parent; + if (!isExtendingConfiguration(interfaceDeclNode, options.supportedInterfaceNames)) { + return context.report({ + messageId: 'notInConfigurationInterface', + node, + loc + }); + } + + actualKeys.forEach((actualKey) => { + if (!supportedKeysSet.has(actualKey)) { + if ( + /((["']).*?\2)/.test(actualKey) + && supportedKeysSet.has(actualKey.replace(/(^["']|["']$)/g, '')) + ) { + return; + } + const fix: (key: string) => TSESLint.ReportFixFunction = (key) => (fixer) => { + return fixer.replaceTextRange(comment.range, createCommentString(comment.value.replace(actualKey, /[^\w]/.test(key) ? `"${key}"` : key))); + }; + return context.report({ + messageId: 'notSupportedKey', + node, + loc, + data: { + actualKey, + supportedKeys: supportedKeys.join(', ') + }, + suggest: supportedKeys.map((supportedKey) => ({ + messageId: 'suggestUseSupportedKey', + data: { + actualKey, + supportedKey + }, + fix: fix(supportedKey) + })) + }); + } + if (/[^\w]/.test(actualKey)) { + const fix: TSESLint.ReportFixFunction = (fixer) => { + return fixer.replaceTextRange(comment.range, createCommentString(comment.value.replace(actualKey, `"${actualKey}"`))); + }; + return context.report({ + messageId: 'notWrapWithQuotes', + data: { + actualKey + }, + node, + loc, + fix, + suggest: [{ + messageId: 'suggestWrapWithQuotes', + data: { + actualKey + }, + fix + }] + }); + } + }); + }; + + return { + TSPropertySignature: rule + }; + } +}); diff --git a/packages/@o3r/extractors/src/utils/config-doc.spec.ts b/packages/@o3r/extractors/src/utils/config-doc.spec.ts index 1f418c5913..073c8ed723 100644 --- a/packages/@o3r/extractors/src/utils/config-doc.spec.ts +++ b/packages/@o3r/extractors/src/utils/config-doc.spec.ts @@ -1,5 +1,6 @@ import { getCategoriesFromDocText, + getRestrictionKeysFromDocText, getWidgetInformationFromDocComment, } from './config-doc'; @@ -68,4 +69,25 @@ describe('config doc', () => { ]); }); }); + describe('getRestrictionKeysFromDocText', () => { + it('should get the valid restriction keys', () => { + const restrictionKeys = getRestrictionKeysFromDocText(` + /** + * @o3rRestrictionKey valid + * @o3rRestrictionKey valid_1 + * @o3rRestrictionKey invalid-2 + * @o3rRestrictionKey "valid-3" + * @o3rRestrictionKey 'valid 4' + * @o3rRestrictionKey invalid 5 + * @o3rRestrictionKey "invalid quote' + * @o3rRestrictionKey 'another invalid quote" + * @o3rRestrictionKey "valid 'quote'" + * @o3rRestrictionKey 'another valid "quote"' + */ + `); + expect(restrictionKeys).toEqual([ + 'valid', 'valid_1', 'valid-3', 'valid 4', "valid 'quote'", 'another valid "quote"' + ]); + }); + }); }); diff --git a/packages/@o3r/extractors/src/utils/config-doc.ts b/packages/@o3r/extractors/src/utils/config-doc.ts index ff2522a93c..5066d04050 100644 --- a/packages/@o3r/extractors/src/utils/config-doc.ts +++ b/packages/@o3r/extractors/src/utils/config-doc.ts @@ -31,6 +31,9 @@ export interface ConfigDocInformation { /** Tags (taken from `@tags` tag) */ tags?: string[]; + /** Restriction keys (taken from `@o3rRestrictionKey` tag) */ + restrictionKeys?: string[]; + /** Category (taken from `@o3rCategory` tag) */ category?: string; @@ -138,6 +141,17 @@ export function isO3rRequiredTagPresent(docText: string): boolean { return /@o3rRequired/.test(docText); } +/** + * Get restriction keys from a given DocComment. + * + * The restriction keys extracted from @o3rRestrictionKey tag. + * @param docComment The DocComment to get category from + */ +export function getRestrictionKeysFromDocText(docComment: string): string[] { + return Array.from(docComment.matchAll(/@o3rRestrictionKey\s+(\w+|"[^"\n]*"|'[^'\n]*')$/gm)) + .map((match) => match[1].replaceAll(/(^["']|["']$)/g, '')); +} + /** * Get category from a given DocComment. * @@ -221,6 +235,7 @@ export class ConfigDocParser { tags: getTagsFromDocComment(docComment), category: getCategoryFromDocText(docText), categories: getCategoriesFromDocText(docText), + restrictionKeys: getRestrictionKeysFromDocText(docText), widget: getWidgetInformationFromDocComment(docText), required: isO3rRequiredTagPresent(docText) };