Skip to content
This repository has been archived by the owner on Feb 26, 2024. It is now read-only.

feat: improved user experience for detached instances with prefix matching and suggestions #4199

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
29 changes: 24 additions & 5 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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 => {
Expand Down
186 changes: 169 additions & 17 deletions packages/cli/src/detach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:";
Expand All @@ -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
Expand All @@ -53,39 +56,147 @@ 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
* was found. If the PID is identified, but the process is not found, any
* 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<boolean> {
): Promise<InstanceOrSuggestions> {
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<DetachedInstance>} 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(
Expand Down Expand Up @@ -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<DetachedInstance[]>} resolves with an array of instances
* @returns resolves with an array of instances
*/
export async function getDetachedInstances(): Promise<DetachedInstance[]> {
let dirEntries: Dirent[];
Expand Down Expand Up @@ -292,7 +403,7 @@ export async function getDetachedInstances(): Promise<DetachedInstance[]> {
/**
* 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
Expand Down Expand Up @@ -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];
}
60 changes: 59 additions & 1 deletion packages/cli/tests/detach.test.ts
Original file line number Diff line number Diff line change
@@ -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"],
Expand Down