diff --git a/OpenInEditor.app/Contents/Resources/script b/OpenInEditor.app/Contents/Resources/script index 99c67b5..0b631fd 100755 --- a/OpenInEditor.app/Contents/Resources/script +++ b/OpenInEditor.app/Contents/Resources/script @@ -19,14 +19,20 @@ except ImportError: LOGFILE = "/tmp/open-in-editor.log" OPEN_IN_EDITOR = os.getenv("OPEN_IN_EDITOR") EDITOR = os.getenv("EDITOR") +TERMINAL = os.getenv("TERMINAL") def main(): try: editor = BaseEditor.infer_from_environment_variables() + terminal = BaseTerminal.infer_from_environment_variables() (url,) = sys.argv[1:] path, line, column = parse_url(url) log_info("path=%s line=%s column=%s" % (path, line, column)) - editor.visit_file(path, line or 1, column or 1) + if hasattr(editor, 'is_terminal') and\ + editor.is_terminal: + editor.visit_file(path, line or 1, column or 1, terminal) + else: + editor.visit_file(path, line or 1, column or 1) except Exception: from traceback import format_exc @@ -76,6 +82,16 @@ def log(line): log_info = log log_error = log +def log_error_terminal(TERMINAL): + log_error( + "ERROR: failed to infer your terminal. " + "The value of the relevant environment variable is: " + "TERMINAL=%s. " + "I was expecting one of these to contain one of the following substrings: " + "wezterm." + % (TERMINAL) + ) + sys.exit(1) class BaseEditor(object): """ @@ -126,8 +142,61 @@ class BaseEditor(object): raise NotImplementedError() +class BaseTerminal(object): + """ + Abstract base class for terminals. + """ + + @classmethod + def infer_terminal_from_path(cls, path): + """ + Infer the terminal type and its executable path heuristically + """ + # '/Apps/Wezterm - In.app/wezterm cli spawn -- ' + # ↓ up to last / = '/Apps/Wezterm - In.app' + path_head, path_tail = os.path.split(path) + # after the last / ↑ = 'wezterm cli spawn --' + path_bin = path_tail.split(' -')[0].strip().split(' ')[0] # remove ' -args' and ' args' → 'wezterm cli spawn' → 'wezterm' + path_head = path_head + os.sep if path_head else path_head # add / back if it existed + executable_path = path_head + path_bin + + terminals = [WezTerm, ] + + inferred_terminal = next((term for term in terminals if path_bin == term.executable_name), None) + if inferred_terminal is None: + return BaseTerminal(None) # error out at the terminal editor since only those require terminal + else: + return inferred_terminal(executable_path) + + @classmethod + def infer_from_environment_variables(cls): + """ + Infer the terminal type and its executable path heuristically from environment variables. + """ + executable_path_with_arguments_maybe = TERMINAL + return cls.infer_terminal_from_path(executable_path_with_arguments_maybe) + + def __init__(self, executable): + self.executable = executable + + def get_args(self): + raise NotImplementedError() + + +class WezTerm(BaseTerminal): + executable_name = 'wezterm' + def get_args(self): + args = [ + self.executable, + "cli", + "spawn", + "--", + ] + return args + class Emacs(BaseEditor): executable_name = 'emacsclient' + is_terminal = False def visit_file(self, path, line, column): cmd = [ self.executable, @@ -150,6 +219,7 @@ class Emacs(BaseEditor): class PyCharm(BaseEditor): executable_name = 'charm' + is_terminal = False def visit_file(self, path, line, column): cmd = [self.executable, "--line", str(line), path] log_info(" ".join(cmd)) @@ -158,6 +228,7 @@ class PyCharm(BaseEditor): class Sublime(BaseEditor): executable_name = 'subl' + is_terminal = False def visit_file(self, path, line, column): cmd = [self.executable, "%s:%s:%s" % (path, line, column)] log_info(" ".join(cmd)) @@ -166,6 +237,7 @@ class Sublime(BaseEditor): class VSCode(BaseEditor): executable_name = 'code' + is_terminal = False def visit_file(self, path, line, column): cmd = [self.executable, "-g", "%s:%s:%s" % (path, line, column)] log_info(" ".join(cmd)) @@ -174,8 +246,12 @@ class VSCode(BaseEditor): class Vim(BaseEditor): executable_name = 'vim' - def visit_file(self, path, line, column): - cmd = [self.executable, "+%s" % str(line)] + is_terminal = True + def visit_file(self, path, line, column, terminal): + if terminal.executable is None: + log_error_terminal(terminal.TERMINAL) + cmd = terminal.get_args() + cmd.extend([self.executable, "+%s" % str(line)]) cmd.extend(["-c", "normal %sl" % str(column - 1), path] if column > 1 else [path]) log_info(" ".join(cmd)) subprocess.check_call(cmd) @@ -183,16 +259,24 @@ class Vim(BaseEditor): class Helix(BaseEditor): executable_name = 'hx' - def visit_file(self, path, line, column): - cmd = [self.executable, "%s:%s:%s" % (path, line, column)] + is_terminal = True + def visit_file(self, path, line, column, terminal): + if terminal.executable is None: + log_error_terminal(terminal.TERMINAL) + cmd = terminal.get_args() + cmd.extend([self.executable, "%s:%s:%s" % (path, line, column)]) log_info(" ".join(cmd)) subprocess.check_call(cmd) class O(BaseEditor): executable_name = 'o' - def visit_file(self, path, line, column): - cmd = [self.executable, path, "+%s" % str(line), "+%s" % str(column)] + is_terminal = True + def visit_file(self, path, line, column, terminal): + if terminal.executable is None: + log_error_terminal(terminal.TERMINAL) + cmd = terminal.get_args() + cmd.extend([self.executable, path, "+%s" % str(line), "+%s" % str(column)]) log_info(" ".join(cmd)) subprocess.check_call(cmd) diff --git a/README.md b/README.md index 71ab43a..059e180 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,22 @@ open-in-editor 'file-line-column:///a/b/myfile.txt:7:77' Download the `open-in-editor` file from this repo and make it executable. Ensure that one of the environment variables `OPEN_IN_EDITOR` or `EDITOR` contains a path to an executable that `open-in-editor` is going to recognize. This environment variable must be set system-wide, not just in your shell process. For example, in MacOS, one does this with `launchctl setenv EDITOR /path/to/my/editor/executable`. +To support terminal editors like `vim` ensure that environment variables `TERMINAL` is set to the path of the terminal executable that will open the editor. -`open-in-editor` looks for any of the following substrings in the path: `emacsclient` (emacs), `subl` (sublime), `charm` (pycharm), `code` (vscode), `vim` (vim) or `o` (o). For example, any of the following values would work: +`open-in-editor` looks for any of the following substrings in the editor path: `emacsclient` (emacs), `subl` (sublime), `charm` (pycharm), `code` (vscode), `vim` (vim) or `o` (o). For example, any of the following values would work: - `/usr/local/bin/emacsclient` - `/usr/local/bin/charm` - `/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl` +- `/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code` - `/usr/local/bin/code` - `/usr/bin/vim` - `/usr/local/bin/nvim` - `/usr/bin/o` -If your editor/IDE isn't supported, then please open an issue. If your editor/IDE is supported, but the above logic needs to be made more sophisticated, then either (a) open an issue, or (b) create a symlink that complies with the above rules. +...and for any of the following substrings in the terminal path: `wezterm` (WezTerm) + +If your editor/IDE/terminal isn't supported, then please open an issue or file a PR. If your editor/IDE is supported, but the above logic needs to be made more sophisticated, then either (a) open an issue, or (b) create a symlink that complies with the above rules. Next, you need to register `open-in-editor` with your OS to act as the handler for the URL schemes you are going to use: diff --git a/open-in-editor b/open-in-editor index 99c67b5..0b631fd 100755 --- a/open-in-editor +++ b/open-in-editor @@ -19,14 +19,20 @@ except ImportError: LOGFILE = "/tmp/open-in-editor.log" OPEN_IN_EDITOR = os.getenv("OPEN_IN_EDITOR") EDITOR = os.getenv("EDITOR") +TERMINAL = os.getenv("TERMINAL") def main(): try: editor = BaseEditor.infer_from_environment_variables() + terminal = BaseTerminal.infer_from_environment_variables() (url,) = sys.argv[1:] path, line, column = parse_url(url) log_info("path=%s line=%s column=%s" % (path, line, column)) - editor.visit_file(path, line or 1, column or 1) + if hasattr(editor, 'is_terminal') and\ + editor.is_terminal: + editor.visit_file(path, line or 1, column or 1, terminal) + else: + editor.visit_file(path, line or 1, column or 1) except Exception: from traceback import format_exc @@ -76,6 +82,16 @@ def log(line): log_info = log log_error = log +def log_error_terminal(TERMINAL): + log_error( + "ERROR: failed to infer your terminal. " + "The value of the relevant environment variable is: " + "TERMINAL=%s. " + "I was expecting one of these to contain one of the following substrings: " + "wezterm." + % (TERMINAL) + ) + sys.exit(1) class BaseEditor(object): """ @@ -126,8 +142,61 @@ class BaseEditor(object): raise NotImplementedError() +class BaseTerminal(object): + """ + Abstract base class for terminals. + """ + + @classmethod + def infer_terminal_from_path(cls, path): + """ + Infer the terminal type and its executable path heuristically + """ + # '/Apps/Wezterm - In.app/wezterm cli spawn -- ' + # ↓ up to last / = '/Apps/Wezterm - In.app' + path_head, path_tail = os.path.split(path) + # after the last / ↑ = 'wezterm cli spawn --' + path_bin = path_tail.split(' -')[0].strip().split(' ')[0] # remove ' -args' and ' args' → 'wezterm cli spawn' → 'wezterm' + path_head = path_head + os.sep if path_head else path_head # add / back if it existed + executable_path = path_head + path_bin + + terminals = [WezTerm, ] + + inferred_terminal = next((term for term in terminals if path_bin == term.executable_name), None) + if inferred_terminal is None: + return BaseTerminal(None) # error out at the terminal editor since only those require terminal + else: + return inferred_terminal(executable_path) + + @classmethod + def infer_from_environment_variables(cls): + """ + Infer the terminal type and its executable path heuristically from environment variables. + """ + executable_path_with_arguments_maybe = TERMINAL + return cls.infer_terminal_from_path(executable_path_with_arguments_maybe) + + def __init__(self, executable): + self.executable = executable + + def get_args(self): + raise NotImplementedError() + + +class WezTerm(BaseTerminal): + executable_name = 'wezterm' + def get_args(self): + args = [ + self.executable, + "cli", + "spawn", + "--", + ] + return args + class Emacs(BaseEditor): executable_name = 'emacsclient' + is_terminal = False def visit_file(self, path, line, column): cmd = [ self.executable, @@ -150,6 +219,7 @@ class Emacs(BaseEditor): class PyCharm(BaseEditor): executable_name = 'charm' + is_terminal = False def visit_file(self, path, line, column): cmd = [self.executable, "--line", str(line), path] log_info(" ".join(cmd)) @@ -158,6 +228,7 @@ class PyCharm(BaseEditor): class Sublime(BaseEditor): executable_name = 'subl' + is_terminal = False def visit_file(self, path, line, column): cmd = [self.executable, "%s:%s:%s" % (path, line, column)] log_info(" ".join(cmd)) @@ -166,6 +237,7 @@ class Sublime(BaseEditor): class VSCode(BaseEditor): executable_name = 'code' + is_terminal = False def visit_file(self, path, line, column): cmd = [self.executable, "-g", "%s:%s:%s" % (path, line, column)] log_info(" ".join(cmd)) @@ -174,8 +246,12 @@ class VSCode(BaseEditor): class Vim(BaseEditor): executable_name = 'vim' - def visit_file(self, path, line, column): - cmd = [self.executable, "+%s" % str(line)] + is_terminal = True + def visit_file(self, path, line, column, terminal): + if terminal.executable is None: + log_error_terminal(terminal.TERMINAL) + cmd = terminal.get_args() + cmd.extend([self.executable, "+%s" % str(line)]) cmd.extend(["-c", "normal %sl" % str(column - 1), path] if column > 1 else [path]) log_info(" ".join(cmd)) subprocess.check_call(cmd) @@ -183,16 +259,24 @@ class Vim(BaseEditor): class Helix(BaseEditor): executable_name = 'hx' - def visit_file(self, path, line, column): - cmd = [self.executable, "%s:%s:%s" % (path, line, column)] + is_terminal = True + def visit_file(self, path, line, column, terminal): + if terminal.executable is None: + log_error_terminal(terminal.TERMINAL) + cmd = terminal.get_args() + cmd.extend([self.executable, "%s:%s:%s" % (path, line, column)]) log_info(" ".join(cmd)) subprocess.check_call(cmd) class O(BaseEditor): executable_name = 'o' - def visit_file(self, path, line, column): - cmd = [self.executable, path, "+%s" % str(line), "+%s" % str(column)] + is_terminal = True + def visit_file(self, path, line, column, terminal): + if terminal.executable is None: + log_error_terminal(terminal.TERMINAL) + cmd = terminal.get_args() + cmd.extend([self.executable, path, "+%s" % str(line), "+%s" % str(column)]) log_info(" ".join(cmd)) subprocess.check_call(cmd) diff --git a/test/terminal_detection.py b/test/terminal_detection.py new file mode 100644 index 0000000..0e9f3bf --- /dev/null +++ b/test/terminal_detection.py @@ -0,0 +1,33 @@ +import os +import sys +import inspect +import importlib.util +from importlib.machinery import SourceFileLoader + +curdir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) +pardir = os.path.dirname(curdir) +sys.path.insert(0, pardir) + + +def import_from_file(module_name, file_path): + loader = SourceFileLoader(module_name, file_path) + spec = importlib.util.spec_from_file_location(module_name, loader=loader) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + return module + +open_in_editor = import_from_file('open-in-editor', 'open-in-editor') +term = open_in_editor.BaseTerminal + +def test_terminal_detection(): + test_paths = { + '/Apps/Wezterm - In.app/wezterm cli spawn -- ' :'wezterm', + '/Apps/Wezterm - In.app/wezterm cli spawn -- ' :'wezterm', + '/usr/local/bin/wezterm ' :'wezterm', + '/usr/local/bin/wezterm' :'wezterm', + '/usr/local/bin/wezterm cli spawn -- ' :'wezterm', + } + for path_,bin_ in test_paths.items(): + assert term.infer_terminal_from_path(path_).executable_name == bin_