Class: Msnav::Navigator

Inherits:
Object
  • Object
show all
Defined in:
lib/msnav/navigator.rb

Overview

Cross-service code navigation over the coderag graph — a line-for-line port of coderag/navigation.py's Navigator, answering:

definition(file, line0)   where an outgoing call/publish LANDS in the
                        other service's source (Faraday call -> Roda
                        route, RabbitMQ publish -> consumer handler,
                        route line -> the Process class it routes to)
references(file, line0)   who calls the endpoint under the cursor, from
                        other services
hover(file, line0)        the target endpoint's doc for the call on the line
file_targets(file)        every cross-service jump point in a file
                        (the editor's CodeLens data)

Lines are 0-based at this boundary (LSP convention, same as the HTTP API); the index stores 1-based lines, converted on the way in/out. Locations are returned as "line", "character" hashes, ready for the JSON API.

Constant Summary collapse

DEF_EDGES =

edge kinds whose target is a cross-service / framework "definition"

["http_call", "publishes", "routes_to"].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(graph) ⇒ Navigator

Returns a new instance of Navigator.



26
27
28
29
30
31
32
33
34
35
# File 'lib/msnav/navigator.rb', line 26

def initialize(graph)
  @graph = graph
  # service name -> absolute root path
  @roots = {}
  graph.services.each do |_id, data|
    name = data["name"]
    root = data["root"]
    @roots[name] = root if name && present?(root)
  end
end

Instance Attribute Details

#graphObject (readonly)

Returns the value of attribute graph.



24
25
26
# File 'lib/msnav/navigator.rb', line 24

def graph
  @graph
end

#rootsObject (readonly)

Returns the value of attribute roots.



24
25
26
# File 'lib/msnav/navigator.rb', line 24

def roots
  @roots
end

Instance Method Details

#definition(abs_path, line0, _character = 0) ⇒ Object



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/msnav/navigator.rb', line 56

def definition(abs_path, line0, _character = 0)
  loc = locate(abs_path)
  return [] if loc.nil?
  service, rel = loc
  line1 = line0 + 1
  targets = []

  # 1) cursor inside a class that makes outgoing calls / publishes
  sym_id = covering_symbol(service, rel, line1)
  unless sym_id.nil?
    edges = @graph.out(sym_id, DEF_EDGES)
    # prefer an edge whose call site is exactly on this line
    exact = edges.select { |_t, e| e["line"] == line1 }
    chosen = exact.empty? ? edges : exact
    chosen.each do |tgt, edata|
      kind = edata["kind"]
      if kind == "publishes"
        tnode = @graph.node(tgt) || {}
        if tnode["kind"] == "topic"
          targets.concat(publish_targets(tgt))
          next
        end
      end
      t = node_target(tgt)
      targets << t if t
    end
  end

  # 2) cursor on a route line -> the Process it routes to (DI indirection)
  ep_id = endpoint_at(service, rel, line1)
  unless ep_id.nil?
    @graph.out(ep_id, ["routes_to"]).each do |tgt, _edata|
      t = node_target(tgt)
      targets << t if t
    end
  end

  dedupe(targets.map { |t| to_location(t) })
end

#file_targets(abs_path) ⇒ Object

All CROSS-service jump points in a file. Each item: the 0-based source line where the call/publish is, and the target's absolute path + 0-based line in the OTHER service. Same-service targets are excluded — ruby-lsp handles those in-window.



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/msnav/navigator.rb', line 150

def file_targets(abs_path)
  loc = locate(abs_path)
  return [] if loc.nil?
  service, rel = loc
  out = []
  @graph.nodes_in_file(service, rel).each do |node_id, d|
    next unless d["kind"] == "symbol"
    @graph.out(node_id, ["http_call", "publishes"]).each do |tgt, edata|
      src_line0 = [(edata["line"] || d["start_line"] || 1) - 1, 0].max
      kind = edata["kind"]
      tnode = @graph.node(tgt) || {}
      if kind == "publishes"
        next unless tnode["kind"] == "topic"
        publish_targets(tgt).each do |t|
          out << target_item(src_line0, t, "publish")
        end
      elsif tnode["kind"] == "endpoint"
        next if tnode["service"] == service # same service -> skip
        t = node_target(tgt)
        out << target_item(src_line0, t, "http") if t
      elsif tnode["kind"] == "service" && tnode["name"] != service
        # service resolved but NO matching route — link to its routes so
        # you can see what it does expose
        rl = service_routes_location(tnode["name"])
        next if rl.nil?
        path, tline = rl
        out << { "line" => src_line0, "path" => path, "target_line" => tline,
                 "label" => "#{edata['verb'] || '?'} #{edata['path'] || '?'}" \
                            " — route not found in #{tnode['name']}",
                 "kind" => "http-unresolved" }
      end
    end
  end
  # dedupe on (source line, target path, target line)
  seen = {}
  out.select do |item|
    key = [item["line"], item["path"], item["target_line"]]
    seen.key?(key) ? false : (seen[key] = true)
  end
end

#hover(abs_path, line0, _character = 0) ⇒ Object

Markdown describing the cross-service endpoint the call on this line targets; fires ONLY when an http_call edge's call site is exactly on line0. nil when there is nothing to show.



121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/msnav/navigator.rb', line 121

def hover(abs_path, line0, _character = 0)
  loc = locate(abs_path)
  return nil if loc.nil?
  service, rel = loc
  line1 = line0 + 1
  sym_id = covering_symbol(service, rel, line1)
  return nil if sym_id.nil?
  edges = @graph.out(sym_id, ["http_call"]).select { |_t, e| e["line"] == line1 }
  return nil if edges.empty?
  blocks = []
  edges.each do |tgt, edata|
    tnode = @graph.node(tgt) || {}
    case tnode["kind"]
    when "endpoint"
      b = endpoint_hover(tgt, tnode)
      blocks << b if b
    when "service"
      blocks << "**#{edata['verb'] || '?'} #{edata['path'] || '?'}** — " \
                "route not found in `#{tnode['name']}`"
    end
  end
  return nil if blocks.empty?
  { "markdown" => blocks.join("\n\n---\n\n") }
end

#locate(abs_path) ⇒ Object

Map an absolute path to [service_name, path_relative_to_service_root], longest matching root wins (nested service dirs). nil when the path is under no indexed service.



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# File 'lib/msnav/navigator.rb', line 40

def locate(abs_path)
  abs_path = File.expand_path(abs_path)
  best = nil
  @roots.each do |name, root|
    root_abs = File.expand_path(root)
    rel = begin
      Pathname.new(abs_path).relative_path_from(Pathname.new(root_abs)).to_s
    rescue ArgumentError
      next
    end
    next if rel.start_with?("..")
    best = [name, rel, root_abs] if best.nil? || root_abs.length > best[2].length
  end
  best && [best[0], best[1].tr(File::SEPARATOR, "/")]
end

#references(abs_path, line0, _character = 0) ⇒ Object

Cross-service callers of the endpoint under the cursor.



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/msnav/navigator.rb', line 97

def references(abs_path, line0, _character = 0)
  loc = locate(abs_path)
  return [] if loc.nil?
  service, rel = loc
  line1 = line0 + 1
  ep_id = endpoint_at(service, rel, line1)
  return [] if ep_id.nil?
  out = []
  @graph.in_edges(ep_id).each do |src, edata|
    next unless edata["kind"] == "http_call"
    src_d = @graph.node(src) || {}
    src_service = src_d["service"]
    call_file = edata["file"] || src_d["file"]
    call_line = edata["line"] || src_d["start_line"] || 1
    abs_p = src_service ? target_path(src_service, call_file) : nil
    next if abs_p.nil?
    out << { path: abs_p, line: [call_line - 1, 0].max, character: 0 }
  end
  dedupe(out.map { |t| to_location(t) })
end