mod bench;
mod manage;
mod render;
mod search;
use std::path::PathBuf;
use clap::{Parser, Subcommand};
use crate::Config;
use bench::cmd_bench_search;
use manage::{cmd_index, cmd_status, cmd_update};
use search::{cmd_search, SearchArgs};
#[derive(Parser)]
#[command(name = "st", version, about, disable_help_subcommand = true)]
pub struct Cli {
pub pattern: Option<String>,
#[arg(value_name = "PATH")]
pub paths: Vec<PathBuf>,
#[arg(short = 'F', long = "fixed-strings")]
pub fixed_strings: bool,
#[arg(short = 's', long = "case-sensitive", overrides_with = "ignore_case")]
pub case_sensitive: bool,
#[arg(short = 'i', long = "ignore-case", overrides_with = "case_sensitive")]
pub ignore_case: bool,
#[arg(short = 'w', long = "word-regexp", overrides_with = "line_regexp")]
pub word_regexp: bool,
#[arg(short = 'x', long = "line-regexp", overrides_with = "word_regexp")]
pub line_regexp: bool,
#[arg(short = 'v', long = "invert-match")]
pub invert_match: bool,
#[arg(
short = 'e',
long = "regexp",
value_name = "PATTERN",
conflicts_with = "pattern"
)]
pub regexp: Option<String>,
#[arg(
short = 'l',
long = "files-with-matches",
overrides_with_all = ["count", "json", "files_without_match"]
)]
pub files_with_matches: bool,
#[arg(
long = "files-without-match",
overrides_with_all = ["files_with_matches", "count", "count_matches", "json"]
)]
pub files_without_match: bool,
#[arg(
short = 'c',
long = "count",
overrides_with_all = ["files_with_matches", "files_without_match", "count_matches", "json"]
)]
pub count: bool,
#[arg(
long = "count-matches",
overrides_with_all = ["files_with_matches", "files_without_match", "count", "json"]
)]
pub count_matches: bool,
#[arg(short = 'm', long = "max-count", value_name = "NUM")]
pub max_count: Option<usize>,
#[arg(short = 'q', long = "quiet")]
pub quiet: bool,
#[arg(short = 'o', long = "only-matching")]
pub only_matching: bool,
#[arg(long = "json", overrides_with_all = ["files_with_matches", "files_without_match", "count", "count_matches"])]
pub json: bool,
#[arg(long = "heading", overrides_with = "no_heading")]
pub heading: bool,
#[arg(long = "no-heading", overrides_with = "heading")]
pub no_heading: bool,
#[arg(short = 'n', long = "line-number", overrides_with = "no_line_number")]
pub line_number: bool,
#[arg(short = 'N', long = "no-line-number", overrides_with = "line_number")]
pub no_line_number: bool,
#[arg(short = 'H', long = "with-filename", overrides_with = "no_filename")]
pub with_filename: bool,
#[arg(short = 'I', long = "no-filename", overrides_with = "with_filename")]
pub no_filename: bool,
#[arg(short = 'A', long = "after-context", value_name = "NUM")]
pub after_context: Option<usize>,
#[arg(short = 'B', long = "before-context", value_name = "NUM")]
pub before_context: Option<usize>,
#[arg(short = 'C', long = "context", value_name = "NUM")]
pub context: Option<usize>,
#[arg(short = 't', long = "type", value_name = "TYPE")]
pub file_type: Option<String>,
#[arg(short = 'T', long = "type-not", value_name = "TYPE")]
pub type_not: Option<String>,
#[arg(short = 'g', long = "glob", value_name = "GLOB")]
pub glob: Option<String>,
#[arg(long, global = true, env = "SYNTEXT_INDEX_DIR")]
pub index_dir: Option<PathBuf>,
#[arg(long, global = true)]
pub repo_root: Option<PathBuf>,
#[arg(long, global = true)]
pub verbose: bool,
#[command(subcommand)]
pub command: Option<ManageCommand>,
}
#[derive(Subcommand)]
pub enum ManageCommand {
Index {
#[arg(long)]
force: bool,
#[arg(long)]
stats: bool,
#[arg(short, long)]
quiet: bool,
},
Status {
#[arg(long)]
json: bool,
},
Update {
#[arg(long)]
flush: bool,
#[arg(short, long)]
quiet: bool,
},
#[command(hide = true)]
BenchSearch {
#[arg(long = "query", required = true)]
queries: Vec<String>,
#[arg(long, default_value_t = 1)]
iterations: usize,
#[arg(long, default_value_t = 0)]
warmups: usize,
},
}
pub fn run() -> i32 {
let cli = Cli::parse();
let mut config = resolve_config(&cli);
config.verbose = cli.verbose;
match cli.command {
Some(ManageCommand::Index {
force,
stats,
quiet,
}) => cmd_index(config, force, stats, quiet),
Some(ManageCommand::Status { json }) => cmd_status(config, json),
Some(ManageCommand::Update { flush, quiet }) => cmd_update(config, flush, quiet),
Some(ManageCommand::BenchSearch {
queries,
iterations,
warmups,
}) => cmd_bench_search(config, &queries, iterations, warmups),
None => {
let pattern = match cli.pattern.or(cli.regexp) {
Some(p) => p,
None => {
eprintln!("st: a pattern is required (try `st --help`)");
return 2;
}
};
let ctx = cli.context.unwrap_or(0);
let search_args = SearchArgs {
pattern,
paths: cli.paths,
fixed_strings: cli.fixed_strings,
ignore_case: cli.ignore_case,
word_regexp: cli.word_regexp,
line_regexp: cli.line_regexp,
line_number: cli.line_number,
with_filename: cli.with_filename,
invert_match: cli.invert_match,
files_with_matches: cli.files_with_matches,
files_without_match: cli.files_without_match,
count: cli.count,
count_matches: cli.count_matches,
max_count: cli.max_count,
quiet: cli.quiet,
only_matching: cli.only_matching,
json: cli.json,
heading: cli.heading,
no_line_number: cli.no_line_number,
no_filename: cli.no_filename,
after_context: cli.after_context.unwrap_or(ctx),
before_context: cli.before_context.unwrap_or(ctx),
file_type: cli.file_type,
type_not: cli.type_not,
glob: cli.glob,
};
cmd_search(config, &search_args)
}
}
}
const MAX_FILE_SIZE_CEILING: u64 = 1_073_741_824;
fn resolve_config(cli: &Cli) -> Config {
let repo_root = cli
.repo_root
.clone()
.or_else(detect_repo_root)
.unwrap_or_else(|| PathBuf::from("."));
let index_dir = {
let raw = cli
.index_dir
.clone()
.unwrap_or_else(|| repo_root.join(".syntext"));
if let Err(msg) = validate_index_dir(&raw) {
eprintln!("{msg}");
std::process::exit(2);
}
raw
};
let max_file_size = parse_max_file_size();
Config {
max_file_size,
max_segments: 10,
index_dir,
repo_root,
verbose: false,
strict_permissions: true,
}
}
fn parse_max_file_size() -> u64 {
clamp_max_file_size(
std::env::var("SYNTEXT_MAX_FILE_SIZE")
.ok()
.and_then(|v| v.parse::<u64>().ok()),
)
}
fn clamp_max_file_size(raw: Option<u64>) -> u64 {
raw.unwrap_or(10 * 1024 * 1024).min(MAX_FILE_SIZE_CEILING)
}
fn detect_repo_root() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
if dir.join(".git").exists() {
return Some(dir);
}
if !dir.pop() {
return None;
}
}
}
#[cfg(unix)]
fn validate_index_dir(index_dir: &std::path::Path) -> Result<(), String> {
if !index_dir.is_absolute() {
return Ok(());
}
const SENSITIVE_PREFIXES: &[&str] = &[
"/etc",
"/usr",
"/bin",
"/sbin",
"/lib",
"/lib64",
"/sys",
"/proc",
"/dev",
"/boot",
"/root",
"/System",
"/Library",
"/private/etc",
"/private/var/root",
];
let dir_str = index_dir.to_string_lossy();
for prefix in SENSITIVE_PREFIXES {
if dir_str == *prefix || dir_str.starts_with(&format!("{prefix}/")) {
return Err(format!(
"st: refusing --index-dir '{}': overlaps system path '{prefix}'; \
use a path under the repository or a user-owned directory",
index_dir.display(),
));
}
}
Ok(())
}
#[cfg(not(unix))]
fn validate_index_dir(_index_dir: &std::path::Path) -> Result<(), String> {
Ok(())
}
#[cfg(test)]
mod tests;