From eff9150256ec55417f8228b55371f7aed368df4c Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 30 Jul 2026 12:25:30 +0100 Subject: [PATCH 1/2] Add SPDX identifier rewriting --- README.md | 20 ++++++ rewrite.go | 47 +++++++++++++ rewrite_test.go | 174 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 rewrite.go create mode 100644 rewrite_test.go diff --git a/README.md b/README.md index c30173d..bc65fb1 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,26 @@ expr, err := spdx.ParseStrict("MIT OR Apache-2.0") // succeeds expr, err := spdx.ParseStrict("Apache 2 OR MIT") // fails ``` +### Rewrite expression identifiers + +Parse an expression once, then replace its identifiers while keeping its +operators and precedence: + +```go +expr, err := spdx.ParseStrict( + "MIT OR GPL-2.0-only WITH Classpath-exception-2.0", +) +rewritten := spdx.RewriteIdentifiers(expr, func(id string) string { + return strings.ToLower(id) +}) +fmt.Println(rewritten) +// "mit OR (gpl-2.0-only WITH classpath-exception-2.0)" +``` + +The callback receives license identifiers, exception identifiers, and complete +`LicenseRef` or `DocumentRef` values in expression order. Replacement values +are not validated as SPDX identifiers. + ### Validate licenses ```go diff --git a/rewrite.go b/rewrite.go new file mode 100644 index 0000000..afc298d --- /dev/null +++ b/rewrite.go @@ -0,0 +1,47 @@ +package spdx + +// RewriteIdentifiers applies rewrite to each license identifier, exception +// identifier, and complete license reference in expression order. Operators, +// modifiers, and operator precedence are preserved. Replacements are inserted +// verbatim and are not validated as SPDX identifiers. +// +// A nil rewrite function returns expression.String(). +func RewriteIdentifiers(expression Expression, rewrite func(string) string) string { + if rewrite == nil { + return expression.String() + } + return rewriteExpressionIdentifiers(expression, rewrite).String() +} + +func rewriteExpressionIdentifiers( + expression Expression, + rewrite func(string) string, +) Expression { + switch node := expression.(type) { + case *License: + license := &License{ + ID: rewrite(node.ID), + Plus: node.Plus, + } + if node.Exception != "" { + license.Exception = rewrite(node.Exception) + } + return license + case *LicenseRef: + return &License{ID: rewrite(node.String())} + case *AndExpression: + return &AndExpression{ + Left: rewriteExpressionIdentifiers(node.Left, rewrite), + Right: rewriteExpressionIdentifiers(node.Right, rewrite), + } + case *OrExpression: + return &OrExpression{ + Left: rewriteExpressionIdentifiers(node.Left, rewrite), + Right: rewriteExpressionIdentifiers(node.Right, rewrite), + } + case *SpecialValue: + return &SpecialValue{Value: node.Value} + default: + panic("spdx: unsupported expression type") + } +} diff --git a/rewrite_test.go b/rewrite_test.go new file mode 100644 index 0000000..7b2aad2 --- /dev/null +++ b/rewrite_test.go @@ -0,0 +1,174 @@ +package spdx + +import ( + "slices" + "strings" + "testing" +) + +func TestRewriteIdentifiers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + rewrite map[string]string + want string + identifiers []string + }{ + { + name: "license", + input: "BSD-3-Clause", + rewrite: map[string]string{"BSD-3-Clause": "bsd-new"}, + want: "bsd-new", + identifiers: []string{ + "BSD-3-Clause", + }, + }, + { + name: "compound expression", + input: "MIT OR Apache-2.0 AND BSD-3-Clause", + rewrite: map[string]string{ + "MIT": "mit", + "Apache-2.0": "apache-2.0", + "BSD-3-Clause": "bsd-new", + }, + want: "mit OR (apache-2.0 AND bsd-new)", + identifiers: []string{ + "MIT", + "Apache-2.0", + "BSD-3-Clause", + }, + }, + { + name: "plus and exception", + input: "GPL-2.0+ OR GPL-2.0-only WITH Classpath-exception-2.0", + rewrite: map[string]string{ + "GPL-2.0": "gpl-2.0", + "GPL-2.0-only": "gpl-2.0", + "Classpath-exception-2.0": "classpath-exception-2.0", + }, + want: "gpl-2.0+ OR (gpl-2.0 WITH classpath-exception-2.0)", + identifiers: []string{ + "GPL-2.0", + "GPL-2.0-only", + "Classpath-exception-2.0", + }, + }, + { + name: "license references", + input: "LicenseRef-scancode-mit AND DocumentRef-vendor:LicenseRef-custom", + rewrite: map[string]string{ + "LicenseRef-scancode-mit": "mit", + "DocumentRef-vendor:LicenseRef-custom": "unknown-spdx", + }, + want: "mit AND unknown-spdx", + identifiers: []string{ + "LicenseRef-scancode-mit", + "DocumentRef-vendor:LicenseRef-custom", + }, + }, + { + name: "repeated identifier", + input: "MIT AND MIT", + rewrite: map[string]string{ + "MIT": "mit", + }, + want: "mit AND mit", + identifiers: []string{"MIT", "MIT"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + expression, err := ParseStrict(test.input) + if err != nil { + t.Fatal(err) + } + original := expression.String() + var identifiers []string + got := RewriteIdentifiers(expression, func(identifier string) string { + identifiers = append(identifiers, identifier) + return test.rewrite[identifier] + }) + if got != test.want { + t.Errorf("RewriteIdentifiers(%q) = %q, want %q", test.input, got, test.want) + } + if !slices.Equal(identifiers, test.identifiers) { + t.Errorf("identifiers = %q, want %q", identifiers, test.identifiers) + } + if expression.String() != original { + t.Errorf("RewriteIdentifiers mutated expression: got %q, want %q", expression, original) + } + }) + } +} + +func TestRewriteIdentifiersSpecialValues(t *testing.T) { + t.Parallel() + + for _, input := range []string{"NONE", "NOASSERTION"} { + expression, err := ParseStrict(input) + if err != nil { + t.Fatal(err) + } + got := RewriteIdentifiers(expression, func(identifier string) string { + t.Fatalf("rewrite called with %q", identifier) + return "" + }) + if got != input { + t.Errorf("RewriteIdentifiers(%q) = %q", input, got) + } + } +} + +func TestRewriteIdentifiersNilFunction(t *testing.T) { + t.Parallel() + + expression, err := ParseStrict("MIT OR Apache-2.0") + if err != nil { + t.Fatal(err) + } + if got := RewriteIdentifiers(expression, nil); got != expression.String() { + t.Errorf("RewriteIdentifiers with nil function = %q", got) + } +} + +func TestRewriteIdentifiersRejectsUnsupportedNode(t *testing.T) { + t.Parallel() + + defer func() { + if recovered := recover(); recovered == nil { + t.Error("RewriteIdentifiers did not panic") + } + }() + RewriteIdentifiers(unsupportedExpression{}, strings.ToLower) +} + +type unsupportedExpression struct{} + +func (unsupportedExpression) String() string { + return "unsupported" +} + +func (unsupportedExpression) Licenses() []string { + return nil +} + +func (unsupportedExpression) isExpr() {} + +func BenchmarkRewriteIdentifiers(b *testing.B) { + expression, err := ParseStrict( + "MIT OR GPL-2.0-only WITH Classpath-exception-2.0", + ) + if err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + for b.Loop() { + RewriteIdentifiers(expression, strings.ToLower) + } +} From 9cb8f760e3f4f66b65323b4d31520942fe8e4cc2 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 30 Jul 2026 12:34:46 +0100 Subject: [PATCH 2/2] Avoid panics for embedded expressions --- parse.go | 5 ++-- rewrite.go | 62 ++++++++++++++++++++-------------------- rewrite_external_test.go | 25 ++++++++++++++++ rewrite_test.go | 23 --------------- 4 files changed, 59 insertions(+), 56 deletions(-) create mode 100644 rewrite_external_test.go diff --git a/parse.go b/parse.go index 6aeca34..c1b2301 100644 --- a/parse.go +++ b/parse.go @@ -13,13 +13,14 @@ type Expression interface { String() string // Licenses returns all license identifiers in the expression. Licenses() []string + rewriteIdentifiers(func(string) string) Expression isExpr() } // License represents a single SPDX license identifier. type License struct { - ID string // The canonical license ID - Plus bool // True if followed by + + ID string // The canonical license ID + Plus bool // True if followed by + Exception string // Exception ID if using WITH } diff --git a/rewrite.go b/rewrite.go index afc298d..f919245 100644 --- a/rewrite.go +++ b/rewrite.go @@ -10,38 +10,38 @@ func RewriteIdentifiers(expression Expression, rewrite func(string) string) stri if rewrite == nil { return expression.String() } - return rewriteExpressionIdentifiers(expression, rewrite).String() + return expression.rewriteIdentifiers(rewrite).String() } -func rewriteExpressionIdentifiers( - expression Expression, - rewrite func(string) string, -) Expression { - switch node := expression.(type) { - case *License: - license := &License{ - ID: rewrite(node.ID), - Plus: node.Plus, - } - if node.Exception != "" { - license.Exception = rewrite(node.Exception) - } - return license - case *LicenseRef: - return &License{ID: rewrite(node.String())} - case *AndExpression: - return &AndExpression{ - Left: rewriteExpressionIdentifiers(node.Left, rewrite), - Right: rewriteExpressionIdentifiers(node.Right, rewrite), - } - case *OrExpression: - return &OrExpression{ - Left: rewriteExpressionIdentifiers(node.Left, rewrite), - Right: rewriteExpressionIdentifiers(node.Right, rewrite), - } - case *SpecialValue: - return &SpecialValue{Value: node.Value} - default: - panic("spdx: unsupported expression type") +func (license *License) rewriteIdentifiers(rewrite func(string) string) Expression { + rewritten := &License{ + ID: rewrite(license.ID), + Plus: license.Plus, } + if license.Exception != "" { + rewritten.Exception = rewrite(license.Exception) + } + return rewritten +} + +func (reference *LicenseRef) rewriteIdentifiers(rewrite func(string) string) Expression { + return &License{ID: rewrite(reference.String())} +} + +func (expression *AndExpression) rewriteIdentifiers(rewrite func(string) string) Expression { + return &AndExpression{ + Left: expression.Left.rewriteIdentifiers(rewrite), + Right: expression.Right.rewriteIdentifiers(rewrite), + } +} + +func (expression *OrExpression) rewriteIdentifiers(rewrite func(string) string) Expression { + return &OrExpression{ + Left: expression.Left.rewriteIdentifiers(rewrite), + Right: expression.Right.rewriteIdentifiers(rewrite), + } +} + +func (special *SpecialValue) rewriteIdentifiers(func(string) string) Expression { + return &SpecialValue{Value: special.Value} } diff --git a/rewrite_external_test.go b/rewrite_external_test.go new file mode 100644 index 0000000..0c1f29d --- /dev/null +++ b/rewrite_external_test.go @@ -0,0 +1,25 @@ +package spdx_test + +import ( + "strings" + "testing" + + "github.com/git-pkgs/spdx" +) + +type expressionWrapper struct { + spdx.Expression +} + +func TestRewriteIdentifiersEmbeddedExpression(t *testing.T) { + t.Parallel() + + expression, err := spdx.ParseStrict("MIT OR Apache-2.0") + if err != nil { + t.Fatal(err) + } + wrapped := expressionWrapper{Expression: expression} + if got := spdx.RewriteIdentifiers(wrapped, strings.ToLower); got != "mit OR apache-2.0" { + t.Errorf("RewriteIdentifiers = %q", got) + } +} diff --git a/rewrite_test.go b/rewrite_test.go index 7b2aad2..0decfde 100644 --- a/rewrite_test.go +++ b/rewrite_test.go @@ -136,29 +136,6 @@ func TestRewriteIdentifiersNilFunction(t *testing.T) { } } -func TestRewriteIdentifiersRejectsUnsupportedNode(t *testing.T) { - t.Parallel() - - defer func() { - if recovered := recover(); recovered == nil { - t.Error("RewriteIdentifiers did not panic") - } - }() - RewriteIdentifiers(unsupportedExpression{}, strings.ToLower) -} - -type unsupportedExpression struct{} - -func (unsupportedExpression) String() string { - return "unsupported" -} - -func (unsupportedExpression) Licenses() []string { - return nil -} - -func (unsupportedExpression) isExpr() {} - func BenchmarkRewriteIdentifiers(b *testing.B) { expression, err := ParseStrict( "MIT OR GPL-2.0-only WITH Classpath-exception-2.0",