use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use anyhow::{Context, Result};
use ast_grep_core::AstGrep;
use ast_grep_core::matcher::Pattern;
use ast_grep_language::SupportLang;
use unicode_normalization::UnicodeNormalization;
use clap::ValueHint;
use crate::atomic::{AtomicWriteOptions, atomic_write};
use crate::checksum;
use crate::cli::GlobalArgs;
use crate::cli_args::BackupOpts;
use crate::commands::resolve_backup;
use crate::error::AtomwriteError;
use crate::ndjson_types::{ScopeResult, Summary};
use crate::output::NdjsonWriter;
use crate::signal::ShutdownSignal;
#[derive(clap::Args, Debug)]
pub struct ScopeArgs {
#[arg(default_value = ".", value_hint = ValueHint::AnyPath)]
pub paths: Vec<std::path::PathBuf>,
#[arg(
short = 'l',
long = "language",
alias = "lang",
required = true,
help = "Language (rust, py, ts, go, c, etc); accepts --lang as alias"
)]
pub language: String,
#[arg(
long,
help = "Prepared query name (comments, strings, fn, pub-fn, etc)"
)]
pub query: Option<String>,
#[arg(long, help = "Custom AST pattern to match")]
pub pattern: Option<String>,
#[arg(long, help = "Delete all matched content", action = clap::ArgAction::SetTrue)]
pub delete: bool,
#[arg(
long,
value_enum,
help = "Transform action: upper, lower, titlecase, squeeze, symbols, normalize"
)]
pub action: Option<ScopeAction>,
#[arg(long, help = "Replace matched content with this text")]
pub replace_with: Option<String>,
#[arg(short = 'g', long, action = clap::ArgAction::Append, help = "Include files matching glob")]
pub include: Vec<String>,
#[arg(long, action = clap::ArgAction::Append, help = "Exclude files matching glob")]
pub exclude: Vec<String>,
#[arg(long, help = "Show what would be done without writing", action = clap::ArgAction::SetTrue)]
pub dry_run: bool,
#[command(flatten)]
pub backup_opts: BackupOpts,
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum ScopeAction {
Upper,
Lower,
Titlecase,
Squeeze,
Symbols,
Normalize,
}
#[tracing::instrument(skip_all, fields(command = "scope"))]
pub fn cmd_scope(
args: &ScopeArgs,
global: &GlobalArgs,
writer: &mut NdjsonWriter<impl Write>,
shutdown: &ShutdownSignal,
defaults: &crate::config::DefaultsSection,
) -> Result<()> {
let start = Instant::now();
let resolved = resolve_backup(&args.backup_opts, defaults);
let lang = parse_language(&args.language)?;
let pattern_strs = resolve_patterns(&args.query, &args.pattern, &args.language)?;
let patterns: Vec<Pattern> = pattern_strs
.iter()
.map(|ps| {
Pattern::try_new(ps, lang).map_err(|e| {
anyhow::anyhow!(AtomwriteError::InvalidInput {
reason: format!("invalid scope pattern: {e}"),
})
})
})
.collect::<Result<Vec<_>>>()?;
let query_name = args.query.clone().unwrap_or_else(|| "custom".to_owned());
let workspace = global.resolve_workspace()?;
let canonical_paths =
crate::commands::path_resolution::resolve_paths_against_workspace(&args.paths, &workspace)?;
let mut walker = ignore::WalkBuilder::new(&canonical_paths[0]);
for p in canonical_paths.iter().skip(1) {
walker.add(p);
}
walker
.hidden(!global.hidden)
.git_ignore(!global.no_gitignore)
.follow_links(global.follow_symlinks);
crate::concurrency::apply_walk_threads(&mut walker, global.threads);
let extensions = crate::lang_utils::lang_extensions(&args.language);
if !extensions.is_empty() {
let mut types_builder = ignore::types::TypesBuilder::new();
for ext in &extensions {
types_builder
.add_def(&format!("lang:*.{ext}"))
.context("invalid extension")?;
}
types_builder.select("lang");
walker.types(types_builder.build().context("build types")?);
}
if !args.include.is_empty() || !args.exclude.is_empty() {
let mut overrides = ignore::overrides::OverrideBuilder::new(&canonical_paths[0]);
for pat in &args.include {
overrides.add(pat)?;
}
for pat in &args.exclude {
overrides.add(&format!("!{pat}"))?;
}
walker.overrides(overrides.build()?);
}
let (tx, rx) =
crossbeam_channel::bounded::<ScopeEvent>(crate::concurrency::event_channel_cap());
let files_visited = Arc::new(AtomicU64::new(0));
let files_modified = Arc::new(AtomicU64::new(0));
let files_skipped = Arc::new(AtomicU64::new(0));
let fv = Arc::clone(&files_visited);
let fm = Arc::clone(&files_modified);
let fs = Arc::clone(&files_skipped);
let delete = args.delete;
let action = args.action;
let replace_with: Option<Arc<str>> = args.replace_with.clone().map(Into::into);
let dry_run = args.dry_run;
let backup = resolved.backup;
let ws: Arc<std::path::Path> = Arc::from(workspace.as_path());
let qn: Arc<str> = query_name.into();
let lang_name: Arc<str> = args.language.clone().into();
let max_size = global.effective_max_filesize();
let shutdown_flag = shutdown.flag();
let patterns = Arc::new(patterns);
let walker_thread = std::thread::spawn(move || {
walker.build_parallel().run(|| {
let patterns = Arc::clone(&patterns);
let tx = tx.clone();
let fv = Arc::clone(&fv);
let fm = Arc::clone(&fm);
let fs = Arc::clone(&fs);
let replace_with = replace_with.clone();
let ws = Arc::clone(&ws);
let qn = Arc::clone(&qn);
let lang_name = Arc::clone(&lang_name);
let shutdown_flag = Arc::clone(&shutdown_flag);
Box::new(move |entry| {
if shutdown_flag.load(Ordering::Acquire) {
return ignore::WalkState::Quit;
}
let entry = match entry {
Ok(e) => e,
Err(_) => return ignore::WalkState::Continue,
};
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
return ignore::WalkState::Continue;
}
fv.fetch_add(1, Ordering::Relaxed);
let path = entry.path().to_path_buf();
let _span = tracing::debug_span!("process_file", path = %path.display()).entered();
let file_start = Instant::now();
let content = match crate::file_io::read_file_string(&path, max_size) {
Ok(c) => c,
Err(_) => {
fs.fetch_add(1, Ordering::Relaxed);
return ignore::WalkState::Continue;
}
};
let grep = AstGrep::new(&content, lang);
let root = grep.root();
let matches: Vec<_> = patterns.iter().flat_map(|p| root.find_all(p)).collect();
if matches.is_empty() {
fs.fetch_add(1, Ordering::Relaxed);
return ignore::WalkState::Continue;
}
let scopes_matched = matches.len() as u64;
let is_read_only = !delete && action.is_none() && replace_with.is_none();
if is_read_only {
fm.fetch_add(1, Ordering::Relaxed);
let checksum = checksum::hash_bytes(content.as_bytes());
let _ = tx.send(ScopeEvent::Result(ScopeResult {
r#type: "scoped",
path: path.display().to_string(),
language: lang_name.to_string(),
query: qn.to_string(),
action: "none".to_owned(),
scopes_matched,
bytes_before: content.len() as u64,
bytes_after: content.len() as u64,
checksum_before: checksum.clone(),
checksum_after: checksum,
elapsed_ms: file_start.elapsed().as_millis() as u64,
}));
return ignore::WalkState::Continue;
}
let mut edits: Vec<(usize, usize, String)> = Vec::with_capacity(matches.len());
for m in &matches {
let range = m.range();
let (effective_start, effective_end) = if delete {
expand_to_full_line(&content, range.start, range.end)
} else {
(range.start, range.end)
};
let matched_text = &content[effective_start..effective_end];
let replacement =
apply_scope_action(matched_text, delete, action, replace_with.as_deref());
edits.push((effective_start, effective_end, replacement.into_owned()));
}
edits.sort_by_key(|e| std::cmp::Reverse(e.0));
let checksum_before = checksum::hash_bytes(content.as_bytes());
let bytes_before = content.len() as u64;
let mut content = content; for (s, e, replacement) in &edits {
content.replace_range(*s..*e, replacement);
}
let checksum_after = checksum::hash_bytes(content.as_bytes());
if checksum_before == checksum_after {
fs.fetch_add(1, Ordering::Relaxed);
return ignore::WalkState::Continue;
}
fm.fetch_add(1, Ordering::Relaxed);
if !dry_run {
let opts = AtomicWriteOptions {
backup,
..Default::default()
};
if let Err(e) = atomic_write(&path, content.as_bytes(), &opts, &ws) {
tracing::warn!(path = %path.display(), error = %e, "scope write failed");
return ignore::WalkState::Continue;
}
}
let action_name = if delete {
"delete"
} else if replace_with.is_some() {
"replace"
} else {
match action {
Some(ScopeAction::Upper) => "upper",
Some(ScopeAction::Lower) => "lower",
Some(ScopeAction::Titlecase) => "titlecase",
Some(ScopeAction::Squeeze) => "squeeze",
Some(ScopeAction::Symbols) => "symbols",
Some(ScopeAction::Normalize) => "normalize",
None => "none",
}
};
let _ = tx.send(ScopeEvent::Result(ScopeResult {
r#type: "scoped",
path: path.display().to_string(),
language: lang_name.to_string(),
query: qn.to_string(),
action: action_name.to_owned(),
scopes_matched,
bytes_before,
bytes_after: content.len() as u64,
checksum_before,
checksum_after,
elapsed_ms: file_start.elapsed().as_millis() as u64,
}));
ignore::WalkState::Continue
})
});
});
for event in rx {
if shutdown.is_shutdown() {
break;
}
match event {
ScopeEvent::Result(r) => writer.write_event(&r)?,
}
}
if let Err(panic_payload) = walker_thread.join() {
std::panic::resume_unwind(panic_payload);
}
let matched = files_modified.load(Ordering::Relaxed);
let is_read_only = !args.delete && args.action.is_none() && args.replace_with.is_none();
let files_modified_summary = if is_read_only {
None
} else {
crate::commands::summary_metrics::files_modified_for_summary(matched, args.dry_run)
};
let summary = Summary {
r#type: "summary",
files_visited: files_visited.load(Ordering::Relaxed),
files_matched: matched,
files_modified: files_modified_summary,
files_skipped: Some(files_skipped.load(Ordering::Relaxed)),
total_matches: None,
total_replacements: None,
elapsed_ms: start.elapsed().as_millis() as u64,
};
writer.write_event(&summary)?;
if files_modified.load(Ordering::Relaxed) == 0
&& files_skipped.load(Ordering::Relaxed) == files_visited.load(Ordering::Relaxed)
{
return Err(crate::error::AtomwriteError::NoMatches.into());
}
Ok(())
}
include!("scope_actions.inc.rs");
include!("queries.inc.rs");
include!("tests.inc.rs");