use std::cmp::Ordering;
use nu_protocol::{
Span,
ast::{Argument, Block, Expr, Expression, Pipeline, PipelineElement, Traverse},
};
use crate::{
Fix, LintLevel, Replacement,
ast::{block::BlockExt, declaration::CustomCommandDef, expression::ExpressionExt},
context::LintContext,
rule::{DetectFix, Rule},
violation::Detection,
};
fn is_inlinable_body(block: &Block, context: &LintContext) -> bool {
let non_empty_pipeline_count = block
.pipelines
.iter()
.filter(|p| {
p.elements
.iter()
.any(|e| !matches!(&e.expr.expr, Expr::Nothing))
})
.count();
if non_empty_pipeline_count != 1 {
return false;
}
block
.span
.is_none_or(|span| context.span_text(span).lines().count() <= 3)
}
fn unwrap_body_pipeline<'a>(
pipeline: &'a Pipeline,
context: &'a LintContext,
) -> Option<(&'a [PipelineElement], &'a Expression, bool)> {
let first = pipeline.elements.first()?;
if let Expr::Collect(_, inner) = &first.expr.expr
&& let Expr::Subexpression(block_id) = &inner.expr
{
let inner_pipeline = context.working_set.get_block(*block_id).pipelines.first()?;
return Some((&inner_pipeline.elements, &first.expr, true));
}
Some((&pipeline.elements, &first.expr, false))
}
fn find_single_call(
decl_id: nu_protocol::DeclId,
context: &LintContext,
) -> Option<(Span, Vec<Span>)> {
let mut calls = Vec::new();
context.ast.flat_map(
context.working_set,
&|expr| {
let Expr::Call(call) = &expr.expr else {
return vec![];
};
if call.decl_id != decl_id {
return vec![];
}
let args = call
.arguments
.iter()
.filter_map(|a| match a {
Argument::Positional(e) | Argument::Unknown(e) => Some(e.span),
_ => None,
})
.collect();
vec![(expr.span, args)]
},
&mut calls,
);
(calls.len() == 1).then(|| calls.into_iter().next())?
}
#[derive(Eq, PartialEq)]
struct Substitution<'a> {
span: Span,
replacement: &'a str,
}
impl PartialOrd for Substitution<'_> {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Substitution<'_> {
fn cmp(&self, other: &Self) -> Ordering {
self.span.start.cmp(&other.span.start)
}
}
struct FixData {
definition_span: Span,
call_span: Span,
body_span: Span,
body_start: usize,
substitutions: Vec<(Span, Span)>, }
struct InlineSingleUseFunction;
impl DetectFix for InlineSingleUseFunction {
type FixInput<'a> = Option<FixData>;
fn id(&self) -> &'static str {
"single_call_command"
}
fn short_description(&self) -> &'static str {
"Single-line command called only once"
}
fn source_link(&self) -> Option<&'static str> {
Some("https://www.nushell.sh/book/custom_commands.html")
}
fn level(&self) -> LintLevel {
LintLevel::Hint
}
fn detect<'a>(&self, context: &'a LintContext) -> Vec<(Detection, Self::FixInput<'a>)> {
let commands = context.custom_commands();
if !commands.iter().any(CustomCommandDef::is_main) {
return vec![];
}
commands
.iter()
.filter(|def| !def.is_main() && !def.is_exported())
.filter_map(|def| {
let block = context.working_set.get_block(def.body);
if !is_inlinable_body(block, context) {
return None;
}
let decl_id = context.working_set.find_decl(def.name.as_bytes())?;
let (call_span, call_args) = find_single_call(decl_id, context)?;
let pipeline = block.pipelines.iter().find(|p| {
p.elements
.iter()
.any(|e| !matches!(&e.expr.expr, Expr::Nothing))
})?;
let (elements, _, has_dollar_in) = unwrap_body_pipeline(pipeline, context)?;
let first = elements.first()?;
let last = elements.last()?;
let body_span = Span::new(first.expr.span.start, last.expr.span.end);
let body_start = if has_dollar_in {
elements.get(1).map(|e| e.expr.span.start)
} else {
first
.expr
.find_dollar_in_usage()
.filter(|s| s.start == first.expr.span.start)
.and_then(|_| elements.get(1).map(|e| e.expr.span.start))
}
.unwrap_or(body_span.start);
let params = def
.signature
.required_positional
.iter()
.chain(&def.signature.optional_positional);
let substitutions: Vec<_> = params
.zip(&call_args)
.filter_map(|(param, &arg_span)| {
let idx = def
.signature
.required_positional
.iter()
.chain(&def.signature.optional_positional)
.position(|p| p.name == param.name)?;
let var_id = block.signature.get_positional(idx)?.var_id?;
let usage_spans = block.var_usages(var_id, context);
Some(
usage_spans
.into_iter()
.map(move |usage_span| (usage_span, arg_span)),
)
})
.flatten()
.collect();
let detection = Detection::from_file_span(
format!(
"Function `{}` has a single-line body and is only used once",
def.name
),
def.declaration_span(context),
)
.with_primary_label("single-use function")
.with_extra_label("could be inlined", block.span?);
Some((
detection,
Some(FixData {
definition_span: context.expand_span_to_statement(def.definition_span),
call_span,
body_span,
body_start,
substitutions,
}),
))
})
.collect()
}
fn fix(&self, context: &LintContext, fix_data: &Self::FixInput<'_>) -> Option<Fix> {
let data = fix_data.as_ref()?;
let body_span = Span::new(data.body_start, data.body_span.end);
let body_text = context.span_text(body_span);
let mut subs: Vec<_> = data
.substitutions
.iter()
.filter(|(usage, _)| usage.start >= body_span.start && usage.end <= body_span.end)
.map(|(usage, arg)| Substitution {
span: Span::new(usage.start - body_span.start, usage.end - body_span.start),
replacement: context.span_text(*arg),
})
.collect();
subs.sort();
let inlined = subs
.iter()
.rev()
.fold(body_text.to_string(), |mut text, sub| {
text.replace_range(sub.span.start..sub.span.end, sub.replacement);
text
});
Some(Fix {
explanation: "Inline function body and remove definition".into(),
replacements: vec![
Replacement::new(data.call_span, inlined),
Replacement::new(data.definition_span, String::new()),
],
})
}
}
pub static RULE: &dyn Rule = &InlineSingleUseFunction;
#[cfg(test)]
mod detect_bad;
#[cfg(test)]
mod generated_fix;
#[cfg(test)]
mod ignore_good;