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 every program here starts with `!:`. Spots print as
22 /::[l c].[l c]> at compile time and /:<[l c].[l c]> at run time, and
23 a syntax error as {l c}. Lines and columns count from 1, columns in
25- `~&` output is dropped. mule catches crashes, but not the bails that
26 end the whole eval, such as running out of memory (%meme) or asking
27 for an atom bigger than the loom (%fail).
36from noun import bytes_to_atom, cue
38ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
39DEFAULT_LIB = os.path.join(ROOT, 'jibb-dev', 'lib', 'jibb.hoon')
41ANSI = re.compile(r'\x1b\[[0-9;?]*[A-Za-z]')
42BOOT_NOISE = re.compile(
43 r'^(loom: mapped \S+|lite: .*|eval \([a-z, ]+\):|newt meaningless .*)?$')
44BAIL = re.compile(r'^(?:eval: )?bail: %?(\w+)')
45# Numbers past 999 print with separators: 2,554 at compile time, 3.411
47NUM = r'\d{1,3}(?:[.,]\d{3})*'
49 r'(?P<path>/[^\s:<\[]*)?:(?P<kind>[:<])'
50 r'\[(?P<l1>%s) (?P<c1>%s)\]\.\[(?P<l2>%s) (?P<c2>%s)\]>' % ((NUM,) * 4))
51SYNTAX = re.compile(r'^\{(?P<l>%s) (?P<c>%s)\}$' % (NUM, NUM))
52BARE_PATH = re.compile(r'^/[^\s]*$')
55class ToolError(Exception):
56 """A failure that stops a tool: report it and exit with status 2."""
59def display_path(path):
60 """A path as the tools print it: from the repo root when inside it."""
61 path = os.path.abspath(path)
62 rel = os.path.relpath(path, ROOT)
63 return path if rel.startswith('..') else rel
67 return ANSI.sub('', text).replace('\r', '')
70def cord_literal(data):
71 """A Hoon cord literal, '...', holding exactly these bytes.
73 Printable ASCII stays as it is, quotes and backslashes are escaped,
74 and every other byte, UTF-8 included, is written \\XX.
76 >>> print(cord_literal("it's \\\\ \\"q\\" é\\n".encode()))
77 'it\\'s \\\\ "q" \\c3\\a9\\0a'
85 elif 0x20 <= b < 0x7f:
88 out.append('\\%02x' % b)
96def cord_pieces(data, size=CORD_PIECE):
97 """data cut into pieces whose cord literals `rap 3` joins back into
98 data: one piece per line, ending in its newline, and a line longer
99 than size cut about every size bytes, since Hoon reads a cord
100 literal in about quadratic time (a 72 KB one takes 11 s). A piece
101 is an atom, which loses NULs at its end, so a cut moves past NULs;
102 only the NULs ending data are lost, as in Clay, where a file is one
105 >>> cord_pieces(b'ab\\ncd')
107 >>> cord_pieces(b'abcdefg\\n', 3)
108 [b'abc', b'def', b'g\\n']
109 >>> cord_pieces(b'ab\\x00\\x00cdef', 3)
110 [b'ab\\x00\\x00c', b'def']
114 lines = data.split(b'\n')
115 lines = [line + b'\n' for line in lines[:-1]] + lines[-1:]
119 while len(line) - start > size:
121 while cut < len(line) and line[cut - 1] == 0:
123 pieces.append(line[start:cut])
125 if start < len(line) or not pieces:
126 pieces.append(line[start:])
131 """A file's text, losslessly: bytes that aren't UTF-8 survive."""
132 with open(path, 'rb') as f:
133 return f.read().decode('utf-8', 'surrogateescape')
137 """Consecutive lines of a program that came from one place."""
139 def __init__(self, name, kind, start, lines):
140 self.name = name # what errors call it: a path, or (harness)
141 self.kind = kind # 'prefix', 'lib', 'harness' or 'expr'
142 self.start = start # its first line in the program, from 1
147 """A Hoon program built from segments, each starting on a new line."""
154 def add(self, text, name, kind):
155 if not text.endswith('\n'):
157 lines = text.split('\n')[:-1]
158 self.segments.append(Segment(name, kind, self.next_line, lines))
159 self.parts.append(text)
160 self.next_line += len(lines)
163 return ''.join(self.parts)
166 return self.text().encode('utf-8', 'surrogateescape')
168 def locate(self, line):
169 """The segment holding a program line, and the line within it."""
170 for seg in self.segments:
171 if seg.start <= line < seg.start + len(seg.lines):
172 return seg, line - seg.start + 1
176def library_program(lib_path):
177 """A program that starts `!: => <library>`, for adding what follows.
179 `!:` turns on debug spots, so errors and crash traces have positions;
180 `=>` makes the library's core the subject of what's added next.
183 prog.add('!:\n=>', '(prefix)', 'prefix')
184 prog.add(read_text(lib_path), display_path(lib_path), 'lib')
189 urbit = os.environ.get('URBIT') or shutil.which('urbit')
191 raise ToolError('urbit is not on PATH; install vere, or set URBIT '
199 self.noun = None # the result, in jam mode
200 self.text = '' # the printed result, in plain mode
201 self.trace = [] # eval's error output, as clean lines
202 self.notes = [] # stderr lines that aren't boot noise
203 self.bail = None # 'exit', 'meme', ... when eval bailed
204 self.timed_out = False
205 self.timeout = None # the time limit it ran under, in seconds
206 self.status = None # the process's exit status
209 """A few words on how eval failed."""
211 return 'urbit eval timed out after %g s' % self.timeout
212 if self.bail == 'meme':
213 return 'urbit eval ran out of memory (bail: %meme)'
214 if self.bail == 'over':
215 return 'urbit eval overflowed its stack (bail: %over)'
217 return 'urbit eval failed (bail: %%%s)' % self.bail
219 return 'urbit eval exited with status %s' % self.status
220 return 'urbit eval failed'
222 def is_compile_error(self):
223 """Whether eval failed while compiling, before running anything.
225 Compiling prints {l c} for a syntax error and /::[..]> spots for
226 the rest, type errors and stack overflows alike; a crash at run
227 time prints /:<[..]>. A trace without spots, as under !., counts
228 as compiling when it bailed %exit, since everything the harness
229 runs is under mule."""
232 if any(SYNTAX.match(line.strip()) for line in self.trace):
234 kinds = {m.group('kind') for line in self.trace
235 for m in SPOT.finditer(line)}
238 return ':' in kinds or self.bail == 'exit'
242 """The jam bytes of the newt frame that ends stdout, or None."""
245 p = out.find(b'\x00', start)
250 if body <= len(out) and \
251 int.from_bytes(out[p + 1:body], 'little') == len(out) - body:
256def _clean_lines(raw):
257 return strip_ansi(raw.decode('utf-8', 'replace')).split('\n')
260def run_eval(program, jam, timeout=None):
261 """Run a Program under urbit eval and return an EvalResult.
263 jam=True runs `urbit eval -j -n` and decodes the result into .noun;
264 jam=False keeps the printed result, colours stripped, in .text.
266 cmd = [find_urbit(), 'eval'] + (['-j', '-n'] if jam else [])
268 res.timeout = timeout
269 # stdout goes to a file, not a pipe: urbit eval exits before a pipe
270 # drains, so piped output is cut off at 64 KB.
271 with tempfile.TemporaryFile() as out:
273 proc = subprocess.run(cmd, input=program.encode(),
274 stdout=out, stderr=subprocess.PIPE,
276 except subprocess.TimeoutExpired:
279 res.trace = [l for l in _clean_lines(out.read()) if l.strip()]
283 res.status = proc.returncode
284 for line in _clean_lines(proc.stderr):
285 m = BAIL.match(line.strip())
287 res.bail = m.group(1)
288 elif not BOOT_NOISE.match(line.strip()):
289 res.notes.append(line.rstrip())
290 if not res.bail and proc.returncode == 0:
292 res.text = strip_ansi(stdout.decode('utf-8', 'replace'))
295 frame = _newt_frame(stdout)
296 if frame is not None:
297 res.noun = cue(bytes_to_atom(frame))
300 res.trace = [l.rstrip() for l in _clean_lines(stdout) if l.strip()]
305 """A range of program text, placed in its segment."""
307 def __init__(self, prog, l1, c1, l2=None, c2=None):
308 self.seg, self.line = prog.locate(l1)
310 end_seg, self.end_line = prog.locate(l1 if l2 is None else l2)
311 self.end_col = c1 if c2 is None else c2
312 if end_seg is not self.seg:
313 self.end_line = None # it runs past its segment
317 return self.seg.kind if self.seg else 'prefix'
320 """name:line:col, then -col, -line:col or -(end) for a range."""
323 out = '%s:%d:%d' % (self.seg.name, self.line, self.col)
324 if self.end_line is None:
325 return out + '-(end)'
326 if (self.end_line, self.end_col) == (self.line, self.col):
328 if self.end_line == self.line:
329 return out + '-%d' % self.end_col
330 return out + '-%d:%d' % (self.end_line, self.end_col)
332 def excerpt(self, indent=' '):
333 """The spot's first line with carets under it; '' if none."""
334 if self.seg is None or not 1 <= self.line <= len(self.seg.lines):
336 text = self.seg.lines[self.line - 1]
337 raw = text.encode('utf-8', 'surrogateescape')
339 def chars(nbytes): # columns count bytes; carets count characters
340 return len(raw[:max(nbytes, 0)].decode('utf-8', 'replace'))
342 lead = chars(self.col - 1)
343 if self.end_line == self.line and self.end_col > self.col:
344 width = chars(self.end_col - 1) - lead
347 gutter = str(self.line)
348 return '%s%s | %s\n%s%s | %s%s' % (
349 indent, gutter, text.replace('\t', ' '),
350 indent, ' ' * len(gutter), ' ' * lead, '^' * max(width, 1))
354 """An integer from Hoon's printing of one, separators and all."""
355 return int(text.replace('.', '').replace(',', ''))
358def _map_line(prog, line, keep, spots):
359 """Rewrite one line's spots; None when every spot on it is dropped."""
363 spot = Spot(prog, num(m.group('l1')), num(m.group('c1')),
364 num(m.group('l2')), num(m.group('c2')))
365 if spot.kind not in keep:
371 new = SPOT.sub(sub, line)
372 if dropped and not new.strip():
377def map_trace(prog, lines, keep=('lib', 'expr', 'harness')):
378 """Rewrite the spots in eval's output as name:line:col, and drop
379 frames in segments whose kind isn't kept.
381 Returns the new lines and the kept spots, outermost first.
384 for i, line in enumerate(lines):
386 m = SYNTAX.match(bare)
388 spot = Spot(prog, num(m.group('l')), num(m.group('c')))
390 out.append(line.replace(bare, spot.label()))
392 nxt = lines[i + 1].strip() if i + 1 < len(lines) else ''
393 if BARE_PATH.match(bare) and SYNTAX.match(nxt):
394 continue # the program's path, printed before {l c}
395 new = _map_line(prog, line, keep, spots)
401def fold_repeats(lines, min_reps=3, max_period=64):
402 """Fold a block of lines that repeats back to back, as a recursion's
403 frames do, into one copy and a note of how often it repeats.
405 >>> fold_repeats(['a', 'b', 'c', 'b', 'c', 'b', 'c', 'd'])
406 ['a', 'b', 'c', '(the 2 lines above repeat 2 more times)', 'd']
408 out, i, n = [], 0, len(lines)
411 for k in range(1, min(max_period, (n - i) // min_reps) + 1):
412 run = 0 # how far lines keep matching those k before them
413 while i + k + run < n and lines[i + k + run] == lines[i + run]:
416 if reps >= min_reps and (best is None or reps * k > best[0]):
417 best = (reps * k, k, reps)
422 covered, k, reps = best
423 indent = lines[i][:len(lines[i]) - len(lines[i].lstrip())]
424 out += lines[i:i + k]
425 out.append('%s(the %s above repeat%s %d more times)' % (
426 indent, 'line' if k == 1 else '%d lines' % k,
427 's' if k == 1 else '', reps - 1))
433 """The spot to show an excerpt of: the innermost one in the library
434 or the expression, else the innermost one."""
435 for spot in reversed(spots):
436 if spot.kind in ('lib', 'expr'):
438 return spots[-1] if spots else None
441def failure_report(prog, res, headline, harness_headline=None):
442 """eval's error output, mapped, with an excerpt of where it points.
444 harness_headline replaces headline when the error is in the harness:
445 the library compiled, but doesn't fit what the harness expects."""
446 lines, spots = map_trace(prog, res.trace)
447 lines = fold_repeats(lines)
449 in_harness = at is not None and at.kind == 'harness'
450 out = [harness_headline if in_harness and harness_headline else headline]
452 out += [' ' + l for l in lines] or [' (urbit eval printed no trace)']
454 out += ['', 'at %s' % at.label()]
459 out += ['', 'That line is in the harness the tool writes after '
460 'the library; --save-program FILE writes it out.']
461 notes = [n for n in res.notes if n.strip()]
463 out += [''] + [' ' + n for n in notes]
464 return '\n'.join(out)
467def lib_hint(lib_text):
468 """A hint when the library starts with a Ford rune, which eval lacks,
469 or with !., which turns off the spots that errors need."""
470 for line in lib_text.split('\n'):
472 if not s or s.startswith('::'):
474 if re.match(r'^/[-+=*~$%]', s):
475 return ('hint: the library starts with the Ford rune %s, but '
476 'urbit eval has no Ford; the library must be one core '
477 'without /- /+ /= /* imports.' % s.split()[0])
478 if s.startswith('!.'):
479 return ('hint: the library starts with !., which turns off '
480 '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