feat: add command substitutions with ()

This requires PreparedCommand's to work with OsStrings rather than
Strings.

Acked-by: cpli
Acked-by: ElKowar
This commit is contained in:
buffet 2022-10-24 19:27:04 +00:00
parent 65b50557c9
commit 4e1a835ddc
24 changed files with 710 additions and 37 deletions

View file

@ -1,4 +1,4 @@
use std::borrow::Cow;
use std::{borrow::Cow, ffi::OsStr};
use oyster_builtin_proc::builtin;
use oyster_runtime::Shell;
@ -6,5 +6,5 @@ use oyster_runtime::Shell;
#[test]
fn normal_usage() {
#[builtin(description = "some text")]
fn test(_: &mut Shell, _: &[Cow<str>]) {}
fn test(_: &mut Shell, _: &[Cow<OsStr>]) {}
}

View file

@ -27,6 +27,7 @@ pub struct Word<'a>(pub Vec<WordPart<'a>>);
#[derive(Debug)]
pub enum WordPart<'a> {
Text(&'a str),
CommandSubstitution(Code<'a>),
}
impl<'a> TryFrom<&'a str> for Code<'a> {
@ -129,6 +130,9 @@ fn build_tree_word<'a>(
ParseEvent::StartNode(NodeKind::DQuotedString) => {
build_tree_string(parser, source, pos, &mut children)?
}
ParseEvent::StartNode(NodeKind::CommandSubstitution) => children.push(
WordPart::CommandSubstitution(build_tree_program(parser, source, pos)?),
),
ParseEvent::EndNode => break,
ParseEvent::NewLeaf(_, len) => *pos += len,
ParseEvent::Error(err, _) => return Err(err),
@ -145,7 +149,7 @@ fn build_tree_string<'a>(
pos: &mut usize,
children: &mut Vec<WordPart<'a>>,
) -> Result<(), ParseError> {
for ev in parser {
while let Some(ev) = parser.next() {
match ev {
ParseEvent::NewLeaf(NodeKind::PlainText, len) => {
children.push(WordPart::Text(&source[*pos..*pos + len]));
@ -155,6 +159,9 @@ fn build_tree_string<'a>(
children.push(WordPart::Text(&source[*pos + 1..*pos + len]));
*pos += len
}
ParseEvent::StartNode(NodeKind::CommandSubstitution) => children.push(
WordPart::CommandSubstitution(build_tree_program(parser, source, pos)?),
),
ParseEvent::EndNode => break,
ParseEvent::NewLeaf(_, len) => *pos += len,
ParseEvent::Error(err, _) => return Err(err),

View file

@ -14,6 +14,10 @@ pub enum TokenKind {
Pipe,
/// Double quotes.
DoubleQuote,
/// Opening parenthesis.
OpeningParenthesis,
/// Closing parenthesis.
ClosingParenthesis,
/// Plain text. Either outside or inside of quotes.
PlainText,
/// A backslash followed by another character.
@ -103,6 +107,8 @@ impl Lexer<'_> {
';' => TokenKind::Semicolon,
'|' => TokenKind::Pipe,
'"' => TokenKind::DoubleQuote,
'(' => TokenKind::OpeningParenthesis,
')' => TokenKind::ClosingParenthesis,
c if is_whitespace(c) => {
self.eat_while(is_whitespace);
@ -125,7 +131,9 @@ impl Lexer<'_> {
}
_ => {
self.eat_while(|c| ![' ', '\t', '\n', ';', '|', '\\', '"'].contains(&c));
self.eat_while(|c| {
![' ', '\t', '\n', ';', '|', '\\', '"', '(', ')'].contains(&c)
});
TokenKind::PlainText
}
};
@ -147,6 +155,8 @@ impl Lexer<'_> {
Some(c) => {
let kind = match c {
'"' => TokenKind::DoubleQuote,
'(' => TokenKind::OpeningParenthesis,
')' => TokenKind::ClosingParenthesis,
'\\' => {
self.next_char();
@ -154,7 +164,7 @@ impl Lexer<'_> {
}
_ => {
self.eat_while(|c| !['"', '\\'].contains(&c));
self.eat_while(|c| !['"', '\\', '(', ')'].contains(&c));
TokenKind::PlainText
}
};

View file

@ -8,7 +8,7 @@
//! _terminator ::= SEMICOLON | NEWLINES | EOF
//! pipeline ::= command (PIPE NEWLINES? command)*
//! command ::= word+
//! word ::= (PLAIN_TEXT|DQUOTES PLAIN_TEXT DQUOTES)+
//! word ::= (PLAIN_TEXT|DQUOTES PLAIN_TEXT DQUOTES|OPEN_PREN program CLOSE_PREN)+
//!
//! extras ::= COMMENT | WHITESPACE | BACKSLASH_N
//! ```

View file

@ -11,6 +11,8 @@ pub enum ParseError {
UnexpectedEof,
#[error("unexpected semicolon in the middle of statement")]
UnexpectedSemicolon,
#[error("unmatched closing parenthesis")]
UnexpectedClosingParenthesis,
}
/// Type of the node.
@ -21,11 +23,14 @@ pub enum NodeKind {
Semicolon,
Pipe,
DoubleQuote,
OpeningParenthesis,
ClosingParenthesis,
PlainText,
EscapedChar,
Comment,
Program,
CommandSubstitution,
Pipeline,
Command,
Word,
@ -157,48 +162,76 @@ impl Iterator for Parser<'_> {
match self.stack.last() {
None => None,
Some(nt) => match nt {
// XXX: unify Program and CommandSubstitution to avoid duplication
NodeKind::Program => match self.lookahead.kind {
Whitespace => leaf!(Whitespace),
Newlines => leaf!(Newlines),
Semicolon => leaf!(Semicolon),
Comment => leaf!(Comment),
PlainText | DoubleQuote | EscapedChar => call!(Pipeline),
PlainText | DoubleQuote | OpeningParenthesis | EscapedChar => call!(Pipeline),
Pipe => error!(UnexpectedPipe),
ClosingParenthesis => error!(UnexpectedClosingParenthesis),
Eof => chain!(None, ret!()), // return silently
},
NodeKind::CommandSubstitution => match self.lookahead.kind {
Whitespace => leaf!(Whitespace),
Newlines => leaf!(Newlines),
Semicolon => leaf!(Semicolon),
Comment => leaf!(Comment),
PlainText | DoubleQuote | OpeningParenthesis | EscapedChar => call!(Pipeline),
ClosingParenthesis => match self.stack.get(self.stack.len() - 2) {
Some(&NodeKind::DQuotedString) => {
chain_buf!(leaf!(ClosingParenthesis, String), ret!())
}
_ => chain_buf!(leaf!(ClosingParenthesis, Command), ret!()),
},
Pipe => error!(UnexpectedPipe),
Eof => chain_buf!(error!(UnexpectedEof), ret!()),
},
NodeKind::Pipeline => match self.lookahead.kind {
Whitespace => leaf!(Whitespace),
Comment => leaf!(Comment),
Pipe => chain!(leaf!(Pipe), call!(PipelineCont)),
PlainText | DoubleQuote | EscapedChar => call!(Command),
Newlines | Semicolon | Eof => ret!(),
PlainText | DoubleQuote | OpeningParenthesis | EscapedChar => call!(Command),
Newlines | Semicolon | ClosingParenthesis | Eof => ret!(),
},
NodeKind::PipelineCont => match self.lookahead.kind {
Whitespace => leaf!(Whitespace),
Newlines => leaf!(Newlines),
Comment => leaf!(Comment),
PlainText | DoubleQuote | EscapedChar => tailcall!(Command),
PlainText | DoubleQuote | OpeningParenthesis | EscapedChar => {
tailcall!(Command)
}
Semicolon => chain_buf!(chain!(error!(UnexpectedSemicolon), ret!()), ret!()),
Pipe => chain!(error!(UnexpectedPipe), ret!()),
ClosingParenthesis => chain!(error!(UnexpectedClosingParenthesis), ret!()),
Eof => chain!(error!(UnexpectedEof), ret!()),
},
NodeKind::Command => match self.lookahead.kind {
Whitespace => leaf!(Whitespace),
Comment => leaf!(Comment),
PlainText | DoubleQuote | EscapedChar => call!(Word),
Newlines | Semicolon | Eof => ret!(),
Pipe => ret!(),
PlainText | DoubleQuote | OpeningParenthesis | EscapedChar => call!(Word),
Newlines | Semicolon | Pipe | ClosingParenthesis | Eof => ret!(),
},
NodeKind::Word => match self.lookahead.kind {
PlainText => leaf!(PlainText),
EscapedChar => leaf!(EscapedChar),
DoubleQuote => chain_buf!(call!(DQuotedString), leaf!(DoubleQuote, String)),
Comment | Whitespace | Newlines | Semicolon | Pipe | Eof => ret!(),
OpeningParenthesis => {
chain_buf!(call!(CommandSubstitution), leaf!(OpeningParenthesis))
}
Comment | Whitespace | Newlines | Semicolon | Pipe | ClosingParenthesis
| Eof => ret!(),
},
NodeKind::DQuotedString => match self.lookahead.kind {
PlainText => leaf!(PlainText, String),
EscapedChar => leaf!(EscapedChar, String),
DoubleQuote => chain_buf!(leaf!(DoubleQuote, Command), ret!()),
OpeningParenthesis => chain_buf!(
call!(CommandSubstitution),
leaf!(OpeningParenthesis, Command)
),
ClosingParenthesis => error!(UnexpectedClosingParenthesis, String),
Eof => chain_buf!(error!(UnexpectedEof, Command), ret!()),
_ => unreachable!(),
},

View file

@ -170,3 +170,22 @@ fn unterminated_double_quotes() {
assert_snapshot!(actual);
}
#[test]
fn command_substitution() {
let source = r#"echo (whoami)"#;
let actual = parse(source);
assert_snapshot!(actual);
}
#[test]
fn quoted_command_substitution() {
let source = r#"echo "(whoami)
""#;
let actual = parse(source);
assert_snapshot!(actual);
}

View file

@ -170,3 +170,22 @@ fn unterminated_double_quotes() {
assert_snapshot!(actual);
}
#[test]
fn command_substitution() {
let source = r#"echo (whoami)"#;
let actual = parse(source);
assert_snapshot!(actual);
}
#[test]
fn quoted_command_substitution() {
let source = r#"echo "(whoami)
""#;
let actual = parse(source);
assert_snapshot!(actual);
}

View file

@ -108,3 +108,39 @@ fn escaped_quotes_in_string() {
assert_snapshot!(actual);
}
#[test]
fn opening_parenthesis_command_mode() {
let source = r"(";
let actual = Lexer::new(source).next_command_token();
assert_snapshot!(actual);
}
#[test]
fn closing_parenthesis_command_mode() {
let source = r")";
let actual = Lexer::new(source).next_command_token();
assert_snapshot!(actual);
}
#[test]
fn opening_parenthesis_string_mode() {
let source = r"(";
let actual = Lexer::new(source).next_string_token();
assert_snapshot!(actual);
}
#[test]
fn closing_parenthesis_string_mode() {
let source = r")";
let actual = Lexer::new(source).next_string_token();
assert_snapshot!(actual);
}

View file

@ -170,3 +170,22 @@ fn unterminated_double_quotes() {
assert_snapshot!(actual);
}
#[test]
fn command_substitution() {
let source = r#"echo (whoami)"#;
let actual = parse(source);
assert_snapshot!(actual);
}
#[test]
fn quoted_command_substitution() {
let source = r#"echo "(whoami)
""#;
let actual = parse(source);
assert_snapshot!(actual);
}

View file

@ -0,0 +1,56 @@
---
source: crates/oyster_parser/tests/it/ast.rs
expression: actual
---
Ok(
Code(
[
Pipeline(
Pipeline(
[
Command(
[
Word(
[
Text(
"echo",
),
],
),
Word(
[
CommandSubstitution(
Code(
[
Pipeline(
Pipeline(
[
Command(
[
Word(
[
Text(
"whoami",
),
],
),
],
None,
),
],
),
),
],
),
),
],
),
],
None,
),
],
),
),
],
),
)

View file

@ -0,0 +1,59 @@
---
source: crates/oyster_parser/tests/it/ast.rs
expression: actual
---
Ok(
Code(
[
Pipeline(
Pipeline(
[
Command(
[
Word(
[
Text(
"echo",
),
],
),
Word(
[
CommandSubstitution(
Code(
[
Pipeline(
Pipeline(
[
Command(
[
Word(
[
Text(
"whoami",
),
],
),
],
None,
),
],
),
),
],
),
),
Text(
"\n ",
),
],
),
],
None,
),
],
),
),
],
),
)

View file

@ -0,0 +1,69 @@
---
source: crates/oyster_parser/tests/it/cst.rs
expression: actual
---
Tree {
kind: Program,
children: [
Tree {
kind: Pipeline,
children: [
Tree {
kind: Command,
children: [
Tree {
kind: Word,
children: [
Leaf {
kind: PlainText,
len: 4,
},
],
},
Leaf {
kind: Whitespace,
len: 1,
},
Tree {
kind: Word,
children: [
Tree {
kind: CommandSubstitution,
children: [
Leaf {
kind: OpeningParenthesis,
len: 1,
},
Tree {
kind: Pipeline,
children: [
Tree {
kind: Command,
children: [
Tree {
kind: Word,
children: [
Leaf {
kind: PlainText,
len: 6,
},
],
},
],
},
],
},
Leaf {
kind: ClosingParenthesis,
len: 1,
},
],
},
],
},
],
},
],
},
],
}

View file

@ -0,0 +1,86 @@
---
source: crates/oyster_parser/tests/it/cst.rs
expression: actual
---
Tree {
kind: Program,
children: [
Tree {
kind: Pipeline,
children: [
Tree {
kind: Command,
children: [
Tree {
kind: Word,
children: [
Leaf {
kind: PlainText,
len: 4,
},
],
},
Leaf {
kind: Whitespace,
len: 1,
},
Tree {
kind: Word,
children: [
Tree {
kind: DQuotedString,
children: [
Leaf {
kind: DoubleQuote,
len: 1,
},
Tree {
kind: CommandSubstitution,
children: [
Leaf {
kind: OpeningParenthesis,
len: 1,
},
Tree {
kind: Pipeline,
children: [
Tree {
kind: Command,
children: [
Tree {
kind: Word,
children: [
Leaf {
kind: PlainText,
len: 6,
},
],
},
],
},
],
},
Leaf {
kind: ClosingParenthesis,
len: 1,
},
],
},
Leaf {
kind: PlainText,
len: 9,
},
Leaf {
kind: DoubleQuote,
len: 1,
},
],
},
],
},
],
},
],
},
],
}

View file

@ -0,0 +1,8 @@
---
source: crates/oyster_parser/tests/it/lexer.rs
expression: actual
---
Token {
kind: ClosingParenthesis,
len: 1,
}

View file

@ -0,0 +1,8 @@
---
source: crates/oyster_parser/tests/it/lexer.rs
expression: actual
---
Token {
kind: ClosingParenthesis,
len: 1,
}

View file

@ -0,0 +1,8 @@
---
source: crates/oyster_parser/tests/it/lexer.rs
expression: actual
---
Token {
kind: OpeningParenthesis,
len: 1,
}

View file

@ -0,0 +1,8 @@
---
source: crates/oyster_parser/tests/it/lexer.rs
expression: actual
---
Token {
kind: OpeningParenthesis,
len: 1,
}

View file

@ -0,0 +1,58 @@
---
source: crates/oyster_parser/tests/it/parser.rs
expression: actual
---
[
StartNode(
Pipeline,
),
StartNode(
Command,
),
StartNode(
Word,
),
NewLeaf(
PlainText,
4,
),
EndNode,
NewLeaf(
Whitespace,
1,
),
StartNode(
Word,
),
StartNode(
CommandSubstitution,
),
NewLeaf(
OpeningParenthesis,
1,
),
StartNode(
Pipeline,
),
StartNode(
Command,
),
StartNode(
Word,
),
NewLeaf(
PlainText,
6,
),
EndNode,
EndNode,
EndNode,
NewLeaf(
ClosingParenthesis,
1,
),
EndNode,
EndNode,
EndNode,
EndNode,
]

View file

@ -0,0 +1,74 @@
---
source: crates/oyster_parser/tests/it/parser.rs
expression: actual
---
[
StartNode(
Pipeline,
),
StartNode(
Command,
),
StartNode(
Word,
),
NewLeaf(
PlainText,
4,
),
EndNode,
NewLeaf(
Whitespace,
1,
),
StartNode(
Word,
),
StartNode(
DQuotedString,
),
NewLeaf(
DoubleQuote,
1,
),
StartNode(
CommandSubstitution,
),
NewLeaf(
OpeningParenthesis,
1,
),
StartNode(
Pipeline,
),
StartNode(
Command,
),
StartNode(
Word,
),
NewLeaf(
PlainText,
6,
),
EndNode,
EndNode,
EndNode,
NewLeaf(
ClosingParenthesis,
1,
),
EndNode,
NewLeaf(
PlainText,
9,
),
NewLeaf(
DoubleQuote,
1,
),
EndNode,
EndNode,
EndNode,
EndNode,
]

View file

@ -1,4 +1,4 @@
use std::{borrow::Cow, collections::HashMap};
use std::{borrow::Cow, collections::HashMap, ffi::OsStr};
use oyster_builtin_proc::builtin;
@ -8,11 +8,11 @@ use crate::Shell;
pub struct Builtin {
pub name: &'static str,
pub description: &'static str,
pub fun: fn(shell: &mut Shell, args: &[Cow<str>]),
pub fun: fn(shell: &mut Shell, args: &[Cow<OsStr>]),
}
#[builtin(description = "prints help for different builtins")]
fn help(shell: &mut Shell, _args: &[Cow<str>]) {
fn help(shell: &mut Shell, _args: &[Cow<OsStr>]) {
println!(
r"oyster help:
@ -25,6 +25,7 @@ These are the loaded builtins:"
}
/// Used to register and retrieve builtins.
/// Builtin names gotta be valid unicode.
#[derive(Default)]
pub struct BuiltinMap(HashMap<&'static str, Builtin>);

View file

@ -4,15 +4,25 @@
pub mod builtins;
mod pipeline;
use std::io;
use std::{
ffi::OsString,
fs::File,
io::{self, Read},
os::unix::{ffi::OsStringExt, io::FromRawFd},
process,
};
use builtins::BuiltinMap;
use nix::{
errno::Errno,
fcntl::OFlag,
libc,
sys::{
signal::{self, SaFlags, SigAction, SigHandler, Signal},
signalfd::SigSet,
wait::{self, WaitPidFlag},
},
unistd::{self, ForkResult},
};
use oyster_parser::ast;
use thiserror::Error;
@ -34,11 +44,14 @@ pub enum RuntimeError {
#[error("failed to create pipe: {0}")]
PipeCreationFailed(#[source] nix::Error),
#[error("command not found: {0}")]
CommandNotFound(String),
#[error("failed to dup fd: {0}")]
DupFailed(#[source] nix::Error),
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("command not found: {0:?}")]
CommandNotFound(OsString),
#[error("permission denied: {0:?}")]
PermissionDenied(OsString),
#[error("failed to spawn process: {0}")]
SpawnFailed(#[source] io::Error),
@ -48,6 +61,9 @@ pub enum RuntimeError {
#[error("waitpid error: {0}")]
WaidPid(nix::Error),
#[error("I/O error: {0}")]
IOError(#[source] io::Error),
}
pub struct Shell {
@ -79,4 +95,45 @@ impl Shell {
Ok(last_status)
}
pub fn run_subshell<'a>(&mut self, code: &'a ast::Code) -> Result<OsString, RuntimeError> {
let (output, input) =
unistd::pipe2(OFlag::empty()).map_err(RuntimeError::PipeCreationFailed)?;
match unsafe { unistd::fork() }.map_err(RuntimeError::ForkFailed)? {
ForkResult::Parent { child } => {
let _ = unistd::close(input);
let mut f = unsafe { File::from_raw_fd(output) };
let mut bytes = Vec::new();
f.read_to_end(&mut bytes).map_err(RuntimeError::IOError)?;
let bytes = match bytes.iter().position(|b| !b.is_ascii_whitespace()) {
Some(start) => {
let end = bytes
.iter()
.rposition(|b| !b.is_ascii_whitespace())
.unwrap();
OsString::from_vec(bytes[start..=end].to_vec())
}
None => OsString::new(),
};
wait::waitpid(child, Some(WaitPidFlag::empty())).map_err(RuntimeError::WaidPid)?;
Ok(bytes)
}
ForkResult::Child => {
let _ = unistd::dup2(input, libc::STDOUT_FILENO);
let _ = unistd::close(input);
let _ = unistd::close(output);
if let Err(err) = self.run(code) {
eprintln!("oyster: {}", err);
}
process::exit(0);
}
}
}
}

View file

@ -1,6 +1,6 @@
use std::{
borrow::Cow,
ffi::{OsStr, OsString},
ffi::OsStr,
fs::File,
io,
os::unix::{
@ -34,8 +34,8 @@ enum CommandKind {
/// A command that's ready to be run.
struct PreparedCommand<'a> {
kind: CommandKind,
cmd: Cow<'a, str>,
args: Vec<Cow<'a, str>>,
cmd: Cow<'a, OsStr>,
args: Vec<Cow<'a, OsStr>>,
redirect: Redirect,
stdin: Option<File>,
stdout: Option<File>,
@ -44,17 +44,23 @@ struct PreparedCommand<'a> {
impl<'a> PreparedCommand<'a> {
/// Create a new PreparedCommand.
fn new(command: &'a ast::Command, shell: &Shell) -> Self {
fn new(command: &'a ast::Command, shell: &mut Shell) -> Self {
let mut words = command.0.iter().map(|word| {
let mut words = word.0.iter();
let mut s = match words.next().unwrap() {
ast::WordPart::Text(text) => Cow::from(*text),
ast::WordPart::Text(text) => Cow::from(OsStr::new(*text)),
ast::WordPart::CommandSubstitution(code) => {
Cow::from(shell.run_subshell(code).unwrap())
}
};
for part in words {
match part {
ast::WordPart::Text(text) => s.to_mut().push_str(text),
ast::WordPart::Text(text) => s.to_mut().push(text),
ast::WordPart::CommandSubstitution(code) => {
s.to_mut().push(shell.run_subshell(code).unwrap())
}
}
}
@ -64,7 +70,7 @@ impl<'a> PreparedCommand<'a> {
let args = words.collect();
let redirect = command.1;
let kind = match shell.builtins().get(&cmd) {
let kind = match cmd.to_str().and_then(|cmd| shell.builtins().get(cmd)) {
Some(builtin) => CommandKind::Builtin(*builtin),
None => CommandKind::External,
};
@ -98,15 +104,10 @@ impl<'a> PreparedCommand<'a> {
}
};
let args = self.args.iter().map(|w| match w {
Cow::Borrowed(s) => Cow::Borrowed(OsStr::new(s)),
Cow::Owned(s) => Cow::Owned(OsString::from(s)),
});
let child = match self.kind {
CommandKind::External => {
let mut cmd = Command::new(self.cmd.as_ref());
cmd.args(args);
let mut cmd = Command::new(&self.cmd);
cmd.args(self.args);
cmd.stdin(self.stdin.map_or(Stdio::inherit(), Stdio::from));
cmd.stdout(self.stdout.map_or(Stdio::inherit(), Stdio::from));
cmd.stderr(self.stderr.map_or(Stdio::inherit(), Stdio::from));

View file

@ -1,6 +1,7 @@
use std::{
borrow::Cow,
env,
ffi::OsStr,
fs::File,
io::{BufRead, BufReader, Write},
os::unix::io::FromRawFd,
@ -22,7 +23,7 @@ use oyster_runtime::Shell;
ioctl_write_int_bad!(tiocsctty, libc::TIOCSCTTY);
#[builtin(description = "test builtin")]
fn test_builtin(_: &mut Shell, _: &[Cow<str>]) {
fn test_builtin(_: &mut Shell, _: &[Cow<OsStr>]) {
// XXX: this is a workaround around libtest's use of io::set_output_capture
let _ = write!(std::io::stdout(), "this is a test\n");
}
@ -227,3 +228,34 @@ fn builtin_redirection() {
assert_snapshot!(actual);
}
#[test]
fn command_substitution() {
let ast = {
use oyster_parser::ast::*;
Code(vec![Statement::Pipeline(Pipeline(vec![Command(
vec![
Word(vec![WordPart::Text("echo")]),
Word(vec![WordPart::CommandSubstitution(Code(vec![
Statement::Pipeline(Pipeline(vec![Command(
vec![
Word(vec![WordPart::Text("echo")]),
Word(vec![WordPart::Text("hello")]),
],
Redirect::None,
)])),
]))]),
],
Redirect::None,
)]))])
};
let actual = collect_output(|| {
let mut shell = Shell::new().unwrap();
shell.builtins_mut().add(test_builtin);
shell.run(&ast).unwrap();
});
assert_snapshot!(actual);
}

View file

@ -0,0 +1,5 @@
---
source: crates/oyster_runtime/tests/it/pipeline.rs
expression: actual
---
"hello\r\n"