Skip to content

Commit f4d7fda

Browse files
authored
feat: resolver files (#16)
* feat(config): resolve source files from resolver documents When the config has `resolvers` entries, the CLI commands (list, convert, search, validate) now read the resolver documents and extract their source file `$ref` entries. This enables using resolver documents as the sole configuration, without needing to also list source files in the `files` config field. Supports both inline sources in `resolutionOrder` entries and named sets referenced via `$ref: "#/sets/<name>"`. Assisted-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review findings for resolver sources - Exclude resolver sources from in-place conversion to prevent accidental rewriting of dependency files - Deduplicate resolved files by path to prevent duplicate tokens when the same file appears in both config files and resolver sources - Return errors from resolveEntry instead of silently skipping them Assisted-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review findings for resolver config - Extract duplicated dedup function to specifier.DedupResolvedFiles - Return error for unrecognized resolver entry shapes instead of nil Assisted-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: strip fragment identifiers from $ref file paths A $ref like "palette.json#/brand" was being passed through as a full path including the fragment, producing invalid filesystem paths. Now strips the fragment before resolving. Assisted-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: decode JSON Pointer escaping in resolver set references Refs like "#/sets/brand~1core" or "#/sets/brand%20core" now correctly resolve to set keys "brand/core" and "brand core" respectively, per RFC 6901 JSON Pointer and percent-encoding specs. Assisted-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a7ed280 commit f4d7fda

11 files changed

Lines changed: 424 additions & 0 deletions

File tree

cmd/convert/convert.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,16 @@ func run(cmd *cobra.Command, args []string) error {
202202
if err != nil {
203203
return fmt.Errorf("error resolving config files: %w", err)
204204
}
205+
206+
// Also resolve sources from resolver documents (not for in-place mode,
207+
// which should only rewrite files explicitly listed in config)
208+
if !inPlace && len(cfg.Resolvers) > 0 {
209+
resolverSources, err := cfg.ResolveResolverSources(specResolver, filesystem, cwd)
210+
if err != nil {
211+
return fmt.Errorf("error resolving resolver sources: %w", err)
212+
}
213+
resolvedFiles = specifier.DedupResolvedFiles(append(resolvedFiles, resolverSources...))
214+
}
205215
} else {
206216
for _, arg := range args {
207217
rf, err := specResolver.Resolve(arg)

cmd/list/list.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ func run(cmd *cobra.Command, args []string) error {
9696
if err != nil {
9797
return fmt.Errorf("error resolving config files: %w", err)
9898
}
99+
100+
// Also resolve sources from resolver documents
101+
if len(cfg.Resolvers) > 0 {
102+
resolverSources, err := cfg.ResolveResolverSources(specResolver, filesystem, cwd)
103+
if err != nil {
104+
return fmt.Errorf("error resolving resolver sources: %w", err)
105+
}
106+
resolvedFiles = specifier.DedupResolvedFiles(append(resolvedFiles, resolverSources...))
107+
}
99108
} else {
100109
for _, arg := range args {
101110
rf, err := specResolver.Resolve(arg)

cmd/search/search.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,15 @@ func run(cmd *cobra.Command, args []string) error {
106106
if err != nil {
107107
return fmt.Errorf("error resolving config files: %w", err)
108108
}
109+
110+
// Also resolve sources from resolver documents
111+
if len(cfg.Resolvers) > 0 {
112+
resolverSources, err := cfg.ResolveResolverSources(specResolver, filesystem, cwd)
113+
if err != nil {
114+
return fmt.Errorf("error resolving resolver sources: %w", err)
115+
}
116+
resolvedFiles = specifier.DedupResolvedFiles(append(resolvedFiles, resolverSources...))
117+
}
109118
} else {
110119
for _, file := range files {
111120
rf, err := specResolver.Resolve(file)

cmd/validate/validate.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ func run(cmd *cobra.Command, args []string) error {
6363
if err != nil {
6464
return fmt.Errorf("error resolving config files: %w", err)
6565
}
66+
67+
// Also resolve sources from resolver documents
68+
if len(cfg.Resolvers) > 0 {
69+
resolverSources, err := cfg.ResolveResolverSources(specResolver, filesystem, cwd)
70+
if err != nil {
71+
return fmt.Errorf("error resolving resolver sources: %w", err)
72+
}
73+
resolvedFiles = specifier.DedupResolvedFiles(append(resolvedFiles, resolverSources...))
74+
}
6675
} else {
6776
for _, arg := range args {
6877
rf, err := specResolver.Resolve(arg)

config/resolver_doc.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
Copyright 2026 Benny Powers. All rights reserved.
3+
Use of this source code is governed by the GPLv3
4+
license that can be found in the LICENSE file.
5+
*/
6+
7+
package config
8+
9+
import (
10+
"encoding/json"
11+
"fmt"
12+
"net/url"
13+
"path/filepath"
14+
"strings"
15+
16+
asimfs "bennypowers.dev/asimonim/fs"
17+
"bennypowers.dev/asimonim/specifier"
18+
)
19+
20+
// resolverDocument represents the structure of a DTCG resolver document.
21+
type resolverDocument struct {
22+
Version string `json:"version"`
23+
Sets map[string]setDef `json:"sets"`
24+
ResolutionOrder json.RawMessage `json:"resolutionOrder"`
25+
}
26+
27+
// setDef represents a named set in a resolver document.
28+
type setDef struct {
29+
Sources []sourceRef `json:"sources"`
30+
}
31+
32+
// sourceRef represents a source reference in a resolver document.
33+
type sourceRef struct {
34+
Ref string `json:"$ref"`
35+
}
36+
37+
// ResolveResolverSources reads resolver documents and returns their source file paths
38+
// as ResolvedFile entries. Each resolver document is parsed to extract $ref entries
39+
// from its resolution order, and those paths are resolved relative to the resolver
40+
// document's directory.
41+
func (c *Config) ResolveResolverSources(resolver specifier.Resolver, filesystem asimfs.FileSystem, rootDir string) ([]*specifier.ResolvedFile, error) {
42+
// First resolve the resolver document paths themselves
43+
resolverFiles, err := c.ResolveResolvers(resolver, filesystem, rootDir)
44+
if err != nil {
45+
return nil, err
46+
}
47+
48+
var result []*specifier.ResolvedFile
49+
seen := make(map[string]bool)
50+
51+
for _, rf := range resolverFiles {
52+
sourcePaths, err := extractResolverSourcePaths(filesystem, rf.Path)
53+
if err != nil {
54+
return nil, fmt.Errorf("failed to extract sources from resolver %s: %w", rf.Specifier, err)
55+
}
56+
57+
for _, srcPath := range sourcePaths {
58+
if seen[srcPath] {
59+
continue
60+
}
61+
seen[srcPath] = true
62+
63+
result = append(result, &specifier.ResolvedFile{
64+
Specifier: srcPath,
65+
Path: srcPath,
66+
Kind: specifier.KindLocal,
67+
})
68+
}
69+
}
70+
71+
return result, nil
72+
}
73+
74+
// extractResolverSourcePaths reads a resolver document and extracts source file paths.
75+
func extractResolverSourcePaths(filesystem asimfs.FileSystem, resolverPath string) ([]string, error) {
76+
data, err := filesystem.ReadFile(resolverPath)
77+
if err != nil {
78+
return nil, fmt.Errorf("failed to read resolver document: %w", err)
79+
}
80+
81+
var doc resolverDocument
82+
if err := json.Unmarshal(data, &doc); err != nil {
83+
return nil, fmt.Errorf("failed to parse resolver document: %w", err)
84+
}
85+
86+
var entries []json.RawMessage
87+
if err := json.Unmarshal(doc.ResolutionOrder, &entries); err != nil {
88+
return nil, fmt.Errorf("failed to parse resolutionOrder: %w", err)
89+
}
90+
91+
resolverDir := filepath.Dir(resolverPath)
92+
var paths []string
93+
seen := make(map[string]bool)
94+
95+
for i, entry := range entries {
96+
entryPaths, err := resolveEntry(entry, doc.Sets)
97+
if err != nil {
98+
return nil, fmt.Errorf("failed to resolve entry %d: %w", i, err)
99+
}
100+
for _, p := range entryPaths {
101+
absPath := resolveRefPath(p, resolverDir)
102+
if !seen[absPath] {
103+
seen[absPath] = true
104+
paths = append(paths, absPath)
105+
}
106+
}
107+
}
108+
109+
return paths, nil
110+
}
111+
112+
// resolveEntry extracts source file paths from a resolution order entry.
113+
func resolveEntry(entry json.RawMessage, sets map[string]setDef) ([]string, error) {
114+
var ref sourceRef
115+
if err := json.Unmarshal(entry, &ref); err == nil && ref.Ref != "" {
116+
if rawName, ok := strings.CutPrefix(ref.Ref, "#/sets/"); ok {
117+
setName := unescapeJSONPointer(rawName)
118+
set, exists := sets[setName]
119+
if !exists {
120+
return nil, fmt.Errorf("referenced set %q not found", setName)
121+
}
122+
return fileRefsFromSources(set.Sources), nil
123+
}
124+
}
125+
126+
var inlineSet struct {
127+
Sources []sourceRef `json:"sources"`
128+
}
129+
if err := json.Unmarshal(entry, &inlineSet); err == nil && len(inlineSet.Sources) > 0 {
130+
return fileRefsFromSources(inlineSet.Sources), nil
131+
}
132+
133+
return nil, fmt.Errorf("unrecognized resolution order entry: %s", string(entry))
134+
}
135+
136+
// fileRefsFromSources extracts file paths from source $ref entries,
137+
// filtering out JSON pointer references.
138+
func fileRefsFromSources(sources []sourceRef) []string {
139+
var paths []string
140+
for _, src := range sources {
141+
if src.Ref == "" || strings.HasPrefix(src.Ref, "#") {
142+
continue
143+
}
144+
// Strip any fragment identifier (e.g., "palette.json#/brand" → "palette.json")
145+
path, _, _ := strings.Cut(src.Ref, "#")
146+
if path != "" {
147+
paths = append(paths, path)
148+
}
149+
}
150+
return paths
151+
}
152+
153+
// unescapeJSONPointer decodes a JSON Pointer token per RFC 6901:
154+
// percent-decoding first, then replacing ~1 with / and ~0 with ~.
155+
func unescapeJSONPointer(s string) string {
156+
if unescaped, err := url.PathUnescape(s); err == nil {
157+
s = unescaped
158+
}
159+
s = strings.ReplaceAll(s, "~1", "/")
160+
s = strings.ReplaceAll(s, "~0", "~")
161+
return s
162+
}
163+
164+
// resolveRefPath resolves a $ref path relative to the resolver document's directory.
165+
func resolveRefPath(refPath, resolverDir string) string {
166+
if filepath.IsAbs(refPath) {
167+
return filepath.Clean(refPath)
168+
}
169+
return filepath.Clean(filepath.Join(resolverDir, refPath))
170+
}

0 commit comments

Comments
 (0)