From f4bf4c6aa117d215142a9d5dd85aeffb957d1702 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Fri, 7 Nov 2025 22:50:01 +0100 Subject: [PATCH] CLI: Introduce `@herb-tools/cli` package for shared `herb` CLI --- javascript/packages/cli/bin/herb | 3 + javascript/packages/cli/package.json | 47 ++ .../cli/scripts/bundle-playground.mjs | 55 ++ .../packages/cli/src/commands/config/cli.ts | 309 ++++++++++ .../packages/cli/src/commands/config/index.ts | 6 + .../packages/cli/src/commands/format/cli.ts | 543 ++++++++++++++++++ .../packages/cli/src/commands/format/index.ts | 27 + .../packages/cli/src/commands/help/index.ts | 130 +++++ .../cli/src/commands/highlight/cli.ts | 224 ++++++++ .../cli/src/commands/highlight/index.ts | 26 + .../packages/cli/src/commands/lex/cli.ts | 113 ++++ .../packages/cli/src/commands/lex/index.ts | 6 + .../packages/cli/src/commands/lint/cli.ts | 243 ++++++++ .../src/commands/lint/cli/argument-parser.ts | 153 +++++ .../src/commands/lint/cli/file-processor.ts | 181 ++++++ .../lint/cli/formatters/base-formatter.ts | 11 + .../lint/cli/formatters/detailed-formatter.ts | 90 +++ .../formatters/github-actions-formatter.ts | 141 +++++ .../src/commands/lint/cli/formatters/index.ts | 5 + .../lint/cli/formatters/json-formatter.ts | 116 ++++ .../lint/cli/formatters/simple-formatter.ts | 56 ++ .../cli/src/commands/lint/cli/index.ts | 6 + .../src/commands/lint/cli/output-manager.ts | 185 ++++++ .../src/commands/lint/cli/summary-reporter.ts | 156 +++++ .../packages/cli/src/commands/lint/index.ts | 27 + .../packages/cli/src/commands/lsp/cli.ts | 23 + .../packages/cli/src/commands/lsp/index.ts | 25 + .../packages/cli/src/commands/parse/cli.ts | 126 ++++ .../packages/cli/src/commands/parse/index.ts | 6 + .../cli/src/commands/playground/cli.ts | 328 +++++++++++ .../cli/src/commands/playground/index.ts | 6 + .../packages/cli/src/commands/print/cli.ts | 268 +++++++++ .../packages/cli/src/commands/print/index.ts | 26 + javascript/packages/cli/src/index.ts | 92 +++ javascript/packages/cli/src/types.ts | 18 + javascript/packages/cli/src/utils/args.ts | 128 +++++ .../packages/cli/src/utils/format-ns.ts | 23 + javascript/packages/cli/src/utils/renderer.ts | 98 ++++ javascript/packages/cli/tsconfig.json | 20 + javascript/packages/cli/tsup.config.ts | 22 + javascript/packages/config/package.json | 6 +- javascript/packages/config/rollup.config.mjs | 2 +- javascript/packages/core/package.json | 6 +- javascript/packages/core/rollup.config.mjs | 6 +- javascript/packages/formatter/package.json | 6 +- .../packages/formatter/rollup.config.mjs | 4 +- javascript/packages/highlighter/package.json | 6 +- .../packages/highlighter/rollup.config.mjs | 4 +- .../packages/language-server/package.json | 6 +- .../language-server/rollup.config.mjs | 23 + javascript/packages/linter/package.json | 8 +- javascript/packages/linter/rollup.config.mjs | 4 +- javascript/packages/printer/package.json | 6 +- javascript/packages/printer/rollup.config.mjs | 2 +- javascript/packages/rewriter/package.json | 10 +- .../packages/rewriter/rollup.config.mjs | 4 +- .../tailwind-class-sorter/package.json | 6 +- .../tailwind-class-sorter/rollup.config.mjs | 6 +- .../tailwind-class-sorter/src/config.ts | 4 +- yarn.lock | 102 +++- 60 files changed, 4238 insertions(+), 51 deletions(-) create mode 100755 javascript/packages/cli/bin/herb create mode 100644 javascript/packages/cli/package.json create mode 100644 javascript/packages/cli/scripts/bundle-playground.mjs create mode 100644 javascript/packages/cli/src/commands/config/cli.ts create mode 100644 javascript/packages/cli/src/commands/config/index.ts create mode 100644 javascript/packages/cli/src/commands/format/cli.ts create mode 100644 javascript/packages/cli/src/commands/format/index.ts create mode 100644 javascript/packages/cli/src/commands/help/index.ts create mode 100644 javascript/packages/cli/src/commands/highlight/cli.ts create mode 100644 javascript/packages/cli/src/commands/highlight/index.ts create mode 100644 javascript/packages/cli/src/commands/lex/cli.ts create mode 100644 javascript/packages/cli/src/commands/lex/index.ts create mode 100644 javascript/packages/cli/src/commands/lint/cli.ts create mode 100644 javascript/packages/cli/src/commands/lint/cli/argument-parser.ts create mode 100644 javascript/packages/cli/src/commands/lint/cli/file-processor.ts create mode 100644 javascript/packages/cli/src/commands/lint/cli/formatters/base-formatter.ts create mode 100644 javascript/packages/cli/src/commands/lint/cli/formatters/detailed-formatter.ts create mode 100644 javascript/packages/cli/src/commands/lint/cli/formatters/github-actions-formatter.ts create mode 100644 javascript/packages/cli/src/commands/lint/cli/formatters/index.ts create mode 100644 javascript/packages/cli/src/commands/lint/cli/formatters/json-formatter.ts create mode 100644 javascript/packages/cli/src/commands/lint/cli/formatters/simple-formatter.ts create mode 100644 javascript/packages/cli/src/commands/lint/cli/index.ts create mode 100644 javascript/packages/cli/src/commands/lint/cli/output-manager.ts create mode 100644 javascript/packages/cli/src/commands/lint/cli/summary-reporter.ts create mode 100644 javascript/packages/cli/src/commands/lint/index.ts create mode 100644 javascript/packages/cli/src/commands/lsp/cli.ts create mode 100644 javascript/packages/cli/src/commands/lsp/index.ts create mode 100644 javascript/packages/cli/src/commands/parse/cli.ts create mode 100644 javascript/packages/cli/src/commands/parse/index.ts create mode 100644 javascript/packages/cli/src/commands/playground/cli.ts create mode 100644 javascript/packages/cli/src/commands/playground/index.ts create mode 100644 javascript/packages/cli/src/commands/print/cli.ts create mode 100644 javascript/packages/cli/src/commands/print/index.ts create mode 100644 javascript/packages/cli/src/index.ts create mode 100644 javascript/packages/cli/src/types.ts create mode 100644 javascript/packages/cli/src/utils/args.ts create mode 100644 javascript/packages/cli/src/utils/format-ns.ts create mode 100644 javascript/packages/cli/src/utils/renderer.ts create mode 100644 javascript/packages/cli/tsconfig.json create mode 100644 javascript/packages/cli/tsup.config.ts diff --git a/javascript/packages/cli/bin/herb b/javascript/packages/cli/bin/herb new file mode 100755 index 000000000..97152935a --- /dev/null +++ b/javascript/packages/cli/bin/herb @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import("../dist/index.mjs") diff --git a/javascript/packages/cli/package.json b/javascript/packages/cli/package.json new file mode 100644 index 000000000..5eae04b04 --- /dev/null +++ b/javascript/packages/cli/package.json @@ -0,0 +1,47 @@ +{ + "name": "@herb-tools/cli", + "version": "0.7.5", + "description": "Unified CLI for Herb HTML+ERB tools", + "license": "MIT", + "homepage": "https://herb-tools.dev", + "bugs": "https://github.com/marcoroth/herb/issues/new?title=Package%20%60@herb-tools/cli%60:%20", + "repository": { + "type": "git", + "url": "https://github.com/marcoroth/herb.git", + "directory": "javascript/packages/cli" + }, + "bin": { + "herb": "./bin/herb" + }, + "scripts": { + "clean": "rimraf dist", + "build": "yarn clean && tsup-node && yarn bundle:playground", + "bundle:playground": "node scripts/bundle-playground.mjs", + "watch": "tsup-node --watch", + "test": "vitest run", + "test:watch": "vitest --watch", + "prepublishOnly": "yarn clean && yarn build && yarn test" + }, + "dependencies": { + "@herb-tools/config": "0.7.5", + "@herb-tools/formatter": "0.7.5", + "@herb-tools/highlighter": "0.7.5", + "@herb-tools/language-server": "0.7.5", + "@herb-tools/linter": "0.7.5", + "@herb-tools/node-wasm": "0.7.5", + "@herb-tools/printer": "0.7.5", + "@herb-tools/tailwind-class-sorter": "0.7.5", + "glob": "^11.0.3", + "lz-string": "^1.5.0", + "mri": "^1.2.0", + "open": "^10.2.0", + "picocolors": "^1.1.1", + "stimulus-lint": "0.1.5" + }, + "files": [ + "package.json", + "README.md", + "bin/", + "dist/" + ] +} diff --git a/javascript/packages/cli/scripts/bundle-playground.mjs b/javascript/packages/cli/scripts/bundle-playground.mjs new file mode 100644 index 000000000..06dcd4b8a --- /dev/null +++ b/javascript/packages/cli/scripts/bundle-playground.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +import fs from "fs/promises" +import path from "path" +import { fileURLToPath } from "url" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +async function copyDir(src, dest) { + await fs.mkdir(dest, { recursive: true }) + const entries = await fs.readdir(src, { withFileTypes: true }) + + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const destPath = path.join(dest, entry.name) + + if (entry.isDirectory()) { + await copyDir(srcPath, destPath) + } else { + await fs.copyFile(srcPath, destPath) + } + } +} + +async function main() { + const playgroundSrc = path.join(__dirname, "../../../../playground/dist") + const playgroundDest = path.join(__dirname, "../dist/playground") + + try { + await fs.access(playgroundSrc) + + console.log("Bundling playground into CLI package...") + + try { + await fs.rm(playgroundDest, { recursive: true, force: true }) + } catch (error) { + // Ignore + } + + await copyDir(playgroundSrc, playgroundDest) + + console.log("✓ Playground bundled successfully") + } catch (error) { + console.warn("⚠ Warning: Could not bundle playground") + console.warn(" Playground dist not found at:", playgroundSrc) + console.warn(" Run 'cd playground && yarn build' to build the playground first") + console.warn(" The CLI will still work for other commands") + } +} + +main().catch((error) => { + console.error("Error bundling playground:", error) + process.exit(1) +}) diff --git a/javascript/packages/cli/src/commands/config/cli.ts b/javascript/packages/cli/src/commands/config/cli.ts new file mode 100644 index 000000000..01381b996 --- /dev/null +++ b/javascript/packages/cli/src/commands/config/cli.ts @@ -0,0 +1,309 @@ +import dedent from "dedent" +import pc from "picocolors" + +import * as fs from "fs/promises" +import * as path from "path" + +import { Config, addHerbExtensionRecommendation, getExtensionsJsonRelativePath } from "@herb-tools/config" + +export class CLI { + async run() { + const args = process.argv.slice(2) + + if (args[0] === "config") { + args.shift() + } + + const command = args[0] + + if (!command || command === "--help" || command === "-h") { + this.showHelp() + + return + } + + if (command === "--version" || command === "-v") { + console.log((await import("@herb-tools/config/package.json")).version) + + return + } + + switch (command) { + case "show": + await this.showConfig(args.slice(1)) + break + + case "validate": + await this.validateConfig(args.slice(1)) + break + + case "init": + await this.initConfig(args.slice(1)) + break + + case "path": + await this.showPath(args.slice(1)) + break + + default: + console.error(pc.red(`Unknown command: ${command}`)) + console.error(`Run ${pc.cyan("herb config --help")} for usage information`) + process.exit(1) + } + } + + private showHelp() { + console.log(dedent` + Usage: herb config [options] + + Commands: + show ········ Display the current Herb configuration + validate ········ Validate .herb.yml configuration file + init ········ Create a .herb.yml configuration file + path ········ Show path to .herb.yml configuration file + + Options: + -h, --help ········ Display usage information + -v, --version ····· Display version information + --raw ········ Show raw YAML content (for 'show' command) + + Examples: + herb config show # Show configuration summary + herb config show --raw # Show raw YAML file + herb config validate # Validate .herb.yml + herb config init # Create .herb.yml in current directory + herb config path # Show path to .herb.yml + `) + } + + private async showConfig(args: string[]) { + try { + const projectPath = args.includes("--project") + ? args[args.indexOf("--project") + 1] + : process.cwd() + + const showRaw = args.includes("--raw") + + const config = await Config.load(projectPath, { silent: true }) + + if (!config) { + console.error(pc.yellow("⚠ No .herb.yml configuration file found")) + console.error(`Run ${pc.cyan("herb config init")} to create one`) + process.exit(1) + } + + try { + await fs.access(config.path) + } catch { + console.error(pc.yellow("⚠ No .herb.yml configuration file found")) + console.error(`Run ${pc.cyan("herb config init")} to create one`) + process.exit(1) + } + + if (showRaw) { + console.log(pc.green("✓") + " Configuration file: " + pc.dim(config.path)) + console.log() + const yamlContent = await fs.readFile(config.path, "utf-8") + console.log(yamlContent) + } else { + this.showConfigSummary(config) + } + } catch (error) { + if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') { + console.error(pc.yellow("⚠ No .herb.yml configuration file found")) + console.error(`Run ${pc.cyan("herb config init")} to create one`) + process.exit(1) + } + + console.error(pc.red("✗ Error loading configuration:")) + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } + } + + private showConfigSummary(config: Config) { + console.log(pc.green("✓") + " Configuration: " + pc.dim(config.path)) + console.log() + + console.log(pc.bold("Version")) + console.log(` ${pc.cyan(config.version)}`) + console.log() + + const filesConfig = config.options.files + + if (filesConfig) { + console.log(pc.bold("Files")) + + if (filesConfig.include && filesConfig.include.length > 0) { + console.log(` ${pc.dim("Include:")} ${filesConfig.include.map(p => pc.green(p)).join(", ")}`) + } + + if (filesConfig.exclude && filesConfig.exclude.length > 0) { + console.log(` ${pc.dim("Exclude:")} ${filesConfig.exclude.map(p => pc.red(p)).join(", ")}`) + } + + if (!filesConfig.include && !filesConfig.exclude) { + console.log(` ${pc.dim("Using defaults")}`) + } + + console.log() + } + + const linterConfig = config.options.linter + console.log(pc.bold("Linter")) + const linterEnabled = linterConfig?.enabled !== false + console.log(` ${pc.dim("Status:")} ${linterEnabled ? pc.green("enabled") : pc.red("disabled")}`) + + if (linterConfig?.rules) { + const ruleCount = Object.keys(linterConfig.rules).length + + const enabledRules = Object.entries(linterConfig.rules).filter(([_, config]) => + (config as any).enabled !== false + ).length + + console.log(` ${pc.dim("Rules:")} ${enabledRules}/${ruleCount} enabled`) + } + + if (linterConfig?.include && linterConfig.include.length > 0) { + console.log(` ${pc.dim("Include:")} ${linterConfig.include.map(p => pc.green(p)).join(", ")}`) + } + + if (linterConfig?.exclude && linterConfig.exclude.length > 0) { + console.log(` ${pc.dim("Exclude:")} ${linterConfig.exclude.map(p => pc.red(p)).join(", ")}`) + } + + console.log() + + const formatterConfig = config.options.formatter + console.log(pc.bold("Formatter")) + + const formatterEnabled = formatterConfig?.enabled !== false + console.log(` ${pc.dim("Status:")} ${formatterEnabled ? pc.green("enabled") : pc.red("disabled")}`) + + if (formatterConfig?.indentWidth) { + console.log(` ${pc.dim("Indent width:")} ${pc.cyan(String(formatterConfig.indentWidth))}`) + } + + if (formatterConfig?.maxLineLength) { + console.log(` ${pc.dim("Max line length:")} ${pc.cyan(String(formatterConfig.maxLineLength))}`) + } + + if (formatterConfig?.rewriter) { + const preCount = formatterConfig.rewriter.pre?.length || 0 + const postCount = formatterConfig.rewriter.post?.length || 0 + + if (preCount > 0 || postCount > 0) { + console.log(` ${pc.dim("Rewriters:")} ${preCount} pre, ${postCount} post`) + } + } + + if (formatterConfig?.include && formatterConfig.include.length > 0) { + console.log(` ${pc.dim("Include:")} ${formatterConfig.include.map(p => pc.green(p)).join(", ")}`) + } + + if (formatterConfig?.exclude && formatterConfig.exclude.length > 0) { + console.log(` ${pc.dim("Exclude:")} ${formatterConfig.exclude.map(p => pc.red(p)).join(", ")}`) + } + console.log() + + console.log(pc.dim("Tip: Use") + " " + pc.cyan("herb config show --raw") + " " + pc.dim("to see the full YAML file")) + } + + private async validateConfig(args: string[]) { + try { + const projectPath = args.includes("--project") + ? args[args.indexOf("--project") + 1] + : process.cwd() + + const config = await Config.load(projectPath, { silent: true }) + + if (!config) { + console.error(pc.red("✗ No .herb.yml configuration file found")) + console.error(`Run ${pc.cyan("herb config init")} to create one`) + process.exit(1) + } + + console.log(pc.green("✓") + " Configuration is valid: " + pc.dim(config.path)) + } catch (error) { + console.error(pc.red("✗ Configuration validation failed:")) + console.error() + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } + } + + private async initConfig(args: string[]) { + try { + const projectPath = args.includes("--project") + ? args[args.indexOf("--project") + 1] + : process.cwd() + + const force = args.includes("--force") + const configPath = Config.configPathFromProjectPath(projectPath) + + let fileExists = false + + try { + await fs.access(configPath) + fileExists = true + } catch { + fileExists = false + } + + if (fileExists && !force) { + console.error(pc.yellow("⚠ Configuration file already exists: ") + pc.dim(configPath)) + console.error(`Use ${pc.cyan("--force")} to overwrite`) + process.exit(1) + } + + if (force && fileExists) { + await fs.unlink(configPath) + } + + const config = await Config.load(projectPath, { + silent: true, + createIfMissing: true + }) + + const relativePath = path.relative(process.cwd(), config.path) || config.path + console.log(pc.green("✓") + " Created configuration at " + pc.cyan(relativePath) + " " + pc.dim(`(${config.path})`)) + + const extensionAdded = addHerbExtensionRecommendation(projectPath) + + if (extensionAdded) { + const extensionsRelativePath = getExtensionsJsonRelativePath() + const extensionsFullPath = path.join(projectPath, extensionsRelativePath) + console.log(pc.green("✓") + " VSCode extension recommended in " + pc.cyan(extensionsRelativePath) + " " + pc.dim(`(${extensionsFullPath})`)) + } + + console.log() + console.log("Edit " + pc.cyan(".herb.yml") + " to customize Herb's behavior for your project.") + } catch (error) { + console.error(pc.red("✗ Error creating configuration:")) + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } + } + + private async showPath(args: string[]) { + try { + const projectPath = args.includes("--project") + ? args[args.indexOf("--project") + 1] + : process.cwd() + + const config = await Config.load(projectPath, { silent: true }) + + if (!config) { + console.error(pc.yellow("⚠ No .herb.yml configuration file found")) + console.error(`Run ${pc.cyan("herb config init")} to create one`) + process.exit(1) + } + + console.log(config.path) + } catch (error) { + console.error(pc.red("✗ Error finding configuration:")) + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } + } +} diff --git a/javascript/packages/cli/src/commands/config/index.ts b/javascript/packages/cli/src/commands/config/index.ts new file mode 100644 index 000000000..03e18b46a --- /dev/null +++ b/javascript/packages/cli/src/commands/config/index.ts @@ -0,0 +1,6 @@ +import { CLI } from "./cli.js" + +export async function handle() { + const cli = new CLI() + await cli.run() +} diff --git a/javascript/packages/cli/src/commands/format/cli.ts b/javascript/packages/cli/src/commands/format/cli.ts new file mode 100644 index 000000000..9fc09cc13 --- /dev/null +++ b/javascript/packages/cli/src/commands/format/cli.ts @@ -0,0 +1,543 @@ +import dedent from "dedent" +import { readFileSync, writeFileSync, statSync } from "fs" +import { glob } from "glob" +import { resolve, relative } from "path" + +import { Herb } from "@herb-tools/node-wasm" +import { Config, addHerbExtensionRecommendation, getExtensionsJsonRelativePath } from "@herb-tools/config" + +import { Formatter } from "@herb-tools/formatter" +import { ASTRewriter, StringRewriter, CustomRewriterLoader, builtinRewriters, isASTRewriterClass, isStringRewriterClass } from "@herb-tools/rewriter/loader" +import { parseArgs } from "util" + +import { version } from "../../../package.json" + +const name = "@herb-tools/cli" +const dependencies = { "@herb-tools/printer": version } + +const pluralize = (count: number, singular: string, plural: string = singular + 's'): string => { + return count === 1 ? singular : plural +} + +export class CLI { + private usage = dedent` + Usage: herb-format [file|directory|glob-pattern] [options] + + Arguments: + file|directory|glob-pattern File to format, directory to format all configured files within, + glob pattern to match files, or '-' for stdin (omit to format all configured files in current directory) + + Options: + -c, --check check if files are formatted without modifying them + -h, --help show help + -v, --version show version + --init create a .herb.yml configuration file in the current directory + --config-file explicitly specify path to .herb.yml config file + --force force formatting even if disabled in .herb.yml + --indent-width number of spaces per indentation level (default: 2) + --max-line-length maximum line length before wrapping (default: 80) + + Examples: + herb-format # Format all configured files in current directory + herb-format index.html.erb # Format and write single file + herb-format templates/index.html.erb # Format and write single file + herb-format templates/ # Format all configured files within the given directory + herb-format "templates/**/*.html.erb" # Format all \`**/*.html.erb\` files in the templates/ directory + herb-format "**/*.html.erb" # Format all \`*.html.erb\` files using glob pattern + herb-format "**/*.xml.erb" # Format all \`*.xml.erb\` files using glob pattern + + herb-format --check # Check if all configured files are formatted + herb-format --check templates/ # Check if all configured files in templates/ are formatted + + herb-format --force # Format even if disabled in project config + herb-format --indent-width 4 # Format with 4-space indentation + herb-format --max-line-length 100 # Format with 100-character line limit + cat template.html.erb | herb-format # Format from stdin to stdout + ` + + private parseArguments() { + const { values, positionals } = parseArgs({ + args: process.argv.slice(2), + options: { + help: { type: "boolean", short: "h" }, + force: { type: "boolean" }, + version: { type: "boolean", short: "v" }, + check: { type: "boolean", short: "c" }, + init: { type: "boolean" }, + "config-file": { type: "string" }, + "indent-width": { type: "string" }, + "max-line-length": { type: "string" } + }, + allowPositionals: true + }) + + if (values.help) { + console.log(this.usage) + process.exit(0) + } + + let indentWidth: number | undefined + + if (values["indent-width"]) { + const parsed = parseInt(values["indent-width"], 10) + if (isNaN(parsed) || parsed < 1) { + console.error( + `Invalid indent-width: ${values["indent-width"]}. Must be a positive integer.`, + ) + process.exit(1) + } + indentWidth = parsed + } + + let maxLineLength: number | undefined + + if (values["max-line-length"]) { + const parsed = parseInt(values["max-line-length"], 10) + if (isNaN(parsed) || parsed < 1) { + console.error( + `Invalid max-line-length: ${values["max-line-length"]}. Must be a positive integer.`, + ) + process.exit(1) + } + maxLineLength = parsed + } + + return { + positionals, + isCheckMode: values.check, + isVersionMode: values.version, + isForceMode: values.force, + isInitMode: values.init, + configFile: values["config-file"], + indentWidth, + maxLineLength + } + } + + async run() { + const { positionals, isCheckMode, isVersionMode, isForceMode, isInitMode, configFile, indentWidth, maxLineLength } = this.parseArguments() + + try { + await Herb.load() + + if (isVersionMode) { + console.log("Versions:") + console.log(` ${name}@${version}`) + console.log(` @herb-tools/printer@${dependencies['@herb-tools/printer']}`) + console.log(` ${Herb.version}`.split(", ").join("\n ")) + + process.exit(0) + } + + const file = positionals[0] + const startPath = file || process.cwd() + + if (isInitMode) { + const configPath = configFile || startPath + + if (Config.exists(configPath)) { + const fullPath = configFile || Config.configPathFromProjectPath(startPath) + console.log(`\n✗ Configuration file already exists at ${fullPath}`) + console.log(` Use --config-file to specify a different location.\n`) + process.exit(1) + } + + const config = await Config.loadForCLI(configPath, version, true) + + await Config.mutateConfigFile(config.path, { + formatter: { + enabled: true + } + }) + + const projectPath = configFile ? resolve(configFile) : startPath + const projectDir = statSync(projectPath).isDirectory() ? projectPath : resolve(projectPath, '..') + const extensionAdded = addHerbExtensionRecommendation(projectDir) + + console.log(`\n✓ Configuration initialized at ${config.path}`) + + if (extensionAdded) { + console.log(`✓ VSCode extension recommended in ${getExtensionsJsonRelativePath()}`) + } + + console.log(` Formatter is enabled by default.`) + console.log(` Edit this file to customize linter and formatter settings.\n`) + + process.exit(0) + } + + const config = await Config.loadForCLI(configFile || startPath, version) + const formatterConfig = config.formatter || {} + + if (formatterConfig.enabled === false && !isForceMode) { + console.log("Formatter is disabled in .herb.yml configuration.") + console.log("To enable formatting, set formatter.enabled: true in .herb.yml") + console.log("Or use --force to format anyway.") + + process.exit(0) + } + + if (isForceMode && formatterConfig.enabled === false) { + console.error("⚠️ Forcing formatter run (disabled in .herb.yml)") + console.error() + } + + console.error("⚠️ Experimental Preview: The formatter is in early development. Please report any unexpected behavior or bugs to https://github.com/marcoroth/herb/issues/new?template=formatting-issue.md") + console.error() + + if (indentWidth !== undefined) { + formatterConfig.indentWidth = indentWidth + } + + if (maxLineLength !== undefined) { + formatterConfig.maxLineLength = maxLineLength + } + + let preRewriters: ASTRewriter[] = [] + let postRewriters: StringRewriter[] = [] + const rewriterNames = { pre: formatterConfig.rewriter?.pre || [], post: formatterConfig.rewriter?.post || [] } + + if (formatterConfig.rewriter && (rewriterNames.pre.length > 0 || rewriterNames.post.length > 0)) { + const baseDir = config.projectPath || process.cwd() + const warnings: string[] = [] + const allRewriterClasses: any[] = [] + + allRewriterClasses.push(...builtinRewriters) + + const loader = new CustomRewriterLoader({ baseDir }) + const { rewriters: customRewriters, duplicateWarnings } = await loader.loadRewritersWithInfo() + + allRewriterClasses.push(...customRewriters) + warnings.push(...duplicateWarnings) + + const rewriterMap = new Map() + for (const RewriterClass of allRewriterClasses) { + const instance = new RewriterClass() + + if (rewriterMap.has(instance.name)) { + warnings.push(`Rewriter "${instance.name}" is defined multiple times. Using the last definition.`) + } + + rewriterMap.set(instance.name, RewriterClass) + } + + for (const name of rewriterNames.pre) { + const RewriterClass = rewriterMap.get(name) + + if (!RewriterClass) { + warnings.push(`Pre-format rewriter "${name}" not found. Skipping.`) + continue + } + + if (!isASTRewriterClass(RewriterClass)) { + warnings.push(`Rewriter "${name}" is not a pre-format rewriter. Skipping.`) + + continue + } + + const instance = new RewriterClass() + try { + await instance.initialize({ baseDir }) + preRewriters.push(instance) + } catch (error) { + warnings.push(`Failed to initialize pre-format rewriter "${name}": ${error}`) + } + } + + for (const name of rewriterNames.post) { + const RewriterClass = rewriterMap.get(name) + + if (!RewriterClass) { + warnings.push(`Post-format rewriter "${name}" not found. Skipping.`) + + continue + } + + if (!isStringRewriterClass(RewriterClass)) { + warnings.push(`Rewriter "${name}" is not a post-format rewriter. Skipping.`) + + continue + } + + const instance = new RewriterClass() + + try { + await instance.initialize({ baseDir }) + + postRewriters.push(instance) + } catch (error) { + warnings.push(`Failed to initialize post-format rewriter "${name}": ${error}`) + } + } + + if (preRewriters.length > 0 || postRewriters.length > 0) { + const parts: string[] = [] + + if (preRewriters.length > 0) { + parts.push(`${preRewriters.length} pre-format ${pluralize(preRewriters.length, 'rewriter')}: ${rewriterNames.pre.join(', ')}`) + } + + if (postRewriters.length > 0) { + parts.push(`${postRewriters.length} post-format ${pluralize(postRewriters.length, 'rewriter')}: ${rewriterNames.post.join(', ')}`) + } + + console.error(`Using ${parts.join(', ')}`) + console.error() + } + + if (warnings.length > 0) { + warnings.forEach(warning => console.error(`⚠️ ${warning}`)) + console.error() + } + } + + const formatter = Formatter.from(Herb, config, { preRewriters, postRewriters }) + + if (!file && !process.stdin.isTTY) { + if (isCheckMode) { + console.error("Error: --check mode is not supported with stdin") + + process.exit(1) + } + + const source = await this.readStdin() + const result = formatter.format(source) + const output = result.endsWith('\n') ? result : result + '\n' + + process.stdout.write(output) + } else if (file === "-") { + if (isCheckMode) { + console.error("Error: --check mode is not supported with stdin") + + process.exit(1) + } + + const source = await this.readStdin() + const result = formatter.format(source) + const output = result.endsWith('\n') ? result : result + '\n' + + process.stdout.write(output) + } else if (file) { + let isDirectory = false + let isFile = false + let pattern = file + + try { + const stats = statSync(file) + isDirectory = stats.isDirectory() + isFile = stats.isFile() + } catch { + // Not a file/directory, treat as glob pattern + } + + const filesConfig = config.getFilesConfigForTool('formatter') + + if (isDirectory) { + const files = await config.findFilesForTool('formatter', resolve(file)) + + if (files.length === 0) { + console.log(`No files found in directory: ${resolve(file)}`) + process.exit(0) + } + + let formattedCount = 0 + let unformattedFiles: string[] = [] + + for (const filePath of files) { + const displayPath = relative(process.cwd(), filePath) + + try { + const source = readFileSync(filePath, "utf-8") + const result = formatter.format(source) + const output = result.endsWith('\n') ? result : result + '\n' + + if (output !== source) { + if (isCheckMode) { + unformattedFiles.push(displayPath) + } else { + writeFileSync(filePath, output, "utf-8") + console.log(`Formatted: ${displayPath}`) + } + formattedCount++ + } + } catch (error) { + console.error(`Error formatting ${displayPath}:`, error) + } + } + + if (isCheckMode) { + if (unformattedFiles.length > 0) { + console.log(`\nThe following ${pluralize(unformattedFiles.length, 'file is', 'files are')} not formatted:`) + unformattedFiles.forEach(file => console.log(` ${file}`)) + console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, found ${unformattedFiles.length} unformatted ${pluralize(unformattedFiles.length, 'file')}`) + process.exit(1) + } else { + console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, all files are properly formatted`) + } + } else { + console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, formatted ${formattedCount} ${pluralize(formattedCount, 'file')}`) + } + + process.exit(0) + } else if (isFile) { + const testFiles = await glob(file, { + cwd: process.cwd(), + ignore: filesConfig.exclude || [] + }) + + if (testFiles.length === 0) { + if (!isForceMode) { + console.error(`⚠️ File ${file} is excluded by configuration patterns.`) + console.error(` Use --force to format it anyway.\n`) + process.exit(0) + } else { + console.error(`⚠️ Forcing formatter on excluded file: ${file}`) + console.error() + } + } + + const source = readFileSync(file, "utf-8") + const result = formatter.format(source) + const output = result.endsWith('\n') ? result : result + '\n' + + if (output !== source) { + if (isCheckMode) { + console.log(`File is not formatted: ${file}`) + process.exit(1) + } else { + writeFileSync(file, output, "utf-8") + console.log(`Formatted: ${file}`) + } + } else if (isCheckMode) { + console.log(`File is properly formatted: ${file}`) + } + + process.exit(0) + } + + try { + const files = await glob(pattern, { ignore: filesConfig.exclude || [] }) + + if (files.length === 0) { + try { + statSync(file) + } catch { + if (!file.includes('*') && !file.includes('?') && !file.includes('[') && !file.includes('{')) { + console.error(`Error: Cannot access '${file}': ENOENT: no such file or directory`) + + process.exit(1) + } + } + + console.log(`No files found matching pattern: ${resolve(pattern)}`) + + process.exit(0) + } + + let formattedCount = 0 + let unformattedFiles: string[] = [] + + for (const filePath of files) { + try { + const source = readFileSync(filePath, "utf-8") + const result = formatter.format(source) + const output = result.endsWith('\n') ? result : result + '\n' + + if (output !== source) { + if (isCheckMode) { + unformattedFiles.push(filePath) + } else { + writeFileSync(filePath, output, "utf-8") + console.log(`Formatted: ${filePath}`) + } + + formattedCount++ + } + } catch (error) { + console.error(`Error formatting ${filePath}:`, error) + } + } + + if (isCheckMode) { + if (unformattedFiles.length > 0) { + console.log(`\nThe following ${pluralize(unformattedFiles.length, 'file is', 'files are')} not formatted:`) + unformattedFiles.forEach(file => console.log(` ${file}`)) + console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, found ${unformattedFiles.length} unformatted ${pluralize(unformattedFiles.length, 'file')}`) + process.exit(1) + } else { + console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, all files are properly formatted`) + } + } else { + console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, formatted ${formattedCount} ${pluralize(formattedCount, 'file')}`) + } + + } catch (error) { + console.error(`Error: Cannot access '${file}':`, error) + + process.exit(1) + } + } else { + const files = await config.findFilesForTool('formatter', process.cwd()) + + if (files.length === 0) { + console.log(`No files found matching configured patterns`) + + process.exit(0) + } + + let formattedCount = 0 + let unformattedFiles: string[] = [] + + for (const filePath of files) { + const displayPath = relative(process.cwd(), filePath) + + try { + const source = readFileSync(filePath, "utf-8") + const result = formatter.format(source) + const output = result.endsWith('\n') ? result : result + '\n' + + if (output !== source) { + if (isCheckMode) { + unformattedFiles.push(displayPath) + } else { + writeFileSync(filePath, output, "utf-8") + console.log(`Formatted: ${displayPath}`) + } + formattedCount++ + } + } catch (error) { + console.error(`Error formatting ${displayPath}:`, error) + } + } + + if (isCheckMode) { + if (unformattedFiles.length > 0) { + console.log(`\nThe following ${pluralize(unformattedFiles.length, 'file is', 'files are')} not formatted:`) + unformattedFiles.forEach(file => console.log(` ${file}`)) + console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, found ${unformattedFiles.length} unformatted ${pluralize(unformattedFiles.length, 'file')}`) + + process.exit(1) + } else { + console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, all files are properly formatted`) + } + } else { + console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, formatted ${formattedCount} ${pluralize(formattedCount, 'file')}`) + } + } + } catch (error) { + console.error(error) + + process.exit(1) + } + } + + private async readStdin(): Promise { + const chunks: Buffer[] = [] + + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk) + } + + return Buffer.concat(chunks).toString("utf8") + } +} diff --git a/javascript/packages/cli/src/commands/format/index.ts b/javascript/packages/cli/src/commands/format/index.ts new file mode 100644 index 000000000..5e21a40a0 --- /dev/null +++ b/javascript/packages/cli/src/commands/format/index.ts @@ -0,0 +1,27 @@ +import { CLI } from './cli.js' + +export async function handle(args: string[]): Promise { + const argv = ['node', 'herb-format', ...args] + const originalArgv = process.argv + process.argv = argv + + try { + const formatter = new CLI() + await formatter.run() + } finally { + process.argv = originalArgv + } +} + +export function helpInfo() { + return { + description: 'Format HTML+ERB templates', + usage: [ + 'herb format [pattern] [options]', + 'herb format index.html.erb', + 'herb format "templates/**/*.html.erb"', + 'herb format --check', + 'herb format --indent-width 4', + ] + } +} diff --git a/javascript/packages/cli/src/commands/help/index.ts b/javascript/packages/cli/src/commands/help/index.ts new file mode 100644 index 000000000..f0ba06b85 --- /dev/null +++ b/javascript/packages/cli/src/commands/help/index.ts @@ -0,0 +1,130 @@ +import pc from 'picocolors' +import type { Arg } from '../../utils/args.js' +import { UI, header, highlight, indent, println, wordWrap } from '../../utils/renderer.js' + +export function help({ + invalid, + usage, + commands, + options, +}: { + invalid?: string + usage?: string[] + commands?: Record + options?: Arg +}) { + let width = process.stdout.columns + + println(header() + pc.dim(' 🌿 Powerful and seamless HTML-aware ERB parsing and tooling.')) + + if (invalid) { + println() + println(`${pc.dim('Invalid command:')} ${pc.red(invalid)}`) + } + + if (usage && usage.length > 0) { + println() + println(pc.dim('Usage:')) + for (let [idx, example] of usage.entries()) { + let command = example.slice(0, example.indexOf('[')) + let options = example.slice(example.indexOf('[')) + + options = options.replace(/\[.*?\]/g, (option) => pc.dim(option)) + + let space = 1 + + let lines = wordWrap(options, width - UI.indent - command.length - space) + + if (lines.length > 1 && idx !== 0) { + println() + } + + println(indent(`${command}${lines.shift()}`)) + for (let line of lines) { + println(indent(line, command.length)) + } + } + } + + if (commands) { + println() + println(pc.dim('Commands:')) + + let maxCommandLength = 0 + for (let command of Object.keys(commands)) { + maxCommandLength = Math.max(maxCommandLength, command.length) + } + + for (let [command, description] of Object.entries(commands)) { + let dotsNeeded = maxCommandLength - command.length + 8 + let spaces = 2 + let availableWidth = width - maxCommandLength - 8 - spaces - UI.indent + + let lines = wordWrap(description, availableWidth) + + println( + indent(`${pc.blue(command)} ${pc.dim(pc.gray('\u00B7')).repeat(dotsNeeded)} ${lines.shift()}`) + ) + + for (let line of lines) { + println(indent(`${' '.repeat(maxCommandLength + 8 + spaces)}${line}`)) + } + } + } + + if (options) { + let maxAliasLength = 0 + for (let { alias } of Object.values(options)) { + if (alias) { + maxAliasLength = Math.max(maxAliasLength, alias.length) + } + } + + let optionStrings: string[] = [] + + let maxOptionLength = 0 + + for (let [flag, { alias, values }] of Object.entries(options)) { + if (values?.length) { + flag += `[=${values.join(', ')}]` + } + + let option = [ + alias ? `${alias.padStart(maxAliasLength)}` : alias, + alias ? flag : ' '.repeat(maxAliasLength + 2 /* `, `.length */) + flag, + ] + .filter(Boolean) + .join(', ') + + optionStrings.push(option) + maxOptionLength = Math.max(maxOptionLength, option.length) + } + + println() + println(pc.dim('Options:')) + + let minimumGap = 8 + + for (let { description, default: defaultValue = null } of Object.values(options)) { + let option = optionStrings.shift() as string + let dotCount = minimumGap + (maxOptionLength - option.length) + let spaces = 2 + let availableWidth = width - option.length - dotCount - spaces - UI.indent + + let lines = wordWrap( + defaultValue !== null + ? `${description} ${pc.dim(`[default:\u202F${highlight(`${defaultValue}`)}]`)}` + : description, + availableWidth, + ) + + println( + indent(`${pc.blue(option)} ${pc.dim(pc.gray('\u00B7')).repeat(dotCount)} ${lines.shift()}`), + ) + + for (let line of lines) { + println(indent(`${' '.repeat(option.length + dotCount + spaces)}${line}`)) + } + } + } +} diff --git a/javascript/packages/cli/src/commands/highlight/cli.ts b/javascript/packages/cli/src/commands/highlight/cli.ts new file mode 100644 index 000000000..5f346f0b5 --- /dev/null +++ b/javascript/packages/cli/src/commands/highlight/cli.ts @@ -0,0 +1,224 @@ +import dedent from "dedent" + +import { readFileSync } from "fs" +import { parseArgs } from "util" +import { resolve } from "path" + +import { Herb } from "@herb-tools/node-wasm" +import { Highlighter, THEME_NAMES, DEFAULT_THEME } from "@herb-tools/highlighter" + +import { version } from "../../../package.json" + +const name = "@herb-tools/cli" + +import type { Diagnostic } from "@herb-tools/core" + +export class CLI { + private usage = dedent` + Usage: herb-highlight [file] [options] + + Arguments: + file File to highlight (required) + + Options: + -h, --help show help + -v, --version show version + --theme color theme (${THEME_NAMES.join('|')}) or path to custom theme file [default: ${DEFAULT_THEME}] + --focus line number to focus on (shows only that line with context) + --context-lines number of context lines around focus line [default: 2] + --no-line-numbers hide line numbers and file path header + --wrap-lines enable line wrapping [default: true] + --no-wrap-lines disable line wrapping + --truncate-lines enable line truncation (mutually exclusive with --wrap-lines) + --max-width maximum width for line wrapping/truncation [default: terminal width] + --diagnostics JSON string or file path containing diagnostics to render + --split-diagnostics render each diagnostic individually (requires --diagnostics) + ` + + private parseArguments() { + const { values, positionals } = parseArgs({ + args: process.argv.slice(2), + options: { + help: { type: "boolean", short: "h" }, + version: { type: "boolean", short: "v" }, + theme: { type: "string" }, + focus: { type: "string" }, + "context-lines": { type: "string" }, + "no-line-numbers": { type: "boolean" }, + "wrap-lines": { type: "boolean" }, + "no-wrap-lines": { type: "boolean" }, + "truncate-lines": { type: "boolean" }, + "max-width": { type: "string" }, + "diagnostics": { type: "string" }, + "split-diagnostics": { type: "boolean" }, + }, + allowPositionals: true, + }) + + if (values.help) { + console.log(this.usage) + process.exit(0) + } + + if (values.version) { + console.log("Versions:") + console.log(` ${name}@${version}, ${Herb.version}`.split(", ").join("\n ")) + process.exit(0) + } + + const theme = values.theme || DEFAULT_THEME + + let focusLine: number | undefined + + if (values.focus) { + const parsed = parseInt(values.focus, 10) + + if (isNaN(parsed) || parsed < 1) { + console.error( + `Invalid focus line: ${values.focus}. Must be a positive integer.`, + ) + process.exit(1) + } + + focusLine = parsed + } + + let contextLines = 2 + + if (values["context-lines"]) { + const parsed = parseInt(values["context-lines"], 10) + if (isNaN(parsed) || parsed < 0) { + console.error( + `Invalid context-lines: ${values["context-lines"]}. Must be a non-negative integer.`, + ) + process.exit(1) + } + contextLines = parsed + } + + const showLineNumbers = !values["no-line-numbers"] + + let wrapLines = true + let truncateLines = false + + if (values["truncate-lines"]) { + truncateLines = true + wrapLines = false + } else if (values["no-wrap-lines"]) { + wrapLines = false + } else if (values["wrap-lines"] !== undefined) { + wrapLines = !!values["wrap-lines"] + } + + if (values["wrap-lines"] && values["truncate-lines"]) { + console.error("Error: --wrap-lines and --truncate-lines cannot be used together.") + process.exit(1) + } + + let maxWidth: number | undefined + + if (values["max-width"]) { + const parsed = parseInt(values["max-width"], 10) + if (isNaN(parsed) || parsed < 1) { + console.error( + `Invalid max-width: ${values["max-width"]}. Must be a positive integer.`, + ) + process.exit(1) + } + maxWidth = parsed + } + + let diagnostics: Diagnostic[] = [] + let splitDiagnostics = false + + if (values["diagnostics"]) { + try { + let diagnosticsData: string + + if (values["diagnostics"].startsWith("{") || values["diagnostics"].startsWith("[")) { + diagnosticsData = values["diagnostics"] + } else { + diagnosticsData = readFileSync(resolve(values["diagnostics"]), "utf-8") + } + + const parsed = JSON.parse(diagnosticsData) + diagnostics = Array.isArray(parsed) ? parsed : [parsed] + + for (const diagnostic of diagnostics) { + if (!diagnostic.message || !diagnostic.location || !diagnostic.severity) { + throw new Error("Invalid diagnostic format: each diagnostic must have message, location, and severity") + } + } + + } catch (error) { + console.error(`Error parsing diagnostics: ${error instanceof Error ? error.message : error}`) + process.exit(1) + } + } + + if (values["split-diagnostics"]) { + if (diagnostics.length === 0) { + console.error("Error: --split-diagnostics requires --diagnostics to be specified") + + process.exit(1) + } + + splitDiagnostics = true + } + + return { + values, + positionals, + theme, + focusLine, + contextLines, + showLineNumbers, + wrapLines, + truncateLines, + maxWidth, + diagnostics, + splitDiagnostics, + } + } + + async run() { + const { positionals, theme, focusLine, contextLines, showLineNumbers, wrapLines, truncateLines, maxWidth, diagnostics, splitDiagnostics } = + this.parseArguments() + + if (positionals.length === 0) { + console.error("Please specify an input file.") + process.exit(1) + } + + const filename = positionals[0] + + try { + const filePath = resolve(filename) + const content = readFileSync(filePath, "utf-8") + + const highlighter = new Highlighter(theme) + await highlighter.initialize() + + const highlighted = highlighter.highlight(filePath, content, { + focusLine, + contextLines: focusLine ? contextLines : (diagnostics.length > 0 ? contextLines : 0), + showLineNumbers, + wrapLines, + truncateLines, + maxWidth, + diagnostics, + splitDiagnostics, + }) + + console.log(highlighted) + } catch (error) { + if (error instanceof Error && error.message.includes("ENOENT")) { + console.error(`File not found: ${filename}`) + } else { + console.error(`Error:`, error) + } + + process.exit(1) + } + } +} diff --git a/javascript/packages/cli/src/commands/highlight/index.ts b/javascript/packages/cli/src/commands/highlight/index.ts new file mode 100644 index 000000000..1b1430fa3 --- /dev/null +++ b/javascript/packages/cli/src/commands/highlight/index.ts @@ -0,0 +1,26 @@ +import { CLI } from './cli.js' + +export async function handle(args: string[]): Promise { + const argv = ['node', 'herb-highlight', ...args] + const originalArgv = process.argv + process.argv = argv + + try { + const highlighter = new CLI() + await highlighter.run() + } finally { + process.argv = originalArgv + } +} + +export function helpInfo() { + return { + description: 'Highlight HTML+ERB templates with syntax highlighting', + usage: [ + 'herb highlight [file] [options]', + 'herb highlight input.html.erb', + 'herb highlight input.html.erb --theme dracula', + 'herb highlight input.html.erb --focus 42', + ] + } +} diff --git a/javascript/packages/cli/src/commands/lex/cli.ts b/javascript/packages/cli/src/commands/lex/cli.ts new file mode 100644 index 000000000..d4f7b3e04 --- /dev/null +++ b/javascript/packages/cli/src/commands/lex/cli.ts @@ -0,0 +1,113 @@ +import dedent from "dedent" +import pc from "picocolors" + +import * as fs from "fs/promises" +import { Herb } from "@herb-tools/node-wasm" + +export class CLI { + async run() { + const args = process.argv.slice(2) + + if (args[0] === "lex") { + args.shift() + } + + if (args.includes("--help") || args.includes("-h")) { + this.showHelp() + return + } + + if (args.includes("--version") || args.includes("-v")) { + console.log((await import("@herb-tools/cli/package.json")).version) + return + } + + const nonOptionArgs = args.filter(arg => !arg.startsWith("-")) + const inputFile = nonOptionArgs[0] + + let input: string + let fromStdin = false + + if (inputFile && inputFile !== "-") { + try { + input = await fs.readFile(inputFile, "utf-8") + } catch (error) { + console.error(pc.red(`✗ Error reading file: ${inputFile}`)) + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } + } else if (process.stdin.isTTY) { + console.log(pc.yellow("⚠ No input provided")) + console.log() + this.showHelp() + process.exit(1) + } else { + fromStdin = true + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk) + } + input = Buffer.concat(chunks).toString("utf-8") + } + + if (!input || input.trim().length === 0) { + console.error(pc.red("✗ No input provided")) + console.error(`Run ${pc.cyan("herb lex --help")} for usage information`) + process.exit(1) + } + + try { + await Herb.load() + + const json = args.includes("--json") || args.includes("-j") + const pretty = args.includes("--pretty") || args.includes("-p") + + const tokens = Herb.lex(input) + + if (json) { + if (pretty) { + console.log(JSON.stringify(tokens, null, 2)) + } else { + console.log(JSON.stringify(tokens)) + } + } else { + console.log(tokens.value.inspect()) + } + + } catch (error) { + console.error(pc.red("✗ Error lexing:")) + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } + } + + private showHelp() { + console.log(dedent` + Usage: herb lex [file] [options] + + Tokenize HTML+ERB templates and output the token stream. + + Arguments: + file File containing template to tokenize, or '-' for stdin (defaults to stdin) + + Options: + -h, --help Show this help message + -v, --version Show version information + -j, --json Output as JSON (default is token inspect format) + -p, --pretty Pretty print JSON output (only with --json) + + Examples: + # Lex from stdin (token inspect format) + echo "
<%= @user.name %>
" | herb lex + + # Lex from a file + herb lex app/views/users/show.html.erb + + # Output as JSON + herb lex app/views/users/show.html.erb --json + + # Output as pretty JSON + herb lex app/views/users/show.html.erb --json --pretty + `) + } +} diff --git a/javascript/packages/cli/src/commands/lex/index.ts b/javascript/packages/cli/src/commands/lex/index.ts new file mode 100644 index 000000000..03e18b46a --- /dev/null +++ b/javascript/packages/cli/src/commands/lex/index.ts @@ -0,0 +1,6 @@ +import { CLI } from "./cli.js" + +export async function handle() { + const cli = new CLI() + await cli.run() +} diff --git a/javascript/packages/cli/src/commands/lint/cli.ts b/javascript/packages/cli/src/commands/lint/cli.ts new file mode 100644 index 000000000..89eaf026a --- /dev/null +++ b/javascript/packages/cli/src/commands/lint/cli.ts @@ -0,0 +1,243 @@ +import { glob } from "glob" +import { Herb } from "@herb-tools/node-wasm" +import { Config, addHerbExtensionRecommendation, getExtensionsJsonRelativePath } from "@herb-tools/config" + +import { existsSync, statSync } from "fs" +import { dirname, resolve, relative } from "path" + +import { ArgumentParser } from "./cli/argument-parser.js" +import { FileProcessor } from "./cli/file-processor.js" +import { OutputManager } from "./cli/output-manager.js" +import { version } from "../../../package.json" + +import type { ProcessingContext } from "./cli/file-processor.js" +import type { FormatOption } from "./cli/argument-parser.js" + +export * from "./cli/index.js" + +export class CLI { + protected argumentParser = new ArgumentParser() + protected fileProcessor = new FileProcessor() + protected outputManager = new OutputManager() + protected projectPath: string = process.cwd() + + getProjectPath(): string { + return this.projectPath + } + + protected exitWithError(message: string, formatOption: FormatOption, exitCode: number = 1) { + this.outputManager.outputError(message, { + formatOption, + theme: 'auto', + wrapLines: false, + truncateLines: false, + showTiming: false, + useGitHubActions: false, + startTime: 0, + startDate: new Date() + }) + process.exit(exitCode) + } + + protected exitWithInfo(message: string, formatOption: FormatOption, exitCode: number = 0, timingData?: { startTime: number, startDate: Date, showTiming: boolean }) { + const outputOptions = { + formatOption, + theme: 'auto' as const, + wrapLines: false, + truncateLines: false, + showTiming: timingData?.showTiming ?? false, + useGitHubActions: false, + startTime: timingData?.startTime ?? Date.now(), + startDate: timingData?.startDate ?? new Date() + } + + this.outputManager.outputInfo(message, outputOptions) + process.exit(exitCode) + } + + protected determineProjectPath(pattern: string | undefined): void { + if (pattern) { + const resolvedPattern = resolve(pattern) + + if (existsSync(resolvedPattern)) { + const stats = statSync(resolvedPattern) + + if (stats.isDirectory()) { + this.projectPath = resolvedPattern + } else { + this.projectPath = dirname(resolvedPattern) + } + } + } + } + + protected adjustPattern(pattern: string | undefined, configGlobPattern: string): string { + if (!pattern) { + return configGlobPattern + } + + const resolvedPattern = resolve(pattern) + + if (existsSync(resolvedPattern)) { + const stats = statSync(resolvedPattern) + + if (stats.isDirectory()) { + return configGlobPattern + } else if (stats.isFile()) { + return relative(this.projectPath, resolvedPattern) + } + } + + return pattern + } + + protected async beforeProcess(): Promise { + // Hook for subclasses to add custom output before processing + } + + protected async afterProcess(_results: any, _outputOptions: any): Promise { + // Hook for subclasses to add custom output after processing + } + + async run() { + await Herb.load() + + const startTime = Date.now() + const startDate = new Date() + + let { pattern, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, ignoreDisableComments, force, init } = this.argumentParser.parse(process.argv) + + this.determineProjectPath(pattern) + + if (init) { + const configPath = configFile || this.projectPath + + if (Config.exists(configPath)) { + const fullPath = configFile || Config.configPathFromProjectPath(this.projectPath) + console.error(`\n✗ Configuration file already exists at ${fullPath}`) + console.error(` Use --config-file to specify a different location.\n`) + process.exit(1) + } + + const config = await Config.loadForCLI(configPath, version, true) + const extensionAdded = addHerbExtensionRecommendation(this.projectPath) + + console.log(`\n✓ Configuration initialized at ${config.path}`) + + if (extensionAdded) { + console.log(`✓ VSCode extension recommended in ${getExtensionsJsonRelativePath()}`) + } + + console.log(` Edit this file to customize linter and formatter settings.\n`) + process.exit(0) + } + + const silent = formatOption === 'json' + const config = await Config.load(configFile || this.projectPath, { version, exitOnError: true, createIfMissing: false, silent }) + const linterConfig = config.options.linter || {} + + const outputOptions = { + formatOption, + theme, + wrapLines, + truncateLines, + showTiming, + useGitHubActions, + startTime, + startDate + } + + try { + await this.beforeProcess() + + if (linterConfig.enabled === false && !force) { + this.exitWithInfo("Linter is disabled in .herb.yml configuration. Use --force to lint anyway.", formatOption, 0, { startTime, startDate, showTiming }) + } + + if (force && linterConfig.enabled === false) { + console.log("⚠️ Forcing linter run (disabled in .herb.yml)") + console.log() + } + + let files: string[] + let explicitSingleFile: string | undefined + + if (!pattern) { + files = await config.findFilesForTool('linter', this.projectPath) + } else { + const resolvedPattern = resolve(pattern) + const isExplicitFile = existsSync(resolvedPattern) && statSync(resolvedPattern).isFile() + + if (isExplicitFile) { + explicitSingleFile = pattern + } + + const filesConfig = config.getFilesConfigForTool('linter') + const configGlobPattern = filesConfig.include && filesConfig.include.length > 0 + ? (filesConfig.include.length === 1 ? filesConfig.include[0] : `{${filesConfig.include.join(',')}}`) + : '**/*.html.erb' + const adjustedPattern = this.adjustPattern(pattern, configGlobPattern) + + files = await glob(adjustedPattern, { + cwd: this.projectPath, + ignore: filesConfig.exclude || [] + }) + + if (explicitSingleFile && files.length === 0) { + if (!force) { + console.error(`⚠️ File ${explicitSingleFile} is excluded by configuration patterns.`) + console.error(` Use --force to lint it anyway.\n`) + process.exit(0) + } else { + console.log(`⚠️ Forcing linter on excluded file: ${explicitSingleFile}`) + console.log() + + files = [adjustedPattern] + } + } + } + + if (files.length === 0) { + this.exitWithInfo(`No files found matching pattern: ${pattern || 'from config'}`, formatOption, 0, { startTime, startDate, showTiming }) + } + + let processingConfig = config + + if (force && explicitSingleFile && files.length === 1) { + const modifiedConfig = Object.create(Object.getPrototypeOf(config)) + Object.assign(modifiedConfig, config) + + modifiedConfig.config = { + ...config.config, + linter: { + ...config.config.linter, + exclude: [] + } + } + + processingConfig = modifiedConfig + } + + const context: ProcessingContext = { + projectPath: this.projectPath, + pattern, + fix, + ignoreDisableComments, + linterConfig, + config: processingConfig + } + + const results = await this.fileProcessor.processFiles(files, formatOption, context) + + await this.outputManager.outputResults({ ...results, files }, outputOptions) + await this.afterProcess(results, outputOptions) + + if (results.totalErrors > 0) { + process.exit(1) + } + + } catch (error) { + this.exitWithError(`Error: ${error}`, formatOption) + } + } +} diff --git a/javascript/packages/cli/src/commands/lint/cli/argument-parser.ts b/javascript/packages/cli/src/commands/lint/cli/argument-parser.ts new file mode 100644 index 000000000..714b1ec16 --- /dev/null +++ b/javascript/packages/cli/src/commands/lint/cli/argument-parser.ts @@ -0,0 +1,153 @@ +import dedent from "dedent" + +import { parseArgs } from "util" +import { Herb } from "@herb-tools/node-wasm" + +import { THEME_NAMES, DEFAULT_THEME } from "@herb-tools/highlighter" +import type { ThemeInput } from "@herb-tools/highlighter" + +import { version } from "../../../../package.json" + +const name = "@herb-tools/cli" +const dependencies = { "@herb-tools/printer": version } + +export type FormatOption = "simple" | "detailed" | "json" + +export interface ParsedArguments { + pattern: string + configFile?: string + formatOption: FormatOption + showTiming: boolean + theme: ThemeInput + wrapLines: boolean + truncateLines: boolean + useGitHubActions: boolean + fix: boolean + ignoreDisableComments: boolean + force: boolean + init: boolean +} + +export class ArgumentParser { + private readonly usage = dedent` + Usage: herb-lint [file|glob-pattern|directory] [options] + + Arguments: + file Single file to lint + glob-pattern Files to lint (defaults to configured extensions in .herb.yml) + directory Directory to lint (automatically appends configured glob pattern) + + Options: + -h, --help show help + -v, --version show version + --init create a .herb.yml configuration file in the current directory + -c, --config-file explicitly specify path to .herb.yml config file + --force force linting even if disabled in .herb.yml + --fix automatically fix auto-correctable offenses + --ignore-disable-comments report offenses even when suppressed with <%# herb:disable %> comments + --format output format (simple|detailed|json) [default: detailed] + --simple use simple output format (shortcut for --format simple) + --json use JSON output format (shortcut for --format json) + --github enable GitHub Actions annotations (combines with --format) + --no-github disable GitHub Actions annotations (even in GitHub Actions environment) + --theme syntax highlighting theme (${THEME_NAMES.join("|")}) or path to custom theme file [default: ${DEFAULT_THEME}] + --no-color disable colored output + --no-timing hide timing information + --no-wrap-lines disable line wrapping + --truncate-lines enable line truncation (mutually exclusive with line wrapping) + ` + + parse(argv: string[]): ParsedArguments { + const { values, positionals } = parseArgs({ + args: argv.slice(2), + options: { + help: { type: "boolean", short: "h" }, + version: { type: "boolean", short: "v" }, + init: { type: "boolean" }, + "config-file": { type: "string", short: "c" }, + force: { type: "boolean" }, + fix: { type: "boolean" }, + "ignore-disable-comments": { type: "boolean" }, + format: { type: "string" }, + simple: { type: "boolean" }, + json: { type: "boolean" }, + github: { type: "boolean" }, + "no-github": { type: "boolean" }, + theme: { type: "string" }, + "no-color": { type: "boolean" }, + "no-timing": { type: "boolean" }, + "no-wrap-lines": { type: "boolean" }, + "truncate-lines": { type: "boolean" } + }, + allowPositionals: true + }) + + if (values.help) { + console.log(this.usage) + process.exit(0) + } + + if (values.version) { + console.log("Versions:") + console.log(` ${name}@${version}`) + console.log(` @herb-tools/printer@${dependencies["@herb-tools/printer"]}`) + console.log(` ${Herb.version}`.split(", ").join("\n ")) + process.exit(0) + } + + const isGitHubActions = process.env.GITHUB_ACTIONS === "true" + + let formatOption: FormatOption = "detailed" + if (values.format && (values.format === "detailed" || values.format === "simple" || values.format === "json")) { + formatOption = values.format + } + + if (values.simple) { + formatOption = "simple" + } + + if (values.json) { + formatOption = "json" + } + + const useGitHubActions = (values.github || isGitHubActions) && !values["no-github"] + + if (useGitHubActions && formatOption === "json") { + console.error("Error: --github cannot be used with --json format. JSON format is already structured for programmatic consumption.") + process.exit(1) + } + + if (values["no-color"]) { + process.env.NO_COLOR = "1" + } + + const showTiming = !values["no-timing"] + + let wrapLines = !values["no-wrap-lines"] + let truncateLines = false + + if (values["truncate-lines"]) { + truncateLines = true + wrapLines = false + } + + if (!values["no-wrap-lines"] && values["truncate-lines"]) { + console.error("Error: Line wrapping and --truncate-lines cannot be used together. Use --no-wrap-lines with --truncate-lines.") + process.exit(1) + } + + const theme = values.theme || DEFAULT_THEME + const pattern = this.getFilePattern(positionals) + const fix = values.fix || false + const force = !!values.force + const ignoreDisableComments = values["ignore-disable-comments"] || false + const configFile = values["config-file"] + const init = values.init || false + + return { pattern, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, ignoreDisableComments, force, init } + } + + private getFilePattern(positionals: string[]): string { + return positionals.length > 0 ? positionals[0] : "" + } +} diff --git a/javascript/packages/cli/src/commands/lint/cli/file-processor.ts b/javascript/packages/cli/src/commands/lint/cli/file-processor.ts new file mode 100644 index 000000000..ce651c681 --- /dev/null +++ b/javascript/packages/cli/src/commands/lint/cli/file-processor.ts @@ -0,0 +1,181 @@ +import { Herb } from "@herb-tools/node-wasm" +import { Linter } from "@herb-tools/linter" +import { Config } from "@herb-tools/config" + +import { readFileSync, writeFileSync } from "fs" +import { resolve } from "path" +import { colorize } from "@herb-tools/highlighter" + +import type { Diagnostic } from "@herb-tools/core" +import type { FormatOption } from "./argument-parser.js" +import type { HerbConfigOptions } from "@herb-tools/config" + +export interface ProcessedFile { + filename: string + offense: Diagnostic + content: string + autocorrectable?: boolean +} + +export interface ProcessingContext { + projectPath?: string + pattern?: string + fix?: boolean + ignoreDisableComments?: boolean + linterConfig?: HerbConfigOptions['linter'] + config?: Config +} + +export interface ProcessingResult { + totalErrors: number + totalWarnings: number + totalInfo: number + totalHints: number + totalIgnored: number + totalWouldBeIgnored?: number + filesWithOffenses: number + filesFixed: number + ruleCount: number + allOffenses: ProcessedFile[] + ruleOffenses: Map }> + context?: ProcessingContext +} + +export class FileProcessor { + private linter: Linter | null = null + + private isRuleAutocorrectable(ruleName: string): boolean { + if (!this.linter) return false + + const RuleClass = (this.linter as any).rules.find((rule: any) => { + const instance = new rule() + + return instance.name === ruleName + }) + + if (!RuleClass) return false + + return RuleClass.autocorrectable === true + } + + async processFiles(files: string[], formatOption: FormatOption = 'detailed', context?: ProcessingContext): Promise { + let totalErrors = 0 + let totalWarnings = 0 + let totalInfo = 0 + let totalHints = 0 + let totalIgnored = 0 + let totalWouldBeIgnored = 0 + let filesWithOffenses = 0 + let filesFixed = 0 + let ruleCount = 0 + const allOffenses: ProcessedFile[] = [] + const ruleOffenses = new Map }>() + + for (const filename of files) { + const filePath = context?.projectPath ? resolve(context.projectPath, filename) : resolve(filename) + let content = readFileSync(filePath, "utf-8") + + if (!this.linter) { + this.linter = Linter.from(Herb, context?.config) + } + + const lintResult = this.linter.lint(content, { + fileName: filename, + ignoreDisableComments: context?.ignoreDisableComments + }) + + if (ruleCount === 0) { + ruleCount = this.linter.getRuleCount() + } + + if (context?.fix && lintResult.offenses.length > 0) { + const autofixResult = this.linter.autofix(content, { + fileName: filename, + ignoreDisableComments: context?.ignoreDisableComments + }) + + if (autofixResult.fixed.length > 0) { + writeFileSync(filePath, autofixResult.source, "utf-8") + + filesFixed++ + + if (formatOption !== 'json') { + console.log(`${colorize("✓", "brightGreen")} ${colorize(filename, "cyan")} - ${colorize(`Fixed ${autofixResult.fixed.length} offense(s)`, "green")}`) + } + } + + content = autofixResult.source + + for (const offense of autofixResult.unfixed) { + allOffenses.push({ + filename, + offense: offense, + content, + autocorrectable: this.isRuleAutocorrectable(offense.rule) + }) + + const ruleData = ruleOffenses.get(offense.rule) || { count: 0, files: new Set() } + ruleData.count++ + ruleData.files.add(filename) + ruleOffenses.set(offense.rule, ruleData) + } + + if (autofixResult.unfixed.length > 0) { + totalErrors += autofixResult.unfixed.filter(offense => offense.severity === "error").length + totalWarnings += autofixResult.unfixed.filter(offense => offense.severity === "warning").length + totalInfo += autofixResult.unfixed.filter(offense => offense.severity === "info").length + totalHints += autofixResult.unfixed.filter(offense => offense.severity === "hint").length + filesWithOffenses++ + } + } else if (lintResult.offenses.length === 0) { + if (files.length === 1 && formatOption !== 'json') { + console.log(`${colorize("✓", "brightGreen")} ${colorize(filename, "cyan")} - ${colorize("No issues found", "green")}`) + } + } else { + for (const offense of lintResult.offenses) { + allOffenses.push({ + filename, + offense: offense, + content, + autocorrectable: this.isRuleAutocorrectable(offense.rule) + }) + + const ruleData = ruleOffenses.get(offense.rule) || { count: 0, files: new Set() } + ruleData.count++ + ruleData.files.add(filename) + ruleOffenses.set(offense.rule, ruleData) + } + + totalErrors += lintResult.errors + totalWarnings += lintResult.warnings + totalInfo += lintResult.offenses.filter(o => o.severity === "info").length + totalHints += lintResult.offenses.filter(o => o.severity === "hint").length + filesWithOffenses++ + } + totalIgnored += lintResult.ignored + if (lintResult.wouldBeIgnored) { + totalWouldBeIgnored += lintResult.wouldBeIgnored + } + } + + const result: ProcessingResult = { + totalErrors, + totalWarnings, + totalInfo, + totalHints, + totalIgnored, + filesWithOffenses, + filesFixed, + ruleCount, + allOffenses, + ruleOffenses, + context + } + + if (totalWouldBeIgnored > 0) { + result.totalWouldBeIgnored = totalWouldBeIgnored + } + + return result + } +} diff --git a/javascript/packages/cli/src/commands/lint/cli/formatters/base-formatter.ts b/javascript/packages/cli/src/commands/lint/cli/formatters/base-formatter.ts new file mode 100644 index 000000000..47fa086a2 --- /dev/null +++ b/javascript/packages/cli/src/commands/lint/cli/formatters/base-formatter.ts @@ -0,0 +1,11 @@ +import type { Diagnostic } from "@herb-tools/core" +import type { ProcessedFile } from "../file-processor.js" + +export abstract class BaseFormatter { + abstract format( + allOffenses: ProcessedFile[], + isSingleFile?: boolean + ): Promise + + abstract formatFile(filename: string, offenses: Diagnostic[]): void +} diff --git a/javascript/packages/cli/src/commands/lint/cli/formatters/detailed-formatter.ts b/javascript/packages/cli/src/commands/lint/cli/formatters/detailed-formatter.ts new file mode 100644 index 000000000..28b0870b2 --- /dev/null +++ b/javascript/packages/cli/src/commands/lint/cli/formatters/detailed-formatter.ts @@ -0,0 +1,90 @@ +import { colorize, Highlighter, type ThemeInput, DEFAULT_THEME } from "@herb-tools/highlighter" + +import { BaseFormatter } from "./base-formatter.js" +import { LineWrapper } from "@herb-tools/highlighter" + +import type { Diagnostic } from "@herb-tools/core" +import type { ProcessedFile } from "../file-processor.js" + +export class DetailedFormatter extends BaseFormatter { + private highlighter: Highlighter | null = null + private theme: ThemeInput + private wrapLines: boolean + private truncateLines: boolean + + constructor(theme: ThemeInput = DEFAULT_THEME, wrapLines: boolean = true, truncateLines: boolean = false) { + super() + this.theme = theme + this.wrapLines = wrapLines + this.truncateLines = truncateLines + } + + async format(allOffenses: ProcessedFile[], isSingleFile: boolean = false): Promise { + if (allOffenses.length === 0) return + + if (!this.highlighter) { + this.highlighter = new Highlighter(this.theme) + await this.highlighter.initialize() + } + + if (isSingleFile) { + const { filename, content } = allOffenses[0] + const diagnostics = allOffenses.map(item => { + if (item.autocorrectable && item.offense.code) { + return { + ...item.offense, + message: `${item.offense.message} ${colorize(colorize("[Correctable]", "green"), "bold")}` + } + } + return item.offense + }) + + const highlighted = this.highlighter.highlight(filename, content, { + diagnostics: diagnostics, + splitDiagnostics: true, + contextLines: 2, + wrapLines: this.wrapLines, + truncateLines: this.truncateLines + }) + + console.log(`\n${highlighted}`) + } else { + const totalMessageCount = allOffenses.length + + for (let i = 0; i < allOffenses.length; i++) { + const { filename, offense, content, autocorrectable } = allOffenses[i] + + let modifiedOffense = offense + + if (autocorrectable && offense.code) { + modifiedOffense = { + ...offense, + message: `${offense.message} ${colorize(colorize("[Correctable]", "green"), "bold")}` + } + } + + const formatted = this.highlighter.highlightDiagnostic(filename, modifiedOffense, content, { + contextLines: 2, + wrapLines: this.wrapLines, + truncateLines: this.truncateLines + }) + console.log(`\n${formatted}`) + + const width = LineWrapper.getTerminalWidth() + const progressText = `[${i + 1}/${totalMessageCount}]` + const rightPadding = 16 + const separatorLength = Math.max(0, width - progressText.length - 1 - rightPadding) + const separator = '⎯' + const leftSeparator = colorize(separator.repeat(separatorLength), "gray") + const rightSeparator = colorize(separator.repeat(4), "gray") + const progress = colorize(progressText, "gray") + + console.log(colorize(`${leftSeparator} ${progress}`, "dim") + colorize(` ${rightSeparator}\n`, "dim")) + } + } + } + + formatFile(_filename: string, _offenses: Diagnostic[]): void { + throw new Error("formatFile is not implemented for DetailedFormatter") + } +} diff --git a/javascript/packages/cli/src/commands/lint/cli/formatters/github-actions-formatter.ts b/javascript/packages/cli/src/commands/lint/cli/formatters/github-actions-formatter.ts new file mode 100644 index 000000000..87fe2b55b --- /dev/null +++ b/javascript/packages/cli/src/commands/lint/cli/formatters/github-actions-formatter.ts @@ -0,0 +1,141 @@ +import { Highlighter } from "@herb-tools/highlighter" + +import { BaseFormatter } from "./base-formatter.js" +import { version } from "../../../../../package.json" + +const name = "@herb-tools/cli" + +import type { Diagnostic } from "@herb-tools/core" +import type { ProcessedFile } from "../file-processor.js" + +// https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands +export class GitHubActionsFormatter extends BaseFormatter { + private highlighter: Highlighter + private wrapLines: boolean + private truncateLines: boolean + + constructor(wrapLines: boolean = true, truncateLines: boolean = false) { + super() + this.wrapLines = wrapLines + this.truncateLines = truncateLines + this.highlighter = new Highlighter() + } + + private static readonly MESSAGE_ESCAPE_MAP: Record = { + '%': '%25', + '\n': '%0A', + '\r': '%0D' + } + + private static readonly PARAM_ESCAPE_MAP: Record = { + '%': '%25', + '\n': '%0A', + '\r': '%0D', + ':': '%3A', + ',': '%2C' + } + + async format(allDiagnostics: ProcessedFile[], _isSingleFile: boolean = false): Promise { + await this.formatAnnotations(allDiagnostics) + } + + async formatAnnotations(allDiagnostics: ProcessedFile[]): Promise { + if (allDiagnostics.length === 0) return + + if (!this.highlighter.initialized) { + await this.highlighter.initialize() + } + + for (const { filename, offense, content } of allDiagnostics) { + const originalNoColor = process.env.NO_COLOR + process.env.NO_COLOR = "1" + + let plainCodePreview = "" + try { + const formatted = this.highlighter.highlightDiagnostic(filename, offense, content, { + contextLines: 2, + wrapLines: this.wrapLines, + truncateLines: this.truncateLines + }) + + plainCodePreview = formatted.split('\n').slice(1).join('\n') + } finally { + if (originalNoColor === undefined) { + delete process.env.NO_COLOR + } else { + process.env.NO_COLOR = originalNoColor + } + } + + this.formatDiagnostic(filename, offense, plainCodePreview) + } + } + + formatFile(filename: string, diagnostics: Diagnostic[]): void { + for (const diagnostic of diagnostics) { + this.formatDiagnostic(filename, diagnostic) + } + } + + // GitHub Actions annotation format: + // ::{level} file={file},line={line},col={col},title={title}::{message} + // + private formatDiagnostic(filename: string, diagnostic: Diagnostic, codePreview: string = ""): void { + let level: string + + switch (diagnostic.severity) { + case "error": + level = "error" + break + case "warning": + level = "warning" + break + case "info": + case "hint": + level = "notice" + break + default: + level = "warning" + } + + const { line, column } = diagnostic.location.start + + const escapedFilename = this.escapeParam(filename) + let message = diagnostic.message + + if (diagnostic.code) { + message += ` [${diagnostic.code}]` + } + + if (codePreview) { + message += "\n\n" + codePreview + } + + const escapedMessage = this.escapeMessage(message) + + let annotations = `file=${escapedFilename},line=${line},col=${column}` + + if (diagnostic.code) { + const title = `${diagnostic.code} • ${name}@${version}` + const escapedTitle = this.escapeParam(title) + + annotations += `,title=${escapedTitle}` + } + + console.log(`\n::${level} ${annotations}::${escapedMessage}`) + } + + private escapeMessage(string: string): string { + return string.replace( + new RegExp(Object.keys(GitHubActionsFormatter.MESSAGE_ESCAPE_MAP).map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g'), + match => GitHubActionsFormatter.MESSAGE_ESCAPE_MAP[match] + ) + } + + private escapeParam(string: string): string { + return string.replace( + new RegExp(Object.keys(GitHubActionsFormatter.PARAM_ESCAPE_MAP).map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g'), + match => GitHubActionsFormatter.PARAM_ESCAPE_MAP[match] + ) + } +} diff --git a/javascript/packages/cli/src/commands/lint/cli/formatters/index.ts b/javascript/packages/cli/src/commands/lint/cli/formatters/index.ts new file mode 100644 index 000000000..9d7ddd5cd --- /dev/null +++ b/javascript/packages/cli/src/commands/lint/cli/formatters/index.ts @@ -0,0 +1,5 @@ +export { BaseFormatter } from "./base-formatter.js" +export { SimpleFormatter } from "./simple-formatter.js" +export { DetailedFormatter } from "./detailed-formatter.js" +export { JSONFormatter, type JSONOutput } from "./json-formatter.js" +export { GitHubActionsFormatter } from "./github-actions-formatter.js" diff --git a/javascript/packages/cli/src/commands/lint/cli/formatters/json-formatter.ts b/javascript/packages/cli/src/commands/lint/cli/formatters/json-formatter.ts new file mode 100644 index 000000000..271bd24cb --- /dev/null +++ b/javascript/packages/cli/src/commands/lint/cli/formatters/json-formatter.ts @@ -0,0 +1,116 @@ +import { BaseFormatter } from "./base-formatter.js" + +import type { Diagnostic, SerializedDiagnostic } from "@herb-tools/core" +import type { ProcessedFile } from "../file-processor.js" + +interface JSONOffense extends SerializedDiagnostic { + filename: string +} + +interface JSONSummary { + filesChecked: number + filesWithOffenses: number + totalErrors: number + totalWarnings: number + totalInfo: number + totalHints: number + totalIgnored: number + totalOffenses: number + ruleCount: number +} + +interface JSONTiming { + startTime: string + duration: number +} + +export interface JSONOutput { + offenses: JSONOffense[] + summary: JSONSummary | null + timing: JSONTiming | null + completed: boolean + clean: boolean | null + message: string | null +} + +interface JSONFormatOptions { + files: string[] + totalErrors: number + totalWarnings: number + totalInfo: number + totalHints: number + totalIgnored: number + filesWithOffenses: number + ruleCount: number + startTime: number + startDate: Date + showTiming: boolean +} + +export class JSONFormatter extends BaseFormatter { + async format(allOffenses: ProcessedFile[]): Promise { + const jsonOffenses: JSONOffense[] = allOffenses.map(({ filename, offense }) => ({ + filename, + message: offense.message, + location: offense.location.toJSON(), + severity: offense.severity, + code: offense.code, + source: offense.source + })) + + const output: JSONOutput = { + offenses: jsonOffenses, + summary: null, + timing: null, + completed: true, + clean: jsonOffenses.length === 0, + message: null + } + + console.log(JSON.stringify(output, null, 2)) + } + + async formatWithSummary(allOffenses: ProcessedFile[], options: JSONFormatOptions): Promise { + const jsonOffenses: JSONOffense[] = allOffenses.map(({ filename, offense }) => ({ + filename, + message: offense.message, + location: offense.location.toJSON(), + severity: offense.severity, + code: offense.code, + source: offense.source + })) + + const summary: JSONSummary = { + filesChecked: options.files.length, + filesWithOffenses: options.filesWithOffenses, + totalErrors: options.totalErrors, + totalWarnings: options.totalWarnings, + totalInfo: options.totalInfo, + totalHints: options.totalHints, + totalIgnored: options.totalIgnored, + totalOffenses: options.totalErrors + options.totalWarnings, + ruleCount: options.ruleCount + } + + const output: JSONOutput = { + offenses: jsonOffenses, + summary, + timing: null, + completed: true, + clean: options.totalErrors === 0 && options.totalWarnings === 0, + message: null + } + + const duration = Date.now() - options.startTime + output.timing = options.showTiming ? { + startTime: options.startDate.toISOString(), + duration: duration + } : null + + console.log(JSON.stringify(output, null, 2)) + } + + formatFile(_filename: string, _offenses: Diagnostic[]): void { + // Not used in JSON formatter, everything is handled in format() + } +} diff --git a/javascript/packages/cli/src/commands/lint/cli/formatters/simple-formatter.ts b/javascript/packages/cli/src/commands/lint/cli/formatters/simple-formatter.ts new file mode 100644 index 000000000..a88e3a0ed --- /dev/null +++ b/javascript/packages/cli/src/commands/lint/cli/formatters/simple-formatter.ts @@ -0,0 +1,56 @@ +import { colorize } from "@herb-tools/highlighter" + +import { BaseFormatter } from "./base-formatter.js" + +import type { Diagnostic } from "@herb-tools/core" +import type { ProcessedFile } from "../file-processor.js" + +export class SimpleFormatter extends BaseFormatter { + async format(allOffenses: ProcessedFile[]): Promise { + if (allOffenses.length === 0) return + + const groupedOffenses = new Map() + + for (const processedFile of allOffenses) { + const offenses = groupedOffenses.get(processedFile.filename) || [] + offenses.push(processedFile) + groupedOffenses.set(processedFile.filename, offenses) + } + + for (const [filename, processedFiles] of groupedOffenses) { + console.log("") + this.formatFileProcessed(filename, processedFiles) + } + } + + formatFile(filename: string, offenses: Diagnostic[]): void { + console.log(`${colorize(filename, "cyan")}:`) + + for (const offense of offenses) { + const isError = offense.severity === "error" + const severity = isError ? colorize("✗", "brightRed") : colorize("⚠", "brightYellow") + const rule = colorize(`(${offense.code})`, "blue") + const locationString = `${offense.location.start.line}:${offense.location.start.column}` + const paddedLocation = locationString.padEnd(4) + + console.log(` ${colorize(paddedLocation, "gray")} ${severity} ${offense.message} ${rule}`) + } + console.log("") + } + + formatFileProcessed(filename: string, processedFiles: ProcessedFile[]): void { + console.log(`${colorize(filename, "cyan")}:`) + + for (const { offense, autocorrectable } of processedFiles) { + const isError = offense.severity === "error" + const severity = isError ? colorize("✗", "brightRed") : colorize("⚠", "brightYellow") + const rule = colorize(`(${offense.code})`, "blue") + const locationString = `${offense.location.start.line}:${offense.location.start.column}` + const paddedLocation = locationString.padEnd(4) + const correctable = autocorrectable ? colorize(colorize(" [Correctable]", "green"), "bold") : "" + + console.log(` ${colorize(paddedLocation, "gray")} ${severity} ${offense.message} ${rule}${correctable}`) + } + console.log("") + } +} diff --git a/javascript/packages/cli/src/commands/lint/cli/index.ts b/javascript/packages/cli/src/commands/lint/cli/index.ts new file mode 100644 index 000000000..f91674aa4 --- /dev/null +++ b/javascript/packages/cli/src/commands/lint/cli/index.ts @@ -0,0 +1,6 @@ +export { ArgumentParser } from "./argument-parser.js" +export { FileProcessor } from "./file-processor.js" +export { SummaryReporter } from "./summary-reporter.js" +export { OutputManager } from "./output-manager.js" + +export * from "./formatters/index.js" diff --git a/javascript/packages/cli/src/commands/lint/cli/output-manager.ts b/javascript/packages/cli/src/commands/lint/cli/output-manager.ts new file mode 100644 index 000000000..ca7a47dd8 --- /dev/null +++ b/javascript/packages/cli/src/commands/lint/cli/output-manager.ts @@ -0,0 +1,185 @@ +import { SummaryReporter } from "./summary-reporter.js" +import { SimpleFormatter, DetailedFormatter, GitHubActionsFormatter, type JSONOutput } from "./formatters/index.js" + +import type { ThemeInput } from "@herb-tools/highlighter" +import type { FormatOption } from "./argument-parser.js" +import type { ProcessingResult } from "./file-processor.js" + +interface OutputOptions { + formatOption: FormatOption + theme: ThemeInput + wrapLines: boolean + truncateLines: boolean + showTiming: boolean + useGitHubActions: boolean + startTime: number + startDate: Date +} + +interface LintResults extends ProcessingResult { + files: string[] +} + +export class OutputManager { + private summaryReporter = new SummaryReporter() + + /** + * Output successful lint results + */ + async outputResults(results: LintResults, options: OutputOptions): Promise { + const { allOffenses, files, totalErrors, totalWarnings, totalInfo, totalHints, totalIgnored, totalWouldBeIgnored, filesWithOffenses, ruleCount, ruleOffenses, context } = results + + const autofixableCount = allOffenses.filter(offense => offense.autocorrectable).length + + if (options.useGitHubActions) { + const githubFormatter = new GitHubActionsFormatter(options.wrapLines, options.truncateLines) + await githubFormatter.formatAnnotations(allOffenses) + + if (options.formatOption !== "json") { + const regularFormatter = options.formatOption === "simple" + ? new SimpleFormatter() + : new DetailedFormatter(options.theme, options.wrapLines, options.truncateLines) + + await regularFormatter.format(allOffenses, files.length === 1) + + this.summaryReporter.displayMostViolatedRules(ruleOffenses) + this.summaryReporter.displaySummary({ + files, + totalErrors, + totalWarnings, + totalInfo, + totalHints, + totalIgnored, + totalWouldBeIgnored, + filesWithOffenses, + ruleCount, + startTime: options.startTime, + startDate: options.startDate, + showTiming: options.showTiming, + ruleOffenses, + autofixableCount, + ignoreDisableComments: context?.ignoreDisableComments, + }) + } + } else if (options.formatOption === "json") { + const output: JSONOutput = { + offenses: allOffenses.map(({ filename, offense }) => ({ + filename, + message: offense.message, + location: offense.location.toJSON(), + severity: offense.severity, + code: offense.code, + source: offense.source + })), + summary: { + filesChecked: files.length, + filesWithOffenses, + totalErrors, + totalWarnings, + totalInfo, + totalHints, + totalIgnored, + totalOffenses: totalErrors + totalWarnings, + ruleCount + }, + timing: null, + completed: true, + clean: totalErrors === 0 && totalWarnings === 0, + message: null + } + + const duration = Date.now() - options.startTime + output.timing = options.showTiming ? { + startTime: options.startDate.toISOString(), + duration: duration + } : null + + console.log(JSON.stringify(output, null, 2)) + } else { + const formatter = options.formatOption === "simple" + ? new SimpleFormatter() + : new DetailedFormatter(options.theme, options.wrapLines, options.truncateLines) + + await formatter.format(allOffenses, files.length === 1) + + this.summaryReporter.displayMostViolatedRules(ruleOffenses) + this.summaryReporter.displaySummary({ + files, + totalErrors, + totalWarnings, + totalInfo, + totalHints, + totalIgnored, + totalWouldBeIgnored, + filesWithOffenses, + ruleCount, + startTime: options.startTime, + startDate: options.startDate, + showTiming: options.showTiming, + ruleOffenses, + autofixableCount, + ignoreDisableComments: context?.ignoreDisableComments, + }) + } + } + + /** + * Output informational message (like "no files found") + */ + outputInfo(message: string, options: OutputOptions): void { + if (options.useGitHubActions) { + // GitHub Actions format doesn't output anything for info messages + } else if (options.formatOption === "json") { + const output: JSONOutput = { + offenses: [], + summary: { + filesChecked: 0, + filesWithOffenses: 0, + totalErrors: 0, + totalWarnings: 0, + totalInfo: 0, + totalHints: 0, + totalIgnored: 0, + totalOffenses: 0, + ruleCount: 0 + }, + timing: null, + completed: false, + clean: null, + message + } + + const duration = Date.now() - options.startTime + output.timing = options.showTiming ? { + startTime: options.startDate.toISOString(), + duration: duration + } : null + + console.log(JSON.stringify(output, null, 2)) + } else { + console.log(message) + } + } + + /** + * Output error message + */ + outputError(message: string, options: OutputOptions): void { + if (options.useGitHubActions) { + console.log(`::error::${message}`) + } else if (options.formatOption === "json") { + const output: JSONOutput = { + offenses: [], + summary: null, + timing: null, + completed: false, + clean: null, + message + } + + console.log(JSON.stringify(output, null, 2)) + } else { + console.error(message) + } + } +} diff --git a/javascript/packages/cli/src/commands/lint/cli/summary-reporter.ts b/javascript/packages/cli/src/commands/lint/cli/summary-reporter.ts new file mode 100644 index 000000000..e6502a2e8 --- /dev/null +++ b/javascript/packages/cli/src/commands/lint/cli/summary-reporter.ts @@ -0,0 +1,156 @@ +import { colorize } from "@herb-tools/highlighter" + +export interface SummaryData { + files: string[] + totalErrors: number + totalWarnings: number + totalInfo?: number + totalHints?: number + totalIgnored: number + totalWouldBeIgnored?: number + filesWithOffenses: number + ruleCount: number + startTime: number + startDate: Date + showTiming: boolean + ruleOffenses: Map }> + autofixableCount: number + ignoreDisableComments?: boolean +} + +export class SummaryReporter { + private pluralize(count: number, singular: string, plural?: string): string { + return count === 1 ? singular : (plural || `${singular}s`) + } + + displaySummary(data: SummaryData): void { + const { files, totalErrors, totalWarnings, totalInfo = 0, totalHints = 0, totalIgnored, totalWouldBeIgnored, filesWithOffenses, ruleCount, startTime, startDate, showTiming, autofixableCount, ignoreDisableComments } = data + + console.log("\n") + console.log(` ${colorize("Summary:", "bold")}`) + + const labelWidth = 12 + const pad = (label: string) => label.padEnd(labelWidth) + + console.log(` ${colorize(pad("Checked"), "gray")} ${colorize(`${files.length} ${this.pluralize(files.length, "file")}`, "cyan")}`) + + if (files.length > 1) { + const filesChecked = files.length + const filesClean = filesChecked - filesWithOffenses + + let filesSummary = "" + let shouldDim = false + + if (filesWithOffenses > 0) { + filesSummary = `${colorize(colorize(`${filesWithOffenses} with offenses`, "brightRed"), "bold")} | ${colorize(colorize(`${filesClean} clean`, "green"), "bold")} ${colorize(colorize(`(${filesChecked} total)`, "gray"), "dim")}` + } else { + filesSummary = `${colorize(colorize(`${filesChecked} clean`, "green"), "bold")} ${colorize(colorize(`(${filesChecked} total)`, "gray"), "dim")}` + shouldDim = true + } + + if (shouldDim) { + console.log(colorize(` ${colorize(pad("Files"), "gray")} ${filesSummary}`, "dim")) + } else { + console.log(` ${colorize(pad("Files"), "gray")} ${filesSummary}`) + } + } + + let offensesSummary = "" + const parts = [] + + if (totalErrors > 0) { + parts.push(colorize(colorize(`${totalErrors} ${this.pluralize(totalErrors, "error")}`, "brightRed"), "bold")) + } + + if (totalWarnings > 0) { + parts.push(colorize(colorize(`${totalWarnings} ${this.pluralize(totalWarnings, "warning")}`, "brightYellow"), "bold")) + } else if (totalErrors > 0) { + parts.push(colorize(colorize(`${totalWarnings} ${this.pluralize(totalWarnings, "warning")}`, "green"), "bold")) + } + + if (totalInfo > 0) { + parts.push(colorize(colorize(`${totalInfo} info`, "brightBlue"), "bold")) + } + + if (totalHints > 0) { + parts.push(colorize(colorize(`${totalHints} ${this.pluralize(totalHints, "hint")}`, "gray"), "bold")) + } + + if (totalIgnored > 0) { + parts.push(colorize(colorize(`${totalIgnored} ignored`, "gray"), "bold")) + } + + if (parts.length === 0) { + offensesSummary = colorize(colorize("0 offenses", "green"), "bold") + } else { + offensesSummary = parts.join(" | ") + + let detailText = "" + + const totalOffenses = totalErrors + totalWarnings + totalInfo + totalHints + + if (filesWithOffenses > 0) { + detailText = `${totalOffenses} ${this.pluralize(totalOffenses, "offense")} across ${filesWithOffenses} ${this.pluralize(filesWithOffenses, "file")}` + } + + if (detailText) { + offensesSummary += ` ${colorize(colorize(`(${detailText})`, "gray"), "dim")}` + } + } + + console.log(` ${colorize(pad("Offenses"), "gray")} ${offensesSummary}`) + + if (ignoreDisableComments && totalWouldBeIgnored && totalWouldBeIgnored > 0) { + const message = `${colorize(colorize(`${totalWouldBeIgnored} additional ${this.pluralize(totalWouldBeIgnored, "offense")} reported (would have been ignored)`, "cyan"), "bold")}` + console.log(` ${colorize(pad("Note"), "gray")} ${message}`) + } + + const totalOffenses = totalErrors + totalWarnings + totalInfo + totalHints + + if (autofixableCount > 0 || totalOffenses > 0) { + let fixableLine = `${colorize(colorize(`${totalOffenses} ${this.pluralize(totalOffenses, "offense")}`, "brightRed"), "bold")}` + + if (autofixableCount > 0) { + fixableLine += ` | ${colorize(colorize(`${autofixableCount} autocorrectable using \`--fix\``, "green"), "bold")}` + } + + console.log(` ${colorize(pad("Fixable"), "gray")} ${fixableLine}`) + } + + if (showTiming) { + const duration = Date.now() - startTime + const timeString = startDate.toTimeString().split(' ')[0] + + console.log(` ${colorize(pad("Start at"), "gray")} ${colorize(timeString, "cyan")}`) + console.log(` ${colorize(pad("Duration"), "gray")} ${colorize(`${duration}ms`, "cyan")} ${colorize(colorize(`(${ruleCount} ${this.pluralize(ruleCount, "rule")})`, "gray"), "dim")}`) + } + + if (filesWithOffenses === 0 && files.length > 1) { + console.log("") + console.log(` ${colorize("✓", "brightGreen")} ${colorize("All files are clean!", "green")}`) + } + } + + displayMostViolatedRules(ruleOffenses: Map }>, limit: number = 5): void { + if (ruleOffenses.size === 0) return + + const allRules = Array.from(ruleOffenses.entries()).sort((a, b) => b[1].count - a[1].count) + const displayedRules = allRules.slice(0, limit) + const remainingRules = allRules.slice(limit) + + const title = ruleOffenses.size <= limit ? "Rule offenses:" : "Most frequent rule offenses:" + console.log(` ${colorize(title, "bold")}`) + + for (const [rule, data] of displayedRules) { + const fileCount = data.files.size + const countText = `(${data.count} ${this.pluralize(data.count, "offense")} in ${fileCount} ${this.pluralize(fileCount, "file")})` + console.log(` ${colorize(rule, "gray")} ${colorize(colorize(countText, "gray"), "dim")}`) + } + + if (remainingRules.length > 0) { + const remainingOffenseCount = remainingRules.reduce((sum, [_, data]) => sum + data.count, 0) + const remainingRuleCount = remainingRules.length + console.log(colorize(colorize(`\n ...and ${remainingRuleCount} more ${this.pluralize(remainingRuleCount, "rule")} with ${remainingOffenseCount} ${this.pluralize(remainingOffenseCount, "offense")}`, "gray"), "dim")) + } + } +} diff --git a/javascript/packages/cli/src/commands/lint/index.ts b/javascript/packages/cli/src/commands/lint/index.ts new file mode 100644 index 000000000..61e2b33e9 --- /dev/null +++ b/javascript/packages/cli/src/commands/lint/index.ts @@ -0,0 +1,27 @@ +import { CLI } from './cli.js' + +export async function handle(args: string[]): Promise { + const argv = ['node', 'herb-lint', ...args] + + const originalArgv = process.argv + process.argv = argv + + try { + const linter = new CLI() + await linter.run() + } finally { + process.argv = originalArgv + } +} + +export function helpInfo() { + return { + description: 'Lint HTML+ERB templates for errors and best practices', + usage: [ + 'herb lint [pattern] [options]', + 'herb lint index.html.erb', + 'herb lint "templates/**/*.html.erb"', + 'herb lint --fix', + ] + } +} diff --git a/javascript/packages/cli/src/commands/lsp/cli.ts b/javascript/packages/cli/src/commands/lsp/cli.ts new file mode 100644 index 000000000..49597e085 --- /dev/null +++ b/javascript/packages/cli/src/commands/lsp/cli.ts @@ -0,0 +1,23 @@ +import { Server } from "@herb-tools/language-server" + +export class CLI { + private usage = ` + Usage: herb-language-server [options] + + Options: + --stdio use stdio + --node-ipc use node-ipc + --socket= use socket +` + + run() { + if (process.argv.length <= 2) { + console.error(`Error: Connection input stream is not set. Set command line parameters: '--node-ipc', '--stdio' or '--socket='`) + console.error(this.usage) + process.exit(1) + } + + const server = new Server() + server.listen() + } +} diff --git a/javascript/packages/cli/src/commands/lsp/index.ts b/javascript/packages/cli/src/commands/lsp/index.ts new file mode 100644 index 000000000..3e4f3fd9d --- /dev/null +++ b/javascript/packages/cli/src/commands/lsp/index.ts @@ -0,0 +1,25 @@ +import { CLI } from './cli.js' + +export async function handle(args: string[]): Promise { + const argv = ['node', 'herb-language-server', ...args] + const originalArgv = process.argv + process.argv = argv + + try { + const server = new CLI() + server.run() + } finally { + process.argv = originalArgv + } +} + +export function helpInfo() { + return { + description: 'Start the Herb language server', + usage: [ + 'herb lsp --stdio', + 'herb lsp --node-ipc', + 'herb lsp --socket=6009', + ] + } +} diff --git a/javascript/packages/cli/src/commands/parse/cli.ts b/javascript/packages/cli/src/commands/parse/cli.ts new file mode 100644 index 000000000..46adccd99 --- /dev/null +++ b/javascript/packages/cli/src/commands/parse/cli.ts @@ -0,0 +1,126 @@ +import dedent from "dedent" +import pc from "picocolors" + +import * as fs from "fs/promises" +import { Herb } from "@herb-tools/node-wasm" + +export class CLI { + async run() { + const args = process.argv.slice(2) + + if (args[0] === "parse") { + args.shift() + } + + if (args.includes("--help") || args.includes("-h")) { + this.showHelp() + return + } + + if (args.includes("--version") || args.includes("-v")) { + console.log((await import("@herb-tools/cli/package.json")).version) + return + } + + const nonOptionArgs = args.filter(arg => !arg.startsWith("-")) + const inputFile = nonOptionArgs[0] + + let input: string + let fromStdin = false + + if (inputFile && inputFile !== "-") { + try { + input = await fs.readFile(inputFile, "utf-8") + } catch (error) { + console.error(pc.red(`✗ Error reading file: ${inputFile}`)) + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } + } else if (process.stdin.isTTY) { + console.log(pc.yellow("⚠ No input provided")) + console.log() + this.showHelp() + process.exit(1) + } else { + fromStdin = true + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk) + } + input = Buffer.concat(chunks).toString("utf-8") + } + + if (!input || input.trim().length === 0) { + console.error(pc.red("✗ No input provided")) + console.error(`Run ${pc.cyan("herb parse --help")} for usage information`) + process.exit(1) + } + + try { + await Herb.load() + + const trackWhitespace = args.includes("--track-whitespace") || args.includes("-w") + const json = args.includes("--json") || args.includes("-j") + const pretty = args.includes("--pretty") || args.includes("-p") + + const parseResult = Herb.parse(input, { track_whitespace: trackWhitespace }) + + if (parseResult.errors.length > 0) { + console.error(pc.red("✗ Parse errors:")) + for (const error of parseResult.errors) { + console.error(` ${error.message}`) + } + process.exit(1) + } + + if (json) { + if (pretty) { + console.log(JSON.stringify(parseResult.value, null, 2)) + } else { + console.log(JSON.stringify(parseResult.value)) + } + } else { + console.log(parseResult.value.inspect()) + } + + } catch (error) { + console.error(pc.red("✗ Error parsing:")) + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } + } + + private showHelp() { + console.log(dedent` + Usage: herb parse [file] [options] + + Parse HTML+ERB templates and output the Abstract Syntax Tree (AST). + + Arguments: + file File containing template to parse, or '-' for stdin (defaults to stdin) + + Options: + -h, --help Show this help message + -v, --version Show version information + -w, --track-whitespace Track whitespace nodes in the AST + -j, --json Output as JSON (default is tree inspect format) + -p, --pretty Pretty print JSON output (only with --json) + + Examples: + # Parse from stdin (tree inspect format) + echo "
<%= @user.name %>
" | herb parse + + # Parse from a file + herb parse app/views/users/show.html.erb + + # Parse with whitespace tracking + herb parse app/views/users/show.html.erb --track-whitespace + + # Output as JSON + herb parse app/views/users/show.html.erb --json + + # Output as pretty JSON + herb parse app/views/users/show.html.erb --json --pretty + `) + } +} diff --git a/javascript/packages/cli/src/commands/parse/index.ts b/javascript/packages/cli/src/commands/parse/index.ts new file mode 100644 index 000000000..03e18b46a --- /dev/null +++ b/javascript/packages/cli/src/commands/parse/index.ts @@ -0,0 +1,6 @@ +import { CLI } from "./cli.js" + +export async function handle() { + const cli = new CLI() + await cli.run() +} diff --git a/javascript/packages/cli/src/commands/playground/cli.ts b/javascript/packages/cli/src/commands/playground/cli.ts new file mode 100644 index 000000000..3153aa3f2 --- /dev/null +++ b/javascript/packages/cli/src/commands/playground/cli.ts @@ -0,0 +1,328 @@ +import dedent from "dedent" +import pc from "picocolors" + +import * as fs from "fs/promises" +import * as path from "path" +import * as http from "http" + +let compressToEncodedURIComponent: (string: string) => string + +async function loadLZString() { + if (!compressToEncodedURIComponent) { + const LZString = await import("lz-string") + compressToEncodedURIComponent = LZString.default.compressToEncodedURIComponent || LZString.compressToEncodedURIComponent + } +} + +const PLAYGROUND_BASE_URL = "https://herb-tools.dev/playground" + +export class CLI { + async run() { + const args = process.argv.slice(2) + + if (args[0] === "playground") { + args.shift() + } + + const subcommand = args[0] + + if (subcommand === "start") { + await this.startServer(args.slice(1)) + return + } + + if (args.includes("--help") || args.includes("-h")) { + this.showHelp() + return + } + + if (args.includes("--version") || args.includes("-v")) { + console.log((await import("@herb-tools/cli/package.json")).version) + return + } + + const nonOptionArgs = args.filter(arg => !arg.startsWith("-")) + const inputFile = nonOptionArgs[0] + + let input: string + let fromStdin = false + + if (inputFile && inputFile !== "-") { + // Read from file + try { + input = await fs.readFile(inputFile, "utf-8") + } catch (error) { + console.error(pc.red(`✗ Error reading file: ${inputFile}`)) + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } + } else if (process.stdin.isTTY) { + console.log(pc.yellow("⚠ No input provided")) + console.log() + + this.showHelp() + + process.exit(1) + } else { + fromStdin = true + + const chunks: Buffer[] = [] + + for await (const chunk of process.stdin) { + chunks.push(chunk) + } + + input = Buffer.concat(chunks).toString("utf-8") + } + + if (!input || input.trim().length === 0) { + console.error(pc.red("✗ No input provided")) + console.error(`Run ${pc.cyan("herb playground --help")} for usage information`) + process.exit(1) + } + + try { + await loadLZString() + + const compressed = compressToEncodedURIComponent(input) + const playgroundUrl = `${PLAYGROUND_BASE_URL}#${compressed}` + + if (inputFile && inputFile !== "-") { + console.log(pc.green("✓") + " Generated playground URL from " + pc.cyan(inputFile)) + } else if (fromStdin) { + console.log(pc.green("✓") + " Generated playground URL from stdin") + } + + console.log() + console.log(pc.dim("Share this URL to share your template:")) + console.log(pc.cyan(playgroundUrl)) + + if (args.includes("--open") || args.includes("-o")) { + console.log() + + const { default: open } = await import("open") + await open(playgroundUrl) + + console.log(pc.green("✓") + " Opened playground in your browser") + } + } catch (error) { + console.error(pc.red("✗ Error creating playground URL:")) + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } + } + + private async startServer(args: string[]) { + try { + const cwd = process.cwd() + const moduleDir = path.dirname(new URL(import.meta.url).pathname) + + const possiblePaths = [ + path.join(moduleDir, "../../playground"), + path.resolve(moduleDir, "../../playground"), + path.join(cwd, "playground/dist"), + path.join(cwd, "../playground/dist"), + path.join(cwd, "../../playground/dist"), + path.join(cwd, "../../../playground/dist"), + ] + + let playgroundDistPath: string | null = null + + for (const tryPath of possiblePaths) { + try { + await fs.access(tryPath) + playgroundDistPath = tryPath + break + } catch { + // Continue + } + } + + if (!playgroundDistPath) { + console.error(pc.red("✗ Playground build not found")) + console.error(`Searched in the following locations:`) + + for (const tryPath of possiblePaths) { + console.error(` - ${tryPath}`) + } + + console.error() + console.error(`The playground needs to be built first.`) + console.error(`Run: ${pc.cyan("cd playground && yarn build")}`) + process.exit(1) + } + + const portArg = args.find(arg => arg.startsWith("--port=")) + const port = portArg ? parseInt(portArg.split("=")[1]) : 5173 + + const hostArg = args.find(arg => arg.startsWith("--host=")) + const host = hostArg ? hostArg.split("=")[1] : "localhost" + + const server = http.createServer(async (req, res) => { + try { + let filePath = req.url === "/" ? "/index.html" : req.url + + const queryIndex = filePath.indexOf("?") + + if (queryIndex !== -1) { + filePath = filePath.substring(0, queryIndex) + } + + const hashIndex = filePath.indexOf("#") + + if (hashIndex !== -1) { + filePath = filePath.substring(0, hashIndex) + } + + const fullPath = path.join(playgroundDistPath, filePath) + const normalizedPath = path.normalize(fullPath) + + if (!normalizedPath.startsWith(playgroundDistPath)) { + res.writeHead(403, { "Content-Type": "text/plain" }) + res.end("Forbidden") + return + } + + try { + const stats = await fs.stat(normalizedPath) + + if (stats.isDirectory()) { + const indexPath = path.join(normalizedPath, "index.html") + const content = await fs.readFile(indexPath) + + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(content) + + return + } + + const content = await fs.readFile(normalizedPath) + + const ext = path.extname(normalizedPath).toLowerCase() + const contentTypes: Record = { + ".html": "text/html", + ".js": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".ttf": "font/ttf", + ".woff": "font/woff", + ".woff2": "font/woff2", + } + + const contentType = contentTypes[ext] || "application/octet-stream" + res.writeHead(200, { "Content-Type": contentType }) + res.end(content) + } catch (error) { + try { + const indexPath = path.join(playgroundDistPath, "index.html") + const content = await fs.readFile(indexPath) + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(content) + } catch { + res.writeHead(404, { "Content-Type": "text/plain" }) + res.end("Not found") + } + } + } catch (error) { + console.error(pc.red("✗ Error serving file:"), error) + res.writeHead(500, { "Content-Type": "text/plain" }) + res.end("Internal server error") + } + }) + + server.listen(port, host, () => { + console.log(pc.green("✓") + " Playground server started") + console.log() + console.log(` ${pc.dim("Local:")} ${pc.cyan(`http://${host}:${port}`)}`) + + if (host === "localhost" || host === "127.0.0.1") { + try { + const os = require("os") + const networkInterfaces = os.networkInterfaces() + const addresses: string[] = [] + + for (const name of Object.keys(networkInterfaces)) { + for (const net of networkInterfaces[name]) { + if (net.family === "IPv4" && !net.internal) { + addresses.push(net.address) + } + } + } + + if (addresses.length > 0) { + console.log(` ${pc.dim("Network:")} ${pc.cyan(`http://${addresses[0]}:${port}`)}`) + } + } catch (error) { + // Ignore + } + } + + console.log() + console.log(pc.dim("Press Ctrl+C to stop the server")) + }) + + process.on("SIGINT", () => { + console.log() + console.log(pc.yellow("⚠ Shutting down server...")) + server.close(() => { + console.log(pc.green("✓") + " Server stopped") + process.exit(0) + }) + }) + + process.on("SIGTERM", () => { + server.close(() => { + process.exit(0) + }) + }) + + } catch (error) { + console.error(pc.red("✗ Error starting server:")) + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } + } + + private showHelp() { + console.log(dedent` + Usage: herb playground [command] [file] [options] + + Commands: + start Start a local playground server + + Generate a shareable Herb playground URL with your template content. + + Arguments: + file File containing template to open in playground, or '-' for stdin (defaults to stdin) + + Options: + -h, --help Show this help message + -v, --version Show version information + -o, --open Open the playground URL in your browser + --port= Port to run server on (default: 5173) [start command only] + --host= Host to bind server to (default: localhost) [start command only] + + Examples: + # Start local playground server + herb playground start + + # Start on custom port + herb playground start --port=8080 + + # Create playground URL from stdin + echo "
<%= @user.name %>
" | herb playground + + # Create playground URL from a file + herb playground app/views/users/show.html.erb + + # Create and open in browser + herb playground app/views/users/show.html.erb --open + `) + } +} diff --git a/javascript/packages/cli/src/commands/playground/index.ts b/javascript/packages/cli/src/commands/playground/index.ts new file mode 100644 index 000000000..03e18b46a --- /dev/null +++ b/javascript/packages/cli/src/commands/playground/index.ts @@ -0,0 +1,6 @@ +import { CLI } from "./cli.js" + +export async function handle() { + const cli = new CLI() + await cli.run() +} diff --git a/javascript/packages/cli/src/commands/print/cli.ts b/javascript/packages/cli/src/commands/print/cli.ts new file mode 100644 index 000000000..955527a46 --- /dev/null +++ b/javascript/packages/cli/src/commands/print/cli.ts @@ -0,0 +1,268 @@ +#!/usr/bin/env node + +import dedent from "dedent" + +import { readFileSync, writeFileSync, existsSync } from "fs" +import { resolve } from "path" +import { glob } from "glob" + +import { Herb } from "@herb-tools/node-wasm" +import { Config } from "@herb-tools/config" + +import { IdentityPrinter } from "@herb-tools/printer" +import { version } from "../../../package.json" + +interface CLIOptions { + input?: string + output?: string + configFile?: string + verify?: boolean + stats?: boolean + help?: boolean + glob?: boolean + force?: boolean +} + +export class CLI { + private parseArgs(args: string[]): CLIOptions { + const options: CLIOptions = {} + + for (let i = 2; i < args.length; i++) { + const arg = args[i] + + switch (arg) { + case '-i': + case '--input': + options.input = args[++i] + break + case '-o': + case '--output': + options.output = args[++i] + break + case '--config-file': + options.configFile = args[++i] + break + case '--verify': + options.verify = true + break + case '--stats': + options.stats = true + break + case '--glob': + options.glob = true + break + case '--force': + options.force = true + break + case '-h': + case '--help': + options.help = true + break + default: + if (!arg.startsWith('-') && !options.input) { + options.input = arg + } + } + } + + return options + } + + private showHelp() { + console.log(dedent` + herb-print - Print HTML+ERB AST back to source code + + This tool parses HTML+ERB templates and prints them back, preserving the original + formatting as closely as possible. Useful for testing parser accuracy and as a + baseline for other transformations. + + Usage: + herb-print [options] + herb-print -i -o + + Options: + -i, --input Input file path + -o, --output Output file path (defaults to stdout) + --config-file Explicitly specify path to .herb.yml config file + --verify Verify that output matches input exactly + --stats Show parsing and printing statistics + --glob Treat input as glob pattern + --force Process files even if excluded by configuration + -h, --help Show this help message + + Examples: + # Single file + herb-print input.html.erb > output.html.erb + herb-print -i input.html.erb -o output.html.erb --verify + herb-print input.html.erb --stats + + # Glob patterns (batch verification) + herb-print --glob --verify # All .html.erb files + herb-print "app/views/**/*.html.erb" --glob --verify --stats + herb-print "*.erb" --glob --verify + herb-print "/path/to/templates" --glob --verify # Directory + herb-print "/path/to/templates/**/*.html.erb" --glob --verify + + # The --verify flag is useful to test parser fidelity: + herb-print input.html.erb --verify + # Checks if parsing and printing results in identical content + `) + } + + async run() { + const options = this.parseArgs(process.argv) + + if (options.help || (!options.input && !options.glob)) { + this.showHelp() + process.exit(0) + } + + try { + await Herb.load() + + const startPath = options.input || process.cwd() + const config = await Config.loadForCLI(options.configFile || startPath, version, true) + + if (options.glob) { + + let files: string[] + if (options.input) { + const filesConfig = config.getFilesConfigForTool('linter') + files = await glob(options.input, { ignore: filesConfig.exclude || [] }) + } else { + files = await config.findFilesForTool('linter', startPath) + } + + if (files.length === 0) { + const patternDesc = options.input || 'configured patterns' + console.error(`No files found matching: ${patternDesc}`) + process.exit(1) + } + + let totalFiles = 0 + let failedFiles = 0 + let verificationFailures = 0 + let totalBytes = 0 + + console.log(`Processing ${files.length} files...\n`) + + for (const file of files) { + try { + const input = readFileSync(file, 'utf-8') + const parseResult = Herb.parse(input, { track_whitespace: true }) + + if (parseResult.errors.length > 0) { + console.error(`\x1b[31m✗\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[1m\x1b[31mFailed\x1b[0m to parse`) + failedFiles++ + continue + } + + const printer = new IdentityPrinter() + const output = printer.print(parseResult.value) + + totalFiles++ + totalBytes += input.length + + if (options.verify) { + if (input === output) { + console.log(`\x1b[32m✓\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[32mPerfect match\x1b[0m`) + } else { + console.error(`\x1b[31m✗\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[1m\x1b[31mVerification failed\x1b[0m - differences detected`) + verificationFailures++ + } + } else { + console.log(`\x1b[32m✓\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[32mProcessed\x1b[0m`) + } + + } catch (error) { + console.error(`\x1b[31m✗\x1b[0m \x1b[1m${file}\x1b[0m: \x1b[1m\x1b[31mError\x1b[0m - ${error}`) + failedFiles++ + } + } + + console.log(`\nSummary:`) + console.log(` Files processed: ${totalFiles}`) + console.log(` Files failed: ${failedFiles}`) + + if (options.verify) { + console.log(` Verifications: ${totalFiles - verificationFailures} passed, ${verificationFailures} failed`) + } + + if (options.stats) { + console.log(` Total bytes: ${totalBytes}`) + } + + process.exit(failedFiles > 0 || verificationFailures > 0 ? 1 : 0) + + } else { + const inputPath = resolve(options.input!) + + const filesConfig = config.getFilesConfigForTool('linter') + const testFiles = await glob(options.input!, { + cwd: process.cwd(), + ignore: filesConfig.exclude || [] + }) + + if (testFiles.length === 0 && existsSync(inputPath)) { + if (!options.force) { + console.error(`⚠️ File ${options.input} is excluded by configuration patterns.`) + console.error(` Use --force to print it anyway.\n`) + + process.exit(0) + } else { + console.error(`⚠️ Forcing printer on excluded file: ${options.input}`) + console.error() + } + } + + const input = readFileSync(inputPath, 'utf-8') + + const parseResult = Herb.parse(input, { track_whitespace: true }) + + if (parseResult.errors.length > 0) { + console.error('Parse errors:', parseResult.errors.map(e => e.message).join(', ')) + process.exit(1) + } + + const printer = new IdentityPrinter() + const output = printer.print(parseResult.value) + + if (options.output) { + const outputPath = resolve(options.output) + writeFileSync(outputPath, output, 'utf-8') + + console.log(`Output written to: ${outputPath}`) + } else { + console.log(output) + } + + if (options.verify) { + if (input === output) { + console.error('\x1b[32m✓ Verification passed\x1b[0m - output matches input exactly') + } else { + console.error('\x1b[31m✗ Verification failed\x1b[0m - output differs from input') + process.exit(1) + } + } + + if (options.stats) { + const errors = parseResult.errors?.length || 0 + const warnings = parseResult.warnings?.length || 0 + + console.error(dedent` + Printing Statistics: + Input size: ${input.length} bytes + Output size: ${output.length} bytes + Parse errors: ${errors} + Parse warnings: ${warnings} + Round-trip: ${input === output ? 'Perfect' : 'Differences detected'} + `) + } + } + + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error) + process.exit(1) + } + } +} diff --git a/javascript/packages/cli/src/commands/print/index.ts b/javascript/packages/cli/src/commands/print/index.ts new file mode 100644 index 000000000..f18230ffa --- /dev/null +++ b/javascript/packages/cli/src/commands/print/index.ts @@ -0,0 +1,26 @@ +import { CLI } from './cli.js' + +export async function handle(args: string[]): Promise { + const argv = ['node', 'herb-print', ...args] + const originalArgv = process.argv + process.argv = argv + + try { + const printer = new CLI() + await printer.run() + } finally { + process.argv = originalArgv + } +} + +export function helpInfo() { + return { + description: 'Print HTML+ERB AST back to source code', + usage: [ + 'herb print [input] [options]', + 'herb print input.html.erb', + 'herb print -i input.html.erb -o output.html.erb', + 'herb print --glob --verify "app/views/**/*.html.erb"', + ] + } +} diff --git a/javascript/packages/cli/src/index.ts b/javascript/packages/cli/src/index.ts new file mode 100644 index 000000000..a3b7a8fef --- /dev/null +++ b/javascript/packages/cli/src/index.ts @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +import { Herb } from '@herb-tools/node-wasm' +import { args, type Arg } from './utils/args.js' +import { eprintln, println } from './utils/renderer.js' +import { help } from './commands/help/index.js' + +declare const __VERSION__: string +const version = __VERSION__ + +const sharedOptions = { + '--help': { + type: 'boolean', + description: 'Display usage information', + alias: '-h' + }, + '--version': { + type: 'boolean', + description: 'Display version information', + alias: '-v' + }, +} satisfies Arg + +const commandDescriptions: Record = { + lint: 'Lint HTML+ERB templates for errors and best practices', + format: 'Format HTML+ERB templates', + parse: 'Parse HTML+ERB templates and output the AST', + lex: 'Tokenize HTML+ERB templates and output the token stream', + print: 'Print HTML+ERB AST back to source code', + highlight: 'Highlight HTML+ERB templates with syntax highlighting', + lsp: 'Start the Herb language server', + config: 'Manage Herb configuration files', + playground: 'Open templates in the Herb playground', +} + +async function main() { + const rawArgs = process.argv.slice(2) + const command = rawArgs[0] + + if (!command || command.startsWith('-')) { + const flags = args(sharedOptions) + + if (flags['--version']) { + await Herb.load() + println(`herb v${version}`) + println() + println(`Tools:`) + println(` @herb-tools/cli@${version}`) + println(` @herb-tools/linter@${version}`) + println(` @herb-tools/formatter@${version}`) + println(` ${Herb.version}`.split(', ').join('\n ')) + process.exit(0) + } + + help({ + usage: ['herb [options]'], + commands: commandDescriptions, + options: sharedOptions, + }) + process.exit(0) + } + + const commandHandlers: Record Promise> = { + lint: () => import('./commands/lint/index.js'), + format: () => import('./commands/format/index.js'), + parse: () => import('./commands/parse/index.js'), + lex: () => import('./commands/lex/index.js'), + print: () => import('./commands/print/index.js'), + highlight: () => import('./commands/highlight/index.js'), + lsp: () => import('./commands/lsp/index.js'), + config: () => import('./commands/config/index.js'), + playground: () => import('./commands/playground/index.js'), + } + + if (command in commandHandlers) { + const cmd = await commandHandlers[command as keyof typeof commandHandlers]() + await cmd.handle(rawArgs.slice(1)) + } else { + help({ + invalid: command, + usage: ['herb [options]'], + commands: commandDescriptions, + options: sharedOptions, + }) + process.exit(1) + } +} + +main().catch((error) => { + eprintln(`Error: ${error instanceof Error ? error.message : error}`) + process.exit(1) +}) diff --git a/javascript/packages/cli/src/types.ts b/javascript/packages/cli/src/types.ts new file mode 100644 index 000000000..281046bcc --- /dev/null +++ b/javascript/packages/cli/src/types.ts @@ -0,0 +1,18 @@ +import type { Arg } from './utils/args.js' + +export interface Command { + /** + * Returns the argument definitions for this command + */ + options(): Arg + + /** + * Handles the execution of the command + */ + handle(args: string[]): Promise + + /** + * Returns help information for this command + */ + helpInfo(): { description: string; usage: string[] } +} diff --git a/javascript/packages/cli/src/utils/args.ts b/javascript/packages/cli/src/utils/args.ts new file mode 100644 index 000000000..7d4902ec5 --- /dev/null +++ b/javascript/packages/cli/src/utils/args.ts @@ -0,0 +1,128 @@ +import parse from 'mri' + +export type Arg = { + [key: `--${string}`]: { + type: keyof Types + description: string + alias?: `-${string}` + default?: Types[keyof Types] + values?: string[] + } +} + +type Types = { + boolean: boolean + number: number | null + string: string | null + 'boolean | string': boolean | string | null + 'number | string': number | string | null + 'boolean | number': boolean | number | null + 'boolean | number | string': boolean | number | string | null +} + +export type Result = { + [K in keyof T]: T[K] extends { type: keyof Types; default?: any } + ? undefined extends T[K]['default'] + ? Types[T[K]['type']] + : NonNullable + : never +} & { + _: string[] +} + +export function args(options: T, argv = process.argv.slice(2)): Result { + for (let [idx, value] of argv.entries()) { + if (value === '-') { + argv[idx] = '__IO_DEFAULT_VALUE__' + } + } + + let parsed = parse(argv) + + for (let key in parsed) { + if (parsed[key] === '__IO_DEFAULT_VALUE__') { + parsed[key] = '-' + } + } + + let result: { _: string[]; [key: string]: unknown } = { + _: parsed._, + } + + for (let [ + flag, + { type, alias, default: defaultValue = type === 'boolean' ? false : null }, + ] of Object.entries(options)) { + result[flag] = defaultValue + + if (alias) { + let key = alias.slice(1) + if (parsed[key] !== undefined) { + result[flag] = convert(parsed[key], type) + } + } + + { + let key = flag.slice(2) + if (parsed[key] !== undefined) { + result[flag] = convert(parsed[key], type) + } + } + } + + return result as Result +} + +type ArgumentType = string | boolean + +function convert(value: string | boolean, type: T) { + switch (type) { + case 'string': + return convertString(value) + case 'boolean': + return convertBoolean(value) + case 'number': + return convertNumber(value) + case 'boolean | string': + return convertBoolean(value) ?? convertString(value) + case 'number | string': + return convertNumber(value) ?? convertString(value) + case 'boolean | number': + return convertBoolean(value) ?? convertNumber(value) + case 'boolean | number | string': + return convertBoolean(value) ?? convertNumber(value) ?? convertString(value) + default: + throw new Error(`Unhandled type: ${type}`) + } +} + +function convertBoolean(value: ArgumentType) { + if (value === true || value === false) { + return value + } + + if (value === 'true') { + return true + } + + if (value === 'false') { + return false + } +} + +function convertNumber(value: ArgumentType) { + if (typeof value === 'number') { + return value + } + + { + let valueAsNumber = Number(value) + if (!Number.isNaN(valueAsNumber)) { + return valueAsNumber + } + } +} + +function convertString(value: ArgumentType) { + return `${value}` +} diff --git a/javascript/packages/cli/src/utils/format-ns.ts b/javascript/packages/cli/src/utils/format-ns.ts new file mode 100644 index 000000000..39889d401 --- /dev/null +++ b/javascript/packages/cli/src/utils/format-ns.ts @@ -0,0 +1,23 @@ +export function formatNanoseconds(input: bigint | number) { + let ns = typeof input === 'number' ? BigInt(input) : input + + if (ns < 1_000n) return `${ns}ns` + ns /= 1_000n + + if (ns < 1_000n) return `${ns}µs` + ns /= 1_000n + + if (ns < 1_000n) return `${ns}ms` + ns /= 1_000n + + if (ns < 60n) return `${ns}s` + ns /= 60n + + if (ns < 60n) return `${ns}m` + ns /= 60n + + if (ns < 24n) return `${ns}h` + ns /= 24n + + return `${ns}d` +} diff --git a/javascript/packages/cli/src/utils/renderer.ts b/javascript/packages/cli/src/utils/renderer.ts new file mode 100644 index 000000000..55a584c34 --- /dev/null +++ b/javascript/packages/cli/src/utils/renderer.ts @@ -0,0 +1,98 @@ +import pc from 'picocolors' + +import { stripVTControlCharacters } from 'node:util' +import { formatNanoseconds } from './format-ns.js' + +export const UI = { + indent: 2, +} + +export function header() { + return `${pc.bold(pc.green('herb'))} ${pc.green(`v${getVersion()}`)}` +} + +export function highlight(text: string) { + return `${pc.dim(pc.blue('`'))}${pc.blue(text)}${pc.dim(pc.blue('`'))}` +} + +/** + * Convert an `absolute` path to a `relative` path from the current working + * directory. + */ +export function relative( + to: string, + from = process.cwd(), + { preferAbsoluteIfShorter = true } = {}, +) { + let result = path.relative(from, to) + if (!result.startsWith('..')) { + result = `.${path.sep}${result}` + } + + if (preferAbsoluteIfShorter && result.length > to.length) { + return to + } + + return result +} + +/** + * Wrap `text` into multiple lines based on the `width`. + */ +export function wordWrap(text: string, width: number) { + let words = text.split(' ') + let lines = [] + + let line = '' + let lineLength = 0 + + for (let word of words) { + let wordLength = stripVTControlCharacters(word).length + + if (lineLength + wordLength + 1 > width) { + lines.push(line) + line = '' + lineLength = 0 + } + + line += (lineLength ? ' ' : '') + word + lineLength += wordLength + (lineLength ? 1 : 0) + } + + if (lineLength) { + lines.push(line) + } + + return lines +} + +/** + * Format a duration in nanoseconds to a more human readable format. + */ +export function formatDuration(ns: bigint) { + let formatted = formatNanoseconds(ns) + + if (ns <= 50 * 1e6) return pc.green(formatted) + if (ns <= 300 * 1e6) return pc.blue(formatted) + if (ns <= 1000 * 1e6) return pc.yellow(formatted) + + return pc.red(formatted) +} + +export function indent(value: string, offset = 0) { + return `${' '.repeat(offset + UI.indent)}${value}` +} + +export function eprintln(value = '') { + process.stderr.write(`${value}\n`) +} + +export function println(value = '') { + process.stdout.write(`${value}\n`) +} + +declare const __VERSION__: string + +function getVersion(): string { + return __VERSION__ +} diff --git a/javascript/packages/cli/tsconfig.json b/javascript/packages/cli/tsconfig.json new file mode 100644 index 000000000..b7ae169dd --- /dev/null +++ b/javascript/packages/cli/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "declaration": true, + "declarationDir": "./dist/types", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/javascript/packages/cli/tsup.config.ts b/javascript/packages/cli/tsup.config.ts new file mode 100644 index 000000000..42899da71 --- /dev/null +++ b/javascript/packages/cli/tsup.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "tsup" +import { readFileSync } from "fs" +import { fileURLToPath } from "url" +import { dirname, join } from "path" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const packageJson = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8")) + +export default defineConfig({ + format: ["esm"], + clean: true, + minify: true, + entry: ["src/index.ts"], + shims: true, + banner: { + js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);" + }, + define: { + "__VERSION__": JSON.stringify(packageJson.version) + } +}) diff --git a/javascript/packages/config/package.json b/javascript/packages/config/package.json index 2ab6e7de2..724a75b56 100644 --- a/javascript/packages/config/package.json +++ b/javascript/packages/config/package.json @@ -11,7 +11,7 @@ "directory": "javascript/packages/config" }, "main": "./dist/herb-config.cjs", - "module": "./dist/herb-config.esm.js", + "module": "./dist/herb-config.esm.mjs", "types": "./dist/types/index.d.ts", "scripts": { "clean": "rimraf dist", @@ -24,9 +24,9 @@ "./package.json": "./package.json", ".": { "types": "./dist/types/index.d.ts", - "import": "./dist/herb-config.esm.js", + "import": "./dist/herb-config.esm.mjs", "require": "./dist/herb-config.cjs", - "default": "./dist/herb-config.esm.js" + "default": "./dist/herb-config.esm.mjs" } }, "dependencies": { diff --git a/javascript/packages/config/rollup.config.mjs b/javascript/packages/config/rollup.config.mjs index 3a44042ab..7601cc51d 100644 --- a/javascript/packages/config/rollup.config.mjs +++ b/javascript/packages/config/rollup.config.mjs @@ -8,7 +8,7 @@ export default [ { input: "src/index.ts", output: { - file: "dist/herb-config.esm.js", + file: "dist/herb-config.esm.mjs", format: "esm", sourcemap: true, }, diff --git a/javascript/packages/core/package.json b/javascript/packages/core/package.json index 81b3c4a00..a438c3c0f 100644 --- a/javascript/packages/core/package.json +++ b/javascript/packages/core/package.json @@ -11,7 +11,7 @@ "directory": "javascript/packages/core" }, "main": "./dist/herb-core.cjs", - "module": "./dist/herb-core.esm.js", + "module": "./dist/herb-core.esm.mjs", "types": "./dist/types/index.d.ts", "scripts": { "build": "yarn clean && rollup -c", @@ -24,9 +24,9 @@ "./package.json": "./package.json", ".": { "types": "./dist/types/index.d.ts", - "import": "./dist/herb-core.esm.js", + "import": "./dist/herb-core.esm.mjs", "require": "./dist/herb-core.cjs", - "default": "./dist/herb-core.esm.js" + "default": "./dist/herb-core.esm.mjs" } }, "dependencies": {}, diff --git a/javascript/packages/core/rollup.config.mjs b/javascript/packages/core/rollup.config.mjs index fc39ec751..aad77e7c4 100644 --- a/javascript/packages/core/rollup.config.mjs +++ b/javascript/packages/core/rollup.config.mjs @@ -6,7 +6,7 @@ export default [ { input: "src/index.ts", output: { - file: "dist/herb-core.esm.js", + file: "dist/herb-core.esm.mjs", format: "esm", sourcemap: true, }, @@ -44,7 +44,7 @@ export default [ { input: "src/index.ts", output: { - file: "dist/herb-core.browser.js", + file: "dist/herb-core.browser.mjs", format: "esm", sourcemap: true, }, @@ -61,7 +61,7 @@ export default [ { input: "src/index.ts", output: { - file: "dist/herb-core.umd.js", + file: "dist/herb-core.umd.mjs", format: "umd", name: "Herb", sourcemap: true, diff --git a/javascript/packages/formatter/package.json b/javascript/packages/formatter/package.json index cb0725f30..2a3e85a0b 100644 --- a/javascript/packages/formatter/package.json +++ b/javascript/packages/formatter/package.json @@ -11,7 +11,7 @@ "directory": "javascript/packages/formatter" }, "main": "./dist/index.cjs", - "module": "./dist/index.esm.js", + "module": "./dist/index.esm.mjs", "require": "./dist/index.cjs", "types": "./dist/types/index.d.ts", "bin": { @@ -29,9 +29,9 @@ "./package.json": "./package.json", ".": { "types": "./dist/types/index.d.ts", - "import": "./dist/index.esm.js", + "import": "./dist/index.esm.mjs", "require": "./dist/index.cjs", - "default": "./dist/index.esm.js" + "default": "./dist/index.esm.mjs" } }, "dependencies": { diff --git a/javascript/packages/formatter/rollup.config.mjs b/javascript/packages/formatter/rollup.config.mjs index b22d7bcba..bc2041b17 100644 --- a/javascript/packages/formatter/rollup.config.mjs +++ b/javascript/packages/formatter/rollup.config.mjs @@ -23,7 +23,7 @@ export default [ { input: "src/herb-format.ts", output: { - file: "dist/herb-format.js", + file: "dist/herb-format.mjs", format: "cjs", sourcemap: true, }, @@ -42,7 +42,7 @@ export default [ { input: "src/index.ts", output: { - file: "dist/index.esm.js", + file: "dist/index.esm.mjs", format: "esm", sourcemap: true, }, diff --git a/javascript/packages/highlighter/package.json b/javascript/packages/highlighter/package.json index f2ca8b2e9..d9fdc7482 100644 --- a/javascript/packages/highlighter/package.json +++ b/javascript/packages/highlighter/package.json @@ -14,16 +14,16 @@ "herb-highlight": "./bin/herb-highlight" }, "main": "./dist/index.js", - "module": "./dist/index.js", + "module": "./dist/index.mjs", "require": "./dist/index.cjs", "types": "./dist/types/index.d.ts", "exports": { "./package.json": "./package.json", ".": { "types": "./dist/types/index.d.ts", - "import": "./dist/index.js", + "import": "./dist/index.mjs", "require": "./dist/index.cjs", - "default": "./dist/index.js" + "default": "./dist/index.mjs" } }, "scripts": { diff --git a/javascript/packages/highlighter/rollup.config.mjs b/javascript/packages/highlighter/rollup.config.mjs index 832d61f75..ff02b6908 100644 --- a/javascript/packages/highlighter/rollup.config.mjs +++ b/javascript/packages/highlighter/rollup.config.mjs @@ -19,7 +19,7 @@ export default [ { input: "src/herb-highlight.ts", output: { - file: "dist/herb-highlight.js", + file: "dist/herb-highlight.mjs", format: "cjs", sourcemap: true, }, @@ -40,7 +40,7 @@ export default [ { input: "src/index.ts", output: { - file: "dist/index.js", + file: "dist/index.mjs", format: "esm", sourcemap: true, }, diff --git a/javascript/packages/language-server/package.json b/javascript/packages/language-server/package.json index f0aed0ef3..dd7f6b590 100644 --- a/javascript/packages/language-server/package.json +++ b/javascript/packages/language-server/package.json @@ -18,16 +18,16 @@ "herb-language-server": "./bin/herb-language-server" }, "main": "./dist/index.cjs", - "module": "./dist/index.js", + "module": "./dist/index.mjs", "require": "./dist/index.cjs", "types": "./dist/types/index.d.ts", "exports": { "./package.json": "./package.json", ".": { "types": "./dist/types/index.d.ts", - "import": "./dist/index.js", + "import": "./dist/index.mjs", "require": "./dist/index.cjs", - "default": "./dist/index.js" + "default": "./dist/index.mjs" } }, "scripts": { diff --git a/javascript/packages/language-server/rollup.config.mjs b/javascript/packages/language-server/rollup.config.mjs index 5891c85eb..ee91f5f26 100644 --- a/javascript/packages/language-server/rollup.config.mjs +++ b/javascript/packages/language-server/rollup.config.mjs @@ -41,6 +41,29 @@ export default [ ], }, + // Library exports (ESM) + { + input: "src/index.ts", + output: { + file: "dist/index.mjs", + format: "esm", + sourcemap: true, + }, + external: isExternal, + plugins: [ + nodeResolve(), + commonjs(), + json(), + typescript({ + tsconfig: "./tsconfig.json", + declaration: true, + declarationDir: "./dist/types", + rootDir: "src/", + module: "esnext", + }), + ], + }, + // Library exports (CommonJS) { input: "src/index.ts", diff --git a/javascript/packages/linter/package.json b/javascript/packages/linter/package.json index d50f79d10..98c604e93 100644 --- a/javascript/packages/linter/package.json +++ b/javascript/packages/linter/package.json @@ -11,7 +11,7 @@ "directory": "javascript/packages/linter" }, "main": "./dist/index.cjs", - "module": "./dist/index.js", + "module": "./dist/index.mjs", "types": "./dist/types/index.d.ts", "bin": { "herb-lint": "./bin/herb-lint" @@ -28,14 +28,14 @@ "./package.json": "./package.json", ".": { "types": "./dist/types/index.d.ts", - "import": "./dist/index.js", + "import": "./dist/index.mjs", "require": "./dist/index.cjs", - "default": "./dist/index.js" + "default": "./dist/index.mjs" }, "./cli": { "types": "./dist/types/src/cli.d.ts", "require": "./dist/src/cli.js", - "default": "./dist/src/cli.js" + "default": "./dist/src/cli.mjs" } }, "dependencies": { diff --git a/javascript/packages/linter/rollup.config.mjs b/javascript/packages/linter/rollup.config.mjs index 4a21c39c8..da2082016 100644 --- a/javascript/packages/linter/rollup.config.mjs +++ b/javascript/packages/linter/rollup.config.mjs @@ -24,7 +24,7 @@ export default [ { input: "src/herb-lint.ts", output: { - file: "dist/herb-lint.js", + file: "dist/herb-lint.mjs", format: "cjs", sourcemap: true, }, @@ -45,7 +45,7 @@ export default [ { input: "src/index.ts", output: { - file: "dist/index.js", + file: "dist/index.mjs", format: "esm", sourcemap: true, }, diff --git a/javascript/packages/printer/package.json b/javascript/packages/printer/package.json index 08de93261..b2d602604 100644 --- a/javascript/packages/printer/package.json +++ b/javascript/packages/printer/package.json @@ -14,15 +14,15 @@ "herb-print": "./bin/herb-print" }, "main": "./dist/index.cjs", - "module": "./dist/index.js", + "module": "./dist/index.mjs", "types": "./dist/types/index.d.ts", "exports": { "./package.json": "./package.json", ".": { "types": "./dist/types/index.d.ts", - "import": "./dist/index.js", + "import": "./dist/index.mjs", "require": "./dist/index.cjs", - "default": "./dist/index.js" + "default": "./dist/index.mjs" } }, "scripts": { diff --git a/javascript/packages/printer/rollup.config.mjs b/javascript/packages/printer/rollup.config.mjs index cad381670..c633a9717 100644 --- a/javascript/packages/printer/rollup.config.mjs +++ b/javascript/packages/printer/rollup.config.mjs @@ -45,7 +45,7 @@ export default [ { input: "src/index.ts", output: { - file: "dist/index.js", + file: "dist/index.mjs", format: "esm", sourcemap: true, }, diff --git a/javascript/packages/rewriter/package.json b/javascript/packages/rewriter/package.json index 2bf2a7181..95e139eab 100644 --- a/javascript/packages/rewriter/package.json +++ b/javascript/packages/rewriter/package.json @@ -11,7 +11,7 @@ "directory": "javascript/packages/rewriter" }, "main": "./dist/index.cjs", - "module": "./dist/index.esm.js", + "module": "./dist/index.esm.mjs", "require": "./dist/index.cjs", "types": "./dist/types/index.d.ts", "scripts": { @@ -26,15 +26,15 @@ "./package.json": "./package.json", ".": { "types": "./dist/types/index.d.ts", - "import": "./dist/index.esm.js", + "import": "./dist/index.esm.mjs", "require": "./dist/index.cjs", - "default": "./dist/index.esm.js" + "default": "./dist/index.esm.mjs" }, "./loader": { "types": "./dist/types/loader.d.ts", - "import": "./dist/loader.esm.js", + "import": "./dist/loader.esm.mjs", "require": "./dist/loader.cjs", - "default": "./dist/loader.esm.js" + "default": "./dist/loader.esm.mjs" } }, "dependencies": { diff --git a/javascript/packages/rewriter/rollup.config.mjs b/javascript/packages/rewriter/rollup.config.mjs index 36d82cdd4..d2e3be8bb 100644 --- a/javascript/packages/rewriter/rollup.config.mjs +++ b/javascript/packages/rewriter/rollup.config.mjs @@ -23,7 +23,7 @@ export default [ { input: "src/index.ts", output: { - file: "dist/index.esm.js", + file: "dist/index.esm.mjs", format: "esm", sourcemap: true, }, @@ -63,7 +63,7 @@ export default [ { input: "src/loader.ts", output: { - file: "dist/loader.esm.js", + file: "dist/loader.esm.mjs", format: "esm", sourcemap: true, }, diff --git a/javascript/packages/tailwind-class-sorter/package.json b/javascript/packages/tailwind-class-sorter/package.json index d94fed5a7..f48d3c4b3 100644 --- a/javascript/packages/tailwind-class-sorter/package.json +++ b/javascript/packages/tailwind-class-sorter/package.json @@ -5,15 +5,15 @@ "version": "0.7.5", "license": "MIT", "main": "./dist/tailwind-class-sorter.cjs", - "module": "./dist/tailwind-class-sorter.esm.js", + "module": "./dist/tailwind-class-sorter.esm.mjs", "types": "./dist/types/index.d.ts", "exports": { "./package.json": "./package.json", ".": { "types": "./dist/types/index.d.ts", - "import": "./dist/tailwind-class-sorter.esm.js", + "import": "./dist/tailwind-class-sorter.esm.mjs", "require": "./dist/tailwind-class-sorter.cjs", - "default": "./dist/tailwind-class-sorter.esm.js" + "default": "./dist/tailwind-class-sorter.esm.mjs" } }, "files": [ diff --git a/javascript/packages/tailwind-class-sorter/rollup.config.mjs b/javascript/packages/tailwind-class-sorter/rollup.config.mjs index aa09e840c..ad31d20de 100644 --- a/javascript/packages/tailwind-class-sorter/rollup.config.mjs +++ b/javascript/packages/tailwind-class-sorter/rollup.config.mjs @@ -7,11 +7,11 @@ export default [ { input: "src/index.ts", output: { - file: "dist/tailwind-class-sorter.esm.js", + file: "dist/tailwind-class-sorter.esm.mjs", format: "esm", sourcemap: true, }, - external: ["tailwindcss", "tailwindcss/loadConfig", "tailwindcss/resolveConfig", "fs/promises", "path", "url"], + external: ["tailwindcss", "tailwindcss/loadConfig.js", "tailwindcss/resolveConfig.js", "fs/promises", "path", "url"], plugins: [ nodeResolve({ preferBuiltins: true }), commonjs(), @@ -32,7 +32,7 @@ export default [ format: "cjs", sourcemap: true, }, - external: ["tailwindcss", "tailwindcss/loadConfig", "tailwindcss/resolveConfig", "fs/promises", "path", "url"], + external: ["tailwindcss", "tailwindcss/loadConfig.js", "tailwindcss/resolveConfig.js", "fs/promises", "path", "url"], plugins: [ nodeResolve({ preferBuiltins: true }), commonjs(), diff --git a/javascript/packages/tailwind-class-sorter/src/config.ts b/javascript/packages/tailwind-class-sorter/src/config.ts index 722e2a860..6b8f4dd6e 100644 --- a/javascript/packages/tailwind-class-sorter/src/config.ts +++ b/javascript/packages/tailwind-class-sorter/src/config.ts @@ -12,8 +12,8 @@ import postcssImport from 'postcss-import' import { generateRules as generateRulesFallback } from 'tailwindcss/lib/lib/generateRules' // @ts-ignore import { createContext as createContextFallback } from 'tailwindcss/lib/lib/setupContextUtils' -import loadConfigFallback from 'tailwindcss/loadConfig' -import resolveConfigFallback from 'tailwindcss/resolveConfig' +import loadConfigFallback from 'tailwindcss/loadConfig.js' +import resolveConfigFallback from 'tailwindcss/resolveConfig.js' import type { RequiredConfig } from 'tailwindcss/types/config.js' import { expiringMap } from './expiring-map.js' import { resolveCssFrom, resolveJsFrom } from './resolve' diff --git a/yarn.lock b/yarn.lock index 871dd88fa..2caa79067 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1053,6 +1053,61 @@ dependencies: "@octokit/openapi-types" "^25.1.0" +"@oven/bun-darwin-aarch64@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.1.tgz#24f15a0bdec25bb52e726207bfeb84258b22f1ea" + integrity sha512-7Rap1BHNWqgnexc4wLjjdZeVRQKtk534iGuJ7qZ42i/q1B+cxJZ6zSnrFsYmo+zreH7dUyUXL3AHuXGrl2772Q== + +"@oven/bun-darwin-x64-baseline@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.1.tgz#fc2f3cd9caba96be20862e011ca2d11d0cd1d216" + integrity sha512-mJo715WvwEHmJ6khNymWyxi0QrFzU94wolsUmxolViNHrk+2ugzIkVIJhTnxf7pHnarxxHwyJ/kgatuV//QILQ== + +"@oven/bun-darwin-x64@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.1.tgz#c54e0eec263eda889169b4d83ee6b5ec3290f958" + integrity sha512-wpqmgT/8w+tEr5YMGt1u1sEAMRHhyA2SKZddC6GCPasHxSqkCWOPQvYIHIApnTsoSsxhxP0x6Cpe93+4c7hq/w== + +"@oven/bun-linux-aarch64-musl@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.1.tgz#851b4cefeea2c8219666bec5324bd560304a29d2" + integrity sha512-gKU3Wv3BTG5VMjqMMnRwqU6tipCveE9oyYNt62efy6cQK3Vo1DOBwY2SmjbFw+yzj+Um20YoFOLGxghfQET4Ng== + +"@oven/bun-linux-aarch64@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.1.tgz#3ee52397da074f40235d2f55016093cb6c98019d" + integrity sha512-ACn038SZL8del+sFnqCjf+haGB02//j2Ez491IMmPTvbv4a/D0iiNz9xiIB3ICbQd3EwQzi+Ut/om3Ba/KoHbQ== + +"@oven/bun-linux-x64-baseline@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.1.tgz#63e3075607640d6df731cc120e43369256e19d4c" + integrity sha512-7+2aCrL81mtltZQbKdiPB58UL+Gr3DAIuPyUAKm0Ib/KG/Z8t7nD/eSMRY/q6b+NsAjYnVPiPwqSjC3edpMmmQ== + +"@oven/bun-linux-x64-musl-baseline@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.1.tgz#26608cd5f87fdf998dd494da8f3399528b23e406" + integrity sha512-tP0WWcAqrMayvkggOHBGBoyyoK+QHAqgRUyj1F6x5/udiqc9vCXmIt1tlydxYV/NvyvUAmJ7MWT0af44Xm2kJw== + +"@oven/bun-linux-x64-musl@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.1.tgz#fd4b6365943db3c6bfe0193ff8dbcf0998b736a0" + integrity sha512-8AgEAHyuJ5Jm9MUo1L53K1SRYu0bNGqV0E0L5rB5DjkteO4GXrnWGBT8qsuwuy7WMuCMY3bj64/pFjlRkZuiXw== + +"@oven/bun-linux-x64@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64/-/bun-linux-x64-1.3.1.tgz#19e1417abf33ab0c3fde795ae70b2285300794e0" + integrity sha512-cAUeM3I5CIYlu5Ur52eCOGg9yfqibQd4lzt9G1/rA0ajqcnCBaTuekhUDZETJJf5H9QV+Gm46CqQg2DpdJzJsw== + +"@oven/bun-windows-x64-baseline@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.1.tgz#c95a119f997f4b6332c43355e1a254bd4b31487e" + integrity sha512-dcA+Kj7hGFrY3G8NWyYf3Lj3/GMViknpttWUf5pI6p6RphltZaoDu0lY5Lr71PkMdRZTwL2NnZopa/x/NWCdKA== + +"@oven/bun-windows-x64@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@oven/bun-windows-x64/-/bun-windows-x64-1.3.1.tgz#a64137c3b67f818b5a8a50f2680cd60d3e6ebe7e" + integrity sha512-xdUjOZRq6PwPbbz4/F2QEMLBZwintGp7AS50cWxgkHnyp7Omz5eJfV6/vWtN4qwZIyR3V3DT/2oXsY1+7p3rtg== + "@oxlint/darwin-arm64@1.13.0": version "1.13.0" resolved "https://registry.npmjs.org/@oxlint/darwin-arm64/-/darwin-arm64-1.13.0.tgz" @@ -1725,6 +1780,13 @@ "@types/connect" "*" "@types/node" "*" +"@types/bun@^1.3.0": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@types/bun/-/bun-1.3.1.tgz#275836f9dfcb2f9b1a1d4144026e404b4d42a766" + integrity sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ== + dependencies: + bun-types "1.3.1" + "@types/chai@^5.2.2": version "5.2.3" resolved "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz" @@ -3008,6 +3070,30 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" +bun-types@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/bun-types/-/bun-types-1.3.1.tgz#15857727b1030960538a0485983044af8696c81d" + integrity sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw== + dependencies: + "@types/node" "*" + +bun@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/bun/-/bun-1.3.1.tgz#bf1cb09802a8b991b6a0dbd8e44cbc6664f2b0bc" + integrity sha512-enqkEb0RhNOgDzHQwv7uvnIhX3uSzmKzz779dL7kdH8SauyTdQvCz4O1UT2rU0UldQp2K9OlrJNdyDHayPEIvw== + optionalDependencies: + "@oven/bun-darwin-aarch64" "1.3.1" + "@oven/bun-darwin-x64" "1.3.1" + "@oven/bun-darwin-x64-baseline" "1.3.1" + "@oven/bun-linux-aarch64" "1.3.1" + "@oven/bun-linux-aarch64-musl" "1.3.1" + "@oven/bun-linux-x64" "1.3.1" + "@oven/bun-linux-x64-baseline" "1.3.1" + "@oven/bun-linux-x64-musl" "1.3.1" + "@oven/bun-linux-x64-musl-baseline" "1.3.1" + "@oven/bun-windows-x64" "1.3.1" + "@oven/bun-windows-x64-baseline" "1.3.1" + bundle-name@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz" @@ -3779,6 +3865,11 @@ dequal@^2.0.0: resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== +detect-libc@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== + detect-libc@^2.0.0: version "2.0.4" resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz" @@ -6096,7 +6187,7 @@ lru-cache@^6.0.0: lz-string@^1.5.0: version "1.5.0" - resolved "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== magic-string@^0.30.17: @@ -6795,6 +6886,11 @@ monaco-editor@^0.52.2: resolved "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz" integrity sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ== +mri@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" + integrity sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA== + mrmime@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz" @@ -7211,9 +7307,9 @@ oniguruma-to-es@^4.3.3: regex "^6.0.1" regex-recursion "^6.0.2" -open@^10.1.0: +open@^10.1.0, open@^10.2.0: version "10.2.0" - resolved "https://registry.npmjs.org/open/-/open-10.2.0.tgz" + resolved "https://registry.yarnpkg.com/open/-/open-10.2.0.tgz#b9d855be007620e80b6fb05fac98141fe62db73c" integrity sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA== dependencies: default-browser "^5.2.1"