88 "os"
99 "path/filepath"
1010 "regexp"
11+ "strings"
1112
1213 "github.com/ryanolee/go-chaff/internal/util"
1314 "github.com/thoas/go-funk"
@@ -25,6 +26,19 @@ type (
2526
2627 // A map of protocols to their associated document fetchers
2728 documentFetchers map [string ]* documentFetcherInterface
29+
30+ // Maps resolved $id URIs to the real document + JSON pointer path
31+ // where the sub-schema lives, avoiding document duplication.
32+ idAliases map [string ]idAlias
33+ }
34+
35+ // idAlias maps a resolved $id URI back to the parent document and the
36+ // JSON pointer path within that document. For example, a $defs entry
37+ // with "$id": "color" in https://example.com/base.json produces:
38+ // idAlias{documentId: "https://example.com/base.json", path: "#/$defs/color"}
39+ idAlias struct {
40+ documentId string // The real document containing this sub-schema
41+ path string // JSON pointer within documentId, e.g. "#/$defs/color"
2842 }
2943
3044 documentFetcherInterface interface {
@@ -76,7 +90,7 @@ func newDocumentResolver(opts ParserOptions, rootDocument *schemaNode) (*documen
7690 resolvedRootDocumentId = opts .RelativeTo
7791 }
7892
79- return & documentResolver {
93+ resolver := & documentResolver {
8094 // Setup the root document
8195 documentCurrentlyBeingParsedId : opts .RelativeTo ,
8296 parsedExternalDocuments : map [string ]bool {
@@ -86,7 +100,22 @@ func newDocumentResolver(opts ParserOptions, rootDocument *schemaNode) (*documen
86100 resolvedRootDocumentId : rootDocument ,
87101 },
88102 documentFetchers : documentFetchers ,
89- }, nil
103+ idAliases : make (map [string ]idAlias ),
104+ }
105+
106+ // Collect $id aliases from the root document tree so that relative
107+ // $ref values (e.g. $ref: "color") can be resolved without I/O.
108+ baseURI := resolvedRootDocumentId
109+ if rootDocument .Id != nil && isAbsoluteURI (* rootDocument .Id ) {
110+ baseURI = * rootDocument .Id
111+ }
112+
113+ // Merge into existing idAliases
114+ for resolvedId , alias := range collectSubSchemaIds (baseURI , resolvedRootDocumentId , rootDocument ) {
115+ resolver .idAliases [resolvedId ] = alias
116+ }
117+
118+ return resolver , nil
90119}
91120
92121func (r * documentResolver ) GetDocumentIdCurrentlyBeingParsed () string {
@@ -115,21 +144,11 @@ func (r *documentResolver) ResolveDocumentIdAndPath(ref string) (string, string,
115144 }
116145
117146 // If we have no document, it is a local or relative reference and can be handled as such
118- if ! hasDocument || documentID == "" || r . HasNoFetchers () {
147+ if ! hasDocument || documentID == "" {
119148 return r .GetCurrentScope (), path , nil
120149 }
121150
122- fetcher , err := r .getFetcherForRef (ref )
123- if err != nil {
124- return "" , "" , fmt .Errorf ("failed to get document fetcher for document '%s': %w" , ref , err )
125- }
126-
127- resolvedDocumentId , err := fetcher .resolveDocumentId (r .GetCurrentScope (), documentID )
128- if err != nil {
129- return "" , "" , fmt .Errorf ("failed to resolve document ID for reference '%s': %w" , ref , err )
130- }
131-
132- return resolvedDocumentId , path , nil
151+ return r .resolveDocumentRef (documentID , path )
133152}
134153
135154// Traverses a schema node and rewrites any $ref fields to be relative to the given document ID
@@ -223,26 +242,21 @@ func (r *documentResolver) HandleDeferredReferenceResolution(ref string, metadat
223242 }
224243
225244 // If we have no document, it is a local or relative reference and can be handled as such
226- if ! hasDocument || documentID == "" || r . HasNoFetchers () {
245+ if ! hasDocument || documentID == "" {
227246 return r .GetCurrentScope (), path , nil
228247 }
229248
230- fetcher , err := r .getFetcherForRef (documentID )
231- if err != nil {
232- return "" , "" , fmt .Errorf ("failed to get document fetcher for document '%s': %w" , ref , err )
233- }
234-
235- resolvedDocumentId , err := fetcher .resolveDocumentId (r .GetCurrentScope (), documentID )
249+ resolvedDocId , resolvedPath , err := r .resolveDocumentRef (documentID , path )
236250 if err != nil {
237- return "" , "" , fmt . Errorf ( "failed to resolve document ID for reference '%s': %w" , ref , err )
251+ return "" , "" , err
238252 }
239253
240254 // Queue document for parsing if it hasn't already been parsed
241- if ! funk .ContainsString (r .externalDocumentsThatNeedParsing , resolvedDocumentId ) && r .pathWouldRequireNewDocumentParsed (resolvedDocumentId ) {
242- r .externalDocumentsThatNeedParsing = append (r .externalDocumentsThatNeedParsing , resolvedDocumentId )
255+ if ! funk .ContainsString (r .externalDocumentsThatNeedParsing , resolvedDocId ) && r .pathWouldRequireNewDocumentParsed (resolvedDocId ) {
256+ r .externalDocumentsThatNeedParsing = append (r .externalDocumentsThatNeedParsing , resolvedDocId )
243257 }
244258
245- return resolvedDocumentId , path , nil
259+ return resolvedDocId , resolvedPath , nil
246260}
247261
248262/**
@@ -269,42 +283,37 @@ func (r *documentResolver) ResolvePath(metadata *parserMetadata, ref string) (*s
269283 }
270284
271285 // If we have no document, it is a local or relative reference and can be handled as such
272- if ! hasDocument || documentID == "" || r . HasNoFetchers () {
286+ if ! hasDocument || documentID == "" {
273287 subNode , err := resolveSubReferencePath (r .documents [r .GetCurrentScope ()], path , "" )
274288 if err != nil {
275289 return nil , "" , fmt .Errorf ("failed to resolve sub-reference path '%s' in document '%s': %w" , path , r .GetCurrentScope (), err )
276290 }
277291 return subNode , fmt .Sprintf ("%s%s" , r .GetCurrentScope (), path ), nil
278292 }
279293
280- fetcher , err := r .getFetcherForRef (documentID )
294+ resolvedDocId , resolvedPath , err := r .resolveDocumentRef (documentID , path )
281295 if err != nil {
282- return nil , "" , fmt . Errorf ( "failed to get document fetcher for document '%s': %w" , ref , err )
296+ return nil , "" , err
283297 }
284298
285- resolvedDocumentId , err := fetcher .resolveDocumentId (r .GetCurrentScope (), documentID )
286- if resolvedDocumentId == "" {
287- return nil , "" , fmt .Errorf ("failed to resolve document ID for reference '%s': %w" , ref , err )
288- }
289-
290- document , documentLoaded := r .documents [resolvedDocumentId ]
299+ document , documentLoaded := r .documents [resolvedDocId ]
291300
292301 if ! documentLoaded {
293- document , err = r .resolveDocument (resolvedDocumentId )
302+ document , err = r .resolveDocument (resolvedDocId )
294303 if err != nil {
295304 return nil , "" , fmt .Errorf ("failed to resolve document '%s': %w" , documentID , err )
296305 }
297306 }
298307
299- subNode , err := resolveSubReferencePath (document , path , "" )
308+ subNode , err := resolveSubReferencePath (document , resolvedPath , "" )
300309 if err != nil {
301- return nil , "" , fmt .Errorf ("failed to resolve sub-reference path '%s' in document '%s': %w" , path , documentID , err )
310+ return nil , "" , fmt .Errorf ("failed to resolve sub-reference path '%s' in document '%s': %w" , resolvedPath , resolvedDocId , err )
302311 }
303312
304313 // Shift the document scope so base url resolution works correctly
305- r .SetDocumentBeingResolved (resolvedDocumentId )
314+ r .SetDocumentBeingResolved (resolvedDocId )
306315
307- return subNode , fmt .Sprintf ("%s%s" , resolvedDocumentId , path ), nil
316+ return subNode , fmt .Sprintf ("%s%s" , resolvedDocId , resolvedPath ), nil
308317}
309318
310319func (r * documentResolver ) addDocument (id string , node * schemaNode ) {
@@ -318,6 +327,95 @@ func (r *documentResolver) addDocument(id string, node *schemaNode) {
318327 // points into said document
319328 r .documents [id ] = node
320329 r .parsedExternalDocuments [id ] = false
330+
331+ // Collect $id aliases from the new document tree.
332+ baseURI := id
333+ if node .Id != nil && isAbsoluteURI (* node .Id ) {
334+ baseURI = * node .Id
335+ }
336+ for resolvedId , alias := range collectSubSchemaIds (baseURI , id , node ) {
337+ if _ , exists := r .idAliases [resolvedId ]; ! exists {
338+ r .idAliases [resolvedId ] = alias
339+ }
340+ }
341+ }
342+
343+ // collectSubSchemaIds marshals a schema node to a generic map and recursively
344+ // walks the entire structure looking for "$id" entries. Each found $id is
345+ // resolved against its nearest ancestor base URI and recorded as an idAlias.
346+ func collectSubSchemaIds (baseURI string , documentId string , node * schemaNode ) map [string ]idAlias {
347+ data , err := json .Marshal (node )
348+ if err != nil {
349+ return nil
350+ }
351+ var raw map [string ]interface {}
352+ if err := json .Unmarshal (data , & raw ); err != nil {
353+ return nil
354+ }
355+
356+ result := make (map [string ]idAlias )
357+ baseAtPath := map [string ]string {"" : baseURI }
358+
359+ WalkSchema (raw , "" , func (n map [string ]interface {}, path string ) {
360+ idVal , ok := n ["$id" ]
361+ if ! ok {
362+ return
363+ }
364+ idStr , ok := idVal .(string )
365+ if ! ok || idStr == "" {
366+ return
367+ }
368+ resolved := resolveRelativeURI (nearestBaseURI (baseAtPath , path ), idStr )
369+ if resolved == "" {
370+ return
371+ }
372+ baseAtPath [path ] = resolved
373+ if path == "" {
374+ return // root $id is the document identity, not an alias
375+ }
376+ if _ , exists := result [resolved ]; exists {
377+ return
378+ }
379+ result [resolved ] = idAlias {documentId : documentId , path : "#" + path }
380+ })
381+
382+ return result
383+ }
384+
385+ // nearestBaseURI walks up the JSON pointer path to find the closest ancestor
386+ // that established a base URI via $id.
387+ func nearestBaseURI (baseAtPath map [string ]string , path string ) string {
388+ for p := path ; ; {
389+ if base , ok := baseAtPath [p ]; ok {
390+ return base
391+ }
392+ i := strings .LastIndex (p , "/" )
393+ if i < 0 {
394+ break
395+ }
396+ p = p [:i ]
397+ }
398+ return baseAtPath ["" ]
399+ }
400+
401+ // resolveRelativeURI resolves rel against base using standard URL resolution.
402+ // Returns "" if parsing fails.
403+ func resolveRelativeURI (base , rel string ) string {
404+ baseURL , err := url .Parse (base )
405+ if err != nil {
406+ return ""
407+ }
408+ relURL , err := url .Parse (rel )
409+ if err != nil {
410+ return ""
411+ }
412+ return baseURL .ResolveReference (relURL ).String ()
413+ }
414+
415+ // isAbsoluteURI returns true when s looks like an absolute URI (has a scheme).
416+ func isAbsoluteURI (s string ) bool {
417+ u , err := url .Parse (s )
418+ return err == nil && u .Scheme != ""
321419}
322420
323421// Checks if there are more documents that need to be parsed
@@ -339,10 +437,12 @@ func (r *documentResolver) ParseNextDocument(metadata *parserMetadata) (string,
339437 return nextDocumentId , fmt .Errorf ("failed to resolve document '%s': %w" , nextDocumentId , err )
340438 }
341439
342- _ , err = parseRoot ( * documentNode , metadata )
343- // Mark document as parsed
440+ // Mark as parsed before parseRoot so that intra-document $id-based
441+ // refs resolved during parsing don't re-queue this document.
344442 r .parsedExternalDocuments [nextDocumentId ] = true
345443
444+ _ , err = parseRoot (* documentNode , metadata )
445+
346446 if err != nil {
347447 return nextDocumentId , fmt .Errorf ("failed to parse document '%s': %w" , nextDocumentId , err )
348448 }
@@ -375,6 +475,49 @@ func (r *documentResolver) resolveDocument(ref string) (*schemaNode, error) {
375475 return document , nil
376476}
377477
478+ // resolveDocumentRef resolves a document reference to its canonical document
479+ // ID and path. It checks the $id alias table first (a lightweight map of
480+ // resolved $id URI → document + JSON pointer), falling back to I/O-based
481+ // fetchers only if no alias matches.
482+ func (r * documentResolver ) resolveDocumentRef (documentID string , refPath string ) (string , string , error ) {
483+ // Check $id alias table first — resolves bare-name refs like "color"
484+ // to the real document + JSON pointer path with no I/O.
485+ resolved := resolveRelativeURI (r .GetCurrentScope (), documentID )
486+ if resolved != "" {
487+ if alias , exists := r .idAliases [resolved ]; exists {
488+ return alias .documentId , composeJsonPointerPaths (alias .path , refPath ), nil
489+ }
490+ }
491+
492+ // No alias found — fall back to I/O-based fetchers.
493+ if r .HasNoFetchers () {
494+ return r .GetCurrentScope (), refPath , nil
495+ }
496+
497+ fetcher , err := r .getFetcherForRef (documentID )
498+ if err != nil {
499+ return "" , "" , fmt .Errorf ("failed to get document fetcher for document '%s': %w" , documentID , err )
500+ }
501+
502+ resolvedDocumentId , err := fetcher .resolveDocumentId (r .GetCurrentScope (), documentID )
503+ if err != nil {
504+ return "" , "" , fmt .Errorf ("failed to resolve document ID for reference '%s': %w" , documentID , err )
505+ }
506+
507+ return resolvedDocumentId , refPath , nil
508+ }
509+
510+ // composeJsonPointerPaths appends the sub-path from a $ref onto an alias's
511+ // base path. For example, alias "#/$defs/color" + ref "#/type" = "#/$defs/color/type".
512+ // If refPath is "#" (root of the aliased resource), the alias path is returned as-is.
513+ func composeJsonPointerPaths (aliasPath string , refPath string ) string {
514+ if refPath == "#" || refPath == "" {
515+ return aliasPath
516+ }
517+ // refPath is "#/something" — strip the "#" to get "/something"
518+ return aliasPath + refPath [1 :]
519+ }
520+
378521func (r * documentResolver ) getFetcherForRef (documentId string ) (documentFetcherInterface , error ) {
379522 url , err := url .Parse (documentId )
380523 if err != nil {
0 commit comments