#!/usr/bin/env bun import { readFile } from "node:fs/promises" import { Effect, Result } from "effect" import packageManifest from "../../package.json" with { type: "json" } import { splitViolations } from "../adapter/feedback.ts" import { loadConfig } from "../config/load.ts" import { loadConfiguredDictionary } from "../dictionary/configured.ts" import { loadRuleData } from "../dictionary/load.ts" import { classifyPath, type PathClassification } from "../engine/kinds.ts" import { lint } from "../engine/lint.ts" import type { LintKind, LintReport } from "../engine/types.ts" import { TaggerService, WinkTaggerLive } from "../tagger/wink.ts" import { hookInternalFailure, runHookMode } from "./hook.ts" import { observationStats, reviewObservations } from "./observation-log.ts" import { runSessionCommand } from "./session-command.ts" const KINDS: readonly LintKind[] = [ "prose-file", "slash-source", "hash-source", "html", "commit-message", ] const USAGE = `Usage: simple-english [options] [paths...] simple-english observe review simple-english observe stats Options: --json Write a JSON report. --config Use one config file. --kind Use one content kind. --help Print this help. --version Print the package version.` interface CliArgs { readonly json: boolean readonly configPath: string | undefined readonly kind: string | undefined readonly kindMissingValue: boolean readonly help: boolean readonly version: boolean readonly paths: readonly string[] } const parseArgs = (args: readonly string[]): Effect.Effect => Effect.gen(function* () { let json = false let configPath: string | undefined let kind: string | undefined let kindMissingValue = false let help = false let version = false const paths: string[] = [] for (let i = 0; i < args.length; i++) { const arg = args[i] as string if (arg === "--json") { json = true } else if (arg === "--config") { const value = args[i + 1] if (value === undefined || value.startsWith("--")) { yield* Effect.fail(new Error("--config requires a file path")) } configPath = value i++ } else if (arg === "--kind") { const value = args[i + 1] if (value === undefined || value.startsWith("--")) { kindMissingValue = true } else { kind = value i++ } } else if (arg.startsWith("--kind=")) { kind = arg.slice("--kind=".length) } else if (arg === "--help") { help = true } else if (arg === "--version") { version = true } else if (arg.startsWith("--")) { yield* Effect.fail(new Error(`unknown flag "${arg}"`)) } else { paths.push(arg) } } return { json, configPath, kind, kindMissingValue, help, version, paths } }) const rejectUnknownFlags = (args: readonly string[]): Effect.Effect => { const flag = args.find((arg) => arg.startsWith("--")) return flag === undefined ? Effect.void : Effect.fail(new Error(`unknown flag "${flag}"`)) } const isLintKind = (value: string): value is LintKind => (KINDS as readonly string[]).includes(value) interface FileViolation { readonly file: string readonly ruleId: string readonly severity: string readonly message: string readonly snippet: string readonly suggestions?: readonly string[] readonly line: number readonly column: number } interface CliReport { readonly violations: readonly FileViolation[] readonly summary: { readonly total: number; readonly hard: number } readonly skipped: readonly string[] } const readStdin = Effect.promise(async () => { const chunks: Buffer[] = [] for await (const chunk of process.stdin) { chunks.push(chunk as Buffer) } return Buffer.concat(chunks).toString("utf8") }) const readInput = (path: string) => path === "-" ? readStdin.pipe(Effect.map((text) => ({ path: "", text }))) : Effect.tryPromise({ try: () => readFile(path, "utf8"), catch: (cause) => new Error(`cannot read ${path}: ${cause}`), }).pipe(Effect.map((text) => ({ path, text }))) const toCliReport = ( reports: readonly { path: string; report: LintReport }[], skipped: readonly string[], ): CliReport => { const violations = reports.flatMap(({ path, report }) => report.violations.map((violation) => ({ file: path, ...violation })), ) return { violations, summary: { total: violations.length, hard: splitViolations(violations).hard.length, }, skipped, } } const render = (report: CliReport, json: boolean): string => { if (json) { return JSON.stringify(report, null, 2) } const lines = [ ...report.skipped.map((path) => `${path}: skipped (non-prose extension)`), ...report.violations.map( (v) => `${v.file}:${v.line}:${v.column} [${v.severity}] ${v.ruleId} ${v.message}`, ), ] return lines.join("\n") } const args = process.argv.slice(2) const hookProgram = Effect.gen(function* () { const output = yield* runHookMode(yield* readStdin).pipe(Effect.provide(WinkTaggerLive)) console.log(JSON.stringify(output)) return 0 }).pipe( Effect.catchCause((cause) => Effect.sync(() => { console.log(JSON.stringify(hookInternalFailure(cause))) return 0 }), ), ) const sessionProgram = Effect.gen(function* () { console.log(yield* runSessionCommand(args.slice(1))) return 0 }) const observeProgram = Effect.gen(function* () { const command = args[1] if (args.length !== 2 || (command !== "review" && command !== "stats")) { return yield* Effect.fail(new Error("Usage: simple-english observe ")) } if (command === "review") { yield* Effect.tryPromise({ try: () => reviewObservations(), catch: (cause) => new Error(`cannot review observations: ${cause}`), }) } else { console.log( yield* Effect.tryPromise({ try: () => observationStats(), catch: (cause) => new Error(`cannot read observation stats: ${cause}`), }), ) } return 0 }) const lintProgram = Effect.gen(function* () { const tagger = yield* TaggerService const { json, configPath, kind, kindMissingValue, help, version, paths } = yield* parseArgs(args) if (help) { console.log(USAGE) return 0 } if (version) { console.log(packageManifest.version) return 0 } if (kindMissingValue) { return yield* Effect.fail( new Error(`--kind requires a value; expected one of: ${KINDS.join(", ")}`), ) } if (kind !== undefined && !isLintKind(kind)) { return yield* Effect.fail( new Error(`unknown kind "${kind}"; expected one of: ${KINDS.join(", ")}`), ) } const config = yield* loadConfig(configPath) const loadedDictionary = yield* Effect.result( loadConfiguredDictionary(config, process.cwd(), process.env.SIMPLE_ENGLISH_DICTIONARY), ) const loadedRuleData = yield* Effect.result(loadRuleData(config.ruleDataExtensions)) if (Result.isFailure(loadedDictionary) && config.approvedWordsPath !== undefined) { return yield* Effect.fail(loadedDictionary.failure) } const dictionary = Result.getOrUndefined(loadedDictionary) const ruleData = Result.getOrUndefined(loadedRuleData) if (Result.isFailure(loadedDictionary)) { yield* Effect.sync(() => console.error(loadedDictionary.failure.message)) } if (Result.isFailure(loadedRuleData)) { yield* Effect.sync(() => console.error(loadedRuleData.failure.message)) } const inputs = paths.length === 0 ? [{ path: "", text: yield* readStdin }] : yield* Effect.forEach(paths, readInput) const skippedPaths: string[] = [] const lintable: { path: string; text: string; classification: PathClassification }[] = [] for (const input of inputs) { const classification = classifyPath(input.path) if (kind === undefined && classification.skipped) { skippedPaths.push(input.path) continue } lintable.push({ ...input, classification }) } const report = toCliReport( lintable.map(({ path, text, classification }) => ({ path, report: lint(kind ?? classification.kind, text, { ...config, dictionary, ruleData, tagger, sourceDialect: classification.sourceDialect, }), })), skippedPaths, ) const output = render(report, json) if (output !== "") { console.log(output) } return report.summary.hard > 0 ? 1 : 0 }) const program: Effect.Effect = args[0] === "hook" ? rejectUnknownFlags(args.slice(1)).pipe(Effect.andThen(hookProgram)) : args[0] === "session" ? rejectUnknownFlags(args.slice(1)).pipe(Effect.andThen(sessionProgram)) : args[0] === "observe" ? observeProgram : lintProgram.pipe(Effect.provide(WinkTaggerLive)) const handled = program.pipe( Effect.catch((error) => Effect.sync(() => { console.error(error.message) return 2 }), ), ) const exitCode = await Effect.runPromise(handled).catch((error) => { console.error(String(error)) return 2 }) process.exit(exitCode)