diff --git a/assets/shell-integration/bash/elyterminal.bash b/assets/shell-integration/bash/elyterminal.bash index 7b1134f..913cf8e 100644 --- a/assets/shell-integration/bash/elyterminal.bash +++ b/assets/shell-integration/bash/elyterminal.bash @@ -13,6 +13,16 @@ else [ -r ~/.bashrc ] && builtin source ~/.bashrc fi _ely_prompt() { - builtin printf '\e]7;file://%s\a\e]2;%s\a' "${PWD//\%/%25}" "${PWD##*/}" + builtin local ret=$? LC_ALL=C char url= i title + for (( i = 0; i < ${#PWD}; i++ )); do + char=${PWD:i:1} + case $char in + [[:cntrl:]]|%) builtin printf -v char '%%%02X' "'$char" ;; + esac + url+=$char + done + title=${PWD##*/} + builtin printf '\e]7;file://%s\a\e]2;%s\a' "$url" "${title//[[:cntrl:]]/}" + builtin return $ret } PROMPT_COMMAND="_ely_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND}" diff --git a/assets/shell-integration/fish/vendor_conf.d/elyterminal.fish b/assets/shell-integration/fish/vendor_conf.d/elyterminal.fish index 30bf31d..476affc 100644 --- a/assets/shell-integration/fish/vendor_conf.d/elyterminal.fish +++ b/assets/shell-integration/fish/vendor_conf.d/elyterminal.fish @@ -7,6 +7,6 @@ end set -e ELY_FISH_XDG_DATA_DIRS if status is-interactive function _ely_prompt --on-event fish_prompt - printf '\e]7;file://%s\a' (string replace -a % %25 -- $PWD) + printf '\e]7;file://%s\a' (string escape --style=url -- $PWD) end end diff --git a/assets/shell-integration/pwsh/elyterminal.ps1 b/assets/shell-integration/pwsh/elyterminal.ps1 index 87b2dda..5771d31 100644 --- a/assets/shell-integration/pwsh/elyterminal.ps1 +++ b/assets/shell-integration/pwsh/elyterminal.ps1 @@ -1,13 +1,15 @@ # ELYTerminal: wrap the prompt to report folder and title. $global:ElyPrompt = $function:prompt function global:prompt { + $text = & $global:ElyPrompt $folder = $executionContext.SessionState.Path.CurrentLocation if ($folder.Provider.Name -eq 'FileSystem') { - $path = $folder.ProviderPath.Replace('\', '/').Replace('%', '%25') + $parts = $folder.ProviderPath.Replace('\', '/') -split '/' + $path = ($parts | ForEach-Object { [Uri]::EscapeDataString($_) }) -join '/' if (-not $path.StartsWith('/')) { $path = "/$path" } - $leaf = Split-Path -Leaf $folder.ProviderPath + $leaf = (Split-Path -Leaf $folder.ProviderPath) -replace '\p{Cc}', '' $esc, $bel = [char]27, [char]7 [Console]::Write("$esc]7;file://$path$bel$esc]2;$leaf$bel") } - & $global:ElyPrompt + $text } diff --git a/assets/shell-integration/zsh/.zshenv b/assets/shell-integration/zsh/.zshenv index 8ed927f..5414977 100644 --- a/assets/shell-integration/zsh/.zshenv +++ b/assets/shell-integration/zsh/.zshenv @@ -8,7 +8,15 @@ fi [[ -r "${ZDOTDIR:-$HOME}/.zshenv" ]] && builtin source "${ZDOTDIR:-$HOME}/.zshenv" if [[ -o interactive ]]; then _ely_precmd() { - builtin printf '\e]7;file://%s\a\e]2;%s\a' "${PWD//\%/%25}" "${${PWD:t}:-/}" + builtin local ret=$? LC_ALL=C char url= + for char in ${(s::)PWD}; do + case $char in + [[:cntrl:]]|%) builtin printf -v char '%%%02X' "'$char" ;; + esac + url+=$char + done + builtin printf '\e]7;file://%s\a\e]2;%s\a' "$url" "${${${PWD:t}:-/}//[[:cntrl:]]/}" + builtin return ret } _ely_preexec() { builtin printf '\e]2;%s\a' "${1//[[:cntrl:]]/}" diff --git a/src/term/shell.rs b/src/term/shell.rs index 60f2705..e141cf4 100644 --- a/src/term/shell.rs +++ b/src/term/shell.rs @@ -1,5 +1,6 @@ use std::{ env, fs, + io::ErrorKind, path::{Path, PathBuf}, }; @@ -50,8 +51,14 @@ impl ShellIntegration { pub fn install(dir: &Path) -> anyhow::Result<()> { for (name, text) in SCRIPTS { let path = dir.join(name); - if fs::read_to_string(&path).is_ok_and(|kept| kept == text) { - continue; + match fs::read_to_string(&path) { + Ok(kept) if kept == text => continue, + Ok(_) => {} + Err(error) if matches!(error.kind(), ErrorKind::NotFound | ErrorKind::InvalidData) => {} + Err(error) => { + return Err(error) + .with_context(|| format!("shell integration: cannot read {}", path.display())); + } } let folder = path .parent() @@ -98,11 +105,12 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result { } "fish" => { let previous = env::var("XDG_DATA_DIRS").unwrap_or_default(); - let data = if previous.is_empty() { - format!("{}:/usr/local/share:/usr/share", script("fish")) + let rest = if previous.is_empty() { + "/usr/local/share:/usr/share" } else { - format!("{}:{previous}", script("fish")) + &previous }; + let data = format!("{}:{rest}", dir.display()); launch.env.push(("XDG_DATA_DIRS".into(), data)); launch.env.push(("ELY_FISH_XDG_DATA_DIRS".into(), previous)); } @@ -113,8 +121,12 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result { vec![ "-NoLogo".into(), "-NoExit".into(), + "-ExecutionPolicy".into(), + "Bypass".into(), "-Command".into(), - format!(". '{file}'"), + format!( + "try {{ . '{file}' }} catch {{ [Console]::Error.WriteLine(\"ELYTerminal: shell integration failed: $_\"); exit 1 }}" + ), ], )); } diff --git a/src/term/shell/tests.rs b/src/term/shell/tests.rs index 1099223..13bb41a 100644 --- a/src/term/shell/tests.rs +++ b/src/term/shell/tests.rs @@ -1,10 +1,13 @@ use std::time::{Duration, Instant}; -use alacritty_terminal::event::Event; +use alacritty_terminal::{event::Event, grid::Dimensions, index::Line}; use futures::channel::mpsc::TryRecvError; use super::*; -use crate::term::{PtyEvent, spawn}; +use crate::term::{Pty, PtyEvent, spawn}; + +/// A folder name with a space, a percent, a bell and a newline. +const INNER: &str = "in ner%\u{7}x\ny"; /// A fresh folder under the temp dir, by its real path. fn folder(name: &str) -> PathBuf { @@ -21,88 +24,135 @@ fn installed(shell: &str) -> String { .args(["-c", &format!("command -v {shell}")]) .output() .expect("sh runs"); - let path = String::from_utf8(found.stdout).expect("utf-8"); assert!(found.status.success(), "{shell} is not installed"); - path.trim().to_string() + String::from_utf8(found.stdout) + .expect("utf-8") + .trim() + .to_string() } -/// Starts `shell` with integration in a fresh folder, cds once, and returns folders and titles it reported. -fn reports(shell: &str) -> (PathBuf, Vec, Vec) { - crate::testlog::init(); - let root = folder(&format!( - "shell-{}", - shell.rsplit('/').next().expect("a name") - )); - let (dir, home, start) = ( - root.join("integration"), - root.join("home"), - root.join("start"), - ); - let inner = start.join("in ner%"); - for made in [&home, &inner] { - fs::create_dir_all(made).expect("a folder"); - } - install(&dir).expect("scripts install"); - let mut launch = launch(shell, start.clone(), &dir).expect("a launch"); - launch.env.push(("HOME".into(), home.display().to_string())); - let mut pty = spawn(&launch, (80, 24), (8, 16), 100).expect("the shell starts"); - let (mut folders, mut titles) = (Vec::new(), Vec::new()); - let deadline = Instant::now() + Duration::from_secs(20); - let mut asked = false; - while !folders.contains(&inner) { - assert!( - Instant::now() < deadline, - "{shell} reported {folders:?} and {titles:?} on\n{}", - screen(&pty) +/// A shell started with integration under a fake HOME, and what it reported. +struct Run { + shell: String, + pty: Pty, + root: PathBuf, + start: PathBuf, + folders: Vec, + titles: Vec, + deadline: Instant, +} + +impl Run { + /// Starts `shell` in a fresh folder; `files` go under its HOME first. + fn start(shell: &str, files: &[(&str, &str)]) -> Self { + crate::testlog::init(); + let root = folder(&format!( + "shell-{}", + shell.rsplit('/').next().expect("a name") + )); + let (dir, home, start) = ( + root.join("integration"), + root.join("home"), + root.join("start"), ); - match pty.events.try_recv() { - Ok(PtyEvent::Folder(folder)) => folders.push(folder), - Ok(PtyEvent::Term(Event::Title(title))) => titles.push(title), - Ok(PtyEvent::Term(Event::PtyWrite(reply))) => pty.write(reply.into_bytes()), - Ok(PtyEvent::Term(Event::ChildExit(status))) => { - panic!("{shell} ended, {status}, after {folders:?}") + fs::create_dir_all(start.join(INNER)).expect("the inner folder"); + fs::create_dir_all(&home).expect("home"); + for (name, text) in files { + let path = home.join(name); + fs::create_dir_all(path.parent().expect("a folder")).expect("a home folder"); + fs::write(path, text).expect("a home file"); + } + install(&dir).expect("scripts install"); + let mut launch = launch(shell, start.clone(), &dir).expect("a launch"); + launch.env.push(("HOME".into(), home.display().to_string())); + let pty = spawn(&launch, (100, 30), (8, 16), 100).expect("the shell starts"); + Self { + shell: shell.into(), + pty, + root, + start, + folders: Vec::new(), + titles: Vec::new(), + deadline: Instant::now() + Duration::from_secs(30), + } + } + + /// Reads events, answering the shell's terminal queries, until `done` holds. + fn until(&mut self, what: &str, done: impl Fn(&Self) -> bool) { + while !done(self) { + assert!( + Instant::now() < self.deadline, + "{}: no {what}; folders {:?}, titles {:?}, screen\n{}", + self.shell, + self.folders, + self.titles, + self.screen() + ); + match self.pty.events.try_recv() { + Ok(PtyEvent::Folder(folder)) => self.folders.push(folder), + Ok(PtyEvent::Term(Event::Title(title))) => self.titles.push(title), + Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()), + Ok(PtyEvent::Term(Event::ChildExit(status))) => { + panic!("{} ended, {status}:\n{}", self.shell, self.screen()) + } + Ok(_) => {} + Err(TryRecvError::Closed) => panic!("{}'s events ended", self.shell), + Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)), } - Ok(_) => {} - Err(TryRecvError::Closed) => panic!("{shell}'s events ended"), - Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)), - } - if !asked && folders.contains(&start) { - pty.write(format!("cd '{}'\r", inner.display()).into_bytes()); - asked = true; } } - pty.write(&b"exit\r"[..]); - loop { - assert!( - Instant::now() < deadline, - "{shell} did not exit:\n{}", - screen(&pty) - ); - match pty.events.try_recv() { - Ok(PtyEvent::Term(Event::ChildExit(_))) | Err(TryRecvError::Closed) => break, - Ok(PtyEvent::Term(Event::Title(title))) => titles.push(title), - Ok(PtyEvent::Term(Event::PtyWrite(reply))) => pty.write(reply.into_bytes()), - Ok(_) => {} - Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)), - } - } - drop(pty); - fs::remove_dir_all(&root).expect("cleanup"); - (start, folders, titles) -} -/// The screen's rows, for a failure's message. -fn screen(pty: &crate::term::Pty) -> String { - use alacritty_terminal::{grid::Dimensions, index::Line}; - let term = pty.term.lock(); - (0..term.screen_lines()) - .map(|line| { - let row = &term.grid()[Line(line as i32)]; - let text: String = row.into_iter().map(|cell| cell.c).collect(); - text.trim_end().to_string() - }) - .collect::>() - .join("\n") + fn send(&self, line: &str) { + self.pty.write(format!("{line}\r").into_bytes()); + } + + fn shows(&self, text: &str) -> bool { + self.screen().contains(text) + } + + fn screen(&self) -> String { + let term = self.pty.term.lock(); + (0..term.screen_lines()) + .map(|line| { + let row = &term.grid()[Line(line as i32)]; + let text: String = row.into_iter().map(|cell| cell.c).collect(); + text.trim_end().to_string() + }) + .collect::>() + .join("\n") + } + + /// The prompt's folder, the script loaded by `probe`, then a cd into the inner folder. + fn walk(&mut self, probe: &str) { + let start = self.start.clone(); + self.until("first folder", |run| run.folders.contains(&start)); + assert_eq!(self.folders[0], start); + self.send(probe); + self.until("loaded script", |run| run.shows("ELY-LOADED")); + self.send("cd in*"); + let inner = start.join(INNER); + self.until("inner folder", |run| run.folders.contains(&inner)); + } + + fn finish(mut self) { + self.send("exit"); + loop { + assert!( + Instant::now() < self.deadline, + "{} did not exit", + self.shell + ); + match self.pty.events.try_recv() { + Ok(PtyEvent::Term(Event::ChildExit(_))) | Err(TryRecvError::Closed) => break, + Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()), + Ok(_) => {} + Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)), + } + } + let root = self.root.clone(); + drop(self); + fs::remove_dir_all(&root).expect("cleanup"); + } } #[test] @@ -111,46 +161,84 @@ fn the_scripts_install_once_and_mend_a_changed_one() { install(&dir).expect("installs"); let zshenv = dir.join("zsh/.zshenv"); assert_eq!(fs::read_to_string(&zshenv).expect("written"), SCRIPTS[0].1); - fs::write(&zshenv, "changed").expect("write"); + fs::write(&zshenv, [0xff, 0xfe]).expect("write"); install(&dir).expect("installs again"); assert_eq!(fs::read_to_string(&zshenv).expect("mended"), SCRIPTS[0].1); fs::remove_dir_all(dir).expect("cleanup"); } #[test] -fn zsh_reports_its_folder_and_title() { - let (start, folders, titles) = reports(&installed("zsh")); - assert_eq!(folders[0], start); - assert!(titles.contains(&"start".to_string()), "{titles:?}"); +fn a_script_it_cannot_read_fails_the_install() { + use std::os::unix::fs::PermissionsExt; + let dir = folder("unreadable"); + install(&dir).expect("installs"); + let bash = dir.join("bash/elyterminal.bash"); + fs::set_permissions(&bash, fs::Permissions::from_mode(0o200)).expect("200"); + let error = install(&dir).expect_err("an unreadable script fails"); + fs::set_permissions(&bash, fs::Permissions::from_mode(0o600)).expect("600"); assert!( - titles.iter().any(|title| title.starts_with("cd ")), - "{titles:?}" + format!("{error:#}").contains("bash/elyterminal.bash"), + "{error:#}" ); + fs::remove_dir_all(dir).expect("cleanup"); } #[test] -fn bash_reports_its_folder_and_title() { - let (start, folders, titles) = reports(&installed("bash")); - assert_eq!(folders[0], start); - assert!(titles.contains(&"in ner%".to_string()), "{titles:?}"); +fn zsh_reports_its_folder_byte_for_byte_and_its_titles() { + let mut run = Run::start(&installed("zsh"), &[]); + run.walk("(( $+functions[_ely_precmd] )) && echo ELY-LOAD''ED"); + run.until("title", |run| { + run.titles.iter().any(|title| title == "in ner%xy") + }); + assert!( + run.titles.contains(&"cd in*".to_string()), + "{:?}", + run.titles + ); + run.finish(); } #[test] -fn fish_reports_its_folder_and_keeps_its_own_title() { - let (start, folders, titles) = reports(&installed("fish")); - assert_eq!(folders[0], start); - assert!( - titles.iter().any(|title| title.ends_with("start")), - "{titles:?}" - ); +fn bash_reports_its_folder_byte_for_byte_and_keeps_the_status() { + let status = r#"PROMPT_COMMAND='printf "USER_STATUS=%s\n" "$?"'"#; + let files = [(".bashrc", status), (".bash_profile", status)]; + let mut run = Run::start(&installed("bash"), &files); + run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED"); + run.until("title", |run| { + run.titles.iter().any(|title| title == "in ner%xy") + }); + run.send("false"); + run.until("kept status", |run| run.shows("USER_STATUS=1")); + run.finish(); +} + +#[test] +fn fish_loads_its_script_and_restores_its_data_dirs() { + let mut run = Run::start(&installed("fish"), &[]); + run.walk("functions -q _ely_prompt; and echo ELY-LOAD''ED"); + let previous = env::var("XDG_DATA_DIRS").unwrap_or_default(); + run.send(r#"echo "DIRS[$ELY_FISH_XDG_DATA_DIRS][$XDG_DATA_DIRS]""#); + let restored = format!("DIRS[][{previous}]"); + run.until("restored dirs", |run| run.shows(&restored)); + run.finish(); } #[cfg(target_os = "macos")] #[test] -fn powershell_reports_its_folder_and_title() { - let (start, folders, titles) = reports(&installed("pwsh")); - assert_eq!(folders[0], start); - assert!(titles.contains(&"in ner%".to_string()), "{titles:?}"); +fn powershell_reports_its_folder_byte_for_byte_and_keeps_the_status() { + let prompt = r#"function global:prompt { "USER_SUCCESS=$? > " }"#; + let files = [( + ".config/powershell/Microsoft.PowerShell_profile.ps1", + prompt, + )]; + let mut run = Run::start(&installed("pwsh"), &files); + run.walk("if ($global:ElyPrompt) { 'ELY-LOAD' + 'ED' }"); + run.until("title", |run| { + run.titles.iter().any(|title| title == "in ner%xy") + }); + run.send("/usr/bin/false"); + run.until("kept status", |run| run.shows("USER_SUCCESS=False")); + run.finish(); } #[test]