1"""Implements a xonsh tracer."""
2import os
3import re
4import sys
5import inspect
6import argparse
7import linecache
8import importlib
9import functools
10
11from xonsh.lazyasd import LazyObject
12from xonsh.platform import HAS_PYGMENTS
13from xonsh.tools import DefaultNotGiven, print_color, normabspath, to_bool
14from xonsh.inspectors import find_file, getouterframes
15from xonsh.lazyimps import pygments, pyghooks
16from xonsh.proc import STDOUT_CAPTURE_KINDS
17import xonsh.prompt.cwd as prompt
18
19terminal = LazyObject(
20    lambda: importlib.import_module("pygments.formatters.terminal"),
21    globals(),
22    "terminal",
23)
24
25
26class TracerType(object):
27    """Represents a xonsh tracer object, which keeps track of all tracing
28    state. This is a singleton.
29    """
30
31    _inst = None
32    valid_events = frozenset(["line", "call"])
33
34    def __new__(cls, *args, **kwargs):
35        if cls._inst is None:
36            cls._inst = super(TracerType, cls).__new__(cls, *args, **kwargs)
37        return cls._inst
38
39    def __init__(self):
40        self.prev_tracer = DefaultNotGiven
41        self.files = set()
42        self.usecolor = True
43        self.lexer = pyghooks.XonshLexer()
44        self.formatter = terminal.TerminalFormatter()
45        self._last = ("", -1)  # filename, lineno tuple
46
47    def __del__(self):
48        for f in set(self.files):
49            self.stop(f)
50
51    def color_output(self, usecolor):
52        """Specify whether or not the tracer output should be colored."""
53        # we have to use a function to set usecolor because of the way that
54        # lazyasd works. Namely, it cannot dispatch setattr to the target
55        # object without being unable to access its own __dict__. This makes
56        # setting an attr look like getting a function.
57        self.usecolor = usecolor
58
59    def start(self, filename):
60        """Starts tracing a file."""
61        files = self.files
62        if len(files) == 0:
63            self.prev_tracer = sys.gettrace()
64        files.add(normabspath(filename))
65        sys.settrace(self.trace)
66        curr = inspect.currentframe()
67        for frame, fname, *_ in getouterframes(curr, context=0):
68            if normabspath(fname) in files:
69                frame.f_trace = self.trace
70
71    def stop(self, filename):
72        """Stops tracing a file."""
73        filename = normabspath(filename)
74        self.files.discard(filename)
75        if len(self.files) == 0:
76            sys.settrace(self.prev_tracer)
77            curr = inspect.currentframe()
78            for frame, fname, *_ in getouterframes(curr, context=0):
79                if normabspath(fname) == filename:
80                    frame.f_trace = self.prev_tracer
81            self.prev_tracer = DefaultNotGiven
82
83    def trace(self, frame, event, arg):
84        """Implements a line tracing function."""
85        if event not in self.valid_events:
86            return self.trace
87        fname = find_file(frame)
88        if fname in self.files:
89            lineno = frame.f_lineno
90            curr = (fname, lineno)
91            if curr != self._last:
92                line = linecache.getline(fname, lineno).rstrip()
93                s = tracer_format_line(
94                    fname,
95                    lineno,
96                    line,
97                    color=self.usecolor,
98                    lexer=self.lexer,
99                    formatter=self.formatter,
100                )
101                print_color(s)
102                self._last = curr
103        return self.trace
104
105
106tracer = LazyObject(TracerType, globals(), "tracer")
107
108COLORLESS_LINE = "{fname}:{lineno}:{line}"
109COLOR_LINE = "{{PURPLE}}{fname}{{BLUE}}:" "{{GREEN}}{lineno}{{BLUE}}:" "{{NO_COLOR}}"
110
111
112def tracer_format_line(fname, lineno, line, color=True, lexer=None, formatter=None):
113    """Formats a trace line suitable for printing."""
114    fname = min(fname, prompt._replace_home(fname), os.path.relpath(fname), key=len)
115    if not color:
116        return COLORLESS_LINE.format(fname=fname, lineno=lineno, line=line)
117    cline = COLOR_LINE.format(fname=fname, lineno=lineno)
118    if not HAS_PYGMENTS:
119        return cline + line
120    # OK, so we have pygments
121    tokens = pyghooks.partial_color_tokenize(cline)
122    lexer = lexer or pyghooks.XonshLexer()
123    tokens += pygments.lex(line, lexer=lexer)
124    if tokens[-1][1] == "\n":
125        del tokens[-1]
126    elif tokens[-1][1].endswith("\n"):
127        tokens[-1] = (tokens[-1][0], tokens[-1][1].rstrip())
128    return tokens
129
130
131#
132# Command line interface
133#
134
135
136def _find_caller(args):
137    """Somewhat hacky method of finding the __file__ based on the line executed."""
138    re_line = re.compile(r"[^;\s|&<>]+\s+" + r"\s+".join(args))
139    curr = inspect.currentframe()
140    for _, fname, lineno, _, lines, _ in getouterframes(curr, context=1)[3:]:
141        if lines is not None and re_line.search(lines[0]) is not None:
142            return fname
143        elif (
144            lineno == 1 and re_line.search(linecache.getline(fname, lineno)) is not None
145        ):
146            # There is a bug in CPython such that getouterframes(curr, context=1)
147            # will actually return the 2nd line in the code_context field, even though
148            # line number is itself correct. We manually fix that in this branch.
149            return fname
150    else:
151        msg = (
152            "xonsh: warning: __file__ name could not be found. You may be "
153            "trying to trace interactively. Please pass in the file names "
154            "you want to trace explicitly."
155        )
156        print(msg, file=sys.stderr)
157
158
159def _on(ns, args):
160    """Turns on tracing for files."""
161    for f in ns.files:
162        if f == "__file__":
163            f = _find_caller(args)
164        if f is None:
165            continue
166        tracer.start(f)
167
168
169def _off(ns, args):
170    """Turns off tracing for files."""
171    for f in ns.files:
172        if f == "__file__":
173            f = _find_caller(args)
174        if f is None:
175            continue
176        tracer.stop(f)
177
178
179def _color(ns, args):
180    """Manages color action for tracer CLI."""
181    tracer.color_output(ns.toggle)
182
183
184@functools.lru_cache(1)
185def _tracer_create_parser():
186    """Creates tracer argument parser"""
187    p = argparse.ArgumentParser(
188        prog="trace", description="tool for tracing xonsh code as it runs."
189    )
190    subp = p.add_subparsers(title="action", dest="action")
191    onp = subp.add_parser(
192        "on", aliases=["start", "add"], help="begins tracing selected files."
193    )
194    onp.add_argument(
195        "files",
196        nargs="*",
197        default=["__file__"],
198        help=(
199            'file paths to watch, use "__file__" (default) to select '
200            "the current file."
201        ),
202    )
203    off = subp.add_parser(
204        "off", aliases=["stop", "del", "rm"], help="removes selected files fom tracing."
205    )
206    off.add_argument(
207        "files",
208        nargs="*",
209        default=["__file__"],
210        help=(
211            'file paths to stop watching, use "__file__" (default) to '
212            "select the current file."
213        ),
214    )
215    col = subp.add_parser("color", help="output color management for tracer.")
216    col.add_argument(
217        "toggle", type=to_bool, help="true/false, y/n, etc. to toggle color usage."
218    )
219    return p
220
221
222_TRACER_MAIN_ACTIONS = {
223    "on": _on,
224    "add": _on,
225    "start": _on,
226    "rm": _off,
227    "off": _off,
228    "del": _off,
229    "stop": _off,
230    "color": _color,
231}
232
233
234def tracermain(args=None, stdin=None, stdout=None, stderr=None, spec=None):
235    """Main function for tracer command-line interface."""
236    parser = _tracer_create_parser()
237    ns = parser.parse_args(args)
238    usecolor = (spec.captured not in STDOUT_CAPTURE_KINDS) and sys.stdout.isatty()
239    tracer.color_output(usecolor)
240    return _TRACER_MAIN_ACTIONS[ns.action](ns, args)
241