Skip to content

Commit

Permalink
Recover interactively when the folder is not specified or is wrong (#147
Browse files Browse the repository at this point in the history
)
  • Loading branch information
illright authored Dec 30, 2024
1 parent caa9ede commit 80d9f46
Show file tree
Hide file tree
Showing 14 changed files with 478 additions and 88 deletions.
5 changes: 5 additions & 0 deletions .changeset/lovely-queens-occur.md
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
2 changes: 1 addition & 1 deletion packages/steiger-plugin-fsd/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,6 @@
"@types/pluralize": "^0.0.33",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
"vitest": "^3.0.0-beta.2"
}
}
7 changes: 6 additions & 1 deletion packages/steiger/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,19 @@
"README.md"
],
"dependencies": {
"@clack/prompts": "^0.8.2",
"@feature-sliced/steiger-plugin": "workspace:*",
"chokidar": "^4.0.1",
"cosmiconfig": "^9.0.0",
"effector": "^23.2.3",
"empathic": "^1.0.0",
"fastest-levenshtein": "^1.0.16",
"globby": "^14.0.2",
"immer": "^10.1.1",
"lodash-es": "^4.17.21",
"minimatch": "^10.0.1",
"patronum": "^2.3.0",
"picocolors": "^1.1.1",
"prexit": "^2.3.0",
"yargs": "^17.7.2",
"zod": "^3.24.0",
Expand All @@ -63,9 +67,10 @@
"@total-typescript/ts-reset": "^0.6.1",
"@types/lodash-es": "^4.17.12",
"@types/yargs": "^17.0.33",
"memfs": "^4.15.0",
"tsup": "^8.3.5",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
"vitest": "^3.0.0-beta.2"
}
}
39 changes: 27 additions & 12 deletions packages/steiger/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env node

import { resolve, relative, dirname } from 'node:path'
import { stat } from 'node:fs/promises'
import * as process from 'node:process'
import yargs from 'yargs'
import prexit from 'prexit'
Expand All @@ -13,9 +12,11 @@ import { cosmiconfig } from 'cosmiconfig'
import { linter } from './app'
import { processConfiguration, $plugins } from './models/config'
import { applyAutofixes } from './features/autofix'
import { chooseRootFolderFromGuesses, chooseRootFolderFromSimilar, ExitException } from './features/choose-root-folder'
import fsd from '@feature-sliced/steiger-plugin'
import type { Diagnostic } from '@steiger/types'
import packageJson from '../package.json'
import { existsAndIsFolder } from './shared/file-system'

const { config, filepath } = (await cosmiconfig('steiger').search()) ?? { config: null, filepath: undefined }
const defaultConfig = fsd.configs.recommended
Expand Down Expand Up @@ -64,8 +65,6 @@ const yargsProgram = yargs(hideBin(process.argv))
const filePaths = argv._
if (filePaths.length > 1) {
throw new Error('Pass only one path to watch')
} else if (filePaths.length === 0) {
throw new Error('Pass a path to watch')
} else {
return true
}
Expand All @@ -87,17 +86,33 @@ const yargsProgram = yargs(hideBin(process.argv))
.showHelpOnFail(true)

const consoleArgs = yargsProgram.parseSync()
const inputPaths = consoleArgs._

const targetPath = resolve(consoleArgs._[0])

try {
if (!(await stat(targetPath)).isDirectory()) {
console.error(`${consoleArgs._[0]} is a file, must be a folder`)
process.exit(102)
let targetPath: string | undefined
if (inputPaths.length > 0) {
if (await existsAndIsFolder(inputPaths[0])) {
targetPath = resolve(inputPaths[0])
} else {
try {
targetPath = resolve(await chooseRootFolderFromSimilar(inputPaths[0]))
} catch (e) {
if (e instanceof ExitException) {
process.exit(0)
} else {
throw e
}
}
}
} else {
try {
targetPath = resolve(await chooseRootFolderFromGuesses())
} catch (e) {
if (e instanceof ExitException) {
process.exit(0)
} else {
throw e
}
}
} catch {
console.error(`Folder ${consoleArgs._[0]} does not exist`)
process.exit(101)
}

const printDiagnostics = (diagnostics: Array<Diagnostic>) => {
Expand Down
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([])
})
})
}
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'))
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class ExitException extends Error {}
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}\``)
}
3 changes: 3 additions & 0 deletions packages/steiger/src/features/choose-root-folder/index.ts
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'
Loading

0 comments on commit 80d9f46

Please sign in to comment.