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/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 new file mode 100644 index 0000000..f919245 --- /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 expression.rewriteIdentifiers(rewrite).String() +} + +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 new file mode 100644 index 0000000..0decfde --- /dev/null +++ b/rewrite_test.go @@ -0,0 +1,151 @@ +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 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) + } +}