SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
d90101d verified
Raw History Blame Contribute Delete
20.7 kB
//! Pasted-text formatting utilities.
//!
//! When the terminal sends a bracketed-paste event (e.g. from a drag-and-drop),
//! this module checks whether the pasted text is an existing file path and,
//! if so, wraps it in `@[...]` syntax so the user sees the reference
//! immediately in the input field.
use std::path::Path;
/// Transforms pasted text by wrapping bare file paths in `@[...]` syntax.
///
/// Called when a bracketed-paste event is received. The pasted content is
/// normalised (CRLF to LF) and then checked for file paths. If the entire
/// paste (after stripping whitespace/quotes) is a single existing absolute
/// path it gets wrapped directly -- this handles paths with spaces. Otherwise
/// the text is scanned token-by-token for quoted or unquoted absolute paths.
///
/// Already-wrapped `@[...]` references and non-existent paths are left
/// untouched.
pub fn wrap_pasted_text(pasted: &str) -> String {
let normalised = pasted.replace("\r\n", "\n").replace('\r', "\n");
let trimmed = normalised.trim();
// If the whole paste is empty, just return normalised form
if trimmed.is_empty() {
return normalised;
}
// Fast path: the entire paste is a single path (possibly quoted).
// This is the most common drag-and-drop case and correctly handles
// paths that contain spaces or backslash-escaped spaces.
let unquoted = strip_surrounding_quotes(trimmed);
if let Some(resolved) = resolve_file_path(unquoted) {
// Reconstruct with the same leading/trailing whitespace the
// original normalised string had.
let trim_start_len = normalised.trim_start().len();
let trim_end_len = normalised.trim_end().len();
let leading = normalised
.get(..normalised.len() - trim_start_len)
.unwrap_or("");
let trailing = normalised.get(trim_end_len..).unwrap_or("");
return format!("{leading}@[{resolved}]{trailing}");
}
// Scan token by token, wrapping any absolute paths that exist on disk
wrap_tokens(&normalised)
}
/// Strips surrounding single or double quotes that some terminals add
/// when dragging files with spaces in their names.
fn strip_surrounding_quotes(s: &str) -> &str {
if s.len() < 2 {
return s;
}
if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
s.get(1..s.len().saturating_sub(1)).unwrap_or(s)
} else {
s
}
}
/// Removes backslash escapes from a string (e.g. `\ ` becomes ` `).
///
/// Many terminals (Ghostty, iTerm2, etc.) backslash-escape spaces when
/// drag-and-dropping file paths, producing strings like
/// `/path/my\ folder/file.txt`. This helper un-escapes them so the path
/// can be resolved against the filesystem.
///
/// Returns `None` if no backslash escapes were found (i.e. the input is
/// already clean), allowing callers to skip redundant `is_file()` checks.
fn unescape_backslashes(s: &str) -> Option<String> {
if !s.contains('\\') {
return None;
}
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
// Take the next char literally, or keep the backslash if at end
if let Some(next) = chars.next() {
out.push(next);
} else {
out.push(c);
}
} else {
out.push(c);
}
}
Some(out)
}
/// Checks whether `candidate` resolves to an existing absolute file or
/// directory path.
///
/// Tries the raw string first, then falls back to un-escaping backslashes
/// (for terminals that send `/path/my\ file.txt`). Returns the resolved
/// clean path on success, or `None` if no match was found.
fn resolve_file_path(candidate: &str) -> Option<String> {
let path = Path::new(candidate);
if path.is_absolute() && path.exists() {
return Some(candidate.to_string());
}
// Try un-escaping backslashes (e.g. Ghostty sends `/path/my\ file.txt`)
if let Some(unescaped) = unescape_backslashes(candidate) {
let path = Path::new(&unescaped);
if path.is_absolute() && path.exists() {
return Some(unescaped);
}
}
None
}
/// Finds the end of a token in `input`, treating `\<char>` as an escaped
/// character that is part of the token (not a boundary).
///
/// Returns the byte offset of the first unescaped whitespace character, or
/// the length of `input` if no unescaped whitespace is found.
fn find_token_end(input: &str) -> usize {
let mut escaped = false;
for (i, c) in input.char_indices() {
if escaped {
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
continue;
}
if c.is_whitespace() {
return i;
}
}
input.len()
}
/// Walks through `input` token-by-token and wraps absolute file paths.
///
/// Handles both unquoted tokens (split on whitespace) and quoted strings
/// (single or double quotes) so that paths containing spaces are kept
/// together as a single token.
fn wrap_tokens(input: &str) -> String {
let mut result = String::with_capacity(input.len() + 32);
let mut remaining = input;
while !remaining.is_empty() {
// Preserve leading whitespace
let ws_end = remaining
.find(|c: char| !c.is_whitespace())
.unwrap_or(remaining.len());
result.push_str(remaining.get(..ws_end).unwrap_or(""));
remaining = remaining.get(ws_end..).unwrap_or("");
if remaining.is_empty() {
break;
}
// Skip already-wrapped @[...] references
if remaining.starts_with("@[")
&& let Some(close) = remaining.find(']')
{
result.push_str(remaining.get(..=close).unwrap_or(""));
remaining = remaining.get(close.saturating_add(1)..).unwrap_or("");
continue;
}
// If the token starts with a quote, consume everything up to the
// matching closing quote so that paths with spaces stay together.
let first_char = remaining.as_bytes().first().copied().unwrap_or(0);
if first_char == b'\'' || first_char == b'"' {
let quote = first_char as char;
if let Some(close) = remaining.get(1..).and_then(|s| s.find(quote)) {
let token_end = close.saturating_add(2); // include both quotes
let token = remaining.get(..token_end).unwrap_or("");
let clean = strip_surrounding_quotes(token);
if let Some(resolved) = resolve_file_path(clean) {
result.push_str(&format!("@[{}]", resolved));
} else {
result.push_str(token);
}
remaining = remaining.get(token_end..).unwrap_or("");
continue;
}
}
// Extract the next token, treating backslash-escaped whitespace
// (e.g. `\ `) as part of the token. This handles terminals like
// Ghostty that send `/path/my\ file.txt` for drag-and-drop.
let token_end = find_token_end(remaining);
let token = remaining.get(..token_end).unwrap_or("");
if let Some(resolved) = resolve_file_path(token) {
result.push_str(&format!("@[{}]", resolved));
} else {
result.push_str(token);
}
remaining = remaining.get(token_end..).unwrap_or("");
}
result
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_wrap_pasted_text_cjk_no_paths() {
let fixture = "公";
let actual = wrap_pasted_text(fixture);
let expected = "公";
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_no_paths() {
let fixture = "hello world";
let actual = wrap_pasted_text(fixture);
let expected = "hello world";
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_already_wrapped() {
let fixture = "check @[/usr/bin/env]";
let actual = wrap_pasted_text(fixture);
let expected = "check @[/usr/bin/env]";
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_existing_file() {
// /usr/bin/env exists on macOS/Linux
let fixture = "look at /usr/bin/env please";
let actual = wrap_pasted_text(fixture);
let expected = "look at @[/usr/bin/env] please";
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_nonexistent_path_untouched() {
let fixture = "look at /nonexistent/path/file.rs please";
let actual = wrap_pasted_text(fixture);
let expected = "look at /nonexistent/path/file.rs please";
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_bare_path_only() {
// Just a bare path (typical drag-and-drop result)
// /usr/bin/env is a real file, so it should be wrapped
let fixture = "/usr/bin/env";
let actual = wrap_pasted_text(fixture);
let expected = "@[/usr/bin/env]";
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_bare_path_nonexistent() {
let fixture = "/nonexistent/path/file.rs";
let actual = wrap_pasted_text(fixture);
let expected = "/nonexistent/path/file.rs";
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_with_text_before() {
let fixture = "analyze /usr/bin/env";
let actual = wrap_pasted_text(fixture);
let expected = "analyze @[/usr/bin/env]";
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_preserves_whitespace() {
let fixture = "hello world";
let actual = wrap_pasted_text(fixture);
let expected = "hello world";
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_mixed_existing_and_nonexistent() {
let fixture = "check /usr/bin/env and /nonexistent/foo.rs";
let actual = wrap_pasted_text(fixture);
let expected = "check @[/usr/bin/env] and /nonexistent/foo.rs";
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_crlf_normalised() {
let fixture = "/usr/bin/env\r\n";
let actual = wrap_pasted_text(fixture);
let expected = "@[/usr/bin/env]\n";
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_single_quoted_path() {
let fixture = "'/usr/bin/env'";
let actual = wrap_pasted_text(fixture);
let expected = "@[/usr/bin/env]";
assert_eq!(actual, expected);
}
#[test]
fn test_strip_surrounding_quotes_single() {
let actual = strip_surrounding_quotes("'/some/path'");
let expected = "/some/path";
assert_eq!(actual, expected);
}
#[test]
fn test_strip_surrounding_quotes_double() {
let actual = strip_surrounding_quotes("\"/some/path\"");
let expected = "/some/path";
assert_eq!(actual, expected);
}
#[test]
fn test_strip_surrounding_quotes_none() {
let actual = strip_surrounding_quotes("/some/path");
let expected = "/some/path";
assert_eq!(actual, expected);
}
#[test]
fn test_strip_surrounding_quotes_single_char() {
let actual = strip_surrounding_quotes("'");
let expected = "'";
assert_eq!(actual, expected);
}
// -- Tests for paths with spaces -----------------------------------------
/// Helper that creates a temp directory containing a file at the given
/// relative path (which may include spaces) and returns the absolute path
/// to that file along with the `TempDir` guard to keep it alive.
fn create_file_with_spaces(relative: &str) -> (String, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join(relative);
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&file_path, "test").unwrap();
(file_path.to_string_lossy().into_owned(), dir)
}
#[test]
fn test_wrap_pasted_text_bare_path_with_spaces() {
let (path, _dir) = create_file_with_spaces("my file.txt");
let actual = wrap_pasted_text(&path);
let expected = format!("@[{path}]");
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_single_quoted_path_with_spaces() {
let (path, _dir) = create_file_with_spaces("my file.txt");
let fixture = format!("'{path}'");
let actual = wrap_pasted_text(&fixture);
let expected = format!("@[{path}]");
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_double_quoted_path_with_spaces() {
let (path, _dir) = create_file_with_spaces("my file.txt");
let fixture = format!("\"{path}\"");
let actual = wrap_pasted_text(&fixture);
let expected = format!("@[{path}]");
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_path_with_spaces_in_directory() {
let (path, _dir) = create_file_with_spaces("my folder/file.txt");
let actual = wrap_pasted_text(&path);
let expected = format!("@[{path}]");
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_path_with_spaces_trailing_newline() {
let (path, _dir) = create_file_with_spaces("my file.txt");
let fixture = format!("{path}\n");
let actual = wrap_pasted_text(&fixture);
let expected = format!("@[{path}]\n");
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_path_with_spaces_crlf() {
let (path, _dir) = create_file_with_spaces("my file.txt");
let fixture = format!("{path}\r\n");
let actual = wrap_pasted_text(&fixture);
let expected = format!("@[{path}]\n");
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_quoted_path_with_spaces_in_sentence() {
let (path, _dir) = create_file_with_spaces("my file.txt");
let fixture = format!("check '{path}' please");
let actual = wrap_pasted_text(&fixture);
let expected = format!("check @[{path}] please");
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_nonexistent_path_with_spaces() {
let fixture = "/nonexistent/path with spaces/file.txt";
let actual = wrap_pasted_text(fixture);
let expected = "/nonexistent/path with spaces/file.txt";
assert_eq!(actual, expected);
}
// -- Tests for backslash-escaped paths -----------------------------------
#[test]
fn test_wrap_pasted_text_backslash_escaped_spaces() {
// Terminals like Ghostty send /path/my\ file.txt for drag-and-drop
let (path, _dir) = create_file_with_spaces("my file.txt");
let escaped = path.replace(' ', "\\ ");
let actual = wrap_pasted_text(&escaped);
let expected = format!("@[{path}]");
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_backslash_escaped_spaces_in_directory() {
let (path, _dir) = create_file_with_spaces("my folder/file.txt");
let escaped = path.replace(' ', "\\ ");
let actual = wrap_pasted_text(&escaped);
let expected = format!("@[{path}]");
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_backslash_escaped_nonexistent() {
let fixture = "/nonexistent/my\\ folder/file.txt";
let actual = wrap_pasted_text(fixture);
let expected = "/nonexistent/my\\ folder/file.txt";
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_backslash_escaped_in_sentence() {
let (path, _dir) = create_file_with_spaces("my file.txt");
let escaped = path.replace(' ', "\\ ");
let fixture = format!("check {escaped} please");
let actual = wrap_pasted_text(&fixture);
let expected = format!("check @[{path}] please");
assert_eq!(actual, expected);
}
#[test]
fn test_unescape_backslashes_spaces() {
let actual = unescape_backslashes("/path/my\\ file.txt");
let expected = Some("/path/my file.txt".to_string());
assert_eq!(actual, expected);
}
#[test]
fn test_unescape_backslashes_no_escapes() {
let actual = unescape_backslashes("/path/file.txt");
assert_eq!(actual, None);
}
#[test]
fn test_unescape_backslashes_trailing_backslash() {
let actual = unescape_backslashes("/path/file\\");
let expected = Some("/path/file\\".to_string());
assert_eq!(actual, expected);
}
#[test]
fn test_find_token_end_backslash_escaped_space() {
let actual = find_token_end("/path/my\\ file.txt please");
let expected = "/path/my\\ file.txt".len();
assert_eq!(actual, expected);
}
#[test]
fn test_resolve_file_path_plain() {
let actual = resolve_file_path("/usr/bin/env");
assert_eq!(actual, Some("/usr/bin/env".to_string()));
}
#[test]
fn test_resolve_file_path_escaped() {
let (path, _dir) = create_file_with_spaces("my file.txt");
let escaped = path.replace(' ', "\\ ");
let actual = resolve_file_path(&escaped);
assert_eq!(actual, Some(path));
}
#[test]
fn test_resolve_file_path_nonexistent() {
let actual = resolve_file_path("/nonexistent/file.txt");
assert_eq!(actual, None);
}
#[test]
fn test_resolve_file_path_directory() {
// /tmp is a real directory on macOS/Linux
let actual = resolve_file_path("/tmp");
assert_eq!(actual, Some("/tmp".to_string()));
}
#[test]
fn test_wrap_pasted_text_directory_path() {
// /tmp is a real directory, so it should be wrapped
let fixture = "/tmp";
let actual = wrap_pasted_text(fixture);
let expected = "@[/tmp]";
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_directory_in_sentence() {
let fixture = "look at /tmp please";
let actual = wrap_pasted_text(fixture);
let expected = "look at @[/tmp] please";
assert_eq!(actual, expected);
}
// -- Tests for VSCode-style drag-and-drop (path sent via sendText) -------
#[test]
fn test_wrap_pasted_text_vscode_quoted_path() {
// VSCode's preparePathForShell may single-quote paths with spaces
let (path, _dir) = create_file_with_spaces("my file.txt");
let fixture = format!("'{path}'");
let actual = wrap_pasted_text(&fixture);
let expected = format!("@[{path}]");
assert_eq!(actual, expected);
}
#[test]
fn test_wrap_pasted_text_vscode_plain_path() {
// VSCode sends a plain path for files without spaces
let fixture = "/usr/bin/env";
let actual = wrap_pasted_text(fixture);
let expected = "@[/usr/bin/env]";
assert_eq!(actual, expected);
}
// Verifies that Cyrillic text with multi-byte UTF-8 characters doesn't panic
// when pasting. The original bug was caused by unsafe string slicing at byte
// boundaries inside multi-byte UTF-8 characters.
#[test]
fn test_wrap_pasted_text_cyrillic_no_crash() {
let fixture = "Проверь ПОЛНОСТЬЮ этот проект на соответствие КАЖДОГО пункта функционала исходному тексту задачи";
// This should NOT panic - the fix uses .get() instead of direct slicing
let actual = wrap_pasted_text(fixture);
eprintln!("DEBUG: actual output = {:?}", actual);
// The text should be preserved (it contains no absolute paths)
// The important thing is this doesn't panic with "byte index is not a char
// boundary"
assert!(!actual.is_empty());
assert!(actual.starts_with("Проверь"));
}
#[test]
fn test_wrap_pasted_text_cyrillic_with_mixed_paths() {
// Mix of Cyrillic text and paths that should be wrapped
let fixture = "Проверь /usr/bin/env и /tmp пожалуйста";
let actual = wrap_pasted_text(fixture);
let expected = "Проверь @[/usr/bin/env] и @[/tmp] пожалуйста";
assert_eq!(actual, expected);
}
}