use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ClickTarget {
Url(String),
File(PathBuf),
}
impl ClickTarget {
pub(crate) fn label(&self) -> String {
match self {
ClickTarget::Url(u) => u.clone(),
ClickTarget::File(p) => p.display().to_string(),
}
}
}
fn is_break(c: char) -> bool {
c.is_whitespace() || c.is_control() || matches!(c, '"' | '\'' | '`' | '<' | '>')
}
pub(crate) fn target_at(line: &str, col: usize) -> Option<ClickTarget> {
let cwd = std::env::current_dir().unwrap_or_default();
target_at_with(line, col, &cwd, dirs::home_dir().as_deref())
}
pub(crate) fn target_at_with(
line: &str,
col: usize,
cwd: &Path,
home: Option<&Path>,
) -> Option<ClickTarget> {
let token = token_at(line, col)?;
classify(token, cwd, home)
}
fn token_at(line: &str, col: usize) -> Option<&str> {
let chars: Vec<(usize, char)> = line.char_indices().collect();
if col >= chars.len() || is_break(chars[col].1) {
return None;
}
let mut a = col;
while a > 0 && !is_break(chars[a - 1].1) {
a -= 1;
}
let mut b = col;
while b + 1 < chars.len() && !is_break(chars[b + 1].1) {
b += 1;
}
let start = chars[a].0;
let end = chars[b].0 + chars[b].1.len_utf8();
Some(&line[start..end])
}
fn clean_token(raw: &str) -> &str {
let mut s = raw;
loop {
let prev = s;
for (open, close) in [('(', ')'), ('[', ']'), ('{', '}')] {
if s.len() > 2 && s.starts_with(open) && s.ends_with(close) {
s = &s[1..s.len() - 1];
break;
}
}
s = s.trim_end_matches(['.', ',', ';', ':', '!', '?']);
for (open, close) in [('(', ')'), ('[', ']'), ('{', '}')] {
if s.ends_with(close) && !s.contains(open) {
s = &s[..s.len() - close.len_utf8()];
}
}
if s == prev {
return s;
}
}
}
fn classify(raw: &str, cwd: &Path, home: Option<&Path>) -> Option<ClickTarget> {
let token = clean_token(raw);
if token.is_empty() {
return None;
}
let lower = token.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
let rest = &token[lower.find("://").unwrap() + 3..];
if rest.contains('.') || rest.contains(':') || rest.eq_ignore_ascii_case("localhost") {
return Some(ClickTarget::Url(token.to_string()));
}
return None;
}
if lower.starts_with("file://") {
let path = &token["file://".len()..];
return resolve_path(path, cwd, home);
}
if lower.starts_with("mailto:") && token.contains('@') {
return Some(ClickTarget::Url(token.to_string()));
}
resolve_path(token, cwd, home)
}
fn resolve_path(token: &str, cwd: &Path, home: Option<&Path>) -> Option<ClickTarget> {
let token = strip_line_ref(token);
let path_like = token.starts_with('/')
|| token.starts_with('~')
|| token.starts_with('.')
|| token.contains('/')
|| token.contains('\\')
|| looks_extended(token);
if !path_like {
return None;
}
let expanded = expand_tilde(token, home)?;
let candidate = if Path::new(&expanded).is_absolute() {
PathBuf::from(&expanded)
} else {
cwd.join(&expanded)
};
if candidate.exists() {
Some(ClickTarget::File(candidate))
} else {
None
}
}
fn strip_line_ref(token: &str) -> &str {
let mut s = token;
while let Some((head, tail)) = s.rsplit_once(':') {
let tail_is_number = !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit());
if !tail_is_number || head.is_empty() || head.chars().count() <= 1 {
break;
}
s = head;
}
s
}
fn looks_extended(token: &str) -> bool {
match token.rsplit_once('.') {
Some((stem, ext)) => {
!stem.is_empty()
&& !ext.is_empty()
&& ext.len() <= 8
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
}
None => false,
}
}
fn expand_tilde(token: &str, home: Option<&Path>) -> Option<String> {
if token == "~" {
return home.map(|h| h.display().to_string());
}
if let Some(rest) = token.strip_prefix("~/") {
let h = home?;
return Some(h.join(rest.trim_start_matches('/')).display().to_string());
}
Some(token.to_string())
}
pub(crate) fn opener_command() -> &'static str {
#[cfg(target_os = "macos")]
let cmd = "open";
#[cfg(target_os = "linux")]
let cmd = "xdg-open";
#[cfg(target_os = "windows")]
let cmd = "explorer";
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let cmd = "xdg-open";
cmd
}
pub(crate) fn open(target: &ClickTarget) -> std::io::Result<()> {
let arg = target.label();
let mut cmd = std::process::Command::new(opener_command());
cmd.arg(&arg)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
cmd.spawn().map(|_child| ())
}