diff --git a/go.mod b/go.mod index adaf1607..86d44411 100644 --- a/go.mod +++ b/go.mod @@ -44,5 +44,5 @@ require ( golang.org/x/sys v0.44.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/tools v0.45.0 // indirect ) diff --git a/go.sum b/go.sum index 8a7c864c..d2f94c77 100644 --- a/go.sum +++ b/go.sum @@ -118,8 +118,8 @@ golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cmd/common.go b/internal/cmd/common.go index be1e54a6..371873ba 100644 --- a/internal/cmd/common.go +++ b/internal/cmd/common.go @@ -55,6 +55,7 @@ type reportConfig struct { UseGoPatchPaths bool `envDefault:"false"` MinorChangeThreshold float64 `envDefault:"0.1"` MultilineContextLines int `envDefault:"4"` + IncludeLineNumbers bool `envDefault:"false"` KubernetesEntityDetection bool `envDefault:"true"` AdditionalIdentifiers []string @@ -108,6 +109,7 @@ func reportOptionsFlags() []*pflag.FlagSet { fs.BoolVarP(&reportOptions.UseGoPatchPaths, "use-go-patch-style", "g", reportOptions.UseGoPatchPaths, "use Go-Patch style paths in outputs") fs.Float64VarP(&reportOptions.MinorChangeThreshold, "minor-change-threshold", "", reportOptions.MinorChangeThreshold, "minor change threshold") fs.IntVarP(&reportOptions.MultilineContextLines, "multi-line-context-lines", "", reportOptions.MultilineContextLines, "multi-line context lines") + fs.BoolVarP(&reportOptions.IncludeLineNumbers, "line-numbers", "n", reportOptions.IncludeLineNumbers, "include line numbers in diff output") }), flagSet("Filter Options", func(fs *pflag.FlagSet) { @@ -256,6 +258,7 @@ func writeReport(cmd *cobra.Command, report dyff.Report) error { reportWriter = &dyff.HumanReport{ Report: report, Indent: 2, + IncludeLineNumbers: reportOptions.IncludeLineNumbers, UseIndentLines: reportOptions.UseIndentLines, DoNotInspectCerts: reportOptions.DoNotInspectCerts, NoTableStyle: reportOptions.NoTableStyle, @@ -274,6 +277,7 @@ func writeReport(cmd *cobra.Command, report dyff.Report) error { HumanReport: dyff.HumanReport{ Report: report, Indent: 0, + IncludeLineNumbers: reportOptions.IncludeLineNumbers, UseIndentLines: reportOptions.UseIndentLines, DoNotInspectCerts: reportOptions.DoNotInspectCerts, NoTableStyle: true, @@ -293,6 +297,7 @@ func writeReport(cmd *cobra.Command, report dyff.Report) error { HumanReport: dyff.HumanReport{ Report: report, Indent: 0, + IncludeLineNumbers: reportOptions.IncludeLineNumbers, UseIndentLines: reportOptions.UseIndentLines, DoNotInspectCerts: reportOptions.DoNotInspectCerts, NoTableStyle: true, @@ -312,6 +317,7 @@ func writeReport(cmd *cobra.Command, report dyff.Report) error { HumanReport: dyff.HumanReport{ Report: report, Indent: 0, + IncludeLineNumbers: reportOptions.IncludeLineNumbers, UseIndentLines: reportOptions.UseIndentLines, DoNotInspectCerts: reportOptions.DoNotInspectCerts, NoTableStyle: true, diff --git a/pkg/dyff/core_suite_test.go b/pkg/dyff/core_suite_test.go index 4791a4f8..d5f13231 100644 --- a/pkg/dyff/core_suite_test.go +++ b/pkg/dyff/core_suite_test.go @@ -291,6 +291,25 @@ func humanDiff(diff dyff.Diff) string { return buf.String() } +func humanDiffWithLineNumbers(diff dyff.Diff) string { + reporter := dyff.HumanReport{ + Report: dyff.Report{Diffs: []dyff.Diff{diff}}, + Indent: 2, + UseIndentLines: true, + DoNotInspectCerts: false, + NoTableStyle: false, + OmitHeader: true, + IncludeLineNumbers: true, + } + + var buf bytes.Buffer + if err := reporter.WriteReport(&buf); err != nil { + Fail(err.Error()) + } + + return buf.String() +} + func diffSyntaxDiff(diff dyff.Diff) string { reporter := dyff.DiffSyntaxReport{ PathPrefix: "@@", diff --git a/pkg/dyff/output_human.go b/pkg/dyff/output_human.go index 6379cf47..64cf997d 100644 --- a/pkg/dyff/output_human.go +++ b/pkg/dyff/output_human.go @@ -52,6 +52,7 @@ type stringWriter interface { type HumanReport struct { Report Indent int + IncludeLineNumbers bool UseIndentLines bool MinorChangeThreshold float64 MultilineContextLines int @@ -112,6 +113,14 @@ func (report *HumanReport) WriteReport(out io.Writer) error { func (report *HumanReport) generateHumanDiffOutput(output stringWriter, diff Diff, useGoPatchPaths bool, showPathRoot bool) error { _, _ = output.WriteString("\n") _, _ = output.WriteString(pathToString(diff.Path, useGoPatchPaths, showPathRoot)) + + if report.IncludeLineNumbers { + if lineInfo := report.lineNumberInfo(diff); lineInfo != "" { + _, _ = output.WriteString(" ") + _, _ = output.WriteString(report.colorizer().DimGray(lineInfo)) + } + } + _, _ = output.WriteString("\n") blocks := make([]string, len(diff.Details)) @@ -135,6 +144,37 @@ func (report *HumanReport) generateHumanDiffOutput(output stringWriter, diff Dif return nil } +// lineNumberInfo returns a string describing the line numbers of the change +// based on the From and To nodes of the first detail in the diff. +func (report *HumanReport) lineNumberInfo(diff Diff) string { + if len(diff.Details) == 0 { + return "" + } + + detail := diff.Details[0] + fromLine := 0 + toLine := 0 + + if detail.From != nil && detail.From.Line > 0 { + fromLine = detail.From.Line + } + + if detail.To != nil && detail.To.Line > 0 { + toLine = detail.To.Line + } + + switch { + case fromLine > 0 && toLine > 0: + return fmt.Sprintf("(line %d -> %d)", fromLine, toLine) + case fromLine > 0: + return fmt.Sprintf("(line %d)", fromLine) + case toLine > 0: + return fmt.Sprintf("(line %d)", toLine) + default: + return "" + } +} + // generateHumanDetailOutput only serves as a dispatcher to call the correct sub function for the respective type of change func (report *HumanReport) generateHumanDetailOutput(detail Detail) (string, error) { switch detail.Kind { diff --git a/pkg/dyff/output_human_test.go b/pkg/dyff/output_human_test.go index abc04671..9de6fc62 100644 --- a/pkg/dyff/output_human_test.go +++ b/pkg/dyff/output_human_test.go @@ -21,6 +21,7 @@ package dyff_test import ( + "bytes" "fmt" . "github.com/onsi/ginkgo/v2" @@ -29,6 +30,7 @@ import ( . "github.com/gonvenience/bunt" "github.com/gonvenience/ytbx" + yamlv3 "go.yaml.in/yaml/v3" "github.com/homeport/dyff/pkg/dyff" ) @@ -265,4 +267,129 @@ variables.ROUTER_TLS_PEM.options ) }) }) + + Context("line number output", func() { + BeforeEach(func() { + SetColorSettings(OFF, OFF) + }) + + AfterEach(func() { + SetColorSettings(AUTO, AUTO) + }) + + It("should show both from and to line numbers for modifications", func() { + from := &yamlv3.Node{Kind: yamlv3.ScalarNode, Tag: "!!str", Value: "old-value", Line: 5} + to := &yamlv3.Node{Kind: yamlv3.ScalarNode, Tag: "!!str", Value: "new-value", Line: 8} + + diff := dyff.Diff{ + Path: path("/some/key"), + Details: []dyff.Detail{{ + Kind: dyff.MODIFICATION, + From: from, + To: to, + }}, + } + + output := humanDiffWithLineNumbers(diff) + Expect(output).To(ContainSubstring("(line 5 -> 8)")) + Expect(output).To(ContainSubstring("± value change")) + }) + + It("should show only to-line number for additions", func() { + to := &yamlv3.Node{ + Kind: yamlv3.SequenceNode, + Tag: "!!seq", + Line: 10, + Content: []*yamlv3.Node{ + {Kind: yamlv3.ScalarNode, Tag: "!!str", Value: "new-entry"}, + }, + } + + diff := dyff.Diff{ + Path: path("/some/list"), + Details: []dyff.Detail{{ + Kind: dyff.ADDITION, + From: nil, + To: to, + }}, + } + + output := humanDiffWithLineNumbers(diff) + Expect(output).To(ContainSubstring("(line 10)")) + Expect(output).NotTo(ContainSubstring("->")) + }) + + It("should show only from-line number for removals", func() { + from := &yamlv3.Node{ + Kind: yamlv3.SequenceNode, + Tag: "!!seq", + Line: 15, + Content: []*yamlv3.Node{ + {Kind: yamlv3.ScalarNode, Tag: "!!str", Value: "old-entry"}, + }, + } + + diff := dyff.Diff{ + Path: path("/some/list"), + Details: []dyff.Detail{{ + Kind: dyff.REMOVAL, + From: from, + To: nil, + }}, + } + + output := humanDiffWithLineNumbers(diff) + Expect(output).To(ContainSubstring("(line 15)")) + Expect(output).NotTo(ContainSubstring("->")) + }) + + It("should not show line numbers when nodes have no line info", func() { + diff := singleDiff("/some/key", dyff.MODIFICATION, "old", "new") + + output := humanDiffWithLineNumbers(diff) + Expect(output).NotTo(ContainSubstring("(line")) + }) + + It("should not show line numbers when IncludeLineNumbers is disabled", func() { + from := &yamlv3.Node{Kind: yamlv3.ScalarNode, Tag: "!!str", Value: "old", Line: 5} + to := &yamlv3.Node{Kind: yamlv3.ScalarNode, Tag: "!!str", Value: "new", Line: 8} + + diff := dyff.Diff{ + Path: path("/some/key"), + Details: []dyff.Detail{{ + Kind: dyff.MODIFICATION, + From: from, + To: to, + }}, + } + + // humanDiff does NOT set IncludeLineNumbers + output := humanDiff(diff) + Expect(output).NotTo(ContainSubstring("(line")) + }) + + It("should show line numbers from real parsed YAML files", func() { + from, to := loadFiles("../../assets/testbed/from.yml", "../../assets/testbed/to.yml") + + report, err := dyff.CompareInputFiles(from, to) + Expect(err).To(BeNil()) + Expect(len(report.Diffs)).To(BeNumerically(">", 0)) + + reporter := &dyff.HumanReport{ + Report: report, + Indent: 2, + OmitHeader: true, + IncludeLineNumbers: true, + } + + var buf bytes.Buffer + Expect(reporter.WriteReport(&buf)).To(BeNil()) + + output := buf.String() + // The testbed files are real parsed YAML, so go-yaml should + // populate Line fields and we should see at least one "(line" + Expect(output).To(ContainSubstring("(line")) + }) + }) }) +