pikuri-lsp 0.1.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/README.md +87 -0
- data/lib/pikuri/lsp/anchor.rb +397 -0
- data/lib/pikuri/lsp/client_wrapper.rb +581 -0
- data/lib/pikuri/lsp/connection.rb +366 -0
- data/lib/pikuri/lsp/extension.rb +75 -0
- data/lib/pikuri/lsp/location.rb +100 -0
- data/lib/pikuri/lsp/lsp_tool.rb +157 -0
- data/lib/pikuri/lsp/mailbox.rb +98 -0
- data/lib/pikuri/lsp/navigator.rb +340 -0
- data/lib/pikuri/lsp/operation.rb +115 -0
- data/lib/pikuri/lsp/position.rb +72 -0
- data/lib/pikuri/lsp/position_encoding.rb +126 -0
- data/lib/pikuri/lsp/range.rb +65 -0
- data/lib/pikuri/lsp/readiness.rb +185 -0
- data/lib/pikuri/lsp/refusal.rb +23 -0
- data/lib/pikuri/lsp/registry.rb +286 -0
- data/lib/pikuri/lsp/renderer.rb +453 -0
- data/lib/pikuri/lsp/server_progress.rb +57 -0
- data/lib/pikuri/lsp/servers.rb +208 -0
- data/lib/pikuri/lsp/sources.rb +272 -0
- data/lib/pikuri/lsp/symbol_name.rb +48 -0
- data/lib/pikuri/lsp/testing.rb +417 -0
- data/lib/pikuri/lsp/uris.rb +61 -0
- data/lib/pikuri-lsp.rb +39 -0
- metadata +107 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# One value of the +lsp+ tool's +operation+ enum: what the model may ask for,
|
|
6
|
+
# which LSP request answers it, which capability the server must advertise
|
|
7
|
+
# first, and how the answer is rendered.
|
|
8
|
+
#
|
|
9
|
+
# Operation['goToDefinition'].capability # => "definitionProvider"
|
|
10
|
+
# Operation['incomingCalls'].prepare # => "textDocument/prepareCallHierarchy"
|
|
11
|
+
# Operation.names.size # => 10
|
|
12
|
+
#
|
|
13
|
+
# Eight names are the surface three shipped harnesses converged on, kept
|
|
14
|
+
# verbatim so they match what a model has seen elsewhere; +goToTypeDefinition+
|
|
15
|
+
# and +supertypes+ are measured additions.
|
|
16
|
+
#
|
|
17
|
+
# == Static enum, gate at dispatch
|
|
18
|
+
#
|
|
19
|
+
# Capabilities are per *workspace*, not per binary ({ClientWrapper#supports?}),
|
|
20
|
+
# and two registered servers have different sets — so a schema computed from
|
|
21
|
+
# them would have to advertise the union anyway, and would move whenever a
|
|
22
|
+
# server restarted or a project gained a formatter. A moving tool description
|
|
23
|
+
# is worse than a static one the model can learn.
|
|
24
|
+
#
|
|
25
|
+
# == Three absences that are decisions
|
|
26
|
+
#
|
|
27
|
+
# Each looks like an obvious addition, so each is worth a line:
|
|
28
|
+
# +prepareCallHierarchy+ is out because exposing it hands the model an opaque
|
|
29
|
+
# +CallHierarchyItem+ to hold and echo back — the tool runs the prepare hop
|
|
30
|
+
# itself; +subtypes+ is out because +goToImplementation+ answers the same
|
|
31
|
+
# question transitively in one call on the one server whose +subtypes+ is not
|
|
32
|
+
# a +# TODO+ stub; and the write-side operations (rename, formatting, code
|
|
33
|
+
# actions) are what this gem refuses to become.
|
|
34
|
+
class Operation < Data.define(:name, :capability, :request, :prepare, :anchor, :shape, :cap, :summary)
|
|
35
|
+
# @param prepare [String, nil] omitted for a one-hop operation.
|
|
36
|
+
def initialize(name:, capability:, request:, anchor:, shape:, cap:, summary:, prepare: nil)
|
|
37
|
+
super
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Boolean] whether results get the source/tests split. True for the
|
|
41
|
+
# two operations whose volume is dominated by test files — 40 references
|
|
42
|
+
# to one method were 4 call sites, 2 declarations and 34 spec hits, and the
|
|
43
|
+
# 4 are what the model asked for.
|
|
44
|
+
def grouped?
|
|
45
|
+
%w[findReferences incomingCalls].include?(name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Every operation, in the order the tool description lists them.
|
|
49
|
+
#
|
|
50
|
+
# @return [Array<Operation>]
|
|
51
|
+
ALL = [
|
|
52
|
+
new(name: 'goToDefinition', capability: 'definitionProvider',
|
|
53
|
+
request: 'textDocument/definition', anchor: :position, shape: :locations, cap: 20,
|
|
54
|
+
summary: 'where a name is defined. Several answers are normal — a reopened Ruby ' \
|
|
55
|
+
'namespace and a dynamically dispatched method both have more than one.'),
|
|
56
|
+
new(name: 'findReferences', capability: 'referencesProvider',
|
|
57
|
+
request: 'textDocument/references', anchor: :position, shape: :locations, cap: 40,
|
|
58
|
+
summary: 'every use of a name, declarations included — textual uses of the ' \
|
|
59
|
+
'name, not only calls of it.'),
|
|
60
|
+
new(name: 'hover', capability: 'hoverProvider', request: 'textDocument/hover',
|
|
61
|
+
anchor: :position, shape: :hover, cap: 1,
|
|
62
|
+
summary: 'the documentation and signature a server holds for a name — often the ' \
|
|
63
|
+
'whole class comment, and the one operation that answers for a library ' \
|
|
64
|
+
'symbol whose source is not on disk.'),
|
|
65
|
+
new(name: 'documentSymbol', capability: 'documentSymbolProvider',
|
|
66
|
+
request: 'textDocument/documentSymbol', anchor: :document, shape: :symbols, cap: 200,
|
|
67
|
+
summary: 'the outline of one file, filtered to `symbol` when it matches a row — ' \
|
|
68
|
+
'the way to find a method the project-wide index does not hold.'),
|
|
69
|
+
new(name: 'workspaceSymbol', capability: 'workspaceSymbolProvider',
|
|
70
|
+
request: 'workspace/symbol', anchor: :query, shape: :symbols, cap: 25,
|
|
71
|
+
summary: 'search the server\'s whole index by name, which reaches inside ' \
|
|
72
|
+
'dependencies; matching is fuzzy.'),
|
|
73
|
+
new(name: 'goToImplementation', capability: 'implementationProvider',
|
|
74
|
+
request: 'textDocument/implementation', anchor: :position, shape: :locations, cap: 25,
|
|
75
|
+
summary: 'what implements or overrides this — an interface to its implementors, a ' \
|
|
76
|
+
'method to its overriders, and on some servers a class to every subclass ' \
|
|
77
|
+
'below it.'),
|
|
78
|
+
new(name: 'goToTypeDefinition', capability: 'typeDefinitionProvider',
|
|
79
|
+
request: 'textDocument/typeDefinition', anchor: :position, shape: :locations, cap: 10,
|
|
80
|
+
summary: 'the *type* of an expression rather than its declaration — the only way ' \
|
|
81
|
+
'to recover a type written nowhere in the source, as with an inferred local.'),
|
|
82
|
+
new(name: 'incomingCalls', capability: 'callHierarchyProvider',
|
|
83
|
+
prepare: 'textDocument/prepareCallHierarchy', request: 'callHierarchy/incomingCalls',
|
|
84
|
+
anchor: :position, shape: :calls, cap: 40,
|
|
85
|
+
summary: 'which functions call this one, resolved through the call graph rather ' \
|
|
86
|
+
'than by name — narrower and more exact than findReferences.'),
|
|
87
|
+
new(name: 'outgoingCalls', capability: 'callHierarchyProvider',
|
|
88
|
+
prepare: 'textDocument/prepareCallHierarchy', request: 'callHierarchy/outgoingCalls',
|
|
89
|
+
anchor: :position, shape: :calls, cap: 40,
|
|
90
|
+
summary: 'what this calls, without reading the body.'),
|
|
91
|
+
new(name: 'supertypes', capability: 'typeHierarchyProvider',
|
|
92
|
+
prepare: 'textDocument/prepareTypeHierarchy', request: 'typeHierarchy/supertypes',
|
|
93
|
+
anchor: :position, shape: :supertypes, cap: 8,
|
|
94
|
+
summary: 'the ancestor chain of a class, walked level by level to the root — not ' \
|
|
95
|
+
'the one level a definition hop on the superclass name would give.')
|
|
96
|
+
].freeze
|
|
97
|
+
|
|
98
|
+
# @return [Hash{String => Operation}] {ALL} keyed by {#name}.
|
|
99
|
+
BY_NAME = ALL.to_h { |operation| [operation.name, operation] }.freeze
|
|
100
|
+
|
|
101
|
+
# @param name [String] an enum value.
|
|
102
|
+
# @return [Operation, nil] +nil+ for a name outside the enum, which
|
|
103
|
+
# +Tool::Parameters+ refuses before this is reached.
|
|
104
|
+
def self.[](name)
|
|
105
|
+
BY_NAME[name]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Array<String>] every enum value, in {ALL}'s order — what the
|
|
109
|
+
# tool's +operation+ parameter advertises.
|
|
110
|
+
def self.names
|
|
111
|
+
BY_NAME.keys
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# One cursor position in pikuri's coordinates: 1-based line, 1-based
|
|
6
|
+
# character column — the same numbers +read+ and +grep+ print, so a
|
|
7
|
+
# rendered location needs no arithmetic and the model never sees an encoded
|
|
8
|
+
# offset in either direction.
|
|
9
|
+
#
|
|
10
|
+
# The wire is 0-based and code-unit-encoded, and this pair of calls is the
|
|
11
|
+
# only place that is true:
|
|
12
|
+
#
|
|
13
|
+
# line = File.readlines('read.rb')[164] # " workspace.resolve_for_read(path)"
|
|
14
|
+
# pos = Position.new(line: 165, column: line.index('resolve_for_read') + 1)
|
|
15
|
+
# pos.to_wire(line_text: line, encoding: PositionEncoding::UTF8)
|
|
16
|
+
# # => {:line=>164, :character=>14}
|
|
17
|
+
#
|
|
18
|
+
# Position.from_wire({ 'line' => 164, 'character' => 14 },
|
|
19
|
+
# line_text: line, encoding: PositionEncoding::UTF8).to_s
|
|
20
|
+
# # => "165:15"
|
|
21
|
+
#
|
|
22
|
+
# @!attribute [r] line
|
|
23
|
+
# @return [Integer] 1-based line number.
|
|
24
|
+
# @!attribute [r] column
|
|
25
|
+
# @return [Integer] 1-based column, counted in characters (not bytes, not
|
|
26
|
+
# UTF-16 code units).
|
|
27
|
+
Position = Data.define(:line, :column) do
|
|
28
|
+
# Parse LSP's +Position+ object.
|
|
29
|
+
#
|
|
30
|
+
# @param hash [Hash{String => Integer}] +{"line" =>, "character" =>}+,
|
|
31
|
+
# 0-based.
|
|
32
|
+
# @param line_text [String, nil] the target line's text; +nil+ costs
|
|
33
|
+
# column exactness on a line with multi-unit characters, see
|
|
34
|
+
# {PositionEncoding.column_for}.
|
|
35
|
+
# @param encoding [String] the server's negotiated +positionEncoding+.
|
|
36
|
+
# @return [Position]
|
|
37
|
+
# @raise [KeyError] if either wire field is missing.
|
|
38
|
+
def self.from_wire(hash, line_text: nil, encoding: PositionEncoding::DEFAULT)
|
|
39
|
+
new(line: Integer(hash.fetch('line')) + 1,
|
|
40
|
+
column: PositionEncoding.column_for(line_text, Integer(hash.fetch('character')), encoding))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @raise [ArgumentError] if either coordinate is below 1 — a 0 means an
|
|
44
|
+
# unconverted wire value leaked in, which is the bug this type exists
|
|
45
|
+
# to prevent.
|
|
46
|
+
def initialize(line:, column:)
|
|
47
|
+
raise ArgumentError, "line must be >= 1, got #{line}" if line < 1
|
|
48
|
+
raise ArgumentError, "column must be >= 1, got #{column}" if column < 1
|
|
49
|
+
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Render as LSP's +Position+ object.
|
|
54
|
+
#
|
|
55
|
+
# @param line_text [String] the text of {#line}, required because the
|
|
56
|
+
# column's encoding depends on the characters preceding it.
|
|
57
|
+
# @param encoding [String] the server's negotiated +positionEncoding+.
|
|
58
|
+
# @return [Hash{Symbol => Integer}] +{line:, character:}+, 0-based,
|
|
59
|
+
# ready for +JSON.generate+.
|
|
60
|
+
# @raise [ArgumentError] if {#column} points past the end of +line_text+.
|
|
61
|
+
def to_wire(line_text:, encoding: PositionEncoding::DEFAULT)
|
|
62
|
+
{ line: line - 1, character: PositionEncoding.offset_for(line_text, column, encoding) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [String] +"165:15"+ — the form a rendered location appends to a
|
|
66
|
+
# path.
|
|
67
|
+
def to_s
|
|
68
|
+
"#{line}:#{column}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# LSP's +positionEncoding+ values and the offset arithmetic each one
|
|
6
|
+
# implies. A column is a *character* index inside pikuri and a code-unit
|
|
7
|
+
# offset on the wire; column 12 of the same line is three different numbers
|
|
8
|
+
# depending on who is asking:
|
|
9
|
+
#
|
|
10
|
+
# line = 'x = "héllo🎉"' # 'é' is 2 bytes, '🎉' is 4
|
|
11
|
+
# PositionEncoding.offset_for(line, 12, UTF32) # => 11 (just 0-based)
|
|
12
|
+
# PositionEncoding.offset_for(line, 12, UTF16) # => 12 (the emoji counts twice)
|
|
13
|
+
# PositionEncoding.offset_for(line, 12, UTF8) # => 15
|
|
14
|
+
# PositionEncoding.column_for(line, 12, UTF16) # => 12 (round-trips)
|
|
15
|
+
#
|
|
16
|
+
# Converting in the wrong direction, or not at all, yields a *plausible*
|
|
17
|
+
# column — so the server answers confidently about the wrong token instead
|
|
18
|
+
# of failing. {DEFAULT} is what a server that negotiates nothing means.
|
|
19
|
+
module PositionEncoding
|
|
20
|
+
# Offsets count UTF-8 bytes. ruby-lsp accepts pikuri's offer of this.
|
|
21
|
+
UTF8 = 'utf-8'
|
|
22
|
+
|
|
23
|
+
# Offsets count UTF-16 code units, so an astral character (emoji, rare
|
|
24
|
+
# CJK) counts twice. jdtls ignores the offer and stays here.
|
|
25
|
+
UTF16 = 'utf-16'
|
|
26
|
+
|
|
27
|
+
# Offsets count codepoints, i.e. exactly pikuri's own columns.
|
|
28
|
+
UTF32 = 'utf-32'
|
|
29
|
+
|
|
30
|
+
# What a server that answers no +positionEncoding+ means: UTF-16 is the
|
|
31
|
+
# 3.17 default, and the conversion is therefore mandatory code rather
|
|
32
|
+
# than a fallback for a hypothetical server.
|
|
33
|
+
DEFAULT = UTF16
|
|
34
|
+
|
|
35
|
+
# Encodings pikuri offers in +general.positionEncodings+, best first.
|
|
36
|
+
# UTF-32 is out: no measured server offers it, and it would be one more
|
|
37
|
+
# branch nothing exercises.
|
|
38
|
+
OFFERED = [UTF8, UTF16].freeze
|
|
39
|
+
|
|
40
|
+
# Every value a caller may pass.
|
|
41
|
+
SUPPORTED = [UTF8, UTF16, UTF32].freeze
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# Wire offset for a 1-based character column on +line_text+.
|
|
46
|
+
#
|
|
47
|
+
# offset_for('def résolve', 8, UTF8) # => 8 ("def rés" is 8 bytes)
|
|
48
|
+
#
|
|
49
|
+
# @param line_text [String] the line the column points into; a trailing
|
|
50
|
+
# newline is harmless.
|
|
51
|
+
# @param column [Integer] 1-based character column, as {Position} holds it.
|
|
52
|
+
# @param encoding [String] one of {SUPPORTED}.
|
|
53
|
+
# @return [Integer] 0-based offset in that encoding's code units.
|
|
54
|
+
# @raise [ArgumentError] if +column+ is below 1, points past the end of
|
|
55
|
+
# +line_text+ (pikuri derived a column its own line cannot hold — a bug
|
|
56
|
+
# in the caller, not a server quirk), or +encoding+ is unknown.
|
|
57
|
+
def offset_for(line_text, column, encoding)
|
|
58
|
+
raise ArgumentError, "column must be >= 1, got #{column}" if column < 1
|
|
59
|
+
|
|
60
|
+
chars = column - 1
|
|
61
|
+
if chars > line_text.length
|
|
62
|
+
raise ArgumentError,
|
|
63
|
+
"column #{column} is past the end of a #{line_text.length}-character " \
|
|
64
|
+
"line: #{line_text.inspect}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
prefix = line_text[0, chars]
|
|
68
|
+
case encoding
|
|
69
|
+
when UTF32 then chars
|
|
70
|
+
when UTF8 then prefix.bytesize
|
|
71
|
+
when UTF16 then utf16_units(prefix)
|
|
72
|
+
else raise ArgumentError, "unknown positionEncoding #{encoding.inspect}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# 1-based character column for a wire offset into +line_text+.
|
|
77
|
+
#
|
|
78
|
+
# An offset past the end of the line clamps to one-past-the-last
|
|
79
|
+
# character rather than raising: a range's +end+ legitimately sits there,
|
|
80
|
+
# and a server that overshoots is relayed, not fought.
|
|
81
|
+
#
|
|
82
|
+
# @param line_text [String, nil] the line the offset points into, or +nil+
|
|
83
|
+
# when the text is not to hand — the offset is then read as a character
|
|
84
|
+
# count, exact for an all-ASCII line and off by one per preceding
|
|
85
|
+
# multi-unit character otherwise. Locations a server walked to arrive
|
|
86
|
+
# this way, and only their line number gets rendered.
|
|
87
|
+
# @param offset [Integer] 0-based offset in +encoding+'s code units.
|
|
88
|
+
# @param encoding [String] one of {SUPPORTED}.
|
|
89
|
+
# @return [Integer] 1-based character column.
|
|
90
|
+
# @raise [ArgumentError] if +offset+ is negative or +encoding+ is unknown.
|
|
91
|
+
def column_for(line_text, offset, encoding)
|
|
92
|
+
raise ArgumentError, "offset must be >= 0, got #{offset}" if offset.negative?
|
|
93
|
+
raise ArgumentError, "unknown positionEncoding #{encoding.inspect}" unless SUPPORTED.include?(encoding)
|
|
94
|
+
return offset + 1 if line_text.nil? || encoding == UTF32
|
|
95
|
+
|
|
96
|
+
chars =
|
|
97
|
+
if encoding == UTF8
|
|
98
|
+
(line_text.byteslice(0, offset) || line_text).scrub.length
|
|
99
|
+
else
|
|
100
|
+
chars_before_utf16(line_text, offset)
|
|
101
|
+
end
|
|
102
|
+
chars + 1
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# UTF-16 code units +str+ occupies.
|
|
106
|
+
#
|
|
107
|
+
# @param str [String]
|
|
108
|
+
# @return [Integer]
|
|
109
|
+
def utf16_units(str)
|
|
110
|
+
str.each_char.sum { |ch| ch.valid_encoding? && ch.ord > 0xFFFF ? 2 : 1 }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Characters preceding a UTF-16 +offset+, clamped to the line's length.
|
|
114
|
+
def chars_before_utf16(line_text, offset)
|
|
115
|
+
units = 0
|
|
116
|
+
line_text.each_char.with_index do |ch, index|
|
|
117
|
+
return index if units >= offset
|
|
118
|
+
|
|
119
|
+
units += utf16_units(ch)
|
|
120
|
+
end
|
|
121
|
+
line_text.length
|
|
122
|
+
end
|
|
123
|
+
private_class_method :chars_before_utf16
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# A span between two {Position}s, in pikuri's 1-based coordinates. Servers
|
|
6
|
+
# answer with ranges everywhere — a definition's whole body, an
|
|
7
|
+
# identifier's own extent, the slice of a class file worth showing — so
|
|
8
|
+
# this is mostly a parse target:
|
|
9
|
+
#
|
|
10
|
+
# range = Range.from_wire({ 'start' => { 'line' => 12, 'character' => 6 },
|
|
11
|
+
# 'end' => { 'line' => 12, 'character' => 22 } },
|
|
12
|
+
# text: File.read(path), encoding: PositionEncoding::UTF8)
|
|
13
|
+
# range.to_s # => "13:7-13:23"
|
|
14
|
+
# range.start.line # => 13
|
|
15
|
+
#
|
|
16
|
+
# There is deliberately no +#to_wire+: pikuri sends positions (an anchor
|
|
17
|
+
# for a query), never ranges. The one client-to-server range in LSP is a
|
|
18
|
+
# +didChange+ edit, and pikuri re-sends +didOpen+ instead.
|
|
19
|
+
#
|
|
20
|
+
# Note the shadowing: inside +Pikuri::Lsp+, +Range+ is this class, so
|
|
21
|
+
# Ruby's own needs +::Range+.
|
|
22
|
+
#
|
|
23
|
+
# @!attribute [r] start
|
|
24
|
+
# @return [Position] first position inside the span.
|
|
25
|
+
# @!attribute [r] end
|
|
26
|
+
# @return [Position] one position *past* the span, LSP's half-open
|
|
27
|
+
# convention, so a zero-width range has +start == end+.
|
|
28
|
+
Range = Data.define(:start, :end) do
|
|
29
|
+
# Parse LSP's +Range+ object.
|
|
30
|
+
#
|
|
31
|
+
# @param hash [Hash{String => Hash}] +{"start" =>, "end" =>}+.
|
|
32
|
+
# @param text [String, nil] the *whole* document the range points into,
|
|
33
|
+
# which is what makes both columns exact; +nil+ when the text is not to
|
|
34
|
+
# hand (see {PositionEncoding.column_for}).
|
|
35
|
+
# @param encoding [String] the server's negotiated +positionEncoding+.
|
|
36
|
+
# @return [Range]
|
|
37
|
+
# @raise [KeyError] if +start+ or +end+ is missing.
|
|
38
|
+
def self.from_wire(hash, text: nil, encoding: PositionEncoding::DEFAULT)
|
|
39
|
+
lines = text&.lines
|
|
40
|
+
wire_start = hash.fetch('start')
|
|
41
|
+
wire_end = hash.fetch('end')
|
|
42
|
+
new(start: Position.from_wire(wire_start, line_text: line_at(lines, wire_start), encoding: encoding),
|
|
43
|
+
end: Position.from_wire(wire_end, line_text: line_at(lines, wire_end), encoding: encoding))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The text of the line a wire position sits on, or +nil+ when the
|
|
47
|
+
# document text was not supplied.
|
|
48
|
+
def self.line_at(lines, wire_position)
|
|
49
|
+
lines && lines[Integer(wire_position.fetch('line'))]
|
|
50
|
+
end
|
|
51
|
+
private_class_method :line_at
|
|
52
|
+
|
|
53
|
+
# @return [Boolean] whether the span begins and ends on one line, i.e.
|
|
54
|
+
# whether a one-line snippet can show all of it.
|
|
55
|
+
def single_line?
|
|
56
|
+
start.line == self.end.line
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [String] +"13:7-13:23"+.
|
|
60
|
+
def to_s
|
|
61
|
+
"#{start}-#{self.end}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# Whether a server has finished indexing, and the wait that blocks until it
|
|
6
|
+
# has. One rule for every server: **no progress token open, and nothing said
|
|
7
|
+
# for {SETTLE} seconds.** Fed from the server's notification stream, drained
|
|
8
|
+
# by whoever is waiting — two different threads:
|
|
9
|
+
#
|
|
10
|
+
# readiness = Readiness.new(server_id: 'java')
|
|
11
|
+
# client.on_notification { |method, params| readiness.observe(method, params) }
|
|
12
|
+
#
|
|
13
|
+
# readiness.wait(cancellable: cancellable, alive: -> { client.alive? }) do |progress|
|
|
14
|
+
# emitter.call(progress) # ServerProgress, on *this* thread
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# The gate exists because the alternative is a lie: ruby-lsp answers a
|
|
18
|
+
# +definition+ sent mid-index with a successful +[]+, and an agent reads that
|
|
19
|
+
# as "this symbol has no definition" and proceeds. There is nothing in +[]+ to
|
|
20
|
+
# reason about, which is why this is the one case where pikuri waits instead
|
|
21
|
+
# of relaying.
|
|
22
|
+
#
|
|
23
|
+
# == Why a debounce, and why one rule fits every server
|
|
24
|
+
#
|
|
25
|
+
# Quiescence alone is a false edge: jdtls's long-lived +Initialize Workspace+
|
|
26
|
+
# token is *bracketed* by transient ones — a plain two-file project produced
|
|
27
|
+
# eight — and there every token was closed **0.9s before the server could
|
|
28
|
+
# answer**. The debounce is not the timeout pikuri removed: it never abandons
|
|
29
|
+
# the wait, it only refuses to call an idle moment the end.
|
|
30
|
+
#
|
|
31
|
+
# One rule covers both reference servers because their failure modes are
|
|
32
|
+
# opposite. jdtls *defers* mid-index requests and flushes the backlog when it
|
|
33
|
+
# is ready, so waiting too long costs latency and nothing else; ruby-lsp
|
|
34
|
+
# answers early and wrongly, so waiting is the only defense. No per-server
|
|
35
|
+
# predicate, no knob.
|
|
36
|
+
#
|
|
37
|
+
# == Two edges it cannot see, both relayed rather than papered over
|
|
38
|
+
#
|
|
39
|
+
# * A server whose *first* token opens more than {SETTLE} after its handshake
|
|
40
|
+
# is briefly called ready before it ever began. Both measured servers open
|
|
41
|
+
# theirs within milliseconds of +initialized+, so this is the residual cost
|
|
42
|
+
# of a generic gate rather than an observed one.
|
|
43
|
+
# * A token the server opens and never closes waits forever — the no-clock
|
|
44
|
+
# stance working as intended, with the human as the timeout via
|
|
45
|
+
# +cancellable+.
|
|
46
|
+
#
|
|
47
|
+
# Thread-safe: {#observe} runs on the server's reader thread while {#wait}
|
|
48
|
+
# runs on the caller's, which is what the {Mailbox} is here for.
|
|
49
|
+
class Readiness
|
|
50
|
+
# Seconds of quiet, after the last token closes, before the index is
|
|
51
|
+
# called built. Above the 0.9s gap jdtls was measured to need, with margin;
|
|
52
|
+
# the cost is that much added to a first call against a warm server.
|
|
53
|
+
SETTLE = 1.5
|
|
54
|
+
|
|
55
|
+
# How often {#wait} wakes with nothing parked, to re-check readiness,
|
|
56
|
+
# liveness and cancellation.
|
|
57
|
+
TICK = 0.1
|
|
58
|
+
|
|
59
|
+
# @param server_id [String] the registry entry's id, carried on every
|
|
60
|
+
# {ServerProgress} this emits.
|
|
61
|
+
# @param settle [Float] seconds of quiet that end the wait. Overridden in
|
|
62
|
+
# tests; there is deliberately no configuration seam for it.
|
|
63
|
+
def initialize(server_id:, settle: SETTLE)
|
|
64
|
+
@server_id = server_id
|
|
65
|
+
@settle = settle
|
|
66
|
+
@mutex = Mutex.new
|
|
67
|
+
@mailbox = Mailbox.new
|
|
68
|
+
@open = {}
|
|
69
|
+
@quiet_since = now
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Take one server notification into account. Anything that is not
|
|
73
|
+
# +$/progress+ is ignored, so this can be handed the whole stream.
|
|
74
|
+
#
|
|
75
|
+
# Runs on the reader thread: it only records state and parks a
|
|
76
|
+
# {ServerProgress} for {#wait} to emit, because
|
|
77
|
+
# {Pikuri::Agent::ExtensionContext#emit_event} is the agent thread's.
|
|
78
|
+
#
|
|
79
|
+
# @param method [String] the notification method.
|
|
80
|
+
# @param params [Hash, nil] its +params+ member.
|
|
81
|
+
# @return [void]
|
|
82
|
+
def observe(method, params)
|
|
83
|
+
return unless method == '$/progress'
|
|
84
|
+
|
|
85
|
+
token = params&.fetch('token', nil).to_s
|
|
86
|
+
value = params&.fetch('value', nil)
|
|
87
|
+
return unless value.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
progress = record(token, value)
|
|
90
|
+
@mailbox.push(token, progress) if progress
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# @return [Boolean] whether every task the server announced has ended and
|
|
95
|
+
# it has been quiet since — the gate's whole rule.
|
|
96
|
+
def ready?
|
|
97
|
+
@mutex.synchronize { @open.empty? && (now - @quiet_since) >= @settle }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Block until the server is ready, yielding progress as it arrives.
|
|
101
|
+
#
|
|
102
|
+
# Returns immediately when the gate is already open, so a warm server
|
|
103
|
+
# costs one predicate. Otherwise it yields every {ServerProgress} the
|
|
104
|
+
# server sends — coalesced, so a three-minute wait yields *current* state
|
|
105
|
+
# rather than replaying the backlog — and finally a +done: true+ for every
|
|
106
|
+
# task still in flight, however the wait ended and whether or not this wait
|
|
107
|
+
# was the one that announced it: a host that drew a bar must be told to
|
|
108
|
+
# take it down even when the server never sent the +end+.
|
|
109
|
+
#
|
|
110
|
+
# @param cancellable [Pikuri::Agent::Control::Cancellable, nil] polled
|
|
111
|
+
# every {TICK}; this is what makes an unbounded wait acceptable.
|
|
112
|
+
# @param alive [Proc] answers whether the server can still become ready.
|
|
113
|
+
# Without it a dead child would be waited on forever.
|
|
114
|
+
# @yieldparam progress [ServerProgress]
|
|
115
|
+
# @return [Boolean] +true+ when the server is ready, +false+ when +alive+
|
|
116
|
+
# went false first. Cancellation raises instead.
|
|
117
|
+
# @raise [Pikuri::Agent::Control::Cancellable::Cancelled] on cancellation.
|
|
118
|
+
def wait(cancellable: nil, alive: -> { true })
|
|
119
|
+
# Tracked by title, not token: the host draws its bars off what it was
|
|
120
|
+
# shown, and a ServerProgress carries no token for it to key on.
|
|
121
|
+
shown = []
|
|
122
|
+
@mailbox.drain(tick: TICK) do |progress|
|
|
123
|
+
cancellable&.check!
|
|
124
|
+
# Liveness first: a child that died without ever announcing a task is
|
|
125
|
+
# *quiet*, and quiet is half of what this gate calls ready.
|
|
126
|
+
break false unless alive.call
|
|
127
|
+
break true if ready?
|
|
128
|
+
next unless progress
|
|
129
|
+
|
|
130
|
+
progress.done ? shown.delete(progress.title) : shown |= [progress.title]
|
|
131
|
+
yield progress
|
|
132
|
+
end
|
|
133
|
+
ensure
|
|
134
|
+
(shown | open_titles).each do |title|
|
|
135
|
+
yield ServerProgress.new(server_id: @server_id, title: title, done: true)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Forget everything: a restarted server has an empty index, so its previous
|
|
140
|
+
# tokens say nothing and the quiet clock starts over.
|
|
141
|
+
#
|
|
142
|
+
# @return [void]
|
|
143
|
+
def reset!
|
|
144
|
+
@mutex.synchronize do
|
|
145
|
+
@open.clear
|
|
146
|
+
@quiet_since = now
|
|
147
|
+
end
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# @return [Array<String>] titles of the tasks the server has not ended.
|
|
154
|
+
# The union with what a wait showed is what guarantees no bar outlives
|
|
155
|
+
# its wait — including a task announced before the wait even started.
|
|
156
|
+
def open_titles
|
|
157
|
+
@mutex.synchronize { @open.values.uniq }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @return [ServerProgress, nil] what to park, or +nil+ for a +kind+ no
|
|
161
|
+
# +WorkDoneProgress+ defines.
|
|
162
|
+
def record(token, value)
|
|
163
|
+
@mutex.synchronize do
|
|
164
|
+
@quiet_since = now
|
|
165
|
+
# A report or end whose begin we never saw is titled by its token: the
|
|
166
|
+
# spec requires the begin first, so that is a server bug to relay, not
|
|
167
|
+
# to invent a title for.
|
|
168
|
+
case value['kind']
|
|
169
|
+
when 'begin' then title = @open[token] = (value['title'] || token).to_s
|
|
170
|
+
when 'report' then title = (@open[token] ||= token)
|
|
171
|
+
when 'end' then title = @open.delete(token) || token
|
|
172
|
+
else return nil
|
|
173
|
+
end
|
|
174
|
+
ServerProgress.new(server_id: @server_id, title: title,
|
|
175
|
+
message: value['message'], percentage: value['percentage'],
|
|
176
|
+
done: value['kind'] == 'end')
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def now
|
|
181
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Lsp
|
|
5
|
+
# A model-facing decline: the tool will not answer this call, and the message
|
|
6
|
+
# is the sentence the model reads.
|
|
7
|
+
#
|
|
8
|
+
# raise Refusal, "no language server is configured for .md files"
|
|
9
|
+
#
|
|
10
|
+
# Raised deep — routing, the capability gate, an anchor that resolves
|
|
11
|
+
# nowhere — and caught once, in {Navigator#navigate}, which prefixes
|
|
12
|
+
# +"Error: "+.
|
|
13
|
+
#
|
|
14
|
+
# It carries no code and no category, deliberately. The three answers a caller
|
|
15
|
+
# must be able to tell apart — *unsupported*, *the server answered nothing*,
|
|
16
|
+
# *the server could not be asked* — stay apart because each has its own
|
|
17
|
+
# sentence, not because a consumer switches on a symbol; collapsing them into
|
|
18
|
+
# one "nothing found, this may be because…" is the defect in the shipped prior
|
|
19
|
+
# art. And a refusal never proposes what to do instead: what failed is a fact,
|
|
20
|
+
# what to try next is the model's call on evidence this tool does not have.
|
|
21
|
+
class Refusal < StandardError; end
|
|
22
|
+
end
|
|
23
|
+
end
|