use std::error::Error;
use std::fmt::Write as _; use std::fs::{self, File};
use std::io::{self, IsTerminal, Read, Write, stdout};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::{env, fmt};
use anyhow::{Context, Result};
use colored::Colorize;
use ignore::{WalkBuilder, WalkState};
use itertools::Itertools;
use log::{LevelFilter, debug, error, info, trace, warn};
use pathdiff::diff_paths;
#[cfg(feature = "german")]
use srgn::actions::German;
use srgn::actions::{
Action, ActionError, Deletion, Lower, Normalization, Replacement, Style, Titlecase, Upper,
};
#[cfg(feature = "symbols")]
use srgn::actions::{Symbols, SymbolsInversion};
use srgn::iterext::ParallelZipExt;
use srgn::scoping::Scoper;
use srgn::scoping::langs::LanguageScoper;
use srgn::scoping::literal::Literal;
use srgn::scoping::regex::Regex;
use srgn::scoping::scope::Scope;
use srgn::scoping::view::ScopedViewBuilder;
use tree_sitter::QueryError as TSQueryError;
type ScoperList = Vec<Box<dyn LanguageScoper>>;
#[expect(clippy::too_many_lines)] #[expect(clippy::cognitive_complexity)]
fn main() -> Result<()> {
let args = cli::Args::init();
let level_filter = level_filter_from_env_and_verbosity(args.options.additional_verbosity);
env_logger::Builder::new()
.filter_level(level_filter)
.format_timestamp_micros() .init();
info!("Launching app with args: {args:?}");
let cli::Args {
scope,
shell,
composable_actions,
standalone_actions,
mut options,
languages_scopes,
#[cfg(feature = "german")]
german_options,
} = args;
if let Some(shell) = shell {
debug!("Generating completions file for {shell:?}.");
cli::print_completions(shell, &mut cli::Args::command());
debug!("Done generating completions file, exiting.");
return Ok(());
}
let standalone_action = standalone_actions.into();
debug!("Assembling scopers.");
let general_scoper = get_general_scoper(&options, scope)?;
let language_scopers = languages_scopes
.compile_query_sources_to_scopes()?
.map(Arc::new);
debug!("Done assembling scopers.");
let mut actions = {
debug!("Assembling actions.");
let mut actions = assemble_common_actions(&composable_actions, standalone_action)?;
#[cfg(feature = "symbols")]
if composable_actions.symbols {
if options.invert {
actions.push(Box::<SymbolsInversion>::default());
debug!("Loaded action: SymbolsInversion");
} else {
actions.push(Box::<Symbols>::default());
debug!("Loaded action: Symbols");
}
}
#[cfg(feature = "german")]
if composable_actions.german {
actions.push(Box::new(German::new(
german_options.german_prefer_original,
german_options.german_naive,
)));
debug!("Loaded action: German");
}
debug!("Done assembling actions.");
actions
};
let is_stdout_tty = match options.stdout_detection {
cli::StdoutDetection::Auto => {
debug!("Detecting if stdout is a TTY");
stdout().is_terminal()
}
cli::StdoutDetection::ForceTTY => true,
cli::StdoutDetection::ForcePipe => false,
};
info!("Treating stdout as tty: {is_stdout_tty}.");
let is_stdin_readable = match options.stdin_detection {
cli::StdinDetection::Auto => {
debug!("Detecting if stdin is readable");
grep_cli::is_readable_stdin()
}
cli::StdinDetection::ForceReadable => true,
cli::StdinDetection::ForceUnreadable => false,
};
info!("Treating stdin as readable: {is_stdin_readable}.");
let input = match (
is_stdin_readable,
options.glob.clone(),
&language_scopers,
) {
(true, None, _)
| (false, None, None) => Input::Stdin,
(true, Some(..), _) => {
error!("Detected stdin, and request for files: will use stdin and ignore files.");
Input::Stdin
}
(false, Some(pattern), _) => Input::WalkOn(Box::new(move |path| {
let res = pattern.matches_path(path);
trace!("Path '{}' matches: {}.", path.display(), res);
res
})),
(false, None, Some(language_scopers)) => {
let language_scopers = Arc::clone(language_scopers);
Input::WalkOn(Box::new(move |path| {
let res = language_scopers
.iter()
.map(|s| s.is_valid_path(path))
.all_equal_value()
.expect("all language scopers to agree on path validity");
trace!(
"Language scoper considers path '{}' valid: {}",
path.display(),
res
);
res
}))
},
};
let search_mode = actions.is_empty() && language_scopers.is_some() || options.dry_run;
if search_mode {
info!("Will use search mode.");
let style = if options.dry_run {
Style::green_bold() } else {
Style::red_bold() };
if is_stdout_tty {
actions.push(Box::new(style));
}
options.only_matching = true;
options.line_numbers = true;
options.fail_none = true;
}
if actions.is_empty() && !search_mode {
error!(
"No actions specified, and not in search mode. Will return input unchanged, if any."
);
}
let pipeline = if options.dry_run {
let action: Box<dyn Action> = Box::new(Style::red_bold());
let color_only = vec![action];
vec![color_only, actions]
} else {
vec![actions]
};
let pipeline: Vec<&[Box<dyn Action>]> = pipeline.iter().map(Vec::as_slice).collect();
let language_scopers = language_scopers.unwrap_or_default();
match (input, options.sorted) {
(Input::Stdin, _ ) => {
info!("Will read from stdin and write to stdout, applying actions.");
handle_actions_on_stdin(
&options,
standalone_action,
general_scoper.as_ref(),
&language_scopers,
&pipeline,
is_stdout_tty,
)?;
}
(Input::WalkOn(validator), false) => {
info!("Will walk file tree, applying actions.");
handle_actions_on_many_files_threaded(
&options,
standalone_action,
&validator,
general_scoper.as_ref(),
&language_scopers,
&pipeline,
search_mode,
options.threads.map_or_else(
|| std::thread::available_parallelism().map_or(1, std::num::NonZero::get),
std::num::NonZero::get,
),
is_stdout_tty,
)?;
}
(Input::WalkOn(validator), true) => {
info!("Will walk file tree, applying actions.");
handle_actions_on_many_files_sorted(
&options,
standalone_action,
&validator,
general_scoper.as_ref(),
&language_scopers,
&pipeline,
search_mode,
is_stdout_tty,
)?;
}
}
info!("Done, exiting");
Ok(())
}
type Validator = Box<dyn Fn(&Path) -> bool + Send + Sync>;
enum Input {
Stdin,
WalkOn(Validator),
}
#[derive(Clone, Copy, Debug)]
enum StandaloneAction {
Delete,
Squeeze,
None,
}
type Pipeline<'a> = &'a [&'a [Box<dyn Action>]];
fn handle_actions_on_stdin(
global_options: &cli::GlobalOptions,
standalone_action: StandaloneAction,
general_scoper: &dyn Scoper,
language_scopers: &[Box<dyn LanguageScoper>],
pipeline: Pipeline<'_>,
is_stdout_tty: bool,
) -> Result<(), ProgramError> {
info!("Will use stdin to stdout.");
let mut source = String::new();
io::stdin().lock().read_to_string(&mut source)?;
let mut destination = String::with_capacity(source.len());
apply(
global_options,
standalone_action,
&source,
&mut destination,
general_scoper,
language_scopers,
pipeline,
None, is_stdout_tty,
)?;
stdout().lock().write_all(destination.as_bytes())?;
Ok(())
}
#[expect(clippy::too_many_arguments)] fn handle_actions_on_many_files_sorted(
global_options: &cli::GlobalOptions,
standalone_action: StandaloneAction,
validator: &Validator,
general_scoper: &dyn Scoper,
language_scopers: &[Box<dyn LanguageScoper>],
pipeline: Pipeline<'_>,
search_mode: bool,
is_stdout_tty: bool,
) -> Result<(), ProgramError> {
let root = env::current_dir()?;
info!(
"Will walk file tree sequentially, in sorted order, starting from: {:?}",
root.canonicalize()
);
let mut n_files_processed: usize = 0;
let mut n_files_seen: usize = 0;
for entry in WalkBuilder::new(&root)
.hidden(!global_options.hidden)
.git_ignore(!global_options.gitignored)
.sort_by_file_path(Ord::cmp)
.build()
{
match entry {
Ok(entry) => {
let path = entry.path();
let res = process_path(
global_options,
standalone_action,
path,
&root,
validator,
general_scoper,
language_scopers,
pipeline,
search_mode,
is_stdout_tty,
);
n_files_seen += match res {
Err(PathProcessingError::NotAFile | PathProcessingError::InvalidFile) => 0,
_ => 1,
};
n_files_processed += match res {
Ok(()) => 1,
Err(PathProcessingError::NotAFile | PathProcessingError::InvalidFile) => 0,
Err(PathProcessingError::ApplicationError(ApplicationError::SomeInScope))
if global_options.fail_any =>
{
info!("Match at {}, exiting early", path.display());
return Err(ProgramError::SomethingProcessed);
}
#[expect(clippy::match_same_arms)]
Err(PathProcessingError::ApplicationError(
ApplicationError::NoneInScope | ApplicationError::SomeInScope,
)) => 0,
Err(PathProcessingError::IoError(e, _))
if e.kind() == io::ErrorKind::BrokenPipe && search_mode =>
{
trace!("Detected broken pipe, stopping search.");
break;
}
Err(PathProcessingError::IoError(e, _))
if e.kind() == io::ErrorKind::InvalidData =>
{
warn!("File contains unreadable data (binary? invalid utf-8?), skipped: {}", path.display());
0
}
Err(
e @ (PathProcessingError::ApplicationError(ApplicationError::ActionError(
..,
))
| PathProcessingError::IoError(..)),
) => {
if search_mode {
error!("Error walking at {}: {}", path.display(), e);
0
} else {
error!("Aborting walk at {} due to: {}", path.display(), e);
return Err(e.into());
}
}
}
}
Err(e) => {
if search_mode {
error!("Error walking: {e}");
} else {
error!("Aborting walk due to: {e}");
return Err(e.into());
}
}
}
}
info!("Saw {n_files_seen} items");
info!("Processed {n_files_processed} files");
if n_files_seen == 0 && global_options.fail_no_files {
Err(ProgramError::NoFilesFound)
} else if n_files_processed == 0 && global_options.fail_none {
Err(ProgramError::NothingProcessed)
} else {
Ok(())
}
}
#[expect(clippy::too_many_lines)]
#[expect(clippy::too_many_arguments)]
fn handle_actions_on_many_files_threaded(
global_options: &cli::GlobalOptions,
standalone_action: StandaloneAction,
validator: &Validator,
general_scoper: &dyn Scoper,
language_scopers: &[Box<dyn LanguageScoper>],
pipeline: Pipeline<'_>,
search_mode: bool,
n_threads: usize,
is_stdout_tty: bool,
) -> Result<(), ProgramError> {
let root = env::current_dir()?;
info!(
"Will walk file tree using {:?} thread(s), processing in arbitrary order, starting from: {:?}",
n_threads,
root.canonicalize()
);
let n_files_processed = Arc::new(Mutex::new(0usize));
let n_files_seen = Arc::new(Mutex::new(0usize));
let err: Arc<Mutex<Option<ProgramError>>> = Arc::new(Mutex::new(None));
WalkBuilder::new(&root)
.threads(
n_threads,
)
.hidden(!global_options.hidden)
.git_ignore(!global_options.gitignored)
.build_parallel()
.run(|| {
Box::new(|entry| match entry {
Ok(entry) => {
let path = entry.path();
let res = process_path(
global_options,
standalone_action,
path,
&root,
validator,
general_scoper,
language_scopers,
pipeline,
search_mode,
is_stdout_tty,
);
match res {
Err(PathProcessingError::NotAFile | PathProcessingError::InvalidFile) => (),
_ => *n_files_seen.lock().unwrap() += 1,
}
match res {
Ok(()) => {
*n_files_processed.lock().unwrap() += 1;
WalkState::Continue
}
Err(PathProcessingError::NotAFile | PathProcessingError::InvalidFile) => {
WalkState::Continue
}
Err(
e
@ PathProcessingError::ApplicationError(ApplicationError::SomeInScope),
) if global_options.fail_any => {
info!("Match at {}, exiting early", path.display());
*err.lock().unwrap() = Some(e.into());
WalkState::Quit
}
Err(PathProcessingError::ApplicationError(
ApplicationError::NoneInScope | ApplicationError::SomeInScope,
)) => WalkState::Continue,
Err(PathProcessingError::IoError(e, _))
if e.kind() == io::ErrorKind::BrokenPipe && search_mode =>
{
trace!("Detected broken pipe, stopping search.");
WalkState::Quit
}
Err(PathProcessingError::IoError(e, _))
if e.kind() == io::ErrorKind::InvalidData =>
{
warn!("File contains unreadable data (binary? invalid utf-8?), skipped: {}", path.display());
WalkState::Continue
}
Err(
e @ (PathProcessingError::ApplicationError(..)
| PathProcessingError::IoError(..)),
) => {
error!("Error walking at {} due to: {}", path.display(), e);
if search_mode {
WalkState::Continue
} else {
error!("Aborting walk for safety");
*err.lock().unwrap() = Some(e.into());
WalkState::Quit
}
}
}
}
Err(e) => {
if search_mode {
error!("Error walking: {e}");
WalkState::Continue
} else {
error!("Aborting walk due to: {e}");
*err.lock().unwrap() = Some(e.into());
WalkState::Quit
}
}
})
});
let error = err.lock().unwrap().take();
if let Some(e) = error {
return Err(e);
}
let n_files_seen = *n_files_seen.lock().unwrap();
info!("Saw {n_files_seen} items");
let n_files_processed = *n_files_processed.lock().unwrap();
info!("Processed {n_files_processed} files");
if n_files_seen == 0 && global_options.fail_no_files {
Err(ProgramError::NoFilesFound)
} else if n_files_processed == 0 && global_options.fail_none {
Err(ProgramError::NothingProcessed)
} else {
Ok(())
}
}
#[expect(clippy::too_many_arguments)]
fn process_path(
global_options: &cli::GlobalOptions,
standalone_action: StandaloneAction,
path: &Path,
root: &Path,
validator: &Validator,
general_scoper: &dyn Scoper,
language_scopers: &[Box<dyn LanguageScoper>],
pipeline: Pipeline<'_>,
search_mode: bool,
is_stdout_tty: bool,
) -> std::result::Result<(), PathProcessingError> {
if !path.is_file() {
trace!("Skipping path (not a file): {}", path.display());
return Err(PathProcessingError::NotAFile);
}
let path = diff_paths(path, root).expect("started walk at root, so relative to root works");
if !validator(&path) {
trace!("Skipping path (invalid): {}", path.display());
return Err(PathProcessingError::InvalidFile);
}
debug!("Processing path: {}", path.display());
let (new_contents, filesize, changed) = {
let mut file = File::open(&path)?;
let filesize = file.metadata().map_or(0, |m| m.len());
let mut source =
String::with_capacity(filesize.try_into().unwrap_or( 0));
file.read_to_string(&mut source)?;
let mut destination = String::with_capacity(source.len());
let changed = apply(
global_options,
standalone_action,
&source,
&mut destination,
general_scoper,
language_scopers,
pipeline,
Some(&path),
is_stdout_tty,
)?;
(destination, filesize, changed)
};
let mut stdout = stdout().lock();
if search_mode {
if !new_contents.is_empty() {
if is_stdout_tty {
writeln!(
stdout,
"{}\n{}",
path.display().to_string().magenta(),
&new_contents
)?;
} else {
write!(stdout, "{}", &new_contents)?;
}
}
} else {
if filesize > 0 && new_contents.is_empty() {
error!(
"Failsafe triggered: file {} is nonempty ({} bytes), but new contents are empty. Will not wipe file.",
path.display(),
filesize
);
return Err(io::Error::other("attempt to wipe non-empty file (failsafe guard)").into());
}
if changed {
debug!("Got new file contents, writing to file: {}", path.display());
assert!(
!global_options.dry_run,
"Dry running, but attempted to write file!"
);
fs::write(&path, new_contents.as_bytes())?;
writeln!(stdout, "{}", path.display())?;
} else {
debug!(
"Skipping writing file anew (nothing changed): {}",
path.display()
);
}
debug!("Done processing file: {}", path.display());
}
Ok(())
}
#[expect(clippy::too_many_arguments)] fn apply(
global_options: &cli::GlobalOptions,
standalone_action: StandaloneAction,
source: &str,
destination: &mut String,
general_scoper: &dyn Scoper,
language_scopers: &[Box<dyn LanguageScoper>],
pipeline: Pipeline<'_>,
file_path: Option<&Path>,
is_stdout_tty: bool,
) -> std::result::Result<bool, ApplicationError> {
debug!("Building view.");
let mut builder = ScopedViewBuilder::new(source);
if global_options.join_language_scopes {
builder.explode(&language_scopers);
} else {
for scoper in language_scopers {
builder.explode(scoper);
}
}
builder.explode(general_scoper);
let mut view = builder.build();
debug!("Done building view: {view:?}");
if global_options.fail_none && !view.has_any_in_scope() {
return Err(ApplicationError::NoneInScope);
}
if global_options.fail_any && view.has_any_in_scope() {
return Err(ApplicationError::SomeInScope);
}
debug!("Applying actions to view.");
if matches!(standalone_action, StandaloneAction::Squeeze) {
view.squeeze();
}
let mut views = vec![view; pipeline.len()];
for (actions, view) in pipeline.iter().zip_eq(&mut views) {
for action in *actions {
view.map_with_context(action)?;
}
}
debug!("Writing to destination.");
let line_based = global_options.only_matching || global_options.line_numbers;
if line_based {
let line_based_views = views.iter().map(|v| v.lines().into_iter()).collect_vec();
for (i, lines) in line_based_views.into_iter().parallel_zip().enumerate() {
let i = i + 1;
for line in lines {
if global_options.only_matching && !line.has_any_in_scope() {
continue;
}
let sep = ":";
if !is_stdout_tty {
if let Some(path) = file_path {
write!(destination, "{}{sep}", path.display())
.expect("infallible on String (are we OOM?)");
} else {
write!(destination, "(stdin){sep}")
.expect("infallible on String (are we OOM?)");
}
}
if global_options.line_numbers {
write!(
destination,
"{}{sep}",
if is_stdout_tty {
i.to_string().green().to_string()
} else {
i.to_string()
}
)
.expect("infallible on String (are we OOM?)");
}
if !is_stdout_tty {
let mut col = 0;
let mut ranges = Vec::new();
for scope in &line.scopes().0 {
let s: &str = scope.into();
let end = col + s.len();
if let Scope::In(..) = scope.0 {
ranges.push(format!("{col}-{end}"));
}
col = end;
}
write!(destination, "{ranges}{sep}", ranges = ranges.join(";"))
.expect("infallible on String (are we OOM?)");
}
destination.push_str(&line.to_string());
}
}
} else {
assert_eq!(
views.len(),
1,
"Multiple views at this stage make no sense."
);
for view in views {
destination.push_str(&view.to_string());
}
}
debug!("Done writing to destination.");
Ok(source != *destination)
}
#[derive(Debug)]
enum ProgramError {
PathProcessingError(PathProcessingError),
ApplicationError(ApplicationError),
NoFilesFound,
NothingProcessed,
SomethingProcessed,
IoError(io::Error),
IgnoreError(ignore::Error),
QueryError(TSQueryError),
}
impl fmt::Display for ProgramError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PathProcessingError(e) => write!(f, "Error processing path: {e}"),
Self::ApplicationError(e) => write!(f, "Error applying: {e}"),
Self::NoFilesFound => write!(f, "No files found"),
Self::NothingProcessed => write!(f, "No input was in scope"),
Self::SomethingProcessed => write!(f, "Some input was in scope"),
Self::IoError(e) => write!(f, "I/O error: {e}"),
Self::IgnoreError(e) => write!(f, "Error walking files: {e}"),
Self::QueryError(e) => {
write!(f, "Error occurred while creating a tree-sitter query: {e}")
}
}
}
}
impl From<ApplicationError> for ProgramError {
fn from(err: ApplicationError) -> Self {
Self::ApplicationError(err)
}
}
impl From<PathProcessingError> for ProgramError {
fn from(err: PathProcessingError) -> Self {
Self::PathProcessingError(err)
}
}
impl From<io::Error> for ProgramError {
fn from(err: io::Error) -> Self {
Self::IoError(err)
}
}
impl From<ignore::Error> for ProgramError {
fn from(err: ignore::Error) -> Self {
Self::IgnoreError(err)
}
}
impl From<TSQueryError> for ProgramError {
fn from(err: TSQueryError) -> Self {
Self::QueryError(err)
}
}
impl Error for ProgramError {}
#[derive(Debug)]
enum ApplicationError {
SomeInScope,
NoneInScope,
ActionError(ActionError),
}
impl fmt::Display for ApplicationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SomeInScope => write!(f, "Some input was in scope"),
Self::NoneInScope => write!(f, "No input was in scope"),
Self::ActionError(e) => write!(f, "Error in an action: {e}"),
}
}
}
impl From<ActionError> for ApplicationError {
fn from(err: ActionError) -> Self {
Self::ActionError(err)
}
}
impl Error for ApplicationError {}
#[derive(Debug)]
enum PathProcessingError {
IoError(io::Error, Option<PathBuf>),
NotAFile,
InvalidFile,
ApplicationError(ApplicationError),
}
impl fmt::Display for PathProcessingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::IoError(e, None) => write!(f, "I/O error: {e}"),
Self::IoError(e, Some(path)) => write!(f, "I/O error at path {}: {e}", path.display()),
Self::NotAFile => write!(f, "Item is not a file"),
Self::InvalidFile => write!(f, "Item is not a valid file"),
Self::ApplicationError(e) => write!(f, "Error applying: {e}"),
}
}
}
impl From<io::Error> for PathProcessingError {
fn from(err: io::Error) -> Self {
Self::IoError(err, None)
}
}
impl From<ApplicationError> for PathProcessingError {
fn from(err: ApplicationError) -> Self {
Self::ApplicationError(err)
}
}
impl Error for PathProcessingError {}
fn get_general_scoper(options: &cli::GlobalOptions, scope: String) -> Result<Box<dyn Scoper>> {
Ok(if options.literal_string {
Box::new(Literal::try_from(scope).context("Failed building literal string")?)
} else {
Box::new(Regex::try_from(scope).context("Failed building regex")?)
})
}
fn assemble_common_actions(
composable_actions: &cli::ComposableActions,
standalone_actions: StandaloneAction,
) -> Result<Vec<Box<dyn Action>>> {
let mut actions: Vec<Box<dyn Action>> = Vec::new();
if let Some(replacement) = composable_actions.replace.clone() {
actions.push(Box::new(
Replacement::try_from(replacement).context("Failed building replacement string")?,
));
debug!("Loaded action: Replacement");
}
if matches!(standalone_actions, StandaloneAction::Delete) {
actions.push(Box::<Deletion>::default());
debug!("Loaded action: Deletion");
}
if composable_actions.upper {
actions.push(Box::<Upper>::default());
debug!("Loaded action: Upper");
}
if composable_actions.lower {
actions.push(Box::<Lower>::default());
debug!("Loaded action: Lower");
}
if composable_actions.titlecase {
actions.push(Box::<Titlecase>::default());
debug!("Loaded action: Titlecase");
}
if composable_actions.normalize {
actions.push(Box::<Normalization>::default());
debug!("Loaded action: Normalization");
}
Ok(actions)
}
fn level_filter_from_env_and_verbosity(additional_verbosity: u8) -> LevelFilter {
let available = LevelFilter::iter().collect::<Vec<_>>();
let default = env_logger::Builder::from_default_env().build().filter();
let mut level = default as usize; level += additional_verbosity as usize;
available.get(level).copied().unwrap_or_else(|| {
eprintln!(
"Requested additional verbosity on top of env default exceeds maximum, will use maximum"
);
available
.last()
.copied()
.expect("At least one level must be available")
})
}
mod cli {
use std::ffi::OsStr;
use std::fmt::Write;
use std::num::NonZero;
use std::path::PathBuf;
use std::{fs, io};
use clap::builder::{ArgPredicate, StyledStr};
use clap::error::ErrorKind;
use clap::{ArgAction, Command, CommandFactory, Parser, ValueEnum};
use clap_complete::{Generator, Shell, generate};
use log::info;
use regex::bytes::Regex;
use srgn::GLOBAL_SCOPE;
use srgn::scoping::langs::{
LanguageScoper, QuerySource, TreeSitterRegex, c, csharp, go, hcl, python, rust, typescript,
};
use tree_sitter::QueryError as TSQueryError;
use crate::{ProgramError, StandaloneAction};
#[derive(Parser, Debug)]
#[command(
author,
version,
about,
long_about = None,
// Really dumb to hard-code, but we need deterministic output for README tests
// to remain stable, and this is probably both a solid default *and* plays with
// this very source file which is wrapped at *below* that, so it fits and clap
// doesn't touch our manually formatted doc strings anymore.
term_width = 90,
)]
pub struct Args {
#[arg(
value_name = "SCOPE",
default_value = GLOBAL_SCOPE,
verbatim_doc_comment,
default_value_if("literal_string", ArgPredicate::IsPresent, None)
)]
pub(super) scope: String,
#[arg(long = "completions", value_enum, verbatim_doc_comment)]
pub(super) shell: Option<Shell>,
#[command(flatten)]
pub(super) composable_actions: ComposableActions,
#[command(flatten)]
pub(super) standalone_actions: StandaloneActions,
#[command(flatten)]
pub(super) options: GlobalOptions,
#[command(flatten)]
pub(super) languages_scopes: LanguageScopes,
#[cfg(feature = "german")]
#[command(flatten)]
pub(super) german_options: GermanOptions,
}
pub fn print_completions<G: Generator>(generator: G, cmd: &mut Command) {
generate(
generator,
cmd,
cmd.get_name().to_string(),
&mut io::stdout(),
);
}
#[derive(Debug, Clone, ValueEnum)]
pub enum StdinDetection {
Auto,
ForceReadable,
ForceUnreadable,
}
#[derive(Debug, Clone, ValueEnum)]
pub enum StdoutDetection {
Auto,
ForceTTY,
ForcePipe,
}
#[derive(Parser, Debug)]
#[group(required = false, multiple = true)]
#[command(next_help_heading = "Options (global)")]
#[expect(clippy::struct_excessive_bools)]
pub struct GlobalOptions {
#[arg(short('G'), long, verbatim_doc_comment, alias = "files")]
pub glob: Option<glob::Pattern>,
#[arg(long, verbatim_doc_comment, alias = "fail-empty-glob")]
pub fail_no_files: bool,
#[arg(long, verbatim_doc_comment)]
pub dry_run: bool,
#[cfg(feature = "symbols")]
#[arg(short, long, env, requires = "symbols", verbatim_doc_comment)]
pub invert: bool,
#[arg(short('L'), long, env, verbatim_doc_comment)]
pub literal_string: bool,
#[arg(long, verbatim_doc_comment)]
pub fail_any: bool,
#[arg(long, verbatim_doc_comment)]
pub fail_none: bool,
#[arg(short('j'), long, verbatim_doc_comment)]
pub join_language_scopes: bool,
#[arg(long, hide(true), verbatim_doc_comment)]
pub line_numbers: bool,
#[arg(long, hide(true), verbatim_doc_comment)]
pub only_matching: bool,
#[arg(short('H'), long, verbatim_doc_comment)]
pub hidden: bool,
#[arg(long, verbatim_doc_comment)]
pub gitignored: bool,
#[arg(long, verbatim_doc_comment)]
pub sorted: bool,
#[arg(
long,
value_enum,
default_value_t=StdinDetection::Auto,
verbatim_doc_comment
)]
pub stdin_detection: StdinDetection,
#[arg(
long,
value_enum,
default_value_t=StdoutDetection::Auto,
verbatim_doc_comment
)]
pub stdout_detection: StdoutDetection,
#[arg(long, verbatim_doc_comment)]
pub threads: Option<NonZero<usize>>,
#[arg(
short = 'v',
long = "verbose",
action = ArgAction::Count,
verbatim_doc_comment
)]
pub additional_verbosity: u8,
}
#[derive(Parser, Debug)]
#[group(required = false, multiple = true)]
#[command(next_help_heading = "Composable Actions")]
#[expect(clippy::struct_excessive_bools)]
pub struct ComposableActions {
#[arg(value_name = "REPLACEMENT", env, verbatim_doc_comment, last = true)]
pub replace: Option<String>,
#[arg(short, long, env, verbatim_doc_comment)]
pub upper: bool,
#[arg(short, long, env, verbatim_doc_comment)]
pub lower: bool,
#[arg(short, long, env, verbatim_doc_comment)]
pub titlecase: bool,
#[arg(short, long, env, verbatim_doc_comment)]
pub normalize: bool,
#[cfg(feature = "german")]
#[arg(
short,
long,
verbatim_doc_comment,
// `true` as string is very ugly, but there's no other way?
default_value_if("german-opts", ArgPredicate::IsPresent, "true")
)]
pub german: bool,
#[cfg(feature = "symbols")]
#[arg(short = 'S', long, verbatim_doc_comment)]
pub symbols: bool,
}
#[derive(Parser, Debug)]
#[group(required = false, multiple = false)]
#[command(next_help_heading = "Standalone Actions (only usable alone)")]
pub struct StandaloneActions {
#[arg(
short,
long,
requires = "scope",
conflicts_with = stringify!(ComposableActions),
verbatim_doc_comment
)]
pub delete: bool,
#[arg(
short,
long,
visible_alias("squeeze-repeats"),
env,
requires = "scope",
verbatim_doc_comment
)]
pub squeeze: bool,
}
impl From<StandaloneActions> for StandaloneAction {
fn from(value: StandaloneActions) -> Self {
if value.delete {
Self::Delete
} else if value.squeeze {
Self::Squeeze
} else {
Self::None
}
}
}
const TREE_SITTER_QUERY_VALUE: &str = "TREE-SITTER-QUERY-VALUE";
const TREE_SITTER_QUERY_FILENAME: &str = "TREE-SITTER-QUERY-FILENAME";
const NAMED_ITEM_PATTERN_SEPARATOR: &str = "~";
macro_rules! impl_lang_scopes {
($(($lang_flag:ident, $lang_query_flag:ident, $lang_query_file_flag:ident, $lang_scope:ident),)+) => {
#[derive(Parser, Debug)]
#[group(required = false, multiple = false)]
#[command(next_help_heading = "Language scopes")]
pub struct LanguageScopes {
$(
#[command(flatten)]
$lang_flag: Option<$lang_scope>,
)+
}
impl LanguageScopes {
pub(super) fn compile_query_sources_to_scopes(self) -> Result<Option<crate::ScoperList>, ProgramError> {
assert_exclusive_lang_scope(&[
$(self.$lang_flag.is_some(),)+
]);
$(
if let Some(s) = self.$lang_flag {
let s = accumulate_scopes::<$lang_flag::CompiledQuery, _>(s.$lang_flag, s.$lang_query_flag, s.$lang_query_file_flag,)?;
return Ok(Some(s));
}
)+
Ok(None)
}
}
};
}
impl_lang_scopes!(
(c, c_query, c_query_file, CScope),
(csharp, csharp_query, csharp_query_file, CSharpScope),
(go, go_query, go_query_file, GoScope),
(hcl, hcl_query, hcl_query_file, HclScope),
(python, python_query, python_query_file, PythonScope),
(rust, rust_query, rust_query_file, RustScope),
(
typescript,
typescript_query,
typescript_query_file,
TypeScriptScope
),
);
fn assert_exclusive_lang_scope(fields_set: &[bool]) {
let set_fields_count = fields_set.iter().filter(|b| **b).count();
if set_fields_count > 1 {
let mut cmd = Args::command();
cmd.error(
ErrorKind::ArgumentConflict,
"Can only use one language at a time.",
)
.exit();
}
}
fn accumulate_scopes<CQ, PQ>(
prepared_queries: Vec<PQ>,
literal_queries: Vec<QueryLiteral>,
file_queries: Vec<PathBuf>,
) -> Result<super::ScoperList, ProgramError>
where
CQ: LanguageScoper + TryFrom<QuerySource, Error = TSQueryError> + 'static,
PQ: Into<CQ>,
{
let mut scopers: crate::ScoperList = Vec::new();
for prepared_query in prepared_queries {
let compiled_query = prepared_query.into();
scopers.push(Box::new(compiled_query));
}
for query_literal in literal_queries {
let query_source = query_literal.into();
let compiled_query = CQ::try_from(query_source)?;
scopers.push(Box::new(compiled_query));
}
for file_query in file_queries {
let query_source = read_query_from_file(file_query)?;
let compiled_query = CQ::try_from(query_source)?;
scopers.push(Box::new(compiled_query));
}
Ok(scopers)
}
fn read_query_from_file(path: PathBuf) -> io::Result<QuerySource> {
info!("Reading query from file at '{}'", path.display());
let s = fs::read_to_string(path)?;
Ok(QuerySource::from(s))
}
macro_rules! define_prepared_query_parser {
(
$parser_name:ident,
$query_type:path,
variants = [$(($variant:ident, $named_variant:ident)),*],
separator = $separator:expr
) => {
#[derive(Clone)]
struct $parser_name;
impl clap::builder::TypedValueParser for $parser_name {
type Value = $query_type;
fn parse_ref(
&self,
cmd: &Command,
arg: Option<&clap::Arg>,
value: &OsStr,
) -> Result<Self::Value, clap::Error> {
let inner = clap::value_parser!($query_type);
let val = if let Some(Some((name, pattern))) =
value.to_str().map(|s| s.split_once($separator))
{
let pattern = TreeSitterRegex(Regex::new(pattern).map_err(|e| {
let mut err = clap::Error::new(ErrorKind::ValueValidation).with_cmd(cmd);
err.insert(
clap::error::ContextKind::InvalidValue,
clap::error::ContextValue::String(pattern.to_string()),
);
err.insert(
clap::error::ContextKind::Suggested,
clap::error::ContextValue::StyledStrs({
// Need `StyledStrs` here - anything else will not print via
// `RichFormatter` (which is the default):
let mut s = StyledStr::new();
write!(s, "error was: {e}").unwrap();
vec![s]
}),
);
err.insert(
clap::error::ContextKind::InvalidArg,
clap::error::ContextValue::String(name.into()),
);
err
})?);
let parsed = inner.parse_ref(cmd, arg, OsStr::new(name))?;
match parsed {
$(
<$query_type>::$variant => <$query_type>::$named_variant(pattern),
)*
_ => {
let mut err = clap::Error::new(ErrorKind::ArgumentConflict).with_cmd(cmd);
err.insert(
clap::error::ContextKind::PriorArg,
clap::error::ContextValue::String(format!("a pattern ('{pattern}')")),
);
err.insert(
clap::error::ContextKind::InvalidArg,
clap::error::ContextValue::String(name.into()),
);
return Err(err);
}
}
} else {
inner.parse_ref(cmd, arg, value)?
};
Ok(val)
}
fn possible_values(
&self,
) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
let variants = <$query_type>::value_variants()
.iter()
.map(|v|
v.to_possible_value().expect(
"all value variants have a possible mapping, as `ValueEnum` is derived",
)
)
.collect::<Vec<_>>();
let pattern_values = vec![
$(
(
stringify!($variant).to_lowercase(),
clap::builder::PossibleValue::new(format!(
"{}{}{}",
stringify!($variant).to_lowercase(),
$separator,
"<PATTERN>"
))
.help(format!(
"Like {}, but only considers items whose name matches PATTERN.",
stringify!($variant).to_lowercase()
))
),
)*
];
let mut result = Vec::with_capacity(variants.len() + pattern_values.len());
for val in variants {
result.push(val.clone());
for (name, pattern_val) in &pattern_values {
if val.get_name() == name {
result.push(pattern_val.clone());
}
}
}
Some(Box::new(result.into_iter()))
}
}
};
}
#[derive(Parser, Debug, Clone)]
#[group(required = false, multiple = false)]
struct CScope {
#[arg(long, env, verbatim_doc_comment)]
c: Vec<c::PreparedQuery>,
#[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_VALUE)]
c_query: Vec<QueryLiteral>,
#[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_FILENAME)]
c_query_file: Vec<PathBuf>,
}
#[derive(Parser, Debug, Clone)]
#[group(required = false, multiple = false)]
struct CSharpScope {
#[arg(long, env, verbatim_doc_comment, visible_alias = "cs")]
csharp: Vec<csharp::PreparedQuery>,
#[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_VALUE)]
csharp_query: Vec<QueryLiteral>,
#[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_FILENAME)]
csharp_query_file: Vec<PathBuf>,
}
#[derive(Parser, Debug, Clone)]
#[group(required = false, multiple = false)]
struct HclScope {
#[expect(clippy::doc_markdown)] #[arg(long, env, verbatim_doc_comment, value_parser = HclPreparedQueryParser)]
hcl: Vec<hcl::PreparedQuery>,
#[expect(clippy::doc_markdown)] #[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_VALUE)]
hcl_query: Vec<QueryLiteral>,
#[expect(clippy::doc_markdown)] #[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_FILENAME)]
hcl_query_file: Vec<PathBuf>,
}
define_prepared_query_parser!(
HclPreparedQueryParser,
hcl::PreparedQuery,
variants = [
(RequiredProviders, RequiredProvidersNamed) ],
separator = NAMED_ITEM_PATTERN_SEPARATOR
);
#[derive(Parser, Debug, Clone)]
#[group(required = false, multiple = false)]
struct GoScope {
#[arg(long, env, verbatim_doc_comment, value_parser = GoPreparedQueryParser)]
go: Vec<go::PreparedQuery>,
#[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_VALUE)]
go_query: Vec<QueryLiteral>,
#[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_FILENAME)]
go_query_file: Vec<PathBuf>,
}
define_prepared_query_parser!(
GoPreparedQueryParser,
go::PreparedQuery,
variants = [
(Struct, StructNamed),
(Interface, InterfaceNamed),
(Func, FuncNamed)
],
separator = NAMED_ITEM_PATTERN_SEPARATOR
);
#[derive(Parser, Debug, Clone)]
#[group(required = false, multiple = false)]
struct PythonScope {
#[arg(long, env, verbatim_doc_comment, visible_alias = "py")]
python: Vec<python::PreparedQuery>,
#[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_VALUE)]
python_query: Vec<QueryLiteral>,
#[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_FILENAME)]
python_query_file: Vec<PathBuf>,
}
#[derive(Parser, Debug, Clone)]
#[group(required = false, multiple = false)]
struct RustScope {
#[arg(long, env, verbatim_doc_comment, visible_alias = "rs", value_parser = RustPreparedQueryParser)]
rust: Vec<rust::PreparedQuery>,
#[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_VALUE)]
rust_query: Vec<QueryLiteral>,
#[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_FILENAME)]
rust_query_file: Vec<PathBuf>,
}
define_prepared_query_parser!(
RustPreparedQueryParser,
rust::PreparedQuery,
variants = [
(Struct, StructNamed),
(Enum, EnumNamed),
(Fn, FnNamed),
(Trait, TraitNamed),
(Mod, ModNamed)
],
separator = NAMED_ITEM_PATTERN_SEPARATOR
);
#[derive(Parser, Debug, Clone)]
#[group(required = false, multiple = false)]
struct TypeScriptScope {
#[arg(long, env, verbatim_doc_comment, visible_alias = "ts")]
typescript: Vec<typescript::PreparedQuery>,
#[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_VALUE)]
typescript_query: Vec<QueryLiteral>,
#[arg(long, env, verbatim_doc_comment, value_name = TREE_SITTER_QUERY_FILENAME)]
typescript_query_file: Vec<PathBuf>,
}
#[cfg(feature = "german")]
#[derive(Parser, Debug)]
#[group(required = false, multiple = true, id("german-opts"))]
#[command(next_help_heading = "Options (german)")]
pub struct GermanOptions {
#[arg(long, env, verbatim_doc_comment)]
pub german_prefer_original: bool,
#[arg(long, env, verbatim_doc_comment)]
pub german_naive: bool,
}
#[derive(Clone, Debug)]
struct QueryLiteral(String);
impl From<String> for QueryLiteral {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<QueryLiteral> for QuerySource {
fn from(query: QueryLiteral) -> Self {
query.0.into()
}
}
impl Args {
pub(super) fn init() -> Self {
Self::parse()
}
pub(super) fn command() -> Command {
<Self as CommandFactory>::command()
}
}
}
#[cfg(test)]
mod tests {
use std::env;
use std::sync::LazyLock;
use env_logger::DEFAULT_FILTER_ENV;
use log::LevelFilter;
use rstest::rstest;
use super::*;
static ENV_MUTEX: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[rstest]
#[case(None, 0, LevelFilter::Error)]
#[case(None, 1, LevelFilter::Warn)]
#[case(None, 2, LevelFilter::Info)]
#[case(None, 3, LevelFilter::Debug)]
#[case(None, 4, LevelFilter::Trace)]
#[case(None, 5, LevelFilter::Trace)]
#[case(None, 128, LevelFilter::Trace)]
#[case(Some("off"), 0, LevelFilter::Off)]
#[case(Some("off"), 1, LevelFilter::Error)]
#[case(Some("off"), 2, LevelFilter::Warn)]
#[case(Some("off"), 3, LevelFilter::Info)]
#[case(Some("off"), 4, LevelFilter::Debug)]
#[case(Some("off"), 5, LevelFilter::Trace)]
#[case(Some("off"), 6, LevelFilter::Trace)]
#[case(Some("off"), 128, LevelFilter::Trace)]
#[case(Some("error"), 0, LevelFilter::Error)]
#[case(Some("error"), 1, LevelFilter::Warn)]
#[case(Some("error"), 2, LevelFilter::Info)]
#[case(Some("error"), 3, LevelFilter::Debug)]
#[case(Some("error"), 4, LevelFilter::Trace)]
#[case(Some("error"), 5, LevelFilter::Trace)]
#[case(Some("error"), 128, LevelFilter::Trace)]
#[case(Some("warn"), 0, LevelFilter::Warn)]
#[case(Some("warn"), 1, LevelFilter::Info)]
#[case(Some("warn"), 2, LevelFilter::Debug)]
#[case(Some("warn"), 3, LevelFilter::Trace)]
#[case(Some("warn"), 4, LevelFilter::Trace)]
#[case(Some("warn"), 128, LevelFilter::Trace)]
#[case(Some("info"), 0, LevelFilter::Info)]
#[case(Some("info"), 1, LevelFilter::Debug)]
#[case(Some("info"), 2, LevelFilter::Trace)]
#[case(Some("info"), 3, LevelFilter::Trace)]
#[case(Some("info"), 128, LevelFilter::Trace)]
#[case(Some("debug"), 0, LevelFilter::Debug)]
#[case(Some("debug"), 1, LevelFilter::Trace)]
#[case(Some("debug"), 2, LevelFilter::Trace)]
#[case(Some("debug"), 128, LevelFilter::Trace)]
#[case(Some("trace"), 0, LevelFilter::Trace)]
#[case(Some("trace"), 1, LevelFilter::Trace)]
#[case(Some("trace"), 128, LevelFilter::Trace)]
fn test_level_filter_from_env_and_verbosity(
#[case] env_value: Option<&str>,
#[case] additional_verbosity: u8,
#[case] expected: LevelFilter,
) {
let _guard = ENV_MUTEX.lock().unwrap();
#[expect(unsafe_code)]
if let Some(env_value) = env_value {
unsafe {
env::set_var(DEFAULT_FILTER_ENV, env_value);
}
} else {
unsafe {
env::remove_var(DEFAULT_FILTER_ENV);
}
}
let result = level_filter_from_env_and_verbosity(additional_verbosity);
assert_eq!(result, expected);
}
}