hx-remote 0.1.2

Open files in new or existing Helix sessions through a tiny LSP bridge
Documentation
use clap::{ArgAction, Parser};
use hx_remote::{
    SOCKET_ENV, SocketRequest, absolute_path, default_sentinel_path, parse_target,
    resolve_socket_path, run_server, send_socket_request, socket_is_listening,
};
use std::ffi::{OsStr, OsString};
use std::fs::{self, OpenOptions};
use std::io::{self, Read};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};

const HELIX_CONFIG: &str = r#"[language-server.hx-remote]
command = "hxr"
args = ["--lsp"]

[[language]]
name = "hx-remote"
scope = "source.hx-remote"
file-types = ["hxremote"]
language-servers = ["hx-remote"]
"#;

#[derive(Debug, Parser)]
#[command(
    name = "hxr",
    version,
    about = "Open files in a new or existing Helix session",
    after_help = "Examples:\n  hxr README.md\n  hxr --listen\n  hxr --status\n  hxr --open src/main.rs Cargo.toml\n  hxr --open src/main.rs:50:12\n  hxr --stop\n  git diff | hxr --stdin-name changes.diff --open -"
)]
struct Cli {
    /// Launch Helix with the sentinel document that keeps the bridge alive.
    #[arg(long, conflicts_with_all = ["open", "status", "stop", "lsp", "print_config"])]
    listen: bool,

    /// Send the following path or paths to the listening Helix instance.
    #[arg(long, conflicts_with_all = ["listen", "status", "stop", "lsp", "print_config"])]
    open: bool,

    /// Report whether a Unix socket is open and listening.
    #[arg(long, conflicts_with_all = ["listen", "open", "stop", "lsp", "print_config"])]
    status: bool,

    /// Stop the server listening on the selected socket.
    #[arg(long, conflicts_with_all = ["listen", "open", "status", "lsp", "print_config"])]
    stop: bool,

    /// Force the server process to stop immediately.
    #[arg(long, requires = "stop")]
    force: bool,

    /// Print the one-time Helix languages.toml configuration.
    #[arg(long, conflicts_with_all = ["listen", "open", "status", "stop", "lsp"])]
    print_config: bool,

    /// Run as the LSP sidecar. This is invoked by Helix, not by users.
    #[arg(long, hide = true, conflicts_with_all = ["listen", "open", "status", "stop", "print_config"])]
    lsp: bool,

    /// Override the Unix socket path (or set HXR_SOCKET).
    #[arg(long, env = SOCKET_ENV, value_name = "PATH")]
    socket: Option<PathBuf>,

    /// Helix executable used when launching a new session.
    #[arg(long, default_value = "hx", value_name = "COMMAND")]
    helix: OsString,

    /// Override the sentinel file used when launching a new session.
    #[arg(long, value_name = "PATH")]
    sentinel: Option<PathBuf>,

    /// Pass an extra argument when launching Helix; may be repeated.
    #[arg(long, action = ArgAction::Append, allow_hyphen_values = true, value_name = "ARG")]
    helix_arg: Vec<OsString>,

    /// Filename used for piped stdin, which controls syntax detection.
    #[arg(long, default_value = "stdin.txt", value_name = "NAME")]
    stdin_name: String,

    /// Files to open. Without an action, launches Helix; with --open, uses the current session.
    /// Append :line[:column] to select a position; use - with --open for stdin.
    #[arg(value_name = "PATH")]
    paths: Vec<OsString>,
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(message) => {
            eprintln!("hxr: {message}");
            ExitCode::FAILURE
        }
    }
}

fn run() -> Result<(), String> {
    let cli = Cli::parse();
    let action_count = [
        cli.listen,
        cli.open,
        cli.status,
        cli.stop,
        cli.print_config,
        cli.lsp,
    ]
    .into_iter()
    .filter(|enabled| *enabled)
    .count();
    if action_count > 1 || (action_count == 0 && cli.paths.is_empty()) {
        return Err(
            "choose exactly one of --listen, --open, --status, --stop, or --print-config".into(),
        );
    }

    if cli.print_config {
        if !cli.paths.is_empty() {
            return Err("--print-config does not accept paths".into());
        }
        print!("{HELIX_CONFIG}");
        return Ok(());
    }

    let socket_path = resolve_socket_path(cli.socket.clone());

    if action_count == 0 {
        if cli
            .paths
            .iter()
            .any(|path| path.as_os_str() == OsStr::new("-"))
        {
            return Err("stdin (-) requires --open and a listening Helix instance".into());
        }
        return launch_helix(cli, socket_path);
    }

    if cli.status {
        if !cli.paths.is_empty() {
            return Err("--status does not accept paths".into());
        }
        return report_status(&socket_path);
    }

    if cli.stop {
        if !cli.paths.is_empty() {
            return Err("--stop does not accept paths".into());
        }
        let request = if cli.force {
            SocketRequest::ForceStop
        } else {
            SocketRequest::Stop
        };
        let message =
            send_socket_request(&socket_path, &request).map_err(|error| error.to_string())?;
        println!("{message}");
        return Ok(());
    }

    if cli.lsp {
        if !cli.paths.is_empty() {
            return Err("--lsp does not accept paths".into());
        }
        return run_server(socket_path).map_err(|error| error.to_string());
    }

    if cli.listen {
        if !cli.paths.is_empty() {
            return Err("--listen does not accept paths; use --helix-arg for Helix options".into());
        }
        return launch_helix(cli, socket_path);
    }

    if cli.paths.is_empty() {
        return Err("--open requires at least one path (or - for stdin)".into());
    }
    open_paths(cli.paths, &cli.stdin_name, &socket_path)
}

fn report_status(socket_path: &Path) -> Result<(), String> {
    if socket_is_listening(socket_path).map_err(|error| error.to_string())? {
        println!("listening on {}", socket_path.display());
        Ok(())
    } else {
        Err(format!(
            "no socket is listening on {}",
            socket_path.display()
        ))
    }
}

fn launch_helix(cli: Cli, socket_path: PathBuf) -> Result<(), String> {
    let sentinel = cli.sentinel.unwrap_or_else(default_sentinel_path);
    if let Some(parent) = sentinel.parent() {
        fs::create_dir_all(parent).map_err(|error| {
            format!(
                "cannot create sentinel directory {}: {error}",
                parent.display()
            )
        })?;
    }
    OpenOptions::new()
        .create(true)
        .append(true)
        .open(&sentinel)
        .map_err(|error| format!("cannot create sentinel {}: {error}", sentinel.display()))?;

    let uses_split_layout = cli
        .helix_arg
        .iter()
        .any(|argument| argument == OsStr::new("--vsplit") || argument == OsStr::new("--hsplit"));
    let mut command = Command::new(&cli.helix);
    command.args(&cli.helix_arg);
    if cli.paths.is_empty() {
        command.arg(&sentinel);
    } else {
        // Keep Helix from interpreting a filename that starts with `-` as an option.
        command.arg("--");
        if uses_split_layout {
            // Split layouts focus the last startup file, so retain the original
            // sentinel-first order and leave a requested file focused.
            command.arg(&sentinel).args(&cli.paths);
        } else {
            // The normal layout keeps its first startup file focused. The sentinel
            // can come last: its language server still starts in the background.
            command.args(&cli.paths).arg(&sentinel);
        }
    }
    let error = command.env(SOCKET_ENV, &socket_path).exec();
    Err(format!(
        "cannot launch {}: {error}",
        cli.helix.to_string_lossy()
    ))
}

fn open_paths(paths: Vec<OsString>, stdin_name: &str, socket_path: &Path) -> Result<(), String> {
    let stdin_count = paths
        .iter()
        .filter(|path| path.as_os_str() == OsStr::new("-"))
        .count();
    if stdin_count > 1 {
        return Err("stdin (-) can only be opened once per invocation".into());
    }

    for input in paths {
        let request = if input.as_os_str() == OsStr::new("-") {
            let mut contents = String::new();
            io::stdin()
                .read_to_string(&mut contents)
                .map_err(|error| format!("cannot read stdin as UTF-8 text: {error}"))?;
            SocketRequest::OpenStdin {
                contents,
                name: stdin_name.to_owned(),
            }
        } else {
            let target = parse_target(&input)?;
            let path = absolute_path(&target.path)
                .map_err(|error| format!("cannot resolve {}: {error}", target.path.display()))?;
            SocketRequest::Open {
                path,
                line: target.line,
                column: target.column,
            }
        };

        let message =
            send_socket_request(socket_path, &request).map_err(|error| error.to_string())?;
        println!("{message}");
    }
    Ok(())
}