diff --git a/packages/create/src/index.ts b/packages/create/src/index.ts index 624bf2a..dadf851 100644 --- a/packages/create/src/index.ts +++ b/packages/create/src/index.ts @@ -6,6 +6,7 @@ import { createStart } from "./create-start"; import { createSolidV2 } from "./create-solid-v2"; import { GIT_IGNORE, isValidTemplate, LIBRARY_TEMPLATES, PROJECT_TYPES, ProjectType } from "./utils/constants"; import { fetchTemplatesManifest, groupKeyFor, ManifestTemplate, resolveGroup } from "./utils/manifest"; +import { fuzzyScore, rankedOptionsFn } from "./utils/fuzzy"; import { detectPackageManager } from "@solid-cli/utils/package-manager"; import { existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -167,13 +168,24 @@ export const createSolid = (version: string) => const template_opts: ManifestTemplate[] = group ? group.templates : LIBRARY_TEMPLATES.map((name) => ({ name })); + const availableTemplates = template_opts.filter((t) => (useJS ? t : !t.name.startsWith("js"))); + // clack's autocomplete always focuses options[0] when the search box is empty (it only + // honors `initialValue` for multi-select), so the manifest's `default`-flagged template + // has to be sorted to the front to keep it preselected, same as the old `p.select` did. + const defaultTemplate = availableTemplates.find((t) => t.default); + const orderedTemplates = defaultTemplate + ? [defaultTemplate, ...availableTemplates.filter((t) => t !== defaultTemplate)] + : availableTemplates; template ??= await cancelable( - p.select({ + p.autocomplete({ message: "Which template would you like to use?", - initialValue: (template_opts.find((t) => t.default) ?? template_opts[0])?.name, - options: template_opts - .filter((t) => (useJS ? t : !t.name.startsWith("js"))) - .map((t) => ({ label: t.name, value: t.name })), + placeholder: "Type to search...", + // clack's default substring filter would re-filter our fuzzy-ranked options and break + // non-contiguous matches (e.g. "wath" -> "with-auth") and the placeholder Tab-fill guard, + // so supply an equivalent fuzzy filter (asserted in tests/cli.test.ts). + filter: (search, option) => fuzzyScore(search, option.label ?? String(option.value)) > 0, + validate: (value) => (value === undefined ? "No matching template." : undefined), + options: rankedOptionsFn(orderedTemplates.map((t) => ({ label: t.name, value: t.name }))), }), ); diff --git a/packages/create/src/utils/fuzzy.ts b/packages/create/src/utils/fuzzy.ts new file mode 100644 index 0000000..d55c7c4 --- /dev/null +++ b/packages/create/src/utils/fuzzy.ts @@ -0,0 +1,98 @@ +const CHARACTER_SCORE = 1; +const CONTIGUOUS_BONUS = 1; + +type FuzzyMatch = { + score: number; + startIndex: number; +}; + +const NO_MATCH: FuzzyMatch = { score: 0, startIndex: Number.POSITIVE_INFINITY }; + +function betterMatch(a?: FuzzyMatch, b?: FuzzyMatch): FuzzyMatch | undefined { + if (!a) return b; + if (!b) return a; + if (a.score !== b.score) return a.score > b.score ? a : b; + return a.startIndex <= b.startIndex ? a : b; +} + +/** + * Matches `input` against `target` in order. Each matched character is worth + * one point, with one additional point for every contiguous pair. + */ +function findBestMatch(input: string, target: string): FuzzyMatch { + if (!input) return { score: CHARACTER_SCORE, startIndex: 0 }; + + const query = input.toLowerCase(); + const candidate = target.toLowerCase(); + if (query.length > candidate.length) return NO_MATCH; + + const matches: Array> = Array.from( + { length: query.length }, + () => new Array(candidate.length), + ); + + for (let queryIndex = 0; queryIndex < query.length; queryIndex++) { + let bestWithGap: FuzzyMatch | undefined; + + for (let candidateIndex = queryIndex; candidateIndex < candidate.length; candidateIndex++) { + if (queryIndex > 0 && candidateIndex >= 2) { + bestWithGap = betterMatch(bestWithGap, matches[queryIndex - 1][candidateIndex - 2]); + } + if (candidate[candidateIndex] !== query[queryIndex]) continue; + + if (queryIndex === 0) { + matches[queryIndex][candidateIndex] = { + score: CHARACTER_SCORE, + startIndex: candidateIndex, + }; + continue; + } + + const previous = matches[queryIndex - 1][candidateIndex - 1]; + const contiguous = previous && { + score: previous.score + CHARACTER_SCORE + CONTIGUOUS_BONUS, + startIndex: previous.startIndex, + }; + const withGap = bestWithGap && { + score: bestWithGap.score + CHARACTER_SCORE, + startIndex: bestWithGap.startIndex, + }; + + matches[queryIndex][candidateIndex] = betterMatch(contiguous, withGap); + } + } + + return matches.at(-1)?.reduce((best, match) => betterMatch(best, match), undefined) ?? NO_MATCH; +} + +export function fuzzyScore(input: string, target: string): number { + return findBestMatch(input, target).score; +} + +type OptionLike = { label?: string; value: string }; + +/** + * Ranks matches by score, earliest match position, then label. An empty query + * preserves the original option order. + */ +export function rankedOptionsFn(options: T[]) { + return function (this: { userInput?: string }): T[] { + const input = this?.userInput ?? ""; + if (!input) return options; + + return options + .map((option, index) => { + const label = option.label ?? String(option.value); + return { option, index, label, match: findBestMatch(input, label) }; + }) + .filter(({ match }) => match.score > 0) + .sort( + (a, b) => + b.match.score - a.match.score || + a.match.startIndex - b.match.startIndex || + a.label.localeCompare(b.label) || + a.index - b.index, + ) + .map(({ option }) => option); + }; +} diff --git a/packages/create/tests/cli.test.ts b/packages/create/tests/cli.test.ts new file mode 100644 index 0000000..bb61538 --- /dev/null +++ b/packages/create/tests/cli.test.ts @@ -0,0 +1,56 @@ +import { runCommand } from "citty"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const { autocomplete } = vi.hoisted(() => ({ autocomplete: vi.fn() })); + +vi.mock("@clack/prompts", async (importOriginal) => ({ + ...(await importOriginal()), + autocomplete, +})); + +import { createSolid } from "../src"; + +// Unroutable address: fetchTemplatesManifest fails fast and falls back to the +// baked-in template lists, same trick used in manifest.test.ts. +beforeEach(() => { + process.env.SOLID_CLI_TEMPLATES_MANIFEST_URL = "http://127.0.0.1:1/templates.json"; +}); +afterEach(() => { + delete process.env.SOLID_CLI_TEMPLATES_MANIFEST_URL; +}); + +it("uses an autocomplete prompt when selecting a template", async () => { + autocomplete.mockResolvedValueOnce(undefined); + + await runCommand(createSolid("test"), { rawArgs: ["test-app", "--vanilla", "--ts"] }); + + const { options, filter, validate, placeholder } = autocomplete.mock.calls[0][0]; + expect(autocomplete).toHaveBeenCalledWith({ + message: "Which template would you like to use?", + placeholder: "Type to search...", + filter: expect.any(Function), + validate: expect.any(Function), + options, + }); + expect(options.call({ userInput: "tail" })[0].value).toBe("with-tailwindcss"); + + // clack's autocomplete always focuses options[0] on an empty search box (it ignores a + // scalar `initialValue`), so the manifest's default-flagged template must be first. + expect(options.call({ userInput: "" })[0].value).toBe("basic"); + + // The filter must use the same fuzzy scoring as the ranking fn: a substring filter + // would reject "wath" against "with-auth" and re-filter out the ranked results. + expect(filter("wath", { label: "with-auth", value: "with-auth" })).toBe(true); + + // clack copies its `placeholder` into the search box on Tab whenever `filter` matches + // it against any option in the current (unfiltered) list; none may match, or Tab + // would fill the search with unmatched text and trigger the "no match" error. + expect(options.call({ userInput: "" }).some((o: { label: string; value: string }) => filter(placeholder, o))).toBe( + false, + ); + + // An empty autocomplete search resolves to `value: undefined`; validate must + // reject that with an error message instead of letting the CLI exit silently. + expect(validate(undefined)).toEqual(expect.any(String)); + expect(validate("with-auth")).toBeUndefined(); +}); diff --git a/packages/create/tests/fuzzy.test.ts b/packages/create/tests/fuzzy.test.ts new file mode 100644 index 0000000..058843a --- /dev/null +++ b/packages/create/tests/fuzzy.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from "vitest"; +import { fuzzyScore, rankedOptionsFn } from "../src/utils/fuzzy"; + +// Representative template names drawn from src/utils/constants.ts +const TEMPLATES = [ + "basic", + "bare", + "with-auth", + "with-authjs", + "with-drizzle", + "with-mdx", + "with-prisma", + "with-solid-styled", + "with-solidbase", + "with-solid-router", + "with-strict-csp", + "with-tailwindcss", + "with-tanstack-router-config-based", + "with-tanstack-router-file-based", + "with-tanstack-start", + "with-trpc", + "with-unocss", + "with-vitest", +] as const; + +const matches = (query: string) => TEMPLATES.filter((template) => fuzzyScore(query, template) > 0); + +describe("fuzzyScore — against real template names", () => { + it("still matches plain substrings: 'auth' -> with-auth, with-authjs", () => { + expect(matches("auth")).toEqual(["with-auth", "with-authjs"]); + }); + + it("matches non-contiguous chars in order: 'wath' -> with-auth(+)", () => { + // w…a…t…h — the headline fuzzy case + expect(matches("wath")).toEqual(expect.arrayContaining(["with-auth", "with-authjs"])); + expect(matches("wath")).not.toContain("bare"); + expect(matches("wath")).not.toContain("with-trpc"); + }); + + it("'drz' -> with-drizzle only (d…r…z)", () => { + expect(matches("drz")).toEqual(["with-drizzle"]); + }); + + it("'tail' matches tailwindcss (and, as a fuzzy side-effect, the file-based router)", () => { + // 'tail' = t…a…i…l: the same sequence also appears in-order inside + // "...tanstack-router-fi[l]e-based" (t/a from tanstack, i/l from file), + // which is the inherent permissiveness of fuzzy matching. + expect(matches("tail")).toEqual(["with-tailwindcss", "with-tanstack-router-file-based"]); + }); + + it("'csp' -> with-strict-csp only", () => { + expect(matches("csp")).toEqual(["with-strict-csp"]); + }); + + it("'router' -> all router templates", () => { + expect(matches("router")).toEqual([ + "with-solid-router", + "with-tanstack-router-config-based", + "with-tanstack-router-file-based", + ]); + }); + + it("'tanstack' -> all tanstack templates", () => { + expect(matches("tanstack")).toEqual([ + "with-tanstack-router-config-based", + "with-tanstack-router-file-based", + "with-tanstack-start", + ]); + }); + + it("returns empty for impossible queries", () => { + expect(matches("xyz")).toEqual([]); + expect(matches("q")).toEqual([]); + expect(matches("zzz")).toEqual([]); + }); + + it("empty query matches all templates", () => { + expect(matches("")).toEqual([...TEMPLATES]); + }); + + it("is case-insensitive: 'WATH' matches the same templates as 'wath'", () => { + expect(matches("WATH")).toEqual(matches("wath")); + }); +}); + +describe("fuzzyScore — match-quality ranking", () => { + it("contiguous match beats scattered match", () => { + // "tail" aligns as a tight run in tailwindcss, but scatters through tanstack + expect(fuzzyScore("tail", "with-tailwindcss")).toBeGreaterThan( + fuzzyScore("tail", "with-tanstack-router-file-based"), + ); + }); + + it("adds one point for each contiguous pair", () => { + expect(fuzzyScore("auth", "with-auth")).toBe(7); + expect(fuzzyScore("wath", "with-auth")).toBe(5); + }); + + it("does not award word-boundary or prefix bonuses", () => { + expect(fuzzyScore("tail", "detail")).toBe(fuzzyScore("tail", "with-tailwindcss")); + }); + + it("returns 0 when input cannot be matched in order", () => { + expect(fuzzyScore("xyz", "with-auth")).toBe(0); + expect(fuzzyScore("with-auth", "auth")).toBe(0); + }); + + it("empty input ties everything at a positive score", () => { + expect(fuzzyScore("", "with-auth")).toBeGreaterThan(0); + expect(fuzzyScore("", "bare")).toBeGreaterThan(0); + }); + + it("scores case-insensitively: uppercase query matches lowercase target the same", () => { + expect(fuzzyScore("AUTH", "with-auth")).toBe(fuzzyScore("auth", "with-auth")); + }); +}); + +describe("rankedOptionsFn — ordering for clack autocomplete", () => { + const opts = TEMPLATES.map((t) => ({ label: t, value: t })); + // Simulate clack calling the options fn with the prompt's `this.userInput`. + const ranked = (query: string) => + (rankedOptionsFn(opts).call({ userInput: query }) as typeof opts).map((o) => o.label); + + it("ranks an exact substring above a fuzzy scatter: 'tail'", () => { + // tailwindcss contains "tail" verbatim; file-based only matches it fuzzily + expect(ranked("tail")).toEqual(["with-tailwindcss", "with-tanstack-router-file-based"]); + }); + + it("keeps prefix/substring winners first: 'auth'", () => { + expect(ranked("auth")).toEqual(["with-auth", "with-authjs"]); + }); + + it("resolves the headline fuzzy case with stable order: 'wath'", () => { + // both match fuzzily with equal scores and starts → alphabetical order + expect(ranked("wath")).toEqual(["with-auth", "with-authjs"]); + }); + + it("lands early-typing queries on the strongest contiguous match", () => { + const r = ranked("ta"); + expect(r[0]).toBe("with-tailwindcss"); + expect(r.indexOf("with-tailwindcss")).toBeLessThan(r.indexOf("with-auth")); + }); + + it("breaks score ties by earliest match start", () => { + const options = ["zz-tail", "tail-zz"].map((label) => ({ label, value: label })); + const result = rankedOptionsFn(options).call({ userInput: "tail" }); + expect(result.map(({ label }) => label)).toEqual(["tail-zz", "zz-tail"]); + }); + + it("breaks score and start ties alphabetically", () => { + const options = ["cat", "car"].map((label) => ({ label, value: label })); + const result = rankedOptionsFn(options).call({ userInput: "ca" }); + expect(result.map(({ label }) => label)).toEqual(["car", "cat"]); + }); + + it("narrows to a single best match: 'drz'", () => { + expect(ranked("drz")).toEqual(["with-drizzle"]); + }); + + it("returns nothing for impossible queries", () => { + expect(ranked("xyz")).toEqual([]); + }); + + it("empty input preserves the original option order", () => { + expect(ranked("")).toEqual([...TEMPLATES]); + }); +});