Skip to content

Commit d9036b5

Browse files
committed
add tap-editor crate for multi-editor support
Supports line/column positioning for: - vim/nvim/vi: +{line} - cursor/code: -g file:line:col - nano: +line,col - emacs: +line:col - helix: file:line
1 parent f4ebefd commit d9036b5

6 files changed

Lines changed: 207 additions & 16 deletions

File tree

Cargo.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/tap-editor/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[package]
2+
name = "tap-editor"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
authors.workspace = true
7+
8+
[dependencies]

crates/tap-editor/src/lib.rs

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
//! Editor integration utilities.
2+
//!
3+
//! Handles different editor command-line argument formats for opening files
4+
//! at specific line/column positions.
5+
6+
use std::path::Path;
7+
8+
/// Known editor types with their argument formats.
9+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10+
pub enum EditorKind {
11+
/// vim, nvim, vi: `+{line}` before file
12+
Vim,
13+
/// VSCode, Cursor: `-g {file}:{line}:{col}`
14+
VsCode,
15+
/// nano: `+{line},{col}` before file
16+
Nano,
17+
/// emacs: `+{line}:{col}` before file
18+
Emacs,
19+
/// helix: `{file}:{line}`
20+
Helix,
21+
/// Unknown editor, no line number support
22+
Unknown,
23+
}
24+
25+
impl EditorKind {
26+
/// Detect editor kind from command name or path.
27+
pub fn detect(cmd: &str) -> Self {
28+
let name = Path::new(cmd)
29+
.file_name()
30+
.and_then(|s| s.to_str())
31+
.unwrap_or(cmd);
32+
33+
match name {
34+
"vim" | "nvim" | "vi" | "view" | "vimdiff" => Self::Vim,
35+
"code" | "cursor" | "code-insiders" | "codium" | "vscodium" => Self::VsCode,
36+
"nano" | "pico" => Self::Nano,
37+
"emacs" | "emacsclient" => Self::Emacs,
38+
"hx" | "helix" => Self::Helix,
39+
_ => Self::Unknown,
40+
}
41+
}
42+
}
43+
44+
/// Position in a file.
45+
#[derive(Debug, Clone, Copy, Default)]
46+
pub struct Position {
47+
/// 1-indexed line number
48+
pub line: usize,
49+
/// 1-indexed column number (optional)
50+
pub col: Option<usize>,
51+
}
52+
53+
impl Position {
54+
pub fn new(line: usize, col: Option<usize>) -> Self {
55+
Self { line, col }
56+
}
57+
58+
pub fn line(line: usize) -> Self {
59+
Self { line, col: None }
60+
}
61+
}
62+
63+
/// Build command arguments for opening a file at a position.
64+
///
65+
/// Returns (args_before_file, file_arg) where:
66+
/// - `args_before_file`: arguments to add before the file path
67+
/// - `file_arg`: the file argument (may include line number for some editors)
68+
pub fn build_editor_args(
69+
editor_cmd: &str,
70+
file_path: &Path,
71+
pos: Option<Position>,
72+
) -> (Vec<String>, String) {
73+
let kind = EditorKind::detect(editor_cmd);
74+
let file_str = file_path.display().to_string();
75+
76+
let Some(pos) = pos else {
77+
return (vec![], file_str);
78+
};
79+
80+
match kind {
81+
EditorKind::Vim => {
82+
// vim +42 file.txt
83+
(vec![format!("+{}", pos.line)], file_str)
84+
}
85+
EditorKind::VsCode => {
86+
// code -g file.txt:42:10
87+
let col = pos.col.unwrap_or(1);
88+
(vec!["-g".to_string()], format!("{file_str}:{}:{col}", pos.line))
89+
}
90+
EditorKind::Nano => {
91+
// nano +42,10 file.txt
92+
let arg = match pos.col {
93+
Some(col) => format!("+{},{col}", pos.line),
94+
None => format!("+{}", pos.line),
95+
};
96+
(vec![arg], file_str)
97+
}
98+
EditorKind::Emacs => {
99+
// emacs +42:10 file.txt
100+
let arg = match pos.col {
101+
Some(col) => format!("+{}:{col}", pos.line),
102+
None => format!("+{}", pos.line),
103+
};
104+
(vec![arg], file_str)
105+
}
106+
EditorKind::Helix => {
107+
// hx file.txt:42
108+
(vec![], format!("{file_str}:{}", pos.line))
109+
}
110+
EditorKind::Unknown => (vec![], file_str),
111+
}
112+
}
113+
114+
#[cfg(test)]
115+
mod tests {
116+
use super::*;
117+
118+
#[test]
119+
fn test_detect_vim() {
120+
assert_eq!(EditorKind::detect("vim"), EditorKind::Vim);
121+
assert_eq!(EditorKind::detect("nvim"), EditorKind::Vim);
122+
assert_eq!(EditorKind::detect("/usr/bin/vim"), EditorKind::Vim);
123+
assert_eq!(EditorKind::detect("/opt/homebrew/bin/nvim"), EditorKind::Vim);
124+
}
125+
126+
#[test]
127+
fn test_detect_vscode() {
128+
assert_eq!(EditorKind::detect("code"), EditorKind::VsCode);
129+
assert_eq!(EditorKind::detect("cursor"), EditorKind::VsCode);
130+
assert_eq!(EditorKind::detect("/usr/local/bin/code"), EditorKind::VsCode);
131+
}
132+
133+
#[test]
134+
fn test_detect_others() {
135+
assert_eq!(EditorKind::detect("nano"), EditorKind::Nano);
136+
assert_eq!(EditorKind::detect("emacs"), EditorKind::Emacs);
137+
assert_eq!(EditorKind::detect("hx"), EditorKind::Helix);
138+
assert_eq!(EditorKind::detect("unknown-editor"), EditorKind::Unknown);
139+
}
140+
141+
#[test]
142+
fn test_vim_args() {
143+
let (args, file) = build_editor_args("vim", Path::new("/tmp/test.txt"), Some(Position::line(42)));
144+
assert_eq!(args, vec!["+42"]);
145+
assert_eq!(file, "/tmp/test.txt");
146+
}
147+
148+
#[test]
149+
fn test_vscode_args() {
150+
let (args, file) = build_editor_args(
151+
"cursor",
152+
Path::new("/tmp/test.txt"),
153+
Some(Position::new(42, Some(10))),
154+
);
155+
assert_eq!(args, vec!["-g"]);
156+
assert_eq!(file, "/tmp/test.txt:42:10");
157+
}
158+
159+
#[test]
160+
fn test_helix_args() {
161+
let (args, file) = build_editor_args("hx", Path::new("/tmp/test.txt"), Some(Position::line(42)));
162+
assert!(args.is_empty());
163+
assert_eq!(file, "/tmp/test.txt:42");
164+
}
165+
166+
#[test]
167+
fn test_nano_args() {
168+
let (args, file) = build_editor_args(
169+
"nano",
170+
Path::new("/tmp/test.txt"),
171+
Some(Position::new(42, Some(5))),
172+
);
173+
assert_eq!(args, vec!["+42,5"]);
174+
assert_eq!(file, "/tmp/test.txt");
175+
}
176+
177+
#[test]
178+
fn test_no_position() {
179+
let (args, file) = build_editor_args("vim", Path::new("/tmp/test.txt"), None);
180+
assert!(args.is_empty());
181+
assert_eq!(file, "/tmp/test.txt");
182+
}
183+
}

crates/tap-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ vt100.workspace = true
2626
eyre.workspace = true
2727
tempfile.workspace = true
2828
crossterm.workspace = true
29+
tap-editor = { version = "0.1.0", path = "../tap-editor" }

crates/tap-server/src/editor.rs

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@ use std::os::fd::BorrowedFd;
44

55
use eyre::WrapErr as _;
66
use std::io::Write as _;
7+
use tap_editor::Position;
78

89
/// Open scrollback content in the configured editor.
910
/// This function temporarily restores the terminal to cooked mode.
1011
///
11-
/// If `cursor_line` is provided, the editor will open at that line number.
12+
/// If `cursor_pos` is provided, the editor will open at that position.
1213
pub fn open_scrollback_in_editor(
1314
scrollback_content: &str,
1415
editor_cmd: &str,
1516
orig_termios: Option<&nix::sys::termios::Termios>,
16-
cursor_line: Option<usize>,
17+
cursor_pos: Option<Position>,
1718
) -> eyre::Result<()> {
1819
// Create temp file with scrollback content
1920
let mut temp_file = tempfile::NamedTempFile::new()
@@ -38,21 +39,14 @@ pub fn open_scrollback_in_editor(
3839
.split_first()
3940
.ok_or_else(|| eyre::eyre!("empty editor command — set $EDITOR or configure tap"))?;
4041

42+
// Build editor arguments with position support
43+
let (pos_args, file_arg) = tap_editor::build_editor_args(cmd, &temp_path, cursor_pos);
44+
4145
let mut command = std::process::Command::new(cmd);
4246
command.args(args.iter().copied());
47+
command.args(pos_args);
48+
command.arg(&file_arg);
4349

44-
// Add line number argument for vim/nvim (uses +{line} syntax)
45-
if let Some(line) = cursor_line {
46-
let cmd_name = std::path::Path::new(cmd)
47-
.file_name()
48-
.and_then(|s| s.to_str())
49-
.unwrap_or(cmd);
50-
if matches!(cmd_name, "vim" | "nvim" | "vi") {
51-
command.arg(format!("+{line}"));
52-
}
53-
}
54-
55-
command.arg(&temp_path);
5650
let status = command
5751
.status()
5852
.wrap_err_with(|| format!("failed to spawn editor '{cmd}'"))?;

crates/tap-server/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ pub async fn run(config: ServerConfig) -> eyre::Result<i32> {
456456
tracing::debug!("OpenEditor action triggered!");
457457
let scrollback = SCROLLBACK.read();
458458
let scrollback_content = scrollback.get_lines(None);
459-
let (cursor_row, _cursor_col) = scrollback.cursor_position();
459+
let (cursor_row, cursor_col) = scrollback.cursor_position();
460460

461461
// Calculate line number in scrollback content
462462
// cursor_row is relative to viewport, so we add scrollback lines
@@ -471,7 +471,7 @@ pub async fn run(config: ServerConfig) -> eyre::Result<i32> {
471471
&scrollback_content,
472472
&editor_cmd,
473473
orig_termios.as_ref(),
474-
Some(cursor_line),
474+
Some(tap_editor::Position::new(cursor_line, Some(cursor_col + 1))),
475475
) {
476476
tracing::error!("failed to open editor: {e}");
477477
}

0 commit comments

Comments
 (0)