use std::{collections::HashMap, fmt, string::ToString};
use nu_protocol::{
Span,
ast::{Block, Call, Expr, Expression, ExternalArgument},
};
use crate::{
LintLevel,
ast::{self, call::CallExt, expression::ExpressionExt},
context::LintContext,
effect::{
builtin::{BuiltinEffect, has_builtin_side_effect},
external::{ExternEffect, has_external_side_effect},
},
rule::{DetectFix, Rule},
violation::Detection,
};
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
enum IoType {
FileSystem,
Network,
PrintStdout,
}
impl fmt::Display for IoType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::FileSystem => write!(f, "file I/O"),
Self::Network => write!(f, "network I/O"),
Self::PrintStdout => write!(f, "print to stdout"),
}
}
}
fn classify_builtin_io(
call: &Call,
context: &LintContext,
io_spans: &mut HashMap<IoType, Vec<Span>>,
) {
let command_name = call.get_call_name(context);
let category = context
.working_set
.get_decl(call.decl_id)
.signature()
.category;
if has_builtin_side_effect(&command_name, BuiltinEffect::PrintToStdout, context, call) {
io_spans
.entry(IoType::PrintStdout)
.or_default()
.push(call.head);
}
match category {
nu_protocol::Category::FileSystem => {
io_spans
.entry(IoType::FileSystem)
.or_default()
.push(call.head);
}
nu_protocol::Category::Network => {
io_spans.entry(IoType::Network).or_default().push(call.head);
}
_ => {}
}
}
fn matches_external_io_type(
io_type: IoType,
command_name: &str,
args: &[ExternalArgument],
context: &LintContext,
) -> bool {
match io_type {
IoType::Network => has_external_side_effect(
command_name,
ExternEffect::ModifiesNetworkState,
context,
args,
),
IoType::FileSystem => has_external_side_effect(
command_name,
ExternEffect::ModifiesFileSystem,
context,
args,
),
IoType::PrintStdout => {
!has_external_side_effect(command_name, ExternEffect::NoDataInStdout, context, args)
}
}
}
fn classify_external_io(
command_name: &str,
args: &[ExternalArgument],
head_span: Span,
context: &LintContext,
io_spans: &mut HashMap<IoType, Vec<Span>>,
) {
for io_type in [IoType::Network, IoType::FileSystem] {
if matches_external_io_type(io_type, command_name, args, context) {
log::trace!(
"External command '{command_name}' matches IoType::{io_type:?}, adding span \
{head_span:?}"
);
io_spans.entry(io_type).or_default().push(head_span);
}
}
}
fn collect_io_types_from_expression(
expr: &Expression,
context: &LintContext,
io_spans: &mut HashMap<IoType, Vec<Span>>,
) {
match &expr.expr {
Expr::Call(call) => {
classify_builtin_io(call, context, io_spans);
for arg_expr in call.all_arg_expressions() {
if let Some(block_id) = arg_expr.extract_block_id() {
let block = context.working_set.get_block(block_id);
let nested_io_spans = collect_io_types_from_block(block, context);
for (io_type, spans) in nested_io_spans {
io_spans.entry(io_type).or_default().extend(spans);
}
} else {
collect_io_types_from_expression(arg_expr, context, io_spans);
}
}
}
Expr::ExternalCall(head, args) => {
let command_name = context.expr_text(head);
classify_external_io(command_name, args, head.span, context, io_spans);
}
_ => {}
}
}
fn collect_io_types_from_block(block: &Block, context: &LintContext) -> HashMap<IoType, Vec<Span>> {
let mut io_spans = HashMap::new();
for pipeline in &block.pipelines {
for element in &pipeline.elements {
collect_io_types_from_expression(&element.expr, context, &mut io_spans);
}
}
io_spans
}
fn analyze_top_level_script(context: &LintContext) -> Option<Detection> {
let io_spans = collect_io_types_from_block(context.ast, context);
if io_spans.len() < 2 {
return None;
}
let io_type_names: Vec<String> = io_spans.keys().map(ToString::to_string).collect();
let message = format!(
"Script mixes different I/O types: {}",
io_type_names.join(", ")
);
let script_span = context.ast.span.unwrap_or(Span::unknown());
let mut detection = Detection::from_global_span(message, script_span)
.with_primary_label("script with mixed I/O");
for (io_type, spans) in &io_spans {
for span in spans {
detection = detection.with_extra_label(io_type.to_string(), *span);
}
}
Some(detection)
}
fn analyze_function_body(
def: &ast::declaration::CustomCommandDef,
context: &LintContext,
) -> Option<Detection> {
let block = context.working_set.get_block(def.body);
let io_spans = collect_io_types_from_block(block, context);
if io_spans.len() < 2 {
return None;
}
let io_type_names: Vec<String> = io_spans.keys().map(ToString::to_string).collect();
let message = format!(
"Function `{}` mixes different I/O types: {}",
def.name,
io_type_names.join(", ")
);
let mut detection = Detection::from_file_span(message, def.declaration_span(context))
.with_primary_label("function with mixed I/O");
for (io_type, spans) in &io_spans {
for span in spans {
detection = detection.with_extra_label(io_type.to_string(), *span);
}
}
Some(detection)
}
struct SeparateLocalRemoteIo;
impl DetectFix for SeparateLocalRemoteIo {
type FixInput<'a> = ();
fn id(&self) -> &'static str {
"dont_mix_different_effects"
}
fn short_description(&self) -> &'static str {
"Functions should not mix different types of I/O operations or effects."
}
fn level(&self) -> LintLevel {
LintLevel::Hint
}
fn detect<'a>(&self, context: &'a LintContext) -> Vec<(Detection, Self::FixInput<'a>)> {
let mut violations = Vec::new();
let function_definitions = context.custom_commands();
if function_definitions.is_empty()
&& let Some(detection) = analyze_top_level_script(context)
{
violations.push(detection);
}
violations.extend(
function_definitions
.iter()
.filter(|def| !def.is_main())
.filter_map(|def| analyze_function_body(def, context)),
);
Self::no_fix(violations)
}
}
pub static RULE: &dyn Rule = &SeparateLocalRemoteIo;
#[cfg(test)]
mod detect_bad;
#[cfg(test)]
mod ignore_good;