Files
sausage/userland/brush/source/brush-interactive/src/completeness.rs
T
2026-11-11 22:04:16 +00:00

187 lines
7.5 KiB
Rust

//! Deciding whether accumulated input forms a complete shell program.
//!
//! Input backends read a line at a time and need to know when they have enough to hand
//! over for execution.
use brush_core::Shell;
/// Returns whether the given accumulated input is an incomplete shell program, i.e.
/// whether more input must be read before it can be executed.
///
/// Acquires the shell lock without blocking. Input is read between commands, with no
/// other task holding the lock, so acquisition is expected to succeed; blocking instead
/// isn't an option because backends also run on single-threaded runtimes.
///
/// # Arguments
///
/// * `shell_ref` - The shell whose parsing options should be used.
/// * `input` - The input accumulated so far.
#[allow(dead_code)]
pub(crate) fn needs_more_input(
shell_ref: &crate::ShellRef<impl brush_core::ShellExtensions>,
input: &str,
) -> bool {
let Ok(shell) = shell_ref.try_lock() else {
tracing::warn!("shell was unexpectedly busy; treating input as complete");
return false;
};
needs_more_input_locked(&shell, input)
}
/// Returns whether more input is needed, given an already-acquired shell.
#[allow(dead_code)]
fn needs_more_input_locked(shell: &Shell<impl brush_core::ShellExtensions>, input: &str) -> bool {
match shell.parse_string(input) {
// Mid-token: unclosed quotes, unterminated here documents, and the like.
Err(brush_parser::ParseError::Tokenizing { inner, position: _ })
if inner.is_incomplete() =>
{
true
}
// Ran out of tokens partway through a construct; more input may complete it.
Err(brush_parser::ParseError::ParsingAtEndOfInput) => true,
// A bad token at a specific position stays bad no matter what follows it.
Err(_) => false,
// Parsed cleanly. One catch: a trailing backslash-newline is a line
// continuation, which the tokenizer drops silently at end of input. Ask again
// with the newline removed; that leaves the input incomplete (e.g., an
// unterminated escape, or a here-document body not yet ended) only if that
// backslash was really escaping the newline.
Ok(_) => ends_with_line_continuation(shell, input),
}
}
/// Returns whether the given input ends with a backslash-newline acting as a line
/// continuation.
fn ends_with_line_continuation(
shell: &Shell<impl brush_core::ShellExtensions>,
input: &str,
) -> bool {
let Some(truncated) = input.strip_suffix('\n') else {
return false;
};
// Keeps the extra parse off the common path.
if !truncated.ends_with('\\') {
return false;
}
matches!(
shell.parse_string(truncated),
Err(brush_parser::ParseError::Tokenizing { inner, position: _ }) if inner.is_incomplete()
)
}
#[cfg(test)]
mod tests {
use super::*;
async fn test_shell() -> Shell<brush_core::extensions::DefaultShellExtensions> {
brush_core::Shell::builder().build().await.unwrap()
}
#[tokio::test]
async fn treats_trailing_line_continuation_as_incomplete() {
let shell = test_shell().await;
assert!(needs_more_input_locked(&shell, "echo a\\\n"));
assert!(needs_more_input_locked(&shell, "printf '%s' \\\n"));
assert!(needs_more_input_locked(&shell, "echo \"quoted\" \\\n"));
// A backslash at the very end, with no newline yet.
assert!(needs_more_input_locked(&shell, "echo a\\"));
}
#[tokio::test]
async fn treats_input_ending_before_here_tag_as_incomplete() {
let shell = test_shell().await;
// A line continuation right after the operator: the tag comes on the next line.
assert!(needs_more_input_locked(&shell, "cat << \\\n"));
// Input ending right at the operator is incomplete too (the parser reports it).
assert!(needs_more_input_locked(&shell, "cat <<"));
assert!(needs_more_input_locked(&shell, "cat <<\\\n"));
// A line continuation after what would be a here-document's end tag joins the next
// line onto it, so the body hasn't ended.
assert!(needs_more_input_locked(&shell, "cat <<E\nabc\nE\\\n"));
// A newline where the tag belongs is an error, though, not a reason to wait.
assert!(!needs_more_input_locked(&shell, "cat <<\n"));
}
#[tokio::test]
async fn treats_here_doc_with_empty_tag_as_incomplete_until_empty_line() {
let shell = test_shell().await;
// As in bash, an empty tag's body ends at the first empty line, which hasn't been
// read yet; the input isn't complete (with an empty body) at the tag's newline.
assert!(needs_more_input_locked(&shell, "cat <<''\n"));
assert!(needs_more_input_locked(&shell, "cat <<\"\"\n"));
assert!(needs_more_input_locked(&shell, "cat <<''\nbody\n"));
assert!(!needs_more_input_locked(&shell, "cat <<''\n\n"));
assert!(!needs_more_input_locked(&shell, "cat <<''\nbody\n\n"));
}
#[tokio::test]
async fn treats_complete_programs_as_complete() {
let shell = test_shell().await;
assert!(!needs_more_input_locked(&shell, "echo a\n"));
assert!(!needs_more_input_locked(&shell, ""));
// An escaped backslash is a literal backslash, not an escape of the newline.
assert!(!needs_more_input_locked(&shell, "echo a\\\\\n"));
// A continuation that isn't at the end has already been joined.
assert!(!needs_more_input_locked(&shell, "echo a\\\nb\n"));
// A backslash followed by a space escapes the space, not the newline.
assert!(!needs_more_input_locked(&shell, "echo a\\ \n"));
assert!(!needs_more_input_locked(&shell, "echo a\\\t\n"));
}
#[tokio::test]
async fn ignores_backslashes_that_do_not_escape() {
let shell = test_shell().await;
// Backslashes are literal inside single quotes...
assert!(!needs_more_input_locked(&shell, "echo 'a\\'\n"));
// ...and inside comments, which end at the newline regardless.
assert!(!needs_more_input_locked(&shell, "echo hi # trailing\\\n"));
assert!(!needs_more_input_locked(&shell, "# whole-line comment\\\n"));
}
#[tokio::test]
async fn honors_quoting_context() {
let shell = test_shell().await;
// A `#` inside quotes doesn't start a comment, so these really are continuations.
assert!(needs_more_input_locked(
&shell,
"echo \"# not a comment\" \\\n"
));
assert!(needs_more_input_locked(
&shell,
"echo '# not a comment' \\\n"
));
// A `#` mid-word doesn't start a comment either.
assert!(needs_more_input_locked(&shell, "echo a#b \\\n"));
}
#[tokio::test]
async fn treats_bad_syntax_as_complete_even_with_a_trailing_continuation() {
let shell = test_shell().await;
// More input can't repair a bad token, so don't sit waiting for it.
assert!(!needs_more_input_locked(&shell, "echo ;;\n"));
assert!(!needs_more_input_locked(&shell, "echo ;; \\\n"));
}
#[tokio::test]
async fn treats_unfinished_constructs_as_incomplete() {
let shell = test_shell().await;
assert!(needs_more_input_locked(&shell, "if true\n"));
assert!(needs_more_input_locked(&shell, "f() {\n"));
assert!(needs_more_input_locked(&shell, "cat <<EOF\n"));
assert!(needs_more_input_locked(&shell, "echo 'unterminated\n"));
assert!(needs_more_input_locked(&shell, "true &&\n"));
}
}