use std::ops::ControlFlow;
use nu_protocol::{
BlockId, ENV_VARIABLE_ID, IN_VARIABLE_ID, NU_VARIABLE_ID, Span, Type, VarId,
ast::{
Argument, Call, Expr, Expression, FindMapResult, FullCellPath, ListItem, Operator,
PathMember, RecordItem, Traverse,
},
engine::Variable,
};
use super::{block::BlockExt, call::CallExt, pipeline::PipelineExt};
use crate::{
context::LintContext,
effect::external::{ExternEffect, has_external_side_effect},
};
pub trait ExpressionExt: Traverse {
fn extract_variable_name(&self, context: &LintContext) -> Option<String>;
fn is_assignment(&self) -> bool;
fn is_empty_list(&self) -> bool;
fn extract_block_id(&self) -> Option<BlockId>;
fn span_text<'a>(&self, context: &'a LintContext) -> &'a str;
fn extract_assigned_variable(&self) -> Option<VarId>;
fn extract_field_access(&self, field_name: &str) -> Option<(VarId, Span)>;
fn contains_variables(&self, context: &LintContext) -> bool;
fn is_external_call_with_variable(&self, var_id: VarId) -> bool;
fn matches_var(&self, var_id: VarId) -> bool;
fn extract_direct_var(&self) -> Option<VarId>;
fn contains_variable(&self, var_id: VarId) -> bool;
fn uses_pipeline_input(&self, context: &LintContext) -> bool;
fn find_pipeline_input(&self, context: &LintContext) -> Option<(VarId, Span)>;
fn find_dollar_in_usage(&self) -> Option<Span>;
fn infer_output_type(&self, context: &LintContext) -> Option<Type>;
fn infer_input_type(&self, in_var: Option<VarId>, context: &LintContext) -> Option<Type>;
fn extract_external_command_name(&self, context: &LintContext) -> Option<String>;
fn extract_int_value(&self, context: &LintContext) -> Option<i64>;
fn unwrap_block_expr<'a>(&'a self, context: &'a LintContext) -> &'a Self;
fn is_counter_increment(&self, counter_name: &str, context: &LintContext) -> bool;
fn traverse_with_parent<'a, F>(
&'a self,
context: &'a LintContext,
parent: Option<&'a Expression>,
callback: &mut F,
) where
F: FnMut(&'a Expression, Option<&'a Expression>) -> ControlFlow<()>;
}
pub const fn is_dollar_in_var(var_id: VarId) -> bool {
use nu_protocol::IN_VARIABLE_ID;
var_id.get() == IN_VARIABLE_ID.get()
}
const fn has_synthetic_declaration_span(var: &Variable) -> bool {
(var.declaration_span.start == 0 && var.declaration_span.end == 0)
|| (var.declaration_span.start == var.declaration_span.end
&& var.declaration_span.start > 0)
}
pub fn is_pipeline_input_var(var_id: VarId, context: &LintContext) -> bool {
use nu_protocol::{ENV_VARIABLE_ID, NU_VARIABLE_ID};
if is_dollar_in_var(var_id) {
return true;
}
if var_id == ENV_VARIABLE_ID || var_id == NU_VARIABLE_ID {
return false;
}
let var = context.working_set.get_variable(var_id);
has_synthetic_declaration_span(var)
}
const fn extract_var_from_full_cell_path(cell_path: &FullCellPath) -> Option<VarId> {
match &cell_path.head.expr {
Expr::Var(var_id) => Some(*var_id),
_ => None,
}
}
const fn builtin_var_name(var_id: VarId) -> Option<&'static str> {
match var_id.get() {
id if id == NU_VARIABLE_ID.get() => Some("nu"),
id if id == IN_VARIABLE_ID.get() => Some("in"),
id if id == ENV_VARIABLE_ID.get() => Some("env"),
_ => None,
}
}
pub fn var_name_from_expr(expr: &Expression, context: &LintContext) -> Option<String> {
let Expr::Var(var_id) = &expr.expr else {
return None;
};
if let Some(name) = builtin_var_name(*var_id) {
return Some(name.to_string());
}
context
.span_text(expr.span)
.strip_prefix('$')
.map(String::from)
}
impl ExpressionExt for Expression {
fn extract_variable_name(&self, context: &LintContext) -> Option<String> {
match &self.expr {
Expr::Var(var_id) | Expr::VarDecl(var_id) => {
let var = context.working_set.get_variable(*var_id);
Some(context.span_text(var.declaration_span).to_string())
}
Expr::FullCellPath(cell_path) => cell_path.head.extract_variable_name(context),
_ => None,
}
}
fn is_assignment(&self) -> bool {
matches!(
&self.expr,
Expr::BinaryOp(_, op, _) if matches!(
op.expr,
Expr::Operator(Operator::Assignment(_))
)
)
}
fn is_empty_list(&self) -> bool {
match &self.expr {
Expr::List(list) => list.is_empty(),
Expr::FullCellPath(cell_path) => cell_path.head.is_empty_list(),
_ => false,
}
}
fn extract_block_id(&self) -> Option<BlockId> {
match &self.expr {
Expr::Block(block_id)
| Expr::Closure(block_id)
| Expr::Subexpression(block_id)
| Expr::RowCondition(block_id) => Some(*block_id),
Expr::FullCellPath(fcp) if fcp.tail.is_empty() => fcp.head.extract_block_id(),
_ => None,
}
}
fn span_text<'a>(&self, context: &'a LintContext) -> &'a str {
context.expr_text(self)
}
fn extract_assigned_variable(&self) -> Option<VarId> {
let Expr::BinaryOp(lhs, _op, _rhs) = &self.expr else {
return None;
};
if !self.is_assignment() {
return None;
}
match &lhs.expr {
Expr::Var(var_id) => Some(*var_id),
Expr::FullCellPath(cell_path) => extract_var_from_full_cell_path(cell_path),
_ => None,
}
}
fn extract_field_access(&self, field_name: &str) -> Option<(VarId, Span)> {
if let Expr::FullCellPath(cell_path) = &self.expr
&& let Some(var_id) = extract_var_from_full_cell_path(cell_path)
&& cell_path.tail.iter().any(|path_member| {
matches!(
path_member,
PathMember::String { val, .. } if val == field_name
)
})
{
Some((var_id, self.span))
} else {
None
}
}
fn contains_variables(&self, context: &LintContext) -> bool {
self.find_map(context.working_set, &|expr| {
if matches!(&expr.expr, Expr::Var(_) | Expr::VarDecl(_)) {
FindMapResult::Found(())
} else {
FindMapResult::Continue
}
})
.is_some()
}
fn is_external_call_with_variable(&self, var_id: VarId) -> bool {
let Expr::ExternalCall(head, _args) = &self.expr else {
return false;
};
head.matches_var(var_id)
}
fn matches_var(&self, var_id: VarId) -> bool {
match &self.expr {
Expr::Var(id) => *id == var_id,
Expr::FullCellPath(cell_path) => {
extract_var_from_full_cell_path(cell_path) == Some(var_id)
}
_ => false,
}
}
fn extract_direct_var(&self) -> Option<VarId> {
match &self.expr {
Expr::Var(var_id) => Some(*var_id),
Expr::FullCellPath(fcp) if fcp.tail.is_empty() => {
if let Expr::Var(var_id) = &fcp.head.expr {
Some(*var_id)
} else {
None
}
}
_ => None,
}
}
fn contains_variable(&self, var_id: VarId) -> bool {
match &self.expr {
Expr::Var(id) => *id == var_id,
Expr::FullCellPath(cell_path) => cell_path.head.contains_variable(var_id),
Expr::BinaryOp(left, _op, right) => {
left.contains_variable(var_id) || right.contains_variable(var_id)
}
Expr::UnaryNot(inner) => inner.contains_variable(var_id),
Expr::Call(call) => call.arguments.iter().any(|arg| match arg {
Argument::Positional(expr)
| Argument::Named((_, _, Some(expr)))
| Argument::Unknown(expr)
| Argument::Spread(expr) => expr.contains_variable(var_id),
Argument::Named(_) => false,
}),
Expr::List(items) => items.iter().any(|item| {
let expr = match item {
ListItem::Item(e) | ListItem::Spread(_, e) => e,
};
expr.contains_variable(var_id)
}),
Expr::Table(table) => {
table
.columns
.iter()
.any(|col| col.contains_variable(var_id))
|| table
.rows
.iter()
.any(|row| row.iter().any(|cell| cell.contains_variable(var_id)))
}
Expr::Record(items) => items.iter().any(|item| match item {
RecordItem::Pair(key, val) => {
key.contains_variable(var_id) || val.contains_variable(var_id)
}
RecordItem::Spread(_, expr) => expr.contains_variable(var_id),
}),
_ => false,
}
}
fn uses_pipeline_input(&self, context: &LintContext) -> bool {
if matches!(&self.expr, Expr::Collect(..)) {
return true;
}
self.find_pipeline_input(context)
.is_some_and(|(var_id, _)| {
let var = context.working_set.get_variable(var_id);
var.const_val.is_none()
})
}
fn find_pipeline_input(&self, context: &LintContext) -> Option<(VarId, Span)> {
use super::block::BlockExt;
match &self.expr {
Expr::Var(var_id) if is_pipeline_input_var(*var_id, context) => {
Some((*var_id, self.span))
}
Expr::FullCellPath(cell_path) => cell_path.head.find_pipeline_input(context),
Expr::Call(call) => call.arguments.iter().find_map(|arg| match arg {
Argument::Positional(e)
| Argument::Unknown(e)
| Argument::Named((_, _, Some(e)))
| Argument::Spread(e) => e.find_pipeline_input(context),
Argument::Named(_) => None,
}),
Expr::BinaryOp(lhs, _, rhs) => lhs
.find_pipeline_input(context)
.or_else(|| rhs.find_pipeline_input(context)),
Expr::UnaryNot(e) | Expr::Collect(_, e) => e.find_pipeline_input(context),
Expr::Subexpression(block_id) | Expr::Block(block_id) => context
.working_set
.get_block(*block_id)
.find_pipeline_input(context),
Expr::StringInterpolation(items) => items
.iter()
.find_map(|item| item.find_pipeline_input(context)),
_ => None,
}
}
fn find_dollar_in_usage(&self) -> Option<Span> {
match &self.expr {
Expr::Var(var_id) if is_dollar_in_var(*var_id) => Some(self.span),
Expr::FullCellPath(cell_path) => cell_path.head.find_dollar_in_usage(),
Expr::Call(call) => call.arguments.iter().find_map(|arg| match arg {
Argument::Positional(e)
| Argument::Unknown(e)
| Argument::Named((_, _, Some(e)))
| Argument::Spread(e) => e.find_dollar_in_usage(),
Argument::Named(_) => None,
}),
Expr::BinaryOp(lhs, _, rhs) => lhs
.find_dollar_in_usage()
.or_else(|| rhs.find_dollar_in_usage()),
Expr::UnaryNot(e) => e.find_dollar_in_usage(),
Expr::Collect(_, _) => Some(self.span),
Expr::StringInterpolation(items) => {
items.iter().find_map(ExpressionExt::find_dollar_in_usage)
}
_ => None,
}
}
fn infer_output_type(&self, context: &LintContext) -> Option<Type> {
let inner_expr = match &self.expr {
Expr::Collect(_, inner) => &inner.expr,
_ => &self.expr,
};
infer_expr_output_type(inner_expr, &self.ty, context)
}
fn infer_input_type(&self, in_var: Option<VarId>, context: &LintContext) -> Option<Type> {
let in_var_id = in_var?;
log::trace!(
"infer_input_type: checking expr='{}', var_id={in_var_id:?}",
self.span_text(context)
);
let result = match &self.expr {
Expr::FullCellPath(cell_path) if matches!(&cell_path.head.expr, Expr::Var(var_id) if *var_id == in_var_id) =>
{
log::trace!(
" -> FullCellPath with matching var, tail_len={}",
cell_path.tail.len()
);
if !cell_path.tail.is_empty()
&& cell_path
.tail
.iter()
.any(|member| matches!(member, PathMember::String { .. }))
{
Some(Type::Record(Box::new([])))
} else if !cell_path.tail.is_empty() {
Some(Type::List(Box::new(Type::Any)))
} else {
None
}
}
Expr::FullCellPath(cell_path)
if matches!(
&cell_path.head.expr,
Expr::Subexpression(_) | Expr::Block(_) | Expr::Closure(_)
) =>
{
log::trace!(" -> FullCellPath wrapping block-like expression");
cell_path.head.infer_input_type(in_var, context)
}
Expr::Call(call) => {
log::trace!(" -> Call expression, checking arguments");
infer_from_call(call, in_var_id, in_var, context)
}
Expr::BinaryOp(left, op_expr, right) => {
log::trace!(" -> BinaryOp, checking if math/comparison with variable");
if matches!(&op_expr.expr, Expr::Operator(op) if matches!(op, Operator::Math(_) | Operator::Comparison(_)))
&& (left.contains_variable(in_var_id) || right.contains_variable(in_var_id))
{
log::trace!(" -> Found math/comparison op with variable, returning Int");
return Some(Type::Int);
}
log::trace!(" -> Recursing into BinaryOp operands");
left.infer_input_type(in_var, context)
.or_else(|| right.infer_input_type(in_var, context))
}
Expr::Collect(_, inner) | Expr::UnaryNot(inner) => {
log::trace!(" -> Collect/UnaryNot, checking inner");
inner.infer_input_type(in_var, context)
}
Expr::Subexpression(block_id) | Expr::Block(block_id) | Expr::Closure(block_id) => {
log::trace!(" -> Subexpression/Block/Closure, block_id={block_id:?}");
let block = context.working_set.get_block(*block_id);
log::trace!(" Block has {} pipelines", block.pipelines.len());
let pipeline_type = block
.pipelines
.iter()
.find_map(|pipeline| pipeline.infer_param_type(in_var_id, context));
if pipeline_type.is_some() {
log::trace!(" Found type from pipeline analysis: {pipeline_type:?}");
return pipeline_type;
}
block
.pipelines
.iter()
.flat_map(|pipeline| &pipeline.elements)
.find_map(|element| {
log::trace!(
" -> Checking pipeline element, expr='{}', variant={:?}",
element.expr.span_text(context),
&element.expr.expr
);
let result = element.expr.infer_input_type(in_var, context);
log::trace!(" Result: {result:?}");
result
})
}
Expr::MatchBlock(patterns) => {
log::trace!(" -> MatchBlock, checking patterns");
patterns
.iter()
.find_map(|(_, expr)| expr.infer_input_type(in_var, context))
}
_ => {
log::trace!(" -> No match for expression type");
None
}
};
log::trace!("infer_input_type result: {result:?}");
result
}
fn extract_external_command_name(&self, context: &LintContext) -> Option<String> {
use nu_protocol::ast::Traverse;
self.find_map(context.working_set, &|inner_expr| {
if let Expr::ExternalCall(cmd_expr, _) = &inner_expr.expr {
match &cmd_expr.expr {
Expr::String(s) => FindMapResult::Found(s.clone()),
Expr::GlobPattern(pattern, _) => FindMapResult::Found(pattern.clone()),
_ => FindMapResult::Continue,
}
} else {
FindMapResult::Continue
}
})
}
fn extract_int_value(&self, context: &LintContext) -> Option<i64> {
match &self.expr {
Expr::Int(n) => Some(*n),
Expr::Block(block_id) | Expr::Subexpression(block_id) => {
let block = context.working_set.get_block(*block_id);
block
.pipelines
.first()
.and_then(|pipeline| pipeline.elements.first())
.and_then(|elem| elem.expr.extract_int_value(context))
}
_ => None,
}
}
fn unwrap_block_expr<'a>(&'a self, context: &'a LintContext) -> &'a Self {
match &self.expr {
Expr::Block(block_id) | Expr::Subexpression(block_id) => {
let block = context.working_set.get_block(*block_id);
block
.pipelines
.first()
.and_then(|pipeline| pipeline.elements.first())
.map_or(self, |elem| &elem.expr)
}
_ => self,
}
}
fn is_counter_increment(&self, counter_name: &str, context: &LintContext) -> bool {
use nu_protocol::ast::{Assignment, Math};
let Expr::BinaryOp(lhs, op, rhs) = &self.expr else {
return false;
};
let Expr::Operator(Operator::Assignment(assignment_op)) = &op.expr else {
return false;
};
if lhs.extract_variable_name(context).as_deref() != Some(counter_name) {
return false;
}
let is_add_one = |left: &Self, op: &Self, right: &Self| -> bool {
left.extract_variable_name(context).as_deref() == Some(counter_name)
&& matches!(&op.expr, Expr::Operator(Operator::Math(Math::Add)))
&& matches!(&right.expr, Expr::Int(1))
};
match assignment_op {
Assignment::Assign => {
let rhs_unwrapped = rhs.unwrap_block_expr(context);
matches!(
&rhs_unwrapped.expr,
Expr::BinaryOp(add_left, add_op, add_right)
if is_add_one(add_left, add_op, add_right)
)
}
Assignment::AddAssign => rhs.extract_int_value(context) == Some(1),
_ => false,
}
}
#[allow(clippy::excessive_nesting, reason = "Recursive")]
fn traverse_with_parent<'a, F>(
&'a self,
context: &'a LintContext,
parent: Option<&'a Expression>,
callback: &mut F,
) where
F: FnMut(&'a Self, Option<&'a Self>) -> ControlFlow<()>,
{
if callback(self, parent).is_break() {
return;
}
let mut recur = |child: &'a Self| {
child.traverse_with_parent(context, Some(self), callback);
};
match &self.expr {
Expr::RowCondition(block_id)
| Expr::Subexpression(block_id)
| Expr::Block(block_id)
| Expr::Closure(block_id) => {
let block = context.working_set.get_block(*block_id);
block.traverse_with_parent(context, Some(self), callback);
}
Expr::Range(range) => {
for sub_expr in [&range.from, &range.next, &range.to].into_iter().flatten() {
recur(sub_expr);
}
}
Expr::Call(call) => {
for arg in &call.arguments {
if let Some(sub_expr) = arg.expr() {
recur(sub_expr);
}
}
}
Expr::ExternalCall(head, args) => {
recur(head.as_ref());
for arg in args {
recur(arg.expr());
}
}
Expr::UnaryNot(e) | Expr::Collect(_, e) => recur(e.as_ref()),
Expr::BinaryOp(lhs, op, rhs) => {
recur(lhs);
recur(op);
recur(rhs);
}
Expr::MatchBlock(matches) => {
for (_pattern, e) in matches {
recur(e);
}
}
Expr::List(items) => {
for item in items {
match item {
ListItem::Item(e) | ListItem::Spread(_, e) => recur(e),
}
}
}
Expr::Record(items) => {
for item in items {
match item {
RecordItem::Spread(_, e) => recur(e),
RecordItem::Pair(key, val) => {
recur(key);
recur(val);
}
}
}
}
Expr::Table(table) => {
for column in &table.columns {
recur(column);
}
for row in &table.rows {
for item in row {
recur(item);
}
}
}
Expr::ValueWithUnit(vu) => recur(&vu.expr),
Expr::FullCellPath(fcp) => recur(&fcp.head),
Expr::Keyword(kw) => recur(&kw.expr),
Expr::StringInterpolation(vec) | Expr::GlobInterpolation(vec, _) => {
for item in vec {
recur(item);
}
}
Expr::AttributeBlock(ab) => {
for attr in &ab.attributes {
recur(&attr.expr);
}
recur(&ab.item);
}
_ => (),
}
}
}
fn infer_from_call(
call: &Call,
in_var_id: VarId,
in_var: Option<VarId>,
context: &LintContext,
) -> Option<Type> {
log::trace!("infer_from_call: checking call for var_id={in_var_id:?}");
for (idx, arg) in call.arguments.iter().enumerate() {
if let Argument::Positional(arg_expr) | Argument::Unknown(arg_expr) = arg {
log::trace!(" -> Checking positional arg {idx}");
if !arg_expr.contains_variable(in_var_id) {
log::trace!(" -> Does not contain variable");
continue;
}
log::trace!(" -> Contains variable! Checking signature");
let decl = context.working_set.get_decl(call.decl_id);
let signature = decl.signature();
log::trace!(
" -> Command: '{}', input_output_types: {:?}",
decl.name(),
signature.input_output_types
);
if let Some((input_type, _)) = signature.input_output_types.first()
&& !matches!(input_type, nu_protocol::Type::Any)
{
log::trace!(" -> Found input type from signature: {input_type:?}");
return Some(input_type.clone());
}
log::trace!(" -> Signature has no specific input type");
}
}
log::trace!(" -> Recursively checking call arguments");
let result = call.arguments.iter().find_map(|arg| match arg {
Argument::Positional(arg_expr) | Argument::Unknown(arg_expr) => {
arg_expr.infer_input_type(in_var, context)
}
_ => None,
});
log::trace!("infer_from_call result: {result:?}");
result
}
const fn is_filepath_expr(expr: &Expr) -> bool {
matches!(expr, Expr::Filepath(..))
}
const fn is_glob_pattern_expr(expr: &Expr) -> bool {
matches!(expr, Expr::GlobPattern(..))
}
fn check_filepath_output(expr: &Expr) -> Option<Type> {
let ty = Type::Custom("path".into());
match expr {
Expr::ExternalCall(head, _) if matches!(&head.expr, Expr::Filepath(..)) => {
log::trace!(
"check_filepath_output: ExternalCall with filepath head: {:?}",
head.expr
);
Some(ty)
}
Expr::Collect(_, inner) if is_filepath_expr(&inner.expr) => {
log::trace!("check_filepath_output: Collect with filepath inner");
Some(ty)
}
expr if is_filepath_expr(expr) || is_glob_pattern_expr(expr) => {
log::trace!("check_filepath_output: filepath expr: {expr:?}");
Some(ty)
}
_ => None,
}
}
fn infer_expr_output_type(expr: &Expr, ty: &Type, context: &LintContext) -> Option<Type> {
match expr {
expr if check_filepath_output(expr).is_some() => check_filepath_output(expr),
Expr::Bool(_)
| Expr::Int(_)
| Expr::Float(_)
| Expr::String(_)
| Expr::StringInterpolation(_)
| Expr::RawString(_)
| Expr::Record(_)
| Expr::Table(_) => Some(ty.clone()),
Expr::BinaryOp(left, _op, right) => {
if !matches!(ty, Type::Any) {
return Some(ty.clone());
}
infer_binary_op_type(
left.infer_output_type(context).as_ref(),
right.infer_output_type(context).as_ref(),
)
.or_else(|| Some(ty.clone()))
}
Expr::List(items) => Some(infer_list_element_type(items)),
Expr::Nothing => Some(Type::Nothing),
Expr::FullCellPath(path) => {
if let Expr::List(items) = &path.head.expr {
return Some(infer_list_element_type(items));
}
if !path.tail.is_empty() {
return Some(path.head.ty.clone());
}
path.head
.infer_output_type(context)
.or_else(|| Some(path.head.ty.clone()))
}
Expr::Subexpression(block_id) | Expr::Block(block_id) => Some(
context
.working_set
.get_block(*block_id)
.infer_output_type(context),
),
Expr::ExternalCall(call, args) => {
let cmd_name = context.expr_text(call);
if has_external_side_effect(cmd_name, ExternEffect::NoDataInStdout, context, args) {
Some(Type::Nothing)
} else {
Some(Type::String)
}
}
Expr::Call(call) => {
let decl = context.working_set.get_decl(call.decl_id);
let cmd_name = decl.name();
if matches!(cmd_name, "if" | "match" | "try" | "do")
&& let Some(unified_type) = call.infer_from_blocks(context)
{
return Some(unified_type);
}
Some(call.get_output_type(context, None))
}
Expr::Var(var_id) => {
let var = context.working_set.get_variable(*var_id);
if matches!(var.ty, Type::Any) {
None
} else {
Some(var.ty.clone())
}
}
_ => None,
}
}
const fn infer_binary_op_type(left: Option<&Type>, right: Option<&Type>) -> Option<Type> {
match (left, right) {
(Some(Type::Float), _) | (_, Some(Type::Float)) => Some(Type::Float),
(Some(Type::Int | Type::Any), Some(Type::Int)) | (Some(Type::Int), Some(Type::Any)) => {
Some(Type::Int)
}
_ => None,
}
}
fn infer_list_element_type(items: &[ListItem]) -> Type {
if items.is_empty() {
return Type::List(Box::new(Type::Any));
}
let element_types: Vec<Type> = items
.iter()
.map(|item| match item {
ListItem::Item(expr) | ListItem::Spread(_, expr) => expr.ty.clone(),
})
.collect();
if element_types.is_empty() {
return Type::List(Box::new(Type::Any));
}
if element_types.iter().all(|t| t == &element_types[0]) {
log::trace!("All list elements have type: {:?}", element_types[0]);
Type::List(Box::new(element_types[0].clone()))
} else {
log::trace!("List has mixed types, using Any");
Type::List(Box::new(Type::Any))
}
}