kapusta 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.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +2 -0
  3. data/Gemfile +10 -0
  4. data/README.md +58 -0
  5. data/Rakefile +10 -0
  6. data/bin/console +8 -0
  7. data/bin/setup +4 -0
  8. data/examples/accumulator.kap +16 -0
  9. data/examples/ackermann.kap +7 -0
  10. data/examples/anagram.kap +13 -0
  11. data/examples/binary-search.kap +16 -0
  12. data/examples/block-sort.kap +3 -0
  13. data/examples/calc.kap +10 -0
  14. data/examples/counter.kap +19 -0
  15. data/examples/describe.kap +9 -0
  16. data/examples/destructure.kap +4 -0
  17. data/examples/doto.kap +2 -0
  18. data/examples/egg-count.kap +10 -0
  19. data/examples/even-squares.kap +7 -0
  20. data/examples/exceptions.kap +14 -0
  21. data/examples/factorial.kap +8 -0
  22. data/examples/fib.kap +4 -0
  23. data/examples/fizzbuzz.kap +7 -0
  24. data/examples/gcd.kap +5 -0
  25. data/examples/greet.kap +2 -0
  26. data/examples/hashfn.kap +4 -0
  27. data/examples/kwargs.kap +1 -0
  28. data/examples/leap-year.kap +5 -0
  29. data/examples/match.kap +9 -0
  30. data/examples/min-max.kap +11 -0
  31. data/examples/module-header.kap +6 -0
  32. data/examples/palindrome.kap +8 -0
  33. data/examples/pangram.kap +9 -0
  34. data/examples/pcall.kap +9 -0
  35. data/examples/pipeline.kap +6 -0
  36. data/examples/points.kap +9 -0
  37. data/examples/primes.kap +8 -0
  38. data/examples/raindrops.kap +13 -0
  39. data/examples/record.kap +6 -0
  40. data/examples/regex.kap +9 -0
  41. data/examples/ruby-eval.kap +1 -0
  42. data/examples/safe-lookup.kap +6 -0
  43. data/examples/scopes.kap +18 -0
  44. data/examples/shapes.kap +9 -0
  45. data/examples/squares.kap +3 -0
  46. data/examples/stack.kap +19 -0
  47. data/examples/sum.kap +3 -0
  48. data/examples/tset.kap +4 -0
  49. data/examples/two-sum.kap +17 -0
  50. data/exe/kapfmt +6 -0
  51. data/exe/kapusta +6 -0
  52. data/kapfmt +4 -0
  53. data/kapusta.gemspec +25 -0
  54. data/lib/kapusta/ast.rb +76 -0
  55. data/lib/kapusta/cli.rb +61 -0
  56. data/lib/kapusta/compiler/emitter/bindings.rb +178 -0
  57. data/lib/kapusta/compiler/emitter/collections.rb +245 -0
  58. data/lib/kapusta/compiler/emitter/control_flow.rb +168 -0
  59. data/lib/kapusta/compiler/emitter/expressions.rb +107 -0
  60. data/lib/kapusta/compiler/emitter/interop.rb +277 -0
  61. data/lib/kapusta/compiler/emitter/patterns.rb +105 -0
  62. data/lib/kapusta/compiler/emitter/support.rb +169 -0
  63. data/lib/kapusta/compiler/emitter.rb +45 -0
  64. data/lib/kapusta/compiler/normalizer.rb +122 -0
  65. data/lib/kapusta/compiler/runtime.rb +583 -0
  66. data/lib/kapusta/compiler.rb +47 -0
  67. data/lib/kapusta/env.rb +42 -0
  68. data/lib/kapusta/formatter.rb +685 -0
  69. data/lib/kapusta/reader.rb +215 -0
  70. data/lib/kapusta/support.rb +7 -0
  71. data/lib/kapusta/version.rb +5 -0
  72. data/lib/kapusta.rb +30 -0
  73. data/spec/cli_spec.rb +77 -0
  74. data/spec/examples_spec.rb +258 -0
  75. data/spec/formatter_spec.rb +176 -0
  76. data/spec/spec_helper.rb +12 -0
  77. metadata +119 -0
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kapusta
4
+ class Reader
5
+ WHITESPACE = [' ', "\t", "\n", "\r", "\f", "\v", ','].freeze
6
+ DELIMS = ['(', ')', '[', ']', '{', '}', '"', ';'].freeze
7
+
8
+ def self.read_all(source)
9
+ new(source).read_all
10
+ end
11
+
12
+ def initialize(source)
13
+ @src = source
14
+ @pos = 0
15
+ end
16
+
17
+ def read_all
18
+ forms = []
19
+ loop do
20
+ skip_ws
21
+ break if eof?
22
+
23
+ forms << read_form
24
+ end
25
+ forms
26
+ end
27
+
28
+ private
29
+
30
+ def eof?
31
+ @pos >= @src.length
32
+ end
33
+
34
+ def peek
35
+ @src[@pos]
36
+ end
37
+
38
+ def advance
39
+ char = @src[@pos]
40
+ @pos += 1
41
+ char
42
+ end
43
+
44
+ def skip_ws
45
+ until eof?
46
+ char = peek
47
+ if WHITESPACE.include?(char)
48
+ advance
49
+ elsif char == ';'
50
+ advance until eof? || peek == "\n"
51
+ else
52
+ break
53
+ end
54
+ end
55
+ end
56
+
57
+ def delim?(char)
58
+ char.nil? || WHITESPACE.include?(char) || DELIMS.include?(char)
59
+ end
60
+
61
+ def read_form
62
+ skip_ws
63
+ raise 'unexpected eof' if eof?
64
+
65
+ form =
66
+ case peek
67
+ when '(' then read_list
68
+ when '[' then read_vec
69
+ when '{' then read_hash
70
+ when '"' then read_string
71
+ when '#' then read_hashfn
72
+ else
73
+ read_atom
74
+ end
75
+
76
+ read_postfix(form)
77
+ end
78
+
79
+ def read_list
80
+ advance
81
+ items = []
82
+ loop do
83
+ skip_ws
84
+ raise 'unclosed (' if eof?
85
+ break if peek == ')'
86
+
87
+ items << read_form
88
+ end
89
+ advance
90
+ List.new(items)
91
+ end
92
+
93
+ def read_vec
94
+ advance
95
+ items = []
96
+ loop do
97
+ skip_ws
98
+ raise 'unclosed [' if eof?
99
+ break if peek == ']'
100
+
101
+ items << read_form
102
+ end
103
+ advance
104
+ Vec.new(items)
105
+ end
106
+
107
+ def read_hash
108
+ advance
109
+ items = []
110
+ loop do
111
+ skip_ws
112
+ raise 'unclosed {' if eof?
113
+ break if peek == '}'
114
+
115
+ items << read_form
116
+ end
117
+ advance
118
+
119
+ pairs = []
120
+ i = 0
121
+ while i < items.length
122
+ item = items[i]
123
+ if item.is_a?(Sym) && item.name == ':'
124
+ sym = items[i + 1]
125
+ raise 'bad shorthand' unless sym.is_a?(Sym)
126
+
127
+ key = Kapusta.kebab_to_snake(sym.name).to_sym
128
+ pairs << [key, sym]
129
+ else
130
+ pairs << [item, items[i + 1]]
131
+ end
132
+ i += 2
133
+ end
134
+ HashLit.new(pairs)
135
+ end
136
+
137
+ def read_string
138
+ advance
139
+ buffer = +''
140
+ until eof? || peek == '"'
141
+ if peek == '\\'
142
+ advance
143
+ escaped = advance
144
+ buffer << case escaped
145
+ when 'n' then "\n"
146
+ when 't' then "\t"
147
+ when 'r' then "\r"
148
+ when '\\' then '\\'
149
+ when '"' then '"'
150
+ when '0' then "\0"
151
+ when 'a' then "\a"
152
+ when 'b' then "\b"
153
+ when 'f' then "\f"
154
+ when 'v' then "\v"
155
+ else escaped
156
+ end
157
+ else
158
+ buffer << advance
159
+ end
160
+ end
161
+ raise 'unterminated string' if eof?
162
+
163
+ advance
164
+ buffer
165
+ end
166
+
167
+ def read_hashfn
168
+ advance
169
+ form = read_form
170
+ List.new([Sym.new('hashfn'), form])
171
+ end
172
+
173
+ def read_postfix(form)
174
+ current = form
175
+
176
+ loop do
177
+ break unless peek == '.'
178
+
179
+ start = @pos
180
+ advance until delim?(peek)
181
+ token = @src[start...@pos]
182
+ break unless token.start_with?('.') && token.length > 1
183
+
184
+ token[1..].split('.').each do |name|
185
+ current = List.new([Sym.new(':'), current, Kapusta.kebab_to_snake(name).to_sym])
186
+ end
187
+ end
188
+
189
+ current
190
+ end
191
+
192
+ def read_atom
193
+ start = @pos
194
+ advance until delim?(peek)
195
+ token = @src[start...@pos]
196
+ raise 'empty token' if token.empty?
197
+
198
+ parse_atom(token)
199
+ end
200
+
201
+ def parse_atom(token)
202
+ return true if token == 'true'
203
+ return false if token == 'false'
204
+ return nil if token == 'nil'
205
+ return token.to_i if token.match?(/\A-?\d+\z/)
206
+ return token.to_f if token.match?(/\A-?\d+\.\d+\z/)
207
+
208
+ if token.start_with?(':') && token.length > 1
209
+ Kapusta.kebab_to_snake(token[1..]).to_sym
210
+ else
211
+ Sym.new(token)
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kapusta
4
+ def self.kebab_to_snake(name)
5
+ name.tr('-', '_')
6
+ end
7
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kapusta
4
+ VERSION = '0.1.0'
5
+ end
data/lib/kapusta.rb ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'kapusta/version'
4
+ require_relative 'kapusta/support'
5
+ require_relative 'kapusta/ast'
6
+ require_relative 'kapusta/reader'
7
+ require_relative 'kapusta/env'
8
+ require_relative 'kapusta/compiler'
9
+
10
+ module Kapusta
11
+ def self.eval(source, path: '(eval)', **_opts)
12
+ Compiler.run(source, path:)
13
+ end
14
+
15
+ def self.dofile(path, **_opts)
16
+ source = File.read(path)
17
+ self.eval(source, path:)
18
+ end
19
+
20
+ def self.compile(source, path: '(eval)', **_opts)
21
+ Compiler.compile(source, path:)
22
+ end
23
+
24
+ def self.install!
25
+ @install ||= begin
26
+ require 'rubygems'
27
+ true
28
+ end
29
+ end
30
+ end
data/spec/cli_spec.rb ADDED
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'kapusta/cli'
5
+ require 'open3'
6
+ require 'rbconfig'
7
+ require 'stringio'
8
+ require 'tmpdir'
9
+
10
+ def capture_stdout
11
+ previous_stdout = $stdout
12
+ $stdout = StringIO.new
13
+ yield
14
+ $stdout.string
15
+ ensure
16
+ $stdout = previous_stdout
17
+ end
18
+
19
+ def capture_stderr
20
+ previous_stderr = $stderr
21
+ $stderr = StringIO.new
22
+ yield
23
+ $stderr.string
24
+ ensure
25
+ $stderr = previous_stderr
26
+ end
27
+
28
+ RSpec.describe Kapusta::CLI do
29
+ it 'compiles a .kap file to stdout with --compile' do
30
+ path = File.expand_path('../examples/fizzbuzz.kap', __dir__)
31
+
32
+ output = capture_stdout do
33
+ described_class.start(['--compile', path])
34
+ end
35
+
36
+ expect(output).to include('__kap_print_values("FizzBuzz")')
37
+ expect(output).not_to include('Kapusta::Compiler::Runtime')
38
+ expect(output).not_to include('module Kapusta')
39
+ expect(output).not_to include('def __kap_get_path')
40
+ end
41
+
42
+ it 'rejects extra positional arguments in compile mode' do
43
+ path = File.expand_path('../examples/fizzbuzz.kap', __dir__)
44
+
45
+ error_output = capture_stderr do
46
+ expect { described_class.start(['--compile', path, 'fizzbuzz.rb']) }
47
+ .to raise_error(SystemExit)
48
+ end
49
+
50
+ expect(error_output).to include('usage: kapusta')
51
+ end
52
+
53
+ it 'emits standalone Ruby that runs with plain ruby' do
54
+ source_path = File.expand_path('../examples/fizzbuzz.kap', __dir__)
55
+
56
+ Dir.mktmpdir do |dir|
57
+ output_path = File.join(dir, 'fizzbuzz.rb')
58
+ ruby = Kapusta.compile(File.read(source_path), path: source_path)
59
+ File.write(output_path, ruby)
60
+
61
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, output_path)
62
+
63
+ expect(status.success?).to eq(true), stderr
64
+ expect(stdout).to include("FizzBuzz\n")
65
+ end
66
+ end
67
+
68
+ it 'passes remaining arguments through to the Kapusta program' do
69
+ path = File.expand_path('../examples/greet.kap', __dir__)
70
+
71
+ output = capture_stdout do
72
+ described_class.start([path, 'Ada'])
73
+ end
74
+
75
+ expect(output).to eq("Hello, Ada!\n")
76
+ end
77
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'stringio'
5
+
6
+ EXAMPLES_DIR = File.expand_path('../examples', __dir__)
7
+
8
+ def run_example(name, argv: [])
9
+ previous_argv = ARGV.dup
10
+ previous_stdout = $stdout
11
+ ARGV.replace(argv)
12
+ $stdout = StringIO.new
13
+ Kapusta.dofile(File.join(EXAMPLES_DIR, name))
14
+ $stdout.string
15
+ ensure
16
+ $stdout = previous_stdout
17
+ ARGV.replace(previous_argv)
18
+ end
19
+
20
+ RSpec.describe 'examples' do
21
+ it 'ackermann.kap' do
22
+ expect(run_example('ackermann.kap')).to eq("9\n61\n")
23
+ end
24
+
25
+ it 'accumulator.kap' do
26
+ expect(run_example('accumulator.kap')).to eq("22\n")
27
+ end
28
+
29
+ it 'anagram.kap' do
30
+ expect(run_example('anagram.kap')).to eq("true\ntrue\nfalse\n")
31
+ end
32
+
33
+ it 'calc.kap' do
34
+ expect(run_example('calc.kap')).to eq("14\n")
35
+ end
36
+
37
+ it 'binary-search.kap' do
38
+ expect(run_example('binary-search.kap')).to eq("3\nnil\n")
39
+ end
40
+
41
+ it 'block-sort.kap' do
42
+ expect(run_example('block-sort.kap')).to eq("3, 2, 1\n")
43
+ end
44
+
45
+ it 'counter.kap' do
46
+ expect(run_example('counter.kap')).to eq("12\n")
47
+ end
48
+
49
+ it 'doto.kap' do
50
+ expect(run_example('doto.kap')).to eq("1, 2, 3\n")
51
+ end
52
+
53
+ it 'describe.kap' do
54
+ expect(run_example('describe.kap')).to eq("-3\tnegative\n0\tzero\n1\tone\n2\tmany\n99\tmany\n")
55
+ end
56
+
57
+ it 'destructure.kap' do
58
+ expect(run_example('destructure.kap')).to eq("6\nAda\t36\n")
59
+ end
60
+
61
+ it 'egg-count.kap' do
62
+ expect(run_example('egg-count.kap')).to eq("4\n")
63
+ end
64
+
65
+ it 'even-squares.kap' do
66
+ expect(run_example('even-squares.kap')).to eq("4, 16, 36\n")
67
+ end
68
+
69
+ it 'exceptions.kap' do
70
+ expect(run_example('exceptions.kap')).to eq("seen: 12\n12\nseen: oops\nbad: oops\n")
71
+ end
72
+
73
+ it 'factorial.kap' do
74
+ expect(run_example('factorial.kap')).to eq("0\t1\n1\t1\n5\t120\n6\t720\n10\t3628800\n")
75
+ end
76
+
77
+ it 'fib.kap' do
78
+ expect(run_example('fib.kap')).to eq("55\n")
79
+ end
80
+
81
+ it 'fizzbuzz.kap' do
82
+ expected = "1\n2\nFizz\n4\nBuzz\nFizz\n7\n8\nFizz\nBuzz\n11\nFizz\n13\n14\nFizzBuzz\n16\n17\nFizz\n19\nBuzz\n"
83
+ expect(run_example('fizzbuzz.kap')).to eq(expected)
84
+ end
85
+
86
+ it 'gcd.kap' do
87
+ expect(run_example('gcd.kap')).to eq("12\n6\n")
88
+ end
89
+
90
+ it 'greet.kap' do
91
+ expect(run_example('greet.kap', argv: ['Ada'])).to eq("Hello, Ada!\n")
92
+ end
93
+
94
+ it 'hashfn.kap' do
95
+ expect(run_example('hashfn.kap')).to eq("5\n21\n")
96
+ end
97
+
98
+ it 'leap-year.kap' do
99
+ expect(run_example('leap-year.kap')).to eq("true\n")
100
+ end
101
+
102
+ it 'min-max.kap' do
103
+ expect(run_example('min-max.kap')).to eq("1\t9\n")
104
+ end
105
+
106
+ it 'module-header.kap' do
107
+ expect(run_example('module-header.kap')).to eq("Hello, Ada!\n")
108
+ end
109
+
110
+ it 'pipeline.kap' do
111
+ expect(run_example('pipeline.kap')).to eq("BLUE\nRED\n")
112
+ end
113
+
114
+ it 'points.kap' do
115
+ expect(run_example('points.kap')).to eq("origin\ny-axis\nx-axis\npoint\n")
116
+ end
117
+
118
+ it 'primes.kap' do
119
+ expect(run_example('primes.kap')).to eq("2\n3\n5\n7\n11\n13\n17\n19\n23\n29\n")
120
+ end
121
+
122
+ it 'raindrops.kap' do
123
+ expect(run_example('raindrops.kap')).to eq("PlingPlang\n")
124
+ end
125
+
126
+ it 'record.kap' do
127
+ expect(run_example('record.kap')).to eq("Ada / engineer / ruby, lisp\n")
128
+ end
129
+
130
+ it 'regex.kap' do
131
+ expected = <<~OUT
132
+ 2026-04-23 -> {"year"=>"2026", "month"=>"04", "day"=>"23"}
133
+ hello -> nil
134
+ 1999-12-31 -> {"year"=>"1999", "month"=>"12", "day"=>"31"}
135
+ OUT
136
+ expect(run_example('regex.kap')).to eq(expected)
137
+ end
138
+
139
+ it 'ruby-eval.kap' do
140
+ expect(run_example('ruby-eval.kap')).to eq("10-20-30\n")
141
+ end
142
+
143
+ it 'kwargs.kap' do
144
+ expect(run_example('kwargs.kap')).to eq("Ada has 3 tasks\n")
145
+ end
146
+
147
+ it 'match.kap' do
148
+ expect(run_example('match.kap')).to eq("Ada: 9\nLin: no score\nunknown\n")
149
+ end
150
+
151
+ it 'scopes.kap' do
152
+ expect(run_example('scopes.kap')).to eq("5\n9\n9\n9\n")
153
+ end
154
+
155
+ it 'pcall.kap' do
156
+ expected = <<~OUT
157
+ true
158
+ 12
159
+ false
160
+ ArgumentError
161
+ false
162
+ invalid value for Integer(): "oops"
163
+ OUT
164
+ expect(run_example('pcall.kap')).to eq(expected)
165
+ end
166
+
167
+ it 'palindrome.kap' do
168
+ expect(run_example('palindrome.kap')).to eq("true\ntrue\nfalse\n")
169
+ end
170
+
171
+ it 'pangram.kap' do
172
+ expect(run_example('pangram.kap')).to eq("true\nfalse\n")
173
+ end
174
+
175
+ it 'safe-lookup.kap' do
176
+ expect(run_example('safe-lookup.kap')).to eq("Ada\nnil\n")
177
+ end
178
+
179
+ it 'shapes.kap' do
180
+ expect(run_example('shapes.kap')).to eq("78.5\n9\n8\n0\n")
181
+ end
182
+
183
+ it 'squares.kap' do
184
+ expect(run_example('squares.kap')).to eq("1\n4\n9\n16\n25\n")
185
+ end
186
+
187
+ it 'stack.kap' do
188
+ expect(run_example('stack.kap')).to eq('')
189
+ end
190
+
191
+ it 'sum.kap' do
192
+ expect(run_example('sum.kap')).to eq("100\n")
193
+ end
194
+
195
+ it 'tset.kap' do
196
+ expect(run_example('tset.kap')).to eq("{:name=>\"Ada\", :city=>\"Amsterdam\"}\nAmsterdam\n")
197
+ end
198
+
199
+ it 'two-sum.kap' do
200
+ expect(run_example('two-sum.kap')).to eq("[0, 1]\n[1, 2]\nnil\n")
201
+ end
202
+ end
203
+
204
+ RSpec.describe Kapusta do
205
+ it 'exposes a gem version' do
206
+ expect(Kapusta::VERSION).to match(/\A\d+\.\d+\.\d+\z/)
207
+ end
208
+
209
+ it 'defaults classes to Object when the superclass is omitted' do
210
+ source = <<~KAP
211
+ (let [klass (class Stack
212
+ (fn initialize []
213
+ nil))]
214
+ (values (= Stack.superclass Object)
215
+ (= (Stack.new.class) Stack)
216
+ (= klass Stack)))
217
+ KAP
218
+
219
+ expect(Kapusta.eval(source)).to eq([true, true, true])
220
+ end
221
+
222
+ it 'still accepts an explicit superclass vector' do
223
+ source = <<~KAP
224
+ (let [klass (class KapustaError [StandardError])]
225
+ (values (= KapustaError.superclass StandardError)
226
+ (= klass KapustaError)))
227
+ KAP
228
+
229
+ expect(Kapusta.eval(source)).to eq([true, true])
230
+ end
231
+
232
+ it 'preserves nested arithmetic precedence' do
233
+ source = <<~KAP
234
+ (values (/ (+ 3 5) 2)
235
+ (* (+ 1 2) (- 10 4))
236
+ (% (+ 10 5) 4))
237
+ KAP
238
+
239
+ expect(Kapusta.eval(source)).to eq([4, 18, 3])
240
+ end
241
+
242
+ it 'supports postfix zero-arg method calls on non-symbol expressions' do
243
+ source = <<~KAP
244
+ (values [1 2].inspect
245
+ (+ 1 2).inspect
246
+ "Listen".downcase.chars.sort.join)
247
+ KAP
248
+
249
+ expect(Kapusta.eval(source)).to eq(['[1, 2]', '3', 'eilnst'])
250
+ end
251
+ end
252
+
253
+ RSpec.describe 'errors' do
254
+ it 'raises on unclosed list' do
255
+ expect { Kapusta.eval('(fn hello [name] (.. "Hi " name "!")') }
256
+ .to raise_error(/unclosed \(/)
257
+ end
258
+ end