@@ -2,6 +2,7 @@ package openapi
22
33import (
44 "context"
5+ "errors"
56 "fmt"
67 "path/filepath"
78 "regexp"
@@ -253,7 +254,10 @@ func bundleSchema(ctx context.Context, schema *oas3.JSONSchema[oas3.Referenceabl
253254 resolvedHash := hashing .Hash (resolvedRefSchema )
254255
255256 // Generate component name with smart conflict resolution
256- componentName := generateComponentNameWithHashConflictResolution (ref , namingStrategy , componentStorage .componentNames , componentStorage .schemaHashes , resolvedHash )
257+ componentName , err := generateComponentNameWithHashConflictResolution (ref , namingStrategy , componentStorage .componentNames , componentStorage .schemaHashes , resolvedHash , opts .TargetLocation )
258+ if err != nil {
259+ return fmt .Errorf ("failed to generate component name for %s: %w" , ref , err )
260+ }
257261
258262 // Store the mapping
259263 componentStorage .externalRefs [ref ] = componentName
@@ -366,7 +370,10 @@ func bundleGenericReference[T any, V interfaces.Validator[T], C marshaller.CoreM
366370 }
367371
368372 // Generate component name
369- componentName := generateComponentName (refStr , namingStrategy , componentStorage .componentNames )
373+ componentName , err := generateComponentName (refStr , namingStrategy , componentStorage .componentNames , opts .TargetLocation )
374+ if err != nil {
375+ return fmt .Errorf ("failed to generate component name for %s: %w" , refStr , err )
376+ }
370377 componentStorage .componentNames [componentName ] = true
371378
372379 // Store the mapping
@@ -405,19 +412,19 @@ func bundleGenericReference[T any, V interfaces.Validator[T], C marshaller.CoreM
405412}
406413
407414// generateComponentName creates a new component name based on the reference and naming strategy
408- func generateComponentName (ref string , strategy BundleNamingStrategy , usedNames map [string ]bool ) string {
415+ func generateComponentName (ref string , strategy BundleNamingStrategy , usedNames map [string ]bool , targetLocation string ) ( string , error ) {
409416 switch strategy {
410417 case BundleNamingFilePath :
411- return generateFilePathBasedNameWithConflictResolution (ref , usedNames )
418+ return generateFilePathBasedNameWithConflictResolution (ref , usedNames , targetLocation )
412419 case BundleNamingCounter :
413- return generateCounterBasedName (ref , usedNames )
420+ return generateCounterBasedName (ref , usedNames ), nil
414421 default :
415- return generateCounterBasedName (ref , usedNames )
422+ return generateCounterBasedName (ref , usedNames ), nil
416423 }
417424}
418425
419426// generateComponentNameWithHashConflictResolution creates a component name with smart conflict resolution based on content hashes
420- func generateComponentNameWithHashConflictResolution (ref string , strategy BundleNamingStrategy , usedNames map [string ]bool , schemaHashes map [string ]string , resolvedHash string ) string {
427+ func generateComponentNameWithHashConflictResolution (ref string , strategy BundleNamingStrategy , usedNames map [string ]bool , schemaHashes map [string ]string , resolvedHash string , targetLocation string ) ( string , error ) {
421428 // Parse the reference to extract the simple name
422429 parts := strings .Split (ref , "#" )
423430 if len (parts ) == 0 {
@@ -454,24 +461,24 @@ func generateComponentNameWithHashConflictResolution(ref string, strategy Bundle
454461 if existingHash , exists := schemaHashes [simpleName ]; exists {
455462 if existingHash == resolvedHash {
456463 // Same content, reuse existing schema
457- return simpleName
464+ return simpleName , nil
458465 }
459466 // Different content with same name - need conflict resolution
460467 // Fall back to the configured naming strategy for conflict resolution
461- return generateComponentName (ref , strategy , usedNames )
468+ return generateComponentName (ref , strategy , usedNames , targetLocation )
462469 }
463470
464471 // No conflict, use simple name
465- return simpleName
472+ return simpleName , nil
466473}
467474
468475// generateFilePathBasedNameWithConflictResolution tries to use simple names first, falling back to file-path-based names for conflicts
469- func generateFilePathBasedNameWithConflictResolution (ref string , usedNames map [string ]bool ) string {
476+ func generateFilePathBasedNameWithConflictResolution (ref string , usedNames map [string ]bool , targetLocation string ) ( string , error ) {
470477 // Parse the reference to extract file path and fragment
471478 parts := strings .Split (ref , "#" )
472479 if len (parts ) == 0 {
473480 // This should never happen as strings.Split never returns nil or empty slice
474- return "unknown"
481+ return "unknown" , nil
475482 }
476483 fragment := ""
477484 if len (parts ) > 1 {
@@ -502,20 +509,20 @@ func generateFilePathBasedNameWithConflictResolution(ref string, usedNames map[s
502509
503510 // Try simple name first
504511 if ! usedNames [simpleName ] {
505- return simpleName
512+ return simpleName , nil
506513 }
507514
508515 // If there's a conflict, fall back to file-path-based naming
509- return generateFilePathBasedName (ref , usedNames )
516+ return generateFilePathBasedName (ref , usedNames , targetLocation )
510517}
511518
512519// generateFilePathBasedName creates names like "some_path_external_yaml~User" or "some_path_external_yaml" for top-level refs
513- func generateFilePathBasedName (ref string , usedNames map [string ]bool ) string {
520+ func generateFilePathBasedName (ref string , usedNames map [string ]bool , targetLocation string ) ( string , error ) {
514521 // Parse the reference to extract file path and fragment
515522 parts := strings .Split (ref , "#" )
516523 if len (parts ) == 0 {
517524 // This should never happen as strings.Split never returns nil or empty slice
518- return "unknown"
525+ return "unknown" , nil
519526 }
520527 filePath := parts [0 ]
521528 fragment := ""
@@ -530,6 +537,14 @@ func generateFilePathBasedName(ref string, usedNames map[string]bool) string {
530537 // Remove leading "./" if present
531538 cleanPath = strings .TrimPrefix (cleanPath , "./" )
532539
540+ // Handle parent directory references more elegantly
541+ // Instead of converting "../" to "___", we'll normalize the path
542+ normalizedPath , err := normalizePathForComponentName (cleanPath , targetLocation )
543+ if err != nil {
544+ return "" , fmt .Errorf ("failed to normalize path %s: %w" , cleanPath , err )
545+ }
546+ cleanPath = normalizedPath
547+
533548 // Replace extension dot with underscore to keep it but make it safe
534549 ext := filepath .Ext (cleanPath )
535550 if ext != "" {
@@ -559,7 +574,99 @@ func generateFilePathBasedName(ref string, usedNames map[string]bool) string {
559574 counter ++
560575 }
561576
562- return componentName
577+ return componentName , nil
578+ }
579+
580+ // normalizePathForComponentName normalizes a file path to create a more readable component name
581+ // by resolving relative paths to their actual directory names using absolute path resolution
582+ func normalizePathForComponentName (path , targetLocation string ) (string , error ) {
583+ if targetLocation == "" {
584+ return "" , errors .New ("target location cannot be empty for path normalization" )
585+ }
586+
587+ // Get the directory of the target location
588+ targetDir := filepath .Dir (targetLocation )
589+
590+ // Resolve the relative path against the target directory to get absolute path
591+ resolvedAbsPath , err := filepath .Abs (filepath .Join (targetDir , path ))
592+ if err != nil {
593+ return "" , fmt .Errorf ("failed to resolve relative path: %w" , err )
594+ }
595+
596+ // Split the original relative path to find where the real path starts (after all the ../)
597+ // Handle both Unix and Windows path separators
598+ normalizedPath := strings .ReplaceAll (path , "\\ " , "/" )
599+ pathParts := strings .Split (normalizedPath , "/" )
600+
601+ // Count parent directory navigations and find the start of the real path
602+ parentCount := 0
603+ realPathStart := len (pathParts ) // Default to end if no real path found
604+ foundRealPath := false
605+
606+ for i , part := range pathParts {
607+ if foundRealPath {
608+ break
609+ }
610+
611+ switch part {
612+ case ".." :
613+ parentCount ++
614+ case "." :
615+ // Skip current directory references
616+ continue
617+ case "" :
618+ // Skip empty parts
619+ continue
620+ default :
621+ // Found the start of the real path
622+ realPathStart = i
623+ foundRealPath = true
624+ }
625+ }
626+
627+ // Get the real path parts (everything after the ../ navigation)
628+ var realPathParts []string
629+ if realPathStart < len (pathParts ) {
630+ realPathParts = pathParts [realPathStart :]
631+ }
632+
633+ // Use the absolute path to get the meaningful directory structure
634+ // Split the absolute path and take the last meaningful parts
635+ absParts := strings .Split (strings .ReplaceAll (resolvedAbsPath , "\\ " , "/" ), "/" )
636+
637+ // We want to include the directory we land on after navigation plus the real path
638+ // For "../../../other/api.yaml" from "openapi/a/b/c/spec.yaml", we want "openapi/other/api.yaml"
639+ // So we need: landing directory (openapi) + real path parts (other/api.yaml)
640+
641+ var resultParts []string
642+
643+ if parentCount > 0 {
644+ // Find the landing directory after going up parentCount levels
645+ // We need at least parentCount + len(realPathParts) parts in the absolute path
646+ requiredParts := 1 + len (realPathParts ) // 1 for landing directory + real path parts
647+
648+ if len (absParts ) < requiredParts {
649+ return "" , fmt .Errorf ("not enough path components in resolved absolute path: got %d, need at least %d" , len (absParts ), requiredParts )
650+ }
651+
652+ // Take the landing directory (the directory we end up in after going up)
653+ landingDirIndex := len (absParts ) - len (realPathParts ) - 1
654+ if landingDirIndex >= 0 && landingDirIndex < len (absParts ) {
655+ landingDir := absParts [landingDirIndex ]
656+ resultParts = append (resultParts , landingDir )
657+ }
658+ }
659+
660+ // Add the real path parts
661+ resultParts = append (resultParts , realPathParts ... )
662+
663+ // Join and clean up the result
664+ result := strings .Join (resultParts , "/" )
665+
666+ // Remove leading "./" if present
667+ result = strings .TrimPrefix (result , "./" )
668+
669+ return result , nil
563670}
564671
565672// generateCounterBasedName creates names like "User_1", "User_2" for conflicts
0 commit comments