B&W print-friendly diagrams + tinkerpop-0001 + wave-3 proof sections. Squash of 94 local commits onto remote master.
2.8 KiB
| id | repo | severity | status | created |
|---|---|---|---|---|
| solargraph-0002 | Solargraph | MEDIUM | OPEN | 2026-03-26 |
Defect
File: lib/solargraph/api_map/constants.rb:262,267
Pattern: skip.to_a inside recursive inner_get_constants — Set converted to Array for every
Array subtraction, creating O(G × depth) work per namespace resolution
Complexity: O(G × depth × N) where G = gate chain length, depth = namespace tree depth, N = total namespaces
Language: Ruby
Description
inner_get_constants resolves the visible constants for a namespace, walking the include/prepend
chain recursively. A Set<String> named skip prevents re-visiting namespaces. However, when
computing the search gates for each included namespace, the code subtracts the visited set from the
gates array using Array's - operator:
# line 262
pre_fqns = resolve(pre.name, pre.closure.gates - skip.to_a)
# line 267
inc_fqns = resolve(pin.name, pin.closure.gates - skip.to_a)
skip.to_a allocates a new Array on every call. The subsequent Array - operator (Array
difference) is O(G × |skip|) — for each gate in closure.gates (length G), it scans all of
skip.to_a (length = number of already-visited namespaces).
This happens at every recursive call inside inner_get_constants, which visits every namespace in
the inheritance chain. For a project with N namespaces of average depth D and gate chain length G:
Total work = N × D × G × (D/2) = O(N × D² × G)
For a large Rails application (N ≈ 500, D ≈ 5, G ≈ 3): ~1.8 million string comparisons per
get_constants call. get_constants is called during every autocomplete and hover event in the
LSP.
Fix
Replace Array subtraction with a filter that checks Set membership:
# line 262 — before
pre_fqns = resolve(pre.name, pre.closure.gates - skip.to_a)
# line 262 — after
pre_fqns = resolve(pre.name, pre.closure.gates.reject { |g| skip.include?(g) })
# line 267 — before
inc_fqns = resolve(pin.name, pin.closure.gates - skip.to_a)
# line 267 — after
inc_fqns = resolve(pin.name, pin.closure.gates.reject { |g| skip.include?(g) })
Set#include? is O(1). The reject is O(G) rather than O(G × |skip|). Total complexity drops to
O(N × D × G).
Alternatively, maintain skip as an Array from the start (so no .to_a is needed) and use a
parallel Set for the O(1) membership check.
Speedup estimate
2–10× faster constant resolution on large Rails/Hanami projects with deep namespace hierarchies. Most noticeable as reduced LSP hover/completion latency on initial workspace index.
Work required
- Patch in
defects/solargraph/patch/ - Unit test — asserts operation counts before/after using a test namespace graph
- Integration test
- Benchmark — before/after on a project with N=100 and N=500 namespaces
- White paper section