From ea13d32766ff962eaac9a1b0ed9c0cc9d6dc73cf Mon Sep 17 00:00:00 2001 From: IT-WIBRC Date: Wed, 1 Oct 2025 06:46:25 +0100 Subject: [PATCH] feat(option): update 'dk list' filtering to '--where' with new advanced syntax --- .changeset/mean-suits-invite.md | 5 + packages/devkit/README.md | 17 +- packages/devkit/TODO.md | 8 +- .../integrations/config/index.spec.ts | 2 +- .../__tests__/integrations/list.spec.ts | 80 +++-- .../units/commands/config/logic.spec.ts | 15 +- .../__tests__/units/commands/list.spec.ts | 56 +++- .../units/core/template/filter.spec.ts | 282 ++++++++++++++++++ .../units/core/template/printer.spec.ts | 176 ++++++----- packages/devkit/locales/en.json | 15 +- packages/devkit/locales/fr.json | 101 ++++--- packages/devkit/src/commands/list.ts | 10 +- packages/devkit/src/core/template/filter.ts | 156 ++++++++++ packages/devkit/src/core/template/printer.ts | 49 ++- 14 files changed, 745 insertions(+), 227 deletions(-) create mode 100644 .changeset/mean-suits-invite.md create mode 100644 packages/devkit/__tests__/units/core/template/filter.spec.ts create mode 100644 packages/devkit/src/core/template/filter.ts diff --git a/.changeset/mean-suits-invite.md b/.changeset/mean-suits-invite.md new file mode 100644 index 0000000..ff3e732 --- /dev/null +++ b/.changeset/mean-suits-invite.md @@ -0,0 +1,5 @@ +--- +"scaffolder-toolkit": minor +--- + +feat(option): update 'dk list' filtering to '--where' with new advanced syntax diff --git a/packages/devkit/README.md b/packages/devkit/README.md index 511f6fd..c71951c 100644 --- a/packages/devkit/README.md +++ b/packages/devkit/README.md @@ -168,8 +168,8 @@ dk list # List templates for a specific language (e.g., 'javascript') dk list javascript -# List templates and filter by a substring (e.g., 'vue') -dk list --filter vue +# List templates and filter by a where clause (e.g., 'name:vue') +dk list --where name:vue ``` #### Options @@ -179,7 +179,7 @@ The `dk list` command now uses the following options to control which templates - **`--local`**: Only list templates from the local configuration file (`.devkit.json`). - **`--global`**: Only list templates from the global configuration file (`~/.devkitrc`). - **`--all`**: List templates from both the local and global configurations, merging them into a single list. -- **`--filter `**: Filter templates by name or alias substring. +- **`--where `**: **Filter templates using one or more property clauses.** Clauses must be in the format **`property:value`** or **`property=value`** (e.g., `alias:rt`, `pm=npm`, `name:/^node/`). Multiple `--where` arguments form a logical **AND** filter. - **`--mode `**: Sets the display mode for the template list. Options are **`tree`** (default, detailed view) or **`table`** (compact, column-based view). #### Examples @@ -196,14 +196,11 @@ dk list --global # List templates from both local and global configs dk list --all -# List templates and filter by name or alias substring -dk list --filter vue +# List templates and filter by alias 'rt' (using colon) AND package manager 'npm' (using equals) +dk list --where alias:rt --where pm=npm -# List javascript templates and filter by name or alias substring -dk list javascript --filter react - -# List javascript templates and filter by name starting or containing -dk list javascript --filter r +# List javascript templates and filter by templates whose name starts with 'r' +dk list javascript --where name:/^r/ # List templates in a compact table format dk list --mode table diff --git a/packages/devkit/TODO.md b/packages/devkit/TODO.md index a1519b9..a850f81 100644 --- a/packages/devkit/TODO.md +++ b/packages/devkit/TODO.md @@ -58,13 +58,13 @@ This document tracks all planned and completed tasks for the Dev Kit project. - [x] Refactor and restructure the utilities - [x] Better json structure for languages translation - [x] **Enhance `list` Command**: Add support for **different display modes** (e.g., table or tree structure). `tree` as default -- [ ] **Enhance `list` Command**: Add options to **filter by properties** (e.g., `packageManager`, `alias`, etc.). +- [x] **Enhance `list` Command**: Add options to **filter by properties** (e.g., `packageManager`, `alias`, etc.). - [ ] **Enhance `list` Command**: Add flag to also see default config `--with-defaults`. - [ ] Add wildcard support for template name in the `dk config update` and `dk config remove` commands. -- [ ] ** Enhance for organization Purpose **: Add new language `Typescript` with same code as javascript, also support for nodejs template name for those who prefer it than the language -- [ ] Add a configuration validation step when initializing or updating the config file to ensure all required fields are present and correctly formatted. +- [ ] ** Enhance for organization Purpose **: Add new language `Typescript(ts)` with same code as javascript(js), also support for nodejs(node) template name for those who prefer it than the programming language name +- [ ] Add a configuration validation step when updating the config file to ensure all required fields are present and correctly formatted. - [ ] **Dynamic Help Text**: Programmatically generate help text for options with constrained values (e.g., `--cache-strategy`) to ensure it's always up to date. -- [ ] **Skip Confirmation**: Add a global `-y` or `--yes` option to skip confirmation prompts in commands like `dk init`. +- [ ] **Skip Confirmation**: Add a global `-y`/`--yes` and `-n/--no` option to skip confirmation prompts in commands like `dk init`. - [ ] **CLI Self-Update**: Implement a command to allow users to update the CLI itself. `dk upgrade` - [ ] **Testing**: Stabilize the integration test of the `new` command diff --git a/packages/devkit/__tests__/integrations/config/index.spec.ts b/packages/devkit/__tests__/integrations/config/index.spec.ts index 35ee0ae..29892e2 100644 --- a/packages/devkit/__tests__/integrations/config/index.spec.ts +++ b/packages/devkit/__tests__/integrations/config/index.spec.ts @@ -201,7 +201,7 @@ describe("dk config", () => { expect(exitCode).toBe(0); expect(all).toContain( - "Les valeurs pour l'option '--set' doivent être une série de paires clé-valeur (par ex., --set key1 value1 key2 value2).", + "Les valeurs pour l'option '--set' doivent être une série de paires clé-valeur (par ex. --set clé1 valeur1 clé2 valeur2).", ); }); diff --git a/packages/devkit/__tests__/integrations/list.spec.ts b/packages/devkit/__tests__/integrations/list.spec.ts index 8724a23..c17d2f7 100644 --- a/packages/devkit/__tests__/integrations/list.spec.ts +++ b/packages/devkit/__tests__/integrations/list.spec.ts @@ -40,6 +40,7 @@ const localConfig: CliConfig = { description: "A basic Vue template", location: "https://github.com/vuejs/vue", alias: "vb", + packageManager: "pnpm", }, }, }, @@ -49,6 +50,7 @@ const localConfig: CliConfig = { description: "A Node.js API boilerplate", location: "https://github.com/node-api", alias: "na", + packageManager: "yarn", }, }, }, @@ -179,16 +181,12 @@ describe("dk list", () => { expect(all).not.toContain("NODE"); }); - it("should filter templates by name when --filter is used", async () => { + it("should filter templates by name using the new --where syntax", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); - await fs.writeJson( - path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), - globalConfig, - ); const { all, exitCode } = await execa( "bun", - [CLI_PATH, "list", "--filter", "vue"], + [CLI_PATH, "list", "--where", "name:vue"], { all: true, env: { HOME: globalConfigDir }, @@ -196,24 +194,18 @@ describe("dk list", () => { ); expect(exitCode).toBe(0); - expect(all).toContain("Using local configuration."); expect(all).toContain("JAVASCRIPT"); expect(all).toContain("vue-basic"); expect(all).not.toContain("react-ts"); expect(all).not.toContain("NODE"); - expect(all).not.toContain("PYTHON"); }); - it("should filter templates by alias when --filter is used", async () => { + it("should filter templates by alias using the new --where syntax and exact regex match", async () => { await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); - await fs.writeJson( - path.join(globalConfigDir, GLOBAL_CONFIG_FILE_NAME), - globalConfig, - ); const { all, exitCode } = await execa( "bun", - [CLI_PATH, "list", "--filter", "rt"], + [CLI_PATH, "list", "--where", "alias:/^rt$/"], { all: true, env: { HOME: globalConfigDir }, @@ -221,12 +213,62 @@ describe("dk list", () => { ); expect(exitCode).toBe(0); - expect(all).toContain("Using local configuration."); expect(all).toContain("JAVASCRIPT"); expect(all).toContain("react-ts"); expect(all).not.toContain("vue-basic"); - expect(all).not.toContain("NODE"); - expect(all).not.toContain("PYTHON"); + }); + + it("should filter templates by substring in packageManager, matching both npm and pnpm", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + + const { all, exitCode } = await execa( + "bun", + [CLI_PATH, "list", "--where", "pm:npm"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("react-ts"); + expect(all).toContain("vue-basic"); + expect(all).not.toContain("node-api"); + }); + + it("should filter templates by strict packageManager using regex", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + + const { all, exitCode } = await execa( + "bun", + [CLI_PATH, "list", "--where", "pm:/^npm$/"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("react-ts"); + expect(all).not.toContain("vue-basic"); + }); + + it("should filter templates using multiple clauses (Logical AND)", async () => { + await fs.writeJson(path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig); + + const { all, exitCode } = await execa( + "bun", + [CLI_PATH, "list", "--where", "alias:vb", "desc:vue"], + { + all: true, + env: { HOME: globalConfigDir }, + }, + ); + + expect(exitCode).toBe(0); + expect(all).toContain("vue-basic"); + expect(all).not.toContain("react-ts"); + expect(all).not.toContain("node-api"); }); it("should show an error if a language is provided but no templates are found for it", async () => { @@ -331,7 +373,7 @@ describe("dk list", () => { ); }); - it("should filter templates by name when --filter is used", async () => { + it("should filter templates by name when --where is used in table mode", async () => { await fs.writeJson( path.join(tempDir, LOCAL_CONFIG_FILE_NAME), localConfig, @@ -343,7 +385,7 @@ describe("dk list", () => { const { all, exitCode } = await execa( "bun", - [CLI_PATH, "list", "--filter", "vue", "--mode", "table"], + [CLI_PATH, "list", "--where", "name:vue", "--mode", "table"], { all: true, env: { HOME: globalConfigDir }, diff --git a/packages/devkit/__tests__/units/commands/config/logic.spec.ts b/packages/devkit/__tests__/units/commands/config/logic.spec.ts index 640edcb..470ccc6 100644 --- a/packages/devkit/__tests__/units/commands/config/logic.spec.ts +++ b/packages/devkit/__tests__/units/commands/config/logic.spec.ts @@ -3,22 +3,9 @@ import { handleNonInteractiveSettingsUpdate, handleNonInteractiveTemplateUpdate, } from "../../../../src/commands/config/logic.js"; -import { - VALID_CACHE_STRATEGIES, - PackageManagers, -} from "../../../../src/utils/schema/schema.js"; -import deepmerge from "deepmerge"; +import { VALID_CACHE_STRATEGIES } from "../../../../src/utils/schema/schema.js"; import { DevkitError } from "../../../../src/utils/errors/base.js"; import { mocktFn } from "../../../../vitest.setup.js"; -import { - validateAlias, - validateDescription, - validateLocation, -} from "../../../../src/utils/validations/templates.js"; -import { - validatePackageManager, - validateCacheStrategy, -} from "../../../../src/utils/validations/config.js"; const { mockReadAndMergeConfigs, diff --git a/packages/devkit/__tests__/units/commands/list.spec.ts b/packages/devkit/__tests__/units/commands/list.spec.ts index d22196a..618a57b 100644 --- a/packages/devkit/__tests__/units/commands/list.spec.ts +++ b/packages/devkit/__tests__/units/commands/list.spec.ts @@ -82,7 +82,8 @@ const CMD_DESCRIPTION_KEY = "commands.list.command.description"; const LANG_ARGUMENT_KEY = "commands.list.command.language.argument"; const GLOBAL_OPTION_KEY = "commands.list.options.global"; const ALL_OPTION_KEY = "commands.list.options.all"; -const FILTER_OPTION_KEY = "commands.list.command.filter.option"; +const WHERE_OPTION_KEY = "commands.list.command.where.option"; +const MODE_OPTION_KEY = "commands.list.command.mode.option"; const USING_LOCAL_GLOBAL_KEY = "messages.config_source.using_local_and_global"; const USING_GLOBAL_KEY = "messages.config_source.global"; @@ -127,8 +128,17 @@ describe("list command", () => { ALL_OPTION_KEY, ); expect(mockProgram.option).toHaveBeenCalledWith( + "-w, --where ", + WHERE_OPTION_KEY, + ); + expect(mockProgram.option).toHaveBeenCalledWith( + "-m, --mode ", + MODE_OPTION_KEY, + "tree", + ); + expect(mockProgram.option).not.toHaveBeenCalledWith( "-f, --filter ", - FILTER_OPTION_KEY, + expect.any(String), ); expect(mockProgram.option).not.toHaveBeenCalledWith( "-l, --local", @@ -178,7 +188,7 @@ describe("list command", () => { }, ], ], - undefined, + [], "tree", ); }); @@ -209,7 +219,7 @@ describe("list command", () => { }, ], ], - undefined, + [], "tree", ); }); @@ -241,7 +251,7 @@ describe("list command", () => { }, ], ], - undefined, + [], "table", ); }); @@ -262,7 +272,7 @@ describe("list command", () => { await actionFn("javascript", { all: false, global: false, - filter: "", + where: ["name:node"], mode: "table", }); @@ -288,7 +298,7 @@ describe("list command", () => { }, ], ], - "", + ["name:node"], "table", ); }); @@ -308,7 +318,7 @@ describe("list command", () => { await actionFn("javascript", { all: false, global: false, - filter: "javascript", + where: ["loc:local"], mode: "tree", }); @@ -331,7 +341,7 @@ describe("list command", () => { }, ], ], - "javascript", + ["loc:local"], "tree", ); }); @@ -347,7 +357,7 @@ describe("list command", () => { await actionFn("nonexistent", { all: false, global: false, - filter: "", + where: [], mode: "tree", }); @@ -383,7 +393,7 @@ describe("list command", () => { await actionFn("javascript", { all: false, global: false, - filter: "javascript", + where: ["cache:daily"], mode: "folder", }); @@ -402,7 +412,7 @@ describe("list command", () => { }); }); - describe("filter option", () => { + describe("where option", () => { beforeEach(() => { mockReadAndMergeConfigs.mockResolvedValue({ config: structuredClone({ @@ -416,12 +426,13 @@ describe("list command", () => { mockValidateProgrammingLanguage.mockReturnValue(true); }); - it("should pass the filter string to printTemplates", async () => { + it("should pass the array of where clauses to printTemplates", async () => { + const whereClauses = ["pm:npm", "desc:express"]; setupListCommand({ program: mockProgram }); await actionFn("", { all: true, global: false, - filter: "vue", + where: whereClauses, mode: "tree", }); @@ -447,7 +458,22 @@ describe("list command", () => { }, ], ], - "vue", + whereClauses, + "tree", + ); + }); + + it("should pass an empty array to printTemplates when --where is not used", async () => { + setupListCommand({ program: mockProgram }); + await actionFn("", { + all: true, + global: false, + mode: "tree", + }); + + expect(mockPrintTemplates).toHaveBeenCalledWith( + expect.any(Array), + [], "tree", ); }); diff --git a/packages/devkit/__tests__/units/core/template/filter.spec.ts b/packages/devkit/__tests__/units/core/template/filter.spec.ts new file mode 100644 index 0000000..6f8c46c --- /dev/null +++ b/packages/devkit/__tests__/units/core/template/filter.spec.ts @@ -0,0 +1,282 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { filterTemplatesByWhereClause } from "../../../../src/core/template/filter.js"; +import type { LanguageConfig } from "../../../../src/utils/schema/schema.js"; +import { mockLogger } from "../../../../vitest.setup.js"; + +const DELIMITER_MISSING_KEY = "warnings.filter_delimiter_missing"; +const PROPERTY_UNRECOGNIZED_KEY = "warnings.filter_property_unrecognized"; +const REGEX_INVALID_KEY = "errors.validation.template_name_required"; + +const sampleTemplates: LanguageConfig["templates"] = { + "ts-express-api": { + description: "Express API with TypeScript", + alias: "api", + packageManager: "npm", + cacheStrategy: "always-refresh", + location: "github:user/ts-api", + }, + "js-frontend-app": { + description: "Simple React App with JavaScript", + alias: "react", + packageManager: "yarn", + location: "local:./templates/react-app", + }, + "vue-component-lib": { + description: "Component library for Vue", + packageManager: "pnpm", + cacheStrategy: "daily", + location: "github:user/vue-lib", + }, + "ts-node-cli": { + description: "Node CLI with TypeScript and Bun", + alias: "cli", + packageManager: "bun", + cacheStrategy: "never-refresh", + location: "github:user/ts-cli", + }, +}; + +type TemplateEntry = [string, LanguageConfig["templates"][string]]; +const allTemplateEntries: TemplateEntry[] = Object.entries(sampleTemplates); + +describe("filterTemplatesByWhereClause", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return all templates if whereClauses is empty or null/undefined", () => { + expect(filterTemplatesByWhereClause(sampleTemplates, [])).toEqual( + allTemplateEntries, + ); + expect(filterTemplatesByWhereClause(sampleTemplates, [])).toEqual( + allTemplateEntries, + ); + expect(filterTemplatesByWhereClause(sampleTemplates, [])).toEqual( + allTemplateEntries, + ); + }); + + describe("Property Matching (Substring, Case-Insensitive)", () => { + it("should filter by packageManager using includes(), matching both npm and pnpm", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, ["pm:npm"]); + expect(result).toHaveLength(2); + expect(result.map(([name]) => name)).toEqual([ + "ts-express-api", + "vue-component-lib", + ]); + }); + + it("should filter by alias using colon delimiter (alias:react)", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "alias:rEaCt", + ]); + expect(result).toHaveLength(1); + expect(result[0]![0]).toBe("js-frontend-app"); + }); + + it("should return empty array when filtering by alias using equals delimiter for a template without alias", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "alias=vue", + ]); + expect(result).toHaveLength(0); + }); + + it("should filter by description substring (desc:react)", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "desc:react", + ]); + expect(result).toHaveLength(1); + expect(result[0]![0]).toBe("js-frontend-app"); + }); + + it("should filter by location substring (loc:github)", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "loc:github", + ]); + expect(result).toHaveLength(3); + expect(result.map(([name]) => name)).toEqual([ + "ts-express-api", + "vue-component-lib", + "ts-node-cli", + ]); + }); + + it("should return empty array if no matches are found", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "name:nonexistent", + ]); + expect(result).toEqual([]); + }); + }); + + describe("Regex Matching (/.../)", () => { + it("should filter using regex for START WITH (pm: /^npm/)", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "pm:/^npm$/", + ]); + expect(result).toHaveLength(1); + expect(result[0]![0]).toBe("ts-express-api"); + }); + + it("should filter using regex for END WITH (loc: /lib$/)", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "loc:/lib$/", + ]); + expect(result).toHaveLength(1); + expect(result[0]![0]).toBe("vue-component-lib"); + }); + + it("should filter using complex regex pattern (name: /ts-(express|node)/)", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "name:/Ts-(ExPreSs|nOdE)/", + ]); + expect(result).toHaveLength(2); + expect(result.map(([name]) => name)).toEqual([ + "ts-express-api", + "ts-node-cli", + ]); + }); + + it("should handle regex that matches part of the value (loc: /user/)", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "loc:/user/", + ]); + expect(result).toHaveLength(3); + expect(result.map(([name]) => name)).toEqual([ + "ts-express-api", + "vue-component-lib", + "ts-node-cli", + ]); + }); + }); + + describe("Logical AND (Multiple Clauses)", () => { + it("should filter by multiple clauses (Regex AND Substring)", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "pm:/^npm$/", + "name:express", + ]); + expect(result).toHaveLength(1); + expect(result[0]![0]).toBe("ts-express-api"); + }); + + it("should handle multiple clauses that result in no match", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "name:ts", + "pm:yarn", + ]); + expect(result).toHaveLength(0); + }); + + it("should handle mixed delimiters (colon and equals)", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "name:ts", + "pm=npm", + ]); + expect(result).toHaveLength(1); + expect(result[0]![0]).toBe("ts-express-api"); + }); + }); + + describe("Presence/Absence Checks (* and ~)", () => { + it("should filter for properties that are PRESENT (*)", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, ["cache:*"]); + expect(result).toHaveLength(3); + expect(result.map(([name]) => name)).toEqual([ + "ts-express-api", + "vue-component-lib", + "ts-node-cli", + ]); + }); + + it("should filter for properties that are MISSING (~)", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, ["cache:~"]); + expect(result).toHaveLength(1); + expect(result[0]![0]).toBe("js-frontend-app"); + + const result2 = filterTemplatesByWhereClause(sampleTemplates, [ + "alias:~", + ]); + expect(result2).toHaveLength(1); + expect(result2[0]![0]).toBe("vue-component-lib"); + }); + + it("should combine presence/absence with substring match (pm=npm AND cache=*)", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "pm:npm", + "cache:*", + ]); + expect(result).toHaveLength(2); + expect(result.map(([name]) => name)).toEqual([ + "ts-express-api", + "vue-component-lib", + ]); + }); + + it("should combine presence/absence with regex match", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "loc:*", + "loc:/local/", + ]); + expect(result).toHaveLength(1); + expect(result[0]![0]).toBe("js-frontend-app"); + }); + }); + + describe("Error and Warning Handling", () => { + it("should log a warning and ignore a clause missing a delimiter", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "pm:npm", + "invalidclause", + ]); + + expect(mockLogger.warning).toHaveBeenCalledOnce(); + expect(mockLogger.warning).toHaveBeenCalledWith( + `${DELIMITER_MISSING_KEY}- options delimiter1::, delimiter2:=, clause:invalidclause`, + ); + expect(result).toHaveLength(2); + }); + + it("should log a warning and ignore an unrecognized property key", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "pm:npm", + "size:large", + ]); + + expect(mockLogger.warning).toHaveBeenCalledOnce(); + expect(mockLogger.warning).toHaveBeenCalledWith( + `${PROPERTY_UNRECOGNIZED_KEY}- options property:size`, + ); + expect(result).toHaveLength(2); + }); + + it("should log an error and ignore a clause with invalid regex", () => { + const invalidRegexClause = "name:/[a-z/"; + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "pm:npm", + invalidRegexClause, + ]); + + expect(mockLogger.error).toHaveBeenCalledOnce(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.stringContaining( + `${REGEX_INVALID_KEY}- options clause:${invalidRegexClause}, error:`, + ), + ); + expect(result).toHaveLength(2); + }); + + it("should return all templates if all clauses are invalid/ignored", () => { + const result = filterTemplatesByWhereClause(sampleTemplates, [ + "invalid:clause", + "another-bad-clause", + "unknown:prop", + "name:/[a-z/", + ]); + + expect(mockLogger.warning).toHaveBeenCalledTimes(3); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(result).toEqual(allTemplateEntries); + }); + }); +}); diff --git a/packages/devkit/__tests__/units/core/template/printer.spec.ts b/packages/devkit/__tests__/units/core/template/printer.spec.ts index f4ac960..e66a166 100644 --- a/packages/devkit/__tests__/units/core/template/printer.spec.ts +++ b/packages/devkit/__tests__/units/core/template/printer.spec.ts @@ -2,9 +2,18 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { printSettings, printTemplates, + type TemplateList, } from "../../../../src/core/template/printer.js"; import { mockLogger, mocktFn } from "../../../../vitest.setup.js"; -import type { LanguageConfig } from "../../../integrations/common.js"; +import type { LanguageConfig } from "../../../../src/utils/schema/schema.js"; + +const mockFilterTemplatesByWhereClause = vi.hoisted(() => { + return vi.fn(); +}); + +vi.mock("../../../../src/core/template/filter.js", () => ({ + filterTemplatesByWhereClause: mockFilterTemplatesByWhereClause, +})); const NEW_ALIAS_KEY = "commands.template.add.options.alias"; const NEW_DESCRIPTION_KEY = "commands.template.add.options.description"; @@ -16,6 +25,9 @@ mockLogger.table = vi.fn(); describe("print-utils", () => { beforeEach(() => { vi.clearAllMocks(); + mockFilterTemplatesByWhereClause.mockImplementation((templates) => + Object.entries(templates), + ); }); const c: any = mockLogger.colors; @@ -50,98 +62,100 @@ describe("print-utils", () => { language: "en", }; - describe("printTemplates (Mode `Tree`: Default)", () => { - it("should print all templates without a filter", () => { - printTemplates([["typescript", templates]]); + const filteredTemplatesReact = [ + ["react-component", templates["react-component"]] as TemplateList, + ]; - expect(mockLogger.log).toHaveBeenCalledTimes(5); + const filteredTemplatesNTA = [ + ["node-ts-api", templates["node-ts-api"]] as TemplateList, + ]; + describe("printTemplates (Mode `Tree`: Default)", () => { + it("should print all templates without a filter", () => { + mockFilterTemplatesByWhereClause.mockImplementationOnce( + (templates, clauses) => { + expect(clauses).toEqual([]); + return Object.entries(templates); + }, + ); printTemplates([["typescript", templates]]); - expect(mockLogger.log).toHaveBeenCalledWith( - `\n${c.boldBlue("TYPESCRIPT")}:`, + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledOnce(); + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledWith( + templates, + [], ); + expect(mockLogger.log).toHaveBeenCalledTimes(5); expect(mockLogger.log).toHaveBeenCalledWith( ` - ${c.green("node-ts-api")} ${c.cyanDim(`(${t(NEW_ALIAS_KEY)}: nta)`)}${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A simple Node.js API with TypeScript${c.dim("\n Location")}: https://github.com/devkit/node-ts-api${c.dim(`\n ${t(NEW_CACHE_KEY)}`)}: daily${c.dim(`\n ${t(NEW_PM_KEY)}`)}: npm\n`, ); + }); - expect(mockLogger.log).toHaveBeenCalledWith( - ` - ${c.green("react-component")} ${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A reusable React component${c.dim("\n Location")}: /local/path/to/template\n`, + it("should print only filtered templates by a filter clause array", () => { + const filterClauses = ["name:react"]; + mockFilterTemplatesByWhereClause.mockImplementationOnce( + (templates, clauses) => { + expect(clauses).toEqual(filterClauses); + return filteredTemplatesReact; + }, ); - expect(mockLogger.log).toHaveBeenCalledWith( - ` - ${c.green("next-app")} ${c.cyanDim(`(${t(NEW_ALIAS_KEY)}: nextjs)`)}${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A Next.js application template${c.dim("\n Location")}: /local/path/to/template\n`, - ); + printTemplates([["typescript", templates]], filterClauses); - expect(mockLogger.log).toHaveBeenCalledWith( - ` - ${c.green("simple-template")} ${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A simple template${c.dim("\n Location")}: /local/path/to/template\n`, + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledOnce(); + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledWith( + templates, + filterClauses, ); - - mockLogger.log.mockRestore(); - }); - - it("should print only filtered templates by name", () => { - printTemplates([["typescript", templates]], "react"); - - printTemplates([["typescript", templates]], "react"); - + expect(mockLogger.log).toHaveBeenCalledTimes(2); expect(mockLogger.log).toHaveBeenCalledWith( `\n${c.boldBlue("TYPESCRIPT")}:`, ); - expect(mockLogger.log).toHaveBeenCalledTimes(4); expect(mockLogger.log).toHaveBeenCalledWith( ` - ${c.green("react-component")} ${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A reusable React component${c.dim("\n Location")}: /local/path/to/template\n`, ); - - mockLogger.log.mockRestore(); }); - it("should print only filtered templates by alias", () => { - printTemplates([["javascript", templates]], "nta"); - printTemplates([["javascript", templates]], "nta"); + it("should not print anything if filter returns no templates", () => { + const filterClauses = ["name:unrelated"]; + mockFilterTemplatesByWhereClause.mockReturnValueOnce([]); - expect(mockLogger.log).toHaveBeenCalledWith( - `\n${c.boldBlue("JAVASCRIPT")}:`, - ); - expect(mockLogger.log).toHaveBeenCalledTimes(4); - expect(mockLogger.log).toHaveBeenCalledWith( - ` - ${c.green("node-ts-api")} ${c.cyanDim(`(${t(NEW_ALIAS_KEY)}: nta)`)}${c.dim(`\n ${t(NEW_DESCRIPTION_KEY)}`)}: A simple Node.js API with TypeScript${c.dim("\n Location")}: https://github.com/devkit/node-ts-api${c.dim(`\n ${t(NEW_CACHE_KEY)}`)}: daily${c.dim(`\n ${t(NEW_PM_KEY)}`)}: npm\n`, - ); + printTemplates([["python", templates]], filterClauses); - mockLogger.log.mockRestore(); - }); - - it("should not print anything if no templates match the filter", () => { - printTemplates([["python", templates]], "unrelated"); - expect(mockLogger.log).not.toHaveBeenCalled(); - - printTemplates([["python", templates]], "unrelated"); + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledOnce(); expect(mockLogger.log).not.toHaveBeenCalled(); - mockLogger.log.mockRestore(); }); it("should not print anything if templates are empty", () => { printTemplates([["rust", {}]]); - expect(mockLogger.log).not.toHaveBeenCalled(); - printTemplates([["rust", {}]]); expect(mockLogger.log).not.toHaveBeenCalled(); - mockLogger.log.mockRestore(); + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledOnce(); }); }); describe("printTemplates (Mode `Table`)", () => { - it("should call logger.table with all templates across multiple languages without a filter", () => { - printTemplates( - [ - ["typescript", templates], - ["javascript", { "express-api": { description: "Express" } }], - ], - undefined, - "table", - ); + const templatesJs = { "express-api": { description: "Express" } }; + const templatesList = [ + ["typescript", templates], + ["javascript", templatesJs], + ]; + it("should call logger.table with all templates across multiple languages without a filter", () => { + mockFilterTemplatesByWhereClause + .mockImplementationOnce((templates, clauses) => { + expect(clauses).toEqual([]); + return Object.entries(templates); + }) + .mockImplementationOnce((templates, clauses) => { + expect(clauses).toEqual([]); + return Object.entries(templates); + }); + + printTemplates(templatesList as TemplateList[], [], "table"); + + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(2); expect(mockLogger.table).toHaveBeenCalledTimes(1); const expectedTableData = [ @@ -171,14 +185,14 @@ describe("print-utils", () => { c.green("next-app"), "nextjs", "A Next.js application template", - c.dim("/local/path/to/template"), + "/local/path/to/template", ], [ c.boldBlue("Typescript"), c.green("simple-template"), c.dim("N/A"), - c.dim("A simple template"), - c.dim("/local/path/to/template"), + "A simple template", + "/local/path/to/template", ], [ c.boldBlue("Javascript"), @@ -193,9 +207,19 @@ describe("print-utils", () => { expect(mockLogger.log).not.toHaveBeenCalled(); }); - it("should print only filtered templates by name or alias in table mode", () => { - printTemplates([["typescript", templates]], "nextjs", "table"); + it("should print only filtered templates by a filter array in table mode", () => { + const filterClauses = ["alias:nextjs"]; + mockFilterTemplatesByWhereClause + .mockImplementationOnce(() => filteredTemplatesNTA) + .mockImplementationOnce(() => []); + + printTemplates(templatesList as TemplateList[], filterClauses, "table"); + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledTimes(2); + expect(mockFilterTemplatesByWhereClause).toHaveBeenCalledWith( + templates, + filterClauses, + ); expect(mockLogger.table).toHaveBeenCalledTimes(1); const expectedTableData = [ @@ -208,39 +232,37 @@ describe("print-utils", () => { ], [ c.boldBlue("Typescript"), - c.green("next-app"), - "nextjs", - "A Next.js application template", - c.dim("/local/path/to/template"), + c.green("node-ts-api"), + "nta", + "A simple Node.js API with TypeScript", + "https://github.com/devkit/node-ts-api", ], ]; expect(mockLogger.table).toHaveBeenCalledWith(expectedTableData); }); - it("should not call logger.table if no templates match the filter", () => { - printTemplates([["typescript", templates]], "unrelated-app", "table"); + it("should call logger.warning with filter key if filter yields no templates", () => { + const filterClauses = ["name:unrelated-app"]; + mockFilterTemplatesByWhereClause.mockReturnValue([]); - expect(mockLogger.table).not.toHaveBeenCalled(); - }); - - it("should not call logger.table if templates list is empty", () => { - printTemplates([["rust", {}]], undefined, "table"); + printTemplates([["typescript", templates]], filterClauses, "table"); expect(mockLogger.table).not.toHaveBeenCalled(); expect(mockLogger.warning).toHaveBeenCalledOnce(); expect(mockLogger.warning).toHaveBeenCalledWith( - "warnings.template_not_found", + "warnings.template_not_found_with_filter", ); }); - it("should not call logger.table if templates list is empty with filter applied", () => { - printTemplates([["rust", {}]], "unrelated-app", "table"); + it("should call logger.warning without filter key if templates list is empty", () => { + mockFilterTemplatesByWhereClause.mockReturnValue([]); + printTemplates([["rust", {}]], [], "table"); expect(mockLogger.table).not.toHaveBeenCalled(); expect(mockLogger.warning).toHaveBeenCalledOnce(); expect(mockLogger.warning).toHaveBeenCalledWith( - "warnings.template_not_found_with_filter", + "warnings.template_not_found", ); }); }); diff --git a/packages/devkit/locales/en.json b/packages/devkit/locales/en.json index 28dfe08..ae3f010 100644 --- a/packages/devkit/locales/en.json +++ b/packages/devkit/locales/en.json @@ -44,11 +44,11 @@ "language": { "argument": "The language to filter templates by" }, - "filter": { - "option": "Filter templates by name or substring." - }, "mode": { "option": "Set the display mode: 'tree' or 'table'. (Default: tree)" + }, + "where": { + "option": "Filter templates by property using the format :.\n\nFilters use case-insensitive substring matching (e.g., 'desc:vue').\nUse '*' for present (non-empty) and '~' for missing (empty) values.\n\nExample: 'pm:bun alias:* cache:~' (AND logic)" } }, "options": { @@ -253,7 +253,7 @@ "config_init_start": "Initializing config...", "template_adding": "Adding template '{templateName}' to configuration...", "template_removing": "Removing template(s) from configuration...", - "template_updating": "Updating template(s) '{templateName}' in configuration...", + "template_updating": "Updating template(s)'{templateName}' in configuration...", "cache_updating": "Updating cache...", "cache_clone_start": "✨ Cloning new template from {url}...", "cache_refresh_start": "🔄 Refreshing cached template...", @@ -336,7 +336,8 @@ "not_found": "Template '{template}' not found in configuration.", "language_not_found": "Template not found for the '{language}' language.", "exists": "Template '{template}' already exists in the configuration. Use 'devkit config set' to update it.", - "single_fail": "❌ Failed to update '{templateName}': {error}" + "single_fail": "❌ Failed to update '{templateName}': {error}", + "filter_regex_invalid": "Invalid regex pattern in filter: '{clause}' due to {error}." }, "scaffolding": { "fail": "❌ Failed to scaffold project: {error}", @@ -376,6 +377,8 @@ "template_not_found_with_filter": "No templates matched the specified filter.", "templates_not_found": "The following templates were not found: {templates}.", "no_command_provided": "Warning: No command or option provided. Use `dk config --help` to see available commands.", - "no_config_found": "⚠️ No configuration file found. Using default configuration." + "no_config_found": "⚠️ No configuration file found. Using default configuration.", + "filter_delimiter_missing": "Ignoring filter clause missing a delimiter ('{delimiter1}' or '{delimiter2}'): {clause}", + "filter_property_unrecognized": "Ignoring unrecognized filter property: {property}" } } diff --git a/packages/devkit/locales/fr.json b/packages/devkit/locales/fr.json index 05da4df..604921d 100644 --- a/packages/devkit/locales/fr.json +++ b/packages/devkit/locales/fr.json @@ -6,17 +6,17 @@ }, "program": { "program": { - "description": "Une puissante boîte à outils de développement pour échafauder de nouveaux projets.", + "description": "Une puissante boîte à outils (devkit) pour l'échafaudage de nouveaux projets.", "verbose_option": "Activer la journalisation verbeuse pour une sortie détaillée" }, "version": { - "description": "Afficher la version actuelle de l'interface de ligne de commande (CLI)." + "description": "Afficher la version actuelle de l'interface de ligne de commande." }, "help": { "description": "Afficher l'aide pour une commande." }, "status": { - "initializing": "Initialisation de la CLI..." + "initializing": "Initialisation de l'interface de ligne de commande..." } }, "commands": { @@ -26,14 +26,14 @@ }, "project": { "language": { - "argument": "Le langage de programmation du modèle (par ex., 'react', 'node')" + "argument": "Le langage de programmation du modèle (par ex. 'react', 'node')" }, "name": { "argument": "Le nom de votre nouveau projet" }, "template": { "option": { - "description": "Le nom du modèle à utiliser (par ex., 'ts', 'simple-api')" + "description": "Le nom du modèle à utiliser (par ex. 'ts', 'simple-api')" } } } @@ -44,11 +44,11 @@ "language": { "argument": "Le langage pour filtrer les modèles" }, - "filter": { - "option": "Filtrer les modèles par nom ou sous-chaîne." - }, "mode": { - "option": "Définir le mode d'affichage : 'tree' (arborescence) ou 'table' (tableau). (Par défaut : tree)" + "option": "Définir le mode d'affichage : 'tree' ou 'table'. (Par défaut : tree)" + }, + "where": { + "option": "Filtrer les modèles par propriété en utilisant le format : ou =.\n\nLes filtres utilisent la correspondance de sous-chaîne (insensible à la casse) (par ex. 'desc:vue').\nUtilisez '*' pour les valeurs présentes (non vides) et '~' pour les valeurs manquantes (vides).\n\nExemple : 'pm:bun alias:* cache:~' (logique ET)" } }, "options": { @@ -63,7 +63,7 @@ }, "config": { "command": { - "description": "Gérer les paramètres de DevKit" + "description": "Gérer les paramètres DevKit" }, "interactive": { "prompt_action": "Que souhaitez-vous configurer ?", @@ -80,13 +80,13 @@ }, "set": { "command": { - "description": "Définir une ou plusieurs valeurs de configuration. \n\nClés disponibles :\n pm, packageManager - Définit le gestionnaire de paquets par défaut à utiliser pour les nouveaux projets.\n Valeurs possibles : {pmValues}\n cache, cacheStrategy - Définit le comportement global de mise en cache pour les modèles distants.\n Valeurs possibles : 'always-refresh', 'never-refresh', 'daily'\n language, lg - Définit la langue de la CLI. Valeurs possibles : 'en', 'fr'\n" + "description": "Définir une ou plusieurs valeurs de configuration. \n\nClés disponibles :\n pm, packageManager - Définit le gestionnaire de paquets par défaut à utiliser pour les nouveaux projets.\n Valeurs possibles : {pmValues}\n cache, cacheStrategy - Définit le comportement de mise en cache global pour les modèles distants.\n Valeurs possibles : 'always-refresh', 'never-refresh', 'daily'\n language, lg - Définit la langue de l'interface de ligne de commande. Valeurs possibles : 'en', 'fr'\n" }, "argument": { - "description": "Une liste de paires clé-valeur à définir (par ex., 'pm npm language fr')" + "description": "Une liste de paires clé-valeur à définir (par ex. 'pm npm language fr')" }, "option": { - "global": "Mettre à jour la configuration globale au lieu de la configuration locale.", + "global": "Mettre à jour la configuration globale au lieu de la locale.", "bulk": "Définit plusieurs propriétés de configuration." } }, @@ -95,10 +95,10 @@ "description": "Obtenir une ou plusieurs valeurs de configuration." }, "argument": { - "description": "La clé de la valeur de configuration à obtenir (par ex., 'pm', 'language')." + "description": "La clé de la valeur de configuration à obtenir (par ex. 'pm', 'language')." }, "option": { - "global": "Obtenir la configuration globale au lieu de la configuration locale." + "global": "Obtenir la configuration globale au lieu de la locale." } }, "cache": { @@ -130,7 +130,7 @@ "location": "Un nouvel emplacement pour le modèle.", "cache_strategy": "Une nouvelle stratégie de cache pour le modèle.", "package_manager": "Un nouveau gestionnaire de paquets pour le modèle.", - "global": "Mettre à jour le modèle dans la configuration globale au lieu de la configuration locale." + "global": "Mettre à jour le modèle dans la configuration globale au lieu de la locale." } }, "init": { @@ -138,19 +138,19 @@ "description": "Initialise un fichier de configuration avec les paramètres par défaut." }, "option": { - "local": "Initialiser un fichier de configuration locale au lieu d'un fichier global.", - "global": "Initialiser un fichier de configuration globale au lieu d'un fichier local." + "local": "Initialiser un fichier de configuration local au lieu d'un global.", + "global": "Initialiser un fichier de configuration global au lieu d'un local." }, "confirm_overwrite": "Le fichier de configuration existe déjà à {path}. Voulez-vous l'écraser ?", "confirm_monorepo_overwrite": "Un fichier de configuration existe à la racine du monorepo à {path}. Voulez-vous en créer un nouveau dans le paquet actuel ?", "monorepo_location": "Une racine de monorepo a été trouvée, mais aucun fichier de configuration n'y existe. Où voulez-vous créer le nouveau fichier de configuration ?", "location_current": "Le créer dans le paquet actuel.", "location_root": "Le créer à la racine du monorepo.", - "aborted": "Opération annulée. Aucune modification n'a été effectuée." + "aborted": "Opération annulée. Aucune modification n'a été apportée." }, "list": { "command": { - "description": "Lister les paramètres et les modèles disponibles." + "description": "Lister les paramètres et modèles disponibles." }, "options": { "all": "Lister les modèles des configurations locale et globale (fusionnées)." @@ -189,7 +189,7 @@ "argument": "Le nom ou l'alias du modèle à supprimer." }, "option": { - "global": "Supprimer le modèle de la configuration globale au lieu de la configuration locale." + "global": "Supprimer le modèle de la configuration globale au lieu de la locale." } } }, @@ -198,7 +198,7 @@ "description": "Afficher les informations système et d'environnement pour le débogage." }, "header": { - "cli": "CLI de l'échafaudeur", + "cli": "Scaffolder CLI", "runtime": "Environnement d'exécution", "os_details": "Détails du système d'exploitation", "config_files": "Fichiers de configuration" @@ -215,7 +215,7 @@ "type_version": "Type/Version du SE", "architecture": "Architecture", "shell": "Shell actif", - "home_dir": "Répertoire personnel" + "home_dir": "Répertoire d'accueil" }, "shell": { "unknown": "Inconnu" @@ -225,21 +225,25 @@ "local_path": "Chemin de la configuration locale", "found": "[TROUVÉ]", "not_found": "[NON TROUVÉ]", - "global_expected_location": "dans votre répertoire personnel", + "global_expected_location": "dans votre répertoire d'accueil", "local_expected_location": "dans le répertoire de travail actuel" } + }, + "template_update": { + "success": "Modèle '{templateName}' mis à jour avec succès !", + "name_updated": "Nom du modèle mis à jour de '{oldName}' à '{newName}' avec succès !" } }, "messages": { "success": { - "program_initialized": "CLI initialisée avec succès.", + "program_initialized": "Interface de ligne de commande initialisée avec succès.", "new_project": "✔ Projet '{projectName}' créé avec succès !", "template_cloned_or_cache": "Modèle cloné et mis en cache avec succès.", "template_added": "Modèle '{templateName}' ajouté avec succès !", "template_removed": "Suppression réussie de {count} modèle(s) ({templateName}) de {language}.", "template_updated": "✔ Modèle '{templateName}' mis à jour avec succès !", - "template_name_updated": "✔ Modèle '{oldName}' mis à jour vers '{newName}' avec succès !", - "template_summary_updated": "Mise à jour réussie de {count} modèle(s) ({templateName}) de {language} !", + "template_name_updated": "✔ Modèle '{oldName}' mis à jour à '{newName}' avec succès !", + "template_summary_updated": "Mise à jour réussie de {count} modèle(s) ({templateName}) de {language}!", "config_updated": "Configuration mise à jour avec succès !", "config_initialized": "Fichier de configuration créé avec succès !", "info_collected": "Informations système collectées.", @@ -252,14 +256,14 @@ "config_updating": "Mise à jour de la configuration...", "config_init_start": "Initialisation de la configuration...", "template_adding": "Ajout du modèle '{templateName}' à la configuration...", - "template_removing": "Suppression du/des modèle(s) de la configuration...", - "template_updating": "Mise à jour du/des modèle(s) '{templateName}' dans la configuration...", + "template_removing": "Suppression du(des) modèle(s) de la configuration...", + "template_updating": "Mise à jour du(des) modèle(s) '{templateName}' dans la configuration...", "cache_updating": "Mise à jour du cache...", "cache_clone_start": "✨ Clonage du nouveau modèle depuis {url}...", "cache_refresh_start": "🔄 Rafraîchissement du modèle mis en cache...", "cache_use_info": "🚀 Utilisation du modèle mis en cache pour {repoName}.", "cache_copy_start": "📂 Copie du modèle mis en cache dans le répertoire du projet...", - "info_loading": "Collecte des détails du système et de l'environnement...", + "info_loading": "Collecte des détails système et d'environnement...", "config_source_local_and_global": "Utilisation des configurations locale et globale.", "config_source_local": "Utilisation de la configuration locale.", "config_source_global": "Utilisation de la configuration globale.", @@ -267,7 +271,7 @@ }, "scaffolding": { "start": "Échafaudage du projet {language} : {project}", - "copy_start": "📂 Copie des fichiers de modèle local...", + "copy_start": "📂 Copie des fichiers du modèle local...", "copy_success": "✔ Modèle local copié avec succès !", "run_start": "📦 Exécution de la commande CLI officielle : {command}...", "run_success": "✔ Commande CLI officielle exécutée avec succès !", @@ -288,7 +292,7 @@ "check_global": "Configuration locale non trouvée. Vérification de la configuration globale...", "found_local": "Configuration locale trouvée et utilisée à {path}.", "found_global": "Aucune configuration locale trouvée. Utilisation de la configuration globale à {path}.", - "found_existing": "Le fichier de configuration existe déjà à {path}. Saut de l'initialisation." + "found_existing": "Le fichier de configuration existe déjà à {path}. Initialisation ignorée." } }, "errors": { @@ -296,7 +300,7 @@ "unexpected": "Une erreur inattendue s'est produite", "unknown": "Une erreur inconnue s'est produite", "devkit_specific": "Devkit a rencontré un problème interne inattendu", - "aborted": "Opération annulée. Aucune modification n'a été effectuée." + "aborted": "Opération annulée. Aucune modification n'a été apportée." }, "validation": { "invalid_key": "Clé invalide : '{key}'. Les clés valides sont : {keys}", @@ -305,14 +309,14 @@ "invalid_cache_strategy": "Stratégie de cache invalide : '{value}'. Les options valides sont : {options}", "invalid_package_manager": "Gestionnaire de paquets invalide : '{value}'. Les options valides sont : {options}", "remove_required": "Impossible de supprimer une propriété requise : '{key}'.", - "alias_exists": "L'alias '{alias}' existe déjà pour un autre modèle dans ce langage. Veuillez choisir un alias différent.", + "alias_exists": "L'alias '{alias}' existe déjà pour un autre modèle dans cette langue. Veuillez choisir un alias différent.", "alias_empty": "L'alias du modèle ne peut pas être vide.", "alias_too_short": "L'alias du modèle doit comporter au moins 10 lettres.", "description_empty": "La description du modèle ne peut pas être vide.", "description_too_short": "La description du modèle doit comporter au moins 6 mots.", - "github_repo": "URL du dépôt GitHub invalide ou inaccessible : {url}", + "github_repo": "URL de dépôt GitHub invalide ou inaccessible : {url}", "local_path": "Chemin local non trouvé : {path}", - "location": "Emplacement du modèle invalide. Veuillez fournir une URL GitHub ou un chemin local valide.", + "location": "Emplacement de modèle invalide. Veuillez fournir une URL GitHub ou un chemin local valide.", "language_required": "Le langage est requis.", "template_name_required": "Le nom du modèle est requis.", "location_required": "L'emplacement est requis.", @@ -325,31 +329,32 @@ "not_found": "Fichier de configuration non trouvé.", "read_fail": "Échec de la lecture de la configuration à {path}.", "no_file_found": "Aucun fichier de configuration trouvé. Exécutez 'devkit config init' pour en créer un global, ou 'devkit config init --local' pour en créer un local.", - "no_file_found_local": "Aucun fichier de configuration locale trouvé. Exécutez 'devkit config init --local' pour en créer un.", + "no_file_found_local": "Aucun fichier de configuration local trouvé. Exécutez 'devkit config init --local' pour en créer un.", "init_fail": "Échec de l'initialisation de la configuration.", - "init_local_and_global": "Impossible d'utiliser les deux drapeaux --local et --global en même temps.", - "global_not_found": "Fichier de configuration globale non trouvé. Exécutez 'devkit config init --global' pour en créer un.", - "local_not_found": "Aucun fichier de configuration locale trouvé. Exécutez 'devkit config init --local' pour en créer un.", + "init_local_and_global": "Impossible d'utiliser les drapeaux --local et --global en même temps.", + "global_not_found": "Fichier de configuration global non trouvé. Exécutez 'devkit config init --global' pour en créer un.", + "local_not_found": "Aucun fichier de configuration local trouvé. Exécutez 'devkit config init --local' pour en créer un.", "get_key_not_found": "Clé de configuration '{key}' non trouvée." }, "template": { "not_found": "Modèle '{template}' non trouvé dans la configuration.", "language_not_found": "Modèle non trouvé pour le langage '{language}'.", "exists": "Le modèle '{template}' existe déjà dans la configuration. Utilisez 'devkit config set' pour le mettre à jour.", - "single_fail": "❌ Échec de la mise à jour de '{templateName}' : {error}" + "single_fail": "❌ Échec de la mise à jour de '{templateName}' : {error}", + "filter_regex_invalid": "Motif regex invalide dans le filtre : '{clause}' à cause de {error}." }, "scaffolding": { "fail": "❌ Échec de l'échafaudage du projet : {error}", "copy_fail": "❌ Échec de la copie du modèle local.", "run_fail": "❌ Échec de l'exécution de la commande CLI officielle.", "install_fail": "❌ Échec de l'installation des dépendances.", - "unexpected": "Une erreur inattendue s'est produite lors de l'échafaudage.", + "unexpected": "Une erreur inattendue s'est produite pendant l'échafaudage.", "language_not_found": "Langage d'échafaudage non trouvé dans la configuration : '{language}'" }, "command": { "set_invalid_arguments_count": "Nombre d'arguments invalide. Veuillez fournir des paires clé-valeur.", - "set_invalid_format": "Les valeurs pour l'option '--set' doivent être une série de paires clé-valeur (par ex., --set key1 value1 key2 value2).", - "update_no_options": "Aucune option de mise à jour n'a été fournie. Veuillez spécifier au moins une option à mettre à jour (par ex., --new-name, --description, etc.).", + "set_invalid_format": "Les valeurs pour l'option '--set' doivent être une série de paires clé-valeur (par ex. --set clé1 valeur1 clé2 valeur2).", + "update_no_options": "Aucune option de mise à jour n'a été fournie. Veuillez spécifier au moins une option à mettre à jour (par ex. --new-name, --description, etc.).", "mutually_exclusive_options": "Vous ne pouvez pas utiliser les options `{options}` ensemble. Elles sont mutuellement exclusives.", "missing_required_options": "Options requises manquantes. Veuillez fournir tous les champs suivants : {fields}.\nAlternativement, exécutez avec le drapeau '-i' ou '--interactive' pour une configuration guidée." }, @@ -358,7 +363,7 @@ "package_root_not_found": "Impossible de trouver la racine du projet contenant package.json.", "package_file_not_found": "{file} non trouvé à {path}", "package_name_update_fail": "Échec de la mise à jour du nom du projet :", - "version_read_fail": "Échec de la lecture de la version de package.json.", + "version_read_fail": "Échec de la lecture de la version dans package.json.", "git_generic": "Erreur Git", "info_package_manager_not_found": "Version de {manager} non trouvée. Est-il installé ?" }, @@ -373,9 +378,11 @@ "no_local_config": "Aucune configuration de projet locale trouvée. Utilisation des paramètres globaux ou par défaut.", "global_not_initialized": "Configuration globale non initialisée. Exécutez 'devkit config init' pour en créer une.", "template_not_found": "Aucun modèle trouvé dans le fichier de configuration.", - "template_not_found_with_filter": "La liste des modèles est vide après application du filtre.", + "template_not_found_with_filter": "Aucun modèle ne correspondait au filtre spécifié.", "templates_not_found": "Les modèles suivants n'ont pas été trouvés : {templates}.", "no_command_provided": "Avertissement : Aucune commande ou option fournie. Utilisez `dk config --help` pour voir les commandes disponibles.", - "no_config_found": "⚠️ Aucun fichier de configuration trouvé. Utilisation de la configuration par défaut." + "no_config_found": "⚠️ Aucun fichier de configuration trouvé. Utilisation de la configuration par défaut.", + "filter_delimiter_missing": "Ignorons la clause de filtre sans délimiteur ('{delimiter1}' ou '{delimiter2}') : {clause}", + "filter_property_unrecognized": "Ignorons la propriété de filtre non reconnue : {property}" } } diff --git a/packages/devkit/src/commands/list.ts b/packages/devkit/src/commands/list.ts index 19c211c..6b87b21 100644 --- a/packages/devkit/src/commands/list.ts +++ b/packages/devkit/src/commands/list.ts @@ -17,7 +17,7 @@ import { type ListCommandOptions = { global?: boolean; all?: boolean; - filter?: string; + where?: string[]; mode: DisplayModesValues; }; @@ -70,14 +70,16 @@ export function setupListCommand(options: SetupCommandOptions): void { .argument("[language]", t("commands.list.command.language.argument"), "") .option("-g, --global", t("commands.list.options.global")) .option("-a, --all", t("commands.list.options.all")) - .option("-f, --filter ", t("commands.list.command.filter.option")) + .option("-w, --where ", t("commands.list.command.where.option")) .option( "-m, --mode ", t("commands.list.command.mode.option"), "tree", ) .action(async (language, cmdOptions: ListCommandOptions) => { - const { global: isGlobal, all: showAll, filter, mode } = cmdOptions; + const { global: isGlobal, all: showAll, where, mode } = cmdOptions; + + const whereClauses: string[] = where || []; const spinner: TSpinner = logger .spinner(t("messages.status.config_loading")) @@ -134,7 +136,7 @@ export function setupListCommand(options: SetupCommandOptions): void { validateDisplayMode(mode); - printTemplates(templatesToPrint, filter, mode); + printTemplates(templatesToPrint, whereClauses, mode); spinner.stop(); } catch (error: unknown) { diff --git a/packages/devkit/src/core/template/filter.ts b/packages/devkit/src/core/template/filter.ts new file mode 100644 index 0000000..9c9beae --- /dev/null +++ b/packages/devkit/src/core/template/filter.ts @@ -0,0 +1,156 @@ +import { t } from "#utils/i18n/translator"; +import { logger } from "#utils/logger.js"; +import { + type TemplateConfig, + type LanguageConfig, +} from "#utils/schema/schema.js"; + +type TemplateMap = LanguageConfig["templates"]; +type TemplateEntry = [string, TemplateConfig]; + +/** + * @const {object} FILTER_SYMBOLS - Reserved symbols used for presence/absence checks. + */ +export const FILTER_SYMBOLS = { + PRESENT: "*", + MISSING: "~", + REGEX_START: "/", + REGEX_END: "/", +} as const; + +/** + * @const {object} FILTER_DELIMITERS - Supported characters for separating property from value. + */ +export const FILTER_DELIMITERS = { + COLON: ":", + EQUALS: "=", +} as const; + +const PROPERTY_KEY_MAP: Record = { + name: "name", + alias: "alias", + desc: "description", + description: "description", + pm: "packageManager", + packageManager: "packageManager", + cache: "cacheStrategy", + cacheStrategy: "cacheStrategy", + loc: "location", + location: "location", +}; + +function findDelimiterIndex(clause: string): number { + const colonIndex = clause.indexOf(FILTER_DELIMITERS.COLON); + const equalsIndex = clause.indexOf(FILTER_DELIMITERS.EQUALS); + + if (colonIndex > -1 && (equalsIndex === -1 || colonIndex < equalsIndex)) { + return colonIndex; + } + if (equalsIndex > -1) { + return equalsIndex; + } + return -1; +} + +export const filterTemplatesByWhereClause = ( + templates: TemplateMap, + whereClauses: string[], +): TemplateEntry[] => { + if (!whereClauses || whereClauses.length === 0) { + return Object.entries(templates); + } + + const parsedFilters = whereClauses + .map((clause) => { + const delimiterIndex = findDelimiterIndex(clause); + + if (delimiterIndex === -1) { + logger.warning( + t("warnings.filter_delimiter_missing", { + delimiter1: FILTER_DELIMITERS.COLON, + delimiter2: FILTER_DELIMITERS.EQUALS, + clause: clause, + }), + ); + return null; + } + + const propKey = clause.substring(0, delimiterIndex).trim(); + const value = clause.substring(delimiterIndex + 1).trim(); + + const templateProp = PROPERTY_KEY_MAP[propKey.toLowerCase()]; + + if (!templateProp) { + logger.warning( + t("warnings.filter_property_unrecognized", { + property: propKey, + }), + ); + return null; + } + + const isRegex = + value.startsWith(FILTER_SYMBOLS.REGEX_START) && + value.endsWith(FILTER_SYMBOLS.REGEX_END) && + value.length > 1; + + let regex: RegExp | undefined; + + if (isRegex) { + const pattern = value.substring(1, value.length - 1); + try { + regex = new RegExp(pattern, "i"); + } catch (e) { + logger.error( + t("errors.validation.template_name_required", { + clause: clause, + error: (e as Error).message, + }), + ); + return null; + } + } + + const processedValue = isRegex ? value : value.toLowerCase(); + + return { templateProp, value: processedValue, isRegex, regex }; + }) + .filter((filter): filter is NonNullable => filter !== null); + + if (parsedFilters.length === 0) { + return Object.entries(templates); + } + + return Object.entries(templates).filter(([templateName, templateConfig]) => { + return parsedFilters.every( + ({ templateProp, value: filterValue, isRegex, regex }) => { + let templateValue: string | undefined; + + if (templateProp === "name") { + templateValue = templateName; + } else { + templateValue = templateConfig[templateProp] ?? undefined; + } + + const isPresent = !!templateValue && templateValue.length > 0; + + if (!isRegex && filterValue === FILTER_SYMBOLS.PRESENT) { + return isPresent; + } + if (!isRegex && filterValue === FILTER_SYMBOLS.MISSING) { + return !isPresent; + } + + if (!isPresent) { + return false; + } + + if (isRegex && regex) { + return regex.test(templateValue!); + } + + return templateValue!.toLowerCase().includes(filterValue as string); + }, + ); + }); +}; diff --git a/packages/devkit/src/core/template/printer.ts b/packages/devkit/src/core/template/printer.ts index 29845d0..d6057fe 100644 --- a/packages/devkit/src/core/template/printer.ts +++ b/packages/devkit/src/core/template/printer.ts @@ -3,37 +3,22 @@ import { type CliConfig, type DisplayModesValues, type LanguageConfig, - type TemplateConfig, } from "#utils/schema/schema.js"; import { logger } from "#utils/logger.js"; +import { filterTemplatesByWhereClause } from "./filter.js"; type TemplateMap = LanguageConfig["templates"]; -type TemplateList = [string, TemplateMap]; - -const filterTemplateEntries = ( - templates: TemplateMap, - filter?: string, -): [string, TemplateConfig][] => { - let filteredTemplates = Object.entries(templates); - if (filter) { - const lowerFilter = filter.toLowerCase(); - filteredTemplates = filteredTemplates.filter( - ([templateName, templateConfig]) => { - const name = templateName.toLowerCase(); - const alias = templateConfig?.alias?.toLowerCase() ?? ""; - return name.includes(lowerFilter) || alias.includes(lowerFilter); - }, - ); - } - return filteredTemplates; -}; +export type TemplateList = [string, TemplateMap]; export function printTemplatesTree( language: string, templates: TemplateMap, - filter?: string, + whereClauses: string[], ): void { - const filteredTemplates = filterTemplateEntries(templates, filter); + const filteredTemplates = filterTemplatesByWhereClause( + templates, + whereClauses, + ); if (filteredTemplates.length === 0) return; @@ -72,7 +57,7 @@ export function printTemplatesTree( function printTemplatesTable( templatesList: TemplateList[], - filter?: string, + whereClauses: string[], ): void { const tableData: string[][] = []; const languageHeader = logger.colors.bold("Language"); @@ -90,7 +75,10 @@ function printTemplatesTable( ]); templatesList.forEach(([lang, templates]) => { - const filteredTemplates = filterTemplateEntries(templates, filter); + const filteredTemplates = filterTemplatesByWhereClause( + templates, + whereClauses, + ); filteredTemplates.forEach(([templateName, templateConfig]) => { const row = [ @@ -109,25 +97,26 @@ function printTemplatesTable( return; } - const messageKey = filter - ? "warnings.template_not_found_with_filter" - : "warnings.template_not_found"; + const messageKey = + whereClauses.length > 0 + ? "warnings.template_not_found_with_filter" + : "warnings.template_not_found"; logger.warning(t(messageKey)); } export function printTemplates( templatesList: TemplateList[], - filter?: string, + whereClauses: string[] = [], mode: DisplayModesValues = "tree", ): void { if (mode === "table") { - printTemplatesTable(templatesList, filter); + printTemplatesTable(templatesList, whereClauses); return; } templatesList.forEach(([lang, templates]) => { - printTemplatesTree(lang, templates, filter); + printTemplatesTree(lang, templates, whereClauses); }); }