Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Recover interactively when the folder is not specified or is wrong #147

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
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}`)
daniilsapa marked this conversation as resolved.
Show resolved Hide resolved

/** 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
Loading