use std::path::Path;
pub(crate) fn is_markdown_path(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("md" | "markdown" | "mdx" | "mkd")
)
}
pub(crate) fn is_home_workspace(path: &Path) -> bool {
let Some(home) = std::env::var_os("HOME") else {
return false;
};
let home = std::path::PathBuf::from(home);
let home_c = std::fs::canonicalize(&home).unwrap_or(home);
path == home_c
}
pub(crate) fn is_url_like(target: &str) -> bool {
const SCHEMES: &[&str] = &[
"http://",
"https://",
"mailto:",
"ftp://",
"ftps://",
"file://",
"ssh://",
"git://",
"data:",
"javascript:",
];
SCHEMES.iter().any(|s| target.starts_with(s))
}
pub(crate) fn is_image_extension(path: &Path) -> bool {
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref(),
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp")
)
}
pub(crate) fn reveal_in_files_label() -> &'static str {
if cfg!(target_os = "macos") {
"Reveal in Finder"
} else if cfg!(target_os = "windows") {
"Reveal in Explorer"
} else {
"Reveal in file browser"
}
}
pub(crate) fn rel_path(workspace: &Path, p: &Path) -> String {
p.strip_prefix(workspace)
.unwrap_or(p)
.to_string_lossy()
.into_owned()
}
pub(crate) fn walk_entry_count(dir: &Path, depth: u32, max: usize) -> usize {
if depth > 8 {
return 0;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut total = 0usize;
for entry in entries.flatten() {
total += 1;
if total >= max {
return max;
}
let p = entry.path();
if p.is_dir() {
let sub = walk_entry_count(&p, depth + 1, max - total);
total += sub;
if total >= max {
return max;
}
}
}
total
}
pub(crate) fn collision_free_copy_name(path: &Path) -> std::path::PathBuf {
let Some(parent) = path.parent() else {
return path.to_path_buf();
};
let stem_raw = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let ext = path
.extension()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let stem = if let Some(base) = stem_raw
.rsplit_once("-copy-")
.and_then(|(base, tail)| tail.parse::<usize>().ok().map(|_| base))
{
base.to_string()
} else if let Some(base) = stem_raw.strip_suffix("-copy") {
base.to_string()
} else {
stem_raw
};
let make = |suffix: &str| {
if ext.is_empty() {
parent.join(format!("{stem}{suffix}"))
} else {
parent.join(format!("{stem}{suffix}.{ext}"))
}
};
let first = make("-copy");
if !first.exists() && first != path {
return first;
}
for n in 2..1000 {
let candidate = make(&format!("-copy-{n}"));
if !candidate.exists() && candidate != path {
return candidate;
}
}
make("-copy-lots")
}
pub(crate) fn copy_recursively(src: &Path, dst: &Path) -> Result<(), String> {
let meta =
std::fs::symlink_metadata(src).map_err(|e| format!("stat {}: {e}", src.display()))?;
if meta.is_dir() {
std::fs::create_dir_all(dst).map_err(|e| format!("mkdir {}: {e}", dst.display()))?;
for entry in
std::fs::read_dir(src).map_err(|e| format!("read_dir {}: {e}", src.display()))?
{
let entry = entry.map_err(|e| e.to_string())?;
let child_src = entry.path();
let Some(name) = child_src.file_name() else {
continue;
};
let child_dst = dst.join(name);
copy_recursively(&child_src, &child_dst)?;
}
Ok(())
} else if meta.file_type().is_symlink() {
let target = std::fs::read_link(src).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
std::os::unix::fs::symlink(&target, dst)
.map_err(|e| format!("symlink {}: {e}", dst.display()))?;
}
#[cfg(windows)]
{
let resolved = if target.is_absolute() {
target
} else {
src.parent()
.unwrap_or(std::path::Path::new("."))
.join(&target)
};
std::fs::copy(&resolved, dst)
.map(|_| ())
.map_err(|e| format!("symlink-as-copy {}: {e}", dst.display()))?;
}
Ok(())
} else {
std::fs::copy(src, dst)
.map(|_| ())
.map_err(|e| format!("copy {} → {}: {e}", src.display(), dst.display()))
}
}
pub(crate) fn expand_tilde_and_resolve(workspace: &Path, input: &str) -> std::path::PathBuf {
if let Some(rest) = input.strip_prefix("~/")
&& let Some(home) = std::env::var_os("HOME")
{
return std::path::PathBuf::from(home).join(rest);
}
if input == "~"
&& let Some(home) = std::env::var_os("HOME")
{
return std::path::PathBuf::from(home);
}
let p = std::path::PathBuf::from(input);
if p.is_absolute() {
p
} else {
workspace.join(p)
}
}
pub fn collect_whole_word_occurrences(text: &str, word: &str) -> Vec<(usize, usize, usize)> {
let word_chars: Vec<char> = word.chars().collect();
if word_chars.is_empty() {
return Vec::new();
}
let wlen = word_chars.len();
let is_id = |c: char| c.is_alphanumeric() || c == '_';
let mut out = Vec::new();
for (row, line) in text.split('\n').enumerate() {
let chars: Vec<char> = line.chars().collect();
let n = chars.len();
if n < wlen {
continue;
}
let mut i = 0;
while i + wlen <= n {
if chars[i..i + wlen] == word_chars[..]
&& (i == 0 || !is_id(chars[i - 1]))
&& (i + wlen == n || !is_id(chars[i + wlen]))
{
out.push((row, i, wlen));
if out.len() >= 5000 {
return out;
}
i += wlen;
} else {
i += 1;
}
}
}
out
}
fn snap_to_char_boundary(text: &str, byte: usize) -> usize {
let byte = byte.min(text.len());
(0..=byte)
.rev()
.find(|&b| text.is_char_boundary(b))
.unwrap_or(0)
}
pub(crate) fn byte_to_line_col(text: &str, byte: usize) -> (usize, usize) {
let cap = snap_to_char_boundary(text, byte);
let line = text[..cap].bytes().filter(|&b| b == b'\n').count();
let line_start = text[..cap].rfind('\n').map(|i| i + 1).unwrap_or(0);
let col = text[line_start..cap].chars().count();
(line, col)
}
pub(crate) fn byte_to_row_col(text: &str, byte: usize) -> (usize, usize) {
let byte = snap_to_char_boundary(text, byte);
let row = text[..byte].bytes().filter(|&b| b == b'\n').count();
let line_start = text[..byte].rfind('\n').map(|i| i + 1).unwrap_or(0);
let col = text[line_start..byte].chars().count();
(row, col)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn byte_to_row_col_snaps_mid_emoji_to_boundary() {
let text = "a\u{1F600}b"; let (row, col) = byte_to_row_col(text, 3);
assert_eq!(row, 0);
assert_eq!(col, 1, "cursor lands between 'a' and the emoji");
let (row, col) = byte_to_row_col(text, 4);
assert_eq!((row, col), (0, 1));
let (row, col) = byte_to_row_col(text, 6);
assert_eq!((row, col), (0, 3));
let (row, col) = byte_to_row_col(text, 999);
assert_eq!((row, col), (0, 3));
}
#[test]
fn byte_to_line_col_snaps_too() {
let text = "x\u{1F600}\nfoo";
let (line, col) = byte_to_line_col(text, 3);
assert_eq!((line, col), (0, 1));
let (line, col) = byte_to_line_col(text, 8);
assert_eq!((line, col), (1, 2));
}
}