use std::{
env, fs,
io::{self, BufRead},
path::{Path, PathBuf},
sync::{LazyLock, Mutex},
};
use ::ignore::WalkBuilder;
use nu_parser::parse;
use nu_protocol::{
Span, Value,
ast::Block,
engine::{EngineState, FileStack, StateWorkingSet},
};
use rayon::prelude::*;
use crate::{
LintError, LintLevel,
config::Config,
context::LintContext,
ignore,
rules::USED_RULES,
violation::{SourceFile, Violation},
};
pub fn parse_source<'a>(
engine_state: &'a EngineState,
source: &[u8],
file_path: Option<&Path>,
) -> (Block, StateWorkingSet<'a>, usize) {
let mut working_set = StateWorkingSet::new(engine_state);
let (fname, file_buf) = match file_path {
Some(p) => (p.display().to_string(), p.to_path_buf()),
None => ("source".to_string(), Path::new("source").to_path_buf()),
};
let file_offset = working_set.next_span_start();
let _file_id = working_set.add_file(fname.clone(), source);
working_set.files = FileStack::with_file(file_buf);
let block = parse(&mut working_set, Some(&fname), source, false);
((*block).clone(), working_set, file_offset)
}
fn is_nushell_file(path: &Path) -> bool {
path.extension()
.and_then(|s| s.to_str())
.is_some_and(|ext| ext == "nu")
|| fs::File::open(path)
.ok()
.and_then(|file| {
let mut reader = io::BufReader::new(file);
let mut first_line = String::new();
reader.read_line(&mut first_line).ok()?;
first_line.starts_with("#!").then(|| {
first_line
.split_whitespace()
.any(|word| word.ends_with("/nu") || word == "nu")
})
})
.unwrap_or(false)
}
#[must_use]
pub fn collect_nu_files_from_dir(dir: &Path) -> Vec<PathBuf> {
WalkBuilder::new(dir)
.standard_filters(true)
.build()
.filter_map(|result| match result {
Ok(entry) => {
let path = entry.path().to_path_buf();
(path.is_file() && is_nushell_file(&path)).then_some(path)
}
Err(err) => {
log::warn!("Error walking directory: {err}");
None
}
})
.collect()
}
#[must_use]
pub fn collect_nu_files(paths: &[PathBuf]) -> Vec<PathBuf> {
paths
.iter()
.flat_map(|path| {
if !path.exists() {
log::warn!("Path not found: {}", path.display());
return vec![];
}
if path.is_file() {
if is_nushell_file(path) {
vec![path.clone()]
} else {
vec![]
}
} else if path.is_dir() {
collect_nu_files_from_dir(path)
} else {
vec![]
}
})
.collect()
}
pub struct LintEngine {
pub(crate) config: Config,
engine_state: &'static EngineState,
}
impl LintEngine {
#[must_use]
pub fn new_state() -> &'static EngineState {
static ENGINE: LazyLock<EngineState> = LazyLock::new(|| {
let mut engine_state = nu_cmd_lang::create_default_context();
engine_state = nu_command::add_shell_command_context(engine_state);
engine_state = nu_cmd_extra::add_extra_command_context(engine_state);
engine_state = nu_cli::add_cli_context(engine_state);
if let Ok(cwd) = env::current_dir()
&& let Some(cwd) = cwd.to_str()
{
engine_state.add_env_var("PWD".into(), Value::string(cwd, Span::unknown()));
}
let delta = {
let mut working_set = StateWorkingSet::new(&engine_state);
working_set.add_decl(Box::new(nu_cli::Print));
working_set.render()
};
engine_state
.merge_delta(delta)
.expect("Failed to add Print command");
nu_std::load_standard_library(&mut engine_state).unwrap();
engine_state.generate_nu_constant();
engine_state
});
&ENGINE
}
#[must_use]
pub fn new(config: Config) -> Self {
Self {
config,
engine_state: Self::new_state(),
}
}
pub(crate) fn lint_file(&self, path: &Path) -> Result<Vec<Violation>, LintError> {
log::debug!("Linting file: {}", path.display());
let source = fs::read_to_string(path).map_err(|source| LintError::Io {
path: path.to_path_buf(),
source,
})?;
let file_path = fs::canonicalize(path).ok();
let (block, working_set, file_offset) =
parse_source(self.engine_state, source.as_bytes(), file_path.as_deref());
let mut violations = self.lint_parsed(&source, &block, &working_set, file_offset);
for violation in &mut violations {
violation.file = Some(path.into());
}
violations.sort_by(|a, b| {
a.file_span()
.start
.cmp(&b.file_span().start)
.then(a.lint_level.cmp(&b.lint_level))
});
Ok(violations)
}
#[must_use]
pub fn lint_files(&self, files: &[PathBuf]) -> Vec<Violation> {
let violations_mutex = Mutex::new(Vec::new());
let process_file = |path: &PathBuf| match self.lint_file(path) {
Ok(violations) => {
violations_mutex
.lock()
.expect("Failed to lock violations mutex")
.extend(violations);
}
Err(e) => {
log::error!("Error linting {}: {}", path.display(), e);
}
};
if self.config.sequential {
for path in files {
log::debug!("Processing file: {}", path.display());
process_file(path);
}
} else {
files.par_iter().for_each(process_file);
}
violations_mutex
.into_inner()
.expect("Failed to unwrap violations mutex")
}
#[must_use]
pub fn lint_stdin(&self, source: &str) -> Vec<Violation> {
let mut violations = self.lint_str(source);
let source_owned = source.to_string();
for violation in &mut violations {
violation.file = Some(SourceFile::Stdin);
violation.source = Some(source_owned.clone().into());
}
violations
}
#[must_use]
pub fn lint_str(&self, source: &str) -> Vec<Violation> {
let (block, working_set, file_offset) =
parse_source(self.engine_state, source.as_bytes(), None);
self.lint_parsed(source, &block, &working_set, file_offset)
}
fn lint_parsed(
&self,
source: &str,
block: &Block,
working_set: &StateWorkingSet,
file_offset: usize,
) -> Vec<Violation> {
let context = LintContext::new(
source,
block,
self.engine_state,
working_set,
file_offset,
&self.config,
);
let mut violations = self.detect_with_fix_data(&context);
for violation in &mut violations {
violation.normalize_spans(file_offset);
}
let ignore_index = ignore::IgnoreIndex::new(source);
violations
.into_iter()
.filter(|v| {
let rule_id = v.rule_id.as_deref().unwrap_or("");
!ignore_index.should_ignore(v.file_span().start, rule_id)
})
.collect()
}
fn detect_with_fix_data(&self, context: &LintContext) -> Vec<Violation> {
USED_RULES
.iter()
.filter_map(|rule| {
let lint_level = self.config.get_lint_level(*rule);
if lint_level == LintLevel::Off {
return None;
}
let mut violations = rule.check(context);
for violation in &mut violations {
violation.set_rule_id(rule.id());
violation.set_lint_level(lint_level.try_into().unwrap());
violation.set_doc_url(rule.source_link());
violation.set_short_description(rule.short_description());
violation.set_diagnostic_tags(rule.diagnostic_tags());
}
(!violations.is_empty()).then_some(violations)
})
.flatten()
.collect()
}
}