diff --git a/.changeset/rude-items-shout.md b/.changeset/rude-items-shout.md new file mode 100644 index 0000000..580c128 --- /dev/null +++ b/.changeset/rude-items-shout.md @@ -0,0 +1,5 @@ +--- +'@feature-sliced/steiger-plugin': minor +--- + +Extend the functionality of `inconsistent-naming` to support multi-word names and handle rename collisions diff --git a/packages/steiger-plugin-fsd/src/inconsistent-naming/get-main-subject.ts b/packages/steiger-plugin-fsd/src/inconsistent-naming/get-main-subject.ts new file mode 100644 index 0000000..5c8d9e5 --- /dev/null +++ b/packages/steiger-plugin-fsd/src/inconsistent-naming/get-main-subject.ts @@ -0,0 +1,91 @@ +/** Extracts individual words in every naming scheme. */ +export const wordPattern = /([A-Z0-9]{2,}(?![A-Z][a-z])|[A-Z]?[a-z0-9]+)/g + +/** + * Extract the main subject in a multi-word subject name. + * + * @example + * getMainSubject("a book with pages") // "book" + * getMainSubject("admin-users") // "users" + * getMainSubject("receiptsByOrder") // "receipts" + */ +export function getMainSubject(name: string) { + const words = [...name.matchAll(wordPattern)] + .map((match) => match[0]) + .filter((word) => !articles.includes(word.toLocaleLowerCase())) + + const prepositionIndex = words.findIndex((word) => prepositions.includes(word.toLocaleLowerCase())) + if (prepositionIndex === -1) { + return words[words.length - 1] + } + const mainPart = words.slice(0, prepositionIndex) + return mainPart[mainPart.length - 1] +} + +if (import.meta.vitest) { + const { test, expect } = import.meta.vitest + + test('getMainSubject', () => { + expect(getMainSubject('a book with pages')).toEqual('book') + expect(getMainSubject('admin-users')).toEqual('users') + expect(getMainSubject('receiptsByOrder')).toEqual('receipts') + }) +} + +const articles = ['a', 'an', 'the'] +const prepositions = [ + 'about', + 'above', + 'across', + 'after', + 'against', + 'along', + 'among', + 'around', + 'at', + 'before', + 'behind', + 'below', + 'beneath', + 'beside', + 'besides', + 'between', + 'beyond', + 'but', + 'by', + 'concerning', + 'despite', + 'down', + 'during', + 'except', + 'excepting', + 'for', + 'from', + 'in', + 'inside', + 'into', + 'like', + 'near', + 'of', + 'off', + 'on', + 'onto', + 'out', + 'outside', + 'over', + 'past', + 'regarding', + 'since', + 'through', + 'throughout', + 'to', + 'toward', + 'under', + 'underneath', + 'until', + 'up', + 'upon', + 'with', + 'within', + 'without', +] diff --git a/packages/steiger-plugin-fsd/src/inconsistent-naming/index.spec.ts b/packages/steiger-plugin-fsd/src/inconsistent-naming/index.spec.ts index 5bfed10..87aa9a5 100644 --- a/packages/steiger-plugin-fsd/src/inconsistent-naming/index.spec.ts +++ b/packages/steiger-plugin-fsd/src/inconsistent-naming/index.spec.ts @@ -1,10 +1,10 @@ import { expect, it } from 'vitest' -import { compareMessages, joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' +import { joinFromRoot, parseIntoFolder as parseIntoFsdRoot } from '@steiger/toolkit' import inconsistentNaming from './index.js' -it('reports no errors on slice names that are pluralized consistently', () => { - const root = parseIntoFsdRoot( +it('reports no errors on entity names that are pluralized consistently', () => { + const root1 = parseIntoFsdRoot( ` 📂 entities 📂 users @@ -16,11 +16,41 @@ it('reports no errors on slice names that are pluralized consistently', () => { `, joinFromRoot('users', 'user', 'project', 'src'), ) + const root2 = parseIntoFsdRoot( + ` + 📂 entities + 📂 user + 📂 ui + 📄 index.ts + 📂 post + 📂 ui + 📄 index.ts + `, + joinFromRoot('users', 'user', 'project', 'src'), + ) + + expect(inconsistentNaming.check(root1)).toEqual({ diagnostics: [] }) + expect(inconsistentNaming.check(root2)).toEqual({ diagnostics: [] }) +}) + +it('reports no errors on multi-word entity names that are pluralized consistently', () => { + const root = parseIntoFsdRoot( + ` + 📂 entities + 📂 admin-users + 📂 ui + 📄 index.ts + 📂 employers-of-record + 📂 ui + 📄 index.ts + `, + joinFromRoot('users', 'user', 'project', 'src'), + ) expect(inconsistentNaming.check(root)).toEqual({ diagnostics: [] }) }) -it('reports an error on slice names that are not pluralized consistently', () => { +it('reports an error on entity names that are not pluralized consistently', () => { const root = parseIntoFsdRoot( ` 📂 entities @@ -34,10 +64,10 @@ it('reports an error on slice names that are not pluralized consistently', () => joinFromRoot('users', 'user', 'project', 'src'), ) - const diagnostics = inconsistentNaming.check(root).diagnostics.sort(compareMessages) + const diagnostics = inconsistentNaming.check(root).diagnostics expect(diagnostics).toEqual([ { - message: 'Inconsistent pluralization of slice names. Prefer all plural names', + message: 'Inconsistent pluralization of entity names. Prefer all plural names.', fixes: [ { type: 'rename', @@ -54,10 +84,10 @@ it('prefers the singular form when there are more singular slices', () => { const root = parseIntoFsdRoot( ` 📂 entities - 📂 user + 📂 admin-user 📂 ui 📄 index.ts - 📂 post + 📂 news-post 📂 ui 📄 index.ts 📂 comments @@ -67,10 +97,10 @@ it('prefers the singular form when there are more singular slices', () => { joinFromRoot('users', 'user', 'project', 'src'), ) - const diagnostics = inconsistentNaming.check(root).diagnostics.sort(compareMessages) + const diagnostics = inconsistentNaming.check(root).diagnostics expect(diagnostics).toEqual([ { - message: 'Inconsistent pluralization of slice names. Prefer all singular names', + message: 'Inconsistent pluralization of entity names. Prefer all singular names.', fixes: [ { type: 'rename', @@ -82,3 +112,48 @@ it('prefers the singular form when there are more singular slices', () => { }, ]) }) + +it('recognizes the special case when there are two pluralizations of the same name', () => { + const root = parseIntoFsdRoot( + ` + 📂 entities + 📂 admin-user + 📂 ui + 📄 index.ts + 📂 admin-users + 📂 ui + 📄 index.ts + `, + joinFromRoot('users', 'user', 'project', 'src'), + ) + + const diagnostics = inconsistentNaming.check(root).diagnostics + expect(diagnostics).toEqual([ + { + message: 'Avoid having both "admin-user" and "admin-users" entities.', + location: { path: joinFromRoot('users', 'user', 'project', 'src', 'entities', 'admin-user') }, + }, + ]) +}) + +it('allows inconsistency between different slice groups', () => { + const root = parseIntoFsdRoot( + ` + 📂 entities + 📂 admin-user + 📂 ui + 📄 index.ts + 📂 group + 📄 index.ts + 📂 post-parts + 📂 posts + 📄 index.ts + 📂 authors + 📄 index.ts + `, + joinFromRoot('users', 'user', 'project', 'src'), + ) + + const diagnostics = inconsistentNaming.check(root).diagnostics + expect(diagnostics).toEqual([]) +}) diff --git a/packages/steiger-plugin-fsd/src/inconsistent-naming/index.ts b/packages/steiger-plugin-fsd/src/inconsistent-naming/index.ts index 9fd3af1..5caa170 100644 --- a/packages/steiger-plugin-fsd/src/inconsistent-naming/index.ts +++ b/packages/steiger-plugin-fsd/src/inconsistent-naming/index.ts @@ -7,8 +7,9 @@ import type { PartialDiagnostic, Rule } from '@steiger/toolkit' import { groupSlices } from '../_lib/group-slices.js' import { NAMESPACE } from '../constants.js' +import { getMainSubject } from './get-main-subject.js' -/** Detect inconsistent naming of slices on layers (singular vs plural) */ +/** Detect inconsistent pluralization of entities. */ const inconsistentNaming = { name: `${NAMESPACE}/inconsistent-naming` as const, check(root) { @@ -22,29 +23,57 @@ const inconsistentNaming = { const slices = getSlices(entities) const sliceNames = groupSlices(Object.keys(slices)) for (const [groupPrefix, group] of Object.entries(sliceNames)) { - const [pluralNames, singularNames] = partition(group, isPlural) + const [pluralNames, singularNames] = partition( + group.map((name) => [name, getMainSubject(name)] as const), + ([, mainSubject]) => isPlural(mainSubject), + ) + + /** Names that exist in both singular and plural forms for filtering later. */ + const duplicates = { + singular: [] as Array, + plural: [] as Array, + } if (pluralNames.length > 0 && singularNames.length > 0) { - const message = 'Inconsistent pluralization of slice names' + for (const [singularName, mainSubject] of singularNames) { + const pluralized = singularName.replace(mainSubject, plural(mainSubject)) + if (group.includes(pluralized)) { + duplicates.singular.push(singularName) + duplicates.plural.push(pluralized) + + diagnostics.push({ + message: `Avoid having both "${singularName}" and "${plural(singularName)}" entities${groupPrefix === '' ? '' : ' in the same slice group'}.`, + location: { path: join(entities.path, groupPrefix, singularName) }, + }) + } + } - if (pluralNames.length >= singularNames.length) { + const message = 'Inconsistent pluralization of entity names' + if ( + pluralNames.length >= singularNames.length && + singularNames.some(([name]) => !duplicates.singular.includes(name)) + ) { diagnostics.push({ - message: `${message}. Prefer all plural names`, - fixes: singularNames.map((name) => ({ - type: 'rename', - path: join(entities.path, groupPrefix, name), - newName: plural(name), - })), + message: `${message}. Prefer all plural names.`, + fixes: singularNames + .filter(([name]) => !duplicates.singular.includes(name)) + .map(([name, mainWord]) => ({ + type: 'rename', + path: join(entities.path, groupPrefix, name), + newName: name.replace(mainWord, plural(mainWord)), + })), location: { path: join(entities.path, groupPrefix) }, }) - } else { + } else if (pluralNames.some(([name]) => !duplicates.plural.includes(name))) { diagnostics.push({ - message: `${message}. Prefer all singular names`, - fixes: pluralNames.map((name) => ({ - type: 'rename', - path: join(entities.path, groupPrefix, name), - newName: singular(name), - })), + message: `${message}. Prefer all singular names.`, + fixes: pluralNames + .filter(([name]) => !duplicates.plural.includes(name)) + .map(([name, mainWord]) => ({ + type: 'rename', + path: join(entities.path, groupPrefix, name), + newName: name.replace(mainWord, singular(mainWord)), + })), location: { path: join(entities.path, groupPrefix) }, }) }