From 428e75346fe9638b0d4c3e410eed329a6a77292d Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 8 Nov 2025 03:57:45 +0100 Subject: [PATCH] CLI: Allow Linter and Formatter CLIs to accept multiple inputs --- javascript/packages/formatter/src/cli.ts | 259 ++++++++---------- .../packages/formatter/test/cli.test.ts | 118 +++++++- javascript/packages/linter/src/cli.ts | 91 +++--- .../linter/src/cli/argument-parser.ts | 17 +- javascript/packages/linter/test/cli.test.ts | 55 ++++ 5 files changed, 350 insertions(+), 190 deletions(-) diff --git a/javascript/packages/formatter/src/cli.ts b/javascript/packages/formatter/src/cli.ts index 395ffc9a4..37d8508c7 100644 --- a/javascript/packages/formatter/src/cli.ts +++ b/javascript/packages/formatter/src/cli.ts @@ -18,11 +18,12 @@ const pluralize = (count: number, singular: string, plural: string = singular + export class CLI { private usage = dedent` - Usage: herb-format [file|directory|glob-pattern] [options] + Usage: herb-format [files|directories|glob-patterns...] [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) + files|directories|glob-patterns Files, directories, or glob patterns to format, or '-' for stdin + Multiple arguments are supported (e.g., herb-format file1.erb file2.erb dir/) + Omit to format all configured files in current directory Options: -c, --check check if files are formatted without modifying them @@ -126,6 +127,11 @@ export class CLI { process.exit(0) } + if (positionals.includes('-') && positionals.length > 1) { + console.error("Error: Cannot mix stdin ('-') with file arguments") + process.exit(1) + } + const file = positionals[0] const startPath = file || process.cwd() @@ -314,165 +320,85 @@ export class CLI { 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 - } + } else if (positionals.length > 0) { + const allFiles: string[] = [] - const filesConfig = config.getFilesConfigForTool('formatter') + let hasErrors = false - if (isDirectory) { - const files = await config.findFilesForTool('formatter', resolve(file)) + for (const pattern of positionals) { + try { + const files = await this.resolvePatternToFiles(pattern, config, isForceMode) - if (files.length === 0) { - console.log(`No files found in directory: ${resolve(file)}`) - process.exit(0) - } + if (files.length === 0) { + const isLikelySpecificFile = !pattern.includes('*') && !pattern.includes('?') && + !pattern.includes('[') && !pattern.includes('{') - 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++ + if (isLikelySpecificFile) { + continue + } else { + console.log(`No files found matching pattern: ${pattern}`) + process.exit(0) } - } 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')}`) + allFiles.push(...files) + } catch (error: any) { + console.error(`Error: ${error.message}`) + hasErrors = true + break } + } - 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() - } - } + if (hasErrors) { + process.exit(1) + } - 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}`) - } + const files = [...new Set(allFiles)] + if (files.length === 0) { + console.log(`No files found matching patterns: ${positionals.join(', ')}`) 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)}`) + let formattedCount = 0 + let unformattedFiles: string[] = [] - process.exit(0) - } + for (const filePath of files) { + const displayPath = relative(process.cwd(), filePath) - 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) - } - } + try { + const source = readFileSync(filePath, "utf-8") + const result = formatter.format(source) + const output = result.endsWith('\n') ? result : result + '\n' - 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) + if (output !== source) { + if (isCheckMode) { + unformattedFiles.push(displayPath) } else { - console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, all files are properly formatted`) + writeFileSync(filePath, output, "utf-8") + console.log(`Formatted: ${displayPath}`) } - } else { - console.log(`\nChecked ${files.length} ${pluralize(files.length, 'file')}, formatted ${formattedCount} ${pluralize(formattedCount, 'file')}`) + formattedCount++ } + } catch (error) { + console.error(`Error formatting ${displayPath}:`, error) + } + } - } catch (error) { - console.error(`Error: Cannot access '${file}':`, error) - - process.exit(1) + 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 { const files = await config.findFilesForTool('formatter', process.cwd()) @@ -537,4 +463,57 @@ export class CLI { return Buffer.concat(chunks).toString("utf8") } + + private async resolvePatternToFiles(pattern: string, config: Config, isForceMode: boolean | undefined): Promise { + let isDirectory = false + let isFile = false + + try { + const stats = statSync(pattern) + 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(pattern)) + return files + } else if (isFile) { + const testFiles = await glob(pattern, { + cwd: process.cwd(), + ignore: filesConfig.exclude || [] + }) + + if (testFiles.length === 0) { + if (!isForceMode) { + console.error(`⚠️ File ${pattern} 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: ${pattern}`) + console.error() + return [pattern] + } + } + + return [pattern] + } + + const files = await glob(pattern, { ignore: filesConfig.exclude || [] }) + + if (files.length === 0) { + try { + statSync(pattern) + } catch { + if (!pattern.includes('*') && !pattern.includes('?') && !pattern.includes('[') && !pattern.includes('{')) { + throw new Error(`Cannot access '${pattern}': ENOENT: no such file or directory`) + } + } + } + + return files + } } diff --git a/javascript/packages/formatter/test/cli.test.ts b/javascript/packages/formatter/test/cli.test.ts index ec53ce04c..9a3c72405 100644 --- a/javascript/packages/formatter/test/cli.test.ts +++ b/javascript/packages/formatter/test/cli.test.ts @@ -33,7 +33,7 @@ describe("CLI Binary", () => { expectExitCode(result, 0) expect(result.stdout).toContain("Usage: herb-format") - expect(result.stdout).toContain("file|directory") + expect(result.stdout).toContain("files|directories|glob-patterns") expect(result.stdout).toContain("Arguments:") expect(result.stdout).toContain("Options:") expect(result.stdout).toContain("Examples:") @@ -44,7 +44,7 @@ describe("CLI Binary", () => { expectExitCode(result, 0) expect(result.stdout).toContain("Usage: herb-format") - expect(result.stdout).toContain("file|directory") + expect(result.stdout).toContain("files|directories|glob-patterns") expect(result.stdout).toContain("Arguments:") expect(result.stdout).toContain("Options:") expect(result.stdout).toContain("Examples:") @@ -147,7 +147,7 @@ describe("CLI Binary", () => { expectExitCode(result, 0) expect(result.stderr).toContain("⚠️ Experimental Preview") - expect(result.stdout).toContain("No files found in directory:") + expect(result.stdout).toContain("No files found matching pattern") } finally { await rm("test-empty-dir", { recursive: true }).catch(() => {}) } @@ -198,7 +198,7 @@ describe("CLI Binary", () => { const result = await execBinary(["test-dir"]) expectExitCode(result, 0) - expect(result.stdout).toContain("No files found in directory:") + expect(result.stdout).toContain("No files found matching pattern") } finally { await rm("test-dir", { recursive: true }).catch(() => {}) } @@ -282,7 +282,7 @@ describe("CLI Binary", () => { const result = await execBinary(["--check", testFile]) expectExitCode(result, 0) - expect(result.stdout).toContain("File is properly formatted") + expect(result.stdout).toContain("all files are properly formatted") } finally { await unlink(testFile).catch(() => {}) } @@ -298,7 +298,7 @@ describe("CLI Binary", () => { const result = await execBinary(["--check", testFile]) expectExitCode(result, 1) - expect(result.stdout).toContain("File is not formatted") + expect(result.stdout).toContain("not formatted") } finally { await unlink(testFile).catch(() => {}) } @@ -586,7 +586,7 @@ describe("CLI Binary", () => { const result = await execBinary(["test-advanced/"]) expectExitCode(result, 0) - expect(result.stdout).toContain("No files found in directory:") + expect(result.stdout).toContain("No files found matching pattern") }) it("should handle mixed file arguments", async () => { @@ -638,4 +638,108 @@ describe("CLI Binary", () => { expect(result.stdout).toContain("Checked 3 files, found 1 unformatted file") }) }) + + describe("Multiple File Arguments", () => { + beforeEach(async () => { + await mkdir("test-multi", { recursive: true }) + }) + + afterEach(async () => { + await rm("test-multi", { recursive: true }).catch(() => {}) + }) + + it("should format multiple files successfully", async () => { + const unformattedContent = '

Test

' + + await writeFile("test-multi/file1.html.erb", unformattedContent) + await writeFile("test-multi/file2.html.erb", unformattedContent) + + const result = await execBinary(["test-multi/file1.html.erb", "test-multi/file2.html.erb"]) + + expectExitCode(result, 0) + expect(result.stdout).toContain("Formatted: test-multi/file1.html.erb") + expect(result.stdout).toContain("Formatted: test-multi/file2.html.erb") + expect(result.stdout).toContain("Checked 2 files, formatted 2 files") + }) + + it("should handle multiple files with mixed formatting states", async () => { + const formattedContent = '
\n

Test

\n
\n' + const unformattedContent = '

Test

' + + await writeFile("test-multi/formatted.html.erb", formattedContent) + await writeFile("test-multi/unformatted.html.erb", unformattedContent) + + const result = await execBinary(["test-multi/formatted.html.erb", "test-multi/unformatted.html.erb"]) + + expectExitCode(result, 0) + expect(result.stdout).not.toContain("Formatted: test-multi/formatted.html.erb") + expect(result.stdout).toContain("Formatted: test-multi/unformatted.html.erb") + expect(result.stdout).toContain("Checked 2 files, formatted 1 file") + }) + + it("should exit with error if one file doesn't exist", async () => { + const content = '

Test

' + await writeFile("test-multi/exists.html.erb", content) + + const result = await execBinary(["test-multi/exists.html.erb", "test-multi/nonexistent.html.erb"]) + + expectExitCode(result, 1) + expect(result.stderr).toContain("Cannot access") + expect(result.stderr).toContain("nonexistent.html.erb") + }) + + it("should deduplicate files passed multiple times", async () => { + const unformattedContent = '

Test

' + await writeFile("test-multi/duplicate.html.erb", unformattedContent) + + const result = await execBinary(["test-multi/duplicate.html.erb", "test-multi/duplicate.html.erb"]) + + expectExitCode(result, 0) + // Should only format once + expect(result.stdout).toContain("Checked 1 file, formatted 1 file") + }) + + it("should handle --check with multiple files", async () => { + const formattedContent = '
\n

Test

\n
\n' + const unformattedContent = '

Test

' + + await writeFile("test-multi/file1.html.erb", formattedContent) + await writeFile("test-multi/file2.html.erb", unformattedContent) + + const result = await execBinary(["--check", "test-multi/file1.html.erb", "test-multi/file2.html.erb"]) + + expectExitCode(result, 1) + expect(result.stdout).toContain("The following") + expect(result.stdout).toContain("not formatted") + expect(result.stdout).toContain("file2.html.erb") + expect(result.stdout).not.toContain("file1.html.erb") + expect(result.stdout).toContain("Checked 2 files, found 1 unformatted file") + }) + + it("should reject stdin mixed with file arguments", async () => { + const content = '

Test

' + await writeFile("test-multi/file.html.erb", content) + + const result = await execBinary(["-", "test-multi/file.html.erb"]) + + expectExitCode(result, 1) + expect(result.stderr).toContain("Cannot mix stdin ('-') with file arguments") + }) + + it("should handle mix of files and globs", async () => { + const unformattedContent = '

Test

' + + await writeFile("test-multi/specific.html.erb", unformattedContent) + await writeFile("test-multi/pattern1.xml.erb", '') + await writeFile("test-multi/pattern2.xml.erb", '') + + const result = await execBinary(["test-multi/specific.html.erb", "test-multi/*.xml.erb"]) + + expectExitCode(result, 0) + expect(result.stdout).toContain("Formatted: test-multi/specific.html.erb") + expect(result.stdout).toContain("Formatted: test-multi/pattern1.xml.erb") + expect(result.stdout).toContain("Formatted: test-multi/pattern2.xml.erb") + expect(result.stdout).toContain("Checked 3 files, formatted 3 files") + }) + }) }) diff --git a/javascript/packages/linter/src/cli.ts b/javascript/packages/linter/src/cli.ts index 9c9c52d3f..b0af673ba 100644 --- a/javascript/packages/linter/src/cli.ts +++ b/javascript/packages/linter/src/cli.ts @@ -55,7 +55,8 @@ export class CLI { process.exit(exitCode) } - protected determineProjectPath(pattern: string | undefined): void { + protected determineProjectPath(patterns: string[]): void { + const pattern = patterns[0] if (pattern) { const resolvedPattern = resolve(pattern) @@ -91,6 +92,41 @@ export class CLI { return pattern } + protected async resolvePatternToFiles(pattern: string, config: Config, force: boolean): Promise<{ files: string[], explicitFile: string | undefined }> { + const resolvedPattern = resolve(pattern) + const isExplicitFile = existsSync(resolvedPattern) && statSync(resolvedPattern).isFile() + let explicitFile: string | undefined + + if (isExplicitFile) { + explicitFile = 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) + + let files = await glob(adjustedPattern, { + cwd: this.projectPath, + ignore: filesConfig.exclude || [] + }) + + if (explicitFile && files.length === 0) { + if (!force) { + console.error(`⚠️ File ${explicitFile} 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: ${explicitFile}`) + console.log() + files = [adjustedPattern] + } + } + + return { files, explicitFile } + } + protected async beforeProcess(): Promise { // Hook for subclasses to add custom output before processing } @@ -105,9 +141,9 @@ export class CLI { 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) + let { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, ignoreDisableComments, force, init } = this.argumentParser.parse(process.argv) - this.determineProjectPath(pattern) + this.determineProjectPath(patterns) if (init) { const configPath = configFile || this.projectPath @@ -160,50 +196,37 @@ export class CLI { } let files: string[] - let explicitSingleFile: string | undefined + let explicitFiles: string[] = [] - if (!pattern) { + if (patterns.length === 0) { files = await config.findFilesForTool('linter', this.projectPath) } else { - const resolvedPattern = resolve(pattern) - const isExplicitFile = existsSync(resolvedPattern) && statSync(resolvedPattern).isFile() + const allFiles: string[] = [] - if (isExplicitFile) { - explicitSingleFile = pattern - } + for (const pattern of patterns) { + const { files: patternFiles, explicitFile } = await this.resolvePatternToFiles(pattern, config, force) + + if (patternFiles.length === 0) { + console.error(`✗ No files found matching pattern: ${pattern}`) + process.exit(1) + } - 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] + allFiles.push(...patternFiles) + if (explicitFile) { + explicitFiles.push(explicitFile) } } + + files = [...new Set(allFiles)] } if (files.length === 0) { - this.exitWithInfo(`No files found matching pattern: ${pattern || 'from config'}`, formatOption, 0, { startTime, startDate, showTiming }) + this.exitWithInfo(`No files found matching patterns: ${patterns.join(', ') || 'from config'}`, formatOption, 0, { startTime, startDate, showTiming }) } let processingConfig = config - if (force && explicitSingleFile && files.length === 1) { + if (force && explicitFiles.length > 0) { const modifiedConfig = Object.create(Object.getPrototypeOf(config)) Object.assign(modifiedConfig, config) @@ -220,7 +243,7 @@ export class CLI { const context: ProcessingContext = { projectPath: this.projectPath, - pattern, + pattern: patterns.join(' '), fix, ignoreDisableComments, linterConfig, diff --git a/javascript/packages/linter/src/cli/argument-parser.ts b/javascript/packages/linter/src/cli/argument-parser.ts index c939d8768..dd703bcdb 100644 --- a/javascript/packages/linter/src/cli/argument-parser.ts +++ b/javascript/packages/linter/src/cli/argument-parser.ts @@ -11,7 +11,7 @@ import { name, version, dependencies } from "../../package.json" export type FormatOption = "simple" | "detailed" | "json" export interface ParsedArguments { - pattern: string + patterns: string[] configFile?: string formatOption: FormatOption showTiming: boolean @@ -27,12 +27,11 @@ export interface ParsedArguments { export class ArgumentParser { private readonly usage = dedent` - Usage: herb-lint [file|glob-pattern|directory] [options] + Usage: herb-lint [files|directories|glob-patterns...] [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) + files Files, directories, or glob patterns to lint (defaults to configured extensions in .herb.yml) + Multiple arguments are supported (e.g., herb-lint file1.erb file2.erb dir/ "**/*.erb") Options: -h, --help show help @@ -134,17 +133,17 @@ export class ArgumentParser { } const theme = values.theme || DEFAULT_THEME - const pattern = this.getFilePattern(positionals) + const patterns = this.getFilePatterns(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 } + return { patterns, configFile, formatOption, showTiming, theme, wrapLines, truncateLines, useGitHubActions, fix, ignoreDisableComments, force, init } } - private getFilePattern(positionals: string[]): string { - return positionals.length > 0 ? positionals[0] : "" + private getFilePatterns(positionals: string[]): string[] { + return positionals } } diff --git a/javascript/packages/linter/test/cli.test.ts b/javascript/packages/linter/test/cli.test.ts index 9a74ec67e..4a63d911c 100644 --- a/javascript/packages/linter/test/cli.test.ts +++ b/javascript/packages/linter/test/cli.test.ts @@ -280,4 +280,59 @@ describe("CLI Output Formatting", () => { } }) }) + + describe("Multiple File Arguments", () => { + function runLinterMultiFile(...files: string[]): { output: string, exitCode: number } { + try { + const { execSync } = require("child_process") + const fileArgs = files.map(f => `test/fixtures/${f}`).join(' ') + + const output = execSync(`bin/herb-lint ${fileArgs} --no-timing 2>&1`, { + encoding: "utf-8", + env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: undefined, GITHUB_ACTIONS: undefined } + }) + + return { output: output.trim(), exitCode: 0 } + } catch (error: any) { + const stderr = error.stderr ? error.stderr.toString().trim() : "" + const stdout = error.stdout ? error.stdout.toString().trim() : "" + const combined = (stdout + "\n" + stderr).trim() + + return { output: combined || stderr || stdout, exitCode: error.status } + } + } + + test("lints multiple files successfully", () => { + const { output, exitCode } = runLinterMultiFile("clean-file.html.erb", "boolean-attribute.html.erb") + + expect(output).toContain("All files are clean") + expect(output).toContain("Checked 2 files") + expect(exitCode).toBe(0) + }) + + test("lints multiple files with errors", () => { + const { output, exitCode } = runLinterMultiFile("test-file-with-errors.html.erb", "bad-file.html.erb") + + expect(output).toContain("test-file-with-errors.html.erb") + expect(output).toContain("bad-file.html.erb") + expect(exitCode).toBe(1) + }) + + test("exits with error if one file doesn't exist", () => { + const { output, exitCode } = runLinterMultiFile("clean-file.html.erb", "nonexistent-file.html.erb") + + expect(output).toContain("No files found matching pattern") + expect(output).toContain("nonexistent-file.html.erb") + expect(exitCode).toBe(1) + }) + + test("deduplicates files when passed multiple times", () => { + const { output, exitCode } = runLinterMultiFile("test-file-with-errors.html.erb", "test-file-with-errors.html.erb") + + const fileMatches = (output.match(/test-file-with-errors\.html\.erb/g) || []).length + + expect(fileMatches).toBeGreaterThan(0) + expect(exitCode).toBe(1) + }) + }) })