use std::borrow::Cow;
use std::ops::Range;
use super::compound::balanced_paren_at;
pub struct Expansion<'a> {
pub body: Cow<'a, str>,
pub arithmetic: bool,
}
pub fn backtick_script(raw: &str) -> Cow<'_, str> {
if !raw.contains('\\') {
return Cow::Borrowed(raw);
}
let bytes = raw.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut changed = false;
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && matches!(bytes.get(i + 1), Some(b'\\' | b'`' | b'$')) {
out.push(bytes[i + 1]);
changed = true;
i += 2;
} else {
out.push(bytes[i]);
i += 1;
}
}
if changed {
Cow::Owned(String::from_utf8(out).unwrap_or_else(|_| raw.to_string()))
} else {
Cow::Borrowed(raw)
}
}
pub struct Heredoc {
pub delim: String,
pub quoted: bool,
pub body: Range<usize>,
}
#[derive(Default)]
pub struct Lexed<'a> {
pub expansions: Vec<Expansion<'a>>,
pub comments: Vec<Range<usize>>,
pub heredocs: Vec<Heredoc>,
pub continuations: Vec<Range<usize>>,
pub problem: Option<&'static str>,
pub too_deep: bool,
}
const MAX_NESTING: usize = 64;
pub fn lex(command: &str) -> Lexed<'_> {
let mut lexer = Lexer {
s: command,
out: Lexed::default(),
pending: Vec::new(),
nesting: 0,
};
if has_stop_parsing_token(command) && super::ps_statements::host_is_powershell() {
lexer.flag(STOP_PARSING);
}
lexer.scan(command.len(), 0, Mode::Shell, Close::Eof, true);
let len = command.len();
for heredoc in std::mem::take(&mut lexer.pending) {
lexer.out.heredocs.push(Heredoc {
delim: heredoc.delim,
quoted: heredoc.quoted,
body: len..len,
});
}
lexer.out
}
pub fn remove_ranges(command: &str, mut ranges: Vec<Range<usize>>) -> String {
if ranges.is_empty() {
return command.to_string();
}
ranges.sort_by_key(|r| r.start);
let mut out = String::with_capacity(command.len());
let mut at = 0;
for range in ranges {
if range.start > at {
out.push_str(&command[at..range.start]);
}
at = at.max(range.end);
}
out.push_str(&command[at.min(command.len())..]);
out
}
const UNTERMINATED_HEREDOC: &str = "a here-document whose terminator line never follows";
const NESTED_HEREDOC: &str = "here-documents queued across a substitution boundary";
const ANSI_C_ESCAPED_QUOTE: &str =
"a `$'…'` string with an escaped quote, which bash/zsh and dash end at different quotes";
const QUOTE_IN_QUOTED_PARAM: &str =
"a `'` inside a double-quoted `${…}`, which bash and dash/zsh end differently";
const BRACE_IN_PARAM: &str = "a `{` inside `${…}`, which bash/dash and zsh end differently";
const SHIFT_IN_ARITHMETIC_COMMAND: &str =
"`<<` inside an arithmetic command `(( … ))`, which dash reads as a here-document";
const LEGACY_ARITHMETIC: &str = "legacy `$[ … ]` arithmetic, which dash reads as shell syntax \
(use `$(( … ))`)";
const ZSH_PARAM_FLAGS: &str =
"zsh parameter flags `${(…)…}`, which can evaluate a value as code (`(e)`)";
const PROMPT_TRANSFORM: &str =
"a `${…@P}` prompt expansion, which runs the substitutions in the value";
const ATTACHED_PAREN: &str = "a `(` attached to a word, which zsh reads as glob qualifiers that can \
run code (`*(e:…:)`, `*(+cmd)`) and bash as an extended glob";
const TOO_DEEP: &str = "substitutions, backticks, `${…}` or PowerShell `( … )` groups nested \
more than 64 levels deep";
const ATTACHED_BLOCK_COMMENT: &str =
"a PowerShell block comment `<#` attached to a word, where it may not start a comment";
const UNTERMINATED_BLOCK_COMMENT: &str = "a PowerShell block comment `<#` without its closing `#>`";
const HEREDOC_IN_REGEX_GROUP: &str = "`<<` inside a `( … )` group of a `[[ … =~ … ]]` regex, which \
bash reads as regex text and other shells may read as a here-document";
const COMMENT_IN_REGEX: &str = "a `#` after a `(`, `)` or `|` in the regex of `[[ … =~ … ]]` \
with quotes, `$`, backticks or a backslash after it on its line: bash reads it as regex \
text, zsh may read it as a comment";
const QUOTE_ESCAPE_IN_BACKTICK: &str = "a `\\\"` in a backtick substitution inside double quotes, \
which bash unescapes and other shells may not";
const HERE_STRING: &str = "a PowerShell here-string (`@'` or `@\"` ending its line), whose \
line-anchored closing `'@`/`\"@` the gate does not follow (write the text to a file instead)";
const ESCAPED_DELIMITER: &str = "a PowerShell backtick escape before a quote, `#`, `;`, `|` or \
`&`, which the gate reads as a substitution";
const PS_BRACED_VARIABLE: &str = "a PowerShell braced variable `${…}` holding a quote, `$`, a \
backtick, a bracket, a line break or a non-ASCII character, where PowerShell ends the name \
at the first `}` but the gate would read past it";
const STOP_PARSING: &str = "the PowerShell stop-parsing token `--%`, after which PowerShell \
passes quotes literally up to the next `|` or line break";
const PS_HEREDOC: &str = "`<<` under PowerShell, which has no here-documents: PowerShell keeps \
the next line as a command where the gate would read it as here-document text";
const PS_ATTACHED_COMMENT: &str = "a `#` attached to a PowerShell quote, group, member access, \
parameter, redirection or non-ASCII character, or after a `[`, `(` or `{`, where PowerShell \
starts a comment the gate would read as part of a word";
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Shell,
HeredocBody,
Param { quoted: bool },
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Close {
Eof,
Paren,
Brace,
}
struct PendingHeredoc {
delim: String,
quoted: bool,
strip_tabs: bool,
}
struct Lexer<'a> {
s: &'a str,
out: Lexed<'a>,
pending: Vec<PendingHeredoc>,
nesting: usize,
}
impl<'a> Lexer<'a> {
fn flag(&mut self, problem: &'static str) {
self.out.problem.get_or_insert(problem);
}
fn push(&mut self, collect: bool, body: impl Into<Cow<'a, str>>, arithmetic: bool) {
if collect {
self.out.expansions.push(Expansion {
body: body.into(),
arithmetic,
});
}
}
fn scan_unescaped(&mut self, script: &str) {
let mut inner = Lexer {
s: script,
out: Lexed::default(),
pending: Vec::new(),
nesting: self.nesting,
};
inner.scan(script.len(), 0, Mode::Shell, Close::Eof, false);
if inner.out.too_deep {
self.out.too_deep = true;
}
if let Some(problem) = inner.out.problem {
self.flag(problem);
}
}
fn scan(
&mut self,
end: usize,
start: usize,
mode: Mode,
close: Close,
collect: bool,
) -> Option<usize> {
if self.nesting >= MAX_NESTING {
self.out.too_deep = true;
self.flag(TOO_DEEP);
return None;
}
self.nesting += 1;
let after = self.scan_level(end, start, mode, close, collect);
self.nesting -= 1;
after
}
fn scan_level(
&mut self,
end: usize,
start: usize,
mode: Mode,
close: Close,
collect: bool,
) -> Option<usize> {
let s = self.s;
let bytes = &s.as_bytes()[..end];
let unterminated = (close == Close::Eof).then_some(end);
let pending_base = self.pending.len();
let param = matches!(mode, Mode::Param { .. });
let mut i = start;
let mut in_double = false;
let mut depth: u32 = 0;
let mut arithmetic_until = 0;
let mut bare_close_at = usize::MAX;
let mut regex_await = false;
let mut regex_word = false;
let mut regex_depth: u32 = 0;
while i < end {
let ch = bytes[i];
if regex_await && !matches!(ch, b' ' | b'\t') {
regex_await = false;
regex_word = !matches!(ch, b'\n' | b'#');
}
if ch == b'\\' {
if bytes.get(i + 1) == Some(&b'\n') {
self.out.continuations.push(i..i + 2);
}
i += 2;
continue;
}
if ch == b'$' {
match bytes.get(i + 1) {
Some(b'(') => {
if bytes.get(i + 2) == Some(&b'(')
&& let Some((expr, after)) = balanced_paren_at(&s[..end], i + 2)
&& bytes.get(after) == Some(&b')')
{
self.push(collect, expr, true);
i = after + 1;
continue;
}
match self.substitution(end, i + 1, collect) {
Some(after) => i = after,
None => return unterminated,
}
continue;
}
Some(b'{') if mode != Mode::HeredocBody => {
if bytes.get(i + 2) == Some(&b'(') {
self.flag(ZSH_PARAM_FLAGS);
}
if super::ps_statements::host_is_powershell()
&& braced_variable_disagrees(&bytes[i + 2..])
{
self.flag(PS_BRACED_VARIABLE);
return unterminated;
}
let quoted = in_double || mode == Mode::Param { quoted: true };
match self.scan(end, i + 2, Mode::Param { quoted }, Close::Brace, collect) {
Some(after) => i = after,
None => return unterminated,
}
continue;
}
Some(b'[') => {
self.flag(LEGACY_ARITHMETIC);
i += 2;
continue;
}
_ => {}
}
}
if ch == b'`' {
if matches!(
bytes.get(i + 1),
Some(b'\'' | b'"' | b'#' | b';' | b'|' | b'&')
) && super::ps_statements::host_is_powershell()
{
self.flag(ESCAPED_DELIMITER);
i += 2;
continue;
}
let close_at = closing_backtick(bytes, i + 1);
let body_end = close_at.unwrap_or(end);
let raw = &s[i + 1..body_end];
if in_double && raw.contains("\\\"") {
self.flag(QUOTE_ESCAPE_IN_BACKTICK);
}
match backtick_script(raw) {
Cow::Borrowed(raw) => {
self.push(collect, raw, false);
self.scan(body_end, i + 1, Mode::Shell, Close::Eof, false);
}
Cow::Owned(script) => {
self.scan_unescaped(&script);
self.push(collect, script, false);
}
}
match close_at {
Some(close_at) => i = close_at + 1,
None => return unterminated,
}
continue;
}
if mode == Mode::HeredocBody {
i += 1;
continue;
}
if in_double {
in_double = ch != b'"';
i += 1;
continue;
}
if regex_word
&& regex_depth == 0
&& matches!(ch, b' ' | b'\t' | b'\n' | b';' | b'&' | b'<' | b'>')
{
regex_word = false;
}
match ch {
b'@' if matches!(bytes.get(i + 1), Some(b'\'' | b'"'))
&& opens_here_string(bytes, i + 2)
&& super::ps_statements::host_is_powershell() =>
{
self.flag(HERE_STRING);
return unterminated;
}
b'"' => in_double = true,
b'\'' if mode == (Mode::Param { quoted: true }) => {
self.flag(QUOTE_IN_QUOTED_PARAM);
}
b'\'' => match s[i + 1..end].find('\'') {
Some(k) => i += k + 1,
None => return unterminated,
},
b'$' if bytes.get(i + 1) == Some(&b'\'') => match self.ansi_c_end(bytes, i + 2) {
Some(close_at) => i = close_at,
None => return unterminated,
},
b'#' if regex_word => {
let line_end = s[i..end].find('\n').map_or(end, |k| i + k);
if starts_word(bytes, i, start, bare_close_at)
&& s[i..line_end].contains(['\'', '"', '`', '$', '\\'])
{
self.flag(COMMENT_IN_REGEX);
}
}
b'<' if regex_depth > 0 && bytes.get(i + 1) == Some(&b'<') => {
self.flag(HEREDOC_IN_REGEX_GROUP);
i += 2;
continue;
}
b'#' if !param && starts_word(bytes, i, start, bare_close_at) => {
let line_end = s[i..end].find('\n').map_or(end, |k| i + k);
self.out.comments.push(i..line_end);
i = line_end;
continue;
}
b'#' if !param
&& super::ps_statements::host_is_powershell()
&& (!in_plain_powershell_word(&bytes[start..i])
|| bytes[start..i]
.iter()
.any(|b| matches!(b, b'[' | b'(' | b'{'))) =>
{
self.flag(PS_ATTACHED_COMMENT);
return unterminated;
}
b'<' if !param
&& bytes.get(i + 1) == Some(&b'#')
&& super::ps_statements::host_is_powershell() =>
{
if !starts_word(bytes, i, start, bare_close_at) {
self.flag(ATTACHED_BLOCK_COMMENT);
}
if let Some(k) = s[i + 2..end].find("#>") {
self.out.comments.push(i..i + 2 + k + 2);
i += 2 + k + 2;
continue;
}
self.flag(UNTERMINATED_BLOCK_COMMENT);
self.out.comments.push(i..end);
return unterminated;
}
b'<' if !param && bytes.get(i + 1) == Some(&b'<') => {
if bytes.get(i + 2) == Some(&b'<') {
i += 3;
continue;
}
if super::ps_statements::host_is_powershell() {
self.flag(PS_HEREDOC);
return unterminated;
}
if i < arithmetic_until {
self.flag(SHIFT_IN_ARITHMETIC_COMMAND);
i += 2;
continue;
}
i = self.heredoc_operator(bytes, i + 2);
continue;
}
b'<' | b'>' if bytes.get(i + 1) == Some(&b'(') => {
match self.substitution(end, i + 1, collect) {
Some(after) => i = after,
None => return unterminated,
}
continue;
}
b'{' if param => self.flag(BRACE_IN_PARAM),
b'}' if close == Close::Brace => {
if s[..i].ends_with("@P") {
self.flag(PROMPT_TRANSFORM);
}
return Some(i + 1);
}
b'=' if mode == Mode::Shell
&& bytes.get(i + 1) == Some(&b'~')
&& matches!(bytes.get(i + 2), Some(b' ' | b'\t'))
&& starts_word(bytes, i, start, bare_close_at) =>
{
regex_await = true;
i += 2;
continue;
}
b'=' if !param
&& bytes.get(i + 1) == Some(&b'(')
&& starts_word(bytes, i, start, bare_close_at) =>
{
match self.substitution(end, i + 1, collect) {
Some(after) => i = after,
None => return unterminated,
}
continue;
}
b'(' => {
if !param
&& i >= arithmetic_until
&& !starts_word(bytes, i, start, bare_close_at)
&& bytes[i - 1] != b'='
&& bytes.get(i + 1) != Some(&b')')
&& !(powershell_member_call(bytes, i, start)
&& super::ps_statements::host_is_powershell())
{
self.flag(ATTACHED_PAREN);
}
if !param
&& bytes.get(i + 1) == Some(&b'(')
&& let Some((expr, after)) = balanced_paren_at(&s[..end], i + 1)
&& bytes.get(after) == Some(&b')')
{
self.push(collect, expr, true);
arithmetic_until = after + 1;
}
if regex_word {
regex_depth += 1;
}
depth += 1;
if self.nesting + depth as usize > MAX_NESTING
&& !self.out.too_deep
&& super::ps_statements::host_is_powershell()
{
self.out.too_deep = true;
self.flag(TOO_DEEP);
}
}
b')' => {
if close == Close::Paren && depth == 0 {
return Some(i + 1);
}
if regex_depth > 0 {
regex_depth -= 1;
} else {
regex_word = false;
}
depth = depth.saturating_sub(1);
bare_close_at = i;
}
b'\n' if !param && !self.pending.is_empty() => {
if pending_base > 0 {
self.flag(NESTED_HEREDOC);
}
i = self.heredoc_bodies(end, i + 1, collect);
continue;
}
_ => {}
}
i += 1;
}
unterminated
}
fn substitution(&mut self, end: usize, open: usize, collect: bool) -> Option<usize> {
let after = self.scan(end, open + 1, Mode::Shell, Close::Paren, false);
let s = self.s;
self.push(collect, &s[open + 1..after.map_or(end, |e| e - 1)], false);
after
}
fn ansi_c_end(&mut self, bytes: &[u8], from: usize) -> Option<usize> {
let mut j = from;
while j < bytes.len() {
match bytes[j] {
b'\\' => {
if bytes.get(j + 1) == Some(&b'\'') {
self.flag(ANSI_C_ESCAPED_QUOTE);
}
j += 2;
}
b'\'' => return Some(j),
_ => j += 1,
}
}
None
}
fn heredoc_operator(&mut self, bytes: &[u8], from: usize) -> usize {
let mut j = from;
let strip_tabs = bytes.get(j) == Some(&b'-');
if strip_tabs {
j += 1;
}
while matches!(bytes.get(j), Some(b' ' | b'\t')) {
j += 1;
}
let word_limit = bytes[j..]
.iter()
.position(|b| matches!(b, b'\n' | b'(' | b')'))
.map_or(bytes.len(), |k| j + k);
match super::read_heredoc_delim(&bytes[..word_limit], j) {
Some((delim, quoted, next)) => {
self.pending.push(PendingHeredoc {
delim,
quoted,
strip_tabs,
});
next
}
None => j,
}
}
fn heredoc_bodies(&mut self, end: usize, mut pos: usize, collect: bool) -> usize {
let s = self.s;
let mut range_start = pos - 1;
for heredoc in std::mem::take(&mut self.pending) {
let body_start = pos;
let mut body_end = end;
let mut range_end = end;
let mut terminated = false;
let mut continued = false;
while pos < end {
let line_end = s[pos..end].find('\n').map_or(end, |k| pos + k);
let line = &s[pos..line_end];
let joined = continued;
continued = !heredoc.quoted
&& line.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1;
let line = if heredoc.strip_tabs {
line.trim_start_matches('\t')
} else {
line
};
if !joined && line == heredoc.delim {
body_end = pos;
range_end = line_end;
pos = line_end + 1;
terminated = true;
break;
}
pos = line_end + 1;
}
if !terminated {
self.flag(UNTERMINATED_HEREDOC);
pos = end;
}
if !heredoc.quoted {
self.scan(body_end, body_start, Mode::HeredocBody, Close::Eof, collect);
}
self.out.heredocs.push(Heredoc {
delim: heredoc.delim,
quoted: heredoc.quoted,
body: range_start..range_end,
});
range_start = range_end;
}
pos.min(end)
}
}
fn braced_variable_disagrees(body: &[u8]) -> bool {
match body.iter().position(|&b| b == b'}') {
Some(close) => body[..close].iter().any(|b| {
matches!(
b,
b'\'' | b'"' | b'`' | b'$' | b'{' | b'(' | b')' | b'\n' | b'\r' | b'\\'
) || *b >= 0x80
}),
None => true,
}
}
fn is_stop_parsing_token(bytes: &[u8]) -> bool {
let mut rest = bytes;
for _ in 0..2 {
rest = match rest {
[b'-', tail @ ..] | [0xE2, 0x80, 0x93..=0x95, tail @ ..] => tail,
_ => return false,
};
}
rest.first() == Some(&b'%')
}
fn has_stop_parsing_token(command: &str) -> bool {
let bytes = command.as_bytes();
(0..bytes.len()).any(|i| {
matches!(bytes[i], b'-' | 0xE2)
&& is_stop_parsing_token(&bytes[i..])
&& !glued_into_plain_word(&bytes[..i])
})
}
fn glued_into_plain_word(before: &[u8]) -> bool {
let start = before
.iter()
.rposition(|b| matches!(b, b' ' | b'\t' | b'\n'))
.map_or(0, |p| p + 1);
let word = &before[start..];
word.last()
.is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'='))
&& word
.iter()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'='))
}
fn starts_word(bytes: &[u8], i: usize, start: usize, bare_close_at: usize) -> bool {
if i == start {
return true;
}
if escaped(bytes, i - 1, start) {
return false;
}
match bytes[i - 1] {
b' ' | b'\t' | b'\n' | b';' | b'&' | b'|' | b'(' => true,
b')' => bare_close_at == i - 1,
_ => false,
}
}
fn escaped(bytes: &[u8], at: usize, start: usize) -> bool {
bytes[start..at]
.iter()
.rev()
.take_while(|&&b| b == b'\\')
.count()
% 2
== 1
}
fn in_plain_powershell_word(before: &[u8]) -> bool {
let start = before
.iter()
.rposition(|b| matches!(b, b' ' | b'\t' | b'\n'))
.map_or(0, |p| p + 1);
let word = &before[start..];
!word.is_empty()
&& word
.iter()
.all(|b| b.is_ascii_alphanumeric() || b"_-=./+~%!?*^".contains(b))
}
fn powershell_member_call(bytes: &[u8], i: usize, start: usize) -> bool {
let mut j = i;
while j > start && (bytes[j - 1].is_ascii_alphanumeric() || bytes[j - 1] == b'_') {
j -= 1;
}
let accessed = j < i
&& j > start
&& (bytes[j - 1] == b'.' && (j - 1 == start || bytes[j - 2] != b'.')
|| bytes[j - 1] == b':' && j - 1 > start && bytes[j - 2] == b':');
if !accessed {
return false;
}
while j > start
&& !matches!(
bytes[j - 1],
b' ' | b'\t' | b'\n' | b';' | b'&' | b'|' | b'{' | b'}' | b'=' | b','
)
{
j -= 1;
}
if !matches!(bytes[j], b'$' | b'[' | b'(') {
return false;
}
j = skip_blanks_back(bytes, j, start);
if j > start && bytes[j - 1] == b'=' {
let mut k = j - 1;
if k > start && matches!(bytes[k - 1], b'+' | b'-' | b'*' | b'/' | b'%') {
k -= 1;
}
k = skip_blanks_back(bytes, k, start);
let name_end = k;
while k > start && (bytes[k - 1].is_ascii_alphanumeric() || bytes[k - 1] == b'_') {
k -= 1;
}
if k == name_end || k == start || bytes[k - 1] != b'$' {
return false;
}
j = skip_blanks_back(bytes, k - 1, start);
}
j == start || matches!(bytes[j - 1], b'\n' | b';' | b'{')
}
fn skip_blanks_back(bytes: &[u8], mut at: usize, start: usize) -> usize {
while at > start && matches!(bytes[at - 1], b' ' | b'\t') {
at -= 1;
}
at
}
fn closing_backtick(bytes: &[u8], from: usize) -> Option<usize> {
let mut j = from;
while j < bytes.len() {
match bytes[j] {
b'\\' => j += 2,
b'`' => return Some(j),
_ => j += 1,
}
}
None
}
fn opens_here_string(bytes: &[u8], from: usize) -> bool {
bytes[from..]
.iter()
.find(|b| !matches!(b, b' ' | b'\t' | b'\r'))
.is_some_and(|b| *b == b'\n')
}