fixed ss3 escape code parsing, added a cursor mode reset that triggers on child exit
This commit is contained in:
@@ -8,7 +8,29 @@ use ariadne::Fmt;
|
||||
|
||||
use crate::{
|
||||
builtin::{
|
||||
alias::{alias, unalias}, arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate}, autocmd::autocmd, cd::cd, complete::{compgen_builtin, complete_builtin}, dirstack::{dirs, popd, pushd}, echo::echo, eval, exec, flowctl::flowctl, getopts::getopts, intro, jobctl::{self, JobBehavior, continue_job, disown, jobs}, keymap, seek::seek, map, pwd::pwd, read::{self, read_builtin}, resource::{ulimit, umask_builtin}, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, varcmds::{export, local, readonly, unset}
|
||||
alias::{alias, unalias},
|
||||
arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate},
|
||||
autocmd::autocmd,
|
||||
cd::cd,
|
||||
complete::{compgen_builtin, complete_builtin},
|
||||
dirstack::{dirs, popd, pushd},
|
||||
echo::echo,
|
||||
eval, exec,
|
||||
flowctl::flowctl,
|
||||
getopts::getopts,
|
||||
intro,
|
||||
jobctl::{self, JobBehavior, continue_job, disown, jobs},
|
||||
keymap, map,
|
||||
pwd::pwd,
|
||||
read::{self, read_builtin},
|
||||
resource::{ulimit, umask_builtin},
|
||||
seek::seek,
|
||||
shift::shift,
|
||||
shopt::shopt,
|
||||
source::source,
|
||||
test::double_bracket_test,
|
||||
trap::{TrapTarget, trap},
|
||||
varcmds::{export, local, readonly, unset},
|
||||
},
|
||||
expand::{expand_aliases, expand_case_pattern, glob_to_regex},
|
||||
jobs::{ChildProc, JobStack, attach_tty, dispatch_job},
|
||||
@@ -319,12 +341,12 @@ impl Dispatcher {
|
||||
};
|
||||
|
||||
let mut elem_iter = elements.into_iter();
|
||||
let mut skip = false;
|
||||
let mut skip = false;
|
||||
while let Some(element) = elem_iter.next() {
|
||||
let ConjunctNode { cmd, operator } = element;
|
||||
if !skip {
|
||||
self.dispatch_node(*cmd)?;
|
||||
}
|
||||
if !skip {
|
||||
self.dispatch_node(*cmd)?;
|
||||
}
|
||||
|
||||
let status = state::get_status();
|
||||
skip = match operator {
|
||||
@@ -351,7 +373,11 @@ impl Dispatcher {
|
||||
};
|
||||
let body_span = body.get_span();
|
||||
let body = body_span.as_str().to_string();
|
||||
let name = name.span.as_str().strip_suffix("()").unwrap_or(name.span.as_str());
|
||||
let name = name
|
||||
.span
|
||||
.as_str()
|
||||
.strip_suffix("()")
|
||||
.unwrap_or(name.span.as_str());
|
||||
|
||||
if KEYWORDS.contains(&name) {
|
||||
return Err(ShErr::at(
|
||||
@@ -863,9 +889,9 @@ impl Dispatcher {
|
||||
if fork_builtins {
|
||||
log::trace!("Forking builtin: {}", cmd_raw);
|
||||
let guard = self.io_stack.pop_frame().redirect()?;
|
||||
if cmd_raw.as_str() == "exec" {
|
||||
guard.persist();
|
||||
}
|
||||
if cmd_raw.as_str() == "exec" {
|
||||
guard.persist();
|
||||
}
|
||||
self.run_fork(&cmd_raw, |s| {
|
||||
if let Err(e) = s.dispatch_builtin(cmd) {
|
||||
e.print_error();
|
||||
@@ -990,7 +1016,7 @@ impl Dispatcher {
|
||||
"autocmd" => autocmd(cmd),
|
||||
"ulimit" => ulimit(cmd),
|
||||
"umask" => umask_builtin(cmd),
|
||||
"seek" => seek(cmd),
|
||||
"seek" => seek(cmd),
|
||||
"true" | ":" => {
|
||||
state::set_status(0);
|
||||
Ok(())
|
||||
|
||||
534
src/parse/lex.rs
534
src/parse/lex.rs
@@ -218,31 +218,30 @@ impl Tk {
|
||||
self.span.as_str().trim() == ";;"
|
||||
}
|
||||
|
||||
pub fn is_opener(&self) -> bool {
|
||||
OPENERS.contains(&self.as_str()) ||
|
||||
matches!(self.class, TkRule::BraceGrpStart) ||
|
||||
matches!(self.class, TkRule::CasePattern)
|
||||
}
|
||||
pub fn is_closer(&self) -> bool {
|
||||
matches!(self.as_str(), "fi" | "done" | "esac") ||
|
||||
self.has_double_semi() ||
|
||||
matches!(self.class, TkRule::BraceGrpEnd)
|
||||
}
|
||||
pub fn is_opener(&self) -> bool {
|
||||
OPENERS.contains(&self.as_str())
|
||||
|| matches!(self.class, TkRule::BraceGrpStart)
|
||||
|| matches!(self.class, TkRule::CasePattern)
|
||||
}
|
||||
pub fn is_closer(&self) -> bool {
|
||||
matches!(self.as_str(), "fi" | "done" | "esac")
|
||||
|| self.has_double_semi()
|
||||
|| matches!(self.class, TkRule::BraceGrpEnd)
|
||||
}
|
||||
|
||||
pub fn is_closer_for(&self, other: &Tk) -> bool {
|
||||
if (matches!(other.class, TkRule::BraceGrpStart) && matches!(self.class, TkRule::BraceGrpEnd))
|
||||
|| (matches!(other.class, TkRule::CasePattern) && self.has_double_semi()) {
|
||||
return true;
|
||||
}
|
||||
match other.as_str() {
|
||||
"for" |
|
||||
"while" |
|
||||
"until" => matches!(self.as_str(), "done"),
|
||||
"if" => matches!(self.as_str(), "fi"),
|
||||
"case" => matches!(self.as_str(), "esac"),
|
||||
_ => false
|
||||
}
|
||||
}
|
||||
pub fn is_closer_for(&self, other: &Tk) -> bool {
|
||||
if (matches!(other.class, TkRule::BraceGrpStart) && matches!(self.class, TkRule::BraceGrpEnd))
|
||||
|| (matches!(other.class, TkRule::CasePattern) && self.has_double_semi())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
match other.as_str() {
|
||||
"for" | "while" | "until" => matches!(self.as_str(), "done"),
|
||||
"if" => matches!(self.as_str(), "fi"),
|
||||
"case" => matches!(self.as_str(), "esac"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Tk {
|
||||
@@ -267,9 +266,9 @@ bitflags! {
|
||||
const ASSIGN = 0b0000000001000000;
|
||||
const BUILTIN = 0b0000000010000000;
|
||||
const IS_PROCSUB = 0b0000000100000000;
|
||||
const IS_HEREDOC = 0b0000001000000000;
|
||||
const LIT_HEREDOC = 0b0000010000000000;
|
||||
const TAB_HEREDOC = 0b0000100000000000;
|
||||
const IS_HEREDOC = 0b0000001000000000;
|
||||
const LIT_HEREDOC = 0b0000010000000000;
|
||||
const TAB_HEREDOC = 0b0000100000000000;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,11 +321,10 @@ pub struct LexStream {
|
||||
brc_grp_depth: usize,
|
||||
brc_grp_start: Option<usize>,
|
||||
case_depth: usize,
|
||||
heredoc_skip: Option<usize>,
|
||||
heredoc_skip: Option<usize>,
|
||||
flags: LexFlags,
|
||||
}
|
||||
|
||||
|
||||
impl LexStream {
|
||||
pub fn new(source: Arc<String>, flags: LexFlags) -> Self {
|
||||
let flags = flags | LexFlags::FRESH | LexFlags::NEXT_IS_CMD;
|
||||
@@ -338,7 +336,7 @@ impl LexStream {
|
||||
quote_state: QuoteState::default(),
|
||||
brc_grp_depth: 0,
|
||||
brc_grp_start: None,
|
||||
heredoc_skip: None,
|
||||
heredoc_skip: None,
|
||||
case_depth: 0,
|
||||
}
|
||||
}
|
||||
@@ -411,13 +409,13 @@ impl LexStream {
|
||||
return None; // It's a process sub
|
||||
}
|
||||
pos += 1;
|
||||
if let Some('|') = chars.peek() {
|
||||
// noclobber force '>|'
|
||||
chars.next();
|
||||
pos += 1;
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break
|
||||
}
|
||||
if let Some('|') = chars.peek() {
|
||||
// noclobber force '>|'
|
||||
chars.next();
|
||||
pos += 1;
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
}
|
||||
|
||||
if let Some('>') = chars.peek() {
|
||||
chars.next();
|
||||
@@ -428,34 +426,34 @@ impl LexStream {
|
||||
break;
|
||||
};
|
||||
|
||||
chars.next();
|
||||
pos += 1;
|
||||
chars.next();
|
||||
pos += 1;
|
||||
|
||||
let mut found_fd = false;
|
||||
if chars.peek().is_some_and(|ch| *ch == '-') {
|
||||
chars.next();
|
||||
found_fd = true;
|
||||
pos += 1;
|
||||
} else {
|
||||
while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
|
||||
chars.next();
|
||||
found_fd = true;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
let mut found_fd = false;
|
||||
if chars.peek().is_some_and(|ch| *ch == '-') {
|
||||
chars.next();
|
||||
found_fd = true;
|
||||
pos += 1;
|
||||
} else {
|
||||
while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
|
||||
chars.next();
|
||||
found_fd = true;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
|
||||
let span_start = self.cursor;
|
||||
self.cursor = pos;
|
||||
return Some(Err(ShErr::at(
|
||||
ShErrKind::ParseErr,
|
||||
Span::new(span_start..pos, self.source.clone()),
|
||||
"Invalid redirection",
|
||||
)));
|
||||
} else {
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
}
|
||||
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
|
||||
let span_start = self.cursor;
|
||||
self.cursor = pos;
|
||||
return Some(Err(ShErr::at(
|
||||
ShErrKind::ParseErr,
|
||||
Span::new(span_start..pos, self.source.clone()),
|
||||
"Invalid redirection",
|
||||
)));
|
||||
} else {
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
}
|
||||
}
|
||||
'<' => {
|
||||
if chars.peek() == Some(&'(') {
|
||||
@@ -463,93 +461,93 @@ impl LexStream {
|
||||
}
|
||||
pos += 1;
|
||||
|
||||
match chars.peek() {
|
||||
Some('<') => {
|
||||
chars.next();
|
||||
pos += 1;
|
||||
match chars.peek() {
|
||||
Some('<') => {
|
||||
chars.next();
|
||||
pos += 1;
|
||||
|
||||
match chars.peek() {
|
||||
Some('<') => {
|
||||
chars.next();
|
||||
pos += 1;
|
||||
}
|
||||
match chars.peek() {
|
||||
Some('<') => {
|
||||
chars.next();
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
Some(ch) => {
|
||||
let mut ch = *ch;
|
||||
while is_field_sep(ch) {
|
||||
let Some(next_ch) = chars.next() else {
|
||||
// Incomplete input — fall through to emit << as Redir
|
||||
break;
|
||||
};
|
||||
pos += next_ch.len_utf8();
|
||||
ch = next_ch;
|
||||
}
|
||||
Some(ch) => {
|
||||
let mut ch = *ch;
|
||||
while is_field_sep(ch) {
|
||||
let Some(next_ch) = chars.next() else {
|
||||
// Incomplete input — fall through to emit << as Redir
|
||||
break;
|
||||
};
|
||||
pos += next_ch.len_utf8();
|
||||
ch = next_ch;
|
||||
}
|
||||
|
||||
if is_field_sep(ch) {
|
||||
// Ran out of input while skipping whitespace — fall through
|
||||
} else {
|
||||
let saved_cursor = self.cursor;
|
||||
match self.read_heredoc(pos) {
|
||||
Ok(Some(heredoc_tk)) => {
|
||||
// cursor is set to after the delimiter word;
|
||||
// heredoc_skip is set to after the body
|
||||
pos = self.cursor;
|
||||
self.cursor = saved_cursor;
|
||||
tk = heredoc_tk;
|
||||
break;
|
||||
}
|
||||
Ok(None) => {
|
||||
// Incomplete heredoc — restore cursor and fall through
|
||||
self.cursor = saved_cursor;
|
||||
}
|
||||
Err(e) => return Some(Err(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// No delimiter yet — input is incomplete
|
||||
// Fall through to emit the << as a Redir token
|
||||
}
|
||||
}
|
||||
}
|
||||
Some('>') => {
|
||||
chars.next();
|
||||
pos += 1;
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
}
|
||||
Some('&') => {
|
||||
chars.next();
|
||||
pos += 1;
|
||||
if is_field_sep(ch) {
|
||||
// Ran out of input while skipping whitespace — fall through
|
||||
} else {
|
||||
let saved_cursor = self.cursor;
|
||||
match self.read_heredoc(pos) {
|
||||
Ok(Some(heredoc_tk)) => {
|
||||
// cursor is set to after the delimiter word;
|
||||
// heredoc_skip is set to after the body
|
||||
pos = self.cursor;
|
||||
self.cursor = saved_cursor;
|
||||
tk = heredoc_tk;
|
||||
break;
|
||||
}
|
||||
Ok(None) => {
|
||||
// Incomplete heredoc — restore cursor and fall through
|
||||
self.cursor = saved_cursor;
|
||||
}
|
||||
Err(e) => return Some(Err(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// No delimiter yet — input is incomplete
|
||||
// Fall through to emit the << as a Redir token
|
||||
}
|
||||
}
|
||||
}
|
||||
Some('>') => {
|
||||
chars.next();
|
||||
pos += 1;
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
}
|
||||
Some('&') => {
|
||||
chars.next();
|
||||
pos += 1;
|
||||
|
||||
let mut found_fd = false;
|
||||
if chars.peek().is_some_and(|ch| *ch == '-') {
|
||||
chars.next();
|
||||
found_fd = true;
|
||||
pos += 1;
|
||||
} else {
|
||||
while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
|
||||
chars.next();
|
||||
found_fd = true;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
let mut found_fd = false;
|
||||
if chars.peek().is_some_and(|ch| *ch == '-') {
|
||||
chars.next();
|
||||
found_fd = true;
|
||||
pos += 1;
|
||||
} else {
|
||||
while chars.peek().is_some_and(|ch| ch.is_ascii_digit()) {
|
||||
chars.next();
|
||||
found_fd = true;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
|
||||
let span_start = self.cursor;
|
||||
self.cursor = pos;
|
||||
return Some(Err(ShErr::at(
|
||||
ShErrKind::ParseErr,
|
||||
Span::new(span_start..pos, self.source.clone()),
|
||||
"Invalid redirection",
|
||||
)));
|
||||
} else {
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !found_fd && !self.flags.contains(LexFlags::LEX_UNFINISHED) {
|
||||
let span_start = self.cursor;
|
||||
self.cursor = pos;
|
||||
return Some(Err(ShErr::at(
|
||||
ShErrKind::ParseErr,
|
||||
Span::new(span_start..pos, self.source.clone()),
|
||||
"Invalid redirection",
|
||||
)));
|
||||
} else {
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
tk = self.get_token(self.cursor..pos, TkRule::Redir);
|
||||
break;
|
||||
@@ -574,130 +572,133 @@ impl LexStream {
|
||||
self.cursor = pos;
|
||||
Some(Ok(tk))
|
||||
}
|
||||
pub fn read_heredoc(&mut self, mut pos: usize) -> ShResult<Option<Tk>> {
|
||||
let slice = self.slice(pos..).unwrap_or_default().to_string();
|
||||
let mut chars = slice.chars();
|
||||
let mut delim = String::new();
|
||||
let mut flags = TkFlags::empty();
|
||||
let mut first_char = true;
|
||||
// Parse the delimiter word, stripping quotes
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'-' if first_char => {
|
||||
pos += 1;
|
||||
flags |= TkFlags::TAB_HEREDOC;
|
||||
}
|
||||
'\"' => {
|
||||
pos += 1;
|
||||
self.quote_state.toggle_double();
|
||||
flags |= TkFlags::LIT_HEREDOC;
|
||||
}
|
||||
'\'' => {
|
||||
pos += 1;
|
||||
self.quote_state.toggle_single();
|
||||
flags |= TkFlags::LIT_HEREDOC;
|
||||
}
|
||||
_ if self.quote_state.in_quote() => {
|
||||
pos += ch.len_utf8();
|
||||
delim.push(ch);
|
||||
}
|
||||
ch if is_hard_sep(ch) => {
|
||||
break;
|
||||
}
|
||||
ch => {
|
||||
pos += ch.len_utf8();
|
||||
delim.push(ch);
|
||||
}
|
||||
}
|
||||
first_char = false;
|
||||
}
|
||||
pub fn read_heredoc(&mut self, mut pos: usize) -> ShResult<Option<Tk>> {
|
||||
let slice = self.slice(pos..).unwrap_or_default().to_string();
|
||||
let mut chars = slice.chars();
|
||||
let mut delim = String::new();
|
||||
let mut flags = TkFlags::empty();
|
||||
let mut first_char = true;
|
||||
// Parse the delimiter word, stripping quotes
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'-' if first_char => {
|
||||
pos += 1;
|
||||
flags |= TkFlags::TAB_HEREDOC;
|
||||
}
|
||||
'\"' => {
|
||||
pos += 1;
|
||||
self.quote_state.toggle_double();
|
||||
flags |= TkFlags::LIT_HEREDOC;
|
||||
}
|
||||
'\'' => {
|
||||
pos += 1;
|
||||
self.quote_state.toggle_single();
|
||||
flags |= TkFlags::LIT_HEREDOC;
|
||||
}
|
||||
_ if self.quote_state.in_quote() => {
|
||||
pos += ch.len_utf8();
|
||||
delim.push(ch);
|
||||
}
|
||||
ch if is_hard_sep(ch) => {
|
||||
break;
|
||||
}
|
||||
ch => {
|
||||
pos += ch.len_utf8();
|
||||
delim.push(ch);
|
||||
}
|
||||
}
|
||||
first_char = false;
|
||||
}
|
||||
|
||||
// pos is now right after the delimiter word — this is where
|
||||
// the cursor should return so the rest of the line gets lexed
|
||||
let cursor_after_delim = pos;
|
||||
// pos is now right after the delimiter word — this is where
|
||||
// the cursor should return so the rest of the line gets lexed
|
||||
let cursor_after_delim = pos;
|
||||
|
||||
// Re-slice from cursor_after_delim so iterator and pos are in sync
|
||||
// (the old chars iterator consumed the hard_sep without advancing pos)
|
||||
let rest = self.slice(cursor_after_delim..).unwrap_or_default().to_string();
|
||||
let mut chars = rest.chars();
|
||||
// Re-slice from cursor_after_delim so iterator and pos are in sync
|
||||
// (the old chars iterator consumed the hard_sep without advancing pos)
|
||||
let rest = self
|
||||
.slice(cursor_after_delim..)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let mut chars = rest.chars();
|
||||
|
||||
// Scan forward to the newline (or use heredoc_skip from a previous heredoc)
|
||||
let body_start = if let Some(skip) = self.heredoc_skip {
|
||||
// A previous heredoc on this line already read its body;
|
||||
// our body starts where that one ended
|
||||
let skip_offset = skip - cursor_after_delim;
|
||||
for _ in 0..skip_offset {
|
||||
chars.next();
|
||||
}
|
||||
skip
|
||||
} else {
|
||||
// Skip the rest of the current line to find where the body begins
|
||||
let mut scan = pos;
|
||||
let mut found_newline = false;
|
||||
while let Some(ch) = chars.next() {
|
||||
scan += ch.len_utf8();
|
||||
if ch == '\n' {
|
||||
found_newline = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found_newline {
|
||||
if self.flags.contains(LexFlags::LEX_UNFINISHED) {
|
||||
return Ok(None);
|
||||
} else {
|
||||
return Err(ShErr::at(
|
||||
ShErrKind::ParseErr,
|
||||
Span::new(pos..pos, self.source.clone()),
|
||||
"Heredoc delimiter not found",
|
||||
));
|
||||
}
|
||||
}
|
||||
scan
|
||||
};
|
||||
// Scan forward to the newline (or use heredoc_skip from a previous heredoc)
|
||||
let body_start = if let Some(skip) = self.heredoc_skip {
|
||||
// A previous heredoc on this line already read its body;
|
||||
// our body starts where that one ended
|
||||
let skip_offset = skip - cursor_after_delim;
|
||||
for _ in 0..skip_offset {
|
||||
chars.next();
|
||||
}
|
||||
skip
|
||||
} else {
|
||||
// Skip the rest of the current line to find where the body begins
|
||||
let mut scan = pos;
|
||||
let mut found_newline = false;
|
||||
while let Some(ch) = chars.next() {
|
||||
scan += ch.len_utf8();
|
||||
if ch == '\n' {
|
||||
found_newline = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found_newline {
|
||||
if self.flags.contains(LexFlags::LEX_UNFINISHED) {
|
||||
return Ok(None);
|
||||
} else {
|
||||
return Err(ShErr::at(
|
||||
ShErrKind::ParseErr,
|
||||
Span::new(pos..pos, self.source.clone()),
|
||||
"Heredoc delimiter not found",
|
||||
));
|
||||
}
|
||||
}
|
||||
scan
|
||||
};
|
||||
|
||||
pos = body_start;
|
||||
let start = pos;
|
||||
pos = body_start;
|
||||
let start = pos;
|
||||
|
||||
// Read lines until we find one that matches the delimiter exactly
|
||||
let mut line = String::new();
|
||||
let mut line_start = pos;
|
||||
while let Some(ch) = chars.next() {
|
||||
pos += ch.len_utf8();
|
||||
if ch == '\n' {
|
||||
let trimmed = line.trim_end_matches('\r');
|
||||
if trimmed == delim {
|
||||
let mut tk = self.get_token(start..line_start, TkRule::Redir);
|
||||
tk.flags |= TkFlags::IS_HEREDOC | flags;
|
||||
self.heredoc_skip = Some(pos);
|
||||
self.cursor = cursor_after_delim;
|
||||
return Ok(Some(tk));
|
||||
}
|
||||
line.clear();
|
||||
line_start = pos;
|
||||
} else {
|
||||
line.push(ch);
|
||||
}
|
||||
}
|
||||
// Check the last line (no trailing newline)
|
||||
let trimmed = line.trim_end_matches('\r');
|
||||
if trimmed == delim {
|
||||
let mut tk = self.get_token(start..line_start, TkRule::Redir);
|
||||
tk.flags |= TkFlags::IS_HEREDOC | flags;
|
||||
self.heredoc_skip = Some(pos);
|
||||
self.cursor = cursor_after_delim;
|
||||
return Ok(Some(tk));
|
||||
}
|
||||
// Read lines until we find one that matches the delimiter exactly
|
||||
let mut line = String::new();
|
||||
let mut line_start = pos;
|
||||
while let Some(ch) = chars.next() {
|
||||
pos += ch.len_utf8();
|
||||
if ch == '\n' {
|
||||
let trimmed = line.trim_end_matches('\r');
|
||||
if trimmed == delim {
|
||||
let mut tk = self.get_token(start..line_start, TkRule::Redir);
|
||||
tk.flags |= TkFlags::IS_HEREDOC | flags;
|
||||
self.heredoc_skip = Some(pos);
|
||||
self.cursor = cursor_after_delim;
|
||||
return Ok(Some(tk));
|
||||
}
|
||||
line.clear();
|
||||
line_start = pos;
|
||||
} else {
|
||||
line.push(ch);
|
||||
}
|
||||
}
|
||||
// Check the last line (no trailing newline)
|
||||
let trimmed = line.trim_end_matches('\r');
|
||||
if trimmed == delim {
|
||||
let mut tk = self.get_token(start..line_start, TkRule::Redir);
|
||||
tk.flags |= TkFlags::IS_HEREDOC | flags;
|
||||
self.heredoc_skip = Some(pos);
|
||||
self.cursor = cursor_after_delim;
|
||||
return Ok(Some(tk));
|
||||
}
|
||||
|
||||
if !self.flags.contains(LexFlags::LEX_UNFINISHED) {
|
||||
Err(ShErr::at(
|
||||
ShErrKind::ParseErr,
|
||||
Span::new(start..pos, self.source.clone()),
|
||||
format!("Heredoc delimiter '{}' not found", delim),
|
||||
))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
if !self.flags.contains(LexFlags::LEX_UNFINISHED) {
|
||||
Err(ShErr::at(
|
||||
ShErrKind::ParseErr,
|
||||
Span::new(start..pos, self.source.clone()),
|
||||
format!("Heredoc delimiter '{}' not found", delim),
|
||||
))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
pub fn read_string(&mut self) -> ShResult<Tk> {
|
||||
assert!(self.cursor <= self.source.len());
|
||||
let slice = self.slice_from_cursor().unwrap().to_string();
|
||||
@@ -1113,9 +1114,10 @@ impl Iterator for LexStream {
|
||||
// If a heredoc was parsed on this line, skip past the body
|
||||
// Only on newline — ';' is a command separator within the same line
|
||||
if (ch == '\n' || ch == '\r')
|
||||
&& let Some(skip) = self.heredoc_skip.take() {
|
||||
self.cursor = skip;
|
||||
}
|
||||
&& let Some(skip) = self.heredoc_skip.take()
|
||||
{
|
||||
self.cursor = skip;
|
||||
}
|
||||
|
||||
while let Some(ch) = get_char(&self.source, self.cursor) {
|
||||
match ch {
|
||||
|
||||
325
src/parse/mod.rs
325
src/parse/mod.rs
@@ -12,7 +12,8 @@ use crate::{
|
||||
},
|
||||
parse::lex::clean_input,
|
||||
prelude::*,
|
||||
procio::IoMode, state::read_shopts,
|
||||
procio::IoMode,
|
||||
state::read_shopts,
|
||||
};
|
||||
|
||||
pub mod execute;
|
||||
@@ -280,17 +281,21 @@ bitflags! {
|
||||
pub struct Redir {
|
||||
pub io_mode: IoMode,
|
||||
pub class: RedirType,
|
||||
pub span: Option<Span>
|
||||
pub span: Option<Span>,
|
||||
}
|
||||
|
||||
impl Redir {
|
||||
pub fn new(io_mode: IoMode, class: RedirType) -> Self {
|
||||
Self { io_mode, class, span: None }
|
||||
Self {
|
||||
io_mode,
|
||||
class,
|
||||
span: None,
|
||||
}
|
||||
}
|
||||
pub fn with_span(mut self, span: Span) -> Self {
|
||||
self.span = Some(span);
|
||||
self
|
||||
}
|
||||
pub fn with_span(mut self, span: Span) -> Self {
|
||||
self.span = Some(span);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
@@ -298,7 +303,7 @@ pub struct RedirBldr {
|
||||
pub io_mode: Option<IoMode>,
|
||||
pub class: Option<RedirType>,
|
||||
pub tgt_fd: Option<RawFd>,
|
||||
pub span: Option<Span>,
|
||||
pub span: Option<Span>,
|
||||
}
|
||||
|
||||
impl RedirBldr {
|
||||
@@ -306,36 +311,36 @@ impl RedirBldr {
|
||||
Default::default()
|
||||
}
|
||||
pub fn with_io_mode(self, io_mode: IoMode) -> Self {
|
||||
Self {
|
||||
io_mode: Some(io_mode),
|
||||
..self
|
||||
}
|
||||
Self {
|
||||
io_mode: Some(io_mode),
|
||||
..self
|
||||
}
|
||||
}
|
||||
pub fn with_class(self, class: RedirType) -> Self {
|
||||
Self {
|
||||
class: Some(class),
|
||||
..self
|
||||
}
|
||||
Self {
|
||||
class: Some(class),
|
||||
..self
|
||||
}
|
||||
}
|
||||
pub fn with_tgt(self, tgt_fd: RawFd) -> Self {
|
||||
Self {
|
||||
tgt_fd: Some(tgt_fd),
|
||||
..self
|
||||
}
|
||||
Self {
|
||||
tgt_fd: Some(tgt_fd),
|
||||
..self
|
||||
}
|
||||
}
|
||||
pub fn with_span(self, span: Span) -> Self {
|
||||
Self {
|
||||
span: Some(span),
|
||||
..self
|
||||
}
|
||||
}
|
||||
pub fn with_span(self, span: Span) -> Self {
|
||||
Self {
|
||||
span: Some(span),
|
||||
..self
|
||||
}
|
||||
}
|
||||
pub fn build(self) -> Redir {
|
||||
let new = Redir::new(self.io_mode.unwrap(), self.class.unwrap());
|
||||
if let Some(span) = self.span {
|
||||
new.with_span(span)
|
||||
} else {
|
||||
new
|
||||
}
|
||||
if let Some(span) = self.span {
|
||||
new.with_span(span)
|
||||
} else {
|
||||
new
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,23 +360,23 @@ impl FromStr for RedirBldr {
|
||||
chars.next();
|
||||
redir = redir.with_class(RedirType::Append);
|
||||
} else if let Some('|') = chars.peek() {
|
||||
chars.next();
|
||||
redir = redir.with_class(RedirType::OutputForce);
|
||||
}
|
||||
chars.next();
|
||||
redir = redir.with_class(RedirType::OutputForce);
|
||||
}
|
||||
}
|
||||
'<' => {
|
||||
redir = redir.with_class(RedirType::Input);
|
||||
let mut count = 0;
|
||||
|
||||
if chars.peek() == Some(&'>') {
|
||||
chars.next(); // consume the '>'
|
||||
redir = redir.with_class(RedirType::ReadWrite);
|
||||
} else {
|
||||
while count < 2 && matches!(chars.peek(), Some('<')) {
|
||||
chars.next();
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if chars.peek() == Some(&'>') {
|
||||
chars.next(); // consume the '>'
|
||||
redir = redir.with_class(RedirType::ReadWrite);
|
||||
} else {
|
||||
while count < 2 && matches!(chars.peek(), Some('<')) {
|
||||
chars.next();
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
redir = match count {
|
||||
1 => redir.with_class(RedirType::HereDoc),
|
||||
@@ -380,23 +385,23 @@ impl FromStr for RedirBldr {
|
||||
};
|
||||
}
|
||||
'&' => {
|
||||
if chars.peek() == Some(&'-') {
|
||||
chars.next();
|
||||
src_fd.push('-');
|
||||
} else {
|
||||
while let Some(next_ch) = chars.next() {
|
||||
if next_ch.is_ascii_digit() {
|
||||
src_fd.push(next_ch)
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if chars.peek() == Some(&'-') {
|
||||
chars.next();
|
||||
src_fd.push('-');
|
||||
} else {
|
||||
while let Some(next_ch) = chars.next() {
|
||||
if next_ch.is_ascii_digit() {
|
||||
src_fd.push(next_ch)
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if src_fd.is_empty() {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::ParseErr,
|
||||
format!("Invalid character '{}' in redirection operator", ch),
|
||||
));
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::ParseErr,
|
||||
format!("Invalid character '{}' in redirection operator", ch),
|
||||
));
|
||||
}
|
||||
}
|
||||
_ if ch.is_ascii_digit() && tgt_fd.is_empty() => {
|
||||
@@ -410,27 +415,26 @@ impl FromStr for RedirBldr {
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(ShErr::simple(
|
||||
ShErrKind::ParseErr,
|
||||
format!("Invalid character '{}' in redirection operator", ch),
|
||||
)),
|
||||
_ => {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::ParseErr,
|
||||
format!("Invalid character '{}' in redirection operator", ch),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tgt_fd = tgt_fd
|
||||
.parse::<i32>()
|
||||
.unwrap_or_else(|_| match redir.class.unwrap() {
|
||||
RedirType::Input |
|
||||
RedirType::ReadWrite |
|
||||
RedirType::HereDoc |
|
||||
RedirType::HereString => 0,
|
||||
RedirType::Input | RedirType::ReadWrite | RedirType::HereDoc | RedirType::HereString => 0,
|
||||
_ => 1,
|
||||
});
|
||||
redir = redir.with_tgt(tgt_fd);
|
||||
if src_fd.as_str() == "-" {
|
||||
let io_mode = IoMode::Close { tgt_fd };
|
||||
redir = redir.with_io_mode(io_mode);
|
||||
} else if let Ok(src_fd) = src_fd.parse::<i32>() {
|
||||
if src_fd.as_str() == "-" {
|
||||
let io_mode = IoMode::Close { tgt_fd };
|
||||
redir = redir.with_io_mode(io_mode);
|
||||
} else if let Ok(src_fd) = src_fd.parse::<i32>() {
|
||||
let io_mode = IoMode::fd(tgt_fd, src_fd);
|
||||
redir = redir.with_io_mode(io_mode);
|
||||
}
|
||||
@@ -439,40 +443,40 @@ impl FromStr for RedirBldr {
|
||||
}
|
||||
|
||||
impl TryFrom<Tk> for RedirBldr {
|
||||
type Error = ShErr;
|
||||
fn try_from(tk: Tk) -> Result<Self, Self::Error> {
|
||||
let span = tk.span.clone();
|
||||
if tk.flags.contains(TkFlags::IS_HEREDOC) {
|
||||
let flags = tk.flags;
|
||||
type Error = ShErr;
|
||||
fn try_from(tk: Tk) -> Result<Self, Self::Error> {
|
||||
let span = tk.span.clone();
|
||||
if tk.flags.contains(TkFlags::IS_HEREDOC) {
|
||||
let flags = tk.flags;
|
||||
|
||||
Ok(RedirBldr {
|
||||
io_mode: Some(IoMode::buffer(0, tk.to_string(), flags)?),
|
||||
class: Some(RedirType::HereDoc),
|
||||
tgt_fd: Some(0),
|
||||
span: Some(span)
|
||||
})
|
||||
} else {
|
||||
match Self::from_str(tk.as_str()) {
|
||||
Ok(bldr) => Ok(bldr.with_span(span)),
|
||||
Err(e) => Err(e.promote(span)),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(RedirBldr {
|
||||
io_mode: Some(IoMode::buffer(0, tk.to_string(), flags)?),
|
||||
class: Some(RedirType::HereDoc),
|
||||
tgt_fd: Some(0),
|
||||
span: Some(span),
|
||||
})
|
||||
} else {
|
||||
match Self::from_str(tk.as_str()) {
|
||||
Ok(bldr) => Ok(bldr.with_span(span)),
|
||||
Err(e) => Err(e.promote(span)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Copy, Debug)]
|
||||
pub enum RedirType {
|
||||
Null, // Default
|
||||
Pipe, // |
|
||||
PipeAnd, // |&, redirs stderr and stdout
|
||||
Input, // <
|
||||
Output, // >
|
||||
OutputForce,// >|
|
||||
Append, // >>
|
||||
HereDoc, // <<
|
||||
IndentHereDoc, // <<-, strips leading tabs
|
||||
HereString, // <<<
|
||||
ReadWrite, // <>, fd is opened for reading and writing
|
||||
Null, // Default
|
||||
Pipe, // |
|
||||
PipeAnd, // |&, redirs stderr and stdout
|
||||
Input, // <
|
||||
Output, // >
|
||||
OutputForce, // >|
|
||||
Append, // >>
|
||||
HereDoc, // <<
|
||||
IndentHereDoc, // <<-, strips leading tabs
|
||||
HereString, // <<<
|
||||
ReadWrite, // <>, fd is opened for reading and writing
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -887,7 +891,9 @@ impl ParseStream {
|
||||
|
||||
// Two forms: "name()" as one token, or "name" followed by "()" as separate tokens
|
||||
let spaced_form = !is_func_name(self.peek_tk())
|
||||
&& self.peek_tk().is_some_and(|tk| tk.flags.contains(TkFlags::IS_CMD))
|
||||
&& self
|
||||
.peek_tk()
|
||||
.is_some_and(|tk| tk.flags.contains(TkFlags::IS_CMD))
|
||||
&& is_func_parens(self.tokens.get(1));
|
||||
|
||||
if !is_func_name(self.peek_tk()) && !spaced_form {
|
||||
@@ -1032,7 +1038,7 @@ impl ParseStream {
|
||||
Ok(Some(node))
|
||||
}
|
||||
fn parse_brc_grp(&mut self, from_func_def: bool) -> ShResult<Option<Node>> {
|
||||
log::debug!("Trying to parse a brace group");
|
||||
log::debug!("Trying to parse a brace group");
|
||||
let mut node_tks: Vec<Tk> = vec![];
|
||||
let mut body: Vec<Node> = vec![];
|
||||
let mut redirs: Vec<Redir> = vec![];
|
||||
@@ -1045,7 +1051,7 @@ impl ParseStream {
|
||||
self.catch_separator(&mut node_tks);
|
||||
|
||||
loop {
|
||||
log::debug!("Parsing a brace group body");
|
||||
log::debug!("Parsing a brace group body");
|
||||
if *self.next_tk_class() == TkRule::BraceGrpEnd {
|
||||
node_tks.push(self.next_tk().unwrap());
|
||||
break;
|
||||
@@ -1054,25 +1060,25 @@ impl ParseStream {
|
||||
node_tks.extend(node.tokens.clone());
|
||||
body.push(node);
|
||||
} else if *self.next_tk_class() != TkRule::BraceGrpEnd {
|
||||
let next = self.peek_tk().cloned();
|
||||
let err = match next {
|
||||
Some(tk) => Err(parse_err_full(
|
||||
&format!("Unexpected token '{}' in brace group body", tk.as_str()),
|
||||
&tk.span,
|
||||
self.context.clone(),
|
||||
)),
|
||||
None => Err(parse_err_full(
|
||||
"Unexpected end of input while parsing brace group body",
|
||||
&node_tks.get_span().unwrap(),
|
||||
self.context.clone(),
|
||||
)),
|
||||
};
|
||||
let next = self.peek_tk().cloned();
|
||||
let err = match next {
|
||||
Some(tk) => Err(parse_err_full(
|
||||
&format!("Unexpected token '{}' in brace group body", tk.as_str()),
|
||||
&tk.span,
|
||||
self.context.clone(),
|
||||
)),
|
||||
None => Err(parse_err_full(
|
||||
"Unexpected end of input while parsing brace group body",
|
||||
&node_tks.get_span().unwrap(),
|
||||
self.context.clone(),
|
||||
)),
|
||||
};
|
||||
self.panic_mode(&mut node_tks);
|
||||
return err;
|
||||
}
|
||||
self.catch_separator(&mut node_tks);
|
||||
if !self.next_tk_is_some() {
|
||||
log::debug!("Hit end of input while parsing a brace group body, entering panic mode");
|
||||
log::debug!("Hit end of input while parsing a brace group body, entering panic mode");
|
||||
self.panic_mode(&mut node_tks);
|
||||
return Err(parse_err_full(
|
||||
"Expected a closing brace for this brace group",
|
||||
@@ -1082,13 +1088,15 @@ impl ParseStream {
|
||||
}
|
||||
}
|
||||
|
||||
log::debug!("Finished parsing brace group body, now looking for redirections if it's not a function definition");
|
||||
log::debug!(
|
||||
"Finished parsing brace group body, now looking for redirections if it's not a function definition"
|
||||
);
|
||||
|
||||
if !from_func_def {
|
||||
self.parse_redir(&mut redirs, &mut node_tks)?;
|
||||
}
|
||||
|
||||
log::debug!("Finished parsing brace group redirections, constructing node");
|
||||
log::debug!("Finished parsing brace group redirections, constructing node");
|
||||
|
||||
let node = Node {
|
||||
class: NdRule::BraceGrp { body },
|
||||
@@ -1106,7 +1114,11 @@ impl ParseStream {
|
||||
context: LabelCtx,
|
||||
) -> ShResult<Redir> {
|
||||
let redir_bldr = RedirBldr::try_from(redir_tk.clone()).unwrap();
|
||||
let next_tk = if redir_bldr.io_mode.is_none() { next() } else { None };
|
||||
let next_tk = if redir_bldr.io_mode.is_none() {
|
||||
next()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if redir_bldr.io_mode.is_some() {
|
||||
return Ok(redir_bldr.build());
|
||||
}
|
||||
@@ -1126,11 +1138,7 @@ impl ParseStream {
|
||||
"Expected a string after this redirection",
|
||||
));
|
||||
}
|
||||
let mut string = next_tk
|
||||
.unwrap()
|
||||
.expand()?
|
||||
.get_words()
|
||||
.join(" ");
|
||||
let mut string = next_tk.unwrap().expand()?.get_words().join(" ");
|
||||
string.push('\n');
|
||||
let io_mode = IoMode::buffer(redir_bldr.tgt_fd.unwrap_or(0), string, redir_tk.flags)?;
|
||||
Ok(redir_bldr.with_io_mode(io_mode).build())
|
||||
@@ -1155,7 +1163,7 @@ impl ParseStream {
|
||||
while self.check_redir() {
|
||||
let tk = self.next_tk().unwrap();
|
||||
node_tks.push(tk.clone());
|
||||
let ctx = self.context.clone();
|
||||
let ctx = self.context.clone();
|
||||
let redir = Self::build_redir(&tk, || self.next_tk(), node_tks, ctx)?;
|
||||
redirs.push(redir);
|
||||
}
|
||||
@@ -1663,7 +1671,7 @@ impl ParseStream {
|
||||
node_tks.push(prefix_tk.clone());
|
||||
assignments.push(assign)
|
||||
} else if is_keyword {
|
||||
return Ok(None)
|
||||
return Ok(None);
|
||||
} else if prefix_tk.class == TkRule::Sep {
|
||||
// Separator ends the prefix section - add it so commit() consumes it
|
||||
node_tks.push(prefix_tk.clone());
|
||||
@@ -1721,7 +1729,7 @@ impl ParseStream {
|
||||
}
|
||||
TkRule::Redir => {
|
||||
node_tks.push(tk.clone());
|
||||
let ctx = self.context.clone();
|
||||
let ctx = self.context.clone();
|
||||
let redir = Self::build_redir(tk, || tk_iter.next().cloned(), &mut node_tks, ctx)?;
|
||||
redirs.push(redir);
|
||||
}
|
||||
@@ -1882,34 +1890,33 @@ pub fn get_redir_file<P: AsRef<Path>>(class: RedirType, path: P) -> ShResult<Fil
|
||||
let path = path.as_ref();
|
||||
let result = match class {
|
||||
RedirType::Input => OpenOptions::new().read(true).open(Path::new(&path)),
|
||||
RedirType::Output => {
|
||||
if read_shopts(|o| o.core.noclobber) && path.is_file() {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::ExecFail,
|
||||
format!("shopt core.noclobber is set, refusing to overwrite existing file `{}`", path.display()),
|
||||
));
|
||||
}
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path)
|
||||
},
|
||||
RedirType::ReadWrite => {
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.read(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path)
|
||||
}
|
||||
RedirType::OutputForce => {
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path)
|
||||
}
|
||||
RedirType::Output => {
|
||||
if read_shopts(|o| o.core.noclobber) && path.is_file() {
|
||||
return Err(ShErr::simple(
|
||||
ShErrKind::ExecFail,
|
||||
format!(
|
||||
"shopt core.noclobber is set, refusing to overwrite existing file `{}`",
|
||||
path.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path)
|
||||
}
|
||||
RedirType::ReadWrite => OpenOptions::new()
|
||||
.write(true)
|
||||
.read(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(path),
|
||||
RedirType::OutputForce => OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path),
|
||||
RedirType::Append => OpenOptions::new().create(true).append(true).open(path),
|
||||
_ => unimplemented!("Unimplemented redir type: {:?}", class),
|
||||
};
|
||||
@@ -1936,9 +1943,7 @@ fn is_func_name(tk: Option<&Tk>) -> bool {
|
||||
}
|
||||
|
||||
fn is_func_parens(tk: Option<&Tk>) -> bool {
|
||||
tk.is_some_and(|tk| {
|
||||
tk.flags.contains(TkFlags::KEYWORD) && tk.span.as_str() == "()"
|
||||
})
|
||||
tk.is_some_and(|tk| tk.flags.contains(TkFlags::KEYWORD) && tk.span.as_str() == "()")
|
||||
}
|
||||
|
||||
/// Perform an operation on the child nodes of a given node
|
||||
@@ -2814,8 +2819,8 @@ pub mod tests {
|
||||
|
||||
// ===================== Heredoc Execution =====================
|
||||
|
||||
use crate::testutil::{TestGuard, test_input};
|
||||
use crate::state::{VarFlags, VarKind, write_vars};
|
||||
use crate::testutil::{TestGuard, test_input};
|
||||
|
||||
#[test]
|
||||
fn heredoc_basic_output() {
|
||||
|
||||
Reference in New Issue
Block a user