diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8e1f8b9f71..5320d86dfc 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -164,11 +164,29 @@ if (argv.action === "start") { } else if (argv.action === "stop") { const instanceName = argv.name; - stopDetachedInstance(instanceName).then(instanceFound => { - if (instanceFound) { - console.log("Instance stopped"); + stopDetachedInstance(instanceName).then(instanceOrSuggestions => { + if ("instance" in instanceOrSuggestions) { + const highlightedName = porscheColor(instanceOrSuggestions.instance.name); + console.log(`${highlightedName} stopped.`); } else { - console.error("Instance not found"); + process.exitCode = 1; + console.log(`We couldn't find '${porscheColor(instanceName)}'.`); + if (instanceOrSuggestions.suggestions?.length > 0) { + console.log(); + console.log("But here's some instances with similar names:"); + console.log( + instanceOrSuggestions.suggestions + .map(name => " - " + porscheColor(name)) + .join("\n") + ); + } + + console.log(); + console.log( + `Try ${porscheColor( + "ganache instances list" + )} to see all running instances.` + ); } }); } else if (argv.action === "start-detached") { @@ -181,7 +199,8 @@ if (argv.action === "start") { }) .catch(err => { // the child process would have output its error to stdout, so no need to - // output anything more + // do anything more other than set the exitCode + process.exitCode = 1; }); } else if (argv.action === "list") { getDetachedInstances().then(instances => { diff --git a/packages/cli/src/detach.ts b/packages/cli/src/detach.ts index f3a8701954..f798a4c69d 100644 --- a/packages/cli/src/detach.ts +++ b/packages/cli/src/detach.ts @@ -19,6 +19,8 @@ export type DetachedInstance = { version: string; }; +const MAX_SUGGESTIONS = 4; +const MAX_LEVENSHTEIN_DISTANCE = 10; const FILE_ENCODING = "utf8"; const START_ERROR = "An error occurred spawning a detached instance of Ganache:"; @@ -39,8 +41,9 @@ export function notifyDetachedInstanceReady(cliSettings: CliSettings) { /** * Attempt to find and remove the instance file for a detached instance. - * @param {string} instanceName the name of the instance to be removed - * @returns boolean indicating whether the instance file was cleaned up successfully + * @param instanceName the name of the instance to be removed + * @returns resolves to a boolean indicating whether the instance file was + * cleaned up successfully */ export async function removeDetachedInstanceFile( instanceName: string @@ -53,6 +56,12 @@ export async function removeDetachedInstanceFile( return false; } +// A fuzzy matched detached instance(s). Either a strong match as instance, +// or a list of suggestions. +type InstanceOrSuggestions = + | { instance: DetachedInstance } + | { suggestions: string[] }; + /** * Attempts to stop a detached instance with the specified instance name by * sending a SIGTERM signal. Returns a boolean indicating whether the process @@ -60,32 +69,134 @@ export async function removeDetachedInstanceFile( * corresponding instance file will be removed. * * Note: This does not guarantee that the instance actually stops. - * @param {string} instanceName - * @returns boolean indicating whether the instance was found. + * @param instanceName + * @returns an object containing either the stopped + * `instance`, or `suggestions` for similar instance names */ export async function stopDetachedInstance( instanceName: string -): Promise { +): Promise { + let instance; + try { - // getDetachedInstanceByName() throws if the instance file is not found or - // cannot be parsed - const instance = await getDetachedInstanceByName(instanceName); + instance = await getDetachedInstanceByName(instanceName); + } catch { + const similarInstances = await getSimilarInstanceNames(instanceName); + + if ("match" in similarInstances) { + try { + instance = await getDetachedInstanceByName(similarInstances.match); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + // The instance file was removed between the call to + // `getSimilarInstanceNames` and `getDetachedInstancesByName`, but we + // didn't get suggestions (although some may exist). We _could_ + // reiterate stopDetachedInstance but that seems messy. Let's just + // tell the user the instance wasn't found, and be done with it. + return { + suggestions: [] + }; + } + throw err; + } + } else { + return { suggestions: similarInstances.suggestions }; + } + } + if (instance) { // process.kill() throws if the process was not found (or was a group // process in Windows) - process.kill(instance.pid, "SIGTERM"); + try { + process.kill(instance.pid, "SIGTERM"); + } catch (err) { + // process not found + // todo: log message saying that the process could not be found + } finally { + await removeDetachedInstanceFile(instance.name); + return { instance }; + } + } +} + +/** + * Find instances with names similar to `instanceName`. + * + * If there is a single instance with an exact prefix match, it is returned as + * the `match` property in the result. Otherwise, up to `MAX_SUGGESTIONS` names + * that are similar to `instanceName` are returned as `suggestions`. Names with + * an exact prefix match are prioritized, followed by increasing Levenshtein + * distance, up to a maximum distance of `MAX_LEVENSHTEIN_DISTANCE`. + * @param {string} instanceName the name for which similarly named instance will + * be searched + * @returns {{ match: string } | { suggestions: string[] }} an object + * containiner either a single exact `match` or a number of `suggestions` + */ +async function getSimilarInstanceNames( + instanceName: string +): Promise<{ match: string } | { suggestions: string[] }> { + const filenames: string[] = []; + try { + const parsedPaths = ( + await fsPromises.readdir(dataPath, { withFileTypes: true }) + ).map(file => path.parse(file.name)); + + for (const { ext, name } of parsedPaths) { + if (ext === ".json") { + filenames.push(name); + } + } } catch (err) { - return false; - } finally { - await removeDetachedInstanceFile(instanceName); + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + // instances directory does not exist, so there can be no suggestions + return { suggestions: [] }; + } + } + + const prefixMatches = []; + for (const name of filenames) { + if (name.startsWith(instanceName)) { + prefixMatches.push(name); + } + } + + if (prefixMatches.length === 1) { + return { match: prefixMatches[0] }; + } + + let suggestions: string[]; + if (prefixMatches.length >= MAX_SUGGESTIONS) { + suggestions = prefixMatches; + } else { + const similar = []; + + for (const name of filenames) { + if (!prefixMatches.some(m => m === name)) { + const distance = levenshteinDistance(instanceName, name); + if (distance <= MAX_LEVENSHTEIN_DISTANCE) { + similar.push({ + name, + distance + }); + } + } + } + similar.sort((a, b) => a.distance - b.distance); + + suggestions = similar.map(s => s.name); + // matches should be at the start of the suggestions array + suggestions.splice(0, 0, ...prefixMatches); } - return true; + + return { + suggestions: suggestions.slice(0, MAX_SUGGESTIONS) + }; } /** * Start an instance of Ganache in detached mode. - * @param {string[]} argv arguments to be passed to the new instance. - * @returns {Promise} resolves to the DetachedInstance once it + * @param argv arguments to be passed to the new instance. + * @returns resolves to the DetachedInstance once it * is started and ready to receive requests. */ export async function startDetachedInstance( @@ -200,7 +311,7 @@ export async function startDetachedInstance( /** * Fetch all instance of Ganache running in detached mode. Cleans up any * instance files for processes that are no longer running. - * @returns {Promise} resolves with an array of instances + * @returns resolves with an array of instances */ export async function getDetachedInstances(): Promise { let dirEntries: Dirent[]; @@ -292,7 +403,7 @@ export async function getDetachedInstances(): Promise { /** * Attempts to load data for the instance specified by instanceName. Throws if * the instance file is not found or cannot be parsed - * @param {string} instanceName + * @param instanceName */ async function getDetachedInstanceByName( instanceName: string @@ -323,3 +434,44 @@ export function formatUptime(ms: number) { return isFuture ? `In ${duration}` : duration; } + +/** + * This function calculates the Levenshtein distance between two strings. + * Levenshtein distance is a measure of the difference between two strings, + * defined as the minimum number of edits (insertions, deletions or substitutions) + * required to transform one string into another. + * + * @param a - The first string to compare. + * @param b - The second string to compare. + * @return The Levenshtein distance between the two strings. + */ +export function levenshteinDistance(a: string, b: string): number { + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + let matrix = []; + + for (let i = 0; i <= b.length; i++) { + matrix[i] = [i]; + } + + for (let j = 0; j <= a.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + if (b.charAt(i - 1) == a.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1 + ); + } + } + } + + return matrix[b.length][a.length]; +} diff --git a/packages/cli/tests/detach.test.ts b/packages/cli/tests/detach.test.ts index e7507387f3..c2a1b68fae 100644 --- a/packages/cli/tests/detach.test.ts +++ b/packages/cli/tests/detach.test.ts @@ -1,8 +1,66 @@ import assert from "assert"; -import { formatUptime } from "../src/detach"; +import { formatUptime, levenshteinDistance } from "../src/detach"; describe("@ganache/cli", () => { describe("detach", () => { + describe("levenshteinDistance", () => { + it("returns 0 for identical strings", () => { + const a = "hello"; + const b = "hello"; + const result = levenshteinDistance(a, b); + + assert.strictEqual(result, 0); + }); + + it("returns correct distance for different strings", () => { + const a = "hello"; + const b = "world"; + const result = levenshteinDistance(a, b); + + assert.strictEqual(result, 4); + }); + + it("returns correct distance for strings of different lengths", () => { + const a = "hello"; + const b = "hi"; + const result = levenshteinDistance(a, b); + + assert.strictEqual(result, 4); + }); + + it("returns correct distance for strings with additions", () => { + const a = "hello"; + const b = "heBlAlo"; + const result = levenshteinDistance(a, b); + + assert.strictEqual(result, 2); + }); + + it("returns correct distance for strings with subtractions", () => { + const a = "hello"; + const b = "hll"; + const result = levenshteinDistance(a, b); + + assert.strictEqual(result, 2); + }); + + it("returns correct distance for strings with substitutions", () => { + const a = "hello"; + const b = "hAlAo"; + const result = levenshteinDistance(a, b); + + assert.strictEqual(result, 2); + }); + + it("returns correct distance for strings with addition, subtraction and substitution", () => { + const a = "hello world"; + const b = "helloo wolB"; + const result = levenshteinDistance(a, b); + + assert.strictEqual(result, 3); + }); + }); + describe("formatUptime()", () => { const durations: [number, string][] = [ [0, "Just started"],