Skip to content

Commit

Permalink
feat: add terminal support for terminal editors
Browse files Browse the repository at this point in the history
- Get the terminal path from TERMINAL environment var
- Wrap the editor command in a terminal command that is specific to that terminal (currently only WezTerm) but only for terminal editors
- Add path parsing test
- Update ReadMe
  • Loading branch information
eugenesvk committed Mar 17, 2023
1 parent 973f482 commit 5249db7
Show file tree
Hide file tree
Showing 4 changed files with 221 additions and 16 deletions.
98 changes: 91 additions & 7 deletions OpenInEditor.app/Contents/Resources/script
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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,
Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -174,25 +246,37 @@ 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)


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)

Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
98 changes: 91 additions & 7 deletions open-in-editor
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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,
Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -174,25 +246,37 @@ 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)


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)

Expand Down
33 changes: 33 additions & 0 deletions test/terminal_detection.py
Original file line number Diff line number Diff line change
@@ -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_

0 comments on commit 5249db7

Please sign in to comment.