use serde::{Deserialize, Serialize};
pub const DEFAULT_HINTS_ALPHABET: &str = "jfkdls;ahgurieowpq";
pub const DEFAULT_URL_REGEX: &str = concat!(
"(?:https?://|mailto:|ftp://|file:|ssh:|git://|ssh://|tel:|magnet:|ipfs://|ipns://|gemini://|gopher://|news:)",
"(?:",
r"(?:\[[:0-9a-fA-F]+(?:[:0-9a-fA-F]*)+\](?::[0-9]+)?)",
"|",
r"[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?",
")+",
r"(?<![,.])",
"|",
r"(?:\.\./|\./|(?<!\w)~/|(?:[\w][\w\-.]*/)*(?<!\w)\$[A-Za-z_]\w*/|\.[\w][\w\-.]*/|(?<![\w~/])/(?!/))",
"(?:",
r"(?=[\w\-.~:/?#@!$&*+;=%]*\.)",
r"[\w\-.~:/?#@!$&*+;=%]+",
r"(?:(?<!:) (?!\w+://)(?!\.{0,2}/)(?!~/)[\w\-.~:/?#@!$&*+;=%]*[/.])*",
r"(?<!:)",
r"(?: +(?= *$))?",
"|",
r"(?![\w\-.~:/?#@!$&*+;=%]*\.)",
r"[\w\-.~:/?#@!$&*+;=%]+",
r"(?:(?<!:) (?!\w+://)(?!\.{0,2}/)(?!~/)[\w\-.~:/?#@!$&*+;=%]+)*",
r"(?<!:)",
r"(?: +(?= *$))?",
")",
"|",
r"(?=[\w\-.~:/?#@!$&*+;=%]*\.)",
r"(?<!\$\d*)(?<!\w)[\w][\w\-.]*/",
r"[\w\-.~:/?#@!$&*+;=%]+",
r"(?<!:)",
r"(?: +(?= *$))?",
);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Hints {
#[serde(default = "default_hints_alphabet")]
pub alphabet: String,
#[serde(default = "default_hints_enabled")]
pub rules: Vec<Hint>,
}
impl Default for Hints {
fn default() -> Self {
Self {
alphabet: default_hints_alphabet(),
rules: default_hints_enabled(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hint {
#[serde(default)]
pub regex: Option<String>,
#[serde(default = "default_bool_false")]
pub hyperlinks: bool,
#[serde(default = "default_bool_true", rename = "post-processing")]
pub post_processing: bool,
#[serde(default = "default_bool_false")]
pub persist: bool,
#[serde(flatten)]
pub action: HintAction,
#[serde(default)]
pub mouse: HintMouse,
#[serde(default)]
pub binding: Option<HintBinding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum HintAction {
Action { action: HintInternalAction },
Command { command: HintCommand },
}
impl<'de> Deserialize<'de> for HintAction {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum ActionValue {
Inline(HintInternalAction),
Nested { action: HintInternalAction },
NestedCommand { command: HintCommand },
}
#[derive(Deserialize)]
#[serde(untagged)]
enum CommandValue {
Inline(HintCommand),
Nested { command: HintCommand },
}
#[derive(Deserialize)]
struct Raw {
#[serde(default)]
action: Option<ActionValue>,
#[serde(default)]
command: Option<CommandValue>,
}
let raw = Raw::deserialize(deserializer)?;
match (raw.action, raw.command) {
(Some(ActionValue::Inline(action)), None)
| (Some(ActionValue::Nested { action }), None) => {
Ok(HintAction::Action { action })
}
(Some(ActionValue::NestedCommand { command }), None)
| (None, Some(CommandValue::Inline(command)))
| (None, Some(CommandValue::Nested { command })) => {
Ok(HintAction::Command { command })
}
(Some(_), Some(_)) => Err(serde::de::Error::custom(
"hint rule has both `action` and `command`; use one",
)),
(None, None) => Err(serde::de::Error::custom(
"hint rule needs an `action` (e.g. \"Copy\") or a `command`",
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum HintInternalAction {
Copy,
Paste,
Select,
MoveViModeCursor,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum HintCommand {
Simple(String),
WithArgs {
program: String,
#[serde(default)]
args: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HintMouse {
#[serde(default = "default_bool_true")]
pub enabled: bool,
#[serde(default)]
pub mods: Vec<String>,
}
impl Default for HintMouse {
fn default() -> Self {
#[cfg(target_os = "macos")]
let default_mods = vec!["Super".to_string()];
#[cfg(not(target_os = "macos"))]
let default_mods = vec!["Alt".to_string()];
Self {
enabled: true,
mods: default_mods,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HintBinding {
pub key: String,
#[serde(default)]
pub mods: Vec<String>,
#[serde(default)]
pub mode: Vec<String>,
}
fn default_hints_alphabet() -> String {
DEFAULT_HINTS_ALPHABET.to_string()
}
fn default_hints_enabled() -> Vec<Hint> {
vec![Hint {
regex: Some(DEFAULT_URL_REGEX.to_string()),
hyperlinks: true,
post_processing: true,
persist: false,
action: HintAction::Command {
command: default_url_command(),
},
mouse: HintMouse::default(),
binding: Some(HintBinding {
key: "O".to_string(),
mods: vec!["Control".to_string(), "Shift".to_string()],
mode: Vec::new(),
}),
}]
}
fn default_url_command() -> HintCommand {
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
return HintCommand::Simple("xdg-open".to_string());
#[cfg(target_os = "macos")]
return HintCommand::Simple("open".to_string());
#[cfg(target_os = "windows")]
return HintCommand::WithArgs {
program: "cmd".to_string(),
args: vec!["/c".to_string(), "start".to_string(), "".to_string()],
};
}
fn default_bool_true() -> bool {
true
}
fn default_bool_false() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hints_default() {
let hints = Hints::default();
assert_eq!(hints.alphabet, DEFAULT_HINTS_ALPHABET);
assert_eq!(hints.rules.len(), 1);
let default_hint = &hints.rules[0];
assert!(default_hint.regex.is_some());
assert!(default_hint.hyperlinks);
assert!(default_hint.post_processing);
assert!(!default_hint.persist);
}
#[test]
fn test_hint_serialization() {
let hint = Hint {
regex: Some("test.*pattern".to_string()),
hyperlinks: false,
post_processing: true,
persist: false,
action: HintAction::Action {
action: HintInternalAction::Copy,
},
mouse: HintMouse::default(),
binding: None,
};
let serialized = toml::to_string(&hint).unwrap();
let deserialized: Hint = toml::from_str(&serialized).unwrap();
assert_eq!(hint, deserialized);
}
#[test]
fn test_default_regex_compiles() {
onig::Regex::new(DEFAULT_URL_REGEX).expect("default regex must compile");
}
fn find_all(input: &str) -> Vec<&str> {
let re = onig::Regex::new(DEFAULT_URL_REGEX).unwrap();
re.find_iter(input).map(|(s, e)| &input[s..e]).collect()
}
#[test]
fn test_default_regex_matches_schemed_urls() {
assert_eq!(
find_all("visit https://rioterm.com here"),
vec!["https://rioterm.com"]
);
assert_eq!(find_all("file://foo"), vec!["file://foo"]);
}
#[test]
fn test_default_regex_matches_rooted_paths() {
assert_eq!(find_all("open ~/notes.md please"), vec!["~/notes.md"],);
assert_eq!(find_all("see ./script.sh"), vec!["./script.sh"]);
assert_eq!(
find_all("check ../parent/file.txt"),
vec!["../parent/file.txt"],
);
assert_eq!(find_all("open ~/Desktop please"), vec!["~/Desktop please"]);
assert_eq!(find_all("cd /tmp/foo"), vec!["/tmp/foo"]);
assert_eq!(find_all("logs at $HOME/logs"), vec!["$HOME/logs"]);
}
#[test]
fn test_default_regex_matches_bare_relative_paths_with_extension() {
assert_eq!(find_all("edit src/main.rs now"), vec!["src/main.rs"]);
assert_eq!(
find_all("see frontends/rioterm/src/hints.rs"),
vec!["frontends/rioterm/src/hints.rs"]
);
}
#[test]
fn test_default_regex_rejects_midword_slash() {
assert!(find_all("foo/bar").is_empty());
}
#[test]
fn test_default_regex_rejects_midword_tilde() {
assert!(find_all("foo~/bar").is_empty());
}
#[test]
fn test_default_regex_strips_trailing_punctuation_on_urls() {
assert_eq!(
find_all("see https://example.com."),
vec!["https://example.com"],
);
}
#[test]
fn test_default_regex_matches_dot_prefixed_paths() {
assert_eq!(
find_all(".config/rio/config.toml"),
vec![".config/rio/config.toml"]
);
}
#[test]
fn test_default_regex_prefers_bare_relative_over_embedded_slash() {
assert_eq!(
find_all("Compiling src/config/url.zig"),
vec!["src/config/url.zig"],
);
}
#[test]
fn test_config_with_hints() {
use crate::config::Config;
let config_toml = r#"
[hints]
alphabet = "abcdef"
[[hints.rules]]
regex = "test.*pattern"
hyperlinks = false
post-processing = true
persist = false
[hints.rules.action]
action = "Copy"
[hints.rules.binding]
key = "T"
mods = ["Control"]
"#;
let config: Config = toml::from_str(config_toml).unwrap();
assert_eq!(config.hints.alphabet, "abcdef");
assert_eq!(config.hints.rules.len(), 1);
let hint = &config.hints.rules[0];
assert_eq!(hint.regex, Some("test.*pattern".to_string()));
assert!(!hint.hyperlinks);
assert!(hint.post_processing);
assert!(!hint.persist);
}
fn parse_rule(rule_body: &str) -> Result<crate::config::Config, toml::de::Error> {
toml::from_str(&format!("[[hints.rules]]\nregex = \"x\"\n{rule_body}"))
}
#[test]
fn test_hint_action_inline_action() {
let config = parse_rule("action = \"Copy\"").unwrap();
assert_eq!(
config.hints.rules[0].action,
HintAction::Action {
action: HintInternalAction::Copy
}
);
}
#[test]
fn test_hint_action_inline_command() {
let config = parse_rule("command = \"open\"").unwrap();
assert_eq!(
config.hints.rules[0].action,
HintAction::Command {
command: HintCommand::Simple("open".to_string())
}
);
}
#[test]
fn test_hint_action_inline_command_with_args() {
let config =
parse_rule("command = { program = \"open\", args = [\"-R\"] }").unwrap();
assert_eq!(
config.hints.rules[0].action,
HintAction::Command {
command: HintCommand::WithArgs {
program: "open".to_string(),
args: vec!["-R".to_string()],
}
}
);
}
#[test]
fn test_hint_action_documented_nested_command() {
let config = parse_rule("[hints.rules.action]\ncommand = \"open\"").unwrap();
assert_eq!(
config.hints.rules[0].action,
HintAction::Command {
command: HintCommand::Simple("open".to_string())
}
);
}
#[test]
fn test_hint_action_legacy_nested_action() {
let config = parse_rule("[hints.rules.action]\naction = \"Paste\"").unwrap();
assert_eq!(
config.hints.rules[0].action,
HintAction::Action {
action: HintInternalAction::Paste
}
);
}
#[test]
fn test_hint_action_legacy_nested_command() {
let config = parse_rule("[hints.rules.command]\ncommand = \"open\"").unwrap();
assert_eq!(
config.hints.rules[0].action,
HintAction::Command {
command: HintCommand::Simple("open".to_string())
}
);
}
#[test]
fn test_hint_action_missing_errors() {
let err = parse_rule("").unwrap_err().to_string();
assert!(err.contains("action"), "unhelpful error: {err}");
}
#[test]
fn test_hint_action_both_keys_error() {
let err = parse_rule("action = \"Copy\"\ncommand = \"open\"")
.unwrap_err()
.to_string();
assert!(err.contains("both"), "unhelpful error: {err}");
}
#[test]
fn test_hint_action_serialize_roundtrip() {
let action = HintAction::Command {
command: HintCommand::Simple("open".to_string()),
};
let hint = Hint {
regex: Some("x".to_string()),
hyperlinks: false,
post_processing: true,
persist: false,
action,
mouse: HintMouse {
enabled: true,
mods: Vec::new(),
},
binding: None,
};
let serialized = toml::to_string(&hint).unwrap();
let parsed: Hint = toml::from_str(&serialized).unwrap();
assert_eq!(parsed.action, hint.action);
}
}