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
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).
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
49NUM = r'\d{1,3}(?:[.,]\d{3})*'
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
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'
87 elif 0x20 <= b < 0x7f:
90 out.append('\\%02x' % b)
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
107 >>> cord_pieces(b'ab\\ncd')
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']
116 lines = data.split(b'\n')
117 lines = [line + b'\n' for line in lines[:-1]] + lines[-1:]
121 while len(line) - start > size:
123 while cut < len(line) and line[cut - 1] == 0:
125 pieces.append(line[start:cut])
127 if start < len(line) or not pieces:
128 pieces.append(line[start:])
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')
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
149 """A Hoon program built from segments, each starting on a new line."""
156 def add(self, text, name, kind):
157 if not text.endswith('\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)
165 return ''.join(self.parts)
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
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.
187 prog.add('!:\n=>' if debug else '=>', '(prefix)', 'prefix')
188 prog.add(read_text(lib_path), display_path(lib_path), 'lib')
193 urbit = os.environ.get('URBIT') or shutil.which('urbit')
195 raise ToolError('urbit is not on PATH; install vere, or set URBIT '
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
213 """A few words on how eval failed."""
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)'
221 return 'urbit eval failed (bail: %%%s)' % self.bail
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."""
236 if any(SYNTAX.match(line.strip()) for line in self.trace):
238 kinds = {m.group('kind') for line in self.trace
239 for m in SPOT.finditer(line)}
242 return ':' in kinds or self.bail == 'exit'
246 """The jam bytes of the newt frame that ends stdout, or None."""
249 p = out.find(b'\x00', start)
254 if body <= len(out) and \
255 int.from_bytes(out[p + 1:body], 'little') == len(out) - body:
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.
270 cmd = [find_urbit(), 'eval'] + (['-j', '-n'] if jam else [])
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:
277 proc = subprocess.run(cmd, input=program.encode(),
278 stdout=out, stderr=subprocess.PIPE,
280 except subprocess.TimeoutExpired:
283 res.trace = [l for l in _clean_lines(out.read()) if l.strip()]
287 res.status = proc.returncode
288 for line in _clean_lines(proc.stderr):
289 m = BAIL.match(line.strip())
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:
296 res.text = strip_ansi(stdout.decode('utf-8', 'replace'))
299 frame = _newt_frame(stdout)
300 if frame is not None:
301 res.noun = cue(bytes_to_atom(frame))
304 res.trace = [l.rstrip() for l in _clean_lines(stdout) if l.strip()]
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)
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
321 return self.seg.kind if self.seg else 'prefix'
324 """name:line:col, then -col, -line:col or -(end) for a range."""
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):
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):
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
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))
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."""
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:
375 new = SPOT.sub(sub, line)
376 if dropped and not new.strip():
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.
388 for i, line in enumerate(lines):
390 m = SYNTAX.match(bare)
392 spot = Spot(prog, num(m.group('l')), num(m.group('c')))
394 out.append(line.replace(bare, spot.label()))
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)
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']
412 out, i, n = [], 0, len(lines)
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]:
420 if reps >= min_reps and (best is None or reps * k > best[0]):
421 best = (reps * k, k, reps)
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))
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'):
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)
455 in_harness = at is not None and at.kind == 'harness'
456 out = [harness_headline if in_harness and harness_headline else headline]
458 out += [' ' + l for l in lines] or [' (urbit eval printed no trace)']
460 out += ['', 'at %s' % at.label()]
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()]
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'):
478 if not s or s.startswith('::'):
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.')
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list