From 68ee87695b371cf14a191c57684d687afe38eb4a Mon Sep 17 00:00:00 2001
From: Alfie Richards <alfierchrds@gmail.com>
Date: Wed, 20 Nov 2024 22:06:23 +0000
Subject: [PATCH] Add clipboard provider configuration (#10839)

---
 book/src/editor.md               |  24 +
 helix-term/src/commands/typed.rs |   2 +-
 helix-term/src/health.rs         |  19 +-
 helix-view/src/clipboard.rs      | 792 +++++++++++++++++--------------
 helix-view/src/editor.rs         |   9 +-
 helix-view/src/register.rs       |  39 +-
 6 files changed, 507 insertions(+), 378 deletions(-)

diff --git a/book/src/editor.md b/book/src/editor.md
index 82d5f846..3edc38fc 100644
--- a/book/src/editor.md
+++ b/book/src/editor.md
@@ -52,6 +52,30 @@
 | `indent-heuristic` | How the indentation for a newly inserted line is computed: `simple` just copies the indentation level from the previous line, `tree-sitter` computes the indentation based on the syntax tree and `hybrid` combines both approaches. If the chosen heuristic is not available, a different one will be used as a fallback (the fallback order being `hybrid` -> `tree-sitter` -> `simple`). | `hybrid`
 | `jump-label-alphabet` | The characters that are used to generate two character jump labels. Characters at the start of the alphabet are used first. | `"abcdefghijklmnopqrstuvwxyz"`
 | `end-of-line-diagnostics` | Minimum severity of diagnostics to render at the end of the line. Set to `disable` to disable entirely. Refer to the setting about `inline-diagnostics` for more details | "disable"
+| `clipboard-provider` | Which API to use for clipboard interaction. One of `pasteboard` (MacOS), `wayland`, `x-clip`, `x-sel`, `win-32-yank`, `termux`, `tmux`, `windows`, `termcode`, `none`, or a custom command set. | Platform and environment specific. |
+
+### `[editor.clipboard-provider]` Section
+
+Helix can be configured wither to use a builtin clipboard configuration or to use
+a provided command.
+
+For instance, setting it to use OSC 52 termcodes, the configuration would be:
+```toml
+[editor]
+clipboard-provider = "termcode"
+```
+
+Alternatively, Helix can be configured to use arbitary commands for clipboard integration:
+
+```toml
+[editor.clipboard-provider.custom]
+yank = { command = "cat",  args = ["test.txt"] }
+paste = { command = "tee",  args = ["test.txt"] }
+primary-yank = { command = "cat",  args = ["test-primary.txt"] } # optional
+primary-paste = { command = "tee",  args = ["test-primary.txt"] } # optional
+```
+
+For custom commands the contents of the yank/paste is communicated over stdin/stdout.
 
 ### `[editor.statusline]` Section
 
diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs
index 68ba9bab..7402a06f 100644
--- a/helix-term/src/commands/typed.rs
+++ b/helix-term/src/commands/typed.rs
@@ -1074,7 +1074,7 @@ fn show_clipboard_provider(
     }
 
     cx.editor
-        .set_status(cx.editor.registers.clipboard_provider_name().to_string());
+        .set_status(cx.editor.registers.clipboard_provider_name());
     Ok(())
 }
 
diff --git a/helix-term/src/health.rs b/helix-term/src/health.rs
index 0bbb5735..e59fd74d 100644
--- a/helix-term/src/health.rs
+++ b/helix-term/src/health.rs
@@ -1,10 +1,10 @@
+use crate::config::{Config, ConfigLoadError};
 use crossterm::{
     style::{Color, Print, Stylize},
     tty::IsTty,
 };
 use helix_core::config::{default_lang_config, user_lang_config};
 use helix_loader::grammar::load_runtime_file;
-use helix_view::clipboard::get_clipboard_provider;
 use std::io::Write;
 
 #[derive(Copy, Clone)]
@@ -53,7 +53,6 @@ pub fn general() -> std::io::Result<()> {
     let lang_file = helix_loader::lang_config_file();
     let log_file = helix_loader::log_file();
     let rt_dirs = helix_loader::runtime_dirs();
-    let clipboard_provider = get_clipboard_provider();
 
     if config_file.exists() {
         writeln!(stdout, "Config file: {}", config_file.display())?;
@@ -92,7 +91,6 @@ pub fn general() -> std::io::Result<()> {
             writeln!(stdout, "{}", msg.yellow())?;
         }
     }
-    writeln!(stdout, "Clipboard provider: {}", clipboard_provider.name())?;
 
     Ok(())
 }
@@ -101,8 +99,19 @@ pub fn clipboard() -> std::io::Result<()> {
     let stdout = std::io::stdout();
     let mut stdout = stdout.lock();
 
-    let board = get_clipboard_provider();
-    match board.name().as_ref() {
+    let config = match Config::load_default() {
+        Ok(config) => config,
+        Err(ConfigLoadError::Error(err)) if err.kind() == std::io::ErrorKind::NotFound => {
+            Config::default()
+        }
+        Err(err) => {
+            writeln!(stdout, "{}", "Configuration file malformed".red())?;
+            writeln!(stdout, "{}", err)?;
+            return Ok(());
+        }
+    };
+
+    match config.editor.clipboard_provider.name().as_ref() {
         "none" => {
             writeln!(
                 stdout,
diff --git a/helix-view/src/clipboard.rs b/helix-view/src/clipboard.rs
index 379accc7..234b91fe 100644
--- a/helix-view/src/clipboard.rs
+++ b/helix-view/src/clipboard.rs
@@ -1,164 +1,408 @@
 // Implementation reference: https://github.com/neovim/neovim/blob/f2906a4669a2eef6d7bf86a29648793d63c98949/runtime/autoload/provider/clipboard.vim#L68-L152
 
-use anyhow::Result;
+use serde::{Deserialize, Serialize};
 use std::borrow::Cow;
+use thiserror::Error;
 
-#[derive(Clone, Copy, Debug)]
+#[derive(Clone, Copy)]
 pub enum ClipboardType {
     Clipboard,
     Selection,
 }
 
-pub trait ClipboardProvider: std::fmt::Debug {
-    fn name(&self) -> Cow<str>;
-    fn get_contents(&self, clipboard_type: ClipboardType) -> Result<String>;
-    fn set_contents(&mut self, contents: String, clipboard_type: ClipboardType) -> Result<()>;
+#[derive(Debug, Error)]
+pub enum ClipboardError {
+    #[error(transparent)]
+    IoError(#[from] std::io::Error),
+    #[error("could not convert terminal output to UTF-8: {0}")]
+    FromUtf8Error(#[from] std::string::FromUtf8Error),
+    #[cfg(windows)]
+    #[error("Windows API error: {0}")]
+    WinAPI(#[from] clipboard_win::ErrorCode),
+    #[error("clipboard provider command failed")]
+    CommandFailed,
+    #[error("failed to write to clipboard provider's stdin")]
+    StdinWriteFailed,
+    #[error("clipboard provider did not return any contents")]
+    MissingStdout,
+    #[error("This clipboard provider does not support reading")]
+    ReadingNotSupported,
 }
 
-#[cfg(not(windows))]
-macro_rules! command_provider {
-    (paste => $get_prg:literal $( , $get_arg:literal )* ; copy => $set_prg:literal $( , $set_arg:literal )* ; ) => {{
-        log::debug!(
-            "Using {} to interact with the system clipboard",
-            if $set_prg != $get_prg { format!("{}+{}", $set_prg, $get_prg)} else { $set_prg.to_string() }
-        );
-        Box::new(provider::command::Provider {
-            get_cmd: provider::command::Config {
-                prg: $get_prg,
-                args: &[ $( $get_arg ),* ],
-            },
-            set_cmd: provider::command::Config {
-                prg: $set_prg,
-                args: &[ $( $set_arg ),* ],
-            },
-            get_primary_cmd: None,
-            set_primary_cmd: None,
-        })
-    }};
-
-    (paste => $get_prg:literal $( , $get_arg:literal )* ;
-     copy => $set_prg:literal $( , $set_arg:literal )* ;
-     primary_paste => $pr_get_prg:literal $( , $pr_get_arg:literal )* ;
-     primary_copy => $pr_set_prg:literal $( , $pr_set_arg:literal )* ;
-    ) => {{
-        log::debug!(
-            "Using {} to interact with the system and selection (primary) clipboard",
-            if $set_prg != $get_prg { format!("{}+{}", $set_prg, $get_prg)} else { $set_prg.to_string() }
-        );
-        Box::new(provider::command::Provider {
-            get_cmd: provider::command::Config {
-                prg: $get_prg,
-                args: &[ $( $get_arg ),* ],
-            },
-            set_cmd: provider::command::Config {
-                prg: $set_prg,
-                args: &[ $( $set_arg ),* ],
-            },
-            get_primary_cmd: Some(provider::command::Config {
-                prg: $pr_get_prg,
-                args: &[ $( $pr_get_arg ),* ],
-            }),
-            set_primary_cmd: Some(provider::command::Config {
-                prg: $pr_set_prg,
-                args: &[ $( $pr_set_arg ),* ],
-            }),
-        })
-    }};
-}
-
-#[cfg(windows)]
-pub fn get_clipboard_provider() -> Box<dyn ClipboardProvider> {
-    Box::<provider::WindowsProvider>::default()
-}
-
-#[cfg(target_os = "macos")]
-pub fn get_clipboard_provider() -> Box<dyn ClipboardProvider> {
-    use helix_stdx::env::{binary_exists, env_var_is_set};
-
-    if env_var_is_set("TMUX") && binary_exists("tmux") {
-        command_provider! {
-            paste => "tmux", "save-buffer", "-";
-            copy => "tmux", "load-buffer", "-w", "-";
-        }
-    } else if binary_exists("pbcopy") && binary_exists("pbpaste") {
-        command_provider! {
-            paste => "pbpaste";
-            copy => "pbcopy";
-        }
-    } else {
-        Box::new(provider::FallbackProvider::new())
-    }
-}
+type Result<T> = std::result::Result<T, ClipboardError>;
 
+#[cfg(not(target_arch = "wasm32"))]
+pub use external::ClipboardProvider;
 #[cfg(target_arch = "wasm32")]
-pub fn get_clipboard_provider() -> Box<dyn ClipboardProvider> {
-    // TODO:
-    Box::new(provider::FallbackProvider::new())
-}
+pub use noop::ClipboardProvider;
 
-#[cfg(not(any(windows, target_arch = "wasm32", target_os = "macos")))]
-pub fn get_clipboard_provider() -> Box<dyn ClipboardProvider> {
-    use helix_stdx::env::{binary_exists, env_var_is_set};
-    use provider::command::is_exit_success;
-    // TODO: support for user-defined provider, probably when we have plugin support by setting a
-    // variable?
+// Clipboard not supported for wasm
+#[cfg(target_arch = "wasm32")]
+mod noop {
+    use super::*;
 
-    if env_var_is_set("WAYLAND_DISPLAY") && binary_exists("wl-copy") && binary_exists("wl-paste") {
-        command_provider! {
-            paste => "wl-paste", "--no-newline";
-            copy => "wl-copy", "--type", "text/plain";
-            primary_paste => "wl-paste", "-p", "--no-newline";
-            primary_copy => "wl-copy", "-p", "--type", "text/plain";
+    #[derive(Debug, Clone)]
+    pub enum ClipboardProvider {}
+
+    impl ClipboardProvider {
+        pub fn detect() -> Self {
+            Self
         }
-    } else if env_var_is_set("DISPLAY") && binary_exists("xclip") {
-        command_provider! {
-            paste => "xclip", "-o", "-selection", "clipboard";
-            copy => "xclip", "-i", "-selection", "clipboard";
-            primary_paste => "xclip", "-o";
-            primary_copy => "xclip", "-i";
+
+        pub fn name(&self) -> Cow<str> {
+            "none".into()
         }
-    } else if env_var_is_set("DISPLAY")
-        && binary_exists("xsel")
-        && is_exit_success("xsel", &["-o", "-b"])
-    {
-        // FIXME: check performance of is_exit_success
-        command_provider! {
-            paste => "xsel", "-o", "-b";
-            copy => "xsel", "-i", "-b";
-            primary_paste => "xsel", "-o";
-            primary_copy => "xsel", "-i";
+
+        pub fn get_contents(&self, _clipboard_type: ClipboardType) -> Result<String> {
+            Err(ClipboardError::ReadingNotSupported)
         }
-    } else if binary_exists("win32yank.exe") {
-        command_provider! {
-            paste => "win32yank.exe", "-o", "--lf";
-            copy => "win32yank.exe", "-i", "--crlf";
+
+        pub fn set_contents(&self, _content: &str, _clipboard_type: ClipboardType) -> Result<()> {
+            Ok(())
         }
-    } else if binary_exists("termux-clipboard-set") && binary_exists("termux-clipboard-get") {
-        command_provider! {
-            paste => "termux-clipboard-get";
-            copy => "termux-clipboard-set";
-        }
-    } else if env_var_is_set("TMUX") && binary_exists("tmux") {
-        command_provider! {
-            paste => "tmux", "save-buffer", "-";
-            copy => "tmux", "load-buffer", "-w", "-";
-        }
-    } else {
-        Box::new(provider::FallbackProvider::new())
     }
 }
 
-#[cfg(not(target_os = "windows"))]
-pub mod provider {
-    use super::{ClipboardProvider, ClipboardType};
-    use anyhow::Result;
-    use std::borrow::Cow;
+#[cfg(not(target_arch = "wasm32"))]
+mod external {
+    use super::*;
+
+    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+    pub struct Command {
+        command: Cow<'static, str>,
+        #[serde(default)]
+        args: Cow<'static, [Cow<'static, str>]>,
+    }
+
+    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+    #[serde(rename_all = "kebab-case")]
+    pub struct CommandProvider {
+        yank: Command,
+        paste: Command,
+        yank_primary: Option<Command>,
+        paste_primary: Option<Command>,
+    }
+
+    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+    #[serde(rename_all = "kebab-case")]
+    #[allow(clippy::large_enum_variant)]
+    pub enum ClipboardProvider {
+        Pasteboard,
+        Wayland,
+        XClip,
+        XSel,
+        Win32Yank,
+        Tmux,
+        #[cfg(windows)]
+        Windows,
+        Termux,
+        #[cfg(feature = "term")]
+        Termcode,
+        Custom(CommandProvider),
+        None,
+    }
+
+    impl Default for ClipboardProvider {
+        #[cfg(windows)]
+        fn default() -> Self {
+            use helix_stdx::env::binary_exists;
+
+            if binary_exists("win32yank.exe") {
+                Self::Win32Yank
+            } else {
+                Self::Windows
+            }
+        }
+
+        #[cfg(target_os = "macos")]
+        fn default() -> Self {
+            use helix_stdx::env::{binary_exists, env_var_is_set};
+
+            if env_var_is_set("TMUX") && binary_exists("tmux") {
+                Self::Tmux
+            } else if binary_exists("pbcopy") && binary_exists("pbpaste") {
+                Self::Pasteboard
+            } else if cfg!(feature = "term") {
+                Self::Termcode
+            } else {
+                Self::None
+            }
+        }
+
+        #[cfg(not(any(windows, target_os = "macos")))]
+        fn default() -> Self {
+            use helix_stdx::env::{binary_exists, env_var_is_set};
+
+            fn is_exit_success(program: &str, args: &[&str]) -> bool {
+                std::process::Command::new(program)
+                    .args(args)
+                    .output()
+                    .ok()
+                    .and_then(|out| out.status.success().then_some(()))
+                    .is_some()
+            }
+
+            if env_var_is_set("WAYLAND_DISPLAY")
+                && binary_exists("wl-copy")
+                && binary_exists("wl-paste")
+            {
+                Self::Wayland
+            } else if env_var_is_set("DISPLAY") && binary_exists("xclip") {
+                Self::XClip
+            } else if env_var_is_set("DISPLAY")
+                && binary_exists("xsel")
+                // FIXME: check performance of is_exit_success
+                && is_exit_success("xsel", &["-o", "-b"])
+            {
+                Self::XSel
+            } else if binary_exists("termux-clipboard-set") && binary_exists("termux-clipboard-get")
+            {
+                Self::Termux
+            } else if env_var_is_set("TMUX") && binary_exists("tmux") {
+                Self::Tmux
+            } else if binary_exists("win32yank.exe") {
+                Self::Win32Yank
+            } else if cfg!(feature = "term") {
+                Self::Termcode
+            } else {
+                Self::None
+            }
+        }
+    }
+
+    impl ClipboardProvider {
+        pub fn name(&self) -> Cow<'_, str> {
+            fn builtin_name<'a>(
+                name: &'static str,
+                provider: &'static CommandProvider,
+            ) -> Cow<'a, str> {
+                if provider.yank.command != provider.paste.command {
+                    Cow::Owned(format!(
+                        "{} ({}+{})",
+                        name, provider.yank.command, provider.paste.command
+                    ))
+                } else {
+                    Cow::Owned(format!("{} ({})", name, provider.yank.command))
+                }
+            }
+
+            match self {
+                // These names should match the config option names from Serde
+                Self::Pasteboard => builtin_name("pasteboard", &PASTEBOARD),
+                Self::Wayland => builtin_name("wayland", &WL_CLIPBOARD),
+                Self::XClip => builtin_name("x-clip", &WL_CLIPBOARD),
+                Self::XSel => builtin_name("x-sel", &WL_CLIPBOARD),
+                Self::Win32Yank => builtin_name("win-32-yank", &WL_CLIPBOARD),
+                Self::Tmux => builtin_name("tmux", &TMUX),
+                Self::Termux => builtin_name("termux", &TERMUX),
+                #[cfg(windows)]
+                Self::Windows => "windows".into(),
+                #[cfg(feature = "term")]
+                Self::Termcode => "termcode".into(),
+                Self::Custom(command_provider) => Cow::Owned(format!(
+                    "custom ({}+{})",
+                    command_provider.yank.command, command_provider.paste.command
+                )),
+                Self::None => "none".into(),
+            }
+        }
+
+        pub fn get_contents(&self, clipboard_type: &ClipboardType) -> Result<String> {
+            fn yank_from_builtin(
+                provider: CommandProvider,
+                clipboard_type: &ClipboardType,
+            ) -> Result<String> {
+                match clipboard_type {
+                    ClipboardType::Clipboard => execute_command(&provider.yank, None, true)?
+                        .ok_or(ClipboardError::MissingStdout),
+                    ClipboardType::Selection => {
+                        if let Some(cmd) = provider.yank_primary.as_ref() {
+                            return execute_command(cmd, None, true)?
+                                .ok_or(ClipboardError::MissingStdout);
+                        }
+
+                        Ok(String::new())
+                    }
+                }
+            }
+
+            match self {
+                Self::Pasteboard => yank_from_builtin(PASTEBOARD, clipboard_type),
+                Self::Wayland => yank_from_builtin(WL_CLIPBOARD, clipboard_type),
+                Self::XClip => yank_from_builtin(XCLIP, clipboard_type),
+                Self::XSel => yank_from_builtin(XSEL, clipboard_type),
+                Self::Win32Yank => yank_from_builtin(WIN32, clipboard_type),
+                Self::Tmux => yank_from_builtin(TMUX, clipboard_type),
+                Self::Termux => yank_from_builtin(TERMUX, clipboard_type),
+                #[cfg(target_os = "windows")]
+                Self::Windows => match clipboard_type {
+                    ClipboardType::Clipboard => {
+                        let contents =
+                            clipboard_win::get_clipboard(clipboard_win::formats::Unicode)?;
+                        Ok(contents)
+                    }
+                    ClipboardType::Selection => Ok(String::new()),
+                },
+                #[cfg(feature = "term")]
+                Self::Termcode => Err(ClipboardError::ReadingNotSupported),
+                Self::Custom(command_provider) => {
+                    execute_command(&command_provider.yank, None, true)?
+                        .ok_or(ClipboardError::MissingStdout)
+                }
+                Self::None => Err(ClipboardError::ReadingNotSupported),
+            }
+        }
+
+        pub fn set_contents(&self, content: &str, clipboard_type: ClipboardType) -> Result<()> {
+            fn paste_to_builtin(
+                provider: CommandProvider,
+                content: &str,
+                clipboard_type: ClipboardType,
+            ) -> Result<()> {
+                let cmd = match clipboard_type {
+                    ClipboardType::Clipboard => &provider.paste,
+                    ClipboardType::Selection => {
+                        if let Some(cmd) = provider.paste_primary.as_ref() {
+                            cmd
+                        } else {
+                            return Ok(());
+                        }
+                    }
+                };
+
+                execute_command(cmd, Some(content), false).map(|_| ())
+            }
+
+            match self {
+                Self::Pasteboard => paste_to_builtin(PASTEBOARD, content, clipboard_type),
+                Self::Wayland => paste_to_builtin(WL_CLIPBOARD, content, clipboard_type),
+                Self::XClip => paste_to_builtin(XCLIP, content, clipboard_type),
+                Self::XSel => paste_to_builtin(XSEL, content, clipboard_type),
+                Self::Win32Yank => paste_to_builtin(WIN32, content, clipboard_type),
+                Self::Tmux => paste_to_builtin(TMUX, content, clipboard_type),
+                Self::Termux => paste_to_builtin(TERMUX, content, clipboard_type),
+                #[cfg(target_os = "windows")]
+                Self::Windows => match clipboard_type {
+                    ClipboardType::Clipboard => {
+                        clipboard_win::set_clipboard(clipboard_win::formats::Unicode, content)?;
+                        Ok(())
+                    }
+                    ClipboardType::Selection => Ok(()),
+                },
+                #[cfg(feature = "term")]
+                Self::Termcode => {
+                    crossterm::queue!(
+                        std::io::stdout(),
+                        osc52::SetClipboardCommand::new(content, clipboard_type)
+                    )?;
+                    Ok(())
+                }
+                Self::Custom(command_provider) => match clipboard_type {
+                    ClipboardType::Clipboard => {
+                        execute_command(&command_provider.paste, Some(content), false).map(|_| ())
+                    }
+                    ClipboardType::Selection => {
+                        if let Some(cmd) = &command_provider.paste_primary {
+                            execute_command(cmd, Some(content), false).map(|_| ())
+                        } else {
+                            Ok(())
+                        }
+                    }
+                },
+                Self::None => Ok(()),
+            }
+        }
+    }
+
+    macro_rules! command_provider {
+        ($name:ident,
+         yank => $yank_cmd:literal $( , $yank_arg:literal )* ;
+         paste => $paste_cmd:literal $( , $paste_arg:literal )* ; ) => {
+            const $name: CommandProvider = CommandProvider {
+                yank: Command {
+                    command: Cow::Borrowed($yank_cmd),
+                    args: Cow::Borrowed(&[ $( Cow::Borrowed($yank_arg) ),* ])
+                },
+                paste: Command {
+                    command: Cow::Borrowed($paste_cmd),
+                    args: Cow::Borrowed(&[ $( Cow::Borrowed($paste_arg) ),* ])
+                },
+                yank_primary: None,
+                paste_primary: None,
+            };
+        };
+        ($name:ident,
+         yank => $yank_cmd:literal $( , $yank_arg:literal )* ;
+         paste => $paste_cmd:literal $( , $paste_arg:literal )* ;
+         yank_primary => $yank_primary_cmd:literal $( , $yank_primary_arg:literal )* ;
+         paste_primary => $paste_primary_cmd:literal $( , $paste_primary_arg:literal )* ; ) => {
+            const $name: CommandProvider = CommandProvider {
+                yank: Command {
+                    command: Cow::Borrowed($yank_cmd),
+                    args: Cow::Borrowed(&[ $( Cow::Borrowed($yank_arg) ),* ])
+                },
+                paste: Command {
+                    command: Cow::Borrowed($paste_cmd),
+                    args: Cow::Borrowed(&[ $( Cow::Borrowed($paste_arg) ),* ])
+                },
+                yank_primary: Some(Command {
+                    command: Cow::Borrowed($yank_primary_cmd),
+                    args: Cow::Borrowed(&[ $( Cow::Borrowed($yank_primary_arg) ),* ])
+                }),
+                paste_primary: Some(Command {
+                    command: Cow::Borrowed($paste_primary_cmd),
+                    args: Cow::Borrowed(&[ $( Cow::Borrowed($paste_primary_arg) ),* ])
+                }),
+            };
+        };
+    }
+
+    command_provider! {
+        TMUX,
+        yank => "tmux", "load-buffer", "-w", "-";
+        paste => "tmux", "save-buffer", "-";
+    }
+    command_provider! {
+        PASTEBOARD,
+        yank => "pbcopy";
+        paste => "pbpaste";
+    }
+    command_provider! {
+        WL_CLIPBOARD,
+        yank => "wl-copy", "--type", "text/plain";
+        paste => "wl-paste", "--no-newline";
+        yank_primary => "wl-copy", "-p", "--type", "text/plain";
+        paste_primary => "wl-paste", "-p", "--no-newline";
+    }
+    command_provider! {
+        XCLIP,
+        yank => "xclip", "-i", "-selection", "clipboard";
+        paste => "xclip", "-o", "-selection", "clipboard";
+        yank_primary => "xclip", "-i";
+        paste_primary => "xclip", "-o";
+    }
+    command_provider! {
+        XSEL,
+        yank => "xsel", "-i", "-b";
+        paste => "xsel", "-o", "-b";
+        yank_primary => "xsel", "-i";
+        paste_primary => "xsel", "-o";
+    }
+    command_provider! {
+        WIN32,
+        yank => "win32yank.exe", "-i", "--crlf";
+        paste => "win32yank.exe", "-o", "--lf";
+    }
+    command_provider! {
+        TERMUX,
+        yank => "termux-clipboard-set";
+        paste => "termux-clipboard-get";
+    }
 
     #[cfg(feature = "term")]
     mod osc52 {
         use {super::ClipboardType, crate::base64};
 
-        #[derive(Debug)]
         pub struct SetClipboardCommand {
             encoded_content: String,
             clipboard_type: ClipboardType,
@@ -182,232 +426,74 @@ pub mod provider {
                 // Send an OSC 52 set command: https://terminalguide.namepad.de/seq/osc-52/
                 write!(f, "\x1b]52;{};{}\x1b\\", kind, &self.encoded_content)
             }
+            #[cfg(windows)]
+            fn execute_winapi(&self) -> std::result::Result<(), std::io::Error> {
+                Err(std::io::Error::new(
+                    std::io::ErrorKind::Other,
+                    "OSC clipboard codes not supported by winapi.",
+                ))
+            }
         }
     }
 
-    #[derive(Debug)]
-    pub struct FallbackProvider {
-        buf: String,
-        primary_buf: String,
-    }
+    fn execute_command(
+        cmd: &Command,
+        input: Option<&str>,
+        pipe_output: bool,
+    ) -> Result<Option<String>> {
+        use std::io::Write;
+        use std::process::{Command, Stdio};
 
-    impl FallbackProvider {
-        pub fn new() -> Self {
-            #[cfg(feature = "term")]
-            log::debug!(
-                "No native clipboard provider found. Yanking by OSC 52 and pasting will be internal to Helix"
+        let stdin = input.map(|_| Stdio::piped()).unwrap_or_else(Stdio::null);
+        let stdout = pipe_output.then(Stdio::piped).unwrap_or_else(Stdio::null);
+
+        let mut command: Command = Command::new(cmd.command.as_ref());
+
+        #[allow(unused_mut)]
+        let mut command_mut: &mut Command = command
+            .args(cmd.args.iter().map(AsRef::as_ref))
+            .stdin(stdin)
+            .stdout(stdout)
+            .stderr(Stdio::null());
+
+        // Fix for https://github.com/helix-editor/helix/issues/5424
+        #[cfg(unix)]
+        {
+            use std::os::unix::process::CommandExt;
+
+            unsafe {
+                command_mut = command_mut.pre_exec(|| match libc::setsid() {
+                    -1 => Err(std::io::Error::last_os_error()),
+                    _ => Ok(()),
+                });
+            }
+        }
+
+        let mut child = command_mut.spawn()?;
+
+        if let Some(input) = input {
+            let mut stdin = child.stdin.take().ok_or(ClipboardError::StdinWriteFailed)?;
+            stdin
+                .write_all(input.as_bytes())
+                .map_err(|_| ClipboardError::StdinWriteFailed)?;
+        }
+
+        // TODO: add timer?
+        let output = child.wait_with_output()?;
+
+        if !output.status.success() {
+            log::error!(
+                "clipboard provider {} failed with stderr: \"{}\"",
+                cmd.command,
+                String::from_utf8_lossy(&output.stderr)
             );
-            #[cfg(not(feature = "term"))]
-            log::warn!(
-                "No native clipboard provider found! Yanking and pasting will be internal to Helix"
-            );
-            Self {
-                buf: String::new(),
-                primary_buf: String::new(),
-            }
-        }
-    }
-
-    impl Default for FallbackProvider {
-        fn default() -> Self {
-            Self::new()
-        }
-    }
-
-    impl ClipboardProvider for FallbackProvider {
-        #[cfg(feature = "term")]
-        fn name(&self) -> Cow<str> {
-            Cow::Borrowed("termcode")
+            return Err(ClipboardError::CommandFailed);
         }
 
-        #[cfg(not(feature = "term"))]
-        fn name(&self) -> Cow<str> {
-            Cow::Borrowed("none")
-        }
-
-        fn get_contents(&self, clipboard_type: ClipboardType) -> Result<String> {
-            // This is the same noop if term is enabled or not.
-            // We don't use the get side of OSC 52 as it isn't often enabled, it's a security hole,
-            // and it would require this to be async to listen for the response
-            let value = match clipboard_type {
-                ClipboardType::Clipboard => self.buf.clone(),
-                ClipboardType::Selection => self.primary_buf.clone(),
-            };
-
-            Ok(value)
-        }
-
-        fn set_contents(&mut self, content: String, clipboard_type: ClipboardType) -> Result<()> {
-            #[cfg(feature = "term")]
-            crossterm::execute!(
-                std::io::stdout(),
-                osc52::SetClipboardCommand::new(&content, clipboard_type)
-            )?;
-            // Set our internal variables to use in get_content regardless of using OSC 52
-            match clipboard_type {
-                ClipboardType::Clipboard => self.buf = content,
-                ClipboardType::Selection => self.primary_buf = content,
-            }
-            Ok(())
-        }
-    }
-
-    #[cfg(not(target_arch = "wasm32"))]
-    pub mod command {
-        use super::*;
-        use anyhow::{bail, Context as _};
-
-        #[cfg(not(any(windows, target_os = "macos")))]
-        pub fn is_exit_success(program: &str, args: &[&str]) -> bool {
-            std::process::Command::new(program)
-                .args(args)
-                .output()
-                .ok()
-                .and_then(|out| out.status.success().then_some(()))
-                .is_some()
-        }
-
-        #[derive(Debug)]
-        pub struct Config {
-            pub prg: &'static str,
-            pub args: &'static [&'static str],
-        }
-
-        impl Config {
-            fn execute(&self, input: Option<&str>, pipe_output: bool) -> Result<Option<String>> {
-                use std::io::Write;
-                use std::process::{Command, Stdio};
-
-                let stdin = input.map(|_| Stdio::piped()).unwrap_or_else(Stdio::null);
-                let stdout = pipe_output.then(Stdio::piped).unwrap_or_else(Stdio::null);
-
-                let mut command: Command = Command::new(self.prg);
-
-                let mut command_mut: &mut Command = command
-                    .args(self.args)
-                    .stdin(stdin)
-                    .stdout(stdout)
-                    .stderr(Stdio::null());
-
-                // Fix for https://github.com/helix-editor/helix/issues/5424
-                if cfg!(unix) {
-                    use std::os::unix::process::CommandExt;
-
-                    unsafe {
-                        command_mut = command_mut.pre_exec(|| match libc::setsid() {
-                            -1 => Err(std::io::Error::last_os_error()),
-                            _ => Ok(()),
-                        });
-                    }
-                }
-
-                let mut child = command_mut.spawn()?;
-
-                if let Some(input) = input {
-                    let mut stdin = child.stdin.take().context("stdin is missing")?;
-                    stdin
-                        .write_all(input.as_bytes())
-                        .context("couldn't write in stdin")?;
-                }
-
-                // TODO: add timer?
-                let output = child.wait_with_output()?;
-
-                if !output.status.success() {
-                    bail!("clipboard provider {} failed", self.prg);
-                }
-
-                if pipe_output {
-                    Ok(Some(String::from_utf8(output.stdout)?))
-                } else {
-                    Ok(None)
-                }
-            }
-        }
-
-        #[derive(Debug)]
-        pub struct Provider {
-            pub get_cmd: Config,
-            pub set_cmd: Config,
-            pub get_primary_cmd: Option<Config>,
-            pub set_primary_cmd: Option<Config>,
-        }
-
-        impl ClipboardProvider for Provider {
-            fn name(&self) -> Cow<str> {
-                if self.get_cmd.prg != self.set_cmd.prg {
-                    Cow::Owned(format!("{}+{}", self.get_cmd.prg, self.set_cmd.prg))
-                } else {
-                    Cow::Borrowed(self.get_cmd.prg)
-                }
-            }
-
-            fn get_contents(&self, clipboard_type: ClipboardType) -> Result<String> {
-                match clipboard_type {
-                    ClipboardType::Clipboard => Ok(self
-                        .get_cmd
-                        .execute(None, true)?
-                        .context("output is missing")?),
-                    ClipboardType::Selection => {
-                        if let Some(cmd) = &self.get_primary_cmd {
-                            return cmd.execute(None, true)?.context("output is missing");
-                        }
-
-                        Ok(String::new())
-                    }
-                }
-            }
-
-            fn set_contents(&mut self, value: String, clipboard_type: ClipboardType) -> Result<()> {
-                let cmd = match clipboard_type {
-                    ClipboardType::Clipboard => &self.set_cmd,
-                    ClipboardType::Selection => {
-                        if let Some(cmd) = &self.set_primary_cmd {
-                            cmd
-                        } else {
-                            return Ok(());
-                        }
-                    }
-                };
-                cmd.execute(Some(&value), false).map(|_| ())
-            }
-        }
-    }
-}
-
-#[cfg(target_os = "windows")]
-mod provider {
-    use super::{ClipboardProvider, ClipboardType};
-    use anyhow::Result;
-    use std::borrow::Cow;
-
-    #[derive(Default, Debug)]
-    pub struct WindowsProvider;
-
-    impl ClipboardProvider for WindowsProvider {
-        fn name(&self) -> Cow<str> {
-            log::debug!("Using clipboard-win to interact with the system clipboard");
-            Cow::Borrowed("clipboard-win")
-        }
-
-        fn get_contents(&self, clipboard_type: ClipboardType) -> Result<String> {
-            match clipboard_type {
-                ClipboardType::Clipboard => {
-                    let contents = clipboard_win::get_clipboard(clipboard_win::formats::Unicode)?;
-                    Ok(contents)
-                }
-                ClipboardType::Selection => Ok(String::new()),
-            }
-        }
-
-        fn set_contents(&mut self, contents: String, clipboard_type: ClipboardType) -> Result<()> {
-            match clipboard_type {
-                ClipboardType::Clipboard => {
-                    clipboard_win::set_clipboard(clipboard_win::formats::Unicode, contents)?;
-                }
-                ClipboardType::Selection => {}
-            };
-            Ok(())
+        if pipe_output {
+            Ok(Some(String::from_utf8(output.stdout)?))
+        } else {
+            Ok(None)
         }
     }
 }
diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
index 26dea3a2..48d3bc36 100644
--- a/helix-view/src/editor.rs
+++ b/helix-view/src/editor.rs
@@ -1,5 +1,6 @@
 use crate::{
     annotations::diagnostics::{DiagnosticFilter, InlineDiagnosticsConfig},
+    clipboard::ClipboardProvider,
     document::{
         DocumentOpenError, DocumentSavedEventFuture, DocumentSavedEventResult, Mode, SavePoint,
     },
@@ -345,6 +346,8 @@ pub struct Config {
     /// Display diagnostic below the line they occur.
     pub inline_diagnostics: InlineDiagnosticsConfig,
     pub end_of_line_diagnostics: DiagnosticFilter,
+    // Set to override the default clipboard provider
+    pub clipboard_provider: ClipboardProvider,
 }
 
 #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)]
@@ -982,6 +985,7 @@ impl Default for Config {
             jump_label_alphabet: ('a'..='z').collect(),
             inline_diagnostics: InlineDiagnosticsConfig::default(),
             end_of_line_diagnostics: DiagnosticFilter::Disable,
+            clipboard_provider: ClipboardProvider::default(),
         }
     }
 }
@@ -1183,7 +1187,10 @@ impl Editor {
             theme_loader,
             last_theme: None,
             last_selection: None,
-            registers: Registers::default(),
+            registers: Registers::new(Box::new(arc_swap::access::Map::new(
+                Arc::clone(&config),
+                |config: &Config| &config.clipboard_provider,
+            ))),
             status_msg: None,
             autoinfo: None,
             idle_timer: Box::pin(sleep(conf.idle_timeout)),
diff --git a/helix-view/src/register.rs b/helix-view/src/register.rs
index 3a2e1b7c..3f7844cd 100644
--- a/helix-view/src/register.rs
+++ b/helix-view/src/register.rs
@@ -1,10 +1,11 @@
 use std::{borrow::Cow, collections::HashMap, iter};
 
 use anyhow::Result;
+use arc_swap::access::DynAccess;
 use helix_core::NATIVE_LINE_ENDING;
 
 use crate::{
-    clipboard::{get_clipboard_provider, ClipboardProvider, ClipboardType},
+    clipboard::{ClipboardProvider, ClipboardType},
     Editor,
 };
 
@@ -20,28 +21,25 @@ use crate::{
 /// * Document path (`%`): filename of the current buffer
 /// * System clipboard (`*`)
 /// * Primary clipboard (`+`)
-#[derive(Debug)]
 pub struct Registers {
     /// The mapping of register to values.
     /// Values are stored in reverse order when inserted with `Registers::write`.
     /// The order is reversed again in `Registers::read`. This allows us to
     /// efficiently prepend new values in `Registers::push`.
     inner: HashMap<char, Vec<String>>,
-    clipboard_provider: Box<dyn ClipboardProvider>,
+    clipboard_provider: Box<dyn DynAccess<ClipboardProvider>>,
     pub last_search_register: char,
 }
 
-impl Default for Registers {
-    fn default() -> Self {
+impl Registers {
+    pub fn new(clipboard_provider: Box<dyn DynAccess<ClipboardProvider>>) -> Self {
         Self {
             inner: Default::default(),
-            clipboard_provider: get_clipboard_provider(),
+            clipboard_provider,
             last_search_register: '/',
         }
     }
-}
 
-impl Registers {
     pub fn read<'a>(&'a self, name: char, editor: &'a Editor) -> Option<RegisterValues<'a>> {
         match name {
             '_' => Some(RegisterValues::new(iter::empty())),
@@ -64,7 +62,7 @@ impl Registers {
                 Some(RegisterValues::new(iter::once(path)))
             }
             '*' | '+' => Some(read_from_clipboard(
-                self.clipboard_provider.as_ref(),
+                &self.clipboard_provider.load(),
                 self.inner.get(&name),
                 match name {
                     '+' => ClipboardType::Clipboard,
@@ -84,8 +82,8 @@ impl Registers {
             '_' => Ok(()),
             '#' | '.' | '%' => Err(anyhow::anyhow!("Register {name} does not support writing")),
             '*' | '+' => {
-                self.clipboard_provider.set_contents(
-                    values.join(NATIVE_LINE_ENDING.as_str()),
+                self.clipboard_provider.load().set_contents(
+                    &values.join(NATIVE_LINE_ENDING.as_str()),
                     match name {
                         '+' => ClipboardType::Clipboard,
                         '*' => ClipboardType::Selection,
@@ -114,7 +112,10 @@ impl Registers {
                     '*' => ClipboardType::Selection,
                     _ => unreachable!(),
                 };
-                let contents = self.clipboard_provider.get_contents(clipboard_type)?;
+                let contents = self
+                    .clipboard_provider
+                    .load()
+                    .get_contents(&clipboard_type)?;
                 let saved_values = self.inner.entry(name).or_default();
 
                 if !contents_are_saved(saved_values, &contents) {
@@ -127,7 +128,8 @@ impl Registers {
                 }
                 value.push_str(&contents);
                 self.clipboard_provider
-                    .set_contents(value, clipboard_type)?;
+                    .load()
+                    .set_contents(&value, clipboard_type)?;
 
                 Ok(())
             }
@@ -198,7 +200,8 @@ impl Registers {
     fn clear_clipboard(&mut self, clipboard_type: ClipboardType) {
         if let Err(err) = self
             .clipboard_provider
-            .set_contents("".into(), clipboard_type)
+            .load()
+            .set_contents("", clipboard_type)
         {
             log::error!(
                 "Failed to clear {} clipboard: {err}",
@@ -210,17 +213,17 @@ impl Registers {
         }
     }
 
-    pub fn clipboard_provider_name(&self) -> Cow<str> {
-        self.clipboard_provider.name()
+    pub fn clipboard_provider_name(&self) -> String {
+        self.clipboard_provider.load().name().into_owned()
     }
 }
 
 fn read_from_clipboard<'a>(
-    provider: &dyn ClipboardProvider,
+    provider: &ClipboardProvider,
     saved_values: Option<&'a Vec<String>>,
     clipboard_type: ClipboardType,
 ) -> RegisterValues<'a> {
-    match provider.get_contents(clipboard_type) {
+    match provider.get_contents(&clipboard_type) {
         Ok(contents) => {
             // If we're pasting the same values that we just yanked, re-use
             // the saved values. This allows pasting multiple selections