sadr 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,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sadr
4
+ class DocumentIndex
5
+ Point = Struct.new(:row, :column)
6
+ BREAK = /(?:\r\n|[\r\n\u2028\u2029])\z/
7
+
8
+ def initialize(text)
9
+ @text = text
10
+ @line_starts = [0]
11
+ offset = 0
12
+ previous_cr = false
13
+ text.each_char do |character|
14
+ offset += character.bytesize
15
+ case character
16
+ when "\r"
17
+ @line_starts << offset
18
+ when "\n"
19
+ previous_cr ? @line_starts[-1] = offset : @line_starts << offset
20
+ when "\u2028", "\u2029"
21
+ @line_starts << offset
22
+ end
23
+ previous_cr = character == "\r"
24
+ end
25
+ end
26
+
27
+ def line_start(row)
28
+ @line_starts.fetch(row)
29
+ rescue IndexError
30
+ raise RangeError, "line out of bounds"
31
+ end
32
+
33
+ def line(row)
34
+ start = line_start(row)
35
+ finish = @line_starts.fetch(row + 1, @text.bytesize)
36
+ @text.byteslice(start, finish - start).sub(BREAK, "")
37
+ end
38
+
39
+ def utf16_offset_at(byte_offset)
40
+ prefix = prefix_at(byte_offset)
41
+ prefix.each_codepoint.sum { |codepoint| codepoint > 0xffff ? 2 : 1 }
42
+ end
43
+
44
+ def offset_at_utf16(utf16_offset)
45
+ raise RangeError, "UTF-16 offset out of bounds" unless utf16_offset.is_a?(Integer) && utf16_offset >= 0
46
+
47
+ units = 0
48
+ bytes = 0
49
+ @text.each_codepoint do |codepoint|
50
+ return bytes if units == utf16_offset
51
+
52
+ units += codepoint > 0xffff ? 2 : 1
53
+ bytes += codepoint.chr(Encoding::UTF_8).bytesize
54
+ raise RangeError, "UTF-16 offset splits a surrogate pair" if units > utf16_offset
55
+ end
56
+ return bytes if units == utf16_offset
57
+
58
+ raise RangeError, "UTF-16 offset out of bounds"
59
+ end
60
+
61
+ def utf16_point_at(byte_offset)
62
+ prefix_at(byte_offset)
63
+ row = @line_starts.bsearch_index { |start| start > byte_offset }
64
+ row = row ? row - 1 : @line_starts.length - 1
65
+ Point.new(row, utf16_offset_at(byte_offset) - utf16_offset_at(line_start(row)))
66
+ end
67
+
68
+ private
69
+
70
+ def prefix_at(byte_offset)
71
+ raise RangeError, "byte offset out of bounds" unless byte_offset.is_a?(Integer) && byte_offset.between?(0, @text.bytesize)
72
+
73
+ prefix = @text.byteslice(0, byte_offset)
74
+ raise RangeError, "byte offset splits a character" unless prefix.valid_encoding?
75
+
76
+ prefix
77
+ end
78
+ end
79
+ private_constant :DocumentIndex
80
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sadr
4
+ class Future
5
+ class Subscription
6
+ def initialize(&detach) = @detach = detach
7
+
8
+ def detach
9
+ callback, @detach = @detach, nil
10
+ callback&.call
11
+ end
12
+ end
13
+
14
+ attr_reader :id, :callback_errors
15
+
16
+ def initialize(id, on_error: nil, &cancel)
17
+ @id = id
18
+ @cancel_callback = cancel
19
+ @on_error = on_error
20
+ @lock = Mutex.new
21
+ @ready = ConditionVariable.new
22
+ @callbacks = []
23
+ @callback_errors = []
24
+ end
25
+
26
+ def fulfill(value = nil, error: nil)
27
+ callbacks = @lock.synchronize do
28
+ return if @done
29
+
30
+ @value = value
31
+ @error = error
32
+ @done = true
33
+ @ready.broadcast
34
+ saved, @callbacks = @callbacks, []
35
+ saved
36
+ end
37
+ callbacks.each { |callback| invoke(callback) }
38
+ self
39
+ end
40
+
41
+ def then(&callback)
42
+ raise ArgumentError, "callback required" unless callback
43
+
44
+ on_complete { callback.call(@value, @error) }
45
+ self
46
+ end
47
+
48
+ def done? = @lock.synchronize { !!@done }
49
+
50
+ def on_complete(&callback)
51
+ raise ArgumentError, "callback required" unless callback
52
+
53
+ ready = @lock.synchronize do
54
+ @callbacks << callback unless @done
55
+ @done
56
+ end
57
+ invoke(callback) if ready
58
+ Subscription.new { @lock.synchronize { @callbacks.delete(callback) } }
59
+ end
60
+
61
+ def await(timeout: 10)
62
+ valid = timeout.nil? || (timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0)
63
+ raise ArgumentError, "timeout must be finite and nonnegative" unless valid
64
+
65
+ if defined?(Zaniah::TaskExecutor) && Zaniah::TaskExecutor.current && !done?
66
+ return Zaniah::TaskExecutor.current.await(self, timeout: timeout)
67
+ end
68
+
69
+ deadline = timeout && Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
70
+ @lock.synchronize do
71
+ until @done
72
+ remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
73
+ raise Timeout, "LSP request #{@id} timed out" if remaining && remaining <= 0
74
+
75
+ @ready.wait(@lock, remaining)
76
+ end
77
+ raise @error if @error
78
+
79
+ @value
80
+ end
81
+ rescue StandardError => error
82
+ if error.is_a?(Timeout) || (defined?(Zaniah::Task::Timeout) && error.is_a?(Zaniah::Task::Timeout))
83
+ cancel
84
+ raise Timeout, "LSP request #{@id} timed out"
85
+ end
86
+ raise
87
+ end
88
+
89
+ def cancel
90
+ callback = @lock.synchronize do
91
+ return false if @done || @cancelling
92
+
93
+ @cancelling = true
94
+ @cancel_callback
95
+ end
96
+ invoke(-> { callback.call(@id) }) if callback
97
+ fulfill(error: Error.new("request cancelled"))
98
+ true
99
+ end
100
+
101
+ private
102
+
103
+ def invoke(callback)
104
+ callback.call
105
+ rescue StandardError => error
106
+ bounded = Error.new("#{error.class}: #{error.message}".scrub.byteslice(0, 2048).scrub(""))
107
+ @lock.synchronize do
108
+ @callback_errors << bounded
109
+ @callback_errors.shift if @callback_errors.length > 32
110
+ end
111
+ begin
112
+ @on_error&.call(bounded)
113
+ rescue StandardError
114
+ nil
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sadr
4
+ module Protocol
5
+ module_function
6
+
7
+ def uri(path)
8
+ raise Error, "path must be a String" unless path.is_a?(String) && !path.include?("\0")
9
+
10
+ absolute = File.expand_path(path).tr("\\", "/")
11
+ absolute = "/#{absolute}" if absolute.match?(/\A[A-Za-z]:/)
12
+ "file://" + URI::RFC2396_PARSER.escape(absolute, /[^a-zA-Z0-9\-._~\/:]/)
13
+ end
14
+
15
+ def path(uri)
16
+ parsed = URI.parse(uri)
17
+ valid = parsed.scheme == "file" && [nil, "", "localhost"].include?(parsed.host) &&
18
+ parsed.query.nil? && parsed.fragment.nil? && parsed.path&.start_with?("/")
19
+ raise Error, "expected local file URI" unless valid
20
+
21
+ value = URI::RFC2396_PARSER.unescape(parsed.path)
22
+ raise Error, "invalid file URI path" if value.include?("\0") || !value.valid_encoding?
23
+
24
+ RUBY_PLATFORM.match?(/mswin|mingw/) ? value.sub(%r{\A/([A-Za-z]:/)}, '\\1') : value
25
+ rescue URI::InvalidURIError => error
26
+ raise Error, error.message
27
+ end
28
+
29
+ def position(index, offset)
30
+ point = index.utf16_point_at(offset)
31
+ Position.new(line: point.row, character: point.column)
32
+ rescue NoMethodError => error
33
+ raise Error, "invalid text index: #{error.message}"
34
+ end
35
+
36
+ def offset(index, position)
37
+ point = position_value(position)
38
+ start = index.line_start(point.line)
39
+ finish = start + index.line(point.line).bytesize
40
+ units = index.utf16_offset_at(finish) - index.utf16_offset_at(start)
41
+ index.offset_at_utf16(index.utf16_offset_at(start) + [point.character, units].min)
42
+ rescue NoMethodError => error
43
+ raise Error, "invalid text index: #{error.message}"
44
+ end
45
+
46
+ def range(index, byte_range)
47
+ raise Error, "invalid byte range" unless byte_range.is_a?(Range) && byte_range.begin.is_a?(Integer) && byte_range.end.is_a?(Integer)
48
+
49
+ finish = byte_range.end + (byte_range.exclude_end? ? 0 : 1)
50
+ Range_.new(start: position(index, byte_range.begin), end: position(index, finish))
51
+ end
52
+
53
+ def text_edits(index, edits)
54
+ raise Error, "invalid LSP text edits" unless edits.is_a?(Array)
55
+
56
+ edits.map do |edit|
57
+ valid = edit.is_a?(Hash) && fetch(edit, "range").is_a?(Hash) && fetch(edit, "newText").is_a?(String)
58
+ raise Error, "invalid LSP text edit" unless valid && fetch(edit, "newText").valid_encoding?
59
+
60
+ value = fetch(edit, "range")
61
+ first = offset(index, fetch(value, "start"))
62
+ last = offset(index, fetch(value, "end"))
63
+ raise Error, "invalid LSP text edit range" if last < first
64
+
65
+ [first...last, fetch(edit, "newText")]
66
+ end
67
+ end
68
+
69
+ def semantic_delta(data, edits)
70
+ raise Error, "invalid semantic token delta" unless data.is_a?(Array) && edits.is_a?(Array)
71
+
72
+ edits.each do |edit|
73
+ valid = edit.is_a?(Hash) && uint?(fetch(edit, "start")) && uint?(fetch(edit, "deleteCount"))
74
+ inserted = fetch(edit, "data", [])
75
+ raise Error, "invalid semantic token delta" unless valid && inserted.is_a?(Array)
76
+ end
77
+ sorted = edits.sort_by { |edit| fetch(edit, "start") }
78
+ previous_end = 0
79
+ sorted.each do |edit|
80
+ start = fetch(edit, "start")
81
+ count = fetch(edit, "deleteCount")
82
+ raise Error, "invalid semantic token delta" if start < previous_end || start + count > data.length
83
+
84
+ previous_end = start + count
85
+ end
86
+ output = data.dup
87
+ sorted.reverse_each do |edit|
88
+ output[fetch(edit, "start"), fetch(edit, "deleteCount")] = fetch(edit, "data", [])
89
+ end
90
+ scan_semantic(output, nil, false)
91
+ output
92
+ end
93
+
94
+ def diagnostics(values)
95
+ valid = values.is_a?(Array) && values.all? do |value|
96
+ next false unless value.is_a?(Hash) && fetch(value, "message").is_a?(String) && fetch(value, "range").is_a?(Hash)
97
+
98
+ range = fetch(value, "range")
99
+ points = %w[start end].map { |key| fetch(range, key, nil) }
100
+ severity = fetch(value, "severity", nil)
101
+ has_severity = value.key?("severity") || value.key?(:severity)
102
+ points.all? { |point| valid_position?(point) } &&
103
+ (position_tuple(points[0]) <=> position_tuple(points[1])) <= 0 &&
104
+ (!has_severity || (severity.is_a?(Integer) && severity.between?(1, 4)))
105
+ end
106
+ raise Error, "invalid LSP diagnostics" unless valid
107
+
108
+ values
109
+ end
110
+
111
+ def semantic_tokens(data, legend: nil)
112
+ scan_semantic(data, legend, true)
113
+ end
114
+
115
+ def scan_semantic(data, legend, collect)
116
+ raise Error, "invalid semantic token tuple count" unless data.is_a?(Array) && (data.length % 5).zero?
117
+ if legend
118
+ valid = legend.is_a?(Hash) && %w[tokenTypes tokenModifiers].all? do |key|
119
+ value = fetch(legend, key)
120
+ value.is_a?(Array) && value.all? { |name| name.is_a?(String) }
121
+ end
122
+ raise Error, "invalid semantic token legend" unless valid
123
+ end
124
+
125
+ unless data.all? { |value| value.is_a?(Integer) && value.between?(0, 0x7fffffff) }
126
+ raise Error, "invalid semantic token value"
127
+ end
128
+ row = 0
129
+ column = 0
130
+ tokens = collect ? Array.new(data.length / 5) : nil
131
+ index = 0
132
+ while index < data.length
133
+ delta_row = data[index]
134
+ delta_column = data[index + 1]
135
+ length = data[index + 2]
136
+ type = data[index + 3]
137
+ modifiers = data[index + 4]
138
+ raise Error, "invalid semantic token value" unless length.positive?
139
+ if legend
140
+ types = fetch(legend, "tokenTypes")
141
+ token_modifiers = fetch(legend, "tokenModifiers")
142
+ raise Error, "semantic token exceeds legend" if type >= types.length || modifiers.bit_length > token_modifiers.length
143
+ end
144
+
145
+ row += delta_row
146
+ column = delta_row.zero? ? column + delta_column : delta_column
147
+ raise Error, "semantic token position overflow" unless uint?(row) && uint?(column + length)
148
+
149
+ tokens[index / 5] = Token.new(line: row, character: column, length: length, type: type, modifiers: modifiers) if collect
150
+ index += 5
151
+ end
152
+ tokens
153
+ end
154
+
155
+ def uint?(value)
156
+ value.is_a?(Integer) && value.between?(0, 0x7fffffff)
157
+ end
158
+
159
+ def position_value(value)
160
+ return value if value.is_a?(Position) && uint?(value.line) && uint?(value.character)
161
+ return Position.new(line: fetch(value, "line"), character: fetch(value, "character")) if valid_position?(value)
162
+
163
+ raise Error, "invalid LSP position"
164
+ end
165
+
166
+ def range_value(value)
167
+ if value.is_a?(Range_)
168
+ valid = (position_tuple(value.start) <=> position_tuple(value.end)) <= 0
169
+ return Range_.new(start: position_value(value.start), end: position_value(value.end)) if valid
170
+ elsif value.is_a?(Hash)
171
+ first = position_value(fetch(value, "start"))
172
+ last = position_value(fetch(value, "end"))
173
+ return Range_.new(start: first, end: last) if (position_tuple(first) <=> position_tuple(last)) <= 0
174
+ end
175
+ raise Error, "invalid LSP range"
176
+ end
177
+
178
+ def position_hash(value)
179
+ point = position_value(value)
180
+ {line: point.line, character: point.character}
181
+ end
182
+
183
+ def range_hash(value)
184
+ value = range_value(value)
185
+ {start: position_hash(value.start), end: position_hash(value.end)}
186
+ end
187
+
188
+ def valid_position?(value)
189
+ value.is_a?(Hash) && uint?(fetch(value, "line")) && uint?(fetch(value, "character"))
190
+ end
191
+
192
+ def position_tuple(value)
193
+ point = value.is_a?(Position) ? value : position_value(value)
194
+ [point.line, point.character]
195
+ end
196
+
197
+ def fetch(hash, key, default = :__missing__)
198
+ return hash[key] if hash.key?(key)
199
+ symbol = key.to_sym
200
+ return hash[symbol] if hash.key?(symbol)
201
+ return default unless default == :__missing__
202
+
203
+ raise KeyError, "key not found: #{key}"
204
+ end
205
+
206
+ private_class_method :scan_semantic, :valid_position?, :position_tuple, :fetch
207
+ end
208
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sadr
4
+ class Transport
5
+ MAX_MESSAGE = 32 << 20
6
+
7
+ attr_reader :stderr_lines, :pid
8
+
9
+ def initialize(command, cwd: nil, env: {}, &receive)
10
+ valid = command.is_a?(Array) && !command.empty? && command.all? do |part|
11
+ part.is_a?(String) && !part.include?("\0")
12
+ end
13
+ raise ArgumentError, "command must be a nonempty argument array" unless valid
14
+ raise ArgumentError, "receiver required" unless receive
15
+
16
+ options = cwd ? {chdir: cwd} : {}
17
+ @stdin, @stdout, @stderr, @process = Open3.popen3(env, *command, **options)
18
+ @stdin.binmode
19
+ @stdout.binmode
20
+ @pid = @process.pid
21
+ @write_lock = Mutex.new
22
+ @stderr_lines = []
23
+ @reader = Thread.new { read(receive) }
24
+ @logger = Thread.new { read_stderr }
25
+ end
26
+
27
+ def self.read_message(io)
28
+ headers = {}
29
+ count = 0
30
+ loop do
31
+ line = io.gets("\r\n", 8193)
32
+ return nil if line.nil? && headers.empty?
33
+ raise Error, "truncated or oversized LSP header" unless line && line.end_with?("\r\n") && line.bytesize <= 8192
34
+ break if line == "\r\n"
35
+
36
+ count += line.bytesize
37
+ raise Error, "oversized LSP headers" if count > 16_384
38
+ raise Error, "non-ASCII LSP header" unless line.ascii_only?
39
+
40
+ key, value = line.strip.split(":", 2)
41
+ raise Error, "invalid LSP header" unless value && key.match?(/\A[A-Za-z][A-Za-z0-9-]*\z/)
42
+
43
+ key = key.downcase
44
+ raise Error, "duplicate LSP header" if headers.key?(key)
45
+
46
+ headers[key] = value.strip
47
+ end
48
+ raw_length = headers["content-length"]
49
+ raise Error, "missing or invalid Content-Length" unless raw_length&.match?(/\A\d+\z/)
50
+
51
+ length = Integer(raw_length, 10)
52
+ raise Error, "oversized LSP message" unless length.between?(1, MAX_MESSAGE)
53
+
54
+ charset = headers["content-type"]&.match(/charset\s*=\s*"?([^;"\s]+)/i)&.[](1)
55
+ raise Error, "unsupported LSP character encoding" if charset && !%w[utf-8 utf8].include?(charset.downcase)
56
+
57
+ body = io.read(length)
58
+ raise Error, "truncated LSP body" unless body && body.bytesize == length
59
+
60
+ body.force_encoding(Encoding::UTF_8)
61
+ raise Error, "invalid LSP UTF-8 body" unless body.valid_encoding?
62
+
63
+ validate_message(JSON.parse(body))
64
+ rescue JSON::ParserError => error
65
+ raise Error, "invalid LSP JSON: #{error.message.byteslice(0, 256)}"
66
+ end
67
+
68
+ def self.validate_message(message)
69
+ raise Error, "invalid JSON-RPC message" unless message.is_a?(Hash) && message["jsonrpc"] == "2.0"
70
+ if message.key?("method")
71
+ raise Error, "invalid JSON-RPC method" unless message["method"].is_a?(String) && !message["method"].empty?
72
+ invalid_params = message.key?("params") && !message["params"].is_a?(Hash) && !message["params"].is_a?(Array)
73
+ raise Error, "invalid JSON-RPC parameters" if invalid_params
74
+ raise Error, "request contains a response" if message.key?("result") || message.key?("error")
75
+ else
76
+ valid_response = message.key?("id") && (message.key?("result") ^ message.key?("error"))
77
+ raise Error, "invalid JSON-RPC response" unless valid_response
78
+ if message.key?("error")
79
+ error = message["error"]
80
+ valid_error = error.is_a?(Hash) && error["code"].is_a?(Integer) && error["message"].is_a?(String)
81
+ raise Error, "invalid JSON-RPC error" unless valid_error
82
+ end
83
+ end
84
+ id = message["id"]
85
+ valid_id = id.is_a?(Integer) || id.is_a?(String) || (id.nil? && !message.key?("method"))
86
+ raise Error, "invalid JSON-RPC id" if message.key?("id") && !valid_id
87
+
88
+ message
89
+ end
90
+
91
+ def write(message)
92
+ raise Error, "expected JSON-RPC object" unless message.is_a?(Hash)
93
+
94
+ normalized = message.transform_keys(&:to_s)
95
+ normalized["error"] = normalized["error"].transform_keys(&:to_s) if normalized["error"].is_a?(Hash)
96
+ self.class.validate_message(normalized)
97
+ body = JSON.generate(message).b
98
+ raise Error, "oversized LSP message" unless body.bytesize.between?(1, MAX_MESSAGE)
99
+
100
+ @write_lock.synchronize do
101
+ @stdin.write("Content-Length: #{body.bytesize}\r\n\r\n")
102
+ @stdin.write(body)
103
+ @stdin.flush
104
+ end
105
+ rescue IOError, Errno::EPIPE => error
106
+ raise Error, "language server write failed: #{error.message}"
107
+ end
108
+
109
+ def alive? = @process.alive?
110
+
111
+ def close
112
+ return if @closing
113
+
114
+ @closing = true
115
+ @stdin.close unless @stdin.closed?
116
+ unless @process.join(1)
117
+ begin
118
+ Process.kill("TERM", @pid)
119
+ rescue Errno::ESRCH
120
+ nil
121
+ end
122
+ unless @process.join(1)
123
+ begin
124
+ Process.kill("KILL", @pid)
125
+ rescue Errno::ESRCH
126
+ nil
127
+ end
128
+ @process.join
129
+ end
130
+ end
131
+ [@stdout, @stderr].each { |io| io.close unless io.closed? }
132
+ [@reader, @logger].each do |thread|
133
+ next if thread == Thread.current
134
+
135
+ thread.kill unless thread.join(1)
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ def read(receive)
142
+ loop do
143
+ message = self.class.read_message(@stdout)
144
+ break unless message
145
+
146
+ receive.call(message, nil)
147
+ end
148
+ receive.call(nil, Error.new("language server closed stdout")) unless @closing
149
+ rescue StandardError => error
150
+ begin
151
+ receive.call(nil, error) unless @closing
152
+ rescue StandardError
153
+ nil
154
+ end
155
+ end
156
+
157
+ def read_stderr
158
+ while (line = @stderr.gets("\n", 8192))
159
+ @stderr_lines << line.scrub.byteslice(0, 8192).scrub("")
160
+ @stderr_lines.shift if @stderr_lines.length > 200
161
+ end
162
+ rescue IOError
163
+ nil
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sadr
4
+ VERSION = "0.1.0"
5
+ end
data/lib/sadr.rb ADDED
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "thread"
6
+ require "uri"
7
+
8
+ require_relative "sadr/version"
9
+
10
+ module Sadr
11
+ class Error < StandardError; end
12
+ class Timeout < Error; end
13
+
14
+ class ServerError < Error
15
+ attr_reader :code, :data
16
+
17
+ def initialize(error)
18
+ @code = error["code"]
19
+ @data = error["data"]
20
+ super(error["message"])
21
+ end
22
+ end
23
+
24
+ module Value
25
+ module_function
26
+
27
+ def define(*members)
28
+ return Data.define(*members) if defined?(Data)
29
+
30
+ Struct.new(*members, keyword_init: true) do
31
+ def initialize(**values)
32
+ super
33
+ freeze
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ Position = Value.define(:line, :character)
40
+ Range_ = Value.define(:start, :end)
41
+ ContentChange = Value.define(:range, :text)
42
+ Document = Value.define(:uri, :language_id, :version, :text)
43
+ Token = Value.define(:line, :character, :length, :type, :modifiers)
44
+ private_constant :Value
45
+
46
+ # Text indexes are accepted by protocol conversion methods through duck typing.
47
+ # Required methods are documented in sig/sadr.rbs.
48
+ module TextIndex; end
49
+ end
50
+
51
+ require_relative "sadr/protocol"
52
+ require_relative "sadr/future"
53
+ require_relative "sadr/transport"
54
+ require_relative "sadr/document_sync"
55
+ require_relative "sadr/client"