Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
//! Implementation of `JsExecutionContext` for V2 completions in terminal sessions.
|
||||
//!
|
||||
//! This calls out to the `CallJsFunctionService` IPC service, which is served by the plugin host
|
||||
//! process where JS plugins are loaded and executed.
|
||||
use async_trait::async_trait;
|
||||
use ipc::ServiceCaller;
|
||||
use std::sync::Arc;
|
||||
use warp_completer::completer::{JsExecutionContext, JsExecutionError};
|
||||
use warp_js::{JsFunctionId, SerializedJsValue};
|
||||
|
||||
use crate::plugin::service::{
|
||||
CallJsFunctionRequest, CallJsFunctionResponse, CallJsFunctionService,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionJsExecutionContext {
|
||||
js_function_caller: Arc<dyn ServiceCaller<CallJsFunctionService>>,
|
||||
}
|
||||
|
||||
impl SessionJsExecutionContext {
|
||||
pub fn new(js_function_caller: Box<dyn ServiceCaller<CallJsFunctionService>>) -> Self {
|
||||
Self {
|
||||
js_function_caller: Arc::from(js_function_caller),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl JsExecutionContext for SessionJsExecutionContext {
|
||||
async fn call_js_function(
|
||||
&self,
|
||||
input: SerializedJsValue,
|
||||
function_id: JsFunctionId,
|
||||
) -> Result<SerializedJsValue, JsExecutionError> {
|
||||
let response = self
|
||||
.js_function_caller
|
||||
.call(CallJsFunctionRequest {
|
||||
id: function_id,
|
||||
serialized_input: input,
|
||||
})
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(CallJsFunctionResponse::Success(output)) => Ok(output),
|
||||
Ok(CallJsFunctionResponse::Error { message }) => {
|
||||
Err(JsExecutionError::Internal(message))
|
||||
}
|
||||
Err(e) => Err(JsExecutionError::Internal(format!(
|
||||
"IPC error occurred: {e:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
#[cfg(feature = "completions_v2")]
|
||||
mod js;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Deref;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use lazy_static::lazy_static;
|
||||
use smol_str::SmolStr;
|
||||
use typed_path::{TypedPath, TypedPathBuf};
|
||||
use warp_completer::completer::{
|
||||
CommandExitStatus, CommandOutput, CompletionContext, EngineDirEntry, EngineFileType,
|
||||
GeneratorContext, PathCompletionContext, PathSeparators, TopLevelCommandCaseSensitivity,
|
||||
};
|
||||
use warp_completer::signatures::CommandRegistry;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_util::path::{EscapeChar, ShellFamily};
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::safe_warn;
|
||||
use crate::terminal::model::session::{ExecuteCommandOptions, Session, SessionType};
|
||||
use crate::util::AsciiDebug;
|
||||
use crate::workflows::aliases::WorkflowAliases;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref CURR_DIRECTORY_ENTRY: EngineDirEntry = EngineDirEntry {
|
||||
file_name: ".".to_owned(),
|
||||
file_type: EngineFileType::Directory,
|
||||
};
|
||||
pub static ref PARENT_DIRECTORY_ENTRY: EngineDirEntry = EngineDirEntry {
|
||||
file_name: "..".to_owned(),
|
||||
file_type: EngineFileType::Directory,
|
||||
};
|
||||
static ref EMPTY_COMMAND_REGISTRY: Arc<CommandRegistry> = Arc::new(CommandRegistry::empty());
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionContext {
|
||||
pub session: Arc<Session>,
|
||||
command_registry: Arc<CommandRegistry>,
|
||||
pub current_working_directory: TypedPathBuf,
|
||||
|
||||
#[cfg(feature = "completions_v2")]
|
||||
js_ctx: Option<js::SessionJsExecutionContext>,
|
||||
|
||||
cached_directory_entries: dashmap::DashMap<TypedPathBuf, Arc<Vec<EngineDirEntry>>>,
|
||||
|
||||
/// Snapshot of all Warp workflow aliases.
|
||||
workflow_aliases: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl SessionContext {
|
||||
async fn list_directory_entries_internal(
|
||||
&self,
|
||||
directory: &TypedPath<'_>,
|
||||
) -> Vec<EngineDirEntry> {
|
||||
match self.session.session_type() {
|
||||
SessionType::Local => {
|
||||
let dir = match self.session.maybe_convert_to_native_path(directory) {
|
||||
Ok(dir) => dir,
|
||||
Err(err) => {
|
||||
log::warn!("Failed to convert path: {err:#}");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
// We intentionally use the synchronous `std::fs::read_dir`,
|
||||
// despite this being an async function, because the overhead
|
||||
// of switching threads is very expensive relative to the
|
||||
// amount of work being done. Converting the a `DirEntry`
|
||||
// to `EngineDirEntry` can usually be done without additional
|
||||
// syscalls (though one is necessary if the entry is a
|
||||
// symlink).
|
||||
//
|
||||
// It's possible that it would be better to use
|
||||
// `async_fs::read_dir` if the directory is on a network mount,
|
||||
// but I don't think it's worth optimizing for that case.
|
||||
let Some(read_dir) = std::fs::read_dir(dir.as_path()).ok() else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
read_dir
|
||||
.filter_map(|res| res.and_then(EngineDirEntry::try_from).ok())
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
SessionType::WarpifiedRemote { .. } => {
|
||||
let env_vars = self
|
||||
.session
|
||||
.path()
|
||||
.as_deref()
|
||||
.map(|path| HashMap::from_iter([("PATH".to_string(), path.to_string())]));
|
||||
|
||||
let Some(ls_command) = ls_script_for_dir(directory) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
// The in-band command executor doesn't support executing from
|
||||
// from an arbitrary directory, so we need to cd into the
|
||||
// directory we want within the ls script.
|
||||
let command_output_result = self
|
||||
.session
|
||||
.execute_command(
|
||||
&ls_command,
|
||||
None,
|
||||
env_vars,
|
||||
ExecuteCommandOptions::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Ok(command_output) = command_output_result {
|
||||
let Ok(output_string) = command_output.to_string() else {
|
||||
log::warn!(
|
||||
"Executing `ls` on remote box returned unparseable bytes: `{:?}`",
|
||||
AsciiDebug(command_output.output())
|
||||
);
|
||||
return vec![];
|
||||
};
|
||||
|
||||
match command_output.status {
|
||||
CommandExitStatus::Success => {
|
||||
let mut entries = Vec::new();
|
||||
let mut entries_iter = output_string.split('\0');
|
||||
let dirs = entries_iter
|
||||
.by_ref()
|
||||
// We use two consecutive null characters to separate files and
|
||||
// folders, so detect that here. Note that take_while consumes the
|
||||
// first entry that returns false.
|
||||
.take_while(|entry| !entry.is_empty())
|
||||
.filter_map(|entry| {
|
||||
if entry == "." {
|
||||
return None;
|
||||
}
|
||||
|
||||
Path::new(entry)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| EngineDirEntry {
|
||||
file_name: name.to_owned(),
|
||||
file_type: EngineFileType::Directory,
|
||||
})
|
||||
});
|
||||
entries.extend(dirs);
|
||||
|
||||
let files = entries_iter.filter_map(|entry| {
|
||||
Path::new(entry)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| EngineDirEntry {
|
||||
file_name: name.to_owned(),
|
||||
file_type: EngineFileType::File,
|
||||
})
|
||||
});
|
||||
entries.extend(files);
|
||||
|
||||
entries
|
||||
}
|
||||
CommandExitStatus::Failure => {
|
||||
safe_warn!(
|
||||
safe: ("Executing `ls` on remote box failed with non-zero status code."),
|
||||
full: ("Executing `ls` on remote box failed with error: {}", &String::from_utf8_lossy(command_output.output()))
|
||||
);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!(
|
||||
"Executing `ls` on remote box failed with error {command_output_result:?}"
|
||||
);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PathCompletionContext for SessionContext {
|
||||
fn home_directory(&self) -> Option<&str> {
|
||||
self.session.home_dir()
|
||||
}
|
||||
|
||||
fn pwd(&self) -> TypedPath<'_> {
|
||||
self.current_working_directory.to_path()
|
||||
}
|
||||
|
||||
fn shell_family(&self) -> ShellFamily {
|
||||
self.session.shell_family()
|
||||
}
|
||||
|
||||
async fn list_directory_entries(&self, directory: TypedPathBuf) -> Arc<Vec<EngineDirEntry>> {
|
||||
if let Some(entries) = self.cached_directory_entries.get(&directory) {
|
||||
return entries.clone();
|
||||
}
|
||||
|
||||
let result = self
|
||||
.list_directory_entries_internal(&directory.to_path())
|
||||
.await;
|
||||
|
||||
let result = Arc::new(result);
|
||||
self.cached_directory_entries
|
||||
.insert(directory, result.clone());
|
||||
result
|
||||
}
|
||||
|
||||
fn path_separators(&self) -> PathSeparators {
|
||||
self.session.path_separators()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GeneratorContext for SessionContext {
|
||||
async fn execute_command_at_pwd(
|
||||
&self,
|
||||
shell_command: &str,
|
||||
session_env_vars: Option<HashMap<String, String>>,
|
||||
) -> Result<CommandOutput> {
|
||||
let mut env_vars = session_env_vars.unwrap_or_default();
|
||||
// We need to run the command with the PATH var set explicitly even if we have session env vars
|
||||
// because if the user opened Warp through a parent process that didn't have the PATH var set
|
||||
// (i.e. outside of a shell, for example opening the app via Finder),
|
||||
// the subshell won't inherit the PATH var, but we need the PATH var
|
||||
// to reference executables we might run as part of generators.
|
||||
if let Some(path) = self.session.path().as_deref() {
|
||||
env_vars.insert("PATH".to_string(), path.to_string());
|
||||
}
|
||||
|
||||
let env_vars_option = if env_vars.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(env_vars)
|
||||
};
|
||||
|
||||
self.session
|
||||
.execute_command(
|
||||
shell_command,
|
||||
self.pwd().to_str(),
|
||||
env_vars_option,
|
||||
ExecuteCommandOptions {
|
||||
run_command_in_same_shell_as_session: !FeatureFlag::RunGeneratorsWithCmdExe
|
||||
.is_enabled(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn supports_parallel_execution(&self) -> bool {
|
||||
self.session.supports_parallel_command_execution()
|
||||
}
|
||||
}
|
||||
|
||||
impl CompletionContext for SessionContext {
|
||||
fn generator_context(&self) -> Option<&dyn GeneratorContext> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn path_completion_context(&self) -> Option<&dyn PathCompletionContext> {
|
||||
Some(self)
|
||||
}
|
||||
|
||||
fn top_level_commands(&self) -> Box<dyn Iterator<Item = &str> + '_> {
|
||||
Box::new(
|
||||
self.session
|
||||
.top_level_commands()
|
||||
.chain(self.workflow_aliases.keys().map(String::as_str)),
|
||||
)
|
||||
}
|
||||
|
||||
fn command_case_sensitivity(&self) -> TopLevelCommandCaseSensitivity {
|
||||
self.session.command_case_sensitivity()
|
||||
}
|
||||
|
||||
fn escape_char(&self) -> EscapeChar {
|
||||
self.session.shell_family().escape_char()
|
||||
}
|
||||
|
||||
fn aliases(&self) -> Box<dyn Iterator<Item = (&str, &str)> + '_> {
|
||||
let session_aliases = self
|
||||
.session
|
||||
.aliases()
|
||||
.iter()
|
||||
.map(|(alias, command)| (alias.as_str(), command.as_str()));
|
||||
let workflow_aliases = self
|
||||
.workflow_aliases
|
||||
.iter()
|
||||
.map(|(alias, command)| (alias.as_str(), command.as_str()));
|
||||
Box::new(workflow_aliases.chain(session_aliases))
|
||||
}
|
||||
|
||||
fn alias_command(&self, alias: &str) -> Option<&str> {
|
||||
self.workflow_aliases
|
||||
.get(alias)
|
||||
.or_else(|| self.session.aliases().get(alias))
|
||||
.map(Deref::deref)
|
||||
}
|
||||
|
||||
fn abbreviations(&self) -> Option<&HashMap<SmolStr, String>> {
|
||||
Some(self.session.abbreviations())
|
||||
}
|
||||
|
||||
fn functions(&self) -> Option<&HashSet<SmolStr>> {
|
||||
Some(self.session.functions())
|
||||
}
|
||||
|
||||
fn builtins(&self) -> Option<&HashSet<SmolStr>> {
|
||||
Some(self.session.builtins())
|
||||
}
|
||||
|
||||
fn command_registry(&self) -> &CommandRegistry {
|
||||
&self.command_registry
|
||||
}
|
||||
|
||||
fn environment_variable_names(&self) -> Option<&HashSet<SmolStr>> {
|
||||
Some(self.session.environment_variable_names())
|
||||
}
|
||||
|
||||
fn shell_supports_autocd(&self) -> Option<bool> {
|
||||
Some(self.session.shell().supports_autocd())
|
||||
}
|
||||
|
||||
#[cfg(feature = "completions_v2")]
|
||||
fn js_context(&self) -> Option<&dyn warp_completer::completer::JsExecutionContext> {
|
||||
self.js_ctx
|
||||
.as_ref()
|
||||
.map(|ctx| -> &dyn warp_completer::completer::JsExecutionContext { ctx })
|
||||
}
|
||||
|
||||
fn shell_family(&self) -> Option<ShellFamily> {
|
||||
Some(self.session.shell_family())
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionContext {
|
||||
pub fn new(
|
||||
session: impl Into<Arc<Session>>,
|
||||
command_registry: Arc<CommandRegistry>,
|
||||
current_working_directory: TypedPathBuf,
|
||||
#[allow(unused_variables)] ctx: &AppContext,
|
||||
) -> Self {
|
||||
let workflow_aliases = if FeatureFlag::WorkflowAliases.is_enabled() {
|
||||
WorkflowAliases::as_ref(ctx).autocomplete_data(ctx)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "completions_v2")] {
|
||||
use crate::plugin::{PluginHost, service::CallJsFunctionService};
|
||||
|
||||
let js_function_caller = PluginHost::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.plugin_service_caller::<CallJsFunctionService>();
|
||||
Self {
|
||||
session: session.into(),
|
||||
command_registry,
|
||||
current_working_directory,
|
||||
js_ctx: js_function_caller.map(js::SessionJsExecutionContext::new),
|
||||
cached_directory_entries: Default::default(),
|
||||
workflow_aliases,
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
session: session.into(),
|
||||
command_registry,
|
||||
current_working_directory,
|
||||
cached_directory_entries: Default::default(),
|
||||
workflow_aliases,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `CompletionContext` implementation for "global" completions, that provide completions on all
|
||||
/// commands in the `command_registry` rather than providing session-specific completions.
|
||||
///
|
||||
/// This `CompletionContext` is not coupled to a specific session and thus does not provide path or
|
||||
/// generator execution, which wouldn't have clear semantics without being coupled to a session.
|
||||
#[derive(Clone)]
|
||||
pub struct SessionAgnosticContext {
|
||||
command_registry: Arc<CommandRegistry>,
|
||||
}
|
||||
|
||||
impl SessionAgnosticContext {
|
||||
pub fn new(command_registry: Arc<CommandRegistry>) -> Self {
|
||||
Self { command_registry }
|
||||
}
|
||||
}
|
||||
|
||||
impl CompletionContext for SessionAgnosticContext {
|
||||
fn top_level_commands(&self) -> Box<dyn Iterator<Item = &str> + '_> {
|
||||
Box::new(self.command_registry.registered_commands())
|
||||
}
|
||||
|
||||
fn command_registry(&self) -> &CommandRegistry {
|
||||
&self.command_registry
|
||||
}
|
||||
|
||||
fn environment_variable_names(&self) -> Option<&HashSet<SmolStr>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn shell_supports_autocd(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
fn path_completion_context(&self) -> Option<&dyn PathCompletionContext> {
|
||||
None
|
||||
}
|
||||
|
||||
fn generator_context(&self) -> Option<&dyn GeneratorContext> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty `CompletionContext` used in places without a live shell session
|
||||
/// (i.e. shared session viewers without a real terminal instance).
|
||||
#[derive(Clone)]
|
||||
pub struct EmptyCompletionContext;
|
||||
impl EmptyCompletionContext {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
impl CompletionContext for EmptyCompletionContext {
|
||||
fn top_level_commands(&self) -> Box<dyn Iterator<Item = &str> + '_> {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
|
||||
fn command_registry(&self) -> &CommandRegistry {
|
||||
&EMPTY_COMMAND_REGISTRY
|
||||
}
|
||||
|
||||
fn environment_variable_names(&self) -> Option<&HashSet<SmolStr>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn shell_supports_autocd(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
fn path_completion_context(&self) -> Option<&dyn PathCompletionContext> {
|
||||
None
|
||||
}
|
||||
|
||||
fn generator_context(&self) -> Option<&dyn GeneratorContext> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// List files and directories in a directory; used for completions on a remote machine.
|
||||
/// This uses `find` instead of `ls` due to the challenges of parsing `ls` output for
|
||||
/// unusual file names (e.g.: ones including newlines).
|
||||
/// We intentionally ignore '.' and '..' here as we add those suggestions manually.
|
||||
fn ls_script_for_dir(directory: &TypedPath) -> Option<String> {
|
||||
// We need to cd into the directory we want completions for
|
||||
let Some(dir_str) = directory.to_str() else {
|
||||
log::warn!("Non-unicode character found in path: `{directory:?}`");
|
||||
return None;
|
||||
};
|
||||
let escaped_dir = warp_util::path::ShellFamily::Posix.shell_escape(dir_str);
|
||||
|
||||
// Get all directories with -print0, which makes all items end in `\0` (null character)
|
||||
// Get all files with -print0, which makes all items end in `\0`
|
||||
// Separate the two lists with `\0`
|
||||
// Ex: `a\0b\0\c\0\0d.txt\0e.txt\0f.txt\0`
|
||||
// Then do the same for anything that is not a directory, and call it a 'File'.
|
||||
let command = format!(
|
||||
r#"
|
||||
cd {escaped_dir} &&
|
||||
find . -maxdepth 1 -type d -print0 &&
|
||||
printf '%b' '\0' &&
|
||||
find . -maxdepth 1 -not -type d -print0
|
||||
"#
|
||||
)
|
||||
// Ensure all newlines are escaped, and that the command is a single line.
|
||||
// ls_script_for_dir should not contain newlines, as we need to run it as a
|
||||
// single line for TMUX control mode at this time.
|
||||
.replace("\n", " ");
|
||||
|
||||
Some(command)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "test.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,374 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::iter::FromIterator;
|
||||
use std::sync::Arc;
|
||||
|
||||
use itertools::Itertools;
|
||||
use typed_path::TypedPathBuf;
|
||||
#[cfg(windows)]
|
||||
use typed_path::{UnixComponent, WindowsComponent, WindowsPrefix};
|
||||
use warp_completer::completer::PathCompletionContext;
|
||||
use warp_completer::completer::{CompletionContext, EngineDirEntry};
|
||||
use warp_completer::signatures::CommandRegistry;
|
||||
use warpui::App;
|
||||
|
||||
use crate::completer::SessionContext;
|
||||
use crate::terminal::model::session::Session;
|
||||
use crate::terminal::model::session::{
|
||||
command_executor::testing::TestCommandExecutor, SessionInfo,
|
||||
};
|
||||
use crate::test_util::{Stub, VirtualFS};
|
||||
|
||||
fn test_session_context(session: Session, cwd: TypedPathBuf, app: &App) -> SessionContext {
|
||||
app.read(|ctx| SessionContext::new(session, CommandRegistry::default().into(), cwd, ctx))
|
||||
}
|
||||
|
||||
fn working_directory() -> TypedPathBuf {
|
||||
#[cfg(unix)]
|
||||
let cwd = TypedPathBuf::from("/test/home/");
|
||||
#[cfg(windows)]
|
||||
let cwd = TypedPathBuf::from_windows(shellexpand::tilde("~").into_owned());
|
||||
|
||||
cwd
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_session_context_top_level_commands_includes_function_names() {
|
||||
App::test((), |app| async move {
|
||||
let function_names = vec![
|
||||
"my_func".into(),
|
||||
"foo".into(),
|
||||
"bar".into(),
|
||||
"foobar".into(),
|
||||
];
|
||||
let session = Session::new(
|
||||
SessionInfo::new_for_test()
|
||||
.with_function_names(function_names.clone().into_iter().collect()),
|
||||
Arc::new(TestCommandExecutor::default()),
|
||||
);
|
||||
let ctx = test_session_context(session, working_directory(), &app);
|
||||
|
||||
let top_level_commands = ctx.top_level_commands().collect_vec();
|
||||
for function_name in function_names.iter() {
|
||||
assert!(top_level_commands.contains(&function_name.as_str()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_session_context_top_level_commands_includes_aliases() {
|
||||
App::test((), |app| async move {
|
||||
let aliases = HashMap::from_iter([
|
||||
("first".into(), "test one".into()),
|
||||
("second".into(), "first".into()),
|
||||
("third".into(), "cd".into()),
|
||||
("ls".into(), "ls -l".into()),
|
||||
]);
|
||||
let session = Session::new(
|
||||
SessionInfo::new_for_test().with_aliases(aliases.clone()),
|
||||
Arc::new(TestCommandExecutor::default()),
|
||||
);
|
||||
let ctx = test_session_context(session, working_directory(), &app);
|
||||
|
||||
let top_level_commands = ctx.top_level_commands().collect_vec();
|
||||
for alias in aliases.keys() {
|
||||
assert!(top_level_commands.contains(&alias.as_str()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_session_context_top_level_commands_includes_abbreviations() {
|
||||
App::test((), |app| async move {
|
||||
let abbreviations = HashMap::from_iter([
|
||||
("gl".into(), "git log".into()),
|
||||
("gs".into(), "git status".into()),
|
||||
]);
|
||||
let session = Session::new(
|
||||
SessionInfo::new_for_test().with_abbreviations(abbreviations.clone()),
|
||||
Arc::new(TestCommandExecutor::default()),
|
||||
);
|
||||
let ctx = test_session_context(session, working_directory(), &app);
|
||||
|
||||
let top_level_commands = ctx.top_level_commands().collect_vec();
|
||||
for abbreviation in abbreviations.keys() {
|
||||
assert!(top_level_commands.contains(&abbreviation.as_str()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_session_context_top_level_commands_includes_keywords() {
|
||||
App::test((), |app| async move {
|
||||
let keywords = vec!["while".into(), "foreach".into(), "repeat".into()];
|
||||
let session = Session::new(
|
||||
SessionInfo::new_for_test().with_keywords(keywords.clone()),
|
||||
Arc::new(TestCommandExecutor::default()),
|
||||
);
|
||||
let ctx = test_session_context(session, working_directory(), &app);
|
||||
|
||||
let top_level_commands = ctx.top_level_commands().collect_vec();
|
||||
for keyword in keywords.iter() {
|
||||
assert!(top_level_commands.contains(&keyword.as_str()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_session_context_top_level_commands_includes_external_commands() {
|
||||
App::test((), |app| async move {
|
||||
let session = Session::new(
|
||||
SessionInfo::new_for_test(),
|
||||
Arc::new(TestCommandExecutor::default()),
|
||||
);
|
||||
warpui::r#async::block_on(session.load_external_commands());
|
||||
|
||||
let ctx = test_session_context(session, working_directory(), &app);
|
||||
|
||||
// We expect git to be installed and on the PATH on all machines on
|
||||
// which we're running our unit tests.
|
||||
assert!(ctx.top_level_commands().contains(&"git"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_session_context_top_level_commands_includes_builtins() {
|
||||
App::test((), |app| async move {
|
||||
let builtins = vec!["export".into(), "print".into(), "break".into()];
|
||||
let session = Session::new(
|
||||
SessionInfo::new_for_test().with_builtins(builtins.clone().into_iter().collect()),
|
||||
Arc::new(TestCommandExecutor::default()),
|
||||
);
|
||||
let ctx = test_session_context(session, working_directory(), &app);
|
||||
|
||||
let top_level_commands = ctx.top_level_commands().collect_vec();
|
||||
for builtin in builtins.iter() {
|
||||
assert!(top_level_commands.contains(&builtin.as_str()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_session_context_lists_directory_entries_locally() {
|
||||
App::test((), |app| async move {
|
||||
VirtualFS::test(
|
||||
"test_session_context_lists_directory_entries_locally",
|
||||
|dirs, mut sandbox| {
|
||||
sandbox.mkdir("src/app");
|
||||
sandbox.mkdir("target/debug");
|
||||
sandbox.mkdir(".hidden/foo");
|
||||
|
||||
sandbox.touch(vec![
|
||||
Stub::EmptyFile("Cargo.toml"),
|
||||
Stub::EmptyFile("src/app/mod.rs"),
|
||||
Stub::EmptyFile("target/debug/warpui"),
|
||||
]);
|
||||
|
||||
let tests_dir = TypedPathBuf::from(dirs.tests().to_string_lossy().as_bytes());
|
||||
|
||||
let ctx = test_session_context(Session::test(), tests_dir.clone(), &app);
|
||||
let ctx = ctx
|
||||
.path_completion_context()
|
||||
.expect("Path completion context should exist with active session");
|
||||
|
||||
assert_eq!(
|
||||
HashSet::<EngineDirEntry>::from_iter(Arc::unwrap_or_clone(
|
||||
warpui::r#async::block_on(ctx.list_directory_entries(tests_dir))
|
||||
)),
|
||||
HashSet::from_iter([
|
||||
EngineDirEntry::test_dir(".hidden"),
|
||||
EngineDirEntry::test_file("Cargo.toml"),
|
||||
EngineDirEntry::test_dir("target"),
|
||||
EngineDirEntry::test_dir("src"),
|
||||
])
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Given a Windows-encoded path, such as `C:\User\my_username`,
|
||||
/// convert it to a UNIX shell path that will work with the `bash`
|
||||
/// executable, such as `/mnt/c/Users/my_username`.
|
||||
///
|
||||
/// This is NOT the same as MSYS2 encoding, which does not
|
||||
/// use the `/mnt` prefix.
|
||||
#[cfg(windows)]
|
||||
fn windows_to_unix_shell_encoding(
|
||||
windows_path: &typed_path::Path<typed_path::WindowsEncoding>,
|
||||
) -> TypedPathBuf {
|
||||
let mut unix_path = TypedPathBuf::unix();
|
||||
for component in windows_path.components() {
|
||||
match component {
|
||||
WindowsComponent::Prefix(p) => {
|
||||
match p.kind() {
|
||||
WindowsPrefix::Disk(disk_letter) | WindowsPrefix::VerbatimDisk(disk_letter) => {
|
||||
let disk_byte = &[disk_letter];
|
||||
let drive_name = String::from_utf8_lossy(disk_byte);
|
||||
unix_path.push(UnixComponent::RootDir);
|
||||
unix_path.push("mnt");
|
||||
unix_path.push(drive_name.to_string().to_ascii_lowercase());
|
||||
}
|
||||
_ => {} // We don't care about other prefix types (see https://doc.rust-lang.org/nightly/std/path/enum.Prefix.html).
|
||||
}
|
||||
}
|
||||
// Avoid adding the root directory twice if there's already a drive
|
||||
WindowsComponent::RootDir => {}
|
||||
_ => {
|
||||
unix_path.push(component);
|
||||
}
|
||||
}
|
||||
}
|
||||
unix_path
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore = "TODO(CORE-3626)")]
|
||||
#[test]
|
||||
pub fn test_session_context_lists_directory_entries_remotely() {
|
||||
App::test((), |app| async move {
|
||||
VirtualFS::test(
|
||||
"test_session_context_lists_directory_entries_remotely",
|
||||
|dirs, mut sandbox| {
|
||||
sandbox.mkdir("src/app");
|
||||
sandbox.mkdir("target/debug");
|
||||
|
||||
sandbox.touch(vec![
|
||||
Stub::EmptyFile("control_path.socket"),
|
||||
Stub::EmptyFile("Cargo.toml"),
|
||||
Stub::EmptyFile("src/app/mod.rs"),
|
||||
Stub::EmptyFile("target/debug/warpui"),
|
||||
]);
|
||||
|
||||
let cwd = TypedPathBuf::from(dirs.tests().to_string_lossy().as_bytes());
|
||||
|
||||
// We assume all remotes are UNIX-based.
|
||||
// The test directory we're using here is a local temp directory, which means
|
||||
// it uses native path encoding.
|
||||
// On Windows, we must convert the test directory to UNIX encoding
|
||||
// before being able to run bash commands within it.
|
||||
#[cfg(windows)]
|
||||
let cwd = match cwd {
|
||||
TypedPathBuf::Unix(_) => cwd,
|
||||
TypedPathBuf::Windows(windows_path) => {
|
||||
windows_to_unix_shell_encoding(windows_path.as_path())
|
||||
}
|
||||
};
|
||||
|
||||
let ctx = test_session_context(Session::test_remote(), cwd.clone(), &app);
|
||||
|
||||
let mut entries = HashSet::<EngineDirEntry>::from_iter(Arc::unwrap_or_clone(
|
||||
warpui::r#async::block_on(ctx.list_directory_entries(cwd)),
|
||||
));
|
||||
// TODO(CORE-2000): The ls script we use to list entries in remote
|
||||
// sessions adds a spurious "." directory when run in the VirtualFS.
|
||||
// As a temporary workaround, we remove this file in the test.
|
||||
entries.remove(&EngineDirEntry::test_dir("."));
|
||||
|
||||
assert_eq!(
|
||||
entries,
|
||||
HashSet::from_iter([
|
||||
EngineDirEntry::test_file("Cargo.toml"),
|
||||
EngineDirEntry::test_file("control_path.socket"),
|
||||
EngineDirEntry::test_dir("src"),
|
||||
EngineDirEntry::test_dir("target"),
|
||||
])
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn perform_special_characters_in_path_test(session: Session, file_names: Vec<&str>) {
|
||||
let file_names = file_names
|
||||
.iter()
|
||||
.map(|&filename| String::from(filename))
|
||||
.collect_vec();
|
||||
App::test((), |app| async move {
|
||||
VirtualFS::test(
|
||||
"test_session_context_lists_directory_entries_with_special_characters",
|
||||
|dirs, mut sandbox| {
|
||||
sandbox.mkdir("te st/");
|
||||
sandbox.mkdir("te st/foo");
|
||||
|
||||
let files_to_create = file_names
|
||||
.iter()
|
||||
.map(|file_name| String::from("te st/") + file_name.as_str())
|
||||
.collect_vec();
|
||||
let file_stubs = files_to_create
|
||||
.iter()
|
||||
.map(|file_path| Stub::EmptyFile(file_path.as_str()))
|
||||
.collect_vec();
|
||||
sandbox.touch(file_stubs);
|
||||
|
||||
let test_dir_base = TypedPathBuf::from(dirs.tests().to_string_lossy().as_bytes());
|
||||
let test_dir = test_dir_base.join("te st/");
|
||||
|
||||
#[cfg(windows)]
|
||||
let test_dir = if session.is_local() {
|
||||
test_dir
|
||||
} else {
|
||||
// We assume all remotes are UNIX-based.
|
||||
// The test directory we're using here is a local temp directory, which means
|
||||
// it uses native path encoding.
|
||||
// On Windows, we must convert the test directory to UNIX encoding
|
||||
// before being able to run bash commands within it.
|
||||
match test_dir {
|
||||
TypedPathBuf::Unix(_) => test_dir,
|
||||
TypedPathBuf::Windows(windows_path) => {
|
||||
windows_to_unix_shell_encoding(windows_path.as_path())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let ctx = test_session_context(session, test_dir.clone(), &app);
|
||||
|
||||
let mut entries = HashSet::<EngineDirEntry>::from_iter(Arc::unwrap_or_clone(
|
||||
warpui::r#async::block_on(ctx.list_directory_entries(test_dir)),
|
||||
));
|
||||
// TODO(CORE-2000): The ls script we use to list entries in remote
|
||||
// sessions adds a spurious "." directory when run in the VirtualFS.
|
||||
// As a temporary workaround, we remove this file in the test.
|
||||
entries.remove(&EngineDirEntry::test_dir("."));
|
||||
|
||||
let mut expected_dir_entries = file_names
|
||||
.into_iter()
|
||||
.map(|file_name| EngineDirEntry::test_file(&file_name))
|
||||
.collect_vec();
|
||||
expected_dir_entries.push(EngineDirEntry::test_dir("foo"));
|
||||
|
||||
assert_eq!(entries, HashSet::from_iter(expected_dir_entries));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_session_context_lists_directory_entries_locally_with_special_characters_in_path() {
|
||||
#[cfg(unix)]
|
||||
let file_names = vec!["a.txt", "b file.txt", "c's.txt", "\"d\".txt", "e\nfile.txt"];
|
||||
|
||||
// Windows filenames are more restrictive than UNIX. Notably,
|
||||
// Windows doesn't allow characters in the 1-31 range, which includes carriage returns (13, '\r')
|
||||
// and newlines (10, '\n') and reserves certain characters.
|
||||
// See https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions.
|
||||
#[cfg(windows)]
|
||||
let file_names = vec![r"a.txt", r"b file.txt", r"c's.txt", r"#d&.txt"];
|
||||
|
||||
perform_special_characters_in_path_test(Session::test(), file_names);
|
||||
}
|
||||
|
||||
/// Regression test for CORE-1927.
|
||||
#[cfg_attr(windows, ignore = "TODO(CORE-3626)")]
|
||||
#[test]
|
||||
pub fn test_session_context_lists_directory_entries_remotely_with_special_characters_in_path() {
|
||||
#[cfg(unix)]
|
||||
let file_names = vec!["a.txt", "b file.txt", "c's.txt", "\"d\".txt", "e\nfile.txt"];
|
||||
|
||||
// Windows filenames are more restrictive than UNIX. Notably,
|
||||
// Windows doesn't allow characters in the 1-31 range, which includes carriage returns (13, '\r')
|
||||
// and newlines (10, '\n') and reserves certain characters.
|
||||
// See https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions.
|
||||
#[cfg(windows)]
|
||||
let file_names = vec![r"a.txt", r"b file.txt", r"c's.txt", r"#d&.txt"];
|
||||
|
||||
perform_special_characters_in_path_test(Session::test_remote(), file_names);
|
||||
}
|
||||
Reference in New Issue
Block a user