megrez 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,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Megrez
4
+ module Testing
5
+ class FakeAdapter
6
+ DEFAULT_CAPABILITIES = {
7
+ "supportsConfigurationDoneRequest" => true,
8
+ "supportsConditionalBreakpoints" => true,
9
+ "supportsLogPoints" => true,
10
+ "supportsSetVariable" => true,
11
+ "supportsSetExpression" => true,
12
+ "supportsCompletionsRequest" => true,
13
+ "supportsCancelRequest" => true
14
+ }.freeze
15
+
16
+ attr_reader :messages
17
+
18
+ def self.command
19
+ root = File.expand_path("../..", __dir__)
20
+ [RbConfig.ruby, "-I#{root}", "-rmegrez/testing", "-e", "Megrez::Testing::FakeAdapter.run_stdio"]
21
+ end
22
+
23
+ def self.run_stdio = new.serve(STDIN, STDOUT)
24
+
25
+ def initialize(responses: {}, capabilities: DEFAULT_CAPABILITIES, variable_count: 3)
26
+ raise ArgumentError, "responses must be a Hash" unless responses.is_a?(Hash)
27
+ raise ArgumentError, "capabilities must be a Hash" unless capabilities.is_a?(Hash)
28
+ raise ArgumentError, "variable_count must be nonnegative" unless variable_count.is_a?(Integer) && variable_count >= 0
29
+
30
+ @responses = responses.transform_keys(&:to_s)
31
+ @capabilities = capabilities
32
+ @variable_count = variable_count
33
+ @messages = []
34
+ @sequence = 0
35
+ @lock = Mutex.new
36
+ @sinks = []
37
+ end
38
+
39
+ def transport(&receive) = FakeTransport.new(self, &receive)
40
+
41
+ def attach(&sink)
42
+ @lock.synchronize { @sinks << sink }
43
+ sink
44
+ end
45
+
46
+ def detach(sink) = @lock.synchronize { @sinks.delete(sink) }
47
+
48
+ def emit(event, body = {})
49
+ message = event_message(event, body)
50
+ @lock.synchronize { @sinks.dup }.each { |sink| sink.call([message]) }
51
+ message
52
+ end
53
+
54
+ def request_client(command, arguments = {})
55
+ message = {
56
+ "seq" => sequence,
57
+ "type" => "request",
58
+ "command" => command.to_s,
59
+ "arguments" => arguments
60
+ }
61
+ @lock.synchronize { @sinks.dup }.each { |sink| sink.call([message]) }
62
+ message
63
+ end
64
+
65
+ def dispatch(message)
66
+ Protocol.validate_message(message)
67
+ @lock.synchronize { @messages << Protocol.deep_freeze(message.dup) }
68
+ return [] unless message["type"] == "request"
69
+ return [] if message["command"] == "never"
70
+
71
+ override = @responses[message["command"]]
72
+ return Array(override.call(message["arguments"] || {}, message)) if override.respond_to?(:call)
73
+ return [response(message, override)] if @responses.key?(message["command"])
74
+
75
+ result, events = response_for(message)
76
+ [response(message, result), *events]
77
+ rescue AdapterError => error
78
+ [response(message, {}, success: false, message: error.message)]
79
+ end
80
+
81
+ def serve(input, output = input)
82
+ input.binmode
83
+ output.binmode
84
+ output.sync = true
85
+ while (message = Transport.read_message(input))
86
+ dispatch(message).each { |item| output.write(Transport.frame(item)) }
87
+ end
88
+ rescue IOError, Errno::EPIPE
89
+ nil
90
+ end
91
+
92
+ private
93
+
94
+ def response_for(message)
95
+ arguments = message["arguments"] || {}
96
+ case message["command"]
97
+ when "initialize"
98
+ [@capabilities, []]
99
+ when "launch", "attach"
100
+ [{}, [event_message("initialized")]]
101
+ when "configurationDone"
102
+ [{}, [stopped_event]]
103
+ when "setBreakpoints"
104
+ values = arguments.fetch("breakpoints", []).each_with_index.map do |breakpoint, index|
105
+ {
106
+ "id" => index + 1,
107
+ "verified" => true,
108
+ "source" => arguments["source"],
109
+ "line" => breakpoint["line"],
110
+ "column" => breakpoint["column"]
111
+ }.compact
112
+ end
113
+ [{"breakpoints" => values}, []]
114
+ when "setFunctionBreakpoints", "setDataBreakpoints"
115
+ values = arguments.fetch("breakpoints", []).each_index.map { |index| {"id" => index + 1, "verified" => true} }
116
+ [{"breakpoints" => values}, []]
117
+ when "setExceptionBreakpoints"
118
+ values = arguments.fetch("filters", []).each_index.map { |index| {"id" => index + 1, "verified" => true} }
119
+ [{"breakpoints" => values}, []]
120
+ when "threads"
121
+ [{"threads" => [{"id" => 1, "name" => "main"}]}, []]
122
+ when "stackTrace"
123
+ [{"stackFrames" => [{"id" => 10, "name" => "main", "source" => {"path" => "app.rb"}, "line" => 1, "column" => 1}], "totalFrames" => 1}, []]
124
+ when "scopes"
125
+ [{"scopes" => [{"name" => "Locals", "variablesReference" => 1, "expensive" => false}]}, []]
126
+ when "variables"
127
+ values = Array.new(@variable_count) do |index|
128
+ {"name" => "value#{index}", "value" => index.to_s, "type" => "Integer", "variablesReference" => 0}
129
+ end
130
+ [{"variables" => values}, []]
131
+ when "evaluate"
132
+ [{"result" => "42", "type" => "Integer", "variablesReference" => 0}, []]
133
+ when "setVariable", "setExpression"
134
+ [{"value" => arguments.fetch("value"), "type" => "String", "variablesReference" => 0}, []]
135
+ when "completions"
136
+ [{"targets" => [{"label" => "value", "text" => "value", "start" => 0, "length" => arguments.fetch("column", 0)}]}, []]
137
+ when "source"
138
+ [{"content" => "puts :ok\n", "mimeType" => "text/x-ruby"}, []]
139
+ when "continue"
140
+ [{"allThreadsContinued" => !arguments["singleThread"]}, [event_message("continued", "threadId" => arguments["threadId"], "allThreadsContinued" => !arguments["singleThread"])] ]
141
+ when "next", "stepIn", "stepOut", "restartFrame"
142
+ [{}, [event_message("continued", "threadId" => arguments["threadId"] || 1), stopped_event]]
143
+ when "restart"
144
+ [{}, [event_message("continued", "threadId" => 1), stopped_event]]
145
+ when "pause"
146
+ [{}, [stopped_event]]
147
+ when "terminate"
148
+ [{}, [event_message("terminated")]]
149
+ when "disconnect"
150
+ [{}, [event_message("terminated")]]
151
+ else
152
+ [@responses.fetch(message["command"], {}), []]
153
+ end
154
+ end
155
+
156
+ def response(request, body, success: true, message: nil)
157
+ value = {
158
+ "seq" => sequence,
159
+ "type" => "response",
160
+ "request_seq" => request["seq"],
161
+ "success" => success,
162
+ "command" => request["command"],
163
+ "body" => body
164
+ }
165
+ value["message"] = message if message
166
+ value
167
+ end
168
+
169
+ def stopped_event = event_message("stopped", "reason" => "breakpoint", "threadId" => 1, "allThreadsStopped" => true)
170
+
171
+ def event_message(event, body = {})
172
+ {"seq" => sequence, "type" => "event", "event" => event, "body" => body}
173
+ end
174
+
175
+ def sequence = @lock.synchronize { @sequence += 1 }
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Megrez
4
+ module Testing
5
+ class FakeTransport < Transport
6
+ STOP = Object.new.freeze
7
+ private_constant :STOP
8
+
9
+ attr_reader :stderr_lines, :pid
10
+
11
+ def initialize(adapter, &receive)
12
+ @adapter = adapter
13
+ @receive = receive
14
+ @stderr_lines = []
15
+ @pid = Process.pid
16
+ @lock = Mutex.new
17
+ @write_lock = Mutex.new
18
+ @responses = Queue.new
19
+ @alive = true
20
+ @sink = @adapter.attach { |messages| enqueue(messages) }
21
+ @reader = Thread.new { read }
22
+ @reader.report_on_exception = false
23
+ end
24
+
25
+ def listen(&receive)
26
+ raise ArgumentError, "receiver required" unless receive
27
+ raise Error, "debug adapter transport is already listening" if @receive
28
+
29
+ @receive = receive
30
+ self
31
+ end
32
+
33
+ def write(message)
34
+ message = wire(message)
35
+ responses = @write_lock.synchronize do
36
+ raise Error, "debug adapter write failed: closed transport" unless alive?
37
+
38
+ @adapter.dispatch(message)
39
+ end
40
+ enqueue(responses)
41
+ nil
42
+ end
43
+
44
+ def alive? = @lock.synchronize { @alive }
45
+
46
+ def close
47
+ reader = @lock.synchronize do
48
+ if @alive
49
+ @alive = false
50
+ @responses << STOP
51
+ end
52
+ @reader
53
+ end
54
+ @adapter.detach(@sink)
55
+ return if reader == Thread.current
56
+
57
+ reader.kill unless reader.join(1)
58
+ reader.join
59
+ nil
60
+ end
61
+
62
+ private
63
+
64
+ def wire(message)
65
+ frame = Transport.frame(message)
66
+ Transport.read_message(StringIO.new(frame))
67
+ end
68
+
69
+ def enqueue(messages)
70
+ values = messages.map { |message| wire(message) }
71
+ @lock.synchronize do
72
+ return unless @alive
73
+
74
+ values.each { |message| @responses << message }
75
+ end
76
+ end
77
+
78
+ def read
79
+ loop do
80
+ message = @responses.pop
81
+ break if message.equal?(STOP)
82
+
83
+ @receive.call(message, nil)
84
+ end
85
+ rescue StandardError => error
86
+ @receive.call(nil, error) if alive?
87
+ ensure
88
+ @lock.synchronize { @alive = false }
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "stringio"
5
+ require_relative "../megrez"
6
+ require_relative "testing/fake_adapter"
7
+ require_relative "testing/fake_transport"
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Megrez
6
+ class Transport
7
+ def self.read_message(io)
8
+ headers = {}
9
+ bytes = 0
10
+ loop do
11
+ line = io.gets("\r\n", MAX_HEADER_LINE + 1)
12
+ return nil if line.nil? && headers.empty?
13
+ valid = line && line.end_with?("\r\n") && line.bytesize <= MAX_HEADER_LINE
14
+ raise Error, "truncated or oversized DAP header" unless valid
15
+ break if line == "\r\n"
16
+
17
+ bytes += line.bytesize
18
+ raise Error, "oversized DAP headers" if bytes > MAX_HEADERS
19
+ raise Error, "non-ASCII DAP header" unless line.ascii_only?
20
+
21
+ key, value = line.strip.split(":", 2)
22
+ raise Error, "invalid DAP header" unless value && key.match?(/\A[A-Za-z][A-Za-z0-9-]*\z/)
23
+
24
+ key = key.downcase
25
+ raise Error, "duplicate DAP header" if headers.key?(key)
26
+
27
+ headers[key] = value.strip
28
+ end
29
+
30
+ raw_length = headers["content-length"]
31
+ raise Error, "missing or invalid Content-Length" unless raw_length&.match?(/\A\d+\z/)
32
+
33
+ length = Integer(raw_length, 10)
34
+ raise Error, "oversized DAP message" unless length.between?(1, MAX_MESSAGE)
35
+
36
+ charset = headers["content-type"]&.match(/charset\s*=\s*"?([^;"\s]+)/i)&.[](1)
37
+ raise Error, "unsupported DAP character encoding" if charset && !%w[utf-8 utf8].include?(charset.downcase)
38
+
39
+ body = io.read(length)
40
+ raise Error, "truncated DAP body" unless body && body.bytesize == length
41
+
42
+ body.force_encoding(Encoding::UTF_8)
43
+ raise Error, "invalid DAP UTF-8 body" unless body.valid_encoding?
44
+
45
+ Protocol.validate_message(JSON.parse(body))
46
+ rescue JSON::ParserError => error
47
+ raise Error, "invalid DAP JSON: #{error.message.byteslice(0, 256)}"
48
+ end
49
+
50
+ def self.frame(message)
51
+ raise Error, "DAP message must be an object" unless message.is_a?(Hash)
52
+
53
+ normalized = JSON.parse(JSON.generate(message))
54
+ Protocol.validate_message(normalized)
55
+ body = JSON.generate(normalized).b
56
+ raise Error, "oversized DAP message" unless body.bytesize.between?(1, MAX_MESSAGE)
57
+
58
+ "Content-Length: #{body.bytesize}\r\n\r\n".b + body
59
+ rescue JSON::GeneratorError, JSON::NestingError => error
60
+ raise Error, "invalid DAP JSON: #{error.message.byteslice(0, 256)}"
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "socket"
5
+
6
+ module Megrez
7
+ class Transport
8
+ MAX_MESSAGE = 32 << 20
9
+ MAX_HEADER_LINE = 8192
10
+ MAX_HEADERS = 16_384
11
+
12
+ attr_reader :stderr_lines, :pid
13
+
14
+ def self.stdio(command:, env: {}, cwd: nil)
15
+ valid = command.is_a?(Array) && !command.empty? && command.all? do |part|
16
+ part.is_a?(String) && !part.empty? && !part.include?("\0")
17
+ end
18
+ raise ArgumentError, "command must be a nonempty argument array" unless valid
19
+ raise ArgumentError, "environment must be a string map" unless valid_environment?(env)
20
+ raise ArgumentError, "cwd must be a path string" unless cwd.nil? || (cwd.is_a?(String) && !cwd.include?("\0"))
21
+
22
+ options = cwd ? {chdir: cwd} : {}
23
+ input, output, error, process = Open3.popen3(env, *command, **options)
24
+ new(output, input, process: process, stderr: error, closeables: [input, output, error])
25
+ rescue SystemCallError => error
26
+ raise Error, "debug adapter spawn failed: #{error.message}"
27
+ end
28
+
29
+ def self.tcp(host:, port:, connect_timeout: 10)
30
+ valid_host = host.is_a?(String) && !host.empty? && !host.include?("\0")
31
+ valid_port = port.is_a?(Integer) && port.between?(1, 65_535)
32
+ valid_timeout = connect_timeout.is_a?(Numeric) && connect_timeout.finite? && connect_timeout.positive?
33
+ raise ArgumentError, "host must be a nonempty string" unless valid_host
34
+ raise ArgumentError, "port must be between 1 and 65535" unless valid_port
35
+ raise ArgumentError, "connect_timeout must be positive and finite" unless valid_timeout
36
+
37
+ socket = Socket.tcp(host, port, connect_timeout: connect_timeout)
38
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
39
+ new(socket, socket, closeables: [socket])
40
+ rescue SystemCallError, SocketError => error
41
+ raise Error, "debug adapter connection failed: #{error.message}"
42
+ end
43
+
44
+ def self.valid_environment?(env)
45
+ env.is_a?(Hash) && env.all? do |key, value|
46
+ key.is_a?(String) && !key.empty? && !key.include?("\0") && !key.include?("=") &&
47
+ (value.nil? || (value.is_a?(String) && !value.include?("\0")))
48
+ end
49
+ end
50
+
51
+ def initialize(input, output, process: nil, stderr: nil, closeables: [])
52
+ @input = input
53
+ @output = output
54
+ @process = process
55
+ @stderr = stderr
56
+ @closeables = closeables.uniq
57
+ @pid = process&.pid
58
+ @stderr_lines = []
59
+ @write_lock = Mutex.new
60
+ @close_lock = Mutex.new
61
+ @closing = false
62
+ [@input, @output, @stderr].compact.uniq.each(&:binmode)
63
+ end
64
+
65
+ def listen(&receive)
66
+ raise ArgumentError, "receiver required" unless receive
67
+
68
+ @close_lock.synchronize do
69
+ raise Error, "debug adapter transport is closed" if @closing
70
+ raise Error, "debug adapter transport is already listening" if @reader
71
+
72
+ @reader = Thread.new { read(receive) }
73
+ @logger = Thread.new { read_stderr } if @stderr
74
+ [@reader, @logger].compact.each { |thread| thread.report_on_exception = false }
75
+ end
76
+ self
77
+ end
78
+
79
+ def write(message)
80
+ frame = self.class.frame(message)
81
+ @write_lock.synchronize do
82
+ raise Error, "debug adapter write failed: closed transport" unless alive?
83
+
84
+ @output.write(frame)
85
+ @output.flush
86
+ end
87
+ nil
88
+ rescue IOError, SystemCallError => error
89
+ raise Error, "debug adapter write failed: #{error.message}"
90
+ end
91
+
92
+ def alive?
93
+ @close_lock.synchronize do
94
+ !@closing && (@process ? @process.alive? : !@input.closed? && !@output.closed?)
95
+ end
96
+ end
97
+
98
+ def close
99
+ threads = @close_lock.synchronize do
100
+ next if @closing
101
+
102
+ @closing = true
103
+ [@reader, @logger]
104
+ end
105
+ return unless threads
106
+
107
+ begin
108
+ begin
109
+ @output.shutdown(Socket::SHUT_RDWR) if @output.is_a?(BasicSocket)
110
+ rescue IOError, SystemCallError
111
+ nil
112
+ end
113
+ @output.close unless @output.closed?
114
+ stop_process
115
+ ensure
116
+ @closeables.each do |io|
117
+ io.close unless io.closed?
118
+ rescue IOError
119
+ nil
120
+ end
121
+ threads.compact.each do |thread|
122
+ next if thread == Thread.current
123
+
124
+ thread.kill unless thread.join(1)
125
+ thread.join(1)
126
+ end
127
+ end
128
+ nil
129
+ end
130
+
131
+ private
132
+
133
+ def read(receive)
134
+ while (message = self.class.read_message(@input))
135
+ receive.call(message, nil)
136
+ end
137
+ receive.call(nil, Error.new("debug adapter closed its connection")) unless closing?
138
+ rescue StandardError => error
139
+ begin
140
+ receive.call(nil, error) unless closing?
141
+ rescue StandardError
142
+ nil
143
+ end
144
+ end
145
+
146
+ def read_stderr
147
+ while (line = @stderr.gets("\n", MAX_HEADER_LINE))
148
+ @stderr_lines << line.scrub.byteslice(0, MAX_HEADER_LINE).scrub("")
149
+ @stderr_lines.shift if @stderr_lines.length > 200
150
+ end
151
+ rescue IOError
152
+ nil
153
+ end
154
+
155
+ def closing? = @close_lock.synchronize { @closing }
156
+
157
+ def stop_process
158
+ return unless @process
159
+ return if @process.join(1)
160
+
161
+ Process.kill("TERM", @process.pid)
162
+ return if @process.join(1)
163
+
164
+ Process.kill("KILL", @process.pid)
165
+ @process.join(1)
166
+ rescue Errno::ESRCH, Errno::ECHILD
167
+ nil
168
+ end
169
+ end
170
+ end
171
+
172
+ require_relative "transport/framing"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Megrez
4
+ VERSION = "0.1.0"
5
+ end
data/lib/megrez.rb ADDED
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "megrez/version"
4
+
5
+ module Megrez
6
+ class Error < StandardError; end
7
+ class Timeout < Error; end
8
+ class Cancelled < Error; end
9
+
10
+ class AdapterError < Error
11
+ attr_reader :command, :body
12
+
13
+ def initialize(command, message, body = nil)
14
+ @command = command
15
+ @body = body
16
+ super(message)
17
+ end
18
+ end
19
+
20
+ module Value
21
+ module_function
22
+
23
+ def define(*members)
24
+ return Data.define(*members) if defined?(Data)
25
+
26
+ Struct.new(*members) do
27
+ members.each { |member| undef_method("#{member}=") }
28
+
29
+ define_method(:initialize) do |*values, **keywords|
30
+ if keywords.empty?
31
+ raise ArgumentError, "wrong number of arguments" unless values.length == self.class.members.length
32
+
33
+ super(*values)
34
+ else
35
+ raise ArgumentError, "cannot mix positional and keyword arguments" unless values.empty?
36
+
37
+ missing = self.class.members - keywords.keys
38
+ unknown = keywords.keys - self.class.members
39
+ raise ArgumentError, "missing keyword: #{missing.first.inspect}" unless missing.empty?
40
+ raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
41
+
42
+ super(*self.class.members.map { |member| keywords.fetch(member) })
43
+ end
44
+ freeze
45
+ end
46
+
47
+ define_method(:with) do |**changes|
48
+ return self if changes.empty?
49
+
50
+ unknown = changes.keys - self.class.members
51
+ raise ArgumentError, "unknown keyword: #{unknown.first.inspect}" unless unknown.empty?
52
+
53
+ self.class.new(**to_h.merge(changes))
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ Breakpoint = Value.define(:id, :verified, :source, :line, :column, :message)
60
+ SourceBreakpoint = Value.define(:line, :column, :condition, :hit_condition, :log_message)
61
+ StackFrame = Value.define(:id, :name, :source, :line, :column, :presentation_hint)
62
+ Scope = Value.define(:name, :variables_reference, :expensive, :presentation_hint)
63
+ Variable = Value.define(:name, :value, :type, :variables_reference,
64
+ :named_count, :indexed_count, :memory_reference)
65
+ ThreadInfo = Value.define(:id, :name)
66
+ private_constant :Value
67
+ end
68
+
69
+ require_relative "megrez/protocol"
70
+ require_relative "megrez/results"
71
+ require_relative "megrez/future"
72
+ require_relative "megrez/transport"
73
+ require_relative "megrez/session"