From 9f8a877e8fa7b68d645844d44bd0a47944eb9886 Mon Sep 17 00:00:00 2001 From: shadcn Date: Fri, 27 Feb 2026 10:03:18 +0400 Subject: [PATCH] refactor: clean up formatter --- .../shadcn/src/utils/dry-run-formatter.ts | 499 ++++++++---------- 1 file changed, 223 insertions(+), 276 deletions(-) diff --git a/packages/shadcn/src/utils/dry-run-formatter.ts b/packages/shadcn/src/utils/dry-run-formatter.ts index d567d564d8..a267e4f376 100644 --- a/packages/shadcn/src/utils/dry-run-formatter.ts +++ b/packages/shadcn/src/utils/dry-run-formatter.ts @@ -2,6 +2,9 @@ import type { DryRunFile, DryRunResult } from "@/src/utils/dry-run" import { diffWords, structuredPatch } from "diff" import { bold, cyan, dim, green, red, yellow } from "kleur/colors" +const BOX_TOP = dim("┌" + "─".repeat(46)) +const BOX_BOTTOM = dim("└" + "─".repeat(46)) + const ACTION_GLYPHS: Record = { create: "+", overwrite: "~", @@ -14,6 +17,40 @@ const ACTION_LABELS: Record = { skip: "skip (identical)", } +// Color an action label. +function colorAction(action: DryRunFile["action"] | "update") { + if (action === "create") return green(action) + if (action === "overwrite" || action === "update") return yellow(action) + return dim(action) +} + +// Format the shared header line. +function formatHeader(componentNames: string[]) { + return `${bold("┌")} ${bold(`shadcn add ${componentNames.join(", ")}`)} ${dim("(dry run)")}` +} + +// Check if a CSS path matches a filter. +function matchesCssPath(cssPath: string, filterPath: string) { + return ( + cssPath === filterPath || + cssPath.includes(filterPath) || + cssPath.endsWith(filterPath) + ) +} + +// Push a content box (border + lines + border) into the output. +function pushContentBox( + lines: string[], + contentLines: string[], + formatLine: (line: string) => string = (l) => l +) { + lines.push(`${dim("│")} ${BOX_TOP}`) + for (const line of contentLines) { + lines.push(`${dim("│")} ${dim("│")} ${formatLine(line)}`) + } + lines.push(`${dim("│")} ${BOX_BOTTOM}`) +} + export function formatDryRunResult( result: DryRunResult, componentNames: string[], @@ -38,30 +75,14 @@ export function formatDryRunResult( function formatSummaryOutput(result: DryRunResult, componentNames: string[]) { const lines: string[] = [] - // Header. - lines.push( - `${bold("┌")} ${bold(`shadcn add ${componentNames.join(", ")}`)} ${dim( - "(dry run)" - )}` - ) + lines.push(formatHeader(componentNames)) lines.push(dim("│")) - // Files section. formatFilesSection(result, lines) - - // Dependencies section. formatListSection("Dependencies", result.dependencies, lines) - - // Dev dependencies section. formatListSection("Dev Dependencies", result.devDependencies, lines) - - // CSS section. formatCssSection(result, lines) - - // Env vars section. formatEnvVarsSection(result, lines) - - // Fonts section. formatFontsSection(result, lines) // Overwrite warning. @@ -71,9 +92,7 @@ function formatSummaryOutput(result: DryRunResult, componentNames: string[]) { if (overwriteCount > 0) { lines.push( yellow( - `⚠ ${overwriteCount} ${ - overwriteCount === 1 ? "file" : "files" - } will be overwritten.` + `⚠ ${overwriteCount} ${overwriteCount === 1 ? "file" : "files"} will be overwritten.` ) ) lines.push(dim("│")) @@ -88,9 +107,7 @@ function formatSummaryOutput(result: DryRunResult, componentNames: string[]) { } if (result.dependencies.length > 0) { summaryParts.push( - `${result.dependencies.length} ${ - result.dependencies.length === 1 ? "dep" : "deps" - }` + `${result.dependencies.length} ${result.dependencies.length === 1 ? "dep" : "deps"}` ) } if (result.css?.cssVarsCount) { @@ -119,21 +136,11 @@ function formatDiffOutput( ) { const lines: string[] = [] - lines.push( - `${bold("┌")} ${bold(`shadcn add ${componentNames.join(", ")}`)} ${dim( - "(dry run)" - )}` - ) + lines.push(formatHeader(componentNames)) lines.push(dim("│")) const filesToDiff = resolveFilterPath(result.files, filterPath) - - // Check if the filter matches the CSS file. - const cssMatch = - result.css && - (result.css.path === filterPath || - result.css.path.includes(filterPath) || - result.css.path.endsWith(filterPath)) + const cssMatch = result.css && matchesCssPath(result.css.path, filterPath) if (filesToDiff.length === 0 && !cssMatch) { lines.push( @@ -145,35 +152,23 @@ function formatDiffOutput( formatFileDiff(file, lines) } - // CSS diff. if (cssMatch && result.css) { - const actionLabel = - result.css.action === "create" ? green("create") : yellow("update") - lines.push( - `${dim("├")} ${bold(result.css.path)} ${dim("(")}${actionLabel}${dim( - ")" - )}` + `${dim("├")} ${bold(result.css.path)} ${dim("(")}${colorAction(result.css.action)}${dim(")")}` ) if (result.css.action === "create" || !result.css.existingContent) { - lines.push(`${dim("│")} ${dim("┌" + "─".repeat(46))}`) - for (const line of result.css.content.split("\n")) { - lines.push(`${dim("│")} ${dim("│")} ${green(`+${line}`)}`) - } - lines.push(`${dim("│")} ${dim("└" + "─".repeat(46))}`) + pushContentBox(lines, result.css.content.split("\n"), (l) => + green(`+${l}`) + ) } else { - lines.push(`${dim("│")} ${dim("┌" + "─".repeat(46))}`) const diffLines = computeUnifiedDiff( result.css.existingContent, result.css.content, result.css.path, { fullContext: true } ) - for (const line of diffLines) { - lines.push(`${dim("│")} ${dim("│")} ${line}`) - } - lines.push(`${dim("│")} ${dim("└" + "─".repeat(46))}`) + pushContentBox(lines, diffLines) } lines.push(dim("│")) @@ -187,37 +182,21 @@ function formatDiffOutput( // Format a single file's diff block. function formatFileDiff(file: DryRunFile, lines: string[]) { - const actionLabel = - file.action === "create" - ? green("create") - : file.action === "overwrite" - ? yellow("overwrite") - : dim("skip") - lines.push( - `${dim("├")} ${bold(file.path)} ${dim("(")}${actionLabel}${dim(")")}` + `${dim("├")} ${bold(file.path)} ${dim("(")}${colorAction(file.action)}${dim(")")}` ) if (file.action === "skip") { lines.push(`${dim("│")} ${dim("No changes.")}`) } else if (file.action === "create") { - lines.push(`${dim("│")} ${dim("┌" + "─".repeat(46))}`) - const contentLines = file.content.split("\n") - for (const line of contentLines) { - lines.push(`${dim("│")} ${dim("│")} ${green(`+${line}`)}`) - } - lines.push(`${dim("│")} ${dim("└" + "─".repeat(46))}`) + pushContentBox(lines, file.content.split("\n"), (l) => green(`+${l}`)) } else { - lines.push(`${dim("│")} ${dim("┌" + "─".repeat(46))}`) const diffLines = computeUnifiedDiff( file.existingContent!, file.content, file.path ) - for (const line of diffLines) { - lines.push(`${dim("│")} ${dim("│")} ${line}`) - } - lines.push(`${dim("│")} ${dim("└" + "─".repeat(46))}`) + pushContentBox(lines, diffLines) } lines.push(dim("│")) @@ -231,21 +210,11 @@ function formatViewOutput( ) { const lines: string[] = [] - lines.push( - `${bold("┌")} ${bold(`shadcn add ${componentNames.join(", ")}`)} ${dim( - "(dry run)" - )}` - ) + lines.push(formatHeader(componentNames)) lines.push(dim("│")) const filesToView = resolveFilterPath(result.files, filterPath) - - // Check if the filter matches the CSS file. - const cssMatch = - result.css && - (result.css.path === filterPath || - result.css.path.includes(filterPath) || - result.css.path.endsWith(filterPath)) + const cssMatch = result.css && matchesCssPath(result.css.path, filterPath) if (filesToView.length === 0 && !cssMatch) { lines.push( @@ -255,46 +224,19 @@ function formatViewOutput( } else { for (const file of filesToView) { const contentLines = file.content.split("\n") - const actionLabel = - file.action === "create" - ? green("create") - : file.action === "overwrite" - ? yellow("overwrite") - : dim("skip") - lines.push( - `${dim("├")} ${bold(file.path)} ${dim("(")}${actionLabel}${dim( - ")" - )} ${dim(`${contentLines.length} lines`)}` + `${dim("├")} ${bold(file.path)} ${dim("(")}${colorAction(file.action)}${dim(")")} ${dim(`${contentLines.length} lines`)}` ) - lines.push(`${dim("│")} ${dim("┌" + "─".repeat(46))}`) - - for (const line of contentLines) { - lines.push(`${dim("│")} ${dim("│")} ${line}`) - } - - lines.push(`${dim("│")} ${dim("└" + "─".repeat(46))}`) + pushContentBox(lines, contentLines) lines.push(dim("│")) } - // CSS view. if (cssMatch && result.css) { const contentLines = result.css.content.split("\n") - const actionLabel = - result.css.action === "create" ? green("create") : yellow("update") - lines.push( - `${dim("├")} ${bold(result.css.path)} ${dim("(")}${actionLabel}${dim( - ")" - )} ${dim(`${contentLines.length} lines`)}` + `${dim("├")} ${bold(result.css.path)} ${dim("(")}${colorAction(result.css.action)}${dim(")")} ${dim(`${contentLines.length} lines`)}` ) - lines.push(`${dim("│")} ${dim("┌" + "─".repeat(46))}`) - - for (const line of contentLines) { - lines.push(`${dim("│")} ${dim("│")} ${line}`) - } - - lines.push(`${dim("│")} ${dim("└" + "─".repeat(46))}`) + pushContentBox(lines, contentLines) lines.push(dim("│")) } } @@ -305,32 +247,31 @@ function formatViewOutput( } function formatFilesSection(result: DryRunResult, lines: string[]) { - const totalCount = result.files.length - - if (totalCount === 0) { + if (result.files.length === 0) { return } // Build summary counts. - const createCount = result.files.filter((f) => f.action === "create").length - const overwriteCount = result.files.filter( - (f) => f.action === "overwrite" - ).length - const skipCount = result.files.filter((f) => f.action === "skip").length + const counts = { create: 0, overwrite: 0, skip: 0 } + for (const f of result.files) { + counts[f.action]++ + } const summaryParts: string[] = [] - if (createCount > 0) { - summaryParts.push(green(`+${createCount} new`)) + if (counts.create > 0) { + summaryParts.push(green(`+${counts.create} new`)) } - if (overwriteCount > 0) { - summaryParts.push(yellow(`~${overwriteCount} overwrite`)) + if (counts.overwrite > 0) { + summaryParts.push(yellow(`~${counts.overwrite} overwrite`)) } - if (skipCount > 0) { - summaryParts.push(dim(`=${skipCount} skip`)) + if (counts.skip > 0) { + summaryParts.push(dim(`=${counts.skip} skip`)) } const summary = summaryParts.length > 0 ? ` ${summaryParts.join(dim(", "))}` : "" - lines.push(`${dim("├")} ${bold(`Files`)} ${dim(`(${totalCount})`)}${summary}`) + lines.push( + `${dim("├")} ${bold("Files")} ${dim(`(${result.files.length})`)}${summary}` + ) // Find the longest path for alignment. const maxPathLen = Math.max(...result.files.map((f) => f.path.length)) @@ -340,23 +281,18 @@ function formatFilesSection(result: DryRunResult, lines: string[]) { const label = ACTION_LABELS[file.action] const padding = " ".repeat(Math.max(1, maxPathLen - file.path.length + 2)) - const glyphStr = + const colorFn = file.action === "create" - ? green(glyph) + ? green : file.action === "overwrite" - ? yellow(glyph) - : dim(glyph) + ? yellow + : dim const pathStr = file.action === "skip" ? dim(file.path) : file.path - const labelStr = - file.action === "create" - ? green(label) - : file.action === "overwrite" - ? yellow(label) - : dim(label) - - lines.push(`${dim("│")} ${glyphStr} ${pathStr}${padding}${labelStr}`) + lines.push( + `${dim("│")} ${colorFn(glyph)} ${pathStr}${padding}${colorFn(label)}` + ) } lines.push(dim("│")) @@ -383,9 +319,7 @@ function formatCssSection(result: DryRunResult, lines: string[]) { if (result.css.cssVarsCount > 0) { lines.push( - `${dim("│")} ${green("+")} ${ - result.css.cssVarsCount - } CSS variables added to ${cyan(result.css.path)}` + `${dim("│")} ${green("+")} ${result.css.cssVarsCount} CSS variables added to ${cyan(result.css.path)}` ) } else { lines.push(`${dim("│")} ${green("+")} Updated ${cyan(result.css.path)}`) @@ -431,14 +365,16 @@ export function resolveFilterPath(files: DryRunFile[], filterPath: string) { } // Partial match: check if the filter appears in the path. - const partial = files.filter( + return files.filter( (f) => f.path.includes(filterPath) || - f.path.endsWith(filterPath) || f.path.replace(/\\/g, "/").includes(filterPath) ) +} - return partial +type HunkEntry = { + kind: "context" | "removed" | "added" + formatted: string } // Compute a unified diff using the `diff` package. @@ -449,12 +385,9 @@ function computeUnifiedDiff( filePath: string, options: { fullContext?: boolean } = {} ) { - const output: string[] = [] - // Check if the only differences are formatting (whitespace, quotes, semicolons). if (isFormattingOnly(oldStr, newStr)) { - output.push(dim(" Formatting-only changes (spacing, quotes, semicolons).")) - return output + return [dim(" Formatting-only changes (spacing, quotes, semicolons).")] } // Normalize both files so structuredPatch only sees real content changes. @@ -480,133 +413,19 @@ function computeUnifiedDiff( ) if (!patch.hunks.length) { - output.push(dim(" No changes.")) - return output + return [dim(" No changes.")] } - output.push(dim(`--- a/${filePath}`)) - output.push(dim(`+++ b/${filePath}`)) + const output: string[] = [ + dim(`--- a/${filePath}`), + dim(`+++ b/${filePath}`), + ] // Use the actual new file lines for display. const newLines = newStr.split("\n") for (const hunk of patch.hunks) { - // Process hunk into typed entries so we can suppress formatting-only - // groups and recompute the header with correct line counts. - type HunkEntry = - | { kind: "context"; formatted: string } - | { kind: "removed"; formatted: string } - | { kind: "added"; formatted: string } - - const entries: HunkEntry[] = [] - let newLineIndex = hunk.newStart - 1 - - const hunkLines = hunk.lines - let i = 0 - - while (i < hunkLines.length) { - const line = hunkLines[i] - - if (line.startsWith("-")) { - // Collect consecutive removed and added lines. - const removed: string[] = [] - while (i < hunkLines.length && hunkLines[i].startsWith("-")) { - removed.push(hunkLines[i].slice(1)) - i++ - } - while (i < hunkLines.length && hunkLines[i].startsWith("\\")) { - i++ - } - const added: string[] = [] - while (i < hunkLines.length && hunkLines[i].startsWith("+")) { - added.push(hunkLines[i].slice(1)) - i++ - } - while (i < hunkLines.length && hunkLines[i].startsWith("\\")) { - i++ - } - - // Check if the entire group is formatting-only (e.g., multi-line to single-line). - if (isGroupFormattingOnly(removed, added)) { - for (let j = 0; j < added.length; j++) { - const actual = newLines[newLineIndex] ?? added[j] - entries.push({ kind: "context", formatted: dim(` ${actual}`) }) - newLineIndex++ - } - } else { - // Collapse continuation lines in the removed group so we can - // match multi-line removed statements against single-line added ones. - // e.g., ["default:", ' "h-8..."'] → ["default: \"h-8...\""]. - const collapsedRemoved = collapseContLines(removed) - const normalizedCollapsed = collapsedRemoved.map((s) => - normalizeLine(s) - ) - const usedCollapsed = new Set() - - for (let j = 0; j < added.length; j++) { - const actualNewLine = newLines[newLineIndex] ?? added[j] - const normalizedAdded = normalizeLine(added[j]) - - // Find a matching collapsed removed statement. - const matchIdx = normalizedCollapsed.findIndex( - (nr, idx) => !usedCollapsed.has(idx) && nr === normalizedAdded - ) - - if (matchIdx !== -1) { - // Formatting-only change — show as context. - usedCollapsed.add(matchIdx) - entries.push({ - kind: "context", - formatted: dim(` ${actualNewLine}`), - }) - } else { - // Real change — find best unmatched removed statement for inline diff. - const unmatchedIdx = normalizedCollapsed.findIndex( - (_, idx) => !usedCollapsed.has(idx) - ) - if (unmatchedIdx !== -1) { - usedCollapsed.add(unmatchedIdx) - const { oldHighlighted, newHighlighted } = - highlightInlineChanges( - collapsedRemoved[unmatchedIdx], - actualNewLine - ) - entries.push({ kind: "removed", formatted: oldHighlighted }) - entries.push({ kind: "added", formatted: newHighlighted }) - } else { - entries.push({ - kind: "added", - formatted: green(`+${actualNewLine}`), - }) - } - } - newLineIndex++ - } - - // Remaining unmatched removed statements. - for (let j = 0; j < collapsedRemoved.length; j++) { - if (!usedCollapsed.has(j)) { - entries.push({ - kind: "removed", - formatted: red(`-${collapsedRemoved[j]}`), - }) - } - } - } - } else if (line.startsWith("+")) { - const actual = newLines[newLineIndex] ?? line.slice(1) - entries.push({ kind: "added", formatted: green(`+${actual}`) }) - newLineIndex++ - i++ - } else if (line.startsWith("\\")) { - i++ - } else { - const actual = newLines[newLineIndex] ?? line.slice(1) - entries.push({ kind: "context", formatted: dim(` ${actual}`) }) - newLineIndex++ - i++ - } - } + const { entries, newLineIndex: _ } = processHunk(hunk, newLines) // Skip hunks that have no real changes after formatting suppression. if (!entries.some((e) => e.kind !== "context")) { @@ -620,9 +439,7 @@ function computeUnifiedDiff( output.push( cyan( - `@@ -${hunk.oldStart},${contextCount + removedCount} +${ - hunk.newStart - },${contextCount + addedCount} @@` + `@@ -${hunk.oldStart},${contextCount + removedCount} +${hunk.newStart},${contextCount + addedCount} @@` ) ) @@ -634,6 +451,136 @@ function computeUnifiedDiff( return output } +// Process a single hunk into typed entries, suppressing formatting-only changes. +function processHunk( + hunk: { oldStart: number; newStart: number; lines: string[] }, + newLines: string[] +) { + const entries: HunkEntry[] = [] + let newLineIndex = hunk.newStart - 1 + let i = 0 + + while (i < hunk.lines.length) { + const line = hunk.lines[i] + + if (line.startsWith("-")) { + // Collect consecutive removed and added lines. + const removed: string[] = [] + while (i < hunk.lines.length && hunk.lines[i].startsWith("-")) { + removed.push(hunk.lines[i].slice(1)) + i++ + } + while (i < hunk.lines.length && hunk.lines[i].startsWith("\\")) { + i++ + } + const added: string[] = [] + while (i < hunk.lines.length && hunk.lines[i].startsWith("+")) { + added.push(hunk.lines[i].slice(1)) + i++ + } + while (i < hunk.lines.length && hunk.lines[i].startsWith("\\")) { + i++ + } + + newLineIndex = processChangeGroup( + removed, + added, + newLines, + newLineIndex, + entries + ) + } else if (line.startsWith("+")) { + const actual = newLines[newLineIndex] ?? line.slice(1) + entries.push({ kind: "added", formatted: green(`+${actual}`) }) + newLineIndex++ + i++ + } else if (line.startsWith("\\")) { + i++ + } else { + const actual = newLines[newLineIndex] ?? line.slice(1) + entries.push({ kind: "context", formatted: dim(` ${actual}`) }) + newLineIndex++ + i++ + } + } + + return { entries, newLineIndex } +} + +// Process a group of removed/added lines, detecting formatting-only changes. +function processChangeGroup( + removed: string[], + added: string[], + newLines: string[], + newLineIndex: number, + entries: HunkEntry[] +) { + // Check if the entire group is formatting-only (e.g., multi-line to single-line). + if (isGroupFormattingOnly(removed, added)) { + for (let j = 0; j < added.length; j++) { + const actual = newLines[newLineIndex] ?? added[j] + entries.push({ kind: "context", formatted: dim(` ${actual}`) }) + newLineIndex++ + } + return newLineIndex + } + + // Collapse continuation lines in the removed group so we can + // match multi-line removed statements against single-line added ones. + // e.g., ["default:", ' "h-8..."'] → ["default: \"h-8...\""]. + const collapsedRemoved = collapseContLines(removed) + const normalizedCollapsed = collapsedRemoved.map(normalizeLine) + const usedCollapsed = new Set() + + for (let j = 0; j < added.length; j++) { + const actualNewLine = newLines[newLineIndex] ?? added[j] + const normalizedAdded = normalizeLine(added[j]) + + // Find a matching collapsed removed statement. + const matchIdx = normalizedCollapsed.findIndex( + (nr, idx) => !usedCollapsed.has(idx) && nr === normalizedAdded + ) + + if (matchIdx !== -1) { + // Formatting-only change — show as context. + usedCollapsed.add(matchIdx) + entries.push({ kind: "context", formatted: dim(` ${actualNewLine}`) }) + } else { + // Real change — find best unmatched removed statement for inline diff. + const unmatchedIdx = normalizedCollapsed.findIndex( + (_, idx) => !usedCollapsed.has(idx) + ) + if (unmatchedIdx !== -1) { + usedCollapsed.add(unmatchedIdx) + const { oldHighlighted, newHighlighted } = highlightInlineChanges( + collapsedRemoved[unmatchedIdx], + actualNewLine + ) + entries.push({ kind: "removed", formatted: oldHighlighted }) + entries.push({ kind: "added", formatted: newHighlighted }) + } else { + entries.push({ + kind: "added", + formatted: green(`+${actualNewLine}`), + }) + } + } + newLineIndex++ + } + + // Remaining unmatched removed statements. + for (let j = 0; j < collapsedRemoved.length; j++) { + if (!usedCollapsed.has(j)) { + entries.push({ + kind: "removed", + formatted: red(`-${collapsedRemoved[j]}`), + }) + } + } + + return newLineIndex +} + // Normalize a file for diffing: apply per-line formatting normalization // while preserving line structure so hunk positions are correct. function normalizeFileForDiff(str: string) { @@ -647,8 +594,8 @@ function normalizeFileForDiff(str: string) { indent + content .replace(/['"]/g, '"') // Normalize quotes to double. - .replace(/;$/g, "") - ) // Remove trailing semicolons. + .replace(/;$/g, "") // Remove trailing semicolons. + ) }) .join("\n") }