use std::borrow::Cow;
use super::{REFUSAL_FRAME, ShellMode, ShellPlatform};
pub(super) const COMMAND_LINE_CAP: usize = 8191;
const HANDOFF_RESERVE: usize = 64 + super::WINDOWS_COMMAND_SWITCHES.len() + 1;
pub(super) const TEXT_UNIT_LIMIT: usize = COMMAND_LINE_CAP - HANDOFF_RESERVE;
const BREAK_IN_DOUBLE_QUOTES: &str = "a line break falls inside a double-quoted argument, and this platform's reader \
ends the command at that break, so the argument would reach its program \
truncated";
const ODD_BACKSLASHES: &str = "an odd number of backslashes immediately before a line break, and this platform \
treats `\\` as an ordinary character: the break there is a command separator, \
while the read-only guard's reader folds the two lines into one";
const TEXT_BLOCK: &str = "the command feeds a text block to the interpreter, and this platform has no \
text blocks";
const ESCAPED_SYNTAX: &str = "a caret escapes a character this platform reads literally and the read-only \
guard's reader reads as an operator or a group (`&`, `|`, `<`, `>`, `(`, `)`): \
the two do not agree about which text is a command";
const ESCAPED_PAREN: &str = "a caret at the end of a line escapes the `(` or `)` the next line opens with, and \
this platform's reader takes that character as an ordinary one: its own group \
counter is left out of step with the text, so the lines after the break cannot \
be read as written";
const CARET_INTO_BLANK_LINE: &str = "a caret at the end of a line escapes the first character of the line it \
continues, and that line is blank: this platform's reader is left taking the \
line break itself as the escaped character, which the respelt lines cannot carry";
const LEADING_CONNECTOR: &str = "the first significant character after a line break is a connector (`&` or `|`), \
which joins the text after the break to nothing: this platform's reader has no \
left-hand command for it, and no respelling of the break can leave the \
operator the command was written with intact";
const UNPROVEN_GROUP: &str = "a `(` in the command may or may not open a bracketed group — this platform's \
reader opens one only where it is looking for a command, and a `(` written in an \
argument is ordinary text whose command ends where the arguments end — so \
nothing that follows it can be read for certain";
const UNPAIRED_PAREN: &str = "a `)` closes no group and stands where this platform's reader is looking for a \
command: the reader discards the rest of that line, so the text after it cannot \
be run as written";
const UNFINISHED_STATEMENT: &str = "the command's last line leaves an `if`/`for` statement waiting for its own \
text (its `do`, or the command its condition governs), and this platform's \
reader is left waiting for what the text never supplies";
const STATEMENT_SPANS_LINE: &str = "the line before the break ends an `if`/`for` statement whose own command is \
written on it, and this platform's reader runs that command to the end of the \
line: a separator here would become part of the statement, so the line after \
the break would run only under the condition — or once per iteration — rather \
than as a command of its own";
const SWALLOWING_LINE: &str = "the line before the break is a comment (`rem`) or a label (`:`), and this \
platform's reader takes the rest of such a line as the comment's text or the \
label's name: the separator a break would be joined with would be part of it, \
so the lines after the break would never run";
const DOUBLED_OPERATOR: &str = "joining the lines at this break would put two operator characters together, \
spelling an operator the command never wrote (`>>`, `<<`, `&>` where it wrote the \
characters separately)";
const DANGLING_CARET: &str = "the command ends with a caret whose escape has nothing left to take, and this \
platform's reader is left waiting for what it escapes";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Break<'a> {
Continued(&'a str),
Joined,
Separator(Option<u8>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Group {
Plain,
Set,
Body,
Unproven,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum Stmt {
#[default]
Start,
Prefixed,
Plain,
If,
Not,
Keyword,
For,
Set,
Await,
Done,
}
struct Word {
at: usize,
command: bool,
escaped: bool,
}
#[derive(Default)]
struct Words {
open: Option<Word>,
stmt: Stmt,
chain: bool,
swallowing: bool,
absorbing: bool,
absorbing_bound: usize,
}
impl Words {
fn start(&mut self, at: usize, command: bool, escaped: bool) {
if self.open.is_none() {
self.open = Some(Word {
at,
command,
escaped,
});
}
}
fn finish(&mut self, bytes: &[u8], end: usize, depth: usize) {
let Some(open) = self.open.take() else { return };
let word = &bytes[open.at..end];
let command = open.command;
let escaped_start = open.escaped;
let previous = self.stmt;
let chain = self.chain;
self.stmt = stmt_step(previous, chain, word, command);
if (command || matches!(previous, Stmt::Prefixed | Stmt::Await) || chain)
&& (is_comment_command(word) || (!escaped_start && is_label_word(word)))
{
self.swallowing = true;
}
if (previous == Stmt::Await || chain)
&& matches!(self.stmt, Stmt::Done | Stmt::If | Stmt::For)
{
self.absorbing = true;
self.absorbing_bound = depth;
}
self.chain = false;
}
fn bounded(&mut self, depth: usize) {
if self.absorbing && depth < self.absorbing_bound {
self.absorbing = false;
}
}
fn restart(&mut self) {
let (absorbing, bound) = (self.absorbing, self.absorbing_bound);
*self = Self::default();
self.absorbing = absorbing;
self.absorbing_bound = bound;
}
fn unfinished(&self) -> bool {
matches!(
self.stmt,
Stmt::Prefixed
| Stmt::If
| Stmt::Not
| Stmt::Keyword
| Stmt::For
| Stmt::Set
| Stmt::Await
)
}
}
fn stmt_step(stmt: Stmt, chain: bool, word: &[u8], command: bool) -> Stmt {
let word = word.strip_prefix(b"@").unwrap_or(word);
if word.is_empty() {
return if command { Stmt::Prefixed } else { stmt };
}
if chain {
return if word.eq_ignore_ascii_case(b"else") || word.eq_ignore_ascii_case(b"do") {
Stmt::Await
} else {
statement_word(word, Stmt::Done)
};
}
if command || stmt == Stmt::Prefixed {
return statement_word(word, Stmt::Plain);
}
match stmt {
Stmt::Start | Stmt::Prefixed | Stmt::Plain | Stmt::Done => Stmt::Plain,
Stmt::If | Stmt::Not => {
if stmt == Stmt::If && word.eq_ignore_ascii_case(b"not") {
Stmt::Not
} else if word.starts_with(b"/") {
stmt
} else if is_condition_keyword(word) || is_comparison_operator(word) {
Stmt::Keyword
} else {
Stmt::Await
}
}
Stmt::Keyword => Stmt::Await,
Stmt::For => {
if word.eq_ignore_ascii_case(b"in") {
Stmt::Set
} else {
Stmt::For
}
}
Stmt::Set => {
if word.eq_ignore_ascii_case(b"do") {
Stmt::Await
} else {
Stmt::Done
}
}
Stmt::Await => {
if is_comparison_operator(word) {
Stmt::Keyword
} else if word.starts_with(b"==") {
Stmt::Await
} else {
statement_word(word, Stmt::Done)
}
}
}
}
fn statement_word(word: &[u8], none: Stmt) -> Stmt {
if word.eq_ignore_ascii_case(b"if") {
Stmt::If
} else if word.eq_ignore_ascii_case(b"for") {
Stmt::For
} else {
none
}
}
#[derive(Clone, Copy)]
pub(super) struct Executed<'a>(pub(super) &'a str);
#[derive(Clone, Copy)]
pub(super) struct Written<'a>(pub(super) &'a str);
pub(super) fn executable(
command: &str,
platform: ShellPlatform,
mode: ShellMode,
) -> Result<Cow<'_, str>, String> {
if platform == ShellPlatform::Unix {
return Ok(Cow::Borrowed(command));
}
let text = if spans_commands(command) {
Cow::Owned(respell(command, mode)?)
} else {
Cow::Borrowed(before_first_break(command))
};
Ok(text)
}
fn line_end(text: &str, break_at: usize) -> usize {
let bytes = text.as_bytes();
let mut end = break_at;
while end > 0 && bytes[end - 1] == b'\r' {
end -= 1;
}
end
}
fn spans_commands(command: &str) -> bool {
let bytes = command.as_bytes();
let Some(break_at) = bytes.iter().position(|b| *b == b'\n') else {
return false;
};
bytes[break_at + 1..].iter().any(|b| !is_separator(*b))
|| trailing_run(&command[..line_end(command, break_at)], b'^') % 2 == 1
}
fn before_first_break(command: &str) -> &str {
match command.find('\n') {
Some(i) => &command[..line_end(command, i)],
None => command,
}
}
pub(super) fn check_command_line(
text: &str,
platform: ShellPlatform,
mode: ShellMode,
) -> Result<(), String> {
if platform != ShellPlatform::Windows {
return Ok(());
}
let units = text.encode_utf16().count();
if units <= TEXT_UNIT_LIMIT {
return Ok(());
}
let cause = format!(
"a command line on this platform holds {COMMAND_LINE_CAP} units in all, and the \
interpreter's own path, the settings it is started with and its `/C` switch take \
about {} of them, so the command text must fit in {TEXT_UNIT_LIMIT}: this text \
needs {units}",
COMMAND_LINE_CAP - TEXT_UNIT_LIMIT
);
Err(refusal(&cause, mode))
}
fn break_ends(command: &str, i: usize, start: usize) -> (&str, usize, &str) {
let end = line_end(command, i);
(&command[start..end], end, &command[end..=i])
}
fn next_significant(bytes: &[u8]) -> Vec<Option<u8>> {
let mut following = vec![None; bytes.len() + 1];
let mut seen = None;
for j in (0..bytes.len()).rev() {
if !is_separator(bytes[j]) {
seen = Some(bytes[j]);
}
following[j] = seen;
}
following
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Open {
None,
Proven,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum Takes {
#[default]
Nothing,
Statement,
Line,
}
struct BreakSite<'a> {
segment: &'a str,
following: Option<u8>,
caret_escapes: Option<u8>,
caret_unescaped: bool,
groups: Open,
statement_unfinished: bool,
bash_quote: Option<u8>,
last: Option<u8>,
takes: Takes,
}
fn break_kind<'a>(
site: &BreakSite<'_>,
spelling: &'a str,
mode: ShellMode,
) -> Result<Break<'a>, String> {
if site.caret_unescaped {
return Err(refusal(DANGLING_CARET, mode));
}
if let Some(target) = site.caret_escapes {
if target == b'\n' {
return Err(refusal(CARET_INTO_BLANK_LINE, mode));
}
if matches!(target, b'(' | b')') {
return Err(refusal(ESCAPED_PAREN, mode));
}
if mode == ShellMode::ReadOnly && matches!(target, b'&' | b'|' | b'<' | b'>') {
return Err(refusal(ESCAPED_SYNTAX, mode));
}
return Ok(Break::Continued(spelling));
}
if site.following.is_none() {
return Ok(Break::Separator(None));
}
if let Some(cause) = divergent(site, mode) {
return Err(refusal(&cause, mode));
}
match site.groups {
Open::Unknown => return Err(refusal(UNPROVEN_GROUP, mode)),
Open::Proven => return Ok(Break::Continued(spelling)),
Open::None => {}
}
if site.statement_unfinished {
return Err(refusal(UNFINISHED_STATEMENT, mode));
}
match site.takes {
Takes::Statement => return Err(refusal(STATEMENT_SPANS_LINE, mode)),
Takes::Line => return Err(refusal(SWALLOWING_LINE, mode)),
Takes::Nothing => {}
}
let operator = site
.last
.is_some_and(|c| matches!(c, b'&' | b'|' | b'<' | b'>'));
if operator && matches!(site.following, Some(b'<' | b'>')) {
return Err(refusal(DOUBLED_OPERATOR, mode));
}
if operator {
return Ok(Break::Joined);
}
Ok(Break::Separator(site.following))
}
fn divergent(site: &BreakSite<'_>, mode: ShellMode) -> Option<String> {
if matches!(site.following, Some(b'&' | b'|')) {
return Some(LEADING_CONNECTOR.to_owned());
}
if mode != ShellMode::ReadOnly {
return None;
}
if let Some(open) = site.bash_quote {
return Some(divergent_quote_cause(open));
}
if trailing_run(site.segment, b'\\') % 2 == 1 {
return Some(ODD_BACKSLASHES.to_owned());
}
None
}
fn respell(command: &str, mode: ShellMode) -> Result<String, String> {
let bytes = command.as_bytes();
let mut lines: Vec<(&str, Break<'_>)> = Vec::new();
let following = next_significant(bytes);
let mut walk = Walk::default();
let mut start = 0usize;
let mut i = 0usize;
while i < bytes.len() {
let b = bytes[i];
if b == b'\n' {
let (segment, kind) = walk.at_break(command, bytes, i, start, &following, mode)?;
lines.push((segment, kind));
start = i + 1;
i += 1;
continue;
}
let (quote, escaped) = bash_step(walk.bash_quote, walk.bash_escaped, b);
walk.bash_quote = quote;
walk.bash_escaped = escaped;
if b == b'\r' {
i += 1;
continue;
}
if b == b'<' && bytes.get(i + 1) == Some(&b'<') && !walk.quote && walk.bash_quote.is_none()
{
return Err(refusal(TEXT_BLOCK, mode));
}
if walk.escaped {
read_escaped(b, mode)?;
walk.words.start(i, walk.at_command(), true);
walk.escaped = false;
} else if walk.quote {
walk.quote = b != b'"';
} else {
walk.step(bytes, i, b, mode)?;
}
i += 1;
}
let tail = &command[start..];
walk.words.finish(bytes, bytes.len(), walk.groups.len());
walk.close(&lines, tail, mode)
}
#[expect(clippy::struct_excessive_bools)] #[derive(Default)]
struct Walk {
quote: bool,
bash_quote: Option<u8>,
bash_escaped: bool,
escaped: bool,
groups: Vec<Group>,
unproven: usize,
last: Option<u8>,
piped: bool,
child_pipe: bool,
after_block: bool,
words: Words,
}
impl Walk {
fn step(&mut self, bytes: &[u8], i: usize, b: u8, mode: ShellMode) -> Result<(), String> {
match b {
b'"' => {
self.words.start(i, self.at_command(), false);
self.quote = true;
self.last = Some(b);
self.piped = false;
self.after_block = false;
}
b'^' => self.escaped = true,
b'(' | b')' => {
read_paren(b, bytes, i, self, mode)?;
self.last = Some(b);
if b == b'(' {
self.after_block = false;
}
self.piped = false;
}
b' ' | b'\t' => {
self.words.finish(bytes, i, self.groups.len());
}
b'&' | b'|' => {
self.words.finish(bytes, i, self.groups.len());
self.words.chain = false;
self.piped = b == b'|' && self.last != Some(b'|');
self.child_pipe = self.piped;
self.after_block = false;
self.last = Some(b);
}
_ => {
self.words.start(i, self.at_command(), false);
self.last = Some(b);
self.piped = false;
self.after_block = false;
}
}
Ok(())
}
fn at_command(&self) -> bool {
match self.last {
None | Some(b'&' | b'|') => true,
Some(b'(') => matches!(self.groups.last(), Some(Group::Plain | Group::Body)),
Some(b')') => self.after_block,
Some(b'@') => self.words.stmt == Stmt::Prefixed,
_ => false,
}
}
fn open(&self) -> Open {
if self.unproven > 0 {
Open::Unknown
} else if self.groups.is_empty() {
Open::None
} else {
Open::Proven
}
}
fn push_group(&mut self, group: Group) {
self.unproven += usize::from(group == Group::Unproven);
self.groups.push(group);
}
fn pop_group(&mut self) -> Option<Group> {
let group = self.groups.pop();
if group == Some(Group::Unproven) {
self.unproven -= 1;
}
group
}
fn at_break<'a>(
&mut self,
command: &'a str,
bytes: &[u8],
i: usize,
start: usize,
following: &[Option<u8>],
mode: ShellMode,
) -> Result<(&'a str, Break<'a>), String> {
let (segment, end, spelling) = break_ends(command, i, start);
self.words.finish(bytes, end, self.groups.len());
self.bash_escaped = false;
if self.quote {
return Err(refusal(BREAK_IN_DOUBLE_QUOTES, mode));
}
let caret = self.escaped;
let target = caret
.then(|| bytes[i + 1..].iter().copied().find(|b| *b != b'\r'))
.flatten();
let site = BreakSite {
segment,
following: following[i + 1],
caret_escapes: target,
caret_unescaped: caret && target.is_none(),
groups: self.open(),
statement_unfinished: self.words.unfinished(),
bash_quote: self.bash_quote,
last: self.last,
takes: if self.words.swallowing {
Takes::Line
} else if self.words.absorbing {
Takes::Statement
} else {
Takes::Nothing
},
};
let kind = break_kind(&site, spelling, mode)?;
self.words.swallowing = false;
match kind {
Break::Joined => {}
_ if carried(&site) => {}
_ => {
self.last = None;
self.piped = false;
self.child_pipe = false;
self.after_block = false;
self.words = Words::default();
}
}
Ok((segment, kind))
}
fn close(
&self,
lines: &[(&str, Break<'_>)],
tail: &str,
mode: ShellMode,
) -> Result<String, String> {
if self.escaped {
return Err(refusal(DANGLING_CARET, mode));
}
if let Some(cause) = unclosed_at_end(
self.quote,
self.bash_quote,
self.open(),
self.words.unfinished(),
mode,
) {
return Err(refusal(&cause, mode));
}
Ok(rebuild(lines, tail))
}
}
fn read_escaped(b: u8, mode: ShellMode) -> Result<(), String> {
if mode == ShellMode::ReadOnly && matches!(b, b'&' | b'|' | b'<' | b'>' | b'(' | b')') {
return Err(refusal(ESCAPED_SYNTAX, mode));
}
Ok(())
}
fn carried(site: &BreakSite<'_>) -> bool {
site.caret_escapes.is_some()
|| site.following.is_none()
|| (site.groups == Open::Proven && site.statement_unfinished)
}
fn read_paren(
paren: u8,
bytes: &[u8],
i: usize,
walk: &mut Walk,
mode: ShellMode,
) -> Result<(), String> {
walk.words.finish(bytes, i, walk.groups.len());
if walk.words.swallowing {
return Ok(());
}
if paren == b'(' {
let group = opens_group(
walk.last,
walk.piped,
walk.child_pipe,
walk.after_block,
&walk.words,
)
.unwrap_or(Group::Unproven);
walk.push_group(group);
if group != Group::Unproven {
walk.words.restart();
}
return Ok(());
}
match walk.pop_group() {
Some(Group::Plain) => {
walk.words.restart();
walk.after_block = true;
}
Some(Group::Body) => {
walk.words.restart();
walk.words.chain = true;
walk.words.absorbing = true;
walk.words.absorbing_bound = walk.groups.len();
}
Some(Group::Set) => {
walk.words.restart();
walk.words.stmt = Stmt::Set;
}
Some(Group::Unproven) if walk.groups.len() != walk.unproven => {
return Err(refusal(UNPROVEN_GROUP, mode));
}
None if matches!(walk.last, None | Some(b'&' | b'|' | b'(' | b')')) => {
return Err(refusal(UNPAIRED_PAREN, mode));
}
Some(Group::Unproven) | None => {}
}
walk.words.bounded(walk.groups.len());
Ok(())
}
fn unclosed_at_end(
platform_quote: bool,
bash_quote: Option<u8>,
groups: Open,
statement_unfinished: bool,
mode: ShellMode,
) -> Option<String> {
if platform_quote {
return Some(unclosed_cause(b'"'));
}
if mode == ShellMode::ReadOnly
&& let Some(open) = bash_quote
{
return Some(unclosed_cause(open));
}
match groups {
Open::Unknown => return Some(UNPROVEN_GROUP.to_owned()),
Open::Proven => return Some(unclosed_cause(b'(')),
Open::None => {}
}
if statement_unfinished {
return Some(UNFINISHED_STATEMENT.to_owned());
}
None
}
fn rebuild(lines: &[(&str, Break<'_>)], tail: &str) -> String {
let mut out = String::new();
let mut ends_command = false;
for (segment, kind) in lines {
out.push_str(segment);
ends_command |= !is_blank(segment);
match kind {
Break::Continued(spelling) => out.push_str(spelling),
Break::Joined => {}
Break::Separator(following) => {
if ends_command && following.is_some() {
out.push('&');
if matches!(following, Some(b'<' | b'>')) {
out.push(' ');
}
ends_command = false;
}
}
}
}
out.push_str(tail);
out
}
fn unclosed_cause(open: u8) -> String {
let shape = match open {
b'"' => "double-quoted argument",
b'\'' => "single-quoted word",
_ => "bracketed group",
};
format!(
"the text ends inside an unclosed {shape} — this platform's reader would be \
left waiting for the rest of the command"
)
}
fn divergent_quote_cause(open: u8) -> String {
let shape = match open {
b'\'' => "single-quoted word",
_ => "double-quoted word",
};
format!(
"a line break falls inside a {shape} — this platform's reader quotes with \
`\"` only, so the command ends at that break while the shell parser that \
read this command reads one quoted word across it, and the two do not agree \
about which text is a command"
)
}
fn opens_group(
last: Option<u8>,
piped: bool,
child_pipe: bool,
after_block: bool,
words: &Words,
) -> Option<Group> {
if child_pipe {
return None;
}
match last {
None | Some(b'&' | b'(') => Some(Group::Plain),
Some(b'|') if !piped => Some(Group::Plain),
Some(b')') if after_block => Some(Group::Plain),
Some(b'@') if words.stmt == Stmt::Prefixed => Some(Group::Plain),
_ if words.stmt == Stmt::Set => Some(Group::Set),
_ if words.stmt == Stmt::Await || words.chain => Some(Group::Body),
_ => None,
}
}
fn is_condition_keyword(word: &[u8]) -> bool {
[&b"exist"[..], b"defined", b"errorlevel", b"cmdextversion"]
.iter()
.any(|keyword| word.eq_ignore_ascii_case(keyword))
}
fn is_comparison_operator(word: &[u8]) -> bool {
[&b"=="[..], b"equ", b"neq", b"lss", b"leq", b"gtr", b"geq"]
.iter()
.any(|operator| word.eq_ignore_ascii_case(operator))
}
fn trailing_run(text: &str, c: u8) -> usize {
text.bytes().rev().take_while(|b| *b == c).count()
}
fn bash_step(quote: Option<u8>, escaped: bool, b: u8) -> (Option<u8>, bool) {
if escaped {
(quote, false)
} else if let Some(open) = quote {
if b == open {
(None, false)
} else if open == b'"' && b == b'\\' {
(quote, true)
} else {
(quote, false)
}
} else if b == b'\\' {
(None, true)
} else if matches!(b, b'\'' | b'"') {
(Some(b), false)
} else {
(None, false)
}
}
fn is_separator(b: u8) -> bool {
matches!(b, b' ' | b'\t' | b'\r' | b'\n')
}
fn is_blank(text: &str) -> bool {
!text.bytes().any(|b| !is_separator(b))
}
fn is_comment_command(word: &[u8]) -> bool {
let word = word.strip_prefix(b"@").unwrap_or(word);
word.split(|b| matches!(b, b',' | b';' | b'='))
.next()
.is_some_and(|head| head.eq_ignore_ascii_case(b"rem"))
}
fn is_label_word(word: &[u8]) -> bool {
word.strip_prefix(b"@").unwrap_or(word).starts_with(b":")
}
fn refusal(cause: &str, mode: ShellMode) -> String {
format!("{REFUSAL_FRAME}{cause}. Nothing ran. {}", remedy(mode))
}
pub(super) fn remedy(mode: ShellMode) -> String {
crate::prompt::load_prompt(match mode {
ShellMode::Full => "tool/shell_command_lines_remedy_full.md",
ShellMode::ReadOnly => "tool/shell_command_lines_remedy_read_only.md",
})
.trim()
.to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
fn read(command: &str) -> Result<String, String> {
executable(command, ShellPlatform::Windows, ShellMode::Full).map(Cow::into_owned)
}
fn refused(command: &str) -> String {
read(command).expect_err("the shape is refused, not read")
}
fn refused_read_only(command: &str) -> String {
match executable(command, ShellPlatform::Windows, ShellMode::ReadOnly) {
Err(cause) => cause,
Ok(text) => panic!("the shape is refused in read-only mode, not read: {text:?}"),
}
}
fn reads(command: &str, expected: &str) {
assert_eq!(read(command).as_deref(), Ok(expected), "{command:?}");
}
#[test]
fn unix_and_single_line_texts_are_handed_back_untouched() {
for text in [
"dir /b",
"for %f in (*.txt) do (del %f)",
"if exist x (echo y)",
] {
assert!(
matches!(
executable(text, ShellPlatform::Unix, ShellMode::Full),
Ok(Cow::Borrowed(borrowed)) if borrowed == text
),
"{text:?}"
);
assert!(
matches!(
executable(text, ShellPlatform::Windows, ShellMode::Full),
Ok(Cow::Borrowed(borrowed)) if borrowed == text
),
"{text:?}"
);
}
}
#[test]
fn a_bare_break_becomes_the_unconditional_separator() {
reads(
"dir C:\\ws\ndel C:\\ws\\x.txt",
"dir C:\\ws&del C:\\ws\\x.txt",
);
reads("dir C:\\ws\\\\\ndel x", "dir C:\\ws\\\\&del x");
}
#[test]
fn blank_lines_and_a_trailing_break_change_nothing() {
reads("a\n\nb", "a&b");
reads("a\n", "a");
reads("\na", "a");
reads("a\n\n", "a");
reads("a\n \t\nb", "a& \tb");
reads("a\n\u{A0}\nb", "a&\u{A0}&b");
reads("a\n\u{3000}\nb", "a&\u{3000}&b");
for (text, one_line) in [
("echo a (", "echo a (\n"),
("echo \"a", "echo \"a\n"),
("if exist x", "if exist x\n"),
("dir C:\\ws\\", "dir C:\\ws\\\n"),
("dir C:\\ws\\", "dir C:\\ws\\\n\n"),
("rem note", "rem note\n"),
] {
reads(one_line, text);
assert_eq!(
executable(one_line, ShellPlatform::Windows, ShellMode::ReadOnly).as_deref(),
Ok(before_first_break(one_line)),
"{one_line:?}"
);
}
for (text, written) in [
("dir&rem note", "dir\nrem note\n"),
("echo a&dir C:\\ws\\", "echo a\ndir C:\\ws\\\n"),
("dir&dir C:\\ws\\", "dir\ndir C:\\ws\\\n"),
] {
reads(written, text);
assert_eq!(
executable(written, ShellPlatform::Windows, ShellMode::ReadOnly).as_deref(),
Ok(text),
"{written:?}"
);
}
assert_eq!(
read("dir\nif exist x\n"),
read("dir\nif exist x"),
"a trailing break changes nothing about the statement the text ends on"
);
}
#[test]
fn a_crlf_break_is_one_break() {
reads("a\r\nb", "a&b");
for (lf, crlf) in [
("if exist x echo\nmore", "if exist x echo\r\nmore"),
(
"if exist x (\ndir\n) echo b",
"if exist x (\r\ndir\r\n) echo b",
),
("if exist x (dir) cls\ndir", "if exist x (dir) cls\r\ndir"),
(
"if exist x echo (a) done\nmore",
"if exist x echo (a) done\r\nmore",
),
("if x ==1\nmore", "if x ==1\r\nmore"),
("echo a ^\nb", "echo a ^\r\nb"),
("echo a ^\n\n", "echo a ^\r\n\r\n"),
("dir || (a\nb)", "dir || (a\r\nb)"),
] {
match (read(lf), read(crlf)) {
(Ok(left), Ok(right)) => assert_eq!(right.replace("\r\n", "\n"), left, "{crlf:?}"),
(Err(left), Err(right)) => assert_eq!(right, left, "{crlf:?}"),
pair => panic!("{crlf:?}: the spelling of the break changed the reading: {pair:?}"),
}
}
}
#[test]
fn a_run_of_carriage_returns_is_the_one_break() {
for (one, run) in [
("echo a ^\r\n", "echo a ^\r\r\n"),
("dir\nrem\r\ndel x", "dir\nrem\r\r\ndel x"),
("dir\n:: note\r\ndel x", "dir\n:: note\r\r\ndel x"),
("if exist x echo\r\nmore", "if exist x echo\r\r\nmore"),
] {
assert_eq!(refused(run), refused(one), "{run:?}");
}
assert!(refused("echo a ^\r\r\n").contains("left waiting"));
assert!(refused("dir\nrem\r\r\ndel x").contains("comment"));
for (one, run) in [
("dir &&\r\nnext", "dir &&\r\r\nnext"),
("dir\r\n\r\r\ndel x", "dir\r\r\n\r\r\ndel x"),
] {
assert_eq!(
read(run).expect("read"),
read(one).expect("read"),
"{run:?}"
);
}
for (one, run) in [
("dir \\\\\\\r\ndel x", "dir \\\\\\\r\r\ndel x"),
("(echo a\\\r\ndel x)", "(echo a\\\r\r\ndel x)"),
] {
assert_eq!(refused_read_only(run), refused_read_only(one), "{run:?}");
}
reads("echo a ^\r\r\nb", "echo a ^\r\r\nb");
reads("(\r\r\ndir\r\r\n)", "(\r\r\ndir\r\r\n)");
reads("dir\rdel x", "dir\rdel x");
reads("echo a\rb", "echo a\rb");
}
#[test]
fn the_platforms_own_continuations_stay_as_written() {
reads("echo a ^\nb", "echo a ^\nb");
reads("copy a.txt ^\nb.txt", "copy a.txt ^\nb.txt");
reads("echo a ^^\nb", "echo a ^^&b");
reads("dir\necho b^^", "dir&echo b^^");
reads(
"for %f in (*.txt) do (\ndel x\n)",
"for %f in (*.txt) do (\ndel x\n)",
);
reads(
"for %f in (*.txt) do (\n\ndel x\n)",
"for %f in (*.txt) do (\n\ndel x\n)",
);
reads(
"if exist C:\\ws\\x.txt (\ndel C:\\ws\\x.txt\n)",
"if exist C:\\ws\\x.txt (\ndel C:\\ws\\x.txt\n)",
);
reads(
"if exist x (echo a\n) else (\necho b\n)",
"if exist x (echo a\n) else (\necho b\n)",
);
reads(
"@if not defined DEBUG (\necho release\n)",
"@if not defined DEBUG (\necho release\n)",
);
reads("if %a%==1 (\necho one\n)", "if %a%==1 (\necho one\n)");
reads(
"if \"%a%\" == \"1\" (\necho one\n)",
"if \"%a%\" == \"1\" (\necho one\n)",
);
reads("if %a% equ 1 (\necho one\n)", "if %a% equ 1 (\necho one\n)");
reads(
"if /i \"A\"==\"a\" (\necho same\n)",
"if /i \"A\"==\"a\" (\necho same\n)",
);
reads(
"for %f in (*.txt) do if exist %f (\ndel %f\n)",
"for %f in (*.txt) do if exist %f (\ndel %f\n)",
);
reads(
"if exist x (\necho a\n) else if exist y (\necho b\n)",
"if exist x (\necho a\n) else if exist y (\necho b\n)",
);
reads("for %f in (a\nb) do echo %f", "for %f in (a\nb) do echo %f");
reads(
"for %f in (*.txt\n*.md) do (\ndel %f\n)",
"for %f in (*.txt\n*.md) do (\ndel %f\n)",
);
reads("echo (a) done\nmore", "echo (a) done&more");
reads(
"echo (see the log) below\ndir",
"echo (see the log) below&dir",
);
reads("if exist x (\ndir\n) echo b", "if exist x (\ndir\n) echo b");
reads(
"for %f in (*.txt) do (\necho %f\n) echo t",
"for %f in (*.txt) do (\necho %f\n) echo t",
);
reads(
"for %f in (*.txt) do (\necho %f\n)& dir",
"for %f in (*.txt) do (\necho %f\n)& dir",
);
assert!(refused("if exist x (dir) cls\ndir").contains("runs that command"));
assert!(refused("if exist x (dir)\ndir").contains("runs that command"));
}
#[test]
fn a_break_behind_a_statements_own_text_is_refused() {
let task = "runs that command to the end";
for text in [
"if exist x echo a\ndel C:\\ws\\x.txt",
"if not exist out mkdir out\ncopy a b",
"if exist x (dir)\ncopy a b",
"if exist x (\ndir\n) echo b\ncopy a b",
"for %f in (*.log) do type %f\ndel *.log",
"if exist x if exist y dir\ncopy a b",
"dir & ^if exist x echo b\ncopy a b",
] {
assert!(refused(text).contains(task), "{text:?}");
}
reads("echo a\nif exist x echo b", "echo a&if exist x echo b");
reads("echo a\nif exist x echo b\n", "echo a&if exist x echo b");
reads(
"for %f in (*.txt) do (\ndel %f\n)\n",
"for %f in (*.txt) do (\ndel %f\n)",
);
reads(
"if exist x (\ndir\n) echo b\n",
"if exist x (\ndir\n) echo b",
);
for text in [
"if exist x echo (a) done\nmore",
"for %f in (*.txt) do echo %f (x)\ndir",
"if not exist out mkdir out (x)\ncopy a b",
"if exist x (echo a) & (b c)\ndir",
] {
assert!(refused(text).contains("runs that command"), "{text:?}");
assert!(
refused_read_only(text).contains("runs that command"),
"{text:?}"
);
}
assert!(refused("if x ==1\nmore").contains("waiting for its own"));
reads(
"(if exist x mkdir out)\ncopy a b",
"(if exist x mkdir out)© a b",
);
reads(
"(for %f in (*.log) do type %f)\ndel *.log",
"(for %f in (*.log) do type %f)&del *.log",
);
reads(
"echo (if exist y mkdir z)\ndir2",
"echo (if exist y mkdir z)&dir2",
);
assert!(
refused("if exist x (if exist y (\ndir\n))\ncopy a b").contains("runs that command"),
);
assert!(refused("if exist x (\ndir\n) & echo b\ndir2").contains("runs that command"),);
reads("if not exist out mkdir out", "if not exist out mkdir out");
}
#[test]
fn a_statement_cut_by_a_break_is_refused_rather_than_split() {
for text in [
"for %f in\ndo echo %f",
"for %f in (a b)\ndo echo %f",
"if exist x\ndel C:\\ws\\x.txt",
"if not exist x\ndel C:\\ws\\x.txt",
] {
assert!(refused(text).contains("waiting"), "{text:?}");
}
assert!(refused("for %f in (a\nb)\ndo echo %f").contains("waiting"));
assert!(refused("if exist x echo\nmore").contains("runs that command to the end"));
}
#[test]
fn a_paren_whose_group_cannot_be_told_is_refused() {
assert!(
refused("if exist x (\ndir C:\\ws\\(a)\ndel C:\\ws\\x.txt\n)")
.contains("bracketed group")
);
assert!(refused("dir\n) echo b").contains("discards"));
assert!(refused("(\ndel C:\\ws\\x.txt)\necho tail\n)").contains("discards"));
assert!(refused("if exist x (echo a\n) )\ndir").contains("discards"));
for text in [
"echo x | (\ndir\n)",
"if exist x | (\ndir\n)",
"for %f in (a) do | (\ndir\n)",
"if exist x (dir) else | (\ndir\n)",
"dir | @(\nb\n)",
] {
assert!(refused(text).contains("bracketed group"), "{text:?}");
}
reads("dir || (a\nb)", "dir || (a\nb)");
reads("dir && (a\nb)", "dir && (a\nb)");
reads("dir & (a\nb)", "dir & (a\nb)");
reads("dir || @(\nb\n)", "dir || @(\nb\n)");
reads("(\ndir\n)\n(\ndel x\n)", "(\ndir\n)&(\ndel x\n)");
reads("(a)\n(b\nc)", "(a)&(b\nc)");
reads("@(\necho a\necho b\n)", "@(\necho a\necho b\n)");
assert!(refused("echo (a) (b\nc)").contains("bracketed group"));
assert!(refused("echo () (b\nc)").contains("bracketed group"));
assert!(refused("if exist x (\necho (a)\n)").contains("bracketed group"));
assert!(refused("if exist x (\ndir C:\\ws (x86)\\bin\n)").contains("bracketed group"));
assert!(refused("dir >\n(echo a\necho b)").contains("bracketed group"));
assert!(refused("if exist x (dir) echo (b\nc)").contains("bracketed group"));
assert!(refused("if exist x (dir) cls (b\nc)").contains("bracketed group"));
reads(
"if exist x (dir) if exist y (b\nc)",
"if exist x (dir) if exist y (b\nc)",
);
}
#[test]
fn a_break_after_an_operator_is_joined_to_its_operand() {
reads("dir &&\nnext", "dir &&next");
reads("dir >\nout.txt", "dir >out.txt");
reads("dir <\nin.txt", "dir <in.txt");
reads("dir |\nmore", "dir |more");
reads("dir >\"out.txt\"\nnext", "dir >\"out.txt\"&next");
reads("dir &&\n(echo a\necho b)", "dir &&(echo a\necho b)");
}
#[test]
fn a_join_that_would_spell_an_operator_of_its_own_is_refused() {
for text in [
"dir >\n> next",
"dir >\n< next",
"dir <\n< next",
"dir <\n> next",
"dir &\n> out.txt",
"dir |\n> out.txt",
"dir &&\n> out.txt",
] {
assert!(refused(text).contains("operator"), "{text:?}");
}
reads("dir\n> out.txt", "dir& > out.txt");
reads("dir\n>> out.txt", "dir& >> out.txt");
reads("dir\n< in.txt", "dir& < in.txt");
reads("dir\n\n> out.txt", "dir& > out.txt");
reads("dir >\nout.txt", "dir >out.txt");
}
#[test]
fn shapes_the_two_readers_disagree_about_are_refused() {
assert!(refused("echo \"a\nb\"").contains("double-quoted"));
assert!(refused("if exist x ^\n(\ndel y\n)").contains("escapes"));
assert!(refused("dir &&^\n(del x\ndel y)").contains("escapes"));
assert!(refused("cat <<EOF\nx\nEOF").contains("text block"));
assert!(refused("cat ^<<EOF\nx\nEOF").contains("text block"));
assert!(refused("echo \"unterminated\nb").contains("double-quoted"));
assert!(refused("echo a\necho \"b").contains("unclosed"));
assert!(refused("(\ndir\n").contains("unclosed"));
assert!(refused("dir\necho b^").contains("left waiting"));
assert!(refused("dir\n&& next").contains("connector"));
assert!(refused("dir &\n& x").contains("connector"));
assert!(refused("dir |\n| more").contains("connector"));
for text in [
"echo a (\necho b)",
"echo (\nb)",
"echo 'total ('\necho more",
"echo a # (note\necho b",
"if exist x echo (\nmore",
"dir C:\\ws (\nmore",
"echo (a\necho b)",
] {
assert!(refused(text).contains("bracketed group"), "{text:?}");
}
}
#[test]
fn a_caret_left_with_nothing_to_escape_is_refused() {
for text in [
"dir\necho b^",
"dir\necho b^\n",
"dir\necho b^\r\n",
"echo a ^\n",
"echo a ^\r\n",
"||\n^\n^\n^",
"||\n^\n^\n^\n",
] {
assert!(refused(text).contains("left waiting"), "{text:?}");
}
for text in ["echo a ^\n\n", "echo a ^\r\n\r\n", "dir ^\n\n(x)"] {
assert!(refused(text).contains("left taking"), "{text:?}");
}
reads("echo a ^\nb", "echo a ^\nb");
reads("echo a ^\nb\n", "echo a ^\nb");
reads("echo a ^^\nb", "echo a ^^&b");
reads("echo a ^\n \nb", "echo a ^\n &b");
reads("echo a ^\n \t\nb", "echo a ^\n \t&b");
}
#[test]
fn the_guards_own_divergences_are_refused_in_the_mode_that_reads_them() {
for text in [
"echo 'a\nb'",
"echo It's here\ndir C:\\ws",
"echo ^'a\ndel C:\\ws\\x.txt\necho ^'b",
"echo ^\"a\ndel C:\\ws\\x.txt\necho ^\"b",
"echo ^\"a b\ndir",
"(echo a\\\ndel C:\\ws\\x.txt)",
"(echo 'a\ndel C:\\ws\\x.txt')",
"dir C:\\ws\\\ndel x",
"echo 'a\\\nb'",
"echo a\necho 'b",
"dir ^& x\ndir y",
"echo a ^>\nbuild.bat",
"dir /b *.txt ^\n| findstr x",
] {
assert!(read(text).is_ok(), "{text:?}");
let cause = refused_read_only(text);
assert!(cause.contains("Nothing ran."), "{text:?}: {cause}");
}
let quoted = refused_read_only("echo 'a\\\nb'");
assert!(quoted.contains("single-quoted word"), "{quoted}");
let continued = refused_read_only("(echo a\\\ndel C:\\ws\\x.txt)");
assert!(
continued.contains("odd number of backslashes"),
"{continued}"
);
reads("echo 'a\nb'", "echo 'a&b'");
reads("dir C:\\ws\\\ndel x", "dir C:\\ws\\&del x");
reads("dir ^& x\ndir y", "dir ^& x&dir y");
reads("echo a ^>\nbuild.bat", "echo a ^>&build.bat");
reads("dir /b *.txt ^\n| findstr x", "dir /b *.txt ^\n| findstr x");
}
#[test]
fn the_cap_is_the_platforms_own_limit_and_the_remedy_is_the_modes() {
let at_cap = "a".repeat(TEXT_UNIT_LIMIT);
let over_cap = "a".repeat(TEXT_UNIT_LIMIT + 1);
assert!(check_command_line(&at_cap, ShellPlatform::Windows, ShellMode::Full).is_ok());
let astral = "\u{1F600}".repeat(TEXT_UNIT_LIMIT / 2 + 1);
assert!(astral.chars().count() < TEXT_UNIT_LIMIT);
let over_cap_in_units =
check_command_line(&astral, ShellPlatform::Windows, ShellMode::Full)
.expect_err("over the cap in the platform's units");
assert!(over_cap_in_units.contains("8191"), "{over_cap_in_units}");
assert!(check_command_line(&over_cap, ShellPlatform::Unix, ShellMode::Full).is_ok());
assert!(check_command_line(&astral, ShellPlatform::Unix, ShellMode::Full).is_ok());
let over_cap_reading =
read(&over_cap).expect("the reading of an over-cap text is produced");
assert!(
check_command_line(&over_cap_reading, ShellPlatform::Windows, ShellMode::Full).is_err()
);
assert!(
check_command_line(
&read(&format!("{at_cap}\n")).expect("read"),
ShellPlatform::Windows,
ShellMode::Full
)
.is_ok()
);
assert!(
check_command_line(
&read(&format!("{over_cap}\n")).expect("read"),
ShellPlatform::Windows,
ShellMode::Full
)
.is_err()
);
let full = check_command_line(&over_cap, ShellPlatform::Windows, ShellMode::Full)
.expect_err("over the cap");
let read_only = check_command_line(&over_cap, ShellPlatform::Windows, ShellMode::ReadOnly)
.expect_err("over the cap");
assert!(full.contains("8191"), "{full}");
assert!(full.contains(&TEXT_UNIT_LIMIT.to_string()), "{full}");
assert!(full.contains("Nothing ran."), "{full}");
assert!(full.contains(&remedy(ShellMode::Full)), "{full}");
assert!(
read_only.contains(&remedy(ShellMode::ReadOnly)),
"{read_only}"
);
assert_ne!(full, read_only);
}
#[test]
fn a_swallowed_line_at_a_break_is_refused_rather_than_separated() {
let task = "comment (`rem`) or a label";
for text in [
"rem note\ndir",
"rem\ndir",
"@rem note\ndir",
"REM NOTE\ndir",
" rem note\ndir",
"rem,note\ndir",
"dir & ^rem x\ndir2",
"del x & ^\nrem note\nmore",
":: note\ndir",
":label\ndir",
"echo a\n:: b\nmore",
"dir & :label\nmore",
"dir | :label\nmore",
"(dir) :label\nmore",
"@:label\ndir",
"@ :label\ndir",
"@\t:label\ndir",
] {
assert!(refused(text).contains(task), "{text:?}");
assert!(refused_read_only(text).contains(task), "{text:?}");
}
reads("remainder\ndir", "remainder&dir");
reads("rem.txt\ndir", "rem.txt&dir");
reads("echo rem\ndir", "echo rem&dir");
reads("echo \"rem note\"\ndir", "echo \"rem note\"&dir");
reads("echo :label\ndir", "echo :label&dir");
reads("rem. note\ndir", "rem. note&dir");
reads(
"echo a ^\n:not-a-label\nmore",
"echo a ^\n:not-a-label&more",
);
reads("rem note\n", "rem note");
reads("dir\nrem note", "dir&rem note");
reads("echo a\n:: b", "echo a&:: b");
for text in ["echo a\n(rem note)", "(rem note)\ndir"] {
assert!(
refused(text).contains("unclosed bracketed group"),
"{text:?}"
);
}
reads("echo (a) rem note\ndir", "echo (a) rem note&dir");
reads("echo (rem note)\ndir", "echo (rem note)&dir");
reads("dir (x) rem note\ndir2", "dir (x) rem note&dir2");
for text in ["rem note (x)\ndir", "(dir) rem note\ndir2"] {
assert!(refused(text).contains(task), "{text:?}");
}
reads("(rem note\ndir\n)", "(rem note\ndir\n)");
}
}