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.
@@ -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