feat(cmd): add unified diff output option for formatting

* Implemented `-d`/`--diff` flag to print unified diffs.
* Added `unifiedDiff` function for generating diffs.
* Config discovery now merges fields from `.pgtidy.yaml`.
This commit is contained in:
2026-06-27 19:32:57 +02:00
parent d4e6102932
commit 932c83dbad
10 changed files with 345 additions and 11 deletions
+152
View File
@@ -0,0 +1,152 @@
package main
import (
"fmt"
"strings"
)
const diffContext = 3
// unifiedDiff returns a unified diff between original and formatted. The path
// argument is used in the diff header. Returns "" when the strings are equal.
func unifiedDiff(original, formatted, path string) string {
a := splitAfterNewline(original)
b := splitAfterNewline(formatted)
ops := editScript(a, b)
if len(ops) == 0 {
return ""
}
// Locate hunk ranges: groups of changed lines expanded by diffContext.
type hunk struct{ lo, hi int }
var hunks []hunk
for k, op := range ops {
if op == ' ' {
continue
}
lo := k - diffContext
if lo < 0 {
lo = 0
}
hi := k + diffContext + 1
if hi > len(ops) {
hi = len(ops)
}
if len(hunks) > 0 && lo <= hunks[len(hunks)-1].hi {
if hi > hunks[len(hunks)-1].hi {
hunks[len(hunks)-1].hi = hi
}
} else {
hunks = append(hunks, hunk{lo, hi})
}
}
if len(hunks) == 0 {
return ""
}
// Reconstitute lines for each op: '+' takes from b, '-' from a, ' ' from a.
aIdx, bIdx := 0, 0
type opLine struct {
op byte
text string
}
lines := make([]opLine, len(ops))
for k, op := range ops {
switch op {
case '+':
lines[k] = opLine{'+', b[bIdx]}
bIdx++
case '-':
lines[k] = opLine{'-', a[aIdx]}
aIdx++
default:
lines[k] = opLine{' ', a[aIdx]}
aIdx++
bIdx++
}
}
var sb strings.Builder
fmt.Fprintf(&sb, "--- a/%s\n+++ b/%s\n", path, path)
for _, h := range hunks {
// Count original and formatted line numbers.
aStart, bStart := 1, 1
for k := 0; k < h.lo; k++ {
if lines[k].op != '+' {
aStart++
}
if lines[k].op != '-' {
bStart++
}
}
aCount, bCount := 0, 0
for k := h.lo; k < h.hi; k++ {
if lines[k].op != '+' {
aCount++
}
if lines[k].op != '-' {
bCount++
}
}
fmt.Fprintf(&sb, "@@ -%d,%d +%d,%d @@\n", aStart, aCount, bStart, bCount)
for k := h.lo; k < h.hi; k++ {
sb.WriteByte(lines[k].op)
sb.WriteString(lines[k].text)
if !strings.HasSuffix(lines[k].text, "\n") {
sb.WriteString("\n\\ No newline at end of file\n")
}
}
}
return sb.String()
}
// editScript computes the shortest edit script between a and b as a sequence
// of ops: ' ' (keep), '+' (insert from b), '-' (delete from a).
// Uses an O(m*n) LCS-based approach — suitable for typical SQL file sizes.
func editScript(a, b []string) []byte {
m, n := len(a), len(b)
// dp[i][j] = LCS length of a[i:] and b[j:]
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
for i := m - 1; i >= 0; i-- {
for j := n - 1; j >= 0; j-- {
if a[i] == b[j] {
dp[i][j] = dp[i+1][j+1] + 1
} else if dp[i+1][j] >= dp[i][j+1] {
dp[i][j] = dp[i+1][j]
} else {
dp[i][j] = dp[i][j+1]
}
}
}
var ops []byte
i, j := 0, 0
for i < m || j < n {
switch {
case i < m && j < n && a[i] == b[j]:
ops = append(ops, ' ')
i++
j++
case j < n && (i >= m || dp[i][j+1] >= dp[i+1][j]):
ops = append(ops, '+')
j++
default:
ops = append(ops, '-')
i++
}
}
return ops
}
// splitAfterNewline splits s into lines keeping the trailing newline on each
// line, so that joining the result reconstructs the original string exactly.
func splitAfterNewline(s string) []string {
return strings.SplitAfter(s, "\n")
}
+25 -6
View File
@@ -12,12 +12,14 @@ import (
// cmdFmt implements `pgtidy fmt`. It follows the gofmt model: with no flags it
// prints the formatted result to stdout; -w rewrites in place; -l lists files
// that differ; --check exits non-zero if any input is unformatted.
// that differ; --check exits non-zero if any input is unformatted; -d prints a
// unified diff for each file that would change.
func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
var (
write bool
list bool
check bool
diff bool
files []string
)
for _, a := range args {
@@ -28,6 +30,8 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
list = true
case "--check":
check = true
case "-d", "--diff":
diff = true
case "-h", "--help":
usage(stdout)
return 0
@@ -40,8 +44,16 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
}
}
// TODO: discover and parse .pgtidy.yaml; for now use the house-style default.
st := config.Default()
// Discover config: walk up from the working directory.
wd, err := os.Getwd()
if err != nil {
wd = "."
}
st, err := config.Load(wd)
if err != nil {
fmt.Fprintf(stderr, "%v\n", err)
return 2
}
// stdin → stdout when no files are given.
if len(files) == 0 {
@@ -51,13 +63,16 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 2
}
out := format.File(parser.Parse(string(src)), st)
if check {
switch {
case check:
if out != string(src) {
return 1
}
return 0
case diff:
io.WriteString(stdout, unifiedDiff(string(src), out, "stdin"))
default:
io.WriteString(stdout, out)
}
io.WriteString(stdout, out)
return 0
}
@@ -87,6 +102,10 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if changed {
fmt.Fprintln(stdout, path)
}
case diff:
if changed {
io.WriteString(stdout, unifiedDiff(string(src), out, path))
}
case check:
// handled after loop via anyDiff
default: