Skip to content

Commit a24dba4

Browse files
committed
Add and test an interpreter
1 parent 3662d49 commit a24dba4

File tree

5 files changed

+364
-2
lines changed

5 files changed

+364
-2
lines changed

herbert/error.py

+12
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,15 @@ class HerbertError(Exception):
44

55
class SyntaxError(HerbertError):
66
pass
7+
8+
9+
class RuntimeError(HerbertError):
10+
pass
11+
12+
13+
class LookupError(RuntimeError):
14+
pass
15+
16+
17+
class TypeError(RuntimeError):
18+
pass

herbert/interpreter.py

+179
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import numbers
2+
3+
from .error import LookupError, TypeError
4+
from .util import pluralize
5+
6+
7+
class Interpreter:
8+
def __call__(self, parse_tree):
9+
assert parse_tree.data == 'h'
10+
11+
self._env = Env()
12+
*self._pdefs, main = parse_tree.children
13+
14+
assert main.data == 'main'
15+
16+
return self._interp_seq(main.children)
17+
18+
def _interp_seq(self, seq):
19+
allow_number = len(seq) == 1
20+
21+
for x in seq:
22+
if hasattr(x, 'type') and x.type == 'PARAM':
23+
yield from self._interp_param(x, allow_number)
24+
else:
25+
yield from self._interp_stmt(x)
26+
27+
def _interp_stmt(self, stmt):
28+
if hasattr(stmt, 'type'):
29+
assert stmt.type == 'COMMAND'
30+
yield str(stmt)
31+
else:
32+
assert stmt.data == 'pcall'
33+
34+
try:
35+
name, args = stmt.children
36+
except ValueError:
37+
name = stmt.children[0]
38+
args = []
39+
else:
40+
assert name.type == 'PNAME'
41+
assert args.data == 'args'
42+
args = args.children
43+
nargs = len(args)
44+
45+
pdef = self._lookup_pdef(str(name))
46+
try:
47+
params, body = pdef.children[1:]
48+
except ValueError:
49+
params = []
50+
body = pdef.children[1]
51+
else:
52+
assert params.data == 'params'
53+
assert body.data == 'body'
54+
params = params.children
55+
nparams = len(params)
56+
57+
if nargs == nparams:
58+
try:
59+
bindings = self._bind(str(name), params, args)
60+
except IgnoreCall:
61+
pass
62+
else:
63+
self._env = Env(bindings=bindings, outer=self._env)
64+
try:
65+
yield from self._interp_seq(body.children)
66+
finally:
67+
self._env = self._env.outer
68+
else:
69+
argument = pluralize(nparams, 'argument', 'arguments')
70+
was = pluralize(nargs, 'was', 'were')
71+
72+
raise TypeError('%s takes %d %s but %d %s given' % (name, nparams, argument, nargs, was))
73+
74+
def _interp_param(self, param, allow_number):
75+
value = self._lookup(str(param))
76+
77+
if isinstance(value, Deferred):
78+
prev_env, self._env = self._env, value.env
79+
try:
80+
yield from self._interp_seq(value.seq)
81+
finally:
82+
self._env = prev_env
83+
else:
84+
assert isinstance(value, numbers.Integral)
85+
if allow_number:
86+
yield value
87+
else:
88+
raise TypeError('parameter %s does not evaluate to a command s, l or r or a procedure call: %d' % (param, value))
89+
90+
def _interp_arg(self, arg, name, index):
91+
if arg.data == 'var':
92+
assert len(arg.children) == 1 and arg.children[0].type == 'PARAM'
93+
return self._lookup(str(arg.children[0]))
94+
95+
if arg.data == 'sexpr':
96+
return Deferred(self._env, arg.children)
97+
98+
assert arg.data == 'expr'
99+
return self._interp_expr(arg.children)
100+
101+
def _interp_expr(self, expr):
102+
assert expr
103+
104+
if expr[0].type == 'NEG':
105+
sign = -1
106+
terms = expr[1:]
107+
else:
108+
sign = 1
109+
terms = expr
110+
111+
sum = 0
112+
for term in expr:
113+
if term.type == 'PLUS':
114+
sign = 1
115+
elif term.type == 'MINUS':
116+
sign = -1
117+
elif term.type == 'NUM':
118+
sum += sign * int(str(term))
119+
else:
120+
assert term.type == 'PARAM'
121+
value = self._lookup(str(term))
122+
123+
if isinstance(value, numbers.Integral):
124+
sum += sign * value
125+
else:
126+
raise TypeError('parameter %s does not evaluate to a number: %s' % (term, value))
127+
128+
return sum
129+
130+
def _bind(self, name, params, args):
131+
bindings = {}
132+
133+
for index, (param, arg) in enumerate(zip(params, args)):
134+
value = self._interp_arg(arg, name, index)
135+
136+
if value == 0:
137+
raise IgnoreCall
138+
139+
bindings[str(param)] = value
140+
141+
return bindings
142+
143+
def _lookup_pdef(self, name):
144+
for pdef in self._pdefs:
145+
pname = pdef.children[0]
146+
assert pname.type == 'PNAME'
147+
148+
if name == pname:
149+
return pdef
150+
151+
raise LookupError('missing procedure: %s' % name)
152+
153+
def _lookup(self, name):
154+
return self._env.lookup(name)
155+
156+
157+
class Env:
158+
def __init__(self, bindings=None, outer=None):
159+
self.outer = outer
160+
self.bindings = {} if bindings is None else bindings
161+
162+
def lookup(self, name):
163+
try:
164+
return self.bindings[name]
165+
except KeyError:
166+
raise LookupError('unbound parameter: %s' % name)
167+
168+
169+
class Deferred:
170+
def __init__(self, env, seq):
171+
self.env = env
172+
self.seq = seq
173+
174+
175+
class IgnoreCall(Exception):
176+
pass
177+
178+
179+
interp = Interpreter()

herbert/parser.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
| PNAME [args] -> pcall
1818
1919
args : "(" arg ("," arg)* ")"
20-
?arg : (stmt | PARAM)+ -> sexpr
20+
?arg : PARAM -> var
21+
| PARAM (stmt | PARAM)+ -> sexpr
22+
| stmt (stmt | PARAM)* -> sexpr
2123
| expr
2224
2325
expr : NEG PARAM

tests/test_interpreter.py

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import itertools
2+
import unittest
3+
4+
from herbert.error import LookupError, TypeError
5+
from herbert.interpreter import interp
6+
from herbert.parser import parse
7+
8+
9+
def run(program, upper_bound=None):
10+
commands = interp(parse(program))
11+
12+
if upper_bound:
13+
commands = itertools.islice(commands, upper_bound)
14+
15+
return ''.join(commands)
16+
17+
18+
class ExamplesTestCase(unittest.TestCase):
19+
def test_example1(self):
20+
program = 'sssssrsssssrsssssrsssssr'
21+
22+
self.assertEqual(run(program), 'sssssrsssssrsssssrsssssr')
23+
24+
def test_example2(self):
25+
program = 'a:sssssr\naaaa'
26+
27+
self.assertEqual(run(program), 'sssssrsssssrsssssrsssssr')
28+
29+
def test_example3(self):
30+
program = 'a:sssssra\na'
31+
32+
self.assertEqual(run(program, 24), 'sssssrsssssrsssssrsssssr')
33+
34+
def test_example4(self):
35+
program = 'a(A):sssssra(A-1)\na(4)'
36+
37+
self.assertEqual(run(program), 'sssssrsssssrsssssrsssssr')
38+
39+
def test_example5(self):
40+
program = 'a(A,B):f(B)ra(A-1,B)\nf(A):sf(A-1)\na(4,5)'
41+
42+
self.assertEqual(run(program), 'sssssrsssssrsssssrsssssr')
43+
44+
def test_example6(self):
45+
program = 'a(A,B,C):f(B)Ca(A-1,B,C)\nf(A):sf(A-1)\na(4,5,r)'
46+
47+
self.assertEqual(run(program), 'sssssrsssssrsssssrsssssr')
48+
49+
def test_example7(self):
50+
program = 'a(A):ArAa(AA)\na(s)'
51+
52+
self.assertEqual(run(program, 17), 'srsssrssssssrssss')
53+
54+
def test_example8(self):
55+
program = 'a(A,B,C):f(B)Ca(A-1,B,C)\nb(A):a(4,5,r)lb(A-1)\nf(A):sf(A-1)\nb(4)'
56+
57+
self.assertEqual(
58+
run(program, 100),
59+
'sssssrsssssrsssssrsssssrl'
60+
'sssssrsssssrsssssrsssssrl'
61+
'sssssrsssssrsssssrsssssrl'
62+
'sssssrsssssrsssssrsssssrl'
63+
)
64+
65+
def test_example9(self):
66+
program = 'a(A,B,C):f(B)Ca(A-1,B,C)\nb(A):a(4,A,r)b(A-1)\nf(A):sf(A-1)\nb(10)'
67+
68+
self.assertEqual(
69+
run(program, 260),
70+
'ssssssssssrssssssssssrssssssssssrssssssssssr' # 40 + 4 = 44
71+
'sssssssssrsssssssssrsssssssssrsssssssssr' # 40
72+
'ssssssssrssssssssrssssssssrssssssssr' # 36
73+
'sssssssrsssssssrsssssssrsssssssr' # 32
74+
'ssssssrssssssrssssssrssssssr' # 28
75+
'sssssrsssssrsssssrsssssr' # 24
76+
'ssssrssssrssssrssssr' # 20
77+
'sssrsssrsssrsssr' # 16
78+
'ssrssrssrssr' # 12
79+
'srsrsrsr' # 8
80+
)
81+
82+
def test_example10(self):
83+
program = 'a(A,B,C):f(B)Ca(A-1,B,C)\nb(A):a(2,11-A,r)b(A-1)\nf(A):sf(A-1)\nb(10)'
84+
85+
self.assertEqual(
86+
run(program, 130),
87+
'srsr' # 4
88+
'ssrssr' # 6
89+
'sssrsssr' # 8
90+
'ssssrssssr' # 10
91+
'sssssrsssssr' # 12
92+
'ssssssrssssssr' # 14
93+
'sssssssrsssssssr' # 16
94+
'ssssssssrssssssssr' # 18
95+
'sssssssssrsssssssssr' # 20
96+
'ssssssssssrssssssssssr' # 22
97+
)
98+
99+
def test_example11(self):
100+
program = 'a(A,B,C):f(B)Ca(A-1,B,C)\nf(A):sf(A-1)\na(4,5,rslsr)'
101+
102+
self.assertEqual(run(program, 40), 'sssssrslsrsssssrslsrsssssrslsrsssssrslsr')
103+
104+
105+
class InfiniteRecursionTestCase(unittest.TestCase):
106+
"""These test cases illustrate that some programs that should be able to run
107+
infinitely long aren't able to do so and cause the Python runtime to raise
108+
a RecursionError exception. This points to a need to improve my
109+
implementation of the interpreter.
110+
111+
Would tail-call optimization help?
112+
113+
Notice how the program in test_example3 has the same runtime effect as that
114+
of the program in test_example1 but the one in test_example1 raises a
115+
RecursionError.
116+
"""
117+
def test_example1(self):
118+
program = 'a:sa\na'
119+
120+
with self.assertRaises(RecursionError):
121+
run(program, 10000)
122+
123+
def test_example2(self):
124+
program = 'a(A):ArAa(AA)\na(s)'
125+
126+
run(program, 10000)
127+
128+
def test_example3(self):
129+
program = 'a(A):Aa(AA)\na(s)'
130+
131+
run(program, 10000)
132+
133+
134+
class RuntimeErrorTestCase(unittest.TestCase):
135+
def test_missing_procedure(self):
136+
program = 'f'
137+
138+
with self.assertRaisesRegex(LookupError, 'missing procedure: f'):
139+
run(program)
140+
141+
def test_unbound_parameter(self):
142+
program = 'a:Aa\na'
143+
144+
with self.assertRaisesRegex(LookupError, 'unbound parameter: A'):
145+
run(program)
146+
147+
def test_too_few_arguments(self):
148+
program = 'a(A):Aa\na(s)'
149+
150+
with self.assertRaisesRegex(TypeError, 'a takes 1 argument but 0 were given'):
151+
run(program)
152+
153+
def test_too_many_arguments(self):
154+
program = 'a(A,B):ABa(A,B,B)\na(s,r)'
155+
156+
with self.assertRaisesRegex(TypeError, 'a takes 2 arguments but 3 were given'):
157+
run(program)
158+
159+
def test_expected_command_or_procedure_call(self):
160+
program = 'a(A):Aa(A)\na(1)'
161+
162+
with self.assertRaisesRegex(TypeError, 'parameter A does not evaluate to a command .* or a procedure call: 1'):
163+
run(program)
164+
165+
def test_expected_number(self):
166+
program = 'a(A):sa(A-1)\na(r)'
167+
168+
with self.assertRaisesRegex(TypeError, 'parameter A does not evaluate to a number'):
169+
run(program)

tests/test_parser.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def test_with_params(self):
109109
self.assertEqual(args.data, 'args')
110110
self.assertEqual(len(args.children), 6)
111111
self.assertEqual(args.children[0].data, 'sexpr')
112-
self.assertEqual(args.children[1].data, 'sexpr')
112+
self.assertEqual(args.children[1].data, 'var')
113113
self.assertEqual(args.children[2].data, 'expr')
114114
self.assertEqual(args.children[3].data, 'expr')
115115
self.assertEqual(args.children[4].data, 'expr')

0 commit comments

Comments
 (0)