use std::collections::HashMap;
use std::fs;
use std::io::{self, Read};
use std::path::Path;
use std::process;
use std::sync::Arc;
use anyhow::{Context, Result};
use clap::Parser;
use rayon::prelude::*;
use snapper_fmt::cli::{Cli, Commands, OutputFormat, parse_range};
use snapper_fmt::config::ProjectConfig;
use snapper_fmt::format::Format;
use snapper_fmt::output::{CheckResult, output_json, output_sarif};
use snapper_fmt::sentence::SentenceSplitter;
use snapper_fmt::{
FormatConfig, build_splitter, format_range, format_text, format_text_with_splitter,
};
fn main() {
if let Err(e) = run() {
eprintln!("error: {e:#}");
process::exit(1);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
if let Some(ref cmd) = cli.command {
match cmd {
Commands::Init { dry_run } => return snapper_fmt::init::run_init(*dry_run),
Commands::Sdiff {
old,
new,
format,
no_color,
} => {
let fmt = format.map(Format::from_arg);
let result = snapper_fmt::sdiff::sentence_diff(old, new, fmt, !no_color)?;
if result.is_empty() {
eprintln!("No sentence-level differences.");
} else {
print!("{result}");
process::exit(1);
}
return Ok(());
}
Commands::GitDiff {
git_ref,
files,
format,
no_color,
} => {
let fmt = format.map(Format::from_arg);
let has_diff = snapper_fmt::git_diff::run_git_diff(git_ref, files, fmt, !no_color)?;
if has_diff {
process::exit(1);
}
return Ok(());
}
Commands::Lsp => {
let rt = tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
rt.block_on(snapper_fmt::lsp::run_lsp());
return Ok(());
}
Commands::Mcp => {
#[cfg(feature = "mcp")]
{
let rt =
tokio::runtime::Runtime::new().expect("failed to create tokio runtime");
rt.block_on(snapper_fmt::mcp::run_mcp())?;
return Ok(());
}
#[cfg(not(feature = "mcp"))]
{
eprintln!("error: snapper was built without the 'mcp' feature");
eprintln!("rebuild with: cargo install snapper-fmt --features mcp");
process::exit(1);
}
}
Commands::Watch { patterns, format } => {
let fmt = format.map(Format::from_arg);
return snapper_fmt::watch::run_watch(patterns, fmt, cli.config.as_deref());
}
}
}
let project_config = ProjectConfig::resolve(cli.config.as_deref()).unwrap_or_default();
if cli.files.is_empty() {
let mut input = String::new();
io::stdin()
.read_to_string(&mut input)
.context("failed to read stdin")?;
let format = resolve_format(
cli.format.map(Format::from_arg),
cli.stdin_filepath.as_deref(),
&project_config,
);
let config =
build_format_config(&cli, &project_config, format, cli.stdin_filepath.as_deref());
let output = if let Some(ref range_str) = cli.range {
let (start, end) =
parse_range(range_str).context("invalid range format, expected START:END")?;
format_range(&input, &config, start, end)?
} else {
format_text(&input, &config)?
};
if cli.diff {
snapper_fmt::diff::print_diff("<stdin>", &input, &output);
} else if let Some(ref path) = cli.output {
fs::write(path, &output)
.with_context(|| format!("failed to write {}", path.display()))?;
} else {
print!("{output}");
}
} else {
let use_parallel = cli.files.len() > 1;
let mut splitter_cache: HashMap<SplitterKey, Arc<dyn SentenceSplitter>> = HashMap::new();
let paths: Vec<&Path> = cli
.files
.iter()
.map(Path::new)
.filter(|path| !should_skip_path(path, &cli, &project_config))
.collect();
for path in &paths {
let format = resolve_format(
cli.format.map(Format::from_arg),
Some(path),
&project_config,
);
let config = build_format_config(&cli, &project_config, format, Some(path));
let key = SplitterKey::from_config(&config);
if let std::collections::hash_map::Entry::Vacant(e) = splitter_cache.entry(key) {
let s = build_splitter(&config).context("failed to build sentence splitter")?;
e.insert(Arc::from(s));
}
}
let cache = Arc::new(splitter_cache);
let results: Vec<(String, String, String)> = if use_parallel {
paths
.par_iter()
.map(|path| process_file(path, &cli, &project_config, &cache))
.collect::<Result<Vec<_>>>()?
} else {
paths
.iter()
.map(|path| process_file(path, &cli, &project_config, &cache))
.collect::<Result<Vec<_>>>()?
};
let mut any_changed = false;
let mut check_results: Vec<CheckResult> = Vec::new();
for (path_str, input, output) in &results {
if cli.diff {
if output != input {
snapper_fmt::diff::print_diff(path_str, input, output);
any_changed = true;
}
} else if cli.check {
if output != input {
match cli.output_format {
OutputFormat::Text => eprintln!("would reformat: {path_str}"),
_ => check_results.push(CheckResult {
file: path_str.clone(),
original_lines: input.lines().count(),
formatted_lines: output.lines().count(),
}),
}
any_changed = true;
}
} else if cli.in_place {
if output != input {
fs::write(path_str, output)
.with_context(|| format!("failed to write {path_str}"))?;
}
} else if let Some(ref out_path) = cli.output {
fs::write(out_path, output)
.with_context(|| format!("failed to write {}", out_path.display()))?;
} else {
print!("{output}");
}
}
if cli.check && !check_results.is_empty() {
match cli.output_format {
OutputFormat::Json => output_json(&check_results),
OutputFormat::Sarif => output_sarif(&check_results),
OutputFormat::Text => {} }
}
if (cli.check || cli.diff) && any_changed {
process::exit(1);
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct SplitterKey {
format: Format,
use_neural: bool,
neural_lang: String,
neural_model: Option<std::path::PathBuf>,
extras: Vec<String>,
}
impl SplitterKey {
fn from_config(config: &FormatConfig) -> Self {
Self {
format: config.format,
use_neural: config.use_neural,
neural_lang: config.neural_lang.clone(),
neural_model: config.neural_model_path.clone(),
extras: config.extra_abbreviations.clone(),
}
}
}
fn process_file(
path: &Path,
cli: &Cli,
project_config: &ProjectConfig,
splitter_cache: &HashMap<SplitterKey, Arc<dyn SentenceSplitter>>,
) -> Result<(String, String, String)> {
let path_str = path.display().to_string();
let input =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let format = resolve_format(cli.format.map(Format::from_arg), Some(path), project_config);
let config = build_format_config(cli, project_config, format, Some(path));
let key = SplitterKey::from_config(&config);
let splitter = splitter_cache
.get(&key)
.ok_or_else(|| anyhow::anyhow!("splitter cache miss for {path_str}"))?;
let output = if let Some(ref range_str) = cli.range {
let (start, end) =
parse_range(range_str).context("invalid range format, expected START:END")?;
let _ = splitter;
format_range(&input, &config, start, end)?
} else {
format_text_with_splitter(&input, &config, splitter.as_ref())?
};
Ok((path_str, input, output))
}
fn build_format_config(
cli: &Cli,
project_config: &ProjectConfig,
format: Format,
file_path: Option<&Path>,
) -> FormatConfig {
let format_key = format.config_key();
let max_width = resolve_max_width(
cli.max_width,
project_config.max_width_for_format(format_key),
file_path,
);
let neural_lang = cli
.lang
.clone()
.or_else(|| project_config.lang.clone())
.unwrap_or_else(|| "en".to_string());
FormatConfig {
format,
max_width,
use_neural: cli.neural,
neural_lang,
neural_model_path: cli.model_path.clone(),
extra_abbreviations: project_config.abbreviations_for_format(format_key),
use_pandoc: cli.use_pandoc,
#[cfg(feature = "pandoc")]
pandoc_backend: cli
.pandoc_backend
.parse()
.unwrap_or(snapper_fmt::parser::pandoc::PandocBackend::Cli),
code: project_config.code.clone(),
format_code: cli.format_code,
..Default::default()
}
}
fn resolve_format(
cli_format: Option<Format>,
path: Option<&Path>,
project_config: &ProjectConfig,
) -> Format {
if let Some(format) = cli_format {
return format;
}
if let Some(path) = path {
let detected = Format::from_path(path);
if detected != Format::Plaintext {
return detected;
}
}
project_config
.default_format
.as_deref()
.map(Format::from_extension)
.unwrap_or(Format::Plaintext)
}
fn should_skip_path(path: &Path, cli: &Cli, project_config: &ProjectConfig) -> bool {
(cli.check || cli.in_place) && project_config.is_ignored(path)
}
fn resolve_max_width(
cli_width: usize,
config_width: Option<usize>,
file_path: Option<&Path>,
) -> usize {
if cli_width > 0 {
return cli_width;
}
if let Some(w) = config_width {
if w > 0 {
return w;
}
}
if let Some(path) = file_path {
if let Ok(props) = ec4rs::properties_of(path) {
if let Ok(ec4rs::property::MaxLineLen::Value(n)) =
props.get::<ec4rs::property::MaxLineLen>()
{
return n;
}
}
}
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_width_wins() {
assert_eq!(resolve_max_width(80, Some(120), None), 80);
}
#[test]
fn config_width_when_cli_zero() {
assert_eq!(resolve_max_width(0, Some(120), None), 120);
}
#[test]
fn both_zero_returns_zero() {
assert_eq!(resolve_max_width(0, None, None), 0);
assert_eq!(resolve_max_width(0, Some(0), None), 0);
}
}