msnav 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +187 -0
- data/exe/msnav +11 -0
- data/lib/msnav/cli.rb +388 -0
- data/lib/msnav/config.rb +79 -0
- data/lib/msnav/ctl.rb +203 -0
- data/lib/msnav/datadir.rb +121 -0
- data/lib/msnav/errors.rb +11 -0
- data/lib/msnav/graph.rb +151 -0
- data/lib/msnav/navigator.rb +326 -0
- data/lib/msnav/server.rb +341 -0
- data/lib/msnav/store.rb +97 -0
- data/lib/msnav/version.rb +5 -0
- data/lib/msnav/windows.rb +221 -0
- data/lib/msnav.rb +23 -0
- metadata +101 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Msnav
|
|
4
|
+
# Cross-service code navigation over the coderag graph — a line-for-line
|
|
5
|
+
# port of coderag/navigation.py's Navigator, answering:
|
|
6
|
+
#
|
|
7
|
+
# definition(file, line0) where an outgoing call/publish LANDS in the
|
|
8
|
+
# other service's source (Faraday call -> Roda
|
|
9
|
+
# route, RabbitMQ publish -> consumer handler,
|
|
10
|
+
# route line -> the Process class it routes to)
|
|
11
|
+
# references(file, line0) who calls the endpoint under the cursor, from
|
|
12
|
+
# other services
|
|
13
|
+
# hover(file, line0) the target endpoint's doc for the call on the line
|
|
14
|
+
# file_targets(file) every cross-service jump point in a file
|
|
15
|
+
# (the editor's CodeLens data)
|
|
16
|
+
#
|
|
17
|
+
# Lines are 0-based at this boundary (LSP convention, same as the HTTP API);
|
|
18
|
+
# the index stores 1-based lines, converted on the way in/out. Locations are
|
|
19
|
+
# returned as {"path", "line", "character"} hashes, ready for the JSON API.
|
|
20
|
+
class Navigator
|
|
21
|
+
# edge kinds whose target is a cross-service / framework "definition"
|
|
22
|
+
DEF_EDGES = ["http_call", "publishes", "routes_to"].freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :graph, :roots
|
|
25
|
+
|
|
26
|
+
def initialize(graph)
|
|
27
|
+
@graph = graph
|
|
28
|
+
# service name -> absolute root path
|
|
29
|
+
@roots = {}
|
|
30
|
+
graph.services.each do |_id, data|
|
|
31
|
+
name = data["name"]
|
|
32
|
+
root = data["root"]
|
|
33
|
+
@roots[name] = root if name && present?(root)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Map an absolute path to [service_name, path_relative_to_service_root],
|
|
38
|
+
# longest matching root wins (nested service dirs). nil when the path is
|
|
39
|
+
# under no indexed service.
|
|
40
|
+
def locate(abs_path)
|
|
41
|
+
abs_path = File.expand_path(abs_path)
|
|
42
|
+
best = nil
|
|
43
|
+
@roots.each do |name, root|
|
|
44
|
+
root_abs = File.expand_path(root)
|
|
45
|
+
rel = begin
|
|
46
|
+
Pathname.new(abs_path).relative_path_from(Pathname.new(root_abs)).to_s
|
|
47
|
+
rescue ArgumentError
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
next if rel.start_with?("..")
|
|
51
|
+
best = [name, rel, root_abs] if best.nil? || root_abs.length > best[2].length
|
|
52
|
+
end
|
|
53
|
+
best && [best[0], best[1].tr(File::SEPARATOR, "/")]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def definition(abs_path, line0, _character = 0)
|
|
57
|
+
loc = locate(abs_path)
|
|
58
|
+
return [] if loc.nil?
|
|
59
|
+
service, rel = loc
|
|
60
|
+
line1 = line0 + 1
|
|
61
|
+
targets = []
|
|
62
|
+
|
|
63
|
+
# 1) cursor inside a class that makes outgoing calls / publishes
|
|
64
|
+
sym_id = covering_symbol(service, rel, line1)
|
|
65
|
+
unless sym_id.nil?
|
|
66
|
+
edges = @graph.out(sym_id, DEF_EDGES)
|
|
67
|
+
# prefer an edge whose call site is exactly on this line
|
|
68
|
+
exact = edges.select { |_t, e| e["line"] == line1 }
|
|
69
|
+
chosen = exact.empty? ? edges : exact
|
|
70
|
+
chosen.each do |tgt, edata|
|
|
71
|
+
kind = edata["kind"]
|
|
72
|
+
if kind == "publishes"
|
|
73
|
+
tnode = @graph.node(tgt) || {}
|
|
74
|
+
if tnode["kind"] == "topic"
|
|
75
|
+
targets.concat(publish_targets(tgt))
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
t = node_target(tgt)
|
|
80
|
+
targets << t if t
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# 2) cursor on a route line -> the Process it routes to (DI indirection)
|
|
85
|
+
ep_id = endpoint_at(service, rel, line1)
|
|
86
|
+
unless ep_id.nil?
|
|
87
|
+
@graph.out(ep_id, ["routes_to"]).each do |tgt, _edata|
|
|
88
|
+
t = node_target(tgt)
|
|
89
|
+
targets << t if t
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
dedupe(targets.map { |t| to_location(t) })
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Cross-service callers of the endpoint under the cursor.
|
|
97
|
+
def references(abs_path, line0, _character = 0)
|
|
98
|
+
loc = locate(abs_path)
|
|
99
|
+
return [] if loc.nil?
|
|
100
|
+
service, rel = loc
|
|
101
|
+
line1 = line0 + 1
|
|
102
|
+
ep_id = endpoint_at(service, rel, line1)
|
|
103
|
+
return [] if ep_id.nil?
|
|
104
|
+
out = []
|
|
105
|
+
@graph.in_edges(ep_id).each do |src, edata|
|
|
106
|
+
next unless edata["kind"] == "http_call"
|
|
107
|
+
src_d = @graph.node(src) || {}
|
|
108
|
+
src_service = src_d["service"]
|
|
109
|
+
call_file = edata["file"] || src_d["file"]
|
|
110
|
+
call_line = edata["line"] || src_d["start_line"] || 1
|
|
111
|
+
abs_p = src_service ? target_path(src_service, call_file) : nil
|
|
112
|
+
next if abs_p.nil?
|
|
113
|
+
out << { path: abs_p, line: [call_line - 1, 0].max, character: 0 }
|
|
114
|
+
end
|
|
115
|
+
dedupe(out.map { |t| to_location(t) })
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Markdown describing the cross-service endpoint the call on this line
|
|
119
|
+
# targets; fires ONLY when an http_call edge's call site is exactly on
|
|
120
|
+
# `line0`. nil when there is nothing to show.
|
|
121
|
+
def hover(abs_path, line0, _character = 0)
|
|
122
|
+
loc = locate(abs_path)
|
|
123
|
+
return nil if loc.nil?
|
|
124
|
+
service, rel = loc
|
|
125
|
+
line1 = line0 + 1
|
|
126
|
+
sym_id = covering_symbol(service, rel, line1)
|
|
127
|
+
return nil if sym_id.nil?
|
|
128
|
+
edges = @graph.out(sym_id, ["http_call"]).select { |_t, e| e["line"] == line1 }
|
|
129
|
+
return nil if edges.empty?
|
|
130
|
+
blocks = []
|
|
131
|
+
edges.each do |tgt, edata|
|
|
132
|
+
tnode = @graph.node(tgt) || {}
|
|
133
|
+
case tnode["kind"]
|
|
134
|
+
when "endpoint"
|
|
135
|
+
b = endpoint_hover(tgt, tnode)
|
|
136
|
+
blocks << b if b
|
|
137
|
+
when "service"
|
|
138
|
+
blocks << "**#{edata['verb'] || '?'} #{edata['path'] || '?'}** — " \
|
|
139
|
+
"route not found in `#{tnode['name']}`"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
return nil if blocks.empty?
|
|
143
|
+
{ "markdown" => blocks.join("\n\n---\n\n") }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# All CROSS-service jump points in a file. Each item: the 0-based source
|
|
147
|
+
# line where the call/publish is, and the target's absolute path +
|
|
148
|
+
# 0-based line in the OTHER service. Same-service targets are excluded —
|
|
149
|
+
# ruby-lsp handles those in-window.
|
|
150
|
+
def file_targets(abs_path)
|
|
151
|
+
loc = locate(abs_path)
|
|
152
|
+
return [] if loc.nil?
|
|
153
|
+
service, rel = loc
|
|
154
|
+
out = []
|
|
155
|
+
@graph.nodes_in_file(service, rel).each do |node_id, d|
|
|
156
|
+
next unless d["kind"] == "symbol"
|
|
157
|
+
@graph.out(node_id, ["http_call", "publishes"]).each do |tgt, edata|
|
|
158
|
+
src_line0 = [(edata["line"] || d["start_line"] || 1) - 1, 0].max
|
|
159
|
+
kind = edata["kind"]
|
|
160
|
+
tnode = @graph.node(tgt) || {}
|
|
161
|
+
if kind == "publishes"
|
|
162
|
+
next unless tnode["kind"] == "topic"
|
|
163
|
+
publish_targets(tgt).each do |t|
|
|
164
|
+
out << target_item(src_line0, t, "publish")
|
|
165
|
+
end
|
|
166
|
+
elsif tnode["kind"] == "endpoint"
|
|
167
|
+
next if tnode["service"] == service # same service -> skip
|
|
168
|
+
t = node_target(tgt)
|
|
169
|
+
out << target_item(src_line0, t, "http") if t
|
|
170
|
+
elsif tnode["kind"] == "service" && tnode["name"] != service
|
|
171
|
+
# service resolved but NO matching route — link to its routes so
|
|
172
|
+
# you can see what it does expose
|
|
173
|
+
rl = service_routes_location(tnode["name"])
|
|
174
|
+
next if rl.nil?
|
|
175
|
+
path, tline = rl
|
|
176
|
+
out << { "line" => src_line0, "path" => path, "target_line" => tline,
|
|
177
|
+
"label" => "#{edata['verb'] || '?'} #{edata['path'] || '?'}" \
|
|
178
|
+
" — route not found in #{tnode['name']}",
|
|
179
|
+
"kind" => "http-unresolved" }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
# dedupe on (source line, target path, target line)
|
|
184
|
+
seen = {}
|
|
185
|
+
out.select do |item|
|
|
186
|
+
key = [item["line"], item["path"], item["target_line"]]
|
|
187
|
+
seen.key?(key) ? false : (seen[key] = true)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
def present?(value)
|
|
194
|
+
!value.nil? && value != ""
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def target_path(service, rel_file)
|
|
198
|
+
root = @roots[service]
|
|
199
|
+
return nil if root.nil? || !present?(rel_file) || rel_file == "?"
|
|
200
|
+
File.join(root, rel_file)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Smallest symbol node (class/module) whose span covers a 1-based line;
|
|
204
|
+
# when the smallest covering node is an endpoint, fall back to the
|
|
205
|
+
# smallest covering *symbol* in the same file.
|
|
206
|
+
def covering_symbol(service, rel_file, line1)
|
|
207
|
+
nid = @graph.covering_symbol(service, rel_file, line1)
|
|
208
|
+
if !nid.nil? && (@graph.node(nid) || {})["kind"] == "symbol"
|
|
209
|
+
return nid
|
|
210
|
+
end
|
|
211
|
+
best_id = nil
|
|
212
|
+
best_span = nil
|
|
213
|
+
@graph.nodes_in_file(service, rel_file).each do |node_id, d|
|
|
214
|
+
next unless d["kind"] == "symbol"
|
|
215
|
+
start_line = d["start_line"]
|
|
216
|
+
next if start_line.nil?
|
|
217
|
+
end_line = d["end_line"] || start_line
|
|
218
|
+
next unless start_line <= line1 && line1 <= end_line
|
|
219
|
+
span = end_line - start_line
|
|
220
|
+
if best_span.nil? || span < best_span
|
|
221
|
+
best_id = node_id
|
|
222
|
+
best_span = span
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
best_id
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Endpoint node whose route definition is on/near (± 2 lines) a 1-based line.
|
|
229
|
+
def endpoint_at(service, rel_file, line1)
|
|
230
|
+
candidates = []
|
|
231
|
+
@graph.nodes_in_file(service, rel_file).each do |node_id, d|
|
|
232
|
+
next unless d["kind"] == "endpoint"
|
|
233
|
+
eline = d["line"]
|
|
234
|
+
next if eline.nil?
|
|
235
|
+
candidates << [(eline - line1).abs, node_id]
|
|
236
|
+
end
|
|
237
|
+
return nil if candidates.empty?
|
|
238
|
+
nearest = candidates.min
|
|
239
|
+
nearest[0] <= 2 ? nearest[1] : nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# A resolved navigation target: {path, line (0-based), character, label,
|
|
243
|
+
# kind}, or nil when the node can't be materialized to a file position.
|
|
244
|
+
def node_target(node_id)
|
|
245
|
+
return nil unless @graph.has_node?(node_id)
|
|
246
|
+
d = @graph.node(node_id)
|
|
247
|
+
kind = d["kind"]
|
|
248
|
+
service = d["service"]
|
|
249
|
+
rel = d["file"]
|
|
250
|
+
line = d["line"] || d["start_line"]
|
|
251
|
+
return nil unless %w[endpoint symbol].include?(kind) &&
|
|
252
|
+
present?(service) && present?(rel) && line && line != 0
|
|
253
|
+
abs_path = target_path(service, rel)
|
|
254
|
+
return nil if abs_path.nil?
|
|
255
|
+
label = if kind == "endpoint"
|
|
256
|
+
"#{d['verb'] || ''} #{d['path'] || ''} [#{service}]"
|
|
257
|
+
else
|
|
258
|
+
"#{d['name'] || node_id} [#{service}]"
|
|
259
|
+
end
|
|
260
|
+
{ path: abs_path, line: [line - 1, 0].max, character: 0,
|
|
261
|
+
label: label, kind: kind }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# publish edge -> topic -> queue(s) -> consumer handler(s), plus each
|
|
265
|
+
# handler's target Process.
|
|
266
|
+
def publish_targets(topic_id)
|
|
267
|
+
out = []
|
|
268
|
+
@graph.out(topic_id, ["binds"]).each do |queue_id, _e|
|
|
269
|
+
@graph.out(queue_id, ["consumes"]).each do |consumer_id, _e2|
|
|
270
|
+
t = node_target(consumer_id)
|
|
271
|
+
out << t if t
|
|
272
|
+
@graph.out(consumer_id, ["calls"]).each do |tgt, _e3|
|
|
273
|
+
ht = node_target(tgt)
|
|
274
|
+
out << ht if ht
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
out
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Topmost route of a service — the landing spot when a call resolved the
|
|
282
|
+
# service but matched no specific route. [abs_path, 0-based line] or nil.
|
|
283
|
+
def service_routes_location(svc_name)
|
|
284
|
+
best = nil
|
|
285
|
+
@graph.endpoints(svc_name).each do |_node_id, d|
|
|
286
|
+
line = d["line"] || 1
|
|
287
|
+
next unless best.nil? || line < best[1]
|
|
288
|
+
abs_path = target_path(svc_name, d["file"])
|
|
289
|
+
best = [abs_path, line] if abs_path
|
|
290
|
+
end
|
|
291
|
+
best && [best[0], [best[1] - 1, 0].max]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def endpoint_hover(ep_id, d)
|
|
295
|
+
verb = d["verb"] || "?"
|
|
296
|
+
path = d["path"] || "?"
|
|
297
|
+
service = d["service"] || "?"
|
|
298
|
+
parts = ["**#{verb} #{path}** · _#{service}_"]
|
|
299
|
+
@graph.out(ep_id, ["routes_to"]).each do |tgt, _e|
|
|
300
|
+
hd = @graph.node(tgt) || {}
|
|
301
|
+
parts << "**Handler:** `#{hd['name']}`" if present?(hd["name"])
|
|
302
|
+
doc = hd["call_doc"] || hd["doc"]
|
|
303
|
+
parts << (present?(doc) ? doc : "_No `@return` documented on the handler._")
|
|
304
|
+
break
|
|
305
|
+
end
|
|
306
|
+
parts.join("\n\n")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def target_item(src_line0, t, kind)
|
|
310
|
+
{ "line" => src_line0, "path" => t[:path], "target_line" => t[:line],
|
|
311
|
+
"label" => t[:label], "kind" => kind }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def to_location(t)
|
|
315
|
+
{ "path" => t[:path], "line" => t[:line], "character" => t[:character] || 0 }
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def dedupe(locations)
|
|
319
|
+
seen = {}
|
|
320
|
+
locations.select do |l|
|
|
321
|
+
key = [l["path"], l["line"], l["character"]]
|
|
322
|
+
seen.key?(key) ? false : (seen[key] = true)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
data/lib/msnav/server.rb
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "webrick"
|
|
5
|
+
|
|
6
|
+
module Msnav
|
|
7
|
+
# The msnav daemon: WEBrick serving the coderag daemon's navigation API.
|
|
8
|
+
#
|
|
9
|
+
# The Holder is the single source of index state (graph + navigator),
|
|
10
|
+
# replaced atomically when the shared DB's `index_generation` moves — a
|
|
11
|
+
# host-side `coderag index` or a peer daemon's rebuild hot-reloads every
|
|
12
|
+
# msnav on the hub within a second, requests always see a consistent index.
|
|
13
|
+
class Holder
|
|
14
|
+
attr_reader :cfg, :store, :windows, :service_root, :service_name
|
|
15
|
+
|
|
16
|
+
def initialize(cfg, service_root: nil, service_name: nil)
|
|
17
|
+
@cfg = cfg
|
|
18
|
+
# service scope: this daemon's container sees ONE service's source at
|
|
19
|
+
# this path (msnav never extracts from it — the scope only names which
|
|
20
|
+
# indexed service this window holds, for the extension's path mapping)
|
|
21
|
+
@service_root = service_root
|
|
22
|
+
@service_name = service_name
|
|
23
|
+
@store = Store.new(cfg.db_path)
|
|
24
|
+
@windows = WindowRegistry.new(@store)
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
@generation = 0
|
|
27
|
+
@db_generation = @store.index_generation
|
|
28
|
+
@navigator = load_navigator
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A service-scoped daemon's identity: the container path holding the
|
|
32
|
+
# service and its canonical (host) location from the shared DB — the
|
|
33
|
+
# exact path mapping the editor extension needs, with no folder-name
|
|
34
|
+
# guessing (mounts like `/app` carry no service name).
|
|
35
|
+
def scope
|
|
36
|
+
return nil unless @service_root
|
|
37
|
+
base = @service_name ||
|
|
38
|
+
File.basename(File.expand_path(@service_root))
|
|
39
|
+
@store.services.each do |name, dirname, canonical_root|
|
|
40
|
+
next unless dirname == base
|
|
41
|
+
return { "service" => name, "dirname" => dirname,
|
|
42
|
+
"service_root" => @service_root.to_s,
|
|
43
|
+
"root" => canonical_root }
|
|
44
|
+
end
|
|
45
|
+
{ "service" => nil, "dirname" => base,
|
|
46
|
+
"service_root" => @service_root.to_s, "root" => nil }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def navigator
|
|
50
|
+
@mutex.synchronize { @navigator }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def generation
|
|
54
|
+
@mutex.synchronize { @generation }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Follower path: hot-swap the navigator when another daemon (or a host
|
|
58
|
+
# reindex) moved the persisted generation. True when a reload happened.
|
|
59
|
+
def maybe_reload
|
|
60
|
+
gen = @store.index_generation
|
|
61
|
+
return false if gen == @db_generation
|
|
62
|
+
nav = load_navigator
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
@navigator = nav
|
|
65
|
+
@generation += 1
|
|
66
|
+
@db_generation = gen
|
|
67
|
+
end
|
|
68
|
+
warn "msnav: index changed in the shared DB — reloaded (generation #{gen})"
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def load_navigator
|
|
75
|
+
Navigator.new(Graph.load(@store, @cfg.workspace_root))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
class Server
|
|
80
|
+
GENERATION_POLL_SECONDS = 1.0
|
|
81
|
+
|
|
82
|
+
# host editor CLI used to open a project folder at a position
|
|
83
|
+
EDITOR_ENV = "CODERAG_EDITOR"
|
|
84
|
+
DEFAULT_EDITOR = "cursor"
|
|
85
|
+
|
|
86
|
+
def initialize(cfg, host: "127.0.0.1", port: 8787,
|
|
87
|
+
service_root: nil, service_name: nil)
|
|
88
|
+
@holder = Holder.new(cfg, service_root: service_root,
|
|
89
|
+
service_name: service_name)
|
|
90
|
+
@host = host
|
|
91
|
+
@port = port
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def start
|
|
95
|
+
server = WEBrick::HTTPServer.new(
|
|
96
|
+
BindAddress: @host, Port: @port, AccessLog: [],
|
|
97
|
+
Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN))
|
|
98
|
+
server.mount_proc("/") { |req, res| dispatch(req, res) }
|
|
99
|
+
|
|
100
|
+
poller = Thread.new do
|
|
101
|
+
loop do
|
|
102
|
+
sleep GENERATION_POLL_SECONDS
|
|
103
|
+
begin
|
|
104
|
+
@holder.maybe_reload
|
|
105
|
+
rescue StandardError => e # the poller must survive anything
|
|
106
|
+
warn "msnav: reload check failed: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
%w[INT TERM].each { |sig| trap(sig) { server.shutdown } }
|
|
112
|
+
puts "msnav daemon on http://#{@host}:#{@port} " \
|
|
113
|
+
"(navigation API for #{@holder.cfg.workspace_root})"
|
|
114
|
+
server.start
|
|
115
|
+
ensure
|
|
116
|
+
poller && poller.kill
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
ROUTES = {
|
|
122
|
+
["GET", "/api/health"] => :health,
|
|
123
|
+
["GET", "/api/nav/definition"] => :nav_definition,
|
|
124
|
+
["GET", "/api/nav/references"] => :nav_references,
|
|
125
|
+
["GET", "/api/nav/hover"] => :nav_hover,
|
|
126
|
+
["GET", "/api/nav/file-targets"] => :nav_file_targets,
|
|
127
|
+
["POST", "/api/register-window"] => :register_window,
|
|
128
|
+
["GET", "/api/window-commands"] => :window_commands,
|
|
129
|
+
["GET", "/api/windows"] => :list_windows,
|
|
130
|
+
["POST", "/api/open"] => :open_in_editor,
|
|
131
|
+
["GET", "/api/services"] => :services,
|
|
132
|
+
["GET", "/api/routes"] => :routes,
|
|
133
|
+
}.freeze
|
|
134
|
+
|
|
135
|
+
def dispatch(req, res)
|
|
136
|
+
handler = ROUTES[[req.request_method, req.path]]
|
|
137
|
+
res["Content-Type"] = "application/json"
|
|
138
|
+
if handler.nil?
|
|
139
|
+
res.status = 404
|
|
140
|
+
res.body = JSON.generate("detail" => "Not Found")
|
|
141
|
+
return
|
|
142
|
+
end
|
|
143
|
+
begin
|
|
144
|
+
res.status = 200
|
|
145
|
+
res.body = JSON.generate(send(handler, req))
|
|
146
|
+
rescue HttpError => e
|
|
147
|
+
res.status = e.status
|
|
148
|
+
res.body = JSON.generate("detail" => e.message)
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
warn "msnav: #{req.request_method} #{req.path} failed: " \
|
|
151
|
+
"#{e.class}: #{e.message}\n #{e.backtrace.first(5).join("\n ")}"
|
|
152
|
+
res.status = 500
|
|
153
|
+
res.body = JSON.generate("detail" => "#{e.class}: #{e.message}")
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
class HttpError < StandardError
|
|
158
|
+
attr_reader :status
|
|
159
|
+
|
|
160
|
+
def initialize(status, message)
|
|
161
|
+
@status = status
|
|
162
|
+
super(message)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def param(req, name)
|
|
167
|
+
value = req.query[name]
|
|
168
|
+
raise HttpError.new(422, "query must include '#{name}'") if value.nil? || value.empty?
|
|
169
|
+
# WEBrick query values are binary-encoded FormData; sqlite3 would bind
|
|
170
|
+
# binary strings as BLOBs (and TEXT comparisons would silently miss),
|
|
171
|
+
# so normalize to a plain UTF-8 String at the boundary
|
|
172
|
+
String.new(value.to_s).force_encoding(Encoding::UTF_8)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def body_json(req)
|
|
176
|
+
JSON.parse(req.body || "")
|
|
177
|
+
rescue JSON::ParserError
|
|
178
|
+
raise HttpError.new(422, "body must be a JSON object")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# ----------------------------------------------------------------- health
|
|
182
|
+
|
|
183
|
+
def health(_req)
|
|
184
|
+
graph = @holder.navigator.graph
|
|
185
|
+
{
|
|
186
|
+
"ok" => true,
|
|
187
|
+
"generation" => @holder.generation,
|
|
188
|
+
"root" => @holder.cfg.workspace_root.to_s,
|
|
189
|
+
"version" => VERSION,
|
|
190
|
+
"pid" => Process.pid,
|
|
191
|
+
"services" => graph.services.map { |_id, d| d["name"] }.compact.sort,
|
|
192
|
+
# canonical service locations (host paths in the service-scoped flow)
|
|
193
|
+
# — callers use these for coverage checks and host opens
|
|
194
|
+
"service_roots" => graph.services.map { |_id, d| d["root"] }
|
|
195
|
+
.reject { |r| r.nil? || r.empty? }.sort,
|
|
196
|
+
# a scoped daemon's own service: the container path it holds and the
|
|
197
|
+
# canonical root — the extension's exact path mapping
|
|
198
|
+
"scope" => @holder.scope,
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------- navigation
|
|
203
|
+
|
|
204
|
+
def nav_definition(req)
|
|
205
|
+
@holder.navigator.definition(param(req, "file"), param(req, "line").to_i)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def nav_references(req)
|
|
209
|
+
@holder.navigator.references(param(req, "file"), param(req, "line").to_i)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def nav_hover(req)
|
|
213
|
+
@holder.navigator.hover(param(req, "file"), param(req, "line").to_i) || {}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def nav_file_targets(req)
|
|
217
|
+
@holder.navigator.file_targets(param(req, "file"))
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------- windows
|
|
221
|
+
|
|
222
|
+
def register_window(req)
|
|
223
|
+
payload = body_json(req)
|
|
224
|
+
window_id = payload["window_id"]
|
|
225
|
+
raise HttpError.new(422, "body must include 'window_id'") if window_id.nil? || window_id.empty?
|
|
226
|
+
@holder.windows.register(window_id, payload["authority"],
|
|
227
|
+
payload["roots"], payload["path_mappings"])
|
|
228
|
+
{ "registered" => true, "window_id" => window_id,
|
|
229
|
+
"windows" => @holder.windows.count }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def window_commands(req)
|
|
233
|
+
window_id = param(req, "window_id")
|
|
234
|
+
if @holder.windows.touch(window_id).nil?
|
|
235
|
+
{ "known" => false, "commands" => [] }
|
|
236
|
+
else
|
|
237
|
+
{ "known" => true, "commands" => @holder.windows.drain(window_id) }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def list_windows(_req)
|
|
242
|
+
now = Time.now.to_f
|
|
243
|
+
@holder.windows.all.map do |r|
|
|
244
|
+
{ "window_id" => r.window_id, "authority" => r.authority,
|
|
245
|
+
"roots" => r.roots, "host_roots" => r.host_roots,
|
|
246
|
+
"alive" => r.alive?(now),
|
|
247
|
+
"last_poll_ago" => (now - r.last_poll).round(1) }
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Open the target at file:line where it is already open: if a live window
|
|
252
|
+
# owns the target, push an open-command to it; otherwise fall back to a
|
|
253
|
+
# new local host window — or, in a container, hand the open back to the
|
|
254
|
+
# caller (its extension has the one host capability we lack).
|
|
255
|
+
def open_in_editor(req)
|
|
256
|
+
payload = body_json(req)
|
|
257
|
+
file = payload["file"]
|
|
258
|
+
line = (payload["line"] || 1).to_i
|
|
259
|
+
raise HttpError.new(422, "body must include 'file'") if file.nil? || file.empty?
|
|
260
|
+
|
|
261
|
+
reg = @holder.windows.enqueue_open(file, line)
|
|
262
|
+
unless reg.nil?
|
|
263
|
+
return { "opened" => true, "routed" => true, "route" => "pushed",
|
|
264
|
+
"window_id" => reg.window_id }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
nav = @holder.navigator
|
|
268
|
+
loc = nav.locate(file)
|
|
269
|
+
folder = loc ? nav.roots[loc[0]] : nil
|
|
270
|
+
folder ||= File.dirname(file) # fall back to the file's dir
|
|
271
|
+
|
|
272
|
+
unless host_editor_reachable?
|
|
273
|
+
# in-container daemon with no live window owning the target: hand the
|
|
274
|
+
# SERVICE FOLDER back to the caller (its extension opens it on the
|
|
275
|
+
# host — the one capability we lack) and park the file:line open; the
|
|
276
|
+
# new window's extension claims it when it registers
|
|
277
|
+
@holder.windows.park_open(file, [line, 1].max)
|
|
278
|
+
return { "opened" => false, "routed" => false, "folder" => folder,
|
|
279
|
+
"path" => file, "line" => [line, 1].max }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
editor = ENV[EDITOR_ENV] || DEFAULT_EDITOR
|
|
283
|
+
launch([editor, folder, "--goto", "#{file}:#{[line, 1].max}:1"])
|
|
284
|
+
{ "opened" => true, "routed" => false, "route" => "fallback",
|
|
285
|
+
"folder" => folder, "goto" => "#{file}:#{line}" }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# ------------------------------------------------------------ diagnostics
|
|
289
|
+
|
|
290
|
+
def services(_req)
|
|
291
|
+
graph = @holder.navigator.graph
|
|
292
|
+
graph.services.sort_by { |id, _d| id }.map do |_id, svc|
|
|
293
|
+
eps = graph.endpoints(svc["name"])
|
|
294
|
+
.sort_by { |_n, d| [d["path"] || "", d["verb"] || ""] }
|
|
295
|
+
{ "name" => svc["name"], "root" => svc["root"],
|
|
296
|
+
"endpoints" => eps.map { |_n, d| { "verb" => d["verb"], "path" => d["path"] } } }
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def routes(req)
|
|
301
|
+
service = req.query["service"]
|
|
302
|
+
graph = @holder.navigator.graph
|
|
303
|
+
out = []
|
|
304
|
+
graph.services.sort_by { |id, _d| id }.each do |_id, svc|
|
|
305
|
+
next if service && !service.empty? && svc["name"] != service
|
|
306
|
+
eps = graph.endpoints(svc["name"])
|
|
307
|
+
.sort_by { |_n, d| [d["path"] || "", d["verb"] || ""] }
|
|
308
|
+
eps.each do |node_id, ep|
|
|
309
|
+
handlers = graph.out(node_id, ["routes_to"])
|
|
310
|
+
.select { |t, _e| graph.has_node?(t) }
|
|
311
|
+
.map { |t, _e| graph.node(t)["name"] }
|
|
312
|
+
out << { "service" => svc["name"], "verb" => ep["verb"],
|
|
313
|
+
"path" => ep["path"], "handler" => handlers.first }
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
out
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# --------------------------------------------------------------- editors
|
|
320
|
+
|
|
321
|
+
# An in-container daemon cannot launch a host editor — /api/open then
|
|
322
|
+
# hands the target back to the caller instead. Overridable for tests and
|
|
323
|
+
# exotic setups via CODERAG_HOST_EDITOR.
|
|
324
|
+
def host_editor_reachable?
|
|
325
|
+
override = ENV["CODERAG_HOST_EDITOR"]
|
|
326
|
+
unless override.nil?
|
|
327
|
+
return !["0", "false", "no", ""].include?(override)
|
|
328
|
+
end
|
|
329
|
+
!File.exist?("/.dockerenv")
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Fire the editor CLI detached; false if the process could not start.
|
|
333
|
+
def launch(cmd)
|
|
334
|
+
pid = Process.spawn(*cmd, out: File::NULL, err: File::NULL)
|
|
335
|
+
Process.detach(pid)
|
|
336
|
+
true
|
|
337
|
+
rescue SystemCallError
|
|
338
|
+
false
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|