mt-lang 0.3.17 → 0.3.20
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 +4 -4
- data/lib/milk_tea/base.rb +1 -1
- data/lib/milk_tea/core/c_backend/expressions.rb +34 -23
- data/lib/milk_tea/core/c_backend/feature_detection.rb +25 -45
- data/lib/milk_tea/core/c_backend/type_collectors.rb +18 -30
- data/lib/milk_tea/core/c_backend/type_declaration.rb +0 -6
- data/lib/milk_tea/core/c_backend.rb +49 -39
- data/lib/milk_tea/core/compile_time.rb +98 -72
- data/lib/milk_tea/core/intrinsics.rb +7 -0
- data/lib/milk_tea/core/lexer.rb +46 -35
- data/lib/milk_tea/core/lowering/block.rb +9 -0
- data/lib/milk_tea/core/lowering/calls.rb +2 -0
- data/lib/milk_tea/core/lowering/declarations.rb +1 -1
- data/lib/milk_tea/core/lowering/functions.rb +11 -8
- data/lib/milk_tea/core/lowering/resolve.rb +10 -3
- data/lib/milk_tea/core/lowering/scans.rb +13 -18
- data/lib/milk_tea/core/module_binder.rb +9 -10
- data/lib/milk_tea/core/module_loader.rb +38 -42
- data/lib/milk_tea/core/module_path_resolver.rb +1 -4
- data/lib/milk_tea/core/parser/declarations.rb +43 -19
- data/lib/milk_tea/core/parser/expressions.rb +4 -7
- data/lib/milk_tea/core/parser/statements.rb +5 -5
- data/lib/milk_tea/core/parser.rb +26 -0
- data/lib/milk_tea/core/semantic_analyzer/calls.rb +24 -31
- data/lib/milk_tea/core/semantic_analyzer/expressions.rb +20 -23
- data/lib/milk_tea/core/semantic_analyzer/name_resolution.rb +117 -85
- data/lib/milk_tea/core/semantic_analyzer/statements.rb +19 -41
- data/lib/milk_tea/core/semantic_analyzer.rb +56 -37
- data/lib/milk_tea/lsp/server/semantic_tokens.rb +6 -0
- data/lib/milk_tea/tooling/cli/commands/bindgen.rb +11 -0
- data/lib/milk_tea/tooling/cli/commands/build.rb +37 -0
- data/lib/milk_tea/tooling/cli/commands/cache.rb +46 -0
- data/lib/milk_tea/tooling/cli/commands/check.rb +116 -0
- data/lib/milk_tea/tooling/cli/commands/command_base.rb +8 -0
- data/lib/milk_tea/tooling/cli/commands/completions.rb +48 -0
- data/lib/milk_tea/tooling/cli/commands/dap.rb +58 -0
- data/lib/milk_tea/tooling/cli/commands/debug.rb +77 -0
- data/lib/milk_tea/tooling/cli/commands/deps.rb +17 -0
- data/lib/milk_tea/tooling/cli/commands/docs.rb +58 -0
- data/lib/milk_tea/tooling/cli/commands/emit_c.rb +64 -0
- data/lib/milk_tea/tooling/cli/commands/format.rb +199 -0
- data/lib/milk_tea/tooling/cli/commands/lex.rb +46 -0
- data/lib/milk_tea/tooling/cli/commands/lint.rb +248 -0
- data/lib/milk_tea/tooling/cli/commands/lower.rb +50 -0
- data/lib/milk_tea/tooling/cli/commands/lsp.rb +43 -0
- data/lib/milk_tea/tooling/cli/commands/new.rb +26 -0
- data/lib/milk_tea/tooling/cli/commands/parse.rb +51 -0
- data/lib/milk_tea/tooling/cli/commands/run.rb +99 -0
- data/lib/milk_tea/tooling/cli/commands/snapshot.rb +117 -0
- data/lib/milk_tea/tooling/cli/commands/test.rb +557 -0
- data/lib/milk_tea/tooling/cli/commands/toolchain.rb +16 -0
- data/lib/milk_tea/tooling/cli.rb +101 -1893
- metadata +24 -2
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MilkTea
|
|
4
|
+
class CLI
|
|
5
|
+
module CommandTest
|
|
6
|
+
TEST_RUN_TIMEOUT_SECONDS = 30
|
|
7
|
+
TEST_RUN_MEMORY_LIMIT_BYTES = 1024 * 1024 * 1024
|
|
8
|
+
|
|
9
|
+
TestResult = Data.define(:name, :status, :detail)
|
|
10
|
+
|
|
11
|
+
def test_command
|
|
12
|
+
limits = extract_test_limit_flags!
|
|
13
|
+
return 1 unless limits
|
|
14
|
+
|
|
15
|
+
@test_timeout_seconds, @test_memory_bytes, @test_jobs, @test_sanitize, @test_filter, @test_format = limits
|
|
16
|
+
|
|
17
|
+
path, options = extract_path_and_options
|
|
18
|
+
return 1 unless path
|
|
19
|
+
|
|
20
|
+
frozen = options.delete(:frozen)
|
|
21
|
+
ensure_current_lockfile!(path) if frozen
|
|
22
|
+
locked = options.delete(:locked)
|
|
23
|
+
|
|
24
|
+
return dispatch_test_run(path, options:, locked:) if @test_format == :human
|
|
25
|
+
|
|
26
|
+
buffer = StringIO.new
|
|
27
|
+
real_out = @out
|
|
28
|
+
real_err = @err
|
|
29
|
+
@out = buffer
|
|
30
|
+
@err = buffer
|
|
31
|
+
begin
|
|
32
|
+
exit_code = dispatch_test_run(path, options:, locked:)
|
|
33
|
+
ensure
|
|
34
|
+
@out = real_out
|
|
35
|
+
@err = real_err
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
emit_machine_results(parse_test_output(buffer.string), @test_format, real_out)
|
|
39
|
+
exit_code
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def dispatch_test_run(path, options:, locked:)
|
|
43
|
+
if File.directory?(path)
|
|
44
|
+
run_test_directory(path, options:, locked:)
|
|
45
|
+
elsif File.file?(path)
|
|
46
|
+
if compile_fail_fixture?(File.read(path))
|
|
47
|
+
run_compile_fail_test(path) ? 0 : 1
|
|
48
|
+
else
|
|
49
|
+
run_test_file(path, options:, locked:)
|
|
50
|
+
end
|
|
51
|
+
else
|
|
52
|
+
@err.puts("mtc test: not a file or directory: #{path}")
|
|
53
|
+
1
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def extract_test_limit_flags!
|
|
58
|
+
timeout_seconds = TEST_RUN_TIMEOUT_SECONDS
|
|
59
|
+
memory_bytes = TEST_RUN_MEMORY_LIMIT_BYTES
|
|
60
|
+
jobs = 1
|
|
61
|
+
sanitize = false
|
|
62
|
+
filter = nil
|
|
63
|
+
format = :human
|
|
64
|
+
remaining = []
|
|
65
|
+
until @argv.empty?
|
|
66
|
+
arg = @argv.shift
|
|
67
|
+
case arg
|
|
68
|
+
when "--timeout"
|
|
69
|
+
value = @argv.shift
|
|
70
|
+
seconds = value && Integer(value, exception: false)
|
|
71
|
+
unless seconds&.positive?
|
|
72
|
+
@err.puts("--timeout requires a positive integer (seconds)")
|
|
73
|
+
return nil
|
|
74
|
+
end
|
|
75
|
+
timeout_seconds = seconds
|
|
76
|
+
when "--mem"
|
|
77
|
+
value = @argv.shift
|
|
78
|
+
megabytes = value && Integer(value, exception: false)
|
|
79
|
+
unless megabytes&.positive?
|
|
80
|
+
@err.puts("--mem requires a positive integer (megabytes)")
|
|
81
|
+
return nil
|
|
82
|
+
end
|
|
83
|
+
memory_bytes = megabytes * 1024 * 1024
|
|
84
|
+
when "--jobs"
|
|
85
|
+
value = @argv.shift
|
|
86
|
+
count = value && Integer(value, exception: false)
|
|
87
|
+
unless count&.positive?
|
|
88
|
+
@err.puts("--jobs requires a positive integer")
|
|
89
|
+
return nil
|
|
90
|
+
end
|
|
91
|
+
jobs = count
|
|
92
|
+
when "--sanitize"
|
|
93
|
+
sanitize = true
|
|
94
|
+
when "-n", "--name"
|
|
95
|
+
value = @argv.shift
|
|
96
|
+
unless value
|
|
97
|
+
@err.puts("-n requires a name substring")
|
|
98
|
+
return nil
|
|
99
|
+
end
|
|
100
|
+
filter = value
|
|
101
|
+
when "--format"
|
|
102
|
+
value = @argv.shift
|
|
103
|
+
unless %w[human tap junit].include?(value)
|
|
104
|
+
@err.puts("--format must be human, tap, or junit")
|
|
105
|
+
return nil
|
|
106
|
+
end
|
|
107
|
+
format = value.to_sym
|
|
108
|
+
when "--"
|
|
109
|
+
remaining << arg
|
|
110
|
+
remaining.concat(@argv)
|
|
111
|
+
@argv.clear
|
|
112
|
+
else
|
|
113
|
+
remaining << arg
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
@argv.replace(remaining)
|
|
117
|
+
[timeout_seconds, memory_bytes, jobs, sanitize, filter, format]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def parse_test_output(text)
|
|
121
|
+
results = []
|
|
122
|
+
current_file = nil
|
|
123
|
+
text.each_line do |raw|
|
|
124
|
+
line = raw.chomp
|
|
125
|
+
case line
|
|
126
|
+
when /\A# (.+)\z/
|
|
127
|
+
current_file = ::Regexp.last_match(1)
|
|
128
|
+
when /\Aok - (.+)\z/
|
|
129
|
+
results << TestResult.new(name: ::Regexp.last_match(1), status: :pass, detail: nil)
|
|
130
|
+
when /\Askip - (.+?)(?:: (.*))?\z/
|
|
131
|
+
results << TestResult.new(name: ::Regexp.last_match(1), status: :skip, detail: ::Regexp.last_match(2))
|
|
132
|
+
when /\AFAIL - (.+?)(?:: (.*))?\z/
|
|
133
|
+
results << TestResult.new(name: ::Regexp.last_match(1), status: :fail, detail: ::Regexp.last_match(2))
|
|
134
|
+
when /\AFAILED - (.+?) \(build error\)\z/
|
|
135
|
+
results << TestResult.new(name: "#{::Regexp.last_match(1)} (build error)", status: :fail, detail: "build error")
|
|
136
|
+
when /\Atest run (?:timed out|crashed)/, /\ASUMMARY: \w+Sanitizer/
|
|
137
|
+
results << TestResult.new(name: "#{current_file || 'test'} (#{line})", status: :fail, detail: line)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
results
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def emit_machine_results(results, format, out)
|
|
144
|
+
case format
|
|
145
|
+
when :tap then emit_tap(results, out)
|
|
146
|
+
when :junit then emit_junit(results, out)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def emit_tap(results, out)
|
|
151
|
+
out.puts("TAP version 13")
|
|
152
|
+
out.puts("1..#{results.length}")
|
|
153
|
+
results.each_with_index do |result, index|
|
|
154
|
+
number = index + 1
|
|
155
|
+
case result.status
|
|
156
|
+
when :pass
|
|
157
|
+
out.puts("ok #{number} - #{result.name}")
|
|
158
|
+
when :skip
|
|
159
|
+
out.puts("ok #{number} - #{result.name} # SKIP#{result.detail ? " #{result.detail}" : ''}")
|
|
160
|
+
when :fail
|
|
161
|
+
out.puts("not ok #{number} - #{result.name}")
|
|
162
|
+
next unless result.detail
|
|
163
|
+
|
|
164
|
+
out.puts(" ---")
|
|
165
|
+
out.puts(" message: #{result.detail}")
|
|
166
|
+
out.puts(" ...")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def emit_junit(results, out)
|
|
172
|
+
failures = results.count { |result| result.status == :fail }
|
|
173
|
+
skipped = results.count { |result| result.status == :skip }
|
|
174
|
+
out.puts(%(<?xml version="1.0" encoding="UTF-8"?>))
|
|
175
|
+
out.puts(%(<testsuites tests="#{results.length}" failures="#{failures}" skipped="#{skipped}">))
|
|
176
|
+
out.puts(%( <testsuite name="mtc test" tests="#{results.length}" failures="#{failures}" skipped="#{skipped}">))
|
|
177
|
+
results.each do |result|
|
|
178
|
+
name = xml_escape(result.name)
|
|
179
|
+
case result.status
|
|
180
|
+
when :pass
|
|
181
|
+
out.puts(%( <testcase name="#{name}"/>))
|
|
182
|
+
when :skip
|
|
183
|
+
out.puts(%( <testcase name="#{name}"><skipped/></testcase>))
|
|
184
|
+
when :fail
|
|
185
|
+
out.puts(%( <testcase name="#{name}"><failure message="#{xml_escape(result.detail || 'failed')}"/></testcase>))
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
out.puts(" </testsuite>")
|
|
189
|
+
out.puts("</testsuites>")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def xml_escape(value)
|
|
193
|
+
value.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def run_test_directory(directory, options:, locked:)
|
|
197
|
+
test_files = discover_test_files(directory)
|
|
198
|
+
if test_files.empty?
|
|
199
|
+
if @test_filter
|
|
200
|
+
@out.puts("no tests matched -n '#{@test_filter}' under #{directory}")
|
|
201
|
+
else
|
|
202
|
+
@out.puts("no @[test] functions or # expect-error: fixtures found under #{directory}")
|
|
203
|
+
end
|
|
204
|
+
return 0
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
jobs = @test_jobs || 1
|
|
208
|
+
return run_test_files_parallel(test_files, jobs:, options:, locked:) if jobs > 1 && Process.respond_to?(:fork)
|
|
209
|
+
|
|
210
|
+
failed = 0
|
|
211
|
+
test_files.each do |file, kind|
|
|
212
|
+
@out.puts("# #{file}")
|
|
213
|
+
@out.flush if @out.respond_to?(:flush)
|
|
214
|
+
failed += 1 unless run_classified_file(file, kind, options:, locked:).zero?
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
@out.puts("")
|
|
218
|
+
@out.puts("#{test_files.length} test file(s), #{failed} failed")
|
|
219
|
+
failed.zero? ? 0 : 1
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def run_classified_file(file, kind, options:, locked:)
|
|
223
|
+
if kind == :compile_fail
|
|
224
|
+
run_compile_fail_test(file) ? 0 : 1
|
|
225
|
+
else
|
|
226
|
+
run_test_file_guarded(file, options:, locked:)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def run_compile_fail_test(path)
|
|
231
|
+
source = File.read(path)
|
|
232
|
+
expectations = extract_expect_error_directives(source)
|
|
233
|
+
messages = compile_fail_diagnostics(path)
|
|
234
|
+
|
|
235
|
+
if messages.empty?
|
|
236
|
+
@out.puts("FAIL - #{path} (compile-fail): expected a compile error, but it compiled cleanly")
|
|
237
|
+
@out.flush if @out.respond_to?(:flush)
|
|
238
|
+
return false
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
unmatched = expectations.find { |expected| messages.none? { |message| message.include?(expected) } }
|
|
242
|
+
if unmatched
|
|
243
|
+
@out.puts("FAIL - #{path} (compile-fail): no diagnostic matched #{unmatched.inspect}")
|
|
244
|
+
@out.flush if @out.respond_to?(:flush)
|
|
245
|
+
return false
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
@out.puts("ok - #{path} (compile-fail)")
|
|
249
|
+
@out.flush if @out.respond_to?(:flush)
|
|
250
|
+
true
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def extract_expect_error_directives(source)
|
|
254
|
+
source.each_line.filter_map do |line|
|
|
255
|
+
match = line.match(/^\s*#\s*expect-error:\s*(.+?)\s*$/)
|
|
256
|
+
match && match[1]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def compile_fail_diagnostics(path)
|
|
261
|
+
errors, = check_single_reporting_all(path, locked: false)
|
|
262
|
+
errors
|
|
263
|
+
.select { |diagnostic| !diagnostic.respond_to?(:severity) || diagnostic.severity == :error }
|
|
264
|
+
.map { |diagnostic| ErrorFormatter.format(diagnostic, color: false) }
|
|
265
|
+
rescue StandardError => e
|
|
266
|
+
raise unless handled_cli_error?(e)
|
|
267
|
+
|
|
268
|
+
[ErrorFormatter.format(e, color: false)]
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def run_test_file_guarded(file, options:, locked:)
|
|
272
|
+
run_test_file(file, options:, locked:)
|
|
273
|
+
rescue StandardError => e
|
|
274
|
+
raise unless handled_cli_error?(e)
|
|
275
|
+
|
|
276
|
+
@err.puts("FAILED - #{file} (build error)")
|
|
277
|
+
@err.puts(ErrorFormatter.format(e, color: error_color?(@err)))
|
|
278
|
+
1
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def run_test_files_parallel(test_files, jobs:, options:, locked:)
|
|
282
|
+
results = Array.new(test_files.length)
|
|
283
|
+
result_paths = {}
|
|
284
|
+
active = {}
|
|
285
|
+
cursor = 0
|
|
286
|
+
|
|
287
|
+
while cursor < test_files.length || !active.empty?
|
|
288
|
+
while active.size < jobs && cursor < test_files.length
|
|
289
|
+
index = cursor
|
|
290
|
+
cursor += 1
|
|
291
|
+
result_path = File.join(Dir.tmpdir, "mttest_result_#{Process.pid}_#{index}")
|
|
292
|
+
result_paths[index] = result_path
|
|
293
|
+
pid = fork do
|
|
294
|
+
captured = StringIO.new
|
|
295
|
+
@out = captured
|
|
296
|
+
@err = captured
|
|
297
|
+
file, kind = test_files[index]
|
|
298
|
+
code = run_classified_file(file, kind, options:, locked:)
|
|
299
|
+
File.binwrite(result_path, [code].pack("N") + captured.string)
|
|
300
|
+
exit!(0)
|
|
301
|
+
end
|
|
302
|
+
active[pid] = index
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
finished_pid, = Process.wait2
|
|
306
|
+
index = active.delete(finished_pid)
|
|
307
|
+
next unless index
|
|
308
|
+
|
|
309
|
+
path = result_paths[index]
|
|
310
|
+
data = begin
|
|
311
|
+
File.binread(path)
|
|
312
|
+
rescue StandardError
|
|
313
|
+
(+"").b
|
|
314
|
+
end
|
|
315
|
+
File.delete(path) if File.exist?(path)
|
|
316
|
+
results[index] =
|
|
317
|
+
if data.bytesize >= 4
|
|
318
|
+
[data[0, 4].unpack1("N"), data.byteslice(4..).force_encoding(Encoding::UTF_8)]
|
|
319
|
+
else
|
|
320
|
+
[1, +""]
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
failed = 0
|
|
325
|
+
test_files.each_with_index do |(file, _kind), index|
|
|
326
|
+
code, output = results[index]
|
|
327
|
+
@out.puts("# #{file}")
|
|
328
|
+
@out.write(output.to_s)
|
|
329
|
+
failed += 1 unless code&.zero?
|
|
330
|
+
end
|
|
331
|
+
@out.flush if @out.respond_to?(:flush)
|
|
332
|
+
@out.puts("")
|
|
333
|
+
@out.puts("#{test_files.length} test file(s), #{failed} failed")
|
|
334
|
+
failed.zero? ? 0 : 1
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def discover_test_files(directory)
|
|
338
|
+
Dir.glob(File.join(directory, "**", "*.mt")).sort.filter_map do |file|
|
|
339
|
+
next if File.basename(file).start_with?("__mt_test_runner_")
|
|
340
|
+
|
|
341
|
+
kind = classify_test_file(file)
|
|
342
|
+
kind && [file, kind]
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def classify_test_file(file)
|
|
347
|
+
source = File.read(file)
|
|
348
|
+
if compile_fail_fixture?(source)
|
|
349
|
+
return nil if @test_filter && !File.basename(file, ".mt").include?(@test_filter)
|
|
350
|
+
|
|
351
|
+
return :compile_fail
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
ast = begin
|
|
355
|
+
MilkTea::Parser.parse(source, path: file)
|
|
356
|
+
rescue ParseError
|
|
357
|
+
return nil
|
|
358
|
+
end
|
|
359
|
+
has_match = ast.declarations.any? do |decl|
|
|
360
|
+
decl.is_a?(AST::FunctionDef) && test_attribute?(decl) && matches_filter?(decl.name)
|
|
361
|
+
end
|
|
362
|
+
has_match ? :test : nil
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def compile_fail_fixture?(source)
|
|
366
|
+
source.match?(/^\s*#\s*expect-error:/)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def test_attribute?(decl)
|
|
370
|
+
decl.attributes.any? { |attribute| attribute.name.parts == ["test"] }
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def matches_filter?(name)
|
|
374
|
+
@test_filter.nil? || name.include?(@test_filter)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def run_test_file(path, options:, locked:)
|
|
378
|
+
source = File.read(path)
|
|
379
|
+
ast = MilkTea::Parser.parse(source, path:)
|
|
380
|
+
|
|
381
|
+
if ast.declarations.any? { |decl| decl.is_a?(AST::FunctionDef) && decl.name == "main" }
|
|
382
|
+
@err.puts("a test file must not define 'main': #{path}")
|
|
383
|
+
return 1
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
tests = ast.declarations.select { |decl| decl.is_a?(AST::FunctionDef) && test_attribute?(decl) }
|
|
387
|
+
|
|
388
|
+
if tests.empty?
|
|
389
|
+
@out.puts("no @[test] functions found in #{path}")
|
|
390
|
+
return 0
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
tests = tests.select { |test| matches_filter?(test.name) }
|
|
394
|
+
return 0 if tests.empty?
|
|
395
|
+
|
|
396
|
+
invalid = tests.find { |test| !test.params.empty? }
|
|
397
|
+
if invalid
|
|
398
|
+
@err.puts("@[test] function '#{invalid.name}' must take no parameters")
|
|
399
|
+
return 1
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
testing_import = ast.imports.find { |import| import.path.parts == %w[std testing] }
|
|
403
|
+
unless testing_import
|
|
404
|
+
@err.puts("a test file must import std.testing: #{path}")
|
|
405
|
+
return 1
|
|
406
|
+
end
|
|
407
|
+
testing_alias = testing_import.alias_name || testing_import.path.parts.last
|
|
408
|
+
|
|
409
|
+
death_tests, normal_tests = tests.partition { |test| expect_fatal_attribute?(test) }
|
|
410
|
+
|
|
411
|
+
exit_code = 0
|
|
412
|
+
|
|
413
|
+
unless normal_tests.empty?
|
|
414
|
+
runner_source = source.dup
|
|
415
|
+
runner_source << "\n\n" << test_runner_main(testing_alias, normal_tests.map(&:name))
|
|
416
|
+
exit_code = run_synthesized_tests(path, runner_source, options:, locked:)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
death_tests.each do |death_test|
|
|
420
|
+
exit_code = 1 unless run_death_test(path, source, death_test.name, options:, locked:)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
exit_code
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def expect_fatal_attribute?(decl)
|
|
427
|
+
decl.attributes.any? { |attribute| attribute.name.parts == ["expect_fatal"] }
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def run_death_test(source_path, source, test_name, options:, locked:)
|
|
431
|
+
runner_source = source.dup
|
|
432
|
+
runner_source << "\n\n" << death_test_runner_main(test_name)
|
|
433
|
+
classification = with_synthesized_binary(source_path, runner_source, options:, locked:) do |binary_path|
|
|
434
|
+
classify_death_test(binary_path)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
passed = classification == :aborted
|
|
438
|
+
line =
|
|
439
|
+
if passed
|
|
440
|
+
"ok - #{test_name} (expect_fatal)"
|
|
441
|
+
elsif classification == :timed_out
|
|
442
|
+
"FAIL - #{test_name} (expect_fatal): timed out"
|
|
443
|
+
else
|
|
444
|
+
"FAIL - #{test_name} (expect_fatal): expected a fatal abort, but the test returned"
|
|
445
|
+
end
|
|
446
|
+
@out.puts(line)
|
|
447
|
+
@out.flush if @out.respond_to?(:flush)
|
|
448
|
+
passed
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def classify_death_test(binary_path)
|
|
452
|
+
_output, status, timed_out = spawn_sandboxed(binary_path)
|
|
453
|
+
return :timed_out if timed_out
|
|
454
|
+
return :returned if status&.exited? && status.exitstatus&.zero?
|
|
455
|
+
|
|
456
|
+
:aborted
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def death_test_runner_main(test_name)
|
|
460
|
+
[
|
|
461
|
+
"function main() -> int:",
|
|
462
|
+
" match #{test_name}():",
|
|
463
|
+
" Result.success:",
|
|
464
|
+
" return 0",
|
|
465
|
+
" Result.failure:",
|
|
466
|
+
" return 0",
|
|
467
|
+
].join("\n") + "\n"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def test_runner_main(testing_alias, test_names)
|
|
471
|
+
lines = ["function main() -> int:"]
|
|
472
|
+
lines << " var __mt_test_stats = #{testing_alias}.Stats.create()"
|
|
473
|
+
test_names.each do |name|
|
|
474
|
+
lines << " __mt_test_stats = #{testing_alias}.record(__mt_test_stats, #{name.inspect}, #{name}())"
|
|
475
|
+
end
|
|
476
|
+
lines << " return #{testing_alias}.summarize(__mt_test_stats)"
|
|
477
|
+
lines.join("\n") + "\n"
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def run_synthesized_tests(source_path, runner_source, options:, locked:)
|
|
481
|
+
with_synthesized_binary(source_path, runner_source, options:, locked:) do |binary_path|
|
|
482
|
+
run_test_binary(binary_path)
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def with_synthesized_binary(source_path, runner_source, options:, locked:)
|
|
487
|
+
directory = File.dirname(File.expand_path(source_path))
|
|
488
|
+
runner_path = File.join(directory, "__mt_test_runner_#{Process.pid}.mt")
|
|
489
|
+
binary_path = File.join(Dir.tmpdir, "__mt_test_runner_#{Process.pid}")
|
|
490
|
+
|
|
491
|
+
File.write(runner_path, runner_source)
|
|
492
|
+
begin
|
|
493
|
+
build_opts = options.except(:timings, :output_path, :bundle, :archive)
|
|
494
|
+
build_opts[:debug_guards] = false
|
|
495
|
+
Build.build(
|
|
496
|
+
runner_path,
|
|
497
|
+
output_path: binary_path,
|
|
498
|
+
module_roots: module_roots_for(source_path, locked:),
|
|
499
|
+
package_graph: package_graph_for(source_path, locked:),
|
|
500
|
+
frontend: @build_frontend,
|
|
501
|
+
sanitize: @test_sanitize,
|
|
502
|
+
**build_opts,
|
|
503
|
+
)
|
|
504
|
+
yield binary_path
|
|
505
|
+
ensure
|
|
506
|
+
File.delete(runner_path) if File.exist?(runner_path)
|
|
507
|
+
File.delete(binary_path) if File.exist?(binary_path)
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def run_test_binary(binary_path)
|
|
512
|
+
output, status, timed_out = spawn_sandboxed(binary_path)
|
|
513
|
+
@out.write(output)
|
|
514
|
+
@out.flush if @out.respond_to?(:flush)
|
|
515
|
+
|
|
516
|
+
if timed_out
|
|
517
|
+
@err.puts("test run timed out after #{@test_timeout_seconds || TEST_RUN_TIMEOUT_SECONDS}s")
|
|
518
|
+
return 1
|
|
519
|
+
end
|
|
520
|
+
if status&.signaled?
|
|
521
|
+
@err.puts("test run crashed (signal #{status.termsig})")
|
|
522
|
+
return 1
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
status&.exitstatus || 1
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def spawn_sandboxed(binary_path)
|
|
529
|
+
timeout_seconds = @test_timeout_seconds || TEST_RUN_TIMEOUT_SECONDS
|
|
530
|
+
memory_bytes = @test_memory_bytes || TEST_RUN_MEMORY_LIMIT_BYTES
|
|
531
|
+
reader, writer = IO.pipe
|
|
532
|
+
spawn_options = { out: writer, err: writer, pgroup: true }
|
|
533
|
+
spawn_options[:rlimit_as] = memory_bytes unless @test_sanitize
|
|
534
|
+
pid = Process.spawn(binary_path, **spawn_options)
|
|
535
|
+
writer.close
|
|
536
|
+
|
|
537
|
+
status = nil
|
|
538
|
+
timed_out = false
|
|
539
|
+
begin
|
|
540
|
+
Timeout.timeout(timeout_seconds) { _, status = Process.wait2(pid) }
|
|
541
|
+
rescue Timeout::Error
|
|
542
|
+
timed_out = true
|
|
543
|
+
begin
|
|
544
|
+
Process.kill("-KILL", Process.getpgid(pid))
|
|
545
|
+
Process.wait(pid)
|
|
546
|
+
rescue StandardError
|
|
547
|
+
nil
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
output = reader.read
|
|
552
|
+
reader.close
|
|
553
|
+
[output, status, timed_out]
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MilkTea
|
|
4
|
+
class CLI
|
|
5
|
+
module CommandToolchain
|
|
6
|
+
def toolchain_command
|
|
7
|
+
ToolchainCLI.start(
|
|
8
|
+
@argv,
|
|
9
|
+
out: @out,
|
|
10
|
+
err: @err,
|
|
11
|
+
help_printer: method(:print_toolchain_help),
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|