nu-lint 1.3.0

Linter for Nu shell scripts that helpfully suggests improvements
Documentation
use nu_protocol::{
    Span,
    ast::{Block, Expr, Expression, Pipeline},
};

use crate::{
    LintLevel,
    context::LintContext,
    rule::{DetectFix, Rule},
    violation::{Detection, Fix, Replacement},
};

/// Semantic fix data: stores the span to replace with " | "
pub struct FixData {
    fix_span: Span,
}

/// AST visitor that checks for pipe spacing issues
struct PipeSpacingVisitor<'a> {
    context: &'a LintContext<'a>,
    violations: Vec<(Detection, FixData)>,
}

impl<'a> PipeSpacingVisitor<'a> {
    const fn new(context: &'a LintContext<'a>) -> Self {
        Self {
            context,
            violations: Vec::new(),
        }
    }

    /// Check spacing around a pipe between two elements (optimized)
    fn check_pipe_spacing(&mut self, prev_span: Span, curr_span: Span) {
        let between = self.context.source_between_span_ends(prev_span, curr_span);

        if between.is_empty() {
            return;
        }

        // If it's exactly " | ", it's correct
        if between == " | " {
            return;
        }

        // Check if it's a multi-line pipe using minimal line counting
        let file_start = prev_span.end.saturating_sub(self.context.file_offset());
        let file_end = curr_span.start.saturating_sub(self.context.file_offset());

        let prev_line = self.context.count_newlines_before(file_start);
        let curr_line = self.context.count_newlines_before(file_end);

        if prev_line != curr_line {
            // Multi-line pipe is fine - skip checking
            return;
        }

        // Skip if this region contains a comment (prevents false positives)
        if between.contains('#') || between.contains("//") {
            return;
        }

        // Same line: check for proper spacing
        if let Some(pipe_pos) = between.find('|') {
            // Check if this is inside closure parameters like |x|
            if Self::is_closure_parameter(between, pipe_pos) {
                return;
            }

            let before_pipe = &between[..pipe_pos];
            let after_pipe = &between[pipe_pos + 1..];

            let has_proper_space_before = before_pipe == " ";
            let has_proper_space_after =
                after_pipe.starts_with(' ') && !after_pipe.starts_with("  ");

            if has_proper_space_before && has_proper_space_after {
                return;
            }

            let message = Self::get_pipe_spacing_message(
                has_proper_space_before,
                has_proper_space_after,
                before_pipe,
            );

            // Use global spans (will be normalized later by the engine)
            let start = prev_span.end;
            let end = curr_span.start;
            let violation_start = start + pipe_pos.saturating_sub(1);
            let violation_end = (start + pipe_pos + 2).min(end);
            let violation_span = Span::new(violation_start, violation_end);

            let fix_span = Span::new(start, end);

            let violation = Detection::from_global_span(message.to_string(), violation_span)
                .with_primary_label("spacing issue");

            let fix_data = FixData { fix_span };

            self.violations.push((violation, fix_data));
        }
    }

    const fn get_pipe_spacing_message(
        has_proper_space_before: bool,
        has_proper_space_after: bool,
        before_pipe: &str,
    ) -> &'static str {
        if !has_proper_space_before && !has_proper_space_after {
            "Pipe should have exactly one space before and after"
        } else if !has_proper_space_before {
            if before_pipe.is_empty() {
                "Pipe should have space before |"
            } else {
                "Pipe should have exactly one space before |"
            }
        } else {
            "Pipe should have space after |"
        }
    }

    fn is_closure_parameter(text: &str, pipe_pos: usize) -> bool {
        let before = &text[..pipe_pos];
        let after = &text[pipe_pos + 1..];
        before.contains('{') && after.contains('|')
    }
}

fn detect_pipeline_spacing(
    pipeline: &Pipeline,
    context: &LintContext,
) -> Vec<(Detection, FixData)> {
    let mut visitor = PipeSpacingVisitor::new(context);

    // Check spacing between consecutive pipeline elements
    for i in 1..pipeline.elements.len() {
        let prev = &pipeline.elements[i - 1];
        let curr = &pipeline.elements[i];
        visitor.check_pipe_spacing(prev.expr.span, curr.expr.span);
    }

    visitor.violations
}

fn walk_block_for_pipelines(
    block: &Block,
    context: &LintContext,
    violations: &mut Vec<(Detection, FixData)>,
) {
    for pipeline in &block.pipelines {
        violations.extend(detect_pipeline_spacing(pipeline, context));

        // Also check nested blocks
        for element in &pipeline.elements {
            walk_expr_for_pipelines(&element.expr, context, violations);
        }
    }
}

fn walk_expr_for_pipelines(
    expr: &Expression,
    context: &LintContext,
    violations: &mut Vec<(Detection, FixData)>,
) {
    match &expr.expr {
        Expr::Block(block_id)
        | Expr::Closure(block_id)
        | Expr::Subexpression(block_id)
        | Expr::RowCondition(block_id) => {
            let block = context.working_set.get_block(*block_id);
            walk_block_for_pipelines(block, context, violations);
        }
        Expr::Call(call) => {
            for arg in &call.arguments {
                if let Some(expr) = arg.expr() {
                    walk_expr_for_pipelines(expr, context, violations);
                }
            }
        }
        _ => {}
    }
}

struct PipeSpacing;

impl DetectFix for PipeSpacing {
    type FixInput<'a> = FixData;

    fn id(&self) -> &'static str {
        "pipe_spacing"
    }

    fn short_description(&self) -> &'static str {
        "Inconsistent spacing around `|`"
    }

    fn source_link(&self) -> Option<&'static str> {
        Some("https://www.nushell.sh/book/style_guide.html#basic")
    }

    fn level(&self) -> LintLevel {
        LintLevel::Warning
    }

    fn detect<'a>(&self, context: &'a LintContext) -> Vec<(Detection, Self::FixInput<'a>)> {
        let mut violations = Vec::new();

        let block: &Block = context.ast;
        for pipeline in &block.pipelines {
            violations.extend(detect_pipeline_spacing(pipeline, context));

            // Also check nested blocks
            for element in &pipeline.elements {
                walk_expr_for_pipelines(&element.expr, context, &mut violations);
            }
        }

        violations
    }

    fn fix(&self, _context: &LintContext, fix_data: &Self::FixInput<'_>) -> Option<Fix> {
        Some(Fix {
            explanation: "Fix pipe spacing to ' | '".into(),
            replacements: vec![Replacement::new(fix_data.fix_span, " | ")],
        })
    }
}

pub static RULE: &dyn Rule = &PipeSpacing;

#[cfg(test)]
mod detect_bad;
#[cfg(test)]
mod generated_fix;
#[cfg(test)]
mod ignore_good;