Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add terminal support for terminal editors #22

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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_