Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
47 changes: 47 additions & 0 deletions rewrite.go
Original file line number Diff line number Diff line change
@@ -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}
}
25 changes: 25 additions & 0 deletions rewrite_external_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
151 changes: 151 additions & 0 deletions rewrite_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}