spitewaste 0.1.001
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +8 -0
- data/README.md +55 -0
- data/Rakefile +10 -0
- data/TUTORIAL.md +125 -0
- data/bin/spw +10 -0
- data/demo/factorial-nicespace.png +0 -0
- data/demo/factorial.asm +47 -0
- data/demo/factorial.png +0 -0
- data/demo/factorial.wsa +5 -0
- data/lib/spitewaste.rb +35 -0
- data/lib/spitewaste/assembler.rb +56 -0
- data/lib/spitewaste/cli.rb +51 -0
- data/lib/spitewaste/cli/asm.rb +10 -0
- data/lib/spitewaste/cli/compile.rb +60 -0
- data/lib/spitewaste/cli/convert.rb +53 -0
- data/lib/spitewaste/cli/exec.rb +43 -0
- data/lib/spitewaste/cli/image.rb +53 -0
- data/lib/spitewaste/emitter.rb +10 -0
- data/lib/spitewaste/emitters/assembly.rb +7 -0
- data/lib/spitewaste/emitters/codegen.rb +72 -0
- data/lib/spitewaste/emitters/image.rb +135 -0
- data/lib/spitewaste/emitters/linefeed.png +0 -0
- data/lib/spitewaste/emitters/schemes.yaml +1143 -0
- data/lib/spitewaste/emitters/whitespace.rb +14 -0
- data/lib/spitewaste/emitters/wsassembly.rb +7 -0
- data/lib/spitewaste/libspw/array.spw +82 -0
- data/lib/spitewaste/libspw/bits.spw +72 -0
- data/lib/spitewaste/libspw/case.spw +32 -0
- data/lib/spitewaste/libspw/fun.spw +42 -0
- data/lib/spitewaste/libspw/io.spw +39 -0
- data/lib/spitewaste/libspw/math.spw +117 -0
- data/lib/spitewaste/libspw/prime.spw +46 -0
- data/lib/spitewaste/libspw/random.spw +10 -0
- data/lib/spitewaste/libspw/stack.spw +84 -0
- data/lib/spitewaste/libspw/string.spw +233 -0
- data/lib/spitewaste/libspw/syntax.spw +2 -0
- data/lib/spitewaste/libspw/test.spw +8 -0
- data/lib/spitewaste/libspw/util.spw +98 -0
- data/lib/spitewaste/parsers/assembly.rb +35 -0
- data/lib/spitewaste/parsers/fucktional.rb +72 -0
- data/lib/spitewaste/parsers/spitewaste.rb +192 -0
- data/lib/spitewaste/parsers/whitespace.rb +60 -0
- data/lib/spitewaste/version.rb +3 -0
- data/spitewaste.gemspec +17 -0
- metadata +88 -0
@@ -0,0 +1,72 @@
|
|
1
|
+
module Spitewaste
|
2
|
+
# select() and reject() do almost the exact same thing, differing only in
|
3
|
+
# whether they drop or keep the match, so this is a bit of deduplication.
|
4
|
+
def self.generate_filter_spw fn, a, b
|
5
|
+
ops = ['push -10 dup call dec load swap store', 'pop']
|
6
|
+
yes, no = ops[a], ops[b]
|
7
|
+
<<SPW
|
8
|
+
push -10 dup store
|
9
|
+
#{fn}_loop_%1$s:
|
10
|
+
push 1 sub dup jn #{fn}_restore_%1$s
|
11
|
+
swap dup %2$s jz #{fn}_no_%1$s #{yes}
|
12
|
+
jump #{fn}_loop_%1$s
|
13
|
+
#{fn}_no_%1$s: #{no} jump #{fn}_loop_%1$s
|
14
|
+
#{fn}_restore_%1$s: push 9 sub load
|
15
|
+
#{fn}_restore_loop_%1$s:
|
16
|
+
dup push 10 add jz #{fn}_done_%1$s
|
17
|
+
dup load swap push 1 add
|
18
|
+
jump #{fn}_restore_loop_%1$s
|
19
|
+
#{fn}_done_%1$s: dup load sub
|
20
|
+
SPW
|
21
|
+
end
|
22
|
+
|
23
|
+
FUCKTIONAL = {
|
24
|
+
'map' => <<SPW,
|
25
|
+
push -9 push -1 store swap
|
26
|
+
map_loop_%1$s:
|
27
|
+
push -9 call inc swap dup dup
|
28
|
+
push -9 load sub jz map_done_%1$s
|
29
|
+
call roll %2$s jump map_loop_%1$s
|
30
|
+
map_done_%1$s: pop
|
31
|
+
SPW
|
32
|
+
|
33
|
+
'reduce' => <<SPW,
|
34
|
+
dup dup push 2 sub jn reduce_done_%1$s pop
|
35
|
+
push -9 swap push 1 sub store
|
36
|
+
reduce_loop_%1$s: %2$s
|
37
|
+
push -9 dup load push 1 sub dup jz reduce_done_%1$s
|
38
|
+
store jump reduce_loop_%1$s
|
39
|
+
reduce_done_%1$s: pop pop
|
40
|
+
SPW
|
41
|
+
|
42
|
+
'times' => <<SPW,
|
43
|
+
push -5 swap push -1 mul store
|
44
|
+
times_loop_%1$s: %2$s push -5 dup call inc load jn times_loop_%1$s
|
45
|
+
SPW
|
46
|
+
|
47
|
+
'find' => <<SPW,
|
48
|
+
find_loop_%1$s:
|
49
|
+
dup jz find_done_%1$s dup call roll
|
50
|
+
dup %2$s jz find_no_%1$s
|
51
|
+
swap push 1 sub call nslide push 1 add jump find_done_%1$s
|
52
|
+
find_no_%1$s: pop push 1 sub jump find_loop_%1$s
|
53
|
+
find_done_%1$s: push 1 sub
|
54
|
+
SPW
|
55
|
+
|
56
|
+
'maxby' => <<SPW,
|
57
|
+
push -3 swap store
|
58
|
+
maxby_loop_%1$s:
|
59
|
+
copy 1 %2$s copy 1 %2$s sub jn maxby_lesser_%1$s
|
60
|
+
maxby_resume_%1$s: pop push -3 dup call dec load push 1 call eq
|
61
|
+
jz maxby_loop_%1$s
|
62
|
+
jump maxby_done_%1$s
|
63
|
+
maxby_lesser_%1$s: swap jump maxby_resume_%1$s
|
64
|
+
maxby_done_%1$s:
|
65
|
+
SPW
|
66
|
+
|
67
|
+
'minby' => 'maxby (%2$s push -1 mul)',
|
68
|
+
'each' => 'dup times (dup call roll %2$s push 1 sub) pop',
|
69
|
+
'select' => generate_filter_spw('select', 0, 1),
|
70
|
+
'reject' => generate_filter_spw('reject', 1, 0),
|
71
|
+
}
|
72
|
+
end
|
@@ -0,0 +1,192 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'set'
|
3
|
+
require_relative 'fucktional'
|
4
|
+
|
5
|
+
module Spitewaste
|
6
|
+
LIBSPW = File.expand_path '../libspw', __dir__
|
7
|
+
|
8
|
+
class SpitewasteParser
|
9
|
+
NameError = Class.new Exception
|
10
|
+
|
11
|
+
INSTRUCTIONS = /(\S+):|(\b(#{OPERATORS_M2T.keys * ?|})\s+(-?\d\S*)?)/
|
12
|
+
SPECIAL_INSN = /(call|jump|jz|jn)\s+(\S+)/
|
13
|
+
|
14
|
+
attr_reader :src, :instructions, :error
|
15
|
+
|
16
|
+
def initialize program, **options
|
17
|
+
@src = program.dup
|
18
|
+
@macros = {}
|
19
|
+
@symbol_file = options['symbol_file']
|
20
|
+
|
21
|
+
preprocess!
|
22
|
+
eliminate_dead_code!
|
23
|
+
end
|
24
|
+
|
25
|
+
def parse
|
26
|
+
@instructions = []
|
27
|
+
|
28
|
+
# first-come, first-served mapping from label names to auto-incrementing
|
29
|
+
# indices starting from 0; it would be nice if names could round-trip,
|
30
|
+
# but even encoding short ones would result in unpleasantly wide code.
|
31
|
+
@symbol_table = Hash.new { |h, k| h[k] = h.size }
|
32
|
+
@src.scan(/(\S+):/) { @symbol_table[$1] } # populate label indices
|
33
|
+
File.write @symbol_file, @symbol_table.to_json if @symbol_file
|
34
|
+
|
35
|
+
special = @src.scan SPECIAL_INSN
|
36
|
+
@src.scan INSTRUCTIONS do |label, _, operator, arg|
|
37
|
+
next @instructions << [:label, @symbol_table[label]] if label
|
38
|
+
|
39
|
+
if %i[call jump jz jn].include? operator = operator.to_sym
|
40
|
+
arg = @symbol_table[special.shift[1]]
|
41
|
+
else
|
42
|
+
if OPERATORS_M2T.keys.index(operator) < 8 && !arg
|
43
|
+
raise "missing arg for #{operator}"
|
44
|
+
elsif arg
|
45
|
+
begin
|
46
|
+
arg = Integer arg
|
47
|
+
rescue ArgumentError
|
48
|
+
raise "invalid argument '#{arg}' for #{operator}"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
@instructions << [operator, arg]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def preprocess!
|
60
|
+
resolve_imports
|
61
|
+
seed_prng if @seen.include? 'random'
|
62
|
+
resolve_strings
|
63
|
+
remove_comments
|
64
|
+
add_sugar
|
65
|
+
fucktionalize
|
66
|
+
propagate_macros
|
67
|
+
end
|
68
|
+
|
69
|
+
def resolve_imports
|
70
|
+
@seen = Set.new
|
71
|
+
|
72
|
+
# "Recursively" resolve `import L (...)` statements with implicit include
|
73
|
+
# guarding. Each chunk of imports is appended to the end of the current
|
74
|
+
# source all at once to prevent having to explicitly jump to the actual
|
75
|
+
# start of the program. Some care must be taken to ensure that the final
|
76
|
+
# routine of one library won't inadvertently "flow" into the next.
|
77
|
+
while @src['import']
|
78
|
+
imports = []
|
79
|
+
@src.gsub!(/import\s+(\S+).*/) {
|
80
|
+
imports << resolve($1) if @seen.add? $1
|
81
|
+
'' # remove import statement
|
82
|
+
}
|
83
|
+
@src << imports.join(?\n)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def resolve path
|
88
|
+
library = path[?/] ? path : File.join(LIBSPW, path)
|
89
|
+
File.read "#{library}.spw"
|
90
|
+
end
|
91
|
+
|
92
|
+
def seed_prng
|
93
|
+
@src.prepend "push $seed,#{rand 2**31} store $seed = -9001"
|
94
|
+
end
|
95
|
+
|
96
|
+
def resolve_strings
|
97
|
+
@src.gsub!(/"([^"]+)"/m) {
|
98
|
+
[0, *$1.reverse.bytes] * ' push ' + ' :strpack'
|
99
|
+
}
|
100
|
+
end
|
101
|
+
|
102
|
+
def remove_comments
|
103
|
+
@src.gsub!(/;.+/, '')
|
104
|
+
end
|
105
|
+
|
106
|
+
def add_sugar
|
107
|
+
# `:foo` = `call foo`
|
108
|
+
@src.gsub!(/:(\S+)/, 'call \1')
|
109
|
+
# character literals
|
110
|
+
@src.gsub!(/'(.)'/) { $1.ord }
|
111
|
+
# quick push (`push 1,2,3` desugars to individual pushes)
|
112
|
+
@src.gsub!(/push \S+/) { _1.split(?,) * ' push ' }
|
113
|
+
end
|
114
|
+
|
115
|
+
def gensym
|
116
|
+
(@sym ||= ?`).succ!
|
117
|
+
end
|
118
|
+
|
119
|
+
def fucktionalize
|
120
|
+
# Iteratively remove pseudo-fp calls until we can't to allow nesting.
|
121
|
+
1 while @src.gsub!(/(#{FUCKTIONAL.keys * ?|})\s*\((.+)\)/) do
|
122
|
+
FUCKTIONAL[$1] % [gensym, $2]
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def propagate_macros
|
127
|
+
@src.gsub!(/(\$\S+)\s*=\s*(.+)/) { @macros[$1] = $2; '' }
|
128
|
+
@src.gsub!(/(\$\S+)/) { @macros[$1] || raise("no macro '#{$1}'") }
|
129
|
+
|
130
|
+
# @src.gsub!(/(\$[^(]+)\s*\((.+)\)\s*{(.+)}/m) { @macros[$1] = $2, $3; '' }
|
131
|
+
# @src.gsub!(/(\$[^(]+)\((.+?)\)/m) {
|
132
|
+
# unless (params, body = @macros[$1])
|
133
|
+
# raise "no macro function '#{$1}'"
|
134
|
+
# end
|
135
|
+
|
136
|
+
# params = params.split(?,).map &:strip
|
137
|
+
# args = $2.split ?,
|
138
|
+
# body.gsub /\b(#{params * ?|})\b/, params.zip(args).to_h
|
139
|
+
# }
|
140
|
+
end
|
141
|
+
|
142
|
+
def eliminate_dead_code!
|
143
|
+
tokens = @src.split
|
144
|
+
|
145
|
+
# We need an entry point from which to begin determining which routines
|
146
|
+
# are never invoked, but Whitespace programs aren't required to start
|
147
|
+
# with a label. Here, we add an implcit "main" to the beginning of the
|
148
|
+
# source unless it already contains an explicit entry point. TODO: better?
|
149
|
+
start = tokens[0][/(\S+):/, 1] || 'main'
|
150
|
+
tokens.prepend "#{start}:" unless $1
|
151
|
+
|
152
|
+
# Group the whole program into subroutines to facilitate the discovery
|
153
|
+
# of code which can be safely removed without affecting its behavior.
|
154
|
+
subroutines = {}
|
155
|
+
while label = tokens.shift
|
156
|
+
sublen = tokens.index { |t| t[/:$/] } || tokens.size
|
157
|
+
subroutines[label.chop] = tokens.shift sublen
|
158
|
+
end
|
159
|
+
|
160
|
+
# A subroutine may indirectly depend on the one immediately after by
|
161
|
+
# "flowing" into it; we assume this is the case if the subroutine's final
|
162
|
+
# instruction isn't one of {jump, jz, jn, exit, ret}.
|
163
|
+
flow = subroutines.each_cons(2).reject { |(_, tokens), _|
|
164
|
+
tokens.last(2).any? { |t| %w[jump jz jn exit ret].include? t }
|
165
|
+
}.map{ |pair| pair.map &:first }.to_h
|
166
|
+
|
167
|
+
alive = Set.new
|
168
|
+
queue = [start]
|
169
|
+
until queue.empty?
|
170
|
+
# Bail early if the queue is empty or we've already handled this label.
|
171
|
+
next unless label = queue.shift and alive.add? label
|
172
|
+
|
173
|
+
unless subroutines[label]
|
174
|
+
raise NameError, "can't branch to '#{label}', no such label"
|
175
|
+
end
|
176
|
+
|
177
|
+
# Naively(?) assume that subroutines hit all of their branch targets.
|
178
|
+
branches = subroutines[label].each_cons(2).select { |insn, _|
|
179
|
+
%w[jump jz jn call].include? insn
|
180
|
+
}.map &:last
|
181
|
+
|
182
|
+
# Check dependencies for further dependencies.
|
183
|
+
queue.concat branches, [flow[label]]
|
184
|
+
end
|
185
|
+
|
186
|
+
# warn alive.grep_v(/^_/).sort_by(&:upcase).inspect
|
187
|
+
@src = subroutines.filter_map { |label, instructions|
|
188
|
+
"#{label}: #{instructions * ' '}" if alive.include? label
|
189
|
+
} * ?\n + ' ' # trailing space required for regex reasons!
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module Spitewaste
|
2
|
+
class WhitespaceParser
|
3
|
+
SyntaxError = Class.new Exception
|
4
|
+
|
5
|
+
attr_reader :instructions, :error
|
6
|
+
|
7
|
+
def initialize program, **options
|
8
|
+
@tokens = program.delete "^\s\t\n" # Remove comments.
|
9
|
+
end
|
10
|
+
|
11
|
+
def parse
|
12
|
+
@instructions = []
|
13
|
+
@line = @column = 1
|
14
|
+
operator_buffer = ''
|
15
|
+
mnemonics = OPERATORS_M2T.keys
|
16
|
+
|
17
|
+
while token = @tokens.slice!(0)
|
18
|
+
operator_buffer << token
|
19
|
+
if @operator = OPERATORS_T2M[operator_buffer]
|
20
|
+
argument = parse_number if mnemonics.index(@operator) < 8
|
21
|
+
@instructions << [@operator, argument]
|
22
|
+
operator_buffer.clear
|
23
|
+
argument = nil
|
24
|
+
end
|
25
|
+
|
26
|
+
if OPERATORS_T2M.none? { |tokens,| tokens.start_with? operator_buffer }
|
27
|
+
@error = [:illegal, operator_buffer, [@line, @column]]
|
28
|
+
raise SyntaxError,
|
29
|
+
"illegal token sequence: #{operator_buffer.inspect} " +
|
30
|
+
"(line #@line, column #@column)"
|
31
|
+
end
|
32
|
+
|
33
|
+
if token == ?\n
|
34
|
+
@line, @column = @line + 1, 1
|
35
|
+
else
|
36
|
+
@column += 1
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def parse_number
|
42
|
+
unless end_of_number = @tokens.index(?\n)
|
43
|
+
@column += @tokens.size
|
44
|
+
@error = [:number, @operator, [@line, @column]]
|
45
|
+
raise SyntaxError,
|
46
|
+
"found EOF before end of number for #@operator operator " +
|
47
|
+
"(line #@line, column #@column)"
|
48
|
+
end
|
49
|
+
|
50
|
+
digits = @tokens.slice! 0, end_of_number + 1
|
51
|
+
|
52
|
+
raise SyntaxError,
|
53
|
+
"too few digits in number for #@operator operator " +
|
54
|
+
"(line #@line, column #@column)" if digits.size <3
|
55
|
+
|
56
|
+
digits[0] = ?- if digits[0] == ?\t
|
57
|
+
digits.tr("\s\t", '01').to_i 2
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
data/spitewaste.gemspec
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
require_relative 'lib/spitewaste/version'
|
2
|
+
|
3
|
+
Gem::Specification.new do |spec|
|
4
|
+
spec.name = 'spitewaste'
|
5
|
+
spec.version = Spitewaste::VERSION
|
6
|
+
spec.author = 'Collided Scope (collidedscope)'
|
7
|
+
|
8
|
+
spec.summary = 'Make programming in Whitespace even better.'
|
9
|
+
spec.description = 'Spitewaste is a collection of tools that makes it almost too easy to write Whitespace.'
|
10
|
+
spec.homepage = 'https://github.com/collidedscope/spitewaste'
|
11
|
+
spec.license = 'WTFPL'
|
12
|
+
|
13
|
+
spec.files = `git ls-files`.split.reject { |f| f[/^test/] }
|
14
|
+
spec.bindir = 'bin'
|
15
|
+
spec.executables = ['spw']
|
16
|
+
spec.require_paths = ['lib']
|
17
|
+
end
|
metadata
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: spitewaste
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.001
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Collided Scope (collidedscope)
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-12-12 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: Spitewaste is a collection of tools that makes it almost too easy to
|
14
|
+
write Whitespace.
|
15
|
+
email:
|
16
|
+
executables:
|
17
|
+
- spw
|
18
|
+
extensions: []
|
19
|
+
extra_rdoc_files: []
|
20
|
+
files:
|
21
|
+
- Gemfile
|
22
|
+
- README.md
|
23
|
+
- Rakefile
|
24
|
+
- TUTORIAL.md
|
25
|
+
- bin/spw
|
26
|
+
- demo/factorial-nicespace.png
|
27
|
+
- demo/factorial.asm
|
28
|
+
- demo/factorial.png
|
29
|
+
- demo/factorial.wsa
|
30
|
+
- lib/spitewaste.rb
|
31
|
+
- lib/spitewaste/assembler.rb
|
32
|
+
- lib/spitewaste/cli.rb
|
33
|
+
- lib/spitewaste/cli/asm.rb
|
34
|
+
- lib/spitewaste/cli/compile.rb
|
35
|
+
- lib/spitewaste/cli/convert.rb
|
36
|
+
- lib/spitewaste/cli/exec.rb
|
37
|
+
- lib/spitewaste/cli/image.rb
|
38
|
+
- lib/spitewaste/emitter.rb
|
39
|
+
- lib/spitewaste/emitters/assembly.rb
|
40
|
+
- lib/spitewaste/emitters/codegen.rb
|
41
|
+
- lib/spitewaste/emitters/image.rb
|
42
|
+
- lib/spitewaste/emitters/linefeed.png
|
43
|
+
- lib/spitewaste/emitters/schemes.yaml
|
44
|
+
- lib/spitewaste/emitters/whitespace.rb
|
45
|
+
- lib/spitewaste/emitters/wsassembly.rb
|
46
|
+
- lib/spitewaste/libspw/array.spw
|
47
|
+
- lib/spitewaste/libspw/bits.spw
|
48
|
+
- lib/spitewaste/libspw/case.spw
|
49
|
+
- lib/spitewaste/libspw/fun.spw
|
50
|
+
- lib/spitewaste/libspw/io.spw
|
51
|
+
- lib/spitewaste/libspw/math.spw
|
52
|
+
- lib/spitewaste/libspw/prime.spw
|
53
|
+
- lib/spitewaste/libspw/random.spw
|
54
|
+
- lib/spitewaste/libspw/stack.spw
|
55
|
+
- lib/spitewaste/libspw/string.spw
|
56
|
+
- lib/spitewaste/libspw/syntax.spw
|
57
|
+
- lib/spitewaste/libspw/test.spw
|
58
|
+
- lib/spitewaste/libspw/util.spw
|
59
|
+
- lib/spitewaste/parsers/assembly.rb
|
60
|
+
- lib/spitewaste/parsers/fucktional.rb
|
61
|
+
- lib/spitewaste/parsers/spitewaste.rb
|
62
|
+
- lib/spitewaste/parsers/whitespace.rb
|
63
|
+
- lib/spitewaste/version.rb
|
64
|
+
- spitewaste.gemspec
|
65
|
+
homepage: https://github.com/collidedscope/spitewaste
|
66
|
+
licenses:
|
67
|
+
- WTFPL
|
68
|
+
metadata: {}
|
69
|
+
post_install_message:
|
70
|
+
rdoc_options: []
|
71
|
+
require_paths:
|
72
|
+
- lib
|
73
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
74
|
+
requirements:
|
75
|
+
- - ">="
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
requirements: []
|
84
|
+
rubygems_version: 3.1.4
|
85
|
+
signing_key:
|
86
|
+
specification_version: 4
|
87
|
+
summary: Make programming in Whitespace even better.
|
88
|
+
test_files: []
|