nu-lint 1.3.0

Linter for Nu shell scripts that helpfully suggests improvements
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
use std::{
    cmp::Reverse, collections::HashMap, fmt::Write, fs, io::Error as IoError, path::PathBuf,
    vec::Vec,
};

use crate::{
    engine::LintEngine,
    format::format_diff_context,
    violation::{Fix, Violation},
};

/// Result of applying fixes to a file
#[derive(Debug)]
pub struct FixResult {
    pub file_path: PathBuf,
    pub original_content: String,
    pub fixed_content: String,
    pub fixes_applied: usize,
}

/// Apply fixes to standard input content
///
/// Returns the fixed content as a string
#[must_use]
pub fn apply_fixes_to_stdin(violations: &[Violation]) -> Option<String> {
    // Filter violations that come from standard input and have fixes
    let stdin_violations: Vec<&Violation> = violations
        .iter()
        .filter(|v| {
            v.file
                .as_ref()
                .is_some_and(super::violation::SourceFile::is_stdin)
                && v.fix.is_some()
        })
        .collect();

    if stdin_violations.is_empty() {
        return None;
    }

    // Get the original source from the first violation
    let original_content = stdin_violations
        .first()
        .and_then(|v| v.source.as_ref())
        .map(std::borrow::Cow::as_ref)?;

    let fixed_content = apply_fixes_to_content(original_content, &stdin_violations);

    Some(fixed_content)
}

/// Apply fixes to violations grouped by file
///
/// # Errors
///
/// Returns an error if a file cannot be read or written
pub fn apply_fixes(
    violations: &[Violation],
    dry_run: bool,
    lint_engine: &LintEngine,
) -> Vec<FixResult> {
    group_violations_by_file(violations)
        .into_iter()
        .filter_map(|(file_path, _file_violations)| {
            apply_fix_to_file(&file_path, dry_run, lint_engine).ok()
        })
        .collect()
}

/// Apply fixes to a single file iteratively
fn apply_fix_to_file(
    file_path: &PathBuf,
    dry_run: bool,
    lint_engine: &LintEngine,
) -> Result<FixResult, IoError> {
    let original_content = fs::read_to_string(file_path)?;

    // Apply fixes iteratively, re-linting after each fix
    let (fixed_content, fixes_applied) = apply_fixes_iteratively(&original_content, lint_engine);

    log::debug!(
        "File: {}, Fixes: {}, Original len: {}, Fixed len: {}",
        file_path.display(),
        fixes_applied,
        original_content.len(),
        fixed_content.len()
    );

    if fixes_applied == 0 {
        return Err(IoError::other("No fixes to apply"));
    }

    if !dry_run {
        fs::write(file_path, &fixed_content)?;
    }

    Ok(FixResult {
        file_path: file_path.clone(),
        original_content,
        fixed_content,
        fixes_applied,
    })
}

/// Apply fixes iteratively, re-linting after each fix to get fresh spans
#[must_use]
pub fn apply_fixes_iteratively(content: &str, lint_engine: &LintEngine) -> (String, usize) {
    let mut current_content = content.to_string();
    let mut total_fixes_applied = 0;
    let max_iterations = 100; // Prevent infinite loops

    for iteration in 0..max_iterations {
        // Re-lint the current content to get violations with fresh spans
        let violations = lint_engine.lint_str(&current_content);

        // Find the first violation that has a fix
        let fixable_violation = violations.iter().find(|v| v.fix.is_some());

        if fixable_violation.is_none() {
            // No more fixes to apply
            log::debug!(
                "Iterative fix complete after {iteration} iterations, {total_fixes_applied} fixes \
                 applied"
            );
            break;
        }

        // Apply just the first fix
        let violation = fixable_violation.unwrap();
        let fix = violation.fix.as_ref().unwrap();

        // Apply all replacements from this one fix
        let new_content = apply_single_fix_to_content(&current_content, fix);

        if new_content == current_content {
            log::warn!("Fix did not change content, stopping to avoid infinite loop");
            break;
        }

        current_content = new_content;
        total_fixes_applied += 1;

        log::debug!(
            "Applied fix {} from rule '{}' at iteration {}",
            total_fixes_applied,
            violation.rule_id.as_deref().unwrap_or("unknown"),
            iteration
        );
    }

    if total_fixes_applied >= max_iterations {
        log::warn!("Reached maximum iteration limit ({max_iterations})");
    }

    (current_content, total_fixes_applied)
}

/// Apply a single fix's replacements to content
fn apply_single_fix_to_content(content: &str, fix: &Fix) -> String {
    let mut replacements = fix.replacements.clone();

    if replacements.is_empty() {
        return content.to_string();
    }

    // Sort replacements by span start in reverse order
    replacements.sort_by_key(|b| Reverse(b.file_span().start));

    let mut result = content.to_string();

    for replacement in replacements {
        let start = replacement.file_span().start;
        let end = replacement.file_span().end;

        // Validate span bounds
        if start > result.len() || end > result.len() || start > end {
            log::warn!(
                "Invalid replacement span: start={}, end={}, content_len={}",
                start,
                end,
                result.len()
            );
            continue;
        }

        // Check UTF-8 boundaries
        if !result.is_char_boundary(start) || !result.is_char_boundary(end) {
            log::warn!("Replacement span not on UTF-8 boundary: start={start}, end={end}");
            continue;
        }

        result.replace_range(start..end, &replacement.replacement_text);
    }

    result
}

/// Group violations by their filepath
fn group_violations_by_file(violations: &[Violation]) -> HashMap<PathBuf, Vec<&Violation>> {
    let mut grouped: HashMap<PathBuf, Vec<&Violation>> = HashMap::new();

    for violation in violations {
        if let Some(file) = &violation.file
            && let Some(path) = file.as_path()
        {
            grouped
                .entry(path.to_path_buf())
                .or_default()
                .push(violation);
        }
    }

    grouped
}

/// Apply fixes to source code content
fn apply_fixes_to_content(content: &str, violations: &[&Violation]) -> String {
    // Collect all replacements from all violations
    let mut replacements = Vec::new();
    for violation in violations {
        if let Some(fix) = &violation.fix {
            replacements.extend(fix.replacements.clone());
        }
    }

    if replacements.is_empty() {
        return content.to_string();
    }

    // Sort replacements by span start in reverse order to apply from end to start
    // This ensures that earlier positions remain valid as we modify the string
    replacements.sort_by_key(|b| Reverse(b.file_span().start));

    // Deduplicate replacements with identical spans
    // This prevents applying the same fix multiple times
    replacements.dedup_by(|a, b| {
        a.file_span().start == b.file_span().start && a.file_span().end == b.file_span().end
    });

    let mut result = content.to_string();
    let content_bytes = content.as_bytes();

    for replacement in replacements {
        let start = replacement.file_span().start;
        let end = replacement.file_span().end;

        // Validate span bounds against original content
        if start > content_bytes.len() || end > content_bytes.len() || start > end {
            log::warn!(
                "Invalid replacement span: start={}, end={}, content_len={}",
                start,
                end,
                content_bytes.len()
            );
            continue;
        }

        // Check UTF-8 boundaries
        if !result.is_char_boundary(start) || !result.is_char_boundary(end) {
            log::warn!("Replacement span not on UTF-8 boundary: start={start}, end={end}");
            continue;
        }

        // Apply the replacement to the result string
        result.replace_range(start..end, &replacement.replacement_text);
    }

    result
}

/// Format fix results for output
#[must_use]
pub fn format_fix_results(results: &[FixResult], dry_run: bool) -> String {
    let mut output = String::new();

    if results.is_empty() {
        output.push_str("No fixable violations found.\n");
        return output;
    }

    if dry_run {
        writeln!(
            output,
            "The following changes would be applied ({} file{}):\n",
            results.len(),
            if results.len() == 1 { "" } else { "s" }
        )
        .unwrap();

        for result in results {
            writeln!(output, "File: {}", result.file_path.display()).unwrap();
            writeln!(output, "Fixes to apply: {}\n", result.fixes_applied).unwrap();

            // Generate and display unified diff
            let diff = format_diff_context(&result.original_content, &result.fixed_content);
            output.push_str(&diff);
            output.push('\n');
        }
    } else {
        writeln!(
            output,
            "Fixed {} file{}:\n",
            results.len(),
            if results.len() == 1 { "" } else { "s" }
        )
        .unwrap();

        for result in results {
            writeln!(
                output,
                "  {} ({} fix{})",
                result.file_path.display(),
                result.fixes_applied,
                if result.fixes_applied == 1 { "" } else { "es" }
            )
            .unwrap();
        }
    }

    output
}

#[cfg(test)]
mod tests {
    use std::borrow::Cow;

    use miette::Severity;
    use nu_protocol::Span;

    use super::*;
    use crate::violation::{Fix, Replacement, SourceFile, Violation};

    #[test]
    fn test_apply_multiple_replacements() {
        use crate::span::FileSpan;

        let content = "let x = 5; let y = 10";
        let replacements = vec![
            Replacement::with_file_span(FileSpan::new(4, 5), "a"),
            Replacement::with_file_span(FileSpan::new(15, 16), "b"),
        ];
        let fix = Fix {
            explanation: "Rename variables".into(),
            replacements,
        };

        let violation = Violation {
            rule_id: Some(Cow::Borrowed("test_rule")),
            lint_level: Severity::Warning,
            message: Cow::Borrowed("Test"),
            span: FileSpan::new(0, 21).into(),
            primary_label: None,
            extra_labels: vec![],
            long_description: None,
            fix: Some(fix),
            file: Some(SourceFile::from("test.nu")),
            source: None,
            doc_url: None,
            short_description: None,
            diagnostic_tags: vec![],
            external_detections: vec![],
        };

        let fixed = apply_fixes_to_content(content, &[&violation]);
        assert_eq!(fixed, "let a = 5; let b = 10");
    }

    #[test]
    fn test_iterative_fixes_with_overlapping_spans() {
        // Test that the iterative fix system can handle fixes that would have
        // overlapping spans if applied simultaneously
        use crate::{config::Config, engine::LintEngine};

        let content = "^evtest /dev/input/event0 err> /dev/null | lines\n";

        let config = Config::default();
        let engine = LintEngine::new(config);

        let (fixed, count) = apply_fixes_iteratively(content, &engine);

        // Should apply at least one fix without panicking
        assert!(count > 0, "Expected at least one fix to be applied");

        // Fixed content should not contain the redirect
        assert!(
            !fixed.contains("err> /dev/null"),
            "Fixed content should not contain err> /dev/null"
        );

        // Should be valid Nushell code (no corruption)
        assert!(
            fixed.contains("evtest"),
            "Fixed content should still contain command name"
        );
        assert!(
            fixed.contains("lines"),
            "Fixed content should still contain pipeline command"
        );
    }

    #[test]
    fn test_iterative_fixes_multiple_rules_same_line() {
        // Test that multiple rules fixing the same line work correctly when applied
        // iteratively
        use crate::{config::Config, engine::LintEngine};

        // This triggers multiple rules.
        let content = "^grep pattern file.txt err> /dev/null | lines\n";

        let config = Config::default();
        let engine = LintEngine::new(config);

        let (fixed, count) = apply_fixes_iteratively(content, &engine);

        // Should apply multiple fixes without corruption
        assert!(count > 0, "Expected at least one fix to be applied");

        // Content should not be corrupted - should still be valid Nushell
        assert!(!fixed.is_empty(), "Fixed content should not be empty");

        // The content should be transformed, not corrupted
        // We don't assert exact output since multiple rules may apply
        assert!(
            fixed.len() < 200,
            "Fixed content should not be unreasonably long (corruption check)"
        );
    }

    #[test]
    fn test_iterative_fixes_converge() {
        // Test that iterative fixes eventually converge (no infinite loop)
        use crate::{config::Config, engine::LintEngine};

        // Multiple violations that could potentially trigger repeatedly
        let content = "^curl https://example.com err> /dev/null | str trim\n";

        let config = Config::default();
        let engine = LintEngine::new(config);

        let (fixed, count) = apply_fixes_iteratively(content, &engine);

        // Should converge within reasonable iterations
        assert!(
            count < 50,
            "Should converge within 50 iterations, got {count}"
        );

        // Re-linting the fixed content should produce no fixable violations
        let violations_after = engine.lint_str(&fixed);
        let fixable_after = violations_after.iter().filter(|v| v.fix.is_some()).count();

        assert_eq!(
            fixable_after, 0,
            "After applying all fixes, there should be no more fixable violations"
        );
    }

    #[test]
    fn test_iterative_fixes_preserve_utf8() {
        // Test that iterative fixes correctly handle UTF-8 boundaries
        use crate::{config::Config, engine::LintEngine};

        let content = "^echo 测试 err> /dev/null | lines\n";

        let config = Config::default();
        let engine = LintEngine::new(config);

        let (fixed, count) = apply_fixes_iteratively(content, &engine);

        // Should apply fixes without UTF-8 boundary panics
        assert!(count > 0, "Expected at least one fix to be applied");

        // UTF-8 characters should be preserved
        assert!(
            fixed.contains("测试"),
            "UTF-8 characters should be preserved"
        );
        assert!(
            !fixed.contains("err> /dev/null"),
            "Redirect should be removed"
        );

        // Verify the result is valid UTF-8 (String is always valid UTF-8, but this
        // confirms no corruption)
        assert!(
            !fixed.is_empty() && fixed.chars().all(|c| !c.is_control() || c.is_whitespace()),
            "Result should contain valid characters"
        );
    }

    #[test]
    fn test_count_applicable_fixes() {
        let fix = Fix {
            explanation: "Test fix".into(),
            replacements: vec![],
        };

        let with_fix = Violation {
            rule_id: Some(Cow::Borrowed("test_rule")),
            lint_level: Severity::Warning,
            message: Cow::Borrowed("Test"),
            span: Span::new(0, 5).into(),
            primary_label: None,
            extra_labels: vec![],
            long_description: None,
            fix: Some(fix),
            file: Some(SourceFile::from("test.nu")),
            source: None,
            doc_url: None,
            short_description: None,
            diagnostic_tags: vec![],
            external_detections: vec![],
        };

        let without_fix = Violation {
            rule_id: Some(Cow::Borrowed("test_rule")),
            lint_level: Severity::Warning,
            message: Cow::Borrowed("Test"),
            span: Span::new(0, 5).into(),
            primary_label: None,
            extra_labels: vec![],
            long_description: None,
            fix: None,
            file: Some(SourceFile::from("test.nu")),
            source: None,
            doc_url: None,
            short_description: None,
            diagnostic_tags: vec![],
            external_detections: vec![],
        };

        let violations = [&with_fix, &without_fix, &with_fix];
        let count = violations.iter().filter(|v| v.fix.is_some()).count();
        assert_eq!(count, 2);
    }

    #[test]
    fn test_group_violations_by_file() {
        let v1 = Violation {
            rule_id: Some(Cow::Borrowed("test_rule")),
            lint_level: Severity::Warning,
            message: Cow::Borrowed("Test"),
            span: Span::new(0, 5).into(),
            primary_label: None,
            extra_labels: vec![],
            long_description: None,
            fix: None,
            file: Some(SourceFile::from("file1.nu")),
            source: None,
            doc_url: None,
            short_description: None,
            diagnostic_tags: vec![],
            external_detections: vec![],
        };

        let v2 = Violation {
            rule_id: Some(Cow::Borrowed("test_rule")),
            lint_level: Severity::Warning,
            message: Cow::Borrowed("Test"),
            span: Span::new(0, 5).into(),
            primary_label: None,
            extra_labels: vec![],
            long_description: None,
            fix: None,
            file: Some(SourceFile::from("file2.nu")),
            source: None,
            doc_url: None,
            short_description: None,
            diagnostic_tags: vec![],
            external_detections: vec![],
        };

        let v3 = Violation {
            rule_id: Some(Cow::Borrowed("test_rule")),
            lint_level: Severity::Warning,
            message: Cow::Borrowed("Test"),
            span: Span::new(5, 10).into(),
            primary_label: None,
            extra_labels: vec![],
            long_description: None,
            fix: None,
            file: Some(SourceFile::from("file1.nu")),
            source: None,
            doc_url: None,
            short_description: None,
            diagnostic_tags: vec![],
            external_detections: vec![],
        };

        let violations = vec![v1, v2, v3];
        let grouped = group_violations_by_file(&violations);

        assert_eq!(grouped.len(), 2);
        assert_eq!(grouped[&PathBuf::from("file1.nu")].len(), 2);
        assert_eq!(grouped[&PathBuf::from("file2.nu")].len(), 1);
    }
}