@@ -16,6 +16,7 @@ import (
1616
1717 "gorm.io/gorm"
1818
19+ "github.com/tae2089/code-context-graph/internal/ccgref"
1920 "github.com/tae2089/code-context-graph/internal/ctxns"
2021 "github.com/tae2089/code-context-graph/internal/model"
2122 nsfs "github.com/tae2089/code-context-graph/internal/namespacefs"
@@ -112,6 +113,7 @@ func (s *Server) APIHandler() http.Handler {
112113 mux .HandleFunc ("/wiki/api/namespaces" , s .handleNamespaces )
113114 mux .HandleFunc ("/wiki/api/tree" , s .handleTree )
114115 mux .HandleFunc ("/wiki/api/doc" , s .handleDoc )
116+ mux .HandleFunc ("/wiki/api/ref" , s .handleRef )
115117 mux .HandleFunc ("/wiki/api/search" , s .handleSearch )
116118 mux .HandleFunc ("/wiki/api/retrieve" , s .handleRetrieve )
117119 mux .HandleFunc ("/wiki/api/graph" , s .handleGraph )
@@ -393,6 +395,50 @@ func (s *Server) handleDoc(w http.ResponseWriter, r *http.Request) {
393395 })
394396}
395397
398+ // @intent resolve a ccg:// annotation reference to a Wiki target and optional graph node.
399+ func (s * Server ) handleRef (w http.ResponseWriter , r * http.Request ) {
400+ if ! requireMethod (w , r , http .MethodGet ) {
401+ return
402+ }
403+ rawRef := strings .TrimSpace (r .URL .Query ().Get ("ref" ))
404+ if rawRef == "" {
405+ writeError (w , http .StatusBadRequest , "ref must not be empty" , nil )
406+ return
407+ }
408+ ref , err := ccgref .Parse (rawRef )
409+ if err != nil {
410+ writeError (w , http .StatusBadRequest , "parse ref" , err )
411+ return
412+ }
413+ if err := validateNamespace (ref .Namespace ); err != nil {
414+ writeError (w , http .StatusBadRequest , err .Error (), nil )
415+ return
416+ }
417+ idx , indexErr := s .loadIndex (ref .Namespace )
418+ var treeNode * ragindex.TreeNode
419+ if indexErr == nil {
420+ treeNode = findRefTreeNode (idx .Root , ref )
421+ }
422+ graphNode , graphErr := s .findRefGraphNode (r , ref )
423+ if treeNode == nil && graphNode == nil {
424+ if indexErr != nil {
425+ writeError (w , statusForReadErr (indexErr ), "load wiki-index" , indexErr )
426+ return
427+ }
428+ if graphErr != nil && ! errors .Is (graphErr , gorm .ErrRecordNotFound ) {
429+ writeError (w , http .StatusInternalServerError , "resolve graph ref" , graphErr )
430+ return
431+ }
432+ writeError (w , http .StatusNotFound , "ref target not found" , nil )
433+ return
434+ }
435+ writeJSON (w , http .StatusOK , refResponse {
436+ Namespace : ref .Namespace ,
437+ Ref : ref ,
438+ Target : refTargetFromMatches (treeNode , graphNode ),
439+ })
440+ }
441+
396442// @intent assemble selected docs or summaries into one Markdown block for LLM context.
397443func (s * Server ) handleContext (w http.ResponseWriter , r * http.Request ) {
398444 if ! requireMethod (w , r , http .MethodPost ) {
@@ -470,6 +516,23 @@ type retrieveResult struct {
470516 ContentTruncated bool `json:"content_truncated,omitempty"`
471517}
472518
519+ // @intent return the resolved Wiki navigation target for one ccg:// ref.
520+ type refResponse struct {
521+ Namespace string `json:"namespace"`
522+ Ref * ccgref.Ref `json:"ref"`
523+ Target refTarget `json:"target"`
524+ }
525+
526+ // @intent describe the doc and graph destinations available for a resolved ccg:// ref.
527+ type refTarget struct {
528+ Label string `json:"label"`
529+ Kind string `json:"kind"`
530+ Summary string `json:"summary,omitempty"`
531+ DocPath string `json:"doc_path,omitempty"`
532+ GraphNodeID string `json:"graph_node_id,omitempty"`
533+ Details * ragindex.NodeDetails `json:"details,omitempty"`
534+ }
535+
473536// @intent describe one graph node in the Wiki force graph API.
474537type graphNode struct {
475538 ID string `json:"id"`
@@ -677,6 +740,151 @@ func findDocPath(root *ragindex.TreeNode, docPath string) *ragindex.TreeNode {
677740 return nil
678741}
679742
743+ // @intent locate the Wiki tree node that best matches a parsed ccg:// ref.
744+ func findRefTreeNode (root * ragindex.TreeNode , ref * ccgref.Ref ) * ragindex.TreeNode {
745+ if root == nil || ref == nil {
746+ return nil
747+ }
748+ if ref .Path == "" && ref .Symbol == "" {
749+ return root
750+ }
751+ if refPathMatchesTree (root , ref ) {
752+ return root
753+ }
754+ for _ , child := range root .Children {
755+ if found := findRefTreeNode (child , ref ); found != nil {
756+ return found
757+ }
758+ }
759+ return nil
760+ }
761+
762+ // @intent compare a ccg:// path/symbol target against one Wiki tree node.
763+ func refPathMatchesTree (node * ragindex.TreeNode , ref * ccgref.Ref ) bool {
764+ if ref .Symbol != "" {
765+ if node .Details == nil {
766+ return false
767+ }
768+ if ref .Path != "" && ! sameRefPath (node .Details .FilePath , ref .Path ) {
769+ return false
770+ }
771+ return symbolMatches (node .Label , node .Details .QualifiedName , ref .Symbol )
772+ }
773+ if ref .Path == "" {
774+ return false
775+ }
776+ if node .Details != nil && sameRefPath (node .Details .FilePath , ref .Path ) {
777+ return true
778+ }
779+ if node .DocPath != "" && filepath .ToSlash (node .DocPath ) == filepath .ToSlash (docPathForSource (ref .Path )) {
780+ return true
781+ }
782+ idPath := strings .TrimPrefix (strings .TrimPrefix (node .ID , "file:" ), "package:" )
783+ idPath = strings .TrimPrefix (idPath , "folder:" )
784+ return sameRefPath (idPath , ref .Path )
785+ }
786+
787+ // @intent resolve a ccg:// ref against graph nodes so the browser graph can focus the destination.
788+ func (s * Server ) findRefGraphNode (r * http.Request , ref * ccgref.Ref ) (* graphNode , error ) {
789+ if s .db == nil || ref == nil {
790+ return nil , gorm .ErrRecordNotFound
791+ }
792+ if ref .Path == "" && ref .Symbol == "" {
793+ return nil , gorm .ErrRecordNotFound
794+ }
795+ var nodes []model.Node
796+ query := s .db .WithContext (r .Context ()).
797+ Where ("namespace = ?" , ref .Namespace ).
798+ Preload ("Annotation.Tags" ).
799+ Order ("kind ASC, file_path ASC, start_line ASC, qualified_name ASC" )
800+ if ref .Path != "" && ref .Symbol != "" {
801+ query = query .Where ("file_path = ?" , ref .Path )
802+ }
803+ if err := query .Find (& nodes ).Error ; err != nil {
804+ return nil , err
805+ }
806+ for _ , node := range nodes {
807+ if ref .Symbol != "" {
808+ if ! symbolMatches (node .Name , node .QualifiedName , ref .Symbol ) {
809+ continue
810+ }
811+ } else if ref .Path != "" {
812+ if ! sameRefPath (node .FilePath , ref .Path ) && ! strings .HasPrefix (filepath .ToSlash (node .FilePath ), strings .TrimSuffix (filepath .ToSlash (ref .Path ), "/" )+ "/" ) {
813+ continue
814+ }
815+ }
816+ graphNode := graphNodeFromModel (node )
817+ if node .Annotation != nil && graphNode .Details != nil {
818+ graphNode .Details .Annotation = annotationDetailFromModel (node .Annotation )
819+ }
820+ return & graphNode , nil
821+ }
822+ return nil , gorm .ErrRecordNotFound
823+ }
824+
825+ // @intent merge tree and graph matches into one browser navigation payload.
826+ func refTargetFromMatches (treeNode * ragindex.TreeNode , graphNode * graphNode ) refTarget {
827+ target := refTarget {}
828+ if treeNode != nil {
829+ target .Label = treeNode .Label
830+ target .Kind = treeNode .Kind
831+ target .Summary = treeNode .Summary
832+ target .DocPath = treeNode .DocPath
833+ target .Details = treeNode .Details
834+ }
835+ if graphNode != nil {
836+ if target .Label == "" {
837+ target .Label = graphNode .Label
838+ }
839+ if target .Kind == "" {
840+ target .Kind = graphNode .Kind
841+ }
842+ if target .Summary == "" {
843+ target .Summary = strings .TrimSpace (graphNode .FilePath )
844+ }
845+ if target .DocPath == "" {
846+ target .DocPath = graphNode .DocPath
847+ }
848+ if target .Details == nil {
849+ target .Details = graphNode .Details
850+ }
851+ target .GraphNodeID = graphNode .ID
852+ }
853+ return target
854+ }
855+
856+ // @intent convert a stored annotation into the same details shape used by wiki-index.json.
857+ func annotationDetailFromModel (annotation * model.Annotation ) * ragindex.AnnotationDetail {
858+ if annotation == nil {
859+ return nil
860+ }
861+ tags := make ([]ragindex.DocTagDetail , 0 , len (annotation .Tags ))
862+ for _ , tag := range annotation .Tags {
863+ tags = append (tags , ragindex .DocTagDetailFromModel (tag ))
864+ }
865+ return & ragindex.AnnotationDetail {
866+ Summary : strings .TrimSpace (annotation .Summary ),
867+ Context : strings .TrimSpace (annotation .Context ),
868+ Tags : tags ,
869+ }
870+ }
871+
872+ // @intent match ccg:// file paths against graph and Wiki slash-separated paths.
873+ func sameRefPath (left , right string ) bool {
874+ return strings .Trim (filepath .ToSlash (left ), "/" ) == strings .Trim (filepath .ToSlash (right ), "/" )
875+ }
876+
877+ // @intent allow short symbol refs to match names and language-qualified names.
878+ func symbolMatches (name , qualifiedName , want string ) bool {
879+ if want == "" {
880+ return true
881+ }
882+ return name == want ||
883+ qualifiedName == want ||
884+ strings .HasSuffix (qualifiedName , "." + want ) ||
885+ strings .HasSuffix (qualifiedName , "::" + want )
886+ }
887+
680888// @intent render a Wiki tree node as fallback Markdown when no generated file exists.
681889func nodeMarkdown (node * ragindex.TreeNode ) string {
682890 parts := []string {fmt .Sprintf ("# %s" , node .Label )}
0 commit comments