Home Download Buy Blog Forum Support

[Solved] Double-clicking through stack traces

[Solved] Double-clicking through stack traces

Postby xeno.by on Tue Aug 13, 2013 10:44 am

Hi folks! I would like to share the way I implemented sketchy (though operational) support for stack trace navigation via a custom build result handler (double-click is now working, F4 can be made working using a similar approach).

Build result system in Sublime (supported both in ST2 and ST3) is a very powerful beast that can designate text fragments of build output as "hyperlinks" that can be clicked through with double-click or iterated with F4. This functionality is governed by result_file_regex, result_line_regex and result_base_dir properties that can be set in View.settings.

How build results work

Build systems manage the aforementioned properties behind the scenes (here are some docs about that), but the best thing here is that we can set the properties programmatically and reap all the benefits. For example, if for a build buffer in my custom build system I do the following:

Code: Select all
view.settings().set("result_file_regex", "([:.a-z_A-Z0-9\\\\/-]+[.]scala):([0-9]+)")
view.settings().set("result_base_dir", "<path to project>")

Then build errors in my Scala projects compiled by SBT, Ant or Maven become recognizable by Sublime in the sense that I can double-click lines with errors and that will navigate me to the precise source of the error:

Code: Select all
quick.comp:
[quick.compiler] Compiling 1 file to /Users/xeno_by/Projects/210x/build/quick/classes/compiler
[quick.compiler] /Users/xeno_by/Projects/210x/src/compiler/scala/tools/nsc/typechecker/Typers.scala:2696: error: not found: value typedIdent
[quick.compiler]           val A1Tpt = typedIdent(A1)
[quick.compiler]                       ^

More precisely, double-clicking the third line of the printout shown above will make Sublime open /Users/xeno_by/Projects/210x/src/compiler/scala/tools/nsc/typechecker/Typers.scala at line 2696. Very convenient!

A minor caveat

There's a minor thing that should be taken into account when programmatically setting build result view parameters. These parameters don't get applied until the view gets focused. To work around one literally has to focus some other view and then immediately switch back to the view being set up. So my full code of build result setup looks as follows (please let me know if there's a better way!):

Code: Select all
view.settings().set("result_file_regex", "([:.a-z_A-Z0-9\\\\/-]+[.]scala):([0-9]+)")
view.settings().set("result_base_dir", repl_restart_args["cwd"])
other_view = self._window.new_file()
self._window.focus_view(other_view)
self._window.run_command("close_file")
self._window.focus_view(view)


Limitations

This regex-based approach fits a lot of use cases, ranging from build systems to search results. Did you know that Find Results in Sublime works exactly in the same way? In the printout shown below, "/Users/xeno_by/Projects/210x/src/reflect/scala/reflect/internal/TreeGen.scala:" matches the file regex and "179:" matches the line regex, making double clicks go to line 179 of TreeGen.scala.

Code: Select all
Searching 11513 files for "typedIdent"

/Users/xeno_by/Projects/210x/src/reflect/scala/reflect/internal/TreeGen.scala:
  177        // foo.bar.name if name is in the package object.
  178        // TODO - factor out the common logic between this and
  179:       // the Typers method "isInPackageObject", used in typedIdent.
  180        val qualsym = (
  181          if (qual.tpe ne null) qual.tpe.typeSymbol

Nevertheless there are situations when regular expressions alone are not enough, and we need to take some context information into account. I'm not talking about relative file names (these are handled by result_base_dir), but about stack traces:

Code: Select all
Welcome to Scala version 2.10.3-20130808-081556-ed5c1abbfc (Java HotSpot(TM) 64-Bit Server VM, Java 1.6.0_51).
Type in expressions to have them evaluated.
Type :help for more information.

scala> throw new Exception
java.lang.Exception
    *snip*
    at scala.tools.nsc.interpreter.ILoop$$anonfun$process$1.apply$mcZ$sp(ILoop.scala:867)
    at scala.tools.nsc.interpreter.ILoop$$anonfun$process$1.apply(ILoop.scala:822)
    at scala.tools.nsc.interpreter.ILoop$$anonfun$process$1.apply(ILoop.scala:822)
    at scala.tools.nsc.util.ScalaClassLoader$.savingContextLoader(ScalaClassLoader.scala:135)
    at scala.tools.nsc.interpreter.ILoop.process(ILoop.scala:822)
    at scala.tools.nsc.MainGenericRunner.runTarget$1(MainGenericRunner.scala:83)
    at scala.tools.nsc.MainGenericRunner.process(MainGenericRunner.scala:96)
    at scala.tools.nsc.MainGenericRunner$.main(MainGenericRunner.scala:105)
    at scala.tools.nsc.MainGenericRunner.main(MainGenericRunner.scala)

When I double-click a line that contains "ILoop.scala:867", build result regexes will happily figure out that the file I'm interested in is called "ILoop.scala" and the line number is 867. That's cool, but incorrect, because ILoop is actually located in package "scala.tools.nsc.interpreter", which means that the file name should be "scala/tools/nsc/interpreter/Loop.scala".

Customizing double-click: Mousemap

Apart from the regular expressions mentioned above, there's no other ways to modify the standard behavior of build result system. Therefore we will now override a double-click handler and implement everything ourselves. That's not as hard as it might seem :)

First, we start with a mousemap. It's widely known that in Sublime one can customize keyboard shortcuts, but it's almost secret knowledge that there's a way to provide user-defined handling for mouse events. It turns out that it's all very simple - instead of modifying *.sublime-keymap, we adjust *.sublime-mousemap. If we go to Default*.sublime-mousemap bundled with Default.sublime-package, we'll see the following:

Code: Select all
*snip*
{
  "button": "button1", "count": 2,
  "press_command": "drag_select",
  "press_args": {"by": "words"}
}
*snip*

Allright, so far so good. Let's just copy/paste this binding into "Packages/User/Default (<your OS name>).sublime-mousemap" and slightly adjust it, so that the file looks like this:

Code: Select all
[
  {
    "button": "button1", "count": 2,
    "press_command": "my_double_click"
  }
]

Note that in our case it's very important to provide the command in "press_command", not in "command", otherwise the whole business will induce slight lags. But, you know, after Sublime being always lightning fast, even insignificant slowdowns become unbearable :)

Customizing double-click: Handlers

The last step is providing a double-click handler. This is pretty much straightforward. I more or less reproduced the standard behaviour providing an escape hatch to a helper script "click-through" that gets the context of the double click and uses it to figure out what to do (for completeness, the sources of click-through are also provided below). By the way, note the usage of "os.spawnlp" instead of "Popen" in the double click handler - this seems to be crucial for allowing click-through to call back in Sublime.

Code: Select all
import sublime, sublime_plugin
import re, os

class MyDoubleClick(sublime_plugin.TextCommand):
  def run(self, edit):
    v = self.view
    sel = v.sel()
    def seek(rx_key, immediate):
      rx = v.settings().get(rx_key)
      if rx:
        def loop(point, first_iter):
          if point < 0: return None
          l = v.line(point)
          m = re.search(rx, v.substr(l))
          if m:
            return m
          else:
            if immediate: return None
            else: return loop(l.a - 1, first_iter = False)
        return loop(sel[0].a, first_iter = True)
    def match(rx_key):
      return (seek(rx_key, immediate = False), seek(rx_key, immediate = True))
    (fm, fimm), (lm, limm) = match("result_file_regex"), match("result_line_regex")
    if fm and (fimm or limm):
      def substr_and_fixup(begin, end):
        text = v.substr(sublime.Region(begin, end))
        # compatibility with how iTerm2 passes strings to semantic history handlers
        return text.replace("(", "\(").replace(")", "\)").replace(" ", "\ ").replace("\n", "\ ")
      file_name = fm.groups()[0]
      line_number = lm.groups()[0] if lm else fm.groups()[1]
      before_click = substr_and_fixup(v.line(sel[0]).a, sel[0].a)
      after_click = substr_and_fixup(sel[0].a, v.line(sel[0]).b)
      view_dir = os.path.basename(v.file_name()) if v.file_name() else None
      cwd = v.settings().get("result_base_dir") or view_dir or ""
      os.spawnlp(os.P_NOWAIT, "click-through", "click-through", file_name, line_number, before_click, after_click, cwd)
    else:
      sel.add(v.word(sel[0]))


Code: Select all
#!/usr/bin/env python
import sys, re, os
from subprocess import call, check_output, Popen

if len(sys.argv) != 6:
  print "usage: " + sys.argv[0] + " <file name> <line number> <text before click> <text after click> <cwd>"
  sys.exit(1)

file_name = sys.argv[1]
line_number = sys.argv[2]
before_click = sys.argv[3]
after_click = sys.argv[4]
cwd = sys.argv[5]

def find(root, name):
  # NOTE: find is too slow - I got too spoiled by Sublime
  # names = check_output(["find", root, "-path", "*" + name]).strip().split("\n")
  # if len(names) == 1: return names[0]
  src = os.path.join(root, "src")
  for dir_name in os.listdir(src):
    # call(["terminal-notifier", "-message", dir_name])
    candidate = os.path.join(src, dir_name, name)
    if os.path.exists(candidate):
      return candidate

if os.path.isabs(file_name) and os.path.exists(file_name):
  command = file_name + ":" + line_number if line_number else file_name
  call(["subl", command])
else:
  cut_prefix = before_click.rfind("\ ") + 1
  prefix = before_click[cut_prefix:]
  cut_suffix = after_click.find("\ ") if after_click.find("\ ") != -1 else len(after_click)
  suffix = after_click[0:cut_suffix]
  line = (prefix + suffix).replace("\(", "(").replace("\)", ")").strip()
  m = re.match("^(?P<package>.*)\.(?P<class>.*?)\.(?P<method>.*?)\((?P<file>.*):(?P<line>\d+)\)$", line)
  scala_root = check_output(["scala-root"], cwd = cwd).strip()
  qualified_name = os.path.join(m.group("package").replace(".", os.sep), m.group("file"))
  full_name = find(scala_root, qualified_name)
  if full_name:
    command = full_name + ":" + m.group("line")
    call(["subl", command])


Bonus chapter: iTerm

Image
Luckily for iTerm users, iTerm also supports something similar via the Semantic History mechanism that can call into custom scripts to determine click-through targets. Unfortunately, this thing is bound to Command+Click. Now if only it allowed to customize it to double-click for consistency with Sublime. But, oh, wait! We have KeyRemap4MacBook. Time for more hacking! upd. See KeyRemap's mailing list for configuration instructions.
xeno.by
 
Posts: 106
Joined: Mon Jan 30, 2012 10:03 am
Location: Lausanne, Switzerland

Return to Ideas and Feature Requests

Who is online

Users browsing this forum: Yahoo [Bot] and 9 guests