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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ jobs:
"$RUNNER_TEMP/read-tinygo" testdata/upstream/codemeta-software.json > "$RUNNER_TEMP/tinygo-output"
"$RUNNER_TEMP/read-go" testdata/upstream/codemeta-software.json > "$RUNNER_TEMP/go-output"
diff -u "$RUNNER_TEMP/go-output" "$RUNNER_TEMP/tinygo-output"
"$RUNNER_TEMP/read-tinygo" testdata/projection.json > "$RUNNER_TEMP/tinygo-output"
"$RUNNER_TEMP/read-go" testdata/projection.json > "$RUNNER_TEMP/go-output"
diff -u "$RUNNER_TEMP/go-output" "$RUNNER_TEMP/tinygo-output"

lint:
runs-on: ubuntu-latest
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package main
import (
"fmt"
"log"
"strings"

"github.com/git-pkgs/codemeta"
)
Expand All @@ -28,7 +29,7 @@ func main() {

fmt.Printf("Name: %s\n", doc.Name())
fmt.Printf("CodeMeta context: %s\n", doc.Version())
fmt.Printf("Software version: %s\n", doc.SoftwareVersion().Text())
fmt.Printf("Software version: %s\n", strings.Join(doc.Strings("version"), ", "))
for _, author := range doc.Author() {
fmt.Println(author.Name(), author.GivenName(), author.FamilyName())
}
Expand All @@ -44,7 +45,7 @@ Documents are immutable and can be read concurrently. Parsing copies retained te

### Metadata

`Name` and `Description` return text. `CodeRepository`, `SoftwareVersion`, `License`, `Keywords`, `ProgrammingLanguages`, `DatePublished`, `DateModified`, `DevelopmentStatus`, and `Identifier` return values that retain the written form. `Version` identifies the pinned CodeMeta context. It does not return the software version.
`Name` and `Description` return the first nonempty projected string. `CodeRepository`, `SoftwareVersion`, `License`, `Keywords`, `ProgrammingLanguages`, `DatePublished`, `DateModified`, `DevelopmentStatus`, and `Identifier` return values that retain the written form. `Version` identifies the pinned CodeMeta context. It does not return the software version.

Use `Get` to access any field, including unknown fields and invalid metadata:

Expand All @@ -55,16 +56,19 @@ for _, keyword := range doc.Keywords().Values() {
}
```

`Strings` projects a term into display text, unwrapping `@list`, `@set`, and `@value` and reading `@id` or `name` from objects. It returns scalar spellings in source order and skips empty strings and nulls:
`Document.Strings` and `Agent.Strings` project a term into display text, unwrapping `@list`, `@set`, and `@value` and reading `@id` or `name` from objects. They resolve fields in their local context, return scalar spellings in source order, and skip empty strings and nulls:

```go
fmt.Println(doc.Strings("keywords"))
fmt.Println(doc.Strings("license"))
for _, author := range doc.Author() {
fmt.Println(author.Strings("givenName"), author.Strings("roleName"))
}
```

Values expose `Kind`, `Text`, `Items`, `Fields`, and `Position`. `Items` returns only array elements; `Values` returns array elements or a single scalar or object. A missing field has kind `Missing` and no values, while explicit JSON `Null` remains a value. Numbers retain their spelling and precision. Dates retain their text.

`Author`, `Contributor`, `Maintainer`, `CopyrightHolder`, and `Funder` return ordered agent views, accepting either a single value or an array. `Agent.Kind` distinguishes people, organisations, strings, ID references, roles, and conflicting fields. `Value` preserves each original value; `Get` accesses its fields. `RoleName` and `Agents` expose a role and its nested agents without discarding the wrapper.
`Author`, `Contributor`, `Maintainer`, `CopyrightHolder`, and `Funder` return ordered agent views, accepting single values, arrays, and list or set containers within arrays. Null entries are skipped. `Agent.Kind` distinguishes people, organisations, strings, ID references, roles, and conflicting fields. `Value` preserves each original value; `Get` accesses its fields. `Name`, `GivenName`, and `FamilyName` return the first nonempty projected string; `Name` also returns a text agent's value. `RoleName` and `Agents` expose a role and its nested agents without discarding the wrapper.

`Document.Get` and `Agent.Get` resolve known aliases and prefixed or full term IRIs when the declared context is usable. An exact written key takes precedence. `Value.Get` always uses the exact key. `Fields` retains written keys and their positions, including both keys when aliases conflict. Both `http://schema.org/` and `https://schema.org/` resolve to the same schema term. CodeMeta namespace IRIs remain specific to each pinned context.

Expand Down
24 changes: 17 additions & 7 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,32 +33,42 @@ func (d *Document) Version() Version {
return d.context.version
}
func inspectContext(v Value) contextState {
return inspectScopedContext(v, nil)
}

func inspectScopedContext(v Value, inherited map[string]termDefinition) contextState {
c := contextState{missing: v.kind == Missing}
c.include(v)
c.include(v, inherited)
if c.conflicting {
c.version = UnknownVersion
}
return c
}
func (c *contextState) include(v Value) {
func (c *contextState) include(v Value, inherited map[string]termDefinition) {
switch v.kind {
case Missing:
return
case Array:
if len(v.items) == 0 {
if len(v.items) == 0 && inherited == nil {
c.unknown = true
}
for _, item := range v.items {
if item.kind == Array {
c.unknown = true
} else {
c.include(item)
c.include(item, inherited)
}
}
case String:
c.selectVersion(contextURL(v.text))
case Object:
if len(v.fields) == 0 || (c.version != UnknownVersion && unchangedDefinitions(v, contextTerms[c.version])) {
terms := inherited
if c.version != UnknownVersion {
terms = contextTerms[c.version]
} else if c.unknown || c.modified || c.conflicting {
terms = nil
}
if unchangedDefinitions(v, terms) {
return
}
for _, version := range []Version{Version2, Version3, VersionMaster} {
Expand Down Expand Up @@ -140,10 +150,10 @@ func unchangedDefinitions(v Value, terms map[string]termDefinition) bool {

func scopedTerms(v Value, inherited map[string]termDefinition) map[string]termDefinition {
if local := v.Get(keywordContext); local.kind != Missing {
if local.kind == Object && unchangedDefinitions(local, inherited) {
state := inspectScopedContext(local, inherited)
if state.version == "" && !state.unknown && !state.modified && !state.conflicting {
return inherited
}
state := inspectContext(local)
if !state.usable() {
return nil
}
Expand Down
17 changes: 13 additions & 4 deletions examples/read/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package main
import (
"fmt"
"os"
"strings"

"github.com/git-pkgs/codemeta"
)
Expand All @@ -26,11 +27,19 @@ func main() {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("Name: %s\nContext: %s\nSoftware version: %s\n", doc.Name(), doc.Version(), doc.SoftwareVersion().Text())
for _, a := range doc.Author() {
fmt.Printf("Author: %s %s %s\n", a.Name(), a.GivenName(), a.FamilyName())
}
fmt.Printf("Name: %s\nContext: %s\nSoftware version: %s\n", doc.Name(), doc.Version(), strings.Join(doc.Strings("version"), ", "))
printAuthors(doc.Author())
for _, d := range doc.Validate() {
fmt.Printf("%d:%d %s %s: %s\n", d.Line, d.Column, d.Path, d.Code, d.Message)
}
}

func printAuthors(authors []codemeta.Agent) {
for _, author := range authors {
fmt.Printf("Author: %s %s %s\n", author.Name(), strings.Join(author.Strings("givenName"), " "), strings.Join(author.Strings("familyName"), " "))
if author.Kind() == codemeta.AgentRole {
fmt.Printf("Role: %s\n", strings.Join(author.Strings("roleName"), ", "))
printAuthors(author.Agents())
}
}
}
12 changes: 12 additions & 0 deletions integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,15 @@ func commandOutput(t *testing.T, dir, name string, args ...string) string {
}
return string(output)
}

func TestConsumerProjection(t *testing.T) {
binary := filepath.Join(t.TempDir(), "read")
commandOutput(t, "", "go", "build", "-o", binary, "./examples/read")
got := commandOutput(t, "", binary, "testdata/projection.json")
const want = "Name: Example\nContext: 3.0\nSoftware version: 1.2, 1.3\n" +
"Author: Ada Augusta Lovelace\nAuthor: Grace Hopper \nAuthor: \n" +
"Role: creator, developer\nAuthor: Team \n"
if got != want {
t.Fatalf("output:\n%s\nwant:\n%s", got, want)
}
}
15 changes: 14 additions & 1 deletion internal/generate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,19 @@ func readRanges(r io.Reader) (map[string]string, error) {
}
}
}
// These properties occur in the pinned contexts but not the property CSV.
for key, expected := range map[string]string{
"codemeta:contIntegration": "URL",
"codemeta:embargoDate": "Date",
"schema:creator": "Organization Person",
"schema:archivedAt": "URL WebPage",
"schema:encodingFormat": "Text URL",
"schema:featureList": "Text URL",
} {
if ranges[key] == "" {
ranges[key] = expected
}
}
return ranges, nil
}
func contains(types, typ string) bool {
Expand All @@ -186,7 +199,7 @@ func translate(expression string) (string, error) {
var types []string
for _, name := range strings.Split(expression, " or ") {
switch name {
case "Text", "URL", "Number", "Integer", "Boolean", "Date", "Datetime", "ComputerLanguage", "Review", "SoftwareApplication", "CreativeWork", "SoftwareSourceCode", "DataFeed", "Organization", "Person", "MediaObject", "Grant", "PropertyValue", "PostalAddress", "ContactPoint", "ScholarlyArticle":
case "Text", "URL", "Number", "Integer", "Boolean", "Date", "Datetime", "ComputerLanguage", "Review", "SoftwareApplication", "CreativeWork", "SoftwareSourceCode", "DataFeed", "Organization", "Person", "MediaObject", "Grant", "PropertyValue", "PostalAddress", "ContactPoint", "ScholarlyArticle", "WebPage":
types = append(types, name)
default:
return "", fmt.Errorf("unsupported property type %q", expression)
Expand Down
128 changes: 128 additions & 0 deletions metadata_regression_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package codemeta_test

import (
"reflect"
"testing"

"github.com/git-pkgs/codemeta"
)

func TestAdditionalPropertyRanges(t *testing.T) {
for _, tc := range []struct {
version, term string
values []any
}{
{"2.0", "contIntegration", []any{"https://example.org/ci"}},
{"2.0", "creator", []any{map[string]any{"@type": "Person", "name": "Ada"}}},
{"2.0", "embargoDate", []any{"2024-02-29"}},
{"master", "archivedAt", []any{"https://example.org/archive", map[string]any{"name": "Archive"}}},
{"master", "encodingFormat", []any{"application/json"}},
{"master", "featureList", []any{"Offline parsing"}},
} {
t.Run(tc.term, func(t *testing.T) {
for _, value := range tc.values {
for _, shape := range []any{value, []any{value}, map[string]any{"@set": []any{value}}} {
doc := parse(t, map[string]any{"@context": contextURL(tc.version), tc.term: shape})
if ds := doc.Validate(); len(ds) != 0 {
t.Fatalf("valid value: %+v", ds)
}
}
}
doc := parse(t, map[string]any{"@context": contextURL(tc.version), tc.term: true})
if ds := doc.Validate(); len(ds) != 1 || ds[0].Code != "value_type" {
t.Fatalf("invalid value: %+v", ds)
}
})
}
}

func TestIdentifierReferenceForms(t *testing.T) {
for _, id := range []string{"", "#person", "../person", "urn:example:person", "_:person", "https://example.org/person"} {
for _, key := range []string{"@id", "id"} {
doc := parse(t, map[string]any{"@context": v3, "author": map[string]any{key: id}})
if ds := doc.Validate(); len(ds) != 0 {
t.Fatalf("%s=%q: %+v", key, id, ds)
}
if a := doc.Author()[0]; a.Kind() != codemeta.AgentReference || a.Identifier().Text() != id {
t.Fatalf("reference changed: %+v", a)
}
}
}
}

func TestAgentTextAndEmptyContainers(t *testing.T) {
doc := parse(t, map[string]any{"@context": v3, "author": []any{nil, map[string]any{"@set": nil}, "Ada"}})
if authors := doc.Author(); len(authors) != 1 || authors[0].Name() != "Ada" || authors[0].Kind() != codemeta.AgentText {
t.Fatalf("authors: %+v", authors)
}
}

func TestAgentStringsLocalContext(t *testing.T) {
for _, tc := range []struct {
context any
want []string
}{
{map[string]any{}, []string{"Ada", "Grace"}},
{[]any{map[string]any{}}, []string{"Ada", "Grace"}},
{[]any{}, []string{"Ada", "Grace"}},
{nil, nil},
{map[string]any{"schema": "https://example.org/"}, nil},
} {
doc := parse(t, map[string]any{"@context": v3, "author": map[string]any{
"@context": tc.context, "schema:name": map[string]any{"@set": []any{"Ada", "Grace"}},
}})
if got := doc.Author()[0].Strings("name"); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("context %v: %v, want %v", tc.context, got, tc.want)
}
}
}

func TestLiteralDirection(t *testing.T) {
for _, direction := range []any{nil, "ltr", "rtl"} {
doc := parse(t, map[string]any{"@context": v3, "name": map[string]any{"@value": "Example", "@direction": direction}})
if ds := doc.Validate(); len(ds) != 0 {
t.Fatal(ds)
}
}
doc := parse(t, map[string]any{"@context": v3, "name": map[string]any{"@value": "Example", "@direction": "sideways"}})
if ds := doc.Validate(); len(ds) != 1 || ds[0].Code != "invalid_direction" {
t.Fatal(ds)
}
}

func FuzzMetadataProjection(f *testing.F) {
for _, seed := range []string{
`{"@context":"https://w3id.org/codemeta/3.0","name":{"@value":"Example"},"author":[{"@set":[{"givenName":"Ada"}]}]}`,
`{"@context":"https://w3id.org/codemeta/3.0","author":{"@context":[{}],"schema:name":{"@list":["Ada","Grace"]}}}`,
`{"author":{"@type":"Role","roleName":{"@value":"creator"},"author":{"name":"Ada"}}}`,
`{"name":[null,{},[],false,12],"author":[null,{"name":null},{"@id":"_:person"}]}`,
} {
f.Add([]byte(seed))
}
f.Fuzz(func(t *testing.T, data []byte) {
doc, err := codemeta.Parse(data)
if err != nil {
return
}
for _, field := range doc.Fields() {
if !reflect.DeepEqual(doc.Strings(field.Name), doc.Strings(field.Name)) {
t.Fatal("nondeterministic document projection")
}
}
for _, relation := range []string{"author", "contributor", "maintainer", "copyrightHolder", "funder"} {
checkAgentProjection(t, agents(doc, relation))
}
})
}

func checkAgentProjection(t *testing.T, values []codemeta.Agent) {
t.Helper()
for _, agent := range values {
for _, field := range agent.Value().Fields() {
if !reflect.DeepEqual(agent.Strings(field.Name), agent.Strings(field.Name)) {
t.Fatal("nondeterministic agent projection")
}
}
checkAgentProjection(t, agent.Agents())
}
}
Loading
Loading