-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Recover interactively when the folder is not specified or is wrong (#147
- Loading branch information
Showing
14 changed files
with
478 additions
and
88 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'steiger': patch | ||
--- | ||
|
||
Interactively suggest folders when a missing folder was specified in the command or when it wasn't specified at all |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
114 changes: 114 additions & 0 deletions
114
packages/steiger/src/features/choose-root-folder/choose-from-guesses.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
import { sep } from 'node:path' | ||
import { confirm, isCancel, outro, select } from '@clack/prompts' | ||
|
||
import { ExitException } from './exit-exception' | ||
import { formatCommand } from './format-command' | ||
import { existsAndIsFolder } from '../../shared/file-system' | ||
|
||
const commonRootFolders = ['src', 'app'].map((folder) => `.${sep}${folder}`) | ||
|
||
/** Present the user with a choice of folders based on an informed guess. */ | ||
export async function chooseFromGuesses(): Promise<string> { | ||
let targetPath: string | undefined | ||
|
||
const candidates = await findRootFolderCandidates() | ||
if (candidates.length === 0) { | ||
const answer = await confirm({ | ||
message: `You haven't specified a path to check. Would you like to check this folder?`, | ||
inactive: 'No, exit', | ||
}) | ||
|
||
if (answer === true) { | ||
targetPath = '.' | ||
} | ||
} else { | ||
const answer = await select({ | ||
message: `You haven't specified a path to check. Would you like to use one of the following?`, | ||
options: candidates | ||
.map((candidate) => ({ value: candidate, label: candidate })) | ||
.concat({ value: '.', label: 'This folder' }) | ||
.concat({ value: '', label: 'No, exit' }), | ||
}) | ||
|
||
if (!isCancel(answer) && answer !== '') { | ||
targetPath = answer | ||
} | ||
} | ||
|
||
if (targetPath === undefined) { | ||
outro(`Alright! To run checks on a specific folder, run ${formatCommand(`steiger .${sep}your-folder`)}.`) | ||
throw new ExitException() | ||
} else { | ||
outro(`Running ${formatCommand(`steiger ${targetPath}`)}`) | ||
} | ||
|
||
return targetPath | ||
} | ||
|
||
/** | ||
* Check if any of the common root project folders are present | ||
* and return a list of the ones that are present. | ||
*/ | ||
async function findRootFolderCandidates(): Promise<Array<string>> { | ||
return ( | ||
await Promise.all(commonRootFolders.map(async (folder) => ((await existsAndIsFolder(folder)) ? folder : undefined))) | ||
).filter(Boolean) | ||
} | ||
|
||
if (import.meta.vitest) { | ||
const { describe, test, expect, vi, beforeEach } = import.meta.vitest | ||
const { vol } = await import('memfs') | ||
const { joinFromRoot } = await import('@steiger/toolkit') | ||
|
||
vi.mock('node:fs/promises', () => import('memfs').then((memfs) => memfs.fs.promises)) | ||
|
||
describe('findRootFolderCandidates', () => { | ||
const root = joinFromRoot('home', 'project') | ||
beforeEach(() => { | ||
vol.reset() | ||
vi.spyOn(process, 'cwd').mockReturnValue(root) | ||
}) | ||
|
||
test('when src is present, app is not', async () => { | ||
const fileStructure = { | ||
src: {}, | ||
dist: {}, | ||
} | ||
|
||
vol.fromNestedJSON(fileStructure, root) | ||
|
||
await expect(findRootFolderCandidates()).resolves.toEqual([`.${sep}src`]) | ||
}) | ||
|
||
test('when app is present, src is not', async () => { | ||
const fileStructure = { | ||
app: {}, | ||
} | ||
|
||
vol.fromNestedJSON(fileStructure, root) | ||
|
||
await expect(findRootFolderCandidates()).resolves.toEqual([`.${sep}app`]) | ||
}) | ||
|
||
test('when both src and app are present', async () => { | ||
const fileStructure = { | ||
src: {}, | ||
app: {}, | ||
} | ||
|
||
vol.fromNestedJSON(fileStructure, root) | ||
|
||
await expect(findRootFolderCandidates()).resolves.toEqual([`.${sep}src`, `.${sep}app`]) | ||
}) | ||
|
||
test('when neither src nor app are present', async () => { | ||
const fileStructure = { | ||
dist: {}, | ||
} | ||
|
||
vol.fromNestedJSON(fileStructure, root) | ||
|
||
await expect(findRootFolderCandidates()).resolves.toEqual([]) | ||
}) | ||
}) | ||
} |
122 changes: 122 additions & 0 deletions
122
packages/steiger/src/features/choose-root-folder/choose-from-similar.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
import { readdir } from 'node:fs/promises' | ||
import { parse, relative, sep, join, dirname } from 'node:path' | ||
import pc from 'picocolors' | ||
import { isGitIgnored } from 'globby' | ||
import * as find from 'empathic/find' | ||
|
||
import { distance } from 'fastest-levenshtein' | ||
import { isCancel, outro, select, confirm } from '@clack/prompts' | ||
import { formatCommand } from './format-command' | ||
import { ExitException } from './exit-exception' | ||
|
||
/** The maximum Levenshtein distance between the input and the reference for the input to be considered a typo. */ | ||
const typoThreshold = 5 | ||
const gitFolder = find.up('.git') | ||
const isIgnored = await isGitIgnored({ cwd: gitFolder ? dirname(gitFolder) : undefined }) | ||
|
||
/** Present the user with a choice of folders based on similarity to a given input. */ | ||
export async function chooseFromSimilar(input: string): Promise<string> { | ||
const resolved = relative('.', input) | ||
const { dir, base } = parse(resolved) | ||
const existingDir = await resolveWithCorrections(dir || '.') | ||
|
||
const candidates = (await readdir(existingDir, { withFileTypes: true })) | ||
.filter((entry) => entry.isDirectory() && entry.name !== '.git' && !isIgnored(join(existingDir, entry.name))) | ||
.map((entry) => entry.name) | ||
const withDistances = candidates.map((candidate) => [candidate, distance(candidate, base)] as const) | ||
const suggestions = withDistances | ||
.filter(([_candidate, distance]) => distance <= typoThreshold) | ||
.sort((a, b) => a[1] - b[1]) | ||
|
||
let answer: string | undefined | ||
if (suggestions.length === 1) { | ||
const confirmation = await confirm({ | ||
message: `${pc.red(input)} is not a folder. Did you mean ${pc.green(`.${sep}${join(existingDir, suggestions[0][0])}`)}?`, | ||
inactive: 'No, exit', | ||
}) | ||
|
||
if (confirmation === true) { | ||
answer = join(existingDir, suggestions[0][0]) | ||
} else { | ||
answer = '' | ||
} | ||
} else { | ||
const selection = await select({ | ||
message: `${pc.red(input)} is not a folder. Did you mean one of the following?`, | ||
options: suggestions | ||
.map(([candidate, _distance]) => ({ value: candidate, label: `.${sep}${join(existingDir, candidate)}` })) | ||
.concat({ value: '', label: 'No, exit' }), | ||
}) | ||
|
||
if (selection !== '' && !isCancel(selection)) { | ||
answer = join(existingDir, selection) | ||
} else { | ||
answer = '' | ||
} | ||
} | ||
|
||
if (answer !== '') { | ||
outro(`Running ${formatCommand(`steiger .${sep}${answer}`)}`) | ||
return answer | ||
} else { | ||
outro(`Alright! To run checks on a specific folder, run ${formatCommand(`steiger .${sep}your-folder`)}.`) | ||
throw new ExitException() | ||
} | ||
} | ||
|
||
/** | ||
* Take a relative path that might contain typos and resolve each typo to the best matching candidate. | ||
* | ||
* @example | ||
* // For a folder structure like: | ||
* // - src | ||
* // - app | ||
* // - shared | ||
* // - dist | ||
* resolveWithCorrections('src/app') // 'src/app' | ||
* resolveWithCorrections('scr/shad') // 'src/shared' | ||
*/ | ||
async function resolveWithCorrections(path: string) { | ||
let finalPath = '.' | ||
for (const part of path.split(sep)) { | ||
if (part === '.') { | ||
continue | ||
} else if (part === '..') { | ||
finalPath = join(finalPath, part) | ||
} else { | ||
const candidates = (await readdir(finalPath, { withFileTypes: true })) | ||
.filter((entry) => entry.isDirectory()) | ||
.map((entry) => entry.name) | ||
const distances = candidates.map((candidate) => distance(candidate, part)) | ||
const bestMatch = candidates[distances.indexOf(Math.min(...distances))] | ||
finalPath = join(finalPath, bestMatch) | ||
} | ||
} | ||
|
||
return finalPath | ||
} | ||
|
||
if (import.meta.vitest) { | ||
const { test, expect, vi } = import.meta.vitest | ||
const { vol } = await import('memfs') | ||
const { joinFromRoot } = await import('@steiger/toolkit') | ||
|
||
vi.mock('node:fs/promises', () => import('memfs').then((memfs) => memfs.fs.promises)) | ||
|
||
test('resolveWithCorrections', async () => { | ||
const root = joinFromRoot('home', 'project') | ||
const fileStructure = { | ||
src: { | ||
app: {}, | ||
shared: {}, | ||
}, | ||
dist: {}, | ||
} | ||
|
||
vi.spyOn(process, 'cwd').mockReturnValue(root) | ||
vol.fromNestedJSON(fileStructure, root) | ||
|
||
expect(await resolveWithCorrections(join('src', 'app'))).toBe(join('src', 'app')) | ||
expect(await resolveWithCorrections(join('scr', 'shad'))).toBe(join('src', 'shared')) | ||
}) | ||
} |
1 change: 1 addition & 0 deletions
1
packages/steiger/src/features/choose-root-folder/exit-exception.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export class ExitException extends Error {} |
5 changes: 5 additions & 0 deletions
5
packages/steiger/src/features/choose-root-folder/format-command.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import pc from 'picocolors' | ||
|
||
export function formatCommand(command: string): string { | ||
return pc.green(`\`${command}\``) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export { chooseFromGuesses as chooseRootFolderFromGuesses } from './choose-from-guesses' | ||
export { chooseFromSimilar as chooseRootFolderFromSimilar } from './choose-from-similar' | ||
export { ExitException } from './exit-exception' |
Oops, something went wrong.