|
| 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