on c4e28d2 help sign in
jibb/tools/urbit_eval.py
488 lines 18 kB history raw
1"""Run Hoon through `urbit eval` for the dev tools, without a ship.
3tools/golden and tools/hoon build one Hoon program out of segments (a
4prefix, the library file, then a harness or an expression), run it with
5`urbit eval`, and map the positions in eval's errors and crash traces
6back to the segment, and the line in it, that they point at.
8How `urbit eval` (vere 4.6) behaves, as found by trying it:
10- It reads the program from stdin. Boot noise ("loom: mapped ...",
11 "lite: ...", "eval (run):") goes to stderr.
12- Plain mode prints the result as Hoon prints a vase, with ANSI colours
13 and \\r\\n line ends, to stdout.
14- `-j` prints the jammed result as \\xNN escapes, but only every other
15 byte of it, so it can't be decoded. `-j -n` writes an exact newt
16 frame instead: a 0 byte, the length as 4 little-endian bytes, then the
17 jam. This module uses `-j -n`.
18- A failure prints its trace to stdout and "eval: bail: %exit" (or
19 %meme, ...) to stderr, and still exits with status 0.
20- Type errors carry no source positions unless the program turns on
21 debug spots, so programs here start with `!:` (tools/hoon --no-debug
22 leaves it out). It makes the checker run 2.2 to 2.6 times slower
23 than on a ship. Spots print as
24 /::[l c].[l c]> at compile time and /:<[l c].[l c]> at run time, and
25 a syntax error as {l c}. Lines and columns count from 1, columns in
26 bytes.
27- `~&` output is dropped. mule catches crashes, but not the bails that
28 end the whole eval, such as running out of memory (%meme) or asking
29 for an atom bigger than the loom (%fail).
30"""
32import os
33import re
34import shutil
35import subprocess
36import tempfile
38from noun import bytes_to_atom, cue
40ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
41DEFAULT_LIB = os.path.join(ROOT, 'jibb-dev', 'lib', 'jibb.hoon')
43ANSI = re.compile(r'\x1b\[[0-9;?]*[A-Za-z]')
44BOOT_NOISE = re.compile(
45 r'^(loom: mapped \S+|lite: .*|eval \([a-z, ]+\):|newt meaningless .*)?$')
46BAIL = re.compile(r'^(?:eval: )?bail: %?(\w+)')
47# Numbers past 999 print with separators: 2,554 at compile time, 3.411
48# in crash traces.
49NUM = r'\d{1,3}(?:[.,]\d{3})*'
50SPOT = re.compile(
51 r'(?P<path>/[^\s:<\[]*)?:(?P<kind>[:<])'
52 r'\[(?P<l1>%s) (?P<c1>%s)\]\.\[(?P<l2>%s) (?P<c2>%s)\]>' % ((NUM,) * 4))
53SYNTAX = re.compile(r'^\{(?P<l>%s) (?P<c>%s)\}$' % (NUM, NUM))
54BARE_PATH = re.compile(r'^/[^\s]*$')
57class ToolError(Exception):
58 """A failure that stops a tool: report it and exit with status 2."""
61def display_path(path):
62 """A path as the tools print it: from the repo root when inside it."""
63 path = os.path.abspath(path)
64 rel = os.path.relpath(path, ROOT)
65 return path if rel.startswith('..') else rel
68def strip_ansi(text):
69 return ANSI.sub('', text).replace('\r', '')
72def cord_literal(data):
73 """A Hoon cord literal, '...', holding exactly these bytes.
75 Printable ASCII stays as it is, quotes and backslashes are escaped,
76 and every other byte, UTF-8 included, is written \\XX.
78 >>> print(cord_literal("it's \\\\ \\"q\\" é\\n".encode()))
79 'it\\'s \\\\ "q" \\c3\\a9\\0a'
80 """
81 out = ["'"]
82 for b in data:
83 if b == 0x5c:
84 out.append('\\\\')
85 elif b == 0x27:
86 out.append("\\'")
87 elif 0x20 <= b < 0x7f:
88 out.append(chr(b))
89 else:
90 out.append('\\%02x' % b)
91 out.append("'")
92 return ''.join(out)
95CORD_PIECE = 1024
98def cord_pieces(data, size=CORD_PIECE):
99 """data cut into pieces whose cord literals `rap 3` joins back into
100 data: one piece per line, ending in its newline, and a line longer
101 than size cut about every size bytes, since Hoon reads a cord
102 literal in about quadratic time (a 72 KB one takes 11 s). A piece
103 is an atom, which loses NULs at its end, so a cut moves past NULs;
104 only the NULs ending data are lost, as in Clay, where a file is one
105 atom.
107 >>> cord_pieces(b'ab\\ncd')
108 [b'ab\\n', b'cd']
109 >>> cord_pieces(b'abcdefg\\n', 3)
110 [b'abc', b'def', b'g\\n']
111 >>> cord_pieces(b'ab\\x00\\x00cdef', 3)
112 [b'ab\\x00\\x00c', b'def']
113 >>> cord_pieces(b'')
114 [b'']
115 """
116 lines = data.split(b'\n')
117 lines = [line + b'\n' for line in lines[:-1]] + lines[-1:]
118 pieces = []
119 for line in lines:
120 start = 0
121 while len(line) - start > size:
122 cut = start + size
123 while cut < len(line) and line[cut - 1] == 0:
124 cut += 1
125 pieces.append(line[start:cut])
126 start = cut
127 if start < len(line) or not pieces:
128 pieces.append(line[start:])
129 return pieces
132def read_text(path):
133 """A file's text, losslessly: bytes that aren't UTF-8 survive."""
134 with open(path, 'rb') as f:
135 return f.read().decode('utf-8', 'surrogateescape')
138class Segment:
139 """Consecutive lines of a program that came from one place."""
141 def __init__(self, name, kind, start, lines):
142 self.name = name # what errors call it: a path, or (harness)
143 self.kind = kind # 'prefix', 'lib', 'harness' or 'expr'
144 self.start = start # its first line in the program, from 1
145 self.lines = lines
148class Program:
149 """A Hoon program built from segments, each starting on a new line."""
151 def __init__(self):
152 self.parts = []
153 self.segments = []
154 self.next_line = 1
156 def add(self, text, name, kind):
157 if not text.endswith('\n'):
158 text += '\n'
159 lines = text.split('\n')[:-1]
160 self.segments.append(Segment(name, kind, self.next_line, lines))
161 self.parts.append(text)
162 self.next_line += len(lines)
164 def text(self):
165 return ''.join(self.parts)
167 def encode(self):
168 return self.text().encode('utf-8', 'surrogateescape')
170 def locate(self, line):
171 """The segment holding a program line, and the line within it."""
172 for seg in self.segments:
173 if seg.start <= line < seg.start + len(seg.lines):
174 return seg, line - seg.start + 1
175 return None, line
178def library_program(lib_path, debug=True):
179 """A program that starts `!: => <library>`, for adding what follows.
181 `!:` turns on debug spots, so errors and crash traces have positions;
182 `=>` makes the library's core the subject of what's added next.
183 Without debug, the program starts `=> <library>`: it runs at a ship's
184 speed, but its errors and traces have no positions.
185 """
186 prog = Program()
187 prog.add('!:\n=>' if debug else '=>', '(prefix)', 'prefix')
188 prog.add(read_text(lib_path), display_path(lib_path), 'lib')
189 return prog
192def find_urbit():
193 urbit = os.environ.get('URBIT') or shutil.which('urbit')
194 if not urbit:
195 raise ToolError('urbit is not on PATH; install vere, or set URBIT '
196 'to its path')
197 return urbit
200class EvalResult:
201 def __init__(self):
202 self.ok = False
203 self.noun = None # the result, in jam mode
204 self.text = '' # the printed result, in plain mode
205 self.trace = [] # eval's error output, as clean lines
206 self.notes = [] # stderr lines that aren't boot noise
207 self.bail = None # 'exit', 'meme', ... when eval bailed
208 self.timed_out = False
209 self.timeout = None # the time limit it ran under, in seconds
210 self.status = None # the process's exit status
212 def describe(self):
213 """A few words on how eval failed."""
214 if self.timed_out:
215 return 'urbit eval timed out after %g s' % self.timeout
216 if self.bail == 'meme':
217 return 'urbit eval ran out of memory (bail: %meme)'
218 if self.bail == 'over':
219 return 'urbit eval overflowed its stack (bail: %over)'
220 if self.bail:
221 return 'urbit eval failed (bail: %%%s)' % self.bail
222 if self.status:
223 return 'urbit eval exited with status %s' % self.status
224 return 'urbit eval failed'
226 def is_compile_error(self):
227 """Whether eval failed while compiling, before running anything.
229 Compiling prints {l c} for a syntax error and /::[..]> spots for
230 the rest, type errors and stack overflows alike; a crash at run
231 time prints /:<[..]>. A trace without spots, as under !., counts
232 as compiling when it bailed %exit, since everything the harness
233 runs is under mule."""
234 if self.timed_out:
235 return False
236 if any(SYNTAX.match(line.strip()) for line in self.trace):
237 return True
238 kinds = {m.group('kind') for line in self.trace
239 for m in SPOT.finditer(line)}
240 if '<' in kinds:
241 return False
242 return ':' in kinds or self.bail == 'exit'
245def _newt_frame(out):
246 """The jam bytes of the newt frame that ends stdout, or None."""
247 start = 0
248 while True:
249 p = out.find(b'\x00', start)
250 if p < 0:
251 return None
252 for width in (4, 8):
253 body = p + 1 + width
254 if body <= len(out) and \
255 int.from_bytes(out[p + 1:body], 'little') == len(out) - body:
256 return out[body:]
257 start = p + 1
260def _clean_lines(raw):
261 return strip_ansi(raw.decode('utf-8', 'replace')).split('\n')
264def run_eval(program, jam, timeout=None):
265 """Run a Program under urbit eval and return an EvalResult.
267 jam=True runs `urbit eval -j -n` and decodes the result into .noun;
268 jam=False keeps the printed result, colours stripped, in .text.
269 """
270 cmd = [find_urbit(), 'eval'] + (['-j', '-n'] if jam else [])
271 res = EvalResult()
272 res.timeout = timeout
273 # stdout goes to a file, not a pipe: urbit eval exits before a pipe
274 # drains, so piped output is cut off at 64 KB.
275 with tempfile.TemporaryFile() as out:
276 try:
277 proc = subprocess.run(cmd, input=program.encode(),
278 stdout=out, stderr=subprocess.PIPE,
279 timeout=timeout)
280 except subprocess.TimeoutExpired:
281 res.timed_out = True
282 out.seek(0)
283 res.trace = [l for l in _clean_lines(out.read()) if l.strip()]
284 return res
285 out.seek(0)
286 stdout = out.read()
287 res.status = proc.returncode
288 for line in _clean_lines(proc.stderr):
289 m = BAIL.match(line.strip())
290 if m:
291 res.bail = m.group(1)
292 elif not BOOT_NOISE.match(line.strip()):
293 res.notes.append(line.rstrip())
294 if not res.bail and proc.returncode == 0:
295 if not jam:
296 res.text = strip_ansi(stdout.decode('utf-8', 'replace'))
297 res.ok = True
298 return res
299 frame = _newt_frame(stdout)
300 if frame is not None:
301 res.noun = cue(bytes_to_atom(frame))
302 res.ok = True
303 return res
304 res.trace = [l.rstrip() for l in _clean_lines(stdout) if l.strip()]
305 return res
308class Spot:
309 """A range of program text, placed in its segment."""
311 def __init__(self, prog, l1, c1, l2=None, c2=None):
312 self.seg, self.line = prog.locate(l1)
313 self.col = c1
314 end_seg, self.end_line = prog.locate(l1 if l2 is None else l2)
315 self.end_col = c1 if c2 is None else c2
316 if end_seg is not self.seg:
317 self.end_line = None # it runs past its segment
319 @property
320 def kind(self):
321 return self.seg.kind if self.seg else 'prefix'
323 def label(self):
324 """name:line:col, then -col, -line:col or -(end) for a range."""
325 if self.seg is None:
326 return '(program)'
327 out = '%s:%d:%d' % (self.seg.name, self.line, self.col)
328 if self.end_line is None:
329 return out + '-(end)'
330 if (self.end_line, self.end_col) == (self.line, self.col):
331 return out
332 if self.end_line == self.line:
333 return out + '-%d' % self.end_col
334 return out + '-%d:%d' % (self.end_line, self.end_col)
336 def excerpt(self, indent=' '):
337 """The spot's first line with carets under it; '' if none."""
338 if self.seg is None or not 1 <= self.line <= len(self.seg.lines):
339 return ''
340 text = self.seg.lines[self.line - 1]
341 raw = text.encode('utf-8', 'surrogateescape')
343 def chars(nbytes): # columns count bytes; carets count characters
344 return len(raw[:max(nbytes, 0)].decode('utf-8', 'replace'))
346 lead = chars(self.col - 1)
347 if self.end_line == self.line and self.end_col > self.col:
348 width = chars(self.end_col - 1) - lead
349 else:
350 width = 1
351 gutter = str(self.line)
352 return '%s%s | %s\n%s%s | %s%s' % (
353 indent, gutter, text.replace('\t', ' '),
354 indent, ' ' * len(gutter), ' ' * lead, '^' * max(width, 1))
357def num(text):
358 """An integer from Hoon's printing of one, separators and all."""
359 return int(text.replace('.', '').replace(',', ''))
362def _map_line(prog, line, keep, spots):
363 """Rewrite one line's spots; None when every spot on it is dropped."""
364 dropped = []
366 def sub(m):
367 spot = Spot(prog, num(m.group('l1')), num(m.group('c1')),
368 num(m.group('l2')), num(m.group('c2')))
369 if spot.kind not in keep:
370 dropped.append(spot)
371 return ''
372 spots.append(spot)
373 return spot.label()
375 new = SPOT.sub(sub, line)
376 if dropped and not new.strip():
377 return None
378 return new
381def map_trace(prog, lines, keep=('lib', 'expr', 'harness')):
382 """Rewrite the spots in eval's output as name:line:col, and drop
383 frames in segments whose kind isn't kept.
385 Returns the new lines and the kept spots, outermost first.
386 """
387 out, spots = [], []
388 for i, line in enumerate(lines):
389 bare = line.strip()
390 m = SYNTAX.match(bare)
391 if m:
392 spot = Spot(prog, num(m.group('l')), num(m.group('c')))
393 spots.append(spot)
394 out.append(line.replace(bare, spot.label()))
395 continue
396 nxt = lines[i + 1].strip() if i + 1 < len(lines) else ''
397 if BARE_PATH.match(bare) and SYNTAX.match(nxt):
398 continue # the program's path, printed before {l c}
399 new = _map_line(prog, line, keep, spots)
400 if new is not None:
401 out.append(new)
402 return out, spots
405def fold_repeats(lines, min_reps=3, max_period=64):
406 """Fold a block of lines that repeats back to back, as a recursion's
407 frames do, into one copy and a note of how often it repeats.
409 >>> fold_repeats(['a', 'b', 'c', 'b', 'c', 'b', 'c', 'd'])
410 ['a', 'b', 'c', '(the 2 lines above repeat 2 more times)', 'd']
411 """
412 out, i, n = [], 0, len(lines)
413 while i < n:
414 best = None
415 for k in range(1, min(max_period, (n - i) // min_reps) + 1):
416 run = 0 # how far lines keep matching those k before them
417 while i + k + run < n and lines[i + k + run] == lines[i + run]:
418 run += 1
419 reps = 1 + run // k
420 if reps >= min_reps and (best is None or reps * k > best[0]):
421 best = (reps * k, k, reps)
422 if best is None:
423 out.append(lines[i])
424 i += 1
425 continue
426 covered, k, reps = best
427 indent = lines[i][:len(lines[i]) - len(lines[i].lstrip())]
428 out += lines[i:i + k]
429 out.append('%s(the %s above repeat%s %d more times)' % (
430 indent, 'line' if k == 1 else '%d lines' % k,
431 's' if k == 1 else '', reps - 1))
432 i += covered
433 return out
436def focus(spots):
437 """The spot to show an excerpt of: the innermost one in the library
438 or the expression, else the innermost one."""
439 for spot in reversed(spots):
440 if spot.kind in ('lib', 'expr'):
441 return spot
442 return spots[-1] if spots else None
445def failure_report(prog, res, headline, harness_headline=None,
446 keep=('lib', 'expr', 'harness')):
447 """eval's error output, mapped, with an excerpt of where it points.
449 harness_headline replaces headline when the error is in the harness:
450 the library compiled, but doesn't fit what the harness expects.
451 keep names the kinds of segment whose frames the trace shows."""
452 lines, spots = map_trace(prog, res.trace, keep)
453 lines = fold_repeats(lines)
454 at = focus(spots)
455 in_harness = at is not None and at.kind == 'harness'
456 out = [harness_headline if in_harness and harness_headline else headline]
457 out.append('')
458 out += [' ' + l for l in lines] or [' (urbit eval printed no trace)']
459 if at is not None:
460 out += ['', 'at %s' % at.label()]
461 ex = at.excerpt()
462 if ex:
463 out.append(ex)
464 if in_harness:
465 out += ['', 'That line is in the harness the tool writes after '
466 'the library; --save-program FILE writes it out.']
467 notes = [n for n in res.notes if n.strip()]
468 if notes:
469 out += [''] + [' ' + n for n in notes]
470 return '\n'.join(out)
473def lib_hint(lib_text):
474 """A hint when the library starts with a Ford rune, which eval lacks,
475 or with !., which turns off the spots that errors need."""
476 for line in lib_text.split('\n'):
477 s = line.strip()
478 if not s or s.startswith('::'):
479 continue
480 if re.match(r'^/[-+=*~$%]', s):
481 return ('hint: the library starts with the Ford rune %s, but '
482 'urbit eval has no Ford; the library must be one core '
483 'without /- /+ /= /* imports.' % s.split()[0])
484 if s.startswith('!.'):
485 return ('hint: the library starts with !., which turns off '
486 'source positions; take it out to see where errors are.')
487 return None
488 return None