Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions packages/create/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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 }))),
}),
);

Expand Down
98 changes: 98 additions & 0 deletions packages/create/src/utils/fuzzy.ts
Original file line numberDiff line numberDiff line change
@@ -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<FuzzyMatch | undefined>> = Array.from(
{ length: query.length },
() => new Array<FuzzyMatch | undefined>(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<T extends OptionLike>(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);
};
}
56 changes: 56 additions & 0 deletions packages/create/tests/cli.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<typeof import("@clack/prompts")>()),
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();
});
167 changes: 167 additions & 0 deletions packages/create/tests/fuzzy.test.ts
Original file line numberDiff line numberDiff line change
@@ -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]);
});
});
Loading