Add shell
setting (#525)
Add a `set SETTING := VALUE` construct. This construct is intended to be extended as needed with new settings, but for now we're starting with `set shell := [COMMAND, ARG1, ...]`, which allows setting the shell to use for recipe and backtick execution in a justfile. One of the primary reasons for adding this feature is to have a better story on windows, where users are forced to scrounge up an `sh` binary if they want to use `just`. This should allow them to use cmd.exe or powershell in their justfiles, making just optionally dependency-free.
This commit is contained in:
parent
177516bcbe
commit
e80bf34d9a
@ -2,28 +2,33 @@ use crate::common::*;
|
|||||||
|
|
||||||
use CompilationErrorKind::*;
|
use CompilationErrorKind::*;
|
||||||
|
|
||||||
pub(crate) struct Analyzer<'a> {
|
pub(crate) struct Analyzer<'src> {
|
||||||
recipes: Table<'a, Recipe<'a>>,
|
recipes: Table<'src, Recipe<'src>>,
|
||||||
assignments: Table<'a, Assignment<'a>>,
|
assignments: Table<'src, Assignment<'src>>,
|
||||||
aliases: Table<'a, Alias<'a>>,
|
aliases: Table<'src, Alias<'src>>,
|
||||||
|
sets: Table<'src, Set<'src>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Analyzer<'a> {
|
impl<'src> Analyzer<'src> {
|
||||||
pub(crate) fn analyze(module: Module<'a>) -> CompilationResult<'a, Justfile> {
|
pub(crate) fn analyze(module: Module<'src>) -> CompilationResult<'src, Justfile> {
|
||||||
let analyzer = Analyzer::new();
|
let analyzer = Analyzer::new();
|
||||||
|
|
||||||
analyzer.justfile(module)
|
analyzer.justfile(module)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new() -> Analyzer<'a> {
|
pub(crate) fn new() -> Analyzer<'src> {
|
||||||
Analyzer {
|
Analyzer {
|
||||||
recipes: empty(),
|
recipes: empty(),
|
||||||
assignments: empty(),
|
assignments: empty(),
|
||||||
aliases: empty(),
|
aliases: empty(),
|
||||||
|
sets: empty(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn justfile(mut self, module: Module<'a>) -> CompilationResult<'a, Justfile<'a>> {
|
pub(crate) fn justfile(
|
||||||
|
mut self,
|
||||||
|
module: Module<'src>,
|
||||||
|
) -> CompilationResult<'src, Justfile<'src>> {
|
||||||
for item in module.items {
|
for item in module.items {
|
||||||
match item {
|
match item {
|
||||||
Item::Alias(alias) => {
|
Item::Alias(alias) => {
|
||||||
@ -38,6 +43,10 @@ impl<'a> Analyzer<'a> {
|
|||||||
self.analyze_recipe(&recipe)?;
|
self.analyze_recipe(&recipe)?;
|
||||||
self.recipes.insert(recipe);
|
self.recipes.insert(recipe);
|
||||||
}
|
}
|
||||||
|
Item::Set(set) => {
|
||||||
|
self.analyze_set(&set)?;
|
||||||
|
self.sets.insert(set);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,15 +79,27 @@ impl<'a> Analyzer<'a> {
|
|||||||
|
|
||||||
AliasResolver::resolve_aliases(&aliases, &recipes)?;
|
AliasResolver::resolve_aliases(&aliases, &recipes)?;
|
||||||
|
|
||||||
|
let mut settings = Settings::new();
|
||||||
|
|
||||||
|
for (_, set) in self.sets.into_iter() {
|
||||||
|
match set.value {
|
||||||
|
Setting::Shell(shell) => {
|
||||||
|
assert!(settings.shell.is_none());
|
||||||
|
settings.shell = Some(shell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Justfile {
|
Ok(Justfile {
|
||||||
warnings: module.warnings,
|
warnings: module.warnings,
|
||||||
recipes,
|
|
||||||
assignments,
|
|
||||||
aliases,
|
aliases,
|
||||||
|
assignments,
|
||||||
|
recipes,
|
||||||
|
settings,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn analyze_recipe(&self, recipe: &Recipe<'a>) -> CompilationResult<'a, ()> {
|
fn analyze_recipe(&self, recipe: &Recipe<'src>) -> CompilationResult<'src, ()> {
|
||||||
if let Some(original) = self.recipes.get(recipe.name.lexeme()) {
|
if let Some(original) = self.recipes.get(recipe.name.lexeme()) {
|
||||||
return Err(recipe.name.token().error(DuplicateRecipe {
|
return Err(recipe.name.token().error(DuplicateRecipe {
|
||||||
recipe: original.name(),
|
recipe: original.name(),
|
||||||
@ -141,7 +162,7 @@ impl<'a> Analyzer<'a> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn analyze_assignment(&self, assignment: &Assignment<'a>) -> CompilationResult<'a, ()> {
|
fn analyze_assignment(&self, assignment: &Assignment<'src>) -> CompilationResult<'src, ()> {
|
||||||
if self.assignments.contains_key(assignment.name.lexeme()) {
|
if self.assignments.contains_key(assignment.name.lexeme()) {
|
||||||
return Err(assignment.name.token().error(DuplicateVariable {
|
return Err(assignment.name.token().error(DuplicateVariable {
|
||||||
variable: assignment.name.lexeme(),
|
variable: assignment.name.lexeme(),
|
||||||
@ -150,7 +171,7 @@ impl<'a> Analyzer<'a> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn analyze_alias(&self, alias: &Alias<'a>) -> CompilationResult<'a, ()> {
|
fn analyze_alias(&self, alias: &Alias<'src>) -> CompilationResult<'src, ()> {
|
||||||
let name = alias.name.lexeme();
|
let name = alias.name.lexeme();
|
||||||
|
|
||||||
if let Some(original) = self.aliases.get(name) {
|
if let Some(original) = self.aliases.get(name) {
|
||||||
@ -162,6 +183,17 @@ impl<'a> Analyzer<'a> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn analyze_set(&self, set: &Set<'src>) -> CompilationResult<'src, ()> {
|
||||||
|
if let Some(original) = self.sets.get(set.name.lexeme()) {
|
||||||
|
return Err(set.name.error(DuplicateSet {
|
||||||
|
setting: original.name.lexeme(),
|
||||||
|
first: original.name.line,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
@ -8,6 +8,7 @@ pub(crate) struct AssignmentEvaluator<'a: 'b, 'b> {
|
|||||||
pub(crate) scope: &'b BTreeMap<&'a str, (bool, String)>,
|
pub(crate) scope: &'b BTreeMap<&'a str, (bool, String)>,
|
||||||
pub(crate) working_directory: &'b Path,
|
pub(crate) working_directory: &'b Path,
|
||||||
pub(crate) overrides: &'b BTreeMap<String, String>,
|
pub(crate) overrides: &'b BTreeMap<String, String>,
|
||||||
|
pub(crate) settings: &'b Settings<'b>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
|
impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
|
||||||
@ -17,10 +18,12 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
|
|||||||
dotenv: &'b BTreeMap<String, String>,
|
dotenv: &'b BTreeMap<String, String>,
|
||||||
assignments: &BTreeMap<&'a str, Assignment<'a>>,
|
assignments: &BTreeMap<&'a str, Assignment<'a>>,
|
||||||
overrides: &BTreeMap<String, String>,
|
overrides: &BTreeMap<String, String>,
|
||||||
|
settings: &'b Settings<'b>,
|
||||||
) -> RunResult<'a, BTreeMap<&'a str, (bool, String)>> {
|
) -> RunResult<'a, BTreeMap<&'a str, (bool, String)>> {
|
||||||
let mut evaluator = AssignmentEvaluator {
|
let mut evaluator = AssignmentEvaluator {
|
||||||
evaluated: empty(),
|
evaluated: empty(),
|
||||||
scope: &empty(),
|
scope: &empty(),
|
||||||
|
settings,
|
||||||
overrides,
|
overrides,
|
||||||
config,
|
config,
|
||||||
assignments,
|
assignments,
|
||||||
@ -134,12 +137,12 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
|
|||||||
raw: &str,
|
raw: &str,
|
||||||
token: &Token<'a>,
|
token: &Token<'a>,
|
||||||
) -> RunResult<'a, String> {
|
) -> RunResult<'a, String> {
|
||||||
let mut cmd = Command::new(&self.config.shell);
|
let mut cmd = self.settings.shell_command(&self.config.shell);
|
||||||
|
|
||||||
|
cmd.arg(raw);
|
||||||
|
|
||||||
cmd.current_dir(self.working_directory);
|
cmd.current_dir(self.working_directory);
|
||||||
|
|
||||||
cmd.arg("-cu").arg(raw);
|
|
||||||
|
|
||||||
cmd.export_environment_variables(self.scope, dotenv)?;
|
cmd.export_environment_variables(self.scope, dotenv)?;
|
||||||
|
|
||||||
cmd.stdin(process::Stdio::inherit());
|
cmd.stdin(process::Stdio::inherit());
|
||||||
|
@ -32,7 +32,7 @@ pub(crate) use snafu::{ResultExt, Snafu};
|
|||||||
pub(crate) use unicode_width::UnicodeWidthChar;
|
pub(crate) use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
// modules
|
// modules
|
||||||
pub(crate) use crate::{config_error, keyword, search_error};
|
pub(crate) use crate::{config_error, keyword, search_error, setting};
|
||||||
|
|
||||||
// functions
|
// functions
|
||||||
pub(crate) use crate::{
|
pub(crate) use crate::{
|
||||||
@ -60,10 +60,11 @@ pub(crate) use crate::{
|
|||||||
parameter::Parameter, parser::Parser, platform::Platform, position::Position,
|
parameter::Parameter, parser::Parser, platform::Platform, position::Position,
|
||||||
positional::Positional, recipe::Recipe, recipe_context::RecipeContext,
|
positional::Positional, recipe::Recipe, recipe_context::RecipeContext,
|
||||||
recipe_resolver::RecipeResolver, runtime_error::RuntimeError, search::Search,
|
recipe_resolver::RecipeResolver, runtime_error::RuntimeError, search::Search,
|
||||||
search_config::SearchConfig, search_error::SearchError, shebang::Shebang,
|
search_config::SearchConfig, search_error::SearchError, set::Set, setting::Setting,
|
||||||
show_whitespace::ShowWhitespace, state::State, string_literal::StringLiteral,
|
settings::Settings, shebang::Shebang, show_whitespace::ShowWhitespace, state::State,
|
||||||
subcommand::Subcommand, table::Table, token::Token, token_kind::TokenKind, use_color::UseColor,
|
string_literal::StringLiteral, subcommand::Subcommand, table::Table, token::Token,
|
||||||
variables::Variables, verbosity::Verbosity, warning::Warning,
|
token_kind::TokenKind, use_color::UseColor, variables::Variables, verbosity::Verbosity,
|
||||||
|
warning::Warning,
|
||||||
};
|
};
|
||||||
|
|
||||||
// type aliases
|
// type aliases
|
||||||
|
@ -109,6 +109,15 @@ impl Display for CompilationError<'_> {
|
|||||||
self.line.ordinal()
|
self.line.ordinal()
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
DuplicateSet { setting, first } => {
|
||||||
|
writeln!(
|
||||||
|
f,
|
||||||
|
"Setting `{}` first set on line {} is redefined on line {}",
|
||||||
|
setting,
|
||||||
|
first.ordinal(),
|
||||||
|
self.line.ordinal(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
DependencyHasParameters { recipe, dependency } => {
|
DependencyHasParameters { recipe, dependency } => {
|
||||||
writeln!(
|
writeln!(
|
||||||
f,
|
f,
|
||||||
@ -184,6 +193,9 @@ impl Display for CompilationError<'_> {
|
|||||||
UnknownFunction { function } => {
|
UnknownFunction { function } => {
|
||||||
writeln!(f, "Call to unknown function `{}`", function)?;
|
writeln!(f, "Call to unknown function `{}`", function)?;
|
||||||
}
|
}
|
||||||
|
UnknownSetting { setting } => {
|
||||||
|
writeln!(f, "Unknown setting `{}`", setting)?;
|
||||||
|
}
|
||||||
UnknownStartOfToken => {
|
UnknownStartOfToken => {
|
||||||
writeln!(f, "Unknown start of token:")?;
|
writeln!(f, "Unknown start of token:")?;
|
||||||
}
|
}
|
||||||
|
@ -37,6 +37,10 @@ pub(crate) enum CompilationErrorKind<'a> {
|
|||||||
DuplicateVariable {
|
DuplicateVariable {
|
||||||
variable: &'a str,
|
variable: &'a str,
|
||||||
},
|
},
|
||||||
|
DuplicateSet {
|
||||||
|
setting: &'a str,
|
||||||
|
first: usize,
|
||||||
|
},
|
||||||
ExtraLeadingWhitespace,
|
ExtraLeadingWhitespace,
|
||||||
FunctionArgumentCountMismatch {
|
FunctionArgumentCountMismatch {
|
||||||
function: &'a str,
|
function: &'a str,
|
||||||
@ -84,6 +88,9 @@ pub(crate) enum CompilationErrorKind<'a> {
|
|||||||
function: &'a str,
|
function: &'a str,
|
||||||
},
|
},
|
||||||
UnknownStartOfToken,
|
UnknownStartOfToken,
|
||||||
|
UnknownSetting {
|
||||||
|
setting: &'a str,
|
||||||
|
},
|
||||||
UnpairedCarriageReturn,
|
UnpairedCarriageReturn,
|
||||||
UnterminatedInterpolation,
|
UnterminatedInterpolation,
|
||||||
UnterminatedString,
|
UnterminatedString,
|
||||||
|
@ -6,4 +6,5 @@ pub(crate) enum Item<'src> {
|
|||||||
Alias(Alias<'src>),
|
Alias(Alias<'src>),
|
||||||
Assignment(Assignment<'src>),
|
Assignment(Assignment<'src>),
|
||||||
Recipe(Recipe<'src>),
|
Recipe(Recipe<'src>),
|
||||||
|
Set(Set<'src>),
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,15 @@
|
|||||||
use crate::common::*;
|
use crate::common::*;
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
pub(crate) struct Justfile<'a> {
|
pub(crate) struct Justfile<'src> {
|
||||||
pub(crate) recipes: Table<'a, Recipe<'a>>,
|
pub(crate) recipes: Table<'src, Recipe<'src>>,
|
||||||
pub(crate) assignments: Table<'a, Assignment<'a>>,
|
pub(crate) assignments: Table<'src, Assignment<'src>>,
|
||||||
pub(crate) aliases: Table<'a, Alias<'a>>,
|
pub(crate) aliases: Table<'src, Alias<'src>>,
|
||||||
pub(crate) warnings: Vec<Warning<'a>>,
|
pub(crate) settings: Settings<'src>,
|
||||||
|
pub(crate) warnings: Vec<Warning<'src>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Justfile<'a> {
|
impl<'src> Justfile<'src> {
|
||||||
pub(crate) fn first(&self) -> Option<&Recipe> {
|
pub(crate) fn first(&self) -> Option<&Recipe> {
|
||||||
let mut first: Option<&Recipe> = None;
|
let mut first: Option<&Recipe> = None;
|
||||||
for recipe in self.recipes.values() {
|
for recipe in self.recipes.values() {
|
||||||
@ -27,7 +28,7 @@ impl<'a> Justfile<'a> {
|
|||||||
self.recipes.len()
|
self.recipes.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn suggest(&self, name: &str) -> Option<&'a str> {
|
pub(crate) fn suggest(&self, name: &str) -> Option<&'src str> {
|
||||||
let mut suggestions = self
|
let mut suggestions = self
|
||||||
.recipes
|
.recipes
|
||||||
.keys()
|
.keys()
|
||||||
@ -43,12 +44,12 @@ impl<'a> Justfile<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn run(
|
pub(crate) fn run(
|
||||||
&'a self,
|
&'src self,
|
||||||
config: &'a Config,
|
config: &'src Config,
|
||||||
working_directory: &'a Path,
|
working_directory: &'src Path,
|
||||||
overrides: &'a BTreeMap<String, String>,
|
overrides: &'src BTreeMap<String, String>,
|
||||||
arguments: &'a Vec<String>,
|
arguments: &'src Vec<String>,
|
||||||
) -> RunResult<'a, ()> {
|
) -> RunResult<'src, ()> {
|
||||||
let argvec: Vec<&str> = if !arguments.is_empty() {
|
let argvec: Vec<&str> = if !arguments.is_empty() {
|
||||||
arguments.iter().map(|argument| argument.as_str()).collect()
|
arguments.iter().map(|argument| argument.as_str()).collect()
|
||||||
} else if let Some(recipe) = self.first() {
|
} else if let Some(recipe) = self.first() {
|
||||||
@ -86,6 +87,7 @@ impl<'a> Justfile<'a> {
|
|||||||
&dotenv,
|
&dotenv,
|
||||||
&self.assignments,
|
&self.assignments,
|
||||||
overrides,
|
overrides,
|
||||||
|
&self.settings,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if let Subcommand::Evaluate { .. } = config.subcommand {
|
if let Subcommand::Evaluate { .. } = config.subcommand {
|
||||||
@ -142,6 +144,7 @@ impl<'a> Justfile<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let context = RecipeContext {
|
let context = RecipeContext {
|
||||||
|
settings: &self.settings,
|
||||||
config,
|
config,
|
||||||
scope,
|
scope,
|
||||||
working_directory,
|
working_directory,
|
||||||
@ -159,7 +162,7 @@ impl<'a> Justfile<'a> {
|
|||||||
self.aliases.get(name)
|
self.aliases.get(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_recipe(&self, name: &str) -> Option<&Recipe<'a>> {
|
pub(crate) fn get_recipe(&self, name: &str) -> Option<&Recipe<'src>> {
|
||||||
if let Some(recipe) = self.recipes.get(name) {
|
if let Some(recipe) = self.recipes.get(name) {
|
||||||
Some(recipe)
|
Some(recipe)
|
||||||
} else if let Some(alias) = self.aliases.get(name) {
|
} else if let Some(alias) = self.aliases.get(name) {
|
||||||
@ -171,11 +174,11 @@ impl<'a> Justfile<'a> {
|
|||||||
|
|
||||||
fn run_recipe<'b>(
|
fn run_recipe<'b>(
|
||||||
&self,
|
&self,
|
||||||
context: &'b RecipeContext<'a>,
|
context: &'b RecipeContext<'src>,
|
||||||
recipe: &Recipe<'a>,
|
recipe: &Recipe<'src>,
|
||||||
arguments: &[&'a str],
|
arguments: &[&'src str],
|
||||||
dotenv: &BTreeMap<String, String>,
|
dotenv: &BTreeMap<String, String>,
|
||||||
ran: &mut BTreeSet<&'a str>,
|
ran: &mut BTreeSet<&'src str>,
|
||||||
overrides: &BTreeMap<String, String>,
|
overrides: &BTreeMap<String, String>,
|
||||||
) -> RunResult<()> {
|
) -> RunResult<()> {
|
||||||
for dependency_name in &recipe.dependencies {
|
for dependency_name in &recipe.dependencies {
|
||||||
@ -190,7 +193,7 @@ impl<'a> Justfile<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Display for Justfile<'a> {
|
impl<'src> Display for Justfile<'src> {
|
||||||
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
|
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
|
||||||
let mut items = self.recipes.len() + self.assignments.len() + self.aliases.len();
|
let mut items = self.recipes.len() + self.assignments.len() + self.aliases.len();
|
||||||
for (name, assignment) in &self.assignments {
|
for (name, assignment) in &self.assignments {
|
||||||
|
@ -1,2 +1,5 @@
|
|||||||
pub(crate) const ALIAS: &str = "alias";
|
pub(crate) const ALIAS: &str = "alias";
|
||||||
pub(crate) const EXPORT: &str = "export";
|
pub(crate) const EXPORT: &str = "export";
|
||||||
|
pub(crate) const SET: &str = "set";
|
||||||
|
|
||||||
|
pub(crate) const SHELL: &str = "shell";
|
||||||
|
10
src/lexer.rs
10
src/lexer.rs
@ -387,6 +387,8 @@ impl<'a> Lexer<'a> {
|
|||||||
fn lex_normal(&mut self, start: char) -> CompilationResult<'a, ()> {
|
fn lex_normal(&mut self, start: char) -> CompilationResult<'a, ()> {
|
||||||
match start {
|
match start {
|
||||||
'@' => self.lex_single(At),
|
'@' => self.lex_single(At),
|
||||||
|
'[' => self.lex_single(BracketL),
|
||||||
|
']' => self.lex_single(BracketR),
|
||||||
'=' => self.lex_single(Equals),
|
'=' => self.lex_single(Equals),
|
||||||
',' => self.lex_single(Comma),
|
',' => self.lex_single(Comma),
|
||||||
':' => self.lex_colon(),
|
':' => self.lex_colon(),
|
||||||
@ -759,6 +761,8 @@ mod tests {
|
|||||||
match kind {
|
match kind {
|
||||||
// Fixed lexemes
|
// Fixed lexemes
|
||||||
At => "@",
|
At => "@",
|
||||||
|
BracketL => "[",
|
||||||
|
BracketR => "]",
|
||||||
Colon => ":",
|
Colon => ":",
|
||||||
ColonEquals => ":=",
|
ColonEquals => ":=",
|
||||||
Comma => ",",
|
Comma => ",",
|
||||||
@ -1604,6 +1608,12 @@ mod tests {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test! {
|
||||||
|
name: brackets,
|
||||||
|
text: "][",
|
||||||
|
tokens: (BracketR, BracketL),
|
||||||
|
}
|
||||||
|
|
||||||
error! {
|
error! {
|
||||||
name: tokenize_space_then_tab,
|
name: tokenize_space_then_tab,
|
||||||
input: "a:
|
input: "a:
|
||||||
|
@ -72,6 +72,9 @@ mod runtime_error;
|
|||||||
mod search;
|
mod search;
|
||||||
mod search_config;
|
mod search_config;
|
||||||
mod search_error;
|
mod search_error;
|
||||||
|
mod set;
|
||||||
|
mod setting;
|
||||||
|
mod settings;
|
||||||
mod shebang;
|
mod shebang;
|
||||||
mod show_whitespace;
|
mod show_whitespace;
|
||||||
mod state;
|
mod state;
|
||||||
|
21
src/node.rs
21
src/node.rs
@ -21,6 +21,7 @@ impl<'src> Node<'src> for Item<'src> {
|
|||||||
Item::Alias(alias) => alias.tree(),
|
Item::Alias(alias) => alias.tree(),
|
||||||
Item::Assignment(assignment) => assignment.tree(),
|
Item::Assignment(assignment) => assignment.tree(),
|
||||||
Item::Recipe(recipe) => recipe.tree(),
|
Item::Recipe(recipe) => recipe.tree(),
|
||||||
|
Item::Set(set) => set.tree(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -141,6 +142,26 @@ impl<'src> Node<'src> for Fragment<'src> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'src> Node<'src> for Set<'src> {
|
||||||
|
fn tree(&self) -> Tree<'src> {
|
||||||
|
let mut set = Tree::atom(keyword::SET);
|
||||||
|
|
||||||
|
set.push_mut(self.name.lexeme());
|
||||||
|
|
||||||
|
use Setting::*;
|
||||||
|
match &self.value {
|
||||||
|
Shell(setting::Shell { command, arguments }) => {
|
||||||
|
set.push_mut(Tree::string(&command.cooked));
|
||||||
|
for argument in arguments {
|
||||||
|
set.push_mut(Tree::string(&argument.cooked));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'src> Node<'src> for Warning<'src> {
|
impl<'src> Node<'src> for Warning<'src> {
|
||||||
fn tree(&self) -> Tree<'src> {
|
fn tree(&self) -> Tree<'src> {
|
||||||
match self {
|
match self {
|
||||||
|
189
src/parser.rs
189
src/parser.rs
@ -135,6 +135,17 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return an error if the next token is not one of kinds `kinds`.
|
||||||
|
fn expect_any(&mut self, expected: &[TokenKind]) -> CompilationResult<'src, Token<'src>> {
|
||||||
|
for expected in expected.iter().cloned() {
|
||||||
|
if let Some(token) = self.accept(expected)? {
|
||||||
|
return Ok(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(self.unexpected_token(expected)?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Return an unexpected token error if the next token is not an EOL
|
/// Return an unexpected token error if the next token is not an EOL
|
||||||
fn expect_eol(&mut self) -> CompilationResult<'src, ()> {
|
fn expect_eol(&mut self) -> CompilationResult<'src, ()> {
|
||||||
self.accept(Comment)?;
|
self.accept(Comment)?;
|
||||||
@ -269,6 +280,13 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
|
|||||||
items.push(Item::Recipe(self.parse_recipe(doc, false)?));
|
items.push(Item::Recipe(self.parse_recipe(doc, false)?));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
keyword::SET => {
|
||||||
|
if self.next_are(&[Identifier, Identifier, ColonEquals]) {
|
||||||
|
items.push(Item::Set(self.parse_set()?));
|
||||||
|
} else {
|
||||||
|
items.push(Item::Recipe(self.parse_recipe(doc, false)?));
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if self.next_are(&[Identifier, Equals]) {
|
if self.next_are(&[Identifier, Equals]) {
|
||||||
warnings.push(Warning::DeprecatedEquals {
|
warnings.push(Warning::DeprecatedEquals {
|
||||||
@ -380,7 +398,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
|
|||||||
|
|
||||||
/// Parse a string literal, e.g. `"FOO"`
|
/// Parse a string literal, e.g. `"FOO"`
|
||||||
fn parse_string_literal(&mut self) -> CompilationResult<'src, StringLiteral<'src>> {
|
fn parse_string_literal(&mut self) -> CompilationResult<'src, StringLiteral<'src>> {
|
||||||
let token = self.presume_any(&[StringRaw, StringCooked])?;
|
let token = self.expect_any(&[StringRaw, StringCooked])?;
|
||||||
|
|
||||||
let raw = &token.lexeme()[1..token.lexeme().len() - 1];
|
let raw = &token.lexeme()[1..token.lexeme().len() - 1];
|
||||||
|
|
||||||
@ -580,12 +598,56 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
|
|||||||
|
|
||||||
Ok(lines)
|
Ok(lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a setting
|
||||||
|
fn parse_set(&mut self) -> CompilationResult<'src, Set<'src>> {
|
||||||
|
self.presume_name(keyword::SET)?;
|
||||||
|
let name = Name::from_identifier(self.presume(Identifier)?);
|
||||||
|
self.presume(ColonEquals)?;
|
||||||
|
match name.lexeme() {
|
||||||
|
keyword::SHELL => {
|
||||||
|
self.expect(BracketL)?;
|
||||||
|
|
||||||
|
let command = self.parse_string_literal()?;
|
||||||
|
|
||||||
|
let mut arguments = Vec::new();
|
||||||
|
|
||||||
|
let mut comma = false;
|
||||||
|
|
||||||
|
if self.accepted(Comma)? {
|
||||||
|
comma = true;
|
||||||
|
while !self.next_is(BracketR) {
|
||||||
|
arguments.push(self.parse_string_literal().expected(&[BracketR])?);
|
||||||
|
|
||||||
|
if !self.accepted(Comma)? {
|
||||||
|
comma = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
comma = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self
|
||||||
|
.expect(BracketR)
|
||||||
|
.expected(if comma { &[] } else { &[Comma] })?;
|
||||||
|
|
||||||
|
Ok(Set {
|
||||||
|
value: Setting::Shell(setting::Shell { command, arguments }),
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Err(name.error(CompilationErrorKind::UnknownSetting {
|
||||||
|
setting: name.lexeme(),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
use testing::unindent;
|
use testing::unindent;
|
||||||
use CompilationErrorKind::*;
|
use CompilationErrorKind::*;
|
||||||
|
|
||||||
@ -1368,6 +1430,42 @@ mod tests {
|
|||||||
tree: (justfile (recipe a (body ("foo"))) (recipe b)),
|
tree: (justfile (recipe a (body ("foo"))) (recipe b)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test! {
|
||||||
|
name: set_shell_no_arguments,
|
||||||
|
text: "set shell := ['tclsh']",
|
||||||
|
tree: (justfile (set shell "tclsh")),
|
||||||
|
}
|
||||||
|
|
||||||
|
test! {
|
||||||
|
name: set_shell_no_arguments_cooked,
|
||||||
|
text: "set shell := [\"tclsh\"]",
|
||||||
|
tree: (justfile (set shell "tclsh")),
|
||||||
|
}
|
||||||
|
|
||||||
|
test! {
|
||||||
|
name: set_shell_no_arguments_trailing_comma,
|
||||||
|
text: "set shell := ['tclsh',]",
|
||||||
|
tree: (justfile (set shell "tclsh")),
|
||||||
|
}
|
||||||
|
|
||||||
|
test! {
|
||||||
|
name: set_shell_with_one_argument,
|
||||||
|
text: "set shell := ['bash', '-cu']",
|
||||||
|
tree: (justfile (set shell "bash" "-cu")),
|
||||||
|
}
|
||||||
|
|
||||||
|
test! {
|
||||||
|
name: set_shell_with_one_argument_trailing_comma,
|
||||||
|
text: "set shell := ['bash', '-cu',]",
|
||||||
|
tree: (justfile (set shell "bash" "-cu")),
|
||||||
|
}
|
||||||
|
|
||||||
|
test! {
|
||||||
|
name: set_shell_with_two_arguments,
|
||||||
|
text: "set shell := ['bash', '-cu', '-l']",
|
||||||
|
tree: (justfile (set shell "bash" "-cu" "-l")),
|
||||||
|
}
|
||||||
|
|
||||||
error! {
|
error! {
|
||||||
name: alias_syntax_multiple_rhs,
|
name: alias_syntax_multiple_rhs,
|
||||||
input: "alias foo = bar baz",
|
input: "alias foo = bar baz",
|
||||||
@ -1529,4 +1627,93 @@ mod tests {
|
|||||||
width: 1,
|
width: 1,
|
||||||
kind: ParameterFollowsVariadicParameter{parameter: "e"},
|
kind: ParameterFollowsVariadicParameter{parameter: "e"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
error! {
|
||||||
|
name: set_shell_empty,
|
||||||
|
input: "set shell := []",
|
||||||
|
offset: 14,
|
||||||
|
line: 0,
|
||||||
|
column: 14,
|
||||||
|
width: 1,
|
||||||
|
kind: UnexpectedToken {
|
||||||
|
expected: vec![StringCooked, StringRaw],
|
||||||
|
found: BracketR,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
error! {
|
||||||
|
name: set_shell_non_literal_first,
|
||||||
|
input: "set shell := ['bar' + 'baz']",
|
||||||
|
offset: 20,
|
||||||
|
line: 0,
|
||||||
|
column: 20,
|
||||||
|
width: 1,
|
||||||
|
kind: UnexpectedToken {
|
||||||
|
expected: vec![BracketR, Comma],
|
||||||
|
found: Plus,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
error! {
|
||||||
|
name: set_shell_non_literal_second,
|
||||||
|
input: "set shell := ['biz', 'bar' + 'baz']",
|
||||||
|
offset: 27,
|
||||||
|
line: 0,
|
||||||
|
column: 27,
|
||||||
|
width: 1,
|
||||||
|
kind: UnexpectedToken {
|
||||||
|
expected: vec![BracketR, Comma],
|
||||||
|
found: Plus,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
error! {
|
||||||
|
name: set_shell_bad_comma,
|
||||||
|
input: "set shell := ['bash',",
|
||||||
|
offset: 21,
|
||||||
|
line: 0,
|
||||||
|
column: 21,
|
||||||
|
width: 0,
|
||||||
|
kind: UnexpectedToken {
|
||||||
|
expected: vec![BracketR, StringCooked, StringRaw],
|
||||||
|
found: Eof,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
error! {
|
||||||
|
name: set_shell_bad,
|
||||||
|
input: "set shell := ['bash'",
|
||||||
|
offset: 20,
|
||||||
|
line: 0,
|
||||||
|
column: 20,
|
||||||
|
width: 0,
|
||||||
|
kind: UnexpectedToken {
|
||||||
|
expected: vec![BracketR, Comma],
|
||||||
|
found: Eof,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
error! {
|
||||||
|
name: set_unknown,
|
||||||
|
input: "set shall := []",
|
||||||
|
offset: 4,
|
||||||
|
line: 0,
|
||||||
|
column: 4,
|
||||||
|
width: 5,
|
||||||
|
kind: UnknownSetting {
|
||||||
|
setting: "shall",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
error! {
|
||||||
|
name: set_shell_non_string,
|
||||||
|
input: "set shall := []",
|
||||||
|
offset: 4,
|
||||||
|
line: 0,
|
||||||
|
column: 4,
|
||||||
|
width: 5,
|
||||||
|
kind: UnknownSetting {
|
||||||
|
setting: "shall",
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
use crate::common::*;
|
use crate::common::*;
|
||||||
|
|
||||||
use std::process::{Command, ExitStatus, Stdio};
|
use std::process::{ExitStatus, Stdio};
|
||||||
|
|
||||||
/// Return a `RuntimeError::Signal` if the process was terminated by a signal,
|
/// Return a `RuntimeError::Signal` if the process was terminated by a signal,
|
||||||
/// otherwise return an `RuntimeError::UnknownFailure`
|
/// otherwise return an `RuntimeError::UnknownFailure`
|
||||||
@ -90,6 +90,7 @@ impl<'a> Recipe<'a> {
|
|||||||
evaluated: empty(),
|
evaluated: empty(),
|
||||||
working_directory: context.working_directory,
|
working_directory: context.working_directory,
|
||||||
scope: &context.scope,
|
scope: &context.scope,
|
||||||
|
settings: &context.settings,
|
||||||
overrides,
|
overrides,
|
||||||
config,
|
config,
|
||||||
dotenv,
|
dotenv,
|
||||||
@ -274,11 +275,11 @@ impl<'a> Recipe<'a> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut cmd = Command::new(&config.shell);
|
let mut cmd = context.settings.shell_command(&config.shell);
|
||||||
|
|
||||||
cmd.current_dir(context.working_directory);
|
cmd.current_dir(context.working_directory);
|
||||||
|
|
||||||
cmd.arg("-cu").arg(command);
|
cmd.arg(command);
|
||||||
|
|
||||||
if config.quiet {
|
if config.quiet {
|
||||||
cmd.stderr(Stdio::null());
|
cmd.stderr(Stdio::null());
|
||||||
|
@ -4,4 +4,5 @@ pub(crate) struct RecipeContext<'a> {
|
|||||||
pub(crate) config: &'a Config,
|
pub(crate) config: &'a Config,
|
||||||
pub(crate) scope: BTreeMap<&'a str, (bool, String)>,
|
pub(crate) scope: BTreeMap<&'a str, (bool, String)>,
|
||||||
pub(crate) working_directory: &'a Path,
|
pub(crate) working_directory: &'a Path,
|
||||||
|
pub(crate) settings: &'a Settings<'a>,
|
||||||
}
|
}
|
||||||
|
13
src/set.rs
Normal file
13
src/set.rs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
use crate::common::*;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct Set<'src> {
|
||||||
|
pub(crate) name: Name<'src>,
|
||||||
|
pub(crate) value: Setting<'src>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'src> Keyed<'src> for Set<'src> {
|
||||||
|
fn key(&self) -> &'src str {
|
||||||
|
self.name.lexeme()
|
||||||
|
}
|
||||||
|
}
|
12
src/setting.rs
Normal file
12
src/setting.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
use crate::common::*;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) enum Setting<'src> {
|
||||||
|
Shell(Shell<'src>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub(crate) struct Shell<'src> {
|
||||||
|
pub(crate) command: StringLiteral<'src>,
|
||||||
|
pub(crate) arguments: Vec<StringLiteral<'src>>,
|
||||||
|
}
|
30
src/settings.rs
Normal file
30
src/settings.rs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
use crate::common::*;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub(crate) struct Settings<'src> {
|
||||||
|
pub(crate) shell: Option<setting::Shell<'src>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'src> Settings<'src> {
|
||||||
|
pub(crate) fn new() -> Settings<'src> {
|
||||||
|
Settings { shell: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn shell_command(&self, default_shell: &str) -> Command {
|
||||||
|
if let Some(shell) = &self.shell {
|
||||||
|
let mut cmd = Command::new(shell.command.cooked.as_ref());
|
||||||
|
|
||||||
|
for argument in &shell.arguments {
|
||||||
|
cmd.arg(argument.cooked.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd
|
||||||
|
} else {
|
||||||
|
let mut cmd = Command::new(default_shell);
|
||||||
|
|
||||||
|
cmd.arg("-cu");
|
||||||
|
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -4,6 +4,8 @@ use crate::common::*;
|
|||||||
pub(crate) enum TokenKind {
|
pub(crate) enum TokenKind {
|
||||||
At,
|
At,
|
||||||
Backtick,
|
Backtick,
|
||||||
|
BracketL,
|
||||||
|
BracketR,
|
||||||
Colon,
|
Colon,
|
||||||
ColonEquals,
|
ColonEquals,
|
||||||
Comma,
|
Comma,
|
||||||
@ -34,6 +36,8 @@ impl Display for TokenKind {
|
|||||||
match *self {
|
match *self {
|
||||||
At => "'@'",
|
At => "'@'",
|
||||||
Backtick => "backtick",
|
Backtick => "backtick",
|
||||||
|
BracketL => "'['",
|
||||||
|
BracketR => "']'",
|
||||||
Colon => "':'",
|
Colon => "':'",
|
||||||
ColonEquals => "':='",
|
ColonEquals => "':='",
|
||||||
Comma => "','",
|
Comma => "','",
|
||||||
|
@ -350,6 +350,22 @@ _y:
|
|||||||
stdout: "a b c d\n",
|
stdout: "a b c d\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test! {
|
||||||
|
name: set_shell,
|
||||||
|
justfile: "
|
||||||
|
set shell := ['echo', '-n']
|
||||||
|
|
||||||
|
x := `bar`
|
||||||
|
|
||||||
|
foo:
|
||||||
|
echo {{x}}
|
||||||
|
echo foo
|
||||||
|
",
|
||||||
|
args: (),
|
||||||
|
stdout: "echo barecho foo",
|
||||||
|
stderr: "echo bar\necho foo\n",
|
||||||
|
}
|
||||||
|
|
||||||
test! {
|
test! {
|
||||||
name: select,
|
name: select,
|
||||||
justfile: "b:
|
justfile: "b:
|
||||||
|
@ -14,9 +14,9 @@ recipe default=`DEFAULT`:
|
|||||||
";
|
";
|
||||||
|
|
||||||
/// Test that --shell correctly sets the shell
|
/// Test that --shell correctly sets the shell
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shell() {
|
#[cfg_attr(windows, ignore)]
|
||||||
|
fn flag() {
|
||||||
let tmp = tmptree! {
|
let tmp = tmptree! {
|
||||||
justfile: JUSTFILE,
|
justfile: JUSTFILE,
|
||||||
shell: "#!/usr/bin/env bash\necho \"$@\"",
|
shell: "#!/usr/bin/env bash\necho \"$@\"",
|
||||||
@ -24,8 +24,11 @@ fn shell() {
|
|||||||
|
|
||||||
let shell = tmp.path().join("shell");
|
let shell = tmp.path().join("shell");
|
||||||
|
|
||||||
let permissions = std::os::unix::fs::PermissionsExt::from_mode(0o700);
|
#[cfg(not(windows))]
|
||||||
std::fs::set_permissions(&shell, permissions).unwrap();
|
{
|
||||||
|
let permissions = std::os::unix::fs::PermissionsExt::from_mode(0o700);
|
||||||
|
std::fs::set_permissions(&shell, permissions).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
let output = Command::new(executable_path("just"))
|
let output = Command::new(executable_path("just"))
|
||||||
.current_dir(tmp.path())
|
.current_dir(tmp.path())
|
||||||
@ -35,6 +38,63 @@ fn shell() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let stdout = "-cu -cu EXPRESSION\n-cu -cu DEFAULT\n-cu RECIPE\n";
|
let stdout = "-cu -cu EXPRESSION\n-cu -cu DEFAULT\n-cu RECIPE\n";
|
||||||
|
assert_stdout(&output, stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const JUSTFILE_CMD: &str = r#"
|
||||||
|
|
||||||
|
set shell := ["cmd.exe", "/C"]
|
||||||
|
|
||||||
|
x := `Echo`
|
||||||
|
|
||||||
|
recipe:
|
||||||
|
REM foo
|
||||||
|
Echo "{{x}}"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Test that we can use `set shell` to use cmd.exe on windows
|
||||||
|
#[test]
|
||||||
|
#[cfg_attr(unix, ignore)]
|
||||||
|
fn cmd() {
|
||||||
|
let tmp = tmptree! {
|
||||||
|
justfile: JUSTFILE_CMD,
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = Command::new(executable_path("just"))
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let stdout = "\\\"ECHO is on.\\\"\r\n";
|
||||||
|
|
||||||
|
assert_stdout(&output, stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const JUSTFILE_POWERSHELL: &str = r#"
|
||||||
|
|
||||||
|
set shell := ["powershell.exe", "-c"]
|
||||||
|
|
||||||
|
x := `Write-Host "Hello, world!"`
|
||||||
|
|
||||||
|
recipe:
|
||||||
|
For ($i=0; $i -le 10; $i++) { Write-Host $i }
|
||||||
|
Write-Host "{{x}}"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Test that we can use `set shell` to use cmd.exe on windows
|
||||||
|
#[test]
|
||||||
|
#[cfg_attr(unix, ignore)]
|
||||||
|
fn powershell() {
|
||||||
|
let tmp = tmptree! {
|
||||||
|
justfile: JUSTFILE_POWERSHELL,
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = Command::new(executable_path("just"))
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let stdout = "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\nHello, world!\n";
|
||||||
|
|
||||||
assert_stdout(&output, stdout);
|
assert_stdout(&output, stdout);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user