edtr 0.1.5

An editor router/transfer tool that dispatches files to the right editor based on rules.
//! Neovim backend.
//!
//! Dispatch matrix (for v0.1):
//!
//! | mode    | sync  | behavior                                                     |
//! |---------|-------|--------------------------------------------------------------|
//! | remote  | false | connect to listen pipe; on fail, spawn detached with --listen |
//! | new     | false | always spawn detached (no --listen)                           |
//! | new     | true  | spawn as child, wait for exit, propagate exit code            |
//! | remote  | true  | v0.1 falls back to `new + true` with a warning                |
//!
//! remote+sync via `nvim_buf_attach` is queued for v0.2.

use std::path::{Path, PathBuf};
use std::process::{Command as StdCommand, Stdio};

use anyhow::{Context as _, Result, anyhow};
use nvim_rs::{Handler, compat::tokio::Compat, create::tokio as create};
use tokio::io::WriteHalf;
use tracing::{debug, info, warn};

use crate::config::Mode;
use crate::matcher::vim_path as vim_path_fn;
use crate::platform;

#[cfg(unix)]
type NvimConnection = tokio::net::UnixStream;
#[cfg(windows)]
type NvimConnection = tokio::net::windows::named_pipe::NamedPipeClient;

type NvimWriter = Compat<WriteHalf<NvimConnection>>;

#[derive(Clone)]
struct DummyHandler;

impl Handler for DummyHandler {
    type Writer = NvimWriter;
}

#[derive(Debug, Clone)]
pub struct NeovimBackend {
    pub command: String,
    pub listen: String,
    /// Extra args inserted before `--listen` when spawning for remote-mode
    /// fallback. Needed for wrappers like `neovide` which only forward args to
    /// nvim when they come after `--`. Default for direct `nvim`: empty.
    pub args_remote: Vec<String>,
    /// Extra args inserted before the files when spawning in `new` mode (no
    /// `--listen`). Useful for e.g. `neovide --no-fork` in sync scenarios.
    pub args_new: Vec<String>,
}

impl NeovimBackend {
    pub async fn dispatch(&self, files: &[PathBuf], mode: Mode, sync: bool) -> Result<()> {
        if files.is_empty() {
            return Ok(());
        }

        match (mode, sync) {
            (Mode::Remote, false) => self.dispatch_remote(files).await,
            (Mode::New, false) => self.spawn_detached_fresh(files),
            (Mode::New, true) => self.spawn_sync(files),
            (Mode::Remote, true) => {
                warn!("neovim remote+sync is not implemented yet (v0.2); falling back to new+sync");
                self.spawn_sync(files)
            }
        }
    }

    /// Try to connect to the listen pipe; on success, send `:edit <file>` for
    /// each file. On connect failure, spawn a detached nvim with `--listen` so
    /// the next remote dispatch finds it.
    async fn dispatch_remote(&self, files: &[PathBuf]) -> Result<()> {
        match create::new_path(self.listen.as_str(), DummyHandler).await {
            Ok((nvim, _io_handle)) => {
                info!(pipe = %self.listen, count = files.len(), "connected to existing nvim");
                for f in files {
                    let vim_cmd = format!("edit {}", vim_path(f));
                    debug!(cmd = %vim_cmd, "sending RPC");
                    nvim.command(&vim_cmd)
                        .await
                        .with_context(|| format!("failed to send :{vim_cmd}"))?;
                }
                Ok(())
            }
            Err(e) => {
                info!(pipe = %self.listen, reason = %e, "no listener; spawning detached nvim");
                self.spawn_detached_with_listen(files)
            }
        }
    }

    /// Argv layout: `command FILES... <args_remote>... --listen LISTEN`.
    ///
    /// Files come first so wrappers like `neovide` recognize them as
    /// `FILES_TO_OPEN` (neovide's help: positional args before `--` are
    /// forwarded to nvim as files). `args_remote` then carries the `--`
    /// separator that lets `--listen` reach the embedded nvim. For plain
    /// `nvim` (empty `args_remote`) this collapses to
    /// `nvim FILE --listen PIPE`, which nvim accepts.
    fn spawn_detached_with_listen(&self, files: &[PathBuf]) -> Result<()> {
        let mut cmd = StdCommand::new(&self.command);
        for f in files {
            cmd.arg(f);
        }
        for a in &self.args_remote {
            cmd.arg(a);
        }
        cmd.arg("--listen").arg(&self.listen);
        platform::spawn_detached(
            &mut cmd,
            files.first().map(PathBuf::as_path).unwrap_or(Path::new("")),
        )
        .with_context(|| format!("failed to spawn {}", self.command))?;
        Ok(())
    }

    fn spawn_detached_fresh(&self, files: &[PathBuf]) -> Result<()> {
        let mut cmd = StdCommand::new(&self.command);
        for a in &self.args_new {
            cmd.arg(a);
        }
        for f in files {
            cmd.arg(f);
        }
        platform::spawn_detached(
            &mut cmd,
            files.first().map(PathBuf::as_path).unwrap_or(Path::new("")),
        )
        .with_context(|| format!("failed to spawn {}", self.command))?;
        Ok(())
    }

    fn spawn_sync(&self, files: &[PathBuf]) -> Result<()> {
        let mut cmd = StdCommand::new(&self.command);
        for a in &self.args_new {
            cmd.arg(a);
        }
        for f in files {
            cmd.arg(f);
        }
        // inherit stdio so nvim can draw to the parent terminal (this is the
        // $EDITOR=edtr use case: git invokes edtr with a TTY attached, and
        // nvim must take over that TTY).
        cmd.stdin(Stdio::inherit())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit());

        let status = cmd
            .status()
            .with_context(|| format!("failed to run {}", self.command))?;

        if status.success() {
            Ok(())
        } else {
            Err(anyhow!("{} exited with status {}", self.command, status))
        }
    }
}

/// Path form that nvim `:edit` reliably accepts across platforms.
/// Delegates to [`matcher::vim_path`] which keeps UNC backslashes intact.
fn vim_path(p: &Path) -> String {
    vim_path_fn(p)
}