use std::fs;
use std::io::{self, IsTerminal, Read};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use clap::Parser;
use similar::{ChangeTag, TextDiff};
use panache::{format, parse};
use serde_json::json;
mod cache;
mod cli;
mod diagnostic_renderer;
use cache::{
CachedLintDocument, CliCache, FormatCacheMode, FormatStoreArgs, global_cache_base_dir,
resolve_cache_dir_for_cli,
};
use cli::{Cli, CliFlavor, ColorMode, Commands, DebugChecks, DebugCommands, ParseOutput};
use diagnostic_renderer::print_diagnostics;
use panache::config::{Flavor, WrapMode};
impl From<CliFlavor> for Flavor {
fn from(value: CliFlavor) -> Self {
match value {
CliFlavor::Pandoc => Flavor::Pandoc,
CliFlavor::Quarto => Flavor::Quarto,
CliFlavor::RMarkdown => Flavor::RMarkdown,
CliFlavor::Gfm => Flavor::Gfm,
CliFlavor::CommonMark => Flavor::CommonMark,
CliFlavor::MultiMarkdown => Flavor::MultiMarkdown,
CliFlavor::Mdsvex => Flavor::Mdsvex,
CliFlavor::Myst => Flavor::Myst,
}
}
}
fn apply_format_overrides(cfg: &mut panache::Config, overrides: &[String]) -> Result<(), String> {
let mut extension_overrides: std::collections::HashMap<String, bool> =
std::collections::HashMap::new();
for raw in overrides {
let (key, value) = raw
.split_once('=')
.ok_or_else(|| format!("invalid --option `{raw}`: expected key=value"))?;
let key = key.trim();
let value = value.trim();
match key {
"line-width" => {
let n: usize = value.parse().map_err(|_| {
format!("invalid value for `line-width`: `{value}` (expected positive integer)")
})?;
if n == 0 {
return Err(
"invalid value for `line-width`: 0 (expected positive integer)".into(),
);
}
cfg.line_width = n;
}
"wrap" => {
let mode = match value {
"reflow" => WrapMode::Reflow,
"sentence" => WrapMode::Sentence,
"semantic" => WrapMode::Semantic,
"preserve" => WrapMode::Preserve,
other => {
return Err(format!(
"invalid value for `wrap`: `{other}` (expected one of: reflow, sentence, semantic, preserve)"
));
}
};
cfg.wrap = Some(mode);
}
"table-indent" => {
let indent: usize = value.parse().map_err(|_| {
format!(
"invalid value for `table-indent`: `{value}` (expected an integer 0, 1, 2, or 3)"
)
})?;
if indent > 3 {
return Err(format!(
"invalid value for `table-indent`: `{indent}` (expected 0, 1, 2, or 3)"
));
}
cfg.table_indent = indent;
}
ext_key if ext_key.starts_with("extensions.") => {
let name = ext_key["extensions.".len()..].trim();
if name.is_empty() {
return Err(format!(
"invalid --option `{raw}`: missing extension name after `extensions.`"
));
}
let bool_value = match value.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => true,
"false" | "0" | "no" | "off" => false,
_ => {
return Err(format!(
"invalid value for `{ext_key}`: `{value}` (expected boolean: true/false)"
));
}
};
extension_overrides.insert(name.to_string(), bool_value);
}
other => {
return Err(format!(
"unknown config key in --option: `{other}` (supported: line-width, wrap, table-indent, extensions.<name>)"
));
}
}
}
if !extension_overrides.is_empty() {
cfg.extensions.apply_overrides(extension_overrides.clone());
cfg.formatter_extensions
.apply_overrides(extension_overrides);
}
Ok(())
}
const SUPPORTED_EXTENSIONS: &[&str] = &[
"md",
"qmd",
"Rmd",
"rmd",
"Rmarkdown",
"rmarkdown",
"markdown",
"mdown",
"mkd",
"svx",
];
fn init_logger(debug_log: Option<&Path>) {
let Some(path) = debug_log else {
env_logger::Builder::from_default_env().init();
return;
};
let mut builder = env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("panache=debug"),
);
if let Ok(file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
builder.target(env_logger::Target::Pipe(Box::new(file)));
}
builder.format_timestamp_millis();
builder.init();
log::info!("LSP debug logging enabled at {}", path.display());
}
fn init_lsp_debug_log() -> io::Result<PathBuf> {
let mut base = dirs::state_dir().unwrap_or_else(|| PathBuf::from("."));
base.push("panache");
fs::create_dir_all(&base)?;
base.push("lsp-debug.log");
Ok(base)
}
struct PathFilters {
exclude: panache::config::GlobMatcher,
include: panache::config::GlobMatcher,
}
fn effective_exclude_patterns(cfg: &panache::Config) -> Vec<String> {
let mut patterns = cfg.exclude.clone().unwrap_or_else(|| {
panache::config::DEFAULT_EXCLUDE_PATTERNS
.iter()
.map(|s| s.to_string())
.collect()
});
patterns.extend(cfg.extend_exclude.iter().cloned());
patterns
}
fn effective_include_patterns(cfg: &panache::Config) -> Vec<String> {
let mut patterns = cfg.include.clone().unwrap_or_else(|| {
panache::config::DEFAULT_INCLUDE_PATTERNS
.iter()
.map(|s| s.to_string())
.collect()
});
patterns.extend(cfg.extend_include.iter().cloned());
patterns
}
fn build_path_filters(cfg: &panache::Config) -> io::Result<PathFilters> {
let exclude = panache::config::GlobMatcher::build(&effective_exclude_patterns(cfg))
.map_err(io::Error::other)?;
let include = panache::config::GlobMatcher::build(&effective_include_patterns(cfg))
.map_err(io::Error::other)?;
Ok(PathFilters { exclude, include })
}
fn relative_path_from_root(path: &Path, root: &Path) -> Option<PathBuf> {
if let Ok(rel) = path.strip_prefix(root) {
return Some(rel.to_path_buf());
}
let canonical_path = path.canonicalize().ok()?;
let canonical_root = root.canonicalize().ok()?;
canonical_path
.strip_prefix(&canonical_root)
.ok()
.map(Path::to_path_buf)
}
fn expand_paths(
paths: &[PathBuf],
cfg: &panache::Config,
anchor: &Path,
force_exclude: bool,
accept_any_extension: bool,
) -> io::Result<Vec<PathBuf>> {
use ignore::WalkBuilder;
let mut files = Vec::new();
let filters = build_path_filters(cfg)?;
for path in paths {
if path.is_file() {
let rel_path = relative_path_from_root(path, anchor)
.or_else(|| path.file_name().map(PathBuf::from))
.unwrap_or_else(|| path.to_path_buf());
let rel = rel_path.to_string_lossy().replace('\\', "/");
if force_exclude && filters.exclude.is_match(&rel) {
continue;
}
if accept_any_extension {
files.push(path.clone());
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if SUPPORTED_EXTENSIONS.contains(&ext) {
files.push(path.clone());
} else {
eprintln!(
"Warning: Skipping unsupported file type: {}",
path.display()
);
}
} else {
eprintln!(
"Warning: Skipping file without extension: {}",
path.display()
);
}
} else if path.is_dir() {
let walker = WalkBuilder::new(path)
.hidden(false) .git_ignore(true) .git_global(true) .build();
for entry in walker {
let entry = entry.map_err(io::Error::other)?;
let entry_path = entry.path();
if entry_path.is_dir() {
continue;
}
let rel_path = relative_path_from_root(entry_path, anchor)
.unwrap_or_else(|| entry_path.to_path_buf());
let rel = rel_path.to_string_lossy().replace('\\', "/");
if filters.exclude.is_match(&rel) {
continue;
}
if !filters.include.is_match(&rel) {
continue;
}
if entry_path.is_file() {
files.push(entry_path.to_path_buf());
}
}
} else {
eprintln!("Warning: Path not found: {}", path.display());
}
}
Ok(files)
}
fn effective_parallelism(cli_jobs: usize, n_files: usize) -> usize {
if n_files <= 1 || cli_jobs == 1 {
return 1;
}
if cli_jobs > 0 {
return cli_jobs.min(n_files);
}
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.min(n_files)
}
fn per_file_external_parallel(budget: usize, workers: usize) -> usize {
budget.div_ceil(workers.max(1)).max(1)
}
fn build_pool(n: usize) -> rayon::ThreadPool {
rayon::ThreadPoolBuilder::new()
.num_threads(n)
.thread_name(|i| format!("panache-worker-{i}"))
.build()
.expect("failed to build rayon thread pool")
}
fn parse_range(range_str: &str) -> Result<(usize, usize), String> {
let parts: Vec<&str> = range_str.split(':').collect();
if parts.len() != 2 {
return Err(format!(
"Invalid range format '{}'. Expected START:END (e.g., 5:10)",
range_str
));
}
let start = parts[0]
.parse::<usize>()
.map_err(|_| format!("Invalid start line '{}'", parts[0]))?;
let end = parts[1]
.parse::<usize>()
.map_err(|_| format!("Invalid end line '{}'", parts[1]))?;
if start == 0 || end == 0 {
return Err("Line numbers must be 1-indexed (start from 1)".to_string());
}
if start > end {
return Err(format!(
"Start line ({}) must be less than or equal to end line ({})",
start, end
));
}
Ok((start, end))
}
fn read_all(path: Option<&PathBuf>) -> io::Result<String> {
match path {
Some(p) => fs::read_to_string(p),
None => {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf)?;
Ok(buf)
}
}
}
fn usage_error(subcommand: &str, kind: clap::error::ErrorKind, message: &str) -> ! {
let mut cmd = <Cli as clap::CommandFactory>::command();
for name in subcommand.split_whitespace() {
cmd = match cmd.find_subcommand(name) {
Some(sub) => sub.clone(),
None => <Cli as clap::CommandFactory>::command(),
};
}
cmd.bin_name(format!("panache {subcommand}"))
.error(kind, message)
.exit()
}
const FORMAT_MISSING_INPUT: &str =
"no input paths; pass files or directories to format, or `-` to read from stdin";
const LINT_MISSING_INPUT: &str =
"no input paths; pass files or directories to lint, or `-` to read from stdin";
const DEBUG_FORMAT_MISSING_INPUT: &str =
"no input paths; pass files or directories to check, or `-` to read from stdin";
const PARSE_MISSING_INPUT: &str = "no input path; pass a file to parse, or `-` to read from stdin";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputError {
StdinWithPaths,
NoInput,
}
fn normalize_input_paths(
files: Vec<PathBuf>,
stdin_is_terminal: bool,
) -> Result<Vec<PathBuf>, InputError> {
let has_dash = files.iter().any(|p| p.as_os_str() == "-");
if !has_dash {
if files.is_empty() && stdin_is_terminal {
return Err(InputError::NoInput);
}
return Ok(files);
}
if files.len() > 1 {
return Err(InputError::StdinWithPaths);
}
Ok(Vec::new())
}
fn normalize_parse_path(
file: Option<PathBuf>,
stdin_is_terminal: bool,
) -> Result<Option<PathBuf>, InputError> {
match file {
Some(p) if p.as_os_str() == "-" => Ok(None),
None if stdin_is_terminal => Err(InputError::NoInput),
other => Ok(other),
}
}
fn normalize_input_paths_or_exit(
files: Vec<PathBuf>,
subcommand: &str,
missing: &str,
) -> Vec<PathBuf> {
match normalize_input_paths(files, io::stdin().is_terminal()) {
Ok(files) => files,
Err(err) => input_usage_error(subcommand, missing, err),
}
}
fn normalize_parse_path_or_exit(file: Option<PathBuf>) -> Option<PathBuf> {
match normalize_parse_path(file, io::stdin().is_terminal()) {
Ok(file) => file,
Err(err) => input_usage_error("parse", PARSE_MISSING_INPUT, err),
}
}
fn input_usage_error(subcommand: &str, missing: &str, err: InputError) -> ! {
match err {
InputError::StdinWithPaths => usage_error(
subcommand,
clap::error::ErrorKind::ArgumentConflict,
"'-' (stdin) cannot be combined with file path arguments",
),
InputError::NoInput => usage_error(
subcommand,
clap::error::ErrorKind::MissingRequiredArgument,
missing,
),
}
}
fn file_count_label(count: usize, singular: &str, plural: &str) -> String {
if count == 1 {
format!("{count} {singular}")
} else {
format!("{count} {plural}")
}
}
fn remove_dir_if_exists(path: &Path) -> io::Result<bool> {
let mut attempt: usize = 0;
loop {
match fs::remove_dir_all(path) {
Ok(()) => return Ok(true),
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(err) => {
if !should_retry_remove(&err, attempt) {
return Err(err);
}
std::thread::sleep(std::time::Duration::from_millis(25u64 << attempt));
attempt += 1;
}
}
}
}
#[cfg(windows)]
fn should_retry_remove(err: &io::Error, attempt: usize) -> bool {
attempt < 5 && err.kind() == io::ErrorKind::PermissionDenied
}
#[cfg(not(windows))]
fn should_retry_remove(_err: &io::Error, _attempt: usize) -> bool {
false
}
fn summarize_dir(path: &Path) -> io::Result<Option<(usize, u64)>> {
if !path.exists() {
return Ok(None);
}
let mut files = 0usize;
let mut bytes = 0u64;
let mut stack = vec![path.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
stack.push(entry.path());
} else if file_type.is_file()
&& let Ok(meta) = entry.metadata()
{
files += 1;
bytes = bytes.saturating_add(meta.len());
}
}
}
Ok(Some((files, bytes)))
}
fn format_bytes(bytes: u64) -> String {
const KIB: f64 = 1024.0;
let bytes_f = bytes as f64;
if bytes < 1024 {
format!("{bytes} B")
} else if bytes_f < KIB * KIB {
format!("{:.1} KiB", bytes_f / KIB)
} else if bytes_f < KIB * KIB * KIB {
format!("{:.2} MiB", bytes_f / (KIB * KIB))
} else {
format!("{:.2} GiB", bytes_f / (KIB * KIB * KIB))
}
}
fn format_clean_summary(summary: Option<(usize, u64)>) -> String {
match summary {
Some((files, bytes)) => {
let file_word = if files == 1 { "file" } else { "files" };
format!(" ({files} {file_word}, {})", format_bytes(bytes))
}
None => String::new(),
}
}
fn open_cli_cache_best_effort(
cfg: &panache::Config,
explicit_config: Option<&Path>,
start_dir: &Path,
) -> Option<CliCache> {
match CliCache::open(cfg, explicit_config, start_dir) {
Ok(cache) => cache,
Err(err) => {
log::warn!("Disabling CLI cache for this run: {err}");
None
}
}
}
fn start_dir_for(input_path: Option<&Path>) -> io::Result<PathBuf> {
if let Some(p) = input_path {
Ok(p.parent().unwrap_or(Path::new(".")).to_path_buf())
} else {
std::env::current_dir()
}
}
fn has_explicit_file_targets(paths: &[PathBuf]) -> bool {
paths.iter().any(|path| !path.is_dir())
}
fn load_config_for_cli(
config_path: Option<&Path>,
isolated: bool,
cli_cache_dir: Option<&Path>,
start_dir: &Path,
input_path: Option<&Path>,
flavor_override: Option<Flavor>,
) -> io::Result<(panache::Config, panache::config::ConfigSource)> {
let mut loaded = if !isolated {
panache::config::load(config_path, start_dir, input_path, flavor_override)?
} else {
let mut cfg = panache::Config::default();
let isolated_flavor = flavor_override
.or_else(|| input_path.and_then(|p| panache::config::detect_flavor_from_path(p, &cfg)));
if let Some(flavor) = isolated_flavor {
cfg.flavor = flavor;
cfg.extensions = panache::config::Extensions::for_flavor(flavor);
}
(cfg, panache::config::ConfigSource::None)
};
if let Some(cache_dir) = cli_cache_dir {
loaded.0.cache_dir = Some(cache_dir.to_string_lossy().to_string());
}
Ok(loaded)
}
fn color_enabled(mode: ColorMode, no_color: bool) -> bool {
resolve_color(
mode,
no_color,
std::env::var_os("NO_COLOR").is_some(),
std::env::var_os("TERM").as_deref(),
io::stdout().is_terminal(),
)
}
fn resolve_color(
mode: ColorMode,
no_color_flag: bool,
no_color_env: bool,
term_env: Option<&std::ffi::OsStr>,
stdout_is_terminal: bool,
) -> bool {
if no_color_flag {
return false;
}
match mode {
ColorMode::Always => true,
ColorMode::Never => false,
ColorMode::Auto => {
if no_color_env {
return false;
}
match term_env {
Some(term) if term == "dumb" => return false,
None => return false,
_ => {}
}
stdout_is_terminal
}
}
}
fn print_diff(file_path: &str, original: &str, formatted: &str, use_color: bool) {
let diff = TextDiff::from_lines(original, formatted);
for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
if idx > 0 {
println!("---");
}
println!("Diff in {}:{}:", file_path, group[0].old_range().start + 1);
for op in group {
for change in diff.iter_changes(op) {
let (sign, style) = match change.tag() {
ChangeTag::Delete => ("-", "\x1b[31m"), ChangeTag::Insert => ("+", "\x1b[32m"), ChangeTag::Equal => (" ", "\x1b[0m"), };
if use_color {
print!("{}{}{}", style, sign, change.value());
} else {
print!("{}{}", sign, change.value());
}
if use_color && change.tag() != ChangeTag::Equal {
print!("\x1b[0m");
}
}
}
}
}
#[derive(Clone, Copy)]
enum CheckKind {
Losslessness,
Idempotency,
}
impl CheckKind {
fn label(self) -> &'static str {
match self {
CheckKind::Losslessness => "losslessness",
CheckKind::Idempotency => "idempotency",
}
}
}
fn sanitize_path_for_filename(path: &str) -> String {
path.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
#[derive(Clone)]
struct DebugFailure {
kind: CheckKind,
left: String,
right: String,
}
fn build_debug_failure_report(
checks: DebugChecks,
files_checked: usize,
failures: &[(String, DebugFailure)],
) -> String {
let mut out = String::new();
out.push_str("# Debug-format regression report\n\n");
out.push_str(&format!(
"- Checks: `{}`\n- Files checked: {}\n- Failures: {}\n\n",
format!("{:?}", checks).to_lowercase(),
files_checked,
failures.len()
));
if failures.is_empty() {
out.push_str("All checks passed.\n");
return out;
}
out.push_str("## Failures\n\n");
for (idx, (file, failure)) in failures.iter().enumerate() {
let diff = TextDiff::from_lines(&failure.left, &failure.right);
let location_line = diff
.grouped_ops(0)
.first()
.and_then(|group| group.first().map(|op| op.old_range().start + 1))
.unwrap_or(1);
out.push_str(&format!(
"### {}. `{}` ({})\n\n",
idx + 1,
file,
failure.kind.label()
));
out.push_str(&format!("- Approx. diff start line: {}\n\n", location_line));
out.push_str("```diff\n");
for change in diff.iter_all_changes() {
let sign = match change.tag() {
ChangeTag::Delete => "-",
ChangeTag::Insert => "+",
ChangeTag::Equal => " ",
};
out.push_str(sign);
out.push_str(change.value());
}
out.push_str("```\n\n");
}
out
}
#[derive(Default)]
struct DebugRunArtifacts {
losslessness: Option<(String, String)>,
idempotency: Option<(String, String, String)>,
failures: Vec<DebugFailure>,
}
fn write_debug_artifacts(
dump_dir: &Path,
stem: &str,
artifacts: &DebugRunArtifacts,
dump_passes: bool,
) -> io::Result<()> {
fs::create_dir_all(dump_dir)?;
if let Some((input, tree_text)) = artifacts.losslessness.as_ref()
&& (dump_passes
|| artifacts
.failures
.iter()
.any(|failure| matches!(failure.kind, CheckKind::Losslessness)))
{
fs::write(
dump_dir.join(format!("{stem}.losslessness.input.txt")),
input,
)?;
fs::write(
dump_dir.join(format!("{stem}.losslessness.parsed.txt")),
tree_text,
)?;
}
if let Some((input, once, twice)) = artifacts.idempotency.as_ref()
&& (dump_passes
|| artifacts
.failures
.iter()
.any(|failure| matches!(failure.kind, CheckKind::Idempotency)))
{
fs::write(
dump_dir.join(format!("{stem}.idempotency.input.txt")),
input,
)?;
fs::write(dump_dir.join(format!("{stem}.idempotency.once.txt")), once)?;
fs::write(
dump_dir.join(format!("{stem}.idempotency.twice.txt")),
twice,
)?;
}
for failure in &artifacts.failures {
let kind = failure.kind.label();
fs::write(
dump_dir.join(format!("{stem}.{kind}.left.txt")),
&failure.left,
)?;
fs::write(
dump_dir.join(format!("{stem}.{kind}.right.txt")),
&failure.right,
)?;
}
Ok(())
}
fn run_debug_checks_for_content(
input: &str,
cfg: &panache::Config,
checks: DebugChecks,
target_label: &str,
) -> DebugRunArtifacts {
let mut artifacts = DebugRunArtifacts::default();
log::debug!(
"debug format: start checks={} target={}",
format!("{:?}", checks).to_lowercase(),
target_label
);
if matches!(checks, DebugChecks::Losslessness | DebugChecks::All) {
log::debug!("debug format: losslessness start target={}", target_label);
let tree_text = parse(input, Some(cfg.clone())).text().to_string();
artifacts.losslessness = Some((input.to_string(), tree_text.clone()));
if input != tree_text {
artifacts.failures.push(DebugFailure {
kind: CheckKind::Losslessness,
left: input.to_string(),
right: tree_text,
});
}
log::debug!("debug format: losslessness end target={}", target_label);
}
if matches!(checks, DebugChecks::Idempotency | DebugChecks::All) {
log::debug!(
"debug format: idempotency pass1 start target={}",
target_label
);
let once = format(input, Some(cfg.clone()), None);
log::debug!(
"debug format: idempotency pass1 end target={}",
target_label
);
log::debug!(
"debug format: idempotency pass2 start target={}",
target_label
);
let twice = format(&once, Some(cfg.clone()), None);
log::debug!(
"debug format: idempotency pass2 end target={}",
target_label
);
artifacts.idempotency = Some((input.to_string(), once.clone(), twice.clone()));
if once != twice {
artifacts.failures.push(DebugFailure {
kind: CheckKind::Idempotency,
left: once,
right: twice,
});
}
}
log::debug!(
"debug format: end target={} failures={}",
target_label,
artifacts.failures.len()
);
artifacts
}
fn main() -> io::Result<()> {
let cli = Cli::parse();
let use_color = color_enabled(cli.color, cli.no_color);
panache::set_warning_color_override(use_color);
let debug_log = match &cli.command {
Commands::Lsp { debug } if *debug => Some(init_lsp_debug_log()?),
_ => None,
};
init_logger(debug_log.as_deref());
match cli.command {
Commands::Parse { file, to, json } => {
let file = normalize_parse_path_or_exit(file);
let input_path = file.as_deref().or(cli.stdin_filename.as_deref());
let start_dir = start_dir_for(input_path)?;
let (cfg, cfg_source) = load_config_for_cli(
cli.config.as_deref(),
cli.isolated,
cli.cache_dir.as_deref(),
&start_dir,
input_path,
cli.flavor.map(Flavor::from),
)?;
if let Some(path) = cfg_source.path() {
log::debug!("Using config from: {}", path.display());
} else {
log::debug!("Using default config");
}
let input = read_all(file.as_ref())?;
let tree = parse(&input, Some(cfg));
if let Some(json_path) = json {
let json_value = panache::syntax::cst_to_json(&tree);
let json_output =
serde_json::to_string_pretty(&json_value).map_err(io::Error::other)?;
fs::write(json_path, json_output)?;
}
if !cli.quiet {
match to {
ParseOutput::Cst => println!("{:#?}", tree),
ParseOutput::PandocAst => {
println!("{}", panache::parser::to_pandoc_ast(&tree));
}
ParseOutput::PandocJson => {
println!("{}", panache::parser::to_pandoc_json(&tree));
}
}
}
Ok(())
}
Commands::Format {
files,
check,
range,
force_exclude,
option,
} => {
let files = normalize_input_paths_or_exit(files, "format", FORMAT_MISSING_INPUT);
let parsed_range = if let Some(range_str) = range {
if files.len() > 1 {
eprintln!("Error: --range cannot be used with multiple files");
std::process::exit(1);
}
match parse_range(&range_str) {
Ok(r) => Some(r),
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
} else {
None
};
if files.is_empty() {
let start_dir = start_dir_for(cli.stdin_filename.as_deref())?;
let (mut cfg, cfg_source) = load_config_for_cli(
cli.config.as_deref(),
cli.isolated,
cli.cache_dir.as_deref(),
&start_dir,
cli.stdin_filename.as_deref(),
cli.flavor.map(Flavor::from),
)?;
if let Err(err) = apply_format_overrides(&mut cfg, &option) {
eprintln!("Error: {err}");
std::process::exit(2);
}
if let Some(path) = cfg_source.path() {
log::debug!("Using config from: {}", path.display());
} else {
log::debug!("Using default config");
}
let input = read_all(None)?;
let output = format(&input, Some(cfg), parsed_range);
if check {
if input != output {
print_diff("<stdin>", &input, &output, use_color);
std::process::exit(1);
}
} else {
print!("{output}");
}
return Ok(());
}
let traversal_anchor = files.first().map(PathBuf::as_path);
let traversal_start_dir = if let Some(anchor) = traversal_anchor {
if anchor.is_dir() {
anchor.to_path_buf()
} else {
start_dir_for(Some(anchor))?
}
} else {
start_dir_for(None)?
};
let (traversal_cfg, traversal_cfg_source) = load_config_for_cli(
cli.config.as_deref(),
cli.isolated,
cli.cache_dir.as_deref(),
&traversal_start_dir,
traversal_anchor,
cli.flavor.map(Flavor::from),
)?;
let anchor = panache::config::anchor_dir(&traversal_cfg_source, &traversal_start_dir);
let expanded_files = expand_paths(
&files,
&traversal_cfg,
&anchor,
force_exclude,
cli.flavor.is_some(),
)?;
let mut cache = if cli.no_cache || !traversal_cfg.cache {
None
} else {
open_cli_cache_best_effort(
&traversal_cfg,
cli.config.as_deref(),
&traversal_start_dir,
)
};
if expanded_files.is_empty() {
if force_exclude {
return Ok(());
}
if has_explicit_file_targets(&files) {
eprintln!("Error: No supported files found");
std::process::exit(1);
}
if !cli.quiet {
println!("No supported files found");
}
return Ok(());
}
let workers = effective_parallelism(cli.jobs, expanded_files.len());
let parallel = workers > 1;
struct FormatOutcome {
file_path: PathBuf,
input: String,
output: String,
}
let cache_shared: Option<Arc<Mutex<CliCache>>> =
cache.take().map(|c| Arc::new(Mutex::new(c)));
let process_file = |file_path: &PathBuf| -> io::Result<FormatOutcome> {
let start_dir = file_path.parent().unwrap_or(Path::new(".")).to_path_buf();
let (mut cfg, cfg_source) = load_config_for_cli(
cli.config.as_deref(),
cli.isolated,
cli.cache_dir.as_deref(),
&start_dir,
Some(file_path),
cli.flavor.map(Flavor::from),
)?;
if let Err(err) = apply_format_overrides(&mut cfg, &option) {
eprintln!("Error: {err}");
std::process::exit(2);
}
panache::init_external_tool_budget(cfg.external_max_parallel);
if parallel {
cfg.external_max_parallel =
per_file_external_parallel(cfg.external_max_parallel, workers);
}
if let Some(path) = cfg_source.path() {
log::debug!("Using config from: {}", path.display());
} else {
log::debug!("Using default config");
}
let input = fs::read_to_string(file_path)?;
let mode = if check {
FormatCacheMode::Check
} else {
FormatCacheMode::Write
};
let output = if parsed_range.is_none() {
if let Some(cache_handle) = cache_shared.as_ref() {
let file_fingerprint = CliCache::file_fingerprint(&input);
let config_fingerprint = CliCache::config_fingerprint(&cfg);
let tool_fingerprint = CliCache::tool_fingerprint();
let cached = {
let guard = cache_handle.lock().unwrap();
if guard.supports_format_mode(&cfg, mode) {
guard
.get_format(
file_path,
mode,
&file_fingerprint,
&config_fingerprint,
&tool_fingerprint,
)
.map(|hit| hit.1)
} else {
None
}
};
if let Some(cached) = cached {
cached
} else {
let output = format(&input, Some(cfg.clone()), parsed_range);
let mut guard = cache_handle.lock().unwrap();
if guard.supports_format_mode(&cfg, mode) {
let unchanged = input == output;
guard.put_format(
file_path,
mode,
FormatStoreArgs {
file_fingerprint,
config_fingerprint,
tool_fingerprint,
unchanged,
output: output.clone(),
},
);
}
output
}
} else {
format(&input, Some(cfg.clone()), parsed_range)
}
} else {
format(&input, Some(cfg.clone()), parsed_range)
};
Ok(FormatOutcome {
file_path: file_path.clone(),
input,
output,
})
};
let outcomes: Vec<io::Result<FormatOutcome>> = if parallel {
use rayon::prelude::*;
let pool = build_pool(workers);
pool.install(|| expanded_files.par_iter().map(&process_file).collect())
} else {
expanded_files.iter().map(&process_file).collect()
};
if let Some(handle) = cache_shared {
cache = Some(
Arc::try_unwrap(handle)
.map_err(|_| {
io::Error::other("cache Arc still shared after parallel pass")
})?
.into_inner()
.map_err(|e| io::Error::other(format!("cache mutex poisoned: {e}")))?,
);
}
let mut all_formatted = true;
let mut reformatted_count = 0usize;
let mut unchanged_count = 0usize;
let mut changed_count = 0usize;
for outcome in outcomes {
let o = outcome?;
if check {
if o.input != o.output {
if cli.quiet {
println!("would reformat {}", o.file_path.display());
} else {
let file_name = o.file_path.to_str().unwrap_or("<unknown>");
print_diff(file_name, &o.input, &o.output, use_color);
}
changed_count += 1;
all_formatted = false;
} else if expanded_files.len() == 1 && !cli.quiet {
println!("{} is correctly formatted", o.file_path.display());
}
} else if o.input != o.output {
fs::write(&o.file_path, &o.output)?;
if !cli.quiet {
println!("Formatted {}", o.file_path.display());
}
reformatted_count += 1;
} else {
unchanged_count += 1;
}
}
if check {
if all_formatted {
if expanded_files.len() > 1 && !cli.quiet {
println!("All {} files are correctly formatted", expanded_files.len());
}
} else {
if cli.quiet {
println!(
"{} of {} file(s) would be reformatted",
changed_count,
expanded_files.len()
);
}
std::process::exit(1);
}
} else if !cli.quiet {
if reformatted_count == 0 {
println!(
"{}",
file_count_label(
unchanged_count,
"file left unchanged",
"files left unchanged"
)
);
} else {
println!(
"{}, {}",
file_count_label(
reformatted_count,
"file reformatted",
"files reformatted"
),
file_count_label(
unchanged_count,
"file left unchanged",
"files left unchanged"
)
);
}
}
if let Some(cache_ref) = cache.as_mut() {
cache_ref.save_if_dirty()?;
}
Ok(())
}
Commands::Clean { all, dry_run } => {
let start_dir = start_dir_for(None)?;
let (cfg, _) = load_config_for_cli(
cli.config.as_deref(),
cli.isolated,
cli.cache_dir.as_deref(),
&start_dir,
None,
cli.flavor.map(Flavor::from),
)?;
let report_clean = |message: String| {
if !cli.quiet {
println!("{message}");
}
};
let summarize = |path: &Path| -> io::Result<Option<(usize, u64)>> {
if dry_run || cli.verbose {
summarize_dir(path)
} else {
Ok(None)
}
};
let removed_verb = if dry_run { "Would remove" } else { "Removed" };
let act = |path: &Path| -> io::Result<bool> {
if dry_run {
Ok(path.exists())
} else {
remove_dir_if_exists(path)
}
};
if all {
if cfg.cache_dir.is_some() {
let cache_dir =
resolve_cache_dir_for_cli(&cfg, cli.config.as_deref(), &start_dir)?;
let summary = summarize(&cache_dir)?;
let removed = act(&cache_dir)?;
if removed {
report_clean(format!(
"{removed_verb} cache directory {}{}",
cache_dir.display(),
format_clean_summary(summary)
));
} else {
report_clean(format!(
"No cache directory found at {}",
cache_dir.display()
));
}
} else if let Some(global_base) = global_cache_base_dir() {
let summary = summarize(&global_base)?;
let removed = act(&global_base)?;
if removed {
report_clean(format!(
"{removed_verb} all cache buckets at {}{}",
global_base.display(),
format_clean_summary(summary)
));
} else {
report_clean(format!(
"No cache buckets found at {}",
global_base.display()
));
}
} else {
let cache_dir =
resolve_cache_dir_for_cli(&cfg, cli.config.as_deref(), &start_dir)?;
let summary = summarize(&cache_dir)?;
let removed = act(&cache_dir)?;
if removed {
report_clean(format!(
"{removed_verb} cache directory {}{}",
cache_dir.display(),
format_clean_summary(summary)
));
} else {
report_clean(format!(
"No cache directory found at {}",
cache_dir.display()
));
}
}
} else {
let cache_dir = resolve_cache_dir_for_cli(&cfg, cli.config.as_deref(), &start_dir)?;
let summary = summarize(&cache_dir)?;
let removed = act(&cache_dir)?;
if removed {
report_clean(format!(
"{removed_verb} cache directory {}{}",
cache_dir.display(),
format_clean_summary(summary)
));
} else {
report_clean(format!(
"No cache directory found at {}",
cache_dir.display()
));
}
}
Ok(())
}
Commands::Debug { command } => match command {
DebugCommands::Format {
files,
checks,
json,
report,
dump_dir,
dump_passes,
force_exclude,
} => {
if json && report {
eprintln!("Error: --json and --report cannot be used together");
std::process::exit(1);
}
if dump_passes && dump_dir.is_none() {
eprintln!("Error: --dump-passes requires --dump-dir <DIR>");
std::process::exit(1);
}
let files = normalize_input_paths_or_exit(
files,
"debug format",
DEBUG_FORMAT_MISSING_INPUT,
);
let use_stdin = files.is_empty();
let targets = if use_stdin {
vec![]
} else {
let traversal_anchor = files.first().map(PathBuf::as_path);
let traversal_start_dir = if let Some(anchor) = traversal_anchor {
if anchor.is_dir() {
anchor.to_path_buf()
} else {
start_dir_for(Some(anchor))?
}
} else {
start_dir_for(None)?
};
let (traversal_cfg, traversal_cfg_source) = load_config_for_cli(
cli.config.as_deref(),
cli.isolated,
cli.cache_dir.as_deref(),
&traversal_start_dir,
traversal_anchor,
cli.flavor.map(Flavor::from),
)?;
let anchor =
panache::config::anchor_dir(&traversal_cfg_source, &traversal_start_dir);
expand_paths(
&files,
&traversal_cfg,
&anchor,
force_exclude,
cli.flavor.is_some(),
)?
};
if !use_stdin && targets.is_empty() {
if has_explicit_file_targets(&files) {
eprintln!("Error: No supported files found");
std::process::exit(1);
}
if json {
let output = json!({
"checks": format!("{:?}", checks).to_lowercase(),
"files_checked": 0,
"failure_count": 0,
"failures": Vec::<serde_json::Value>::new(),
});
println!(
"{}",
serde_json::to_string_pretty(&output).map_err(io::Error::other)?
);
} else if !cli.quiet {
println!("No supported files found");
}
return Ok(());
}
let mut files_checked = 0usize;
let mut failure_count = 0usize;
let mut json_failures = Vec::new();
let mut collected_failures: Vec<(String, DebugFailure)> = Vec::new();
if use_stdin {
let start_dir = start_dir_for(cli.stdin_filename.as_deref())?;
let (cfg, _) = load_config_for_cli(
cli.config.as_deref(),
cli.isolated,
cli.cache_dir.as_deref(),
&start_dir,
cli.stdin_filename.as_deref(),
cli.flavor.map(Flavor::from),
)?;
let input = read_all(None)?;
files_checked += 1;
let artifacts = run_debug_checks_for_content(&input, &cfg, checks, "<stdin>");
if let Some(dir) = dump_dir.as_ref() {
write_debug_artifacts(dir, "stdin", &artifacts, dump_passes)?;
}
for failure in &artifacts.failures {
failure_count += 1;
if !json && !report {
eprintln!("Debug check failed ({}) in <stdin>", failure.kind.label());
print_diff("<stdin>", &failure.left, &failure.right, use_color);
}
json_failures.push(json!({
"file": "<stdin>",
"kind": failure.kind.label(),
}));
if report {
collected_failures.push(("<stdin>".to_string(), failure.clone()));
}
}
} else {
for file_path in &targets {
let start_dir = file_path.parent().unwrap_or(Path::new(".")).to_path_buf();
let (cfg, _) = load_config_for_cli(
cli.config.as_deref(),
cli.isolated,
cli.cache_dir.as_deref(),
&start_dir,
Some(file_path),
cli.flavor.map(Flavor::from),
)?;
let input = fs::read_to_string(file_path)?;
files_checked += 1;
let file_label = file_path.to_str().unwrap_or("<unknown>");
let artifacts =
run_debug_checks_for_content(&input, &cfg, checks, file_label);
if let Some(dir) = dump_dir.as_ref() {
let safe = sanitize_path_for_filename(file_label);
write_debug_artifacts(dir, &safe, &artifacts, dump_passes)?;
}
for failure in &artifacts.failures {
failure_count += 1;
if !json && !report {
eprintln!(
"Debug check failed ({}) in {}",
failure.kind.label(),
file_label
);
print_diff(file_label, &failure.left, &failure.right, use_color);
}
json_failures.push(json!({
"file": file_label,
"kind": failure.kind.label(),
}));
if report {
collected_failures.push((file_label.to_string(), failure.clone()));
}
}
}
}
if json {
let output = json!({
"checks": format!("{:?}", checks).to_lowercase(),
"files_checked": files_checked,
"failure_count": failure_count,
"failures": json_failures,
});
println!(
"{}",
serde_json::to_string_pretty(&output).map_err(io::Error::other)?
);
} else if report {
let markdown =
build_debug_failure_report(checks, files_checked, &collected_failures);
println!("{markdown}");
} else if failure_count == 0 && !cli.quiet {
println!(
"All checks passed (checks: {}, files: {})",
format!("{:?}", checks).to_lowercase(),
files_checked
);
}
if dump_passes
&& !json
&& !cli.quiet
&& let Some(dir) = dump_dir.as_ref()
{
eprintln!("Wrote debug artifacts to {}", dir.display());
}
if failure_count > 0 && !json && !report && !cli.quiet && dump_dir.is_none() {
eprintln!(
"Tip: rerun with --dump-dir <DIR> --dump-passes to inspect input, parse, and format passes."
);
}
if failure_count > 0 {
std::process::exit(1);
}
Ok(())
}
},
#[cfg(feature = "lsp")]
Commands::Lsp { .. } => {
panache::lsp::run()?;
Ok(())
}
Commands::Lint {
files,
check,
fix,
unsafe_fixes,
message_format,
force_exclude,
} => {
if check {
eprintln!(
"Warning: the `--check` flag is deprecated; linting exits non-zero on \
violations by default. The flag is now a no-op."
);
}
let files = normalize_input_paths_or_exit(files, "lint", LINT_MISSING_INPUT);
let (manifest_files, files): (Vec<PathBuf>, Vec<PathBuf>) = files
.into_iter()
.partition(|p| panache::linter::quarto_schema::manifest_schema_root(p).is_some());
if files.is_empty() && manifest_files.is_empty() {
let start_dir = start_dir_for(cli.stdin_filename.as_deref())?;
let (cfg, cfg_source) = load_config_for_cli(
cli.config.as_deref(),
cli.isolated,
cli.cache_dir.as_deref(),
&start_dir,
cli.stdin_filename.as_deref(),
cli.flavor.map(Flavor::from),
)?;
if let Some(path) = cfg_source.path() {
log::debug!("Using config from: {}", path.display());
} else {
log::debug!("Using default config");
}
let input = read_all(None)?;
let tree = parse(&input, Some(cfg.clone()));
let stdin_path = cli
.stdin_filename
.as_deref()
.unwrap_or(Path::new("stdin.md"));
let metadata = panache::metadata::extract_project_metadata(&tree, stdin_path).ok();
let mut diagnostics = panache::linter::lint_with_external_sync_and_metadata(
&tree,
&input,
&cfg,
metadata.as_ref(),
);
let db = panache::salsa::SalsaDb::default();
let yaml_diags = panache::salsa::built_in_lint_plan(
&db,
panache::salsa::FileText::from_str(&db, input.clone()),
panache::salsa::FileConfig::new(&db, cfg.clone()),
)
.diagnostics
.iter()
.filter(|d| d.code == "yaml-parse-error")
.cloned()
.collect::<Vec<_>>();
merge_missing_diagnostics(&mut diagnostics, yaml_diags);
if diagnostics.is_empty() {
if !cli.quiet {
println!("No issues found");
}
return Ok(());
}
if fix {
let fixed = apply_fixes(&input, &diagnostics, unsafe_fixes);
print!("{}", fixed.output);
let unsafe_skipped = if unsafe_fixes {
0
} else {
count_unsafe_fixes(&diagnostics)
};
if unsafe_skipped > 0 && !cli.quiet {
eprintln!("{}", unsafe_fixes_hint(unsafe_skipped));
}
if fixed.conflicted > 0 && !cli.quiet {
eprintln!("{}", conflicting_fixes_hint(fixed.conflicted));
}
return Ok(());
}
if !cli.quiet {
print_diagnostics(
&diagnostics,
None,
Some(&input),
use_color,
message_format,
true,
);
}
std::process::exit(1);
}
let traversal_anchor = files.first().map(PathBuf::as_path);
let traversal_start_dir = if let Some(anchor) = traversal_anchor {
if anchor.is_dir() {
anchor.to_path_buf()
} else {
start_dir_for(Some(anchor))?
}
} else {
start_dir_for(None)?
};
let (traversal_cfg, traversal_cfg_source) = load_config_for_cli(
cli.config.as_deref(),
cli.isolated,
cli.cache_dir.as_deref(),
&traversal_start_dir,
traversal_anchor,
cli.flavor.map(Flavor::from),
)?;
let anchor = panache::config::anchor_dir(&traversal_cfg_source, &traversal_start_dir);
let expanded_files = expand_paths(
&files,
&traversal_cfg,
&anchor,
force_exclude,
cli.flavor.is_some(),
)?;
let mut cache = if cli.no_cache || !traversal_cfg.cache {
None
} else {
open_cli_cache_best_effort(
&traversal_cfg,
cli.config.as_deref(),
&traversal_start_dir,
)
};
if expanded_files.is_empty() && manifest_files.is_empty() {
if force_exclude {
return Ok(());
}
if has_explicit_file_targets(&files) {
eprintln!("Error: No supported files found");
std::process::exit(1);
}
if !cli.quiet {
println!("No supported files found");
}
return Ok(());
}
let workers = effective_parallelism(cli.jobs, expanded_files.len());
let parallel = workers > 1;
let cache_shared: Option<Arc<Mutex<CliCache>>> =
cache.take().map(|c| Arc::new(Mutex::new(c)));
struct PreparedJob {
idx: usize,
file_path: PathBuf,
input: String,
cfg: panache::Config,
file_config: panache::salsa::FileConfig,
file_text: panache::salsa::FileText,
cache_store: Option<(String, String, String)>,
}
enum Prepared {
Cached(usize, Box<LintOutcome>),
Failed(usize, io::Error),
Job(Box<PreparedJob>),
}
let prepare = |idx: usize,
file_path: &Path,
db: &mut panache::salsa::SalsaDb,
intern: &mut Vec<(panache::Config, panache::salsa::FileConfig)>|
-> Prepared {
let mut load = || -> io::Result<Prepared> {
let start_dir = file_path.parent().unwrap_or(Path::new(".")).to_path_buf();
let (mut cfg, cfg_source) = load_config_for_cli(
cli.config.as_deref(),
cli.isolated,
cli.cache_dir.as_deref(),
&start_dir,
Some(file_path),
cli.flavor.map(Flavor::from),
)?;
panache::init_external_tool_budget(cfg.external_max_parallel);
if parallel {
cfg.external_max_parallel =
per_file_external_parallel(cfg.external_max_parallel, workers);
}
if let Some(path) = cfg_source.path() {
log::debug!("Using config from: {}", path.display());
} else {
log::debug!("Using default config");
}
let input = fs::read_to_string(file_path)?;
let (supports, fingerprints, cached_docs) =
if let Some(cache_handle) = cache_shared.as_ref() {
let ff = CliCache::file_fingerprint(&input);
let cf = CliCache::config_fingerprint(&cfg);
let tf = CliCache::tool_fingerprint();
let guard = cache_handle.lock().unwrap();
let supports = guard.supports_lint(&cfg);
let hit = if supports {
guard
.get_lint(file_path, &ff, &cf, &tf)
.filter(|docs| cached_lint_documents_are_fresh(docs))
} else {
None
};
(supports, Some((ff, cf, tf)), hit)
} else {
(false, None, None)
};
if let Some(docs) = cached_docs {
let documents = docs
.iter()
.map(linted_document_from_cached)
.collect::<Vec<_>>();
return Ok(Prepared::Cached(
idx,
Box::new(build_lint_outcome(file_path, documents)),
));
}
let file_config = match intern.iter().find(|(c, _)| c == &cfg) {
Some((_, handle)) => *handle,
None => {
let handle = panache::salsa::FileConfig::new(db, cfg.clone());
intern.push((cfg.clone(), handle));
handle
}
};
let file_text = db.update_file_text(file_path.to_path_buf(), input.clone());
Ok(Prepared::Job(Box::new(PreparedJob {
idx,
file_path: file_path.to_path_buf(),
input,
cfg,
file_config,
file_text,
cache_store: if supports { fingerprints } else { None },
})))
};
match load() {
Ok(prepared) => prepared,
Err(err) => Prepared::Failed(idx, err),
}
};
let process_group =
|files: &[(usize, PathBuf)]| -> Vec<(usize, io::Result<LintOutcome>)> {
use rayon::prelude::*;
let mut db = panache::salsa::SalsaDb::default();
let mut intern: Vec<(panache::Config, panache::salsa::FileConfig)> = Vec::new();
let mut results: Vec<(usize, io::Result<LintOutcome>)> = Vec::new();
let mut jobs: Vec<PreparedJob> = Vec::new();
for (idx, file_path) in files {
match prepare(*idx, file_path, &mut db, &mut intern) {
Prepared::Cached(i, outcome) => results.push((i, Ok(*outcome))),
Prepared::Failed(i, err) => results.push((i, Err(err))),
Prepared::Job(job) => jobs.push(*job),
}
}
let is_project = files.first().is_some_and(|(_, p)| {
let canonical = p.canonicalize().unwrap_or_else(|_| p.clone());
panache::includes::find_project_roots(&canonical)
.quarto_first()
.is_some()
});
{
let mut loaded_by_config: Vec<(
panache::salsa::FileConfig,
std::collections::HashSet<PathBuf>,
)> = Vec::new();
for job in &jobs {
let covered = loaded_by_config
.iter()
.find(|(c, _)| *c == job.file_config)
.is_some_and(|(_, set)| set.contains(&job.file_path));
if covered {
continue;
}
let loaded = db.load_referenced_files(
job.file_text,
job.file_config,
job.file_path.clone(),
);
match loaded_by_config
.iter_mut()
.find(|(c, _)| *c == job.file_config)
{
Some((_, set)) => set.extend(loaded),
None => loaded_by_config.push((job.file_config, loaded)),
}
}
}
let mut graph_by_config: Vec<(
panache::salsa::FileConfig,
ProjectGraphDiagnostics,
)> = Vec::new();
if is_project {
for job in &jobs {
if graph_by_config.iter().any(|(c, _)| *c == job.file_config) {
continue;
}
let mut map: ProjectGraphDiagnostics = std::collections::HashMap::new();
for entry in panache::salsa::project_graph::accumulated::<
panache::salsa::GraphDiagnostic,
>(
&db, job.file_text, job.file_config
) {
map.entry(entry.0.path.clone())
.or_default()
.push(entry.0.diagnostic.clone());
}
graph_by_config.push((job.file_config, map));
}
}
let graph_ref = &graph_by_config;
let lint_job = |job: &PreparedJob,
db: panache::salsa::SalsaDb|
-> (usize, io::Result<LintOutcome>) {
let graph_diags = if is_project {
graph_ref
.iter()
.find(|(c, _)| *c == job.file_config)
.map(|(_, map)| map)
} else {
None
};
let mut documents = Vec::new();
let mut visited = std::collections::HashSet::new();
let mut active = std::collections::HashSet::new();
match lint_loaded_document_with_includes(
&job.file_path,
&job.input,
Some(job.file_text),
&job.cfg,
job.file_config,
&mut documents,
&mut visited,
&mut active,
&db,
graph_diags,
) {
Ok(()) => {
if let (Some(cache_handle), Some((ff, cf, tf))) =
(cache_shared.as_ref(), job.cache_store.as_ref())
{
let mut guard = cache_handle.lock().unwrap();
if guard.supports_lint(&job.cfg) {
let cached_docs = documents
.iter()
.map(cached_lint_document_from_linted)
.collect::<Vec<_>>();
guard.put_lint(
&job.file_path,
ff.clone(),
cf.clone(),
tf.clone(),
cached_docs,
);
}
}
(job.idx, Ok(build_lint_outcome(&job.file_path, documents)))
}
Err(err) => (job.idx, Err(err)),
}
};
let job_dbs: Vec<panache::salsa::SalsaDb> =
jobs.iter().map(|_| db.clone()).collect();
let job_results: Vec<(usize, io::Result<LintOutcome>)> = if parallel {
jobs.par_iter()
.zip(job_dbs.into_par_iter())
.map(|(job, job_db)| lint_job(job, job_db))
.collect()
} else {
jobs.iter()
.zip(job_dbs)
.map(|(job, job_db)| lint_job(job, job_db))
.collect()
};
results.extend(job_results);
results
};
let group_key = |file_path: &Path| -> PathBuf {
let canonical = file_path
.canonicalize()
.unwrap_or_else(|_| file_path.to_path_buf());
panache::includes::find_project_roots(&canonical)
.quarto_first()
.unwrap_or(canonical)
};
let mut group_map: std::collections::HashMap<PathBuf, Vec<(usize, PathBuf)>> =
std::collections::HashMap::new();
for (idx, file_path) in expanded_files.iter().enumerate() {
group_map
.entry(group_key(file_path))
.or_default()
.push((idx, file_path.clone()));
}
let groups: Vec<Vec<(usize, PathBuf)>> = group_map.into_values().collect();
let mut indexed: Vec<(usize, io::Result<LintOutcome>)> = if parallel {
use rayon::prelude::*;
let pool = build_pool(workers);
pool.install(|| groups.par_iter().flat_map(|g| process_group(g)).collect())
} else {
groups.iter().flat_map(|g| process_group(g)).collect()
};
indexed.sort_by_key(|(idx, _)| *idx);
let outcomes: Vec<io::Result<LintOutcome>> =
indexed.into_iter().map(|(_, outcome)| outcome).collect();
if let Some(handle) = cache_shared {
cache = Some(
Arc::try_unwrap(handle)
.map_err(|_| {
io::Error::other("cache Arc still shared after parallel pass")
})?
.into_inner()
.map_err(|e| io::Error::other(format!("cache mutex poisoned: {e}")))?,
);
}
let mut any_issues = false;
let mut total_issues = 0;
let canonical_path =
|path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let mut reported_paths: std::collections::HashSet<PathBuf> =
expanded_files.iter().map(|p| canonical_path(p)).collect();
for outcome in outcomes {
let LintOutcome {
file_path,
root_doc,
included_docs,
} = outcome?;
let Some(root_doc) = root_doc else {
continue;
};
if !root_doc.diagnostics.is_empty() {
any_issues = true;
total_issues += root_doc.diagnostics.len();
if fix {
let fixable = root_doc
.diagnostics
.iter()
.filter(|d| fix_will_apply(d, unsafe_fixes))
.count();
let remaining: Vec<_> = root_doc
.diagnostics
.iter()
.filter(|d| !fix_will_apply(d, unsafe_fixes))
.cloned()
.collect();
let no_fix_count = root_doc
.diagnostics
.iter()
.filter(|d| d.fix.is_none())
.count();
let unsafe_skipped = if unsafe_fixes {
0
} else {
count_unsafe_fixes(&root_doc.diagnostics)
};
let mut conflicted = 0;
if fixable > 0 {
let fixed =
apply_fixes(&root_doc.input, &root_doc.diagnostics, unsafe_fixes);
conflicted = fixed.conflicted;
fs::write(&file_path, fixed.output)?;
}
let fixable = fixable.saturating_sub(conflicted);
if !remaining.is_empty() && !cli.quiet {
print_diagnostics(
&remaining,
Some(file_path.as_path()),
Some(&root_doc.input),
use_color,
message_format,
false,
);
}
if !cli.quiet {
print_fix_summary(fixable, no_fix_count, &file_path);
if unsafe_skipped > 0 {
println!("{}", unsafe_fixes_hint(unsafe_skipped));
}
if conflicted > 0 {
println!("{}", conflicting_fixes_hint(conflicted));
}
}
} else if !cli.quiet {
print_diagnostics(
&root_doc.diagnostics,
Some(file_path.as_path()),
Some(&root_doc.input),
use_color,
message_format,
true,
);
}
}
if !fix {
for doc in &included_docs {
if doc.diagnostics.is_empty() {
continue;
}
if !reported_paths.insert(canonical_path(&doc.path)) {
continue;
}
any_issues = true;
total_issues += doc.diagnostics.len();
if !cli.quiet {
print_diagnostics(
&doc.diagnostics,
Some(doc.path.as_path()),
Some(&doc.input),
use_color,
message_format,
true,
);
}
}
}
}
if let Some(cache_ref) = cache.as_mut() {
cache_ref.save_if_dirty()?;
}
for manifest_path in &manifest_files {
let manifest_doc = match lint_quarto_manifest(
manifest_path,
cli.config.as_deref(),
cli.isolated,
cli.cache_dir.as_deref(),
cli.flavor.map(Flavor::from),
) {
Ok(doc) => doc,
Err(err) => {
eprintln!("Error: {}: {}", manifest_path.display(), err);
std::process::exit(1);
}
};
if manifest_doc.diagnostics.is_empty() {
continue;
}
any_issues = true;
total_issues += manifest_doc.diagnostics.len();
if !cli.quiet {
print_diagnostics(
&manifest_doc.diagnostics,
Some(manifest_path.as_path()),
Some(&manifest_doc.input),
use_color,
message_format,
true,
);
}
}
if files.iter().any(|p| p.is_dir()) {
let explicit: std::collections::HashSet<PathBuf> =
manifest_files.iter().cloned().collect();
let mut discovered: std::collections::BTreeSet<PathBuf> =
std::collections::BTreeSet::new();
for doc in &expanded_files {
for manifest in panache::metadata::project_manifests_for(doc) {
if !explicit.contains(&manifest) {
discovered.insert(manifest);
}
}
}
for manifest_path in discovered {
let manifest_doc = match lint_manifest_bibliography(
&manifest_path,
cli.config.as_deref(),
cli.isolated,
cli.cache_dir.as_deref(),
cli.flavor.map(Flavor::from),
) {
Ok(doc) => doc,
Err(err) => {
eprintln!("Error: {}: {}", manifest_path.display(), err);
std::process::exit(1);
}
};
if manifest_doc.diagnostics.is_empty() {
continue;
}
any_issues = true;
total_issues += manifest_doc.diagnostics.len();
if !cli.quiet {
print_diagnostics(
&manifest_doc.diagnostics,
Some(manifest_path.as_path()),
Some(&manifest_doc.input),
use_color,
message_format,
true,
);
}
}
}
let total_files = expanded_files.len() + manifest_files.len();
if !any_issues && !cli.quiet {
println!("No issues found in {} file(s)", total_files);
}
if any_issues && !fix {
eprintln!(
"\nFound {} issue(s) across {} file(s)",
total_issues, total_files
);
std::process::exit(1);
}
Ok(())
}
}
}
#[derive(Debug, Clone)]
struct LintedDocument {
path: PathBuf,
input: String,
diagnostics: Vec<panache::linter::Diagnostic>,
}
fn lint_quarto_manifest(
path: &Path,
config: Option<&Path>,
isolated: bool,
cache_dir: Option<&Path>,
flavor: Option<Flavor>,
) -> io::Result<LintedDocument> {
let input = fs::read_to_string(path)?;
let root = panache::linter::quarto_schema::manifest_schema_root(path)
.expect("caller only passes recognized manifest paths");
let start_dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
let (cfg, _cfg_source) =
load_config_for_cli(config, isolated, cache_dir, &start_dir, Some(path), flavor)?;
let quarto = cfg.flavor == Flavor::Quarto;
let type_enum_enabled = quarto && cfg.lint.is_rule_enabled("quarto-schema");
let unknown_key_enabled = quarto
&& cfg
.lint
.is_rule_explicitly_enabled("quarto-schema-unknown-key");
let mut diagnostics = panache::linter::quarto_schema::lint_manifest_text(
&input,
root,
type_enum_enabled,
unknown_key_enabled,
);
if cfg.extensions.citations && cfg.lint.is_rule_enabled("citation-keys") {
diagnostics.extend(
panache::linter::metadata_diagnostics::manifest_bibliography_diagnostics(path, &input),
);
diagnostics.sort_by_key(|d| (d.location.line, d.location.column));
}
Ok(LintedDocument {
path: path.to_path_buf(),
input,
diagnostics,
})
}
fn lint_manifest_bibliography(
path: &Path,
config: Option<&Path>,
isolated: bool,
cache_dir: Option<&Path>,
flavor: Option<Flavor>,
) -> io::Result<LintedDocument> {
let input = fs::read_to_string(path)?;
let start_dir = path.parent().unwrap_or(Path::new(".")).to_path_buf();
let (cfg, _cfg_source) =
load_config_for_cli(config, isolated, cache_dir, &start_dir, Some(path), flavor)?;
let diagnostics = if cfg.extensions.citations && cfg.lint.is_rule_enabled("citation-keys") {
panache::linter::metadata_diagnostics::manifest_bibliography_diagnostics(path, &input)
} else {
Vec::new()
};
Ok(LintedDocument {
path: path.to_path_buf(),
input,
diagnostics,
})
}
struct LintOutcome {
file_path: PathBuf,
root_doc: Option<LintedDocument>,
included_docs: Vec<LintedDocument>,
}
fn build_lint_outcome(file_path: &Path, documents: Vec<LintedDocument>) -> LintOutcome {
let root_doc = documents
.iter()
.find(|doc| doc.path.as_path() == file_path)
.cloned();
let mut included_docs: Vec<LintedDocument> = documents
.into_iter()
.filter(|doc| doc.path.as_path() != file_path)
.collect();
included_docs.sort_by(|a, b| a.path.cmp(&b.path));
LintOutcome {
file_path: file_path.to_path_buf(),
root_doc,
included_docs,
}
}
type ProjectGraphDiagnostics = std::collections::HashMap<PathBuf, Vec<panache::linter::Diagnostic>>;
#[allow(clippy::too_many_arguments, clippy::only_used_in_recursion)]
fn lint_loaded_document_with_includes(
doc_path: &Path,
input: &str,
file_text: Option<panache::salsa::FileText>,
cfg: &panache::Config,
file_config: panache::salsa::FileConfig,
results: &mut Vec<LintedDocument>,
visited: &mut std::collections::HashSet<PathBuf>,
active: &mut std::collections::HashSet<PathBuf>,
db: &panache::salsa::SalsaDb,
graph_diags: Option<&ProjectGraphDiagnostics>,
) -> io::Result<()> {
if !visited.insert(doc_path.to_path_buf()) {
return Ok(());
}
active.insert(doc_path.to_path_buf());
let file_text = file_text
.or_else(|| db.file_text_if_cached(doc_path))
.unwrap_or_else(|| panache::salsa::FileText::from_str(db, input));
let plan = panache::salsa::built_in_lint_plan(db, file_text, file_config).clone();
let mut diagnostics = plan.diagnostics;
if !plan.external_jobs.is_empty() {
diagnostics.extend(run_external_lint_jobs_sync(&plan.external_jobs, input));
diagnostics.sort_by_key(|d| (d.location.line, d.location.column));
}
let tree = panache::salsa::parsed_tree_root(db, file_text, file_config);
let base_dir = doc_path.parent().unwrap_or(Path::new("."));
let roots = panache::includes::find_project_roots(doc_path);
let project_root = roots.quarto.clone();
let resolution =
panache::includes::collect_includes(&tree, input, base_dir, project_root.as_deref(), cfg);
diagnostics.extend(resolution.diagnostics);
if let Some(graph_diags) = graph_diags {
if let Some(entries) = graph_diags.get(doc_path) {
diagnostics.extend(entries.iter().cloned());
}
} else if roots.quarto.is_some() || roots.bookdown.is_some() || !resolution.includes.is_empty()
{
let graph_diags = panache::salsa::project_graph::accumulated::<
panache::salsa::GraphDiagnostic,
>(db, file_text, file_config);
for entry in graph_diags {
if entry.0.path.as_path() == doc_path {
diagnostics.push(entry.0.diagnostic.clone());
}
}
}
for include in &resolution.includes {
if active.contains(&include.path) {
diagnostics.push(panache::includes::include_cycle_diagnostic(
input,
include.range,
&include.path,
));
continue;
}
if visited.contains(&include.path) {
continue;
}
match fs::read_to_string(&include.path) {
Ok(include_input) => {
lint_loaded_document_with_includes(
&include.path,
&include_input,
None,
cfg,
file_config,
results,
visited,
active,
db,
graph_diags,
)?;
}
Err(err) => {
diagnostics.push(panache::includes::include_read_error_diagnostic(
input,
include.range,
&include.path,
&err.to_string(),
));
}
}
}
diagnostics.sort_by_key(|d| (d.location.line, d.location.column));
results.push(LintedDocument {
path: doc_path.to_path_buf(),
input: input.to_string(),
diagnostics,
});
active.remove(doc_path);
Ok(())
}
fn print_fix_summary(fixed: usize, remaining: usize, file: &Path) {
match (fixed, remaining) {
(0, 0) => {}
(0, _) => println!(
"Found {} issue(s) in {} (no auto-fix available)",
remaining,
file.display()
),
(_, 0) => println!("Fixed {} issue(s) in {}", fixed, file.display()),
(_, _) => println!(
"Fixed {} issue(s) in {} ({} remaining; no auto-fix available)",
fixed,
file.display(),
remaining
),
}
}
fn fix_will_apply(diag: &panache::linter::Diagnostic, allow_unsafe: bool) -> bool {
use panache::linter::FixSafety;
diag.fix
.as_ref()
.is_some_and(|f| allow_unsafe || f.safety == FixSafety::Safe)
}
fn count_unsafe_fixes(diagnostics: &[panache::linter::Diagnostic]) -> usize {
use panache::linter::FixSafety;
diagnostics
.iter()
.filter(|d| {
d.fix
.as_ref()
.is_some_and(|f| f.safety == FixSafety::Unsafe)
})
.count()
}
fn unsafe_fixes_hint(count: usize) -> String {
format!("{count} unsafe fix(es) available; run with --unsafe-fixes to apply.")
}
fn conflicting_fixes_hint(count: usize) -> String {
format!("{count} fix(es) overlapped an applied fix and were skipped; re-run to apply.")
}
struct AppliedFixes {
output: String,
conflicted: usize,
}
fn apply_fixes(
input: &str,
diagnostics: &[panache::linter::Diagnostic],
allow_unsafe: bool,
) -> AppliedFixes {
use panache::linter::FixSafety;
use panache::linter::diagnostics::Edit;
let mut fixes: Vec<Vec<&Edit>> = diagnostics
.iter()
.filter_map(|d| d.fix.as_ref())
.filter(|f| allow_unsafe || f.safety == FixSafety::Safe)
.map(|f| {
let mut edits: Vec<&Edit> = f.edits.iter().collect();
edits.sort_by_key(|e| e.range.start());
edits
})
.filter(|edits| !edits.is_empty())
.collect();
fixes.sort_by_key(|edits| edits[0].range.start());
let mut accepted: Vec<&Edit> = Vec::new();
let mut covered_end = 0usize;
let mut conflicted = 0usize;
for edits in fixes {
let first_start: usize = edits[0].range.start().into();
if first_start < covered_end {
conflicted += 1;
continue;
}
let fix_end: usize = edits
.iter()
.map(|e| usize::from(e.range.end()))
.max()
.unwrap_or(first_start);
covered_end = fix_end.max(covered_end);
accepted.extend(edits);
}
accepted.sort_by_key(|e| e.range.start());
let mut output = String::new();
let mut last_end = 0usize;
for edit in accepted {
let start: usize = edit.range.start().into();
let end: usize = edit.range.end().into();
if start < last_end {
continue;
}
output.push_str(&input[last_end..start]);
output.push_str(&edit.replacement);
last_end = end;
}
output.push_str(&input[last_end..]);
AppliedFixes { output, conflicted }
}
fn merge_missing_diagnostics(
diagnostics: &mut Vec<panache::linter::Diagnostic>,
additional: Vec<panache::linter::Diagnostic>,
) {
for diag in additional {
if diagnostics.iter().any(|existing| {
existing.code == diag.code && existing.location.range == diag.location.range
}) {
continue;
}
diagnostics.push(diag);
}
}
fn run_external_lint_jobs_sync(
jobs: &[panache::salsa::ExternalLintJob],
input: &str,
) -> Vec<panache::linter::Diagnostic> {
use panache::linter::external_linters::ExternalLinterRegistry;
use panache::linter::external_linters_sync::run_linter_sync;
let registry = ExternalLinterRegistry::new();
let mut out = Vec::new();
for job in jobs {
match run_linter_sync(
&job.linter_name,
&job.language,
&job.content,
input,
®istry,
Some(&job.mappings),
) {
Ok(diags) => out.extend(diags),
Err(err) => log::warn!("External linter '{}' failed: {}", job.linter_name, err),
}
}
out
}
fn cached_lint_documents_are_fresh(documents: &[CachedLintDocument]) -> bool {
documents.iter().all(|doc| {
let path = PathBuf::from(&doc.path);
fs::read_to_string(path).is_ok_and(|current| current == doc.input)
})
}
fn cached_lint_document_from_linted(doc: &LintedDocument) -> CachedLintDocument {
CachedLintDocument {
path: doc.path.to_string_lossy().to_string(),
input: doc.input.clone(),
diagnostics: doc
.diagnostics
.iter()
.map(cached_diagnostic_from_runtime)
.collect(),
}
}
fn linted_document_from_cached(doc: &CachedLintDocument) -> LintedDocument {
LintedDocument {
path: PathBuf::from(&doc.path),
input: doc.input.clone(),
diagnostics: doc
.diagnostics
.iter()
.map(runtime_diagnostic_from_cached)
.collect(),
}
}
fn cached_diagnostic_from_runtime(diag: &panache::linter::Diagnostic) -> cache::CachedDiagnostic {
use cache::{
CachedDiagnostic, CachedDiagnosticNote, CachedDiagnosticNoteKind, CachedDiagnosticOrigin,
CachedEdit, CachedFix, CachedLocation, CachedSeverity,
};
let severity = match diag.severity {
panache::linter::Severity::Error => CachedSeverity::Error,
panache::linter::Severity::Warning => CachedSeverity::Warning,
panache::linter::Severity::Info => CachedSeverity::Info,
};
let origin = match diag.origin {
panache::linter::DiagnosticOrigin::BuiltIn => CachedDiagnosticOrigin::BuiltIn,
panache::linter::DiagnosticOrigin::External => CachedDiagnosticOrigin::External,
};
let notes = diag
.notes
.iter()
.map(|note| CachedDiagnosticNote {
kind: match note.kind {
panache::linter::DiagnosticNoteKind::Note => CachedDiagnosticNoteKind::Note,
panache::linter::DiagnosticNoteKind::Help => CachedDiagnosticNoteKind::Help,
},
message: note.message.clone(),
})
.collect();
let fix = diag.fix.as_ref().map(|fix| CachedFix {
message: fix.message.clone(),
edits: fix
.edits
.iter()
.map(|edit| CachedEdit {
start: u32::from(edit.range.start()),
end: u32::from(edit.range.end()),
replacement: edit.replacement.clone(),
})
.collect(),
safety: match fix.safety {
panache::linter::FixSafety::Safe => cache::CachedFixSafety::Safe,
panache::linter::FixSafety::Unsafe => cache::CachedFixSafety::Unsafe,
},
});
CachedDiagnostic {
severity,
location: CachedLocation {
line: diag.location.line,
column: diag.location.column,
start: u32::from(diag.location.range.start()),
end: u32::from(diag.location.range.end()),
},
message: diag.message.clone(),
code: diag.code.clone(),
origin,
notes,
fix,
}
}
fn runtime_diagnostic_from_cached(diag: &cache::CachedDiagnostic) -> panache::linter::Diagnostic {
use rowan::{TextRange, TextSize};
let severity = match diag.severity {
cache::CachedSeverity::Error => panache::linter::Severity::Error,
cache::CachedSeverity::Warning => panache::linter::Severity::Warning,
cache::CachedSeverity::Info => panache::linter::Severity::Info,
};
let origin = match diag.origin {
cache::CachedDiagnosticOrigin::BuiltIn => panache::linter::DiagnosticOrigin::BuiltIn,
cache::CachedDiagnosticOrigin::External => panache::linter::DiagnosticOrigin::External,
};
let notes = diag
.notes
.iter()
.map(|note| panache::linter::DiagnosticNote {
kind: match note.kind {
cache::CachedDiagnosticNoteKind::Note => panache::linter::DiagnosticNoteKind::Note,
cache::CachedDiagnosticNoteKind::Help => panache::linter::DiagnosticNoteKind::Help,
},
message: note.message.clone(),
})
.collect();
let fix = diag.fix.as_ref().map(|fix| panache::linter::Fix {
message: fix.message.clone(),
edits: fix
.edits
.iter()
.map(|edit| panache::linter::diagnostics::Edit {
range: TextRange::new(TextSize::from(edit.start), TextSize::from(edit.end)),
replacement: edit.replacement.clone(),
})
.collect(),
safety: match fix.safety {
cache::CachedFixSafety::Safe => panache::linter::FixSafety::Safe,
cache::CachedFixSafety::Unsafe => panache::linter::FixSafety::Unsafe,
},
});
panache::linter::Diagnostic {
severity,
location: panache::linter::Location {
line: diag.location.line,
column: diag.location.column,
range: TextRange::new(
TextSize::from(diag.location.start),
TextSize::from(diag.location.end),
),
},
message: diag.message.clone(),
code: diag.code.clone(),
origin,
notes,
fix,
}
}
#[cfg(test)]
mod tests {
use super::{
ColorMode, InputError, normalize_input_paths, normalize_parse_path,
per_file_external_parallel, resolve_color,
};
use std::ffi::OsStr;
use std::path::PathBuf;
fn paths(entries: &[&str]) -> Vec<PathBuf> {
entries.iter().map(PathBuf::from).collect()
}
#[test]
fn no_paths_reads_stdin_unless_it_is_a_terminal() {
assert_eq!(normalize_input_paths(vec![], false), Ok(vec![]));
assert_eq!(
normalize_input_paths(vec![], true),
Err(InputError::NoInput)
);
}
#[test]
fn dash_names_stdin_even_at_a_terminal() {
assert_eq!(normalize_input_paths(paths(&["-"]), true), Ok(vec![]));
assert_eq!(normalize_input_paths(paths(&["-"]), false), Ok(vec![]));
}
#[test]
fn dash_cannot_be_mixed_with_file_paths() {
assert_eq!(
normalize_input_paths(paths(&["-", "a.md"]), false),
Err(InputError::StdinWithPaths)
);
assert_eq!(
normalize_input_paths(paths(&["a.md", "-"]), false),
Err(InputError::StdinWithPaths)
);
}
#[test]
fn named_paths_are_passed_through() {
let named = paths(&["a.md", "docs"]);
assert_eq!(normalize_input_paths(named.clone(), true), Ok(named));
}
#[test]
fn parse_path_follows_the_same_rules() {
assert_eq!(
normalize_parse_path(Some(PathBuf::from("-")), true),
Ok(None)
);
assert_eq!(normalize_parse_path(None, false), Ok(None));
assert_eq!(normalize_parse_path(None, true), Err(InputError::NoInput));
let named = Some(PathBuf::from("a.md"));
assert_eq!(normalize_parse_path(named.clone(), true), Ok(named));
}
#[test]
fn few_files_split_the_budget_to_saturate_it() {
assert_eq!(per_file_external_parallel(8, 3), 3);
assert_eq!(per_file_external_parallel(8, 2), 4);
}
#[test]
fn many_files_collapse_to_one_per_file() {
assert_eq!(per_file_external_parallel(8, 8), 1);
assert_eq!(per_file_external_parallel(8, 16), 1);
}
#[test]
fn per_file_share_never_drops_below_one() {
assert_eq!(per_file_external_parallel(1, 4), 1);
assert_eq!(per_file_external_parallel(0, 4), 1);
assert_eq!(per_file_external_parallel(4, 0), 4);
}
#[test]
fn auto_disables_color_when_term_is_dumb() {
assert!(!resolve_color(
ColorMode::Auto,
false,
false,
Some(OsStr::new("dumb")),
true,
));
}
mod apply_fixes {
use super::super::apply_fixes;
use panache::linter::diagnostics::{Diagnostic, Edit, Fix, Location};
use rowan::TextRange;
fn diag(input: &str, start: u32, end: u32, replacement: &str, safe: bool) -> Diagnostic {
let range = TextRange::new(start.into(), end.into());
let edits = vec![Edit {
range,
replacement: replacement.to_string(),
}];
let fix = if safe {
Fix::safe("fix", edits)
} else {
Fix::unsafe_fix("fix", edits)
};
Diagnostic::warning(Location::from_range(range, input), "test", "test").with_fix(fix)
}
#[test]
fn disjoint_fixes_all_apply() {
let input = "aaa bbb ccc";
let diagnostics = [
diag(input, 0, 3, "XXX", true),
diag(input, 8, 11, "ZZZ", true),
];
let applied = apply_fixes(input, &diagnostics, false);
assert_eq!(applied.output, "XXX bbb ZZZ");
assert_eq!(applied.conflicted, 0);
}
#[test]
fn identical_ranges_apply_once_and_report_the_conflict() {
let input = "import os\nprint()\n";
let diagnostics = [
diag(input, 0, 10, "import os\n\n", true),
diag(input, 0, 10, "", true),
];
let applied = apply_fixes(input, &diagnostics, false);
assert_eq!(applied.output, "import os\n\nprint()\n");
assert_eq!(applied.conflicted, 1);
}
#[test]
fn partially_overlapping_fixes_skip_the_later_one() {
let input = "aaa bbb ccc";
let diagnostics = [diag(input, 0, 7, "X", true), diag(input, 4, 11, "Y", true)];
let applied = apply_fixes(input, &diagnostics, false);
assert_eq!(applied.output, "X ccc");
assert_eq!(applied.conflicted, 1);
}
#[test]
fn abutting_fixes_both_apply() {
let input = "aaabbb";
let diagnostics = [diag(input, 0, 3, "X", true), diag(input, 3, 6, "Y", true)];
let applied = apply_fixes(input, &diagnostics, false);
assert_eq!(applied.output, "XY");
assert_eq!(applied.conflicted, 0);
}
#[test]
fn skipped_unsafe_fix_leaves_the_span_free() {
let input = "aaa bbb";
let diagnostics = [
diag(input, 0, 3, "UNSAFE", false),
diag(input, 0, 3, "SAFE", true),
];
let applied = apply_fixes(input, &diagnostics, false);
assert_eq!(applied.output, "SAFE bbb");
assert_eq!(applied.conflicted, 0);
let applied = apply_fixes(input, &diagnostics, true);
assert_eq!(applied.output, "UNSAFE bbb");
assert_eq!(applied.conflicted, 1);
}
}
#[test]
fn auto_disables_color_when_term_is_unset() {
assert!(!resolve_color(ColorMode::Auto, false, false, None, true));
}
#[test]
fn auto_enables_color_on_tty_with_real_term() {
assert!(resolve_color(
ColorMode::Auto,
false,
false,
Some(OsStr::new("xterm-256color")),
true,
));
}
#[test]
fn auto_disables_color_when_not_a_tty() {
assert!(!resolve_color(
ColorMode::Auto,
false,
false,
Some(OsStr::new("xterm-256color")),
false,
));
}
#[test]
fn always_overrides_dumb_term() {
assert!(resolve_color(
ColorMode::Always,
false,
false,
Some(OsStr::new("dumb")),
false,
));
}
#[test]
fn no_color_flag_overrides_always() {
assert!(!resolve_color(
ColorMode::Always,
true,
false,
Some(OsStr::new("xterm-256color")),
true,
));
}
mod format_overrides {
use crate::apply_format_overrides;
fn cfg() -> panache::Config {
panache::Config::default()
}
#[test]
fn extensions_dot_key_toggles_extension() {
let mut c = cfg();
assert!(!c.extensions.east_asian_line_breaks);
assert!(!c.formatter_extensions.east_asian_line_breaks);
apply_format_overrides(
&mut c,
&["extensions.east-asian-line-breaks=true".to_string()],
)
.unwrap();
assert!(c.extensions.east_asian_line_breaks);
assert!(c.formatter_extensions.east_asian_line_breaks);
}
#[test]
fn extensions_dot_key_ignores_snake_case_alias() {
let mut c = cfg();
apply_format_overrides(
&mut c,
&["extensions.east_asian_line_breaks=true".to_string()],
)
.unwrap();
assert!(!c.extensions.east_asian_line_breaks);
}
#[test]
fn extensions_dot_key_rejects_non_boolean_value() {
let mut c = cfg();
let err = apply_format_overrides(
&mut c,
&["extensions.east-asian-line-breaks=maybe".to_string()],
)
.unwrap_err();
assert!(err.contains("expected boolean"), "{err}");
}
#[test]
fn extensions_dot_key_requires_name() {
let mut c = cfg();
let err =
apply_format_overrides(&mut c, &["extensions.=true".to_string()]).unwrap_err();
assert!(err.contains("missing extension name"), "{err}");
}
#[test]
fn unknown_top_level_key_still_errors() {
let mut c = cfg();
let err = apply_format_overrides(&mut c, &["nope=1".to_string()]).unwrap_err();
assert!(err.contains("unknown config key"), "{err}");
}
#[test]
fn table_indent_override_sets_value() {
let mut c = cfg();
assert_eq!(c.table_indent, 2);
apply_format_overrides(&mut c, &["table-indent=0".to_string()]).unwrap();
assert_eq!(c.table_indent, 0);
}
#[test]
fn table_indent_override_rejects_out_of_range_value() {
let mut c = cfg();
let err = apply_format_overrides(&mut c, &["table-indent=4".to_string()]).unwrap_err();
assert!(err.contains("invalid value for `table-indent`"), "{err}");
}
#[test]
fn table_indent_override_rejects_non_integer_value() {
let mut c = cfg();
let err =
apply_format_overrides(&mut c, &["table-indent=flush".to_string()]).unwrap_err();
assert!(err.contains("invalid value for `table-indent`"), "{err}");
}
}
}