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, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HintAction {
Action { action: HintInternalAction },
Command { command: HintCommand },
}
#[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);
}
}