use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use badness::config::{Config, ConfigSource};
use badness::file_discovery::{
ExcludeFilter, FileDiscoveryError, FileKind, collect_lint_files, file_kind_or_tex,
};
use badness::formatter::{
FormatStyle, MathWrap, SentenceOptions, WrapMode, check_paths_with_style,
format_file_with_packages_sentence, format_with_style_flavored_sentence,
};
use badness::linter::{
Diagnostic, OutputMode, RuleSelection, apply_fixes, check_document_fixable, lint_document,
render_findings,
};
use std::collections::HashMap;
use badness::cli::{Cli, Command, DebugChecksArg, DebugCommand, MathWrapArg, WrapArg};
use badness::parser::{LexConfig, parse_with_flavor};
use badness::project::labels::{document_label_names, document_ref_names, is_document_root};
use badness::project::{
CiteFileFacts, FileFacts, IncludeGraph, PackageOptionFacts, ResolvedCitations, ResolvedLabels,
ResolvedPackageOptions, collect_bib_resource_targets, collect_include_edge_keys,
package_option_facts,
};
use badness::semantic::SemanticModel;
use badness::syntax::SyntaxNode;
use clap::Parser;
use rayon::prelude::*;
use rowan::{GreenNode, NodeOrToken};
use smol_str::SmolStr;
fn wrap_mode(arg: WrapArg) -> WrapMode {
match arg {
WrapArg::Reflow => WrapMode::Reflow,
WrapArg::Stable => WrapMode::Stable,
WrapArg::Sentence => WrapMode::Sentence,
WrapArg::Semantic => WrapMode::Semantic,
WrapArg::Preserve => WrapMode::Preserve,
}
}
fn math_wrap_mode(arg: MathWrapArg) -> MathWrap {
match arg {
MathWrapArg::Auto => MathWrap::Auto,
MathWrapArg::Preserve => MathWrap::Preserve,
MathWrapArg::SingleLine => MathWrap::SingleLine,
MathWrapArg::Break => MathWrap::Break,
}
}
fn main() -> ExitCode {
let Cli {
command,
config: config_arg,
no_config,
} = Cli::parse();
match command {
Command::Format {
paths,
check,
stdin_filepath,
line_width,
indent_width,
wrap,
math_wrap,
exclude,
force_exclude,
} => {
let anchor = match cwd_anchor() {
Ok(anchor) => anchor,
Err(code) => return code,
};
let (config, config_source) =
match resolve_config(config_arg.as_deref(), no_config, &anchor) {
Ok(resolved) => resolved,
Err(code) => return code,
};
let exclude_filter =
match build_exclude_filter(&config, &config_source, &anchor, &exclude) {
Ok(filter) => filter.with_force_exclude(force_exclude),
Err(code) => return code,
};
let (style, wrap_override) =
resolve_style(&config, line_width, indent_width, wrap, math_wrap);
let mut abbrev_scratch = Vec::new();
let sentence = SentenceOptions::resolve(
config.format.lang.as_deref(),
&config.format.no_break_abbreviations,
&mut abbrev_scratch,
);
run_format(
&paths,
check,
stdin_filepath.as_deref(),
style,
wrap_override,
sentence,
&exclude_filter,
)
}
Command::Lint {
paths,
fix,
unsafe_fixes,
stdin_filepath,
exclude,
force_exclude,
select,
ignore,
explain,
} => {
if let Some(rule) = explain {
return run_explain(&rule);
}
let anchor = match cwd_anchor() {
Ok(anchor) => anchor,
Err(code) => return code,
};
let (mut config, config_source) =
match resolve_config(config_arg.as_deref(), no_config, &anchor) {
Ok(resolved) => resolved,
Err(code) => return code,
};
let exclude_filter =
match build_exclude_filter(&config, &config_source, &anchor, &exclude) {
Ok(filter) => filter.with_force_exclude(force_exclude),
Err(code) => return code,
};
if !select.is_empty() {
config.lint.select = Some(select);
}
if !ignore.is_empty() {
config.lint.ignore = ignore;
}
let (rules, unknown) =
RuleSelection::resolve(config.lint.select.as_deref(), &config.lint.ignore);
for id in &unknown {
eprintln!("badness: warning: unknown lint rule `{id}`");
}
run_lint(
&paths,
fix,
unsafe_fixes,
stdin_filepath.as_deref(),
&exclude_filter,
&rules,
)
}
Command::Parse { path } => run_parse(path.as_deref()),
Command::Lsp => run_lsp(),
Command::Init { force } => run_init(force),
Command::Debug { command } => match command {
DebugCommand::Format {
paths,
checks,
report,
dump_dir,
dump_passes,
exclude,
force_exclude,
} => {
let anchor = match cwd_anchor() {
Ok(anchor) => anchor,
Err(code) => return code,
};
let (config, config_source) =
match resolve_config(config_arg.as_deref(), no_config, &anchor) {
Ok(resolved) => resolved,
Err(code) => return code,
};
let exclude_filter =
match build_exclude_filter(&config, &config_source, &anchor, &exclude) {
Ok(filter) => filter.with_force_exclude(force_exclude),
Err(code) => return code,
};
let (style, wrap_override) = resolve_style(&config, None, None, None, None);
let mut abbrev_scratch = Vec::new();
let sentence = SentenceOptions::resolve(
config.format.lang.as_deref(),
&config.format.no_break_abbreviations,
&mut abbrev_scratch,
);
run_debug_format(
&paths,
checks,
report,
dump_dir.as_deref(),
dump_passes,
style,
wrap_override,
sentence,
&exclude_filter,
)
}
},
}
}
fn resolve_style(
config: &Config,
line_width: Option<usize>,
indent_width: Option<usize>,
wrap: Option<WrapArg>,
math_wrap: Option<MathWrapArg>,
) -> (FormatStyle, Option<WrapMode>) {
let mut style = FormatStyle::from(&config.format);
if let Some(w) = line_width {
style.line_width = w;
}
if let Some(w) = indent_width {
style.indent_width = w;
}
let wrap_override: Option<WrapMode> =
wrap.map(wrap_mode).or(config.format.wrap.map(Into::into));
if let Some(mw) = math_wrap {
style.math_wrap = math_wrap_mode(mw);
}
(style, wrap_override)
}
fn cwd_anchor() -> Result<PathBuf, ExitCode> {
std::env::current_dir().map_err(|err| {
eprintln!("badness: cannot determine the current directory: {err}");
ExitCode::from(2)
})
}
fn resolve_config(
explicit: Option<&Path>,
no_config: bool,
anchor: &Path,
) -> Result<(Config, ConfigSource), ExitCode> {
Config::resolve(explicit, no_config, anchor).map_err(|err| {
eprintln!("badness: {err}");
ExitCode::from(2)
})
}
fn build_exclude_filter(
config: &Config,
source: &ConfigSource,
anchor: &Path,
cli_excludes: &[String],
) -> Result<ExcludeFilter, ExitCode> {
let root = source.exclude_root(anchor);
let patterns = config.exclude_patterns(cli_excludes);
ExcludeFilter::new(root, &patterns).map_err(|err| {
eprintln!("badness: {err}");
ExitCode::from(2)
})
}
const STARTER_CONFIG: &str = "\
# badness configuration. All keys are optional; values shown are the defaults.
# Gitignore-style patterns to skip during directory discovery. `exclude` replaces
# the built-in default set (`.git/`); `extend-exclude` adds on top of it. Both
# apply to `format` and `lint`.
# exclude = [\".git/\"]
# extend-exclude = []
[format]
# line-width = 80
# indent-width = 2
# wrap = \"reflow\" # reflow | stable | sentence | semantic | preserve
# omit to use each file kind's default
# (.tex -> reflow, .sty/.cls/.dtx/.ins -> preserve)
# math-wrap = \"auto\" # auto | preserve | single-line | break
# display-math line breaking; auto derives from wrap
# (preserve -> preserve, else break)
[lint]
# select = [\"...\"] # if set, only these rules run
# ignore = [] # rules to disable
";
fn run_init(force: bool) -> ExitCode {
let anchor = match cwd_anchor() {
Ok(anchor) => anchor,
Err(code) => return code,
};
let path = anchor.join(badness::config::CONFIG_FILE_NAME);
if path.exists() && !force {
eprintln!(
"badness: {} already exists; pass --force to overwrite",
path.display()
);
return ExitCode::from(2);
}
match std::fs::write(&path, STARTER_CONFIG) {
Ok(()) => {
println!("Wrote {}", path.display());
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("badness: failed to write {}: {err}", path.display());
ExitCode::from(2)
}
}
}
fn run_lsp() -> ExitCode {
match badness::lsp::run() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("badness: language server error: {err}");
ExitCode::FAILURE
}
}
}
const MAX_FIX_ITERATIONS: usize = 10;
fn run_explain(id: &str) -> ExitCode {
let doc = badness::linter::docs::explain_rule(id)
.or_else(|| badness::bib::linter::docs::explain_rule(id));
match doc {
Some(doc) => {
print!("{doc}");
ExitCode::SUCCESS
}
None => {
eprintln!("badness: unknown lint rule `{id}`");
eprintln!(
"known rules: {}",
badness::linter::rules::all_known_rule_ids()
.collect::<Vec<_>>()
.join(", ")
);
ExitCode::from(2)
}
}
}
type ReadResult = Result<(PathBuf, String, FileKind), (PathBuf, std::io::Error)>;
enum FileAnalysis {
Bib {
diagnostics: Vec<Diagnostic>,
path: PathBuf,
keys: Vec<SmolStr>,
},
Tex(Box<TexAnalysis>),
}
struct TexAnalysis {
diagnostics: Vec<Diagnostic>,
path: PathBuf,
green: GreenNode,
model: SemanticModel,
facts: FileFacts,
label_input: (PathBuf, Vec<SmolStr>, Vec<SmolStr>, bool),
cite_fact: CiteFileFacts,
option_facts: Option<PackageOptionFacts>,
}
fn analyze_source(path: &Path, content: &str, kind: FileKind) -> FileAnalysis {
match kind {
FileKind::Bib => {
let parsed = badness::bib::parse(content);
let mut diagnostics: Vec<Diagnostic> = parsed
.errors
.iter()
.map(|err| Diagnostic {
rule: "parse",
severity: badness::linter::Severity::Error,
path: path.to_path_buf(),
start: err.start,
end: err.end,
message: err.message.clone(),
fix: None,
related: Vec::new(),
})
.collect();
let root = parsed.syntax();
let model = badness::bib::semantic::Model::build(&root);
let keys = model.entries().iter().map(|e| e.key.clone()).collect();
diagnostics.extend(badness::bib::linter::lint_document(path, &root, &model));
FileAnalysis::Bib {
diagnostics,
path: path.to_path_buf(),
keys,
}
}
FileKind::Tex | FileKind::Sty | FileKind::Cls | FileKind::Dtx | FileKind::Ins => {
let parsed = parse_with_flavor(content, kind.lex_config());
let diagnostics: Vec<Diagnostic> = parsed
.errors
.iter()
.map(|err| Diagnostic::from_parse(path.to_path_buf(), err))
.collect();
let green = parsed.green;
let root = SyntaxNode::new_root(green.clone());
let model = SemanticModel::build(&root);
let facts = FileFacts {
path: path.to_path_buf(),
include_edges: collect_include_edge_keys(&root, path.parent()),
};
let label_input = (
path.to_path_buf(),
document_label_names(&model),
document_ref_names(&model),
is_document_root(&root),
);
let cite_fact = CiteFileFacts {
path: path.to_path_buf(),
bib_targets: collect_bib_resource_targets(&root, path.parent()),
nocite_all: model.has_wildcard_nocite(),
is_document_root: is_document_root(&root),
};
let option_facts = package_option_facts(path, &root, &model);
FileAnalysis::Tex(Box::new(TexAnalysis {
diagnostics,
path: path.to_path_buf(),
green,
model,
facts,
label_input,
cite_fact,
option_facts,
}))
}
}
}
fn run_lint(
paths: &[PathBuf],
fix: bool,
unsafe_fixes: bool,
stdin_filepath: Option<&Path>,
exclude: &ExcludeFilter,
rules: &RuleSelection,
) -> ExitCode {
if fix
&& !paths.is_empty()
&& let Some(code) = apply_fixes_to_paths(paths, unsafe_fixes, exclude, rules)
{
return code;
}
let mut sources: Vec<(PathBuf, String, FileKind)> = Vec::new();
let mut failed = false;
if paths.is_empty() {
let mut input = String::new();
if let Err(err) = std::io::stdin().read_to_string(&mut input) {
eprintln!("badness: cannot read stdin: {err}");
return ExitCode::FAILURE;
}
let kind = stdin_filepath.map_or(FileKind::Tex, file_kind_or_tex);
sources.push((PathBuf::from("<stdin>"), input, kind));
} else {
let files = match collect_lint_files(paths, exclude) {
Ok(files) => files,
Err(err) => {
report_discovery_error(&err);
return ExitCode::FAILURE;
}
};
if files.is_empty() {
if exclude.force() {
return ExitCode::SUCCESS;
}
eprintln!(
"badness: no .tex, .sty, .cls, .dtx, .ins, or .bib files found under the provided input paths"
);
return ExitCode::FAILURE;
}
let read_results: Vec<ReadResult> = files
.par_iter()
.map(|(path, kind)| match std::fs::read_to_string(path) {
Ok(content) => Ok((path.clone(), content, *kind)),
Err(err) => Err((path.clone(), err)),
})
.collect();
for result in read_results {
match result {
Ok(source) => sources.push(source),
Err((path, err)) => {
eprintln!("badness: cannot read {}: {err}", path.display());
failed = true;
}
}
}
}
let analyses: Vec<FileAnalysis> = sources
.par_iter()
.map(|(path, content, kind)| analyze_source(path, content, *kind))
.collect();
let mut diagnostics: Vec<Diagnostic> = Vec::new();
let mut analyzed: Vec<(PathBuf, GreenNode, SemanticModel)> = Vec::new();
let mut facts: Vec<FileFacts> = Vec::new();
let mut label_inputs = Vec::new();
let mut cite_facts: Vec<CiteFileFacts> = Vec::new();
let mut option_facts: Vec<PackageOptionFacts> = Vec::new();
let mut bib_keys: HashMap<PathBuf, Vec<SmolStr>> = HashMap::new();
for analysis in analyses {
match analysis {
FileAnalysis::Bib {
diagnostics: d,
path,
keys,
} => {
diagnostics.extend(d);
bib_keys.insert(path, keys);
}
FileAnalysis::Tex(tex) => {
let TexAnalysis {
diagnostics: d,
path,
green,
model,
facts: f,
label_input,
cite_fact,
option_facts: o,
} = *tex;
diagnostics.extend(d);
facts.push(f);
label_inputs.push(label_input);
cite_facts.push(cite_fact);
option_facts.extend(o);
analyzed.push((path, green, model));
}
}
}
let graph = IncludeGraph::build(&facts, None);
let resolved = ResolvedLabels::build(&label_inputs, &graph);
let resolved_citations = ResolvedCitations::build(&cite_facts, &graph, &bib_keys);
let resolved_packages = ResolvedPackageOptions::build(option_facts);
let lint_results: Vec<Vec<Diagnostic>> = analyzed
.par_iter()
.map(|(path, green, model)| {
let root = SyntaxNode::new_root(green.clone());
lint_document(
path,
&root,
model,
Some(&resolved),
Some(&resolved_citations),
Some(&resolved_packages),
)
})
.collect();
for result in lint_results {
diagnostics.extend(result);
}
diagnostics.retain(|d| rules.is_active(d.rule));
diagnostics.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.start.cmp(&b.start))
.then(a.end.cmp(&b.end))
.then(a.rule.cmp(b.rule))
});
if !diagnostics.is_empty() {
let source_index: HashMap<&Path, &str> = sources
.iter()
.map(|(p, text, _)| (p.as_path(), text.as_str()))
.collect();
let source_for = |path: &Path| source_index.get(path).map(|s| s.to_string());
eprint!(
"{}",
render_findings(&diagnostics, OutputMode::Pretty, &source_for)
);
}
if failed || !diagnostics.is_empty() {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn apply_fixes_to_paths(
paths: &[PathBuf],
include_unsafe: bool,
exclude: &ExcludeFilter,
rules: &RuleSelection,
) -> Option<ExitCode> {
let files = match collect_lint_files(paths, exclude) {
Ok(files) => files,
Err(err) => {
report_discovery_error(&err);
return Some(ExitCode::FAILURE);
}
};
if files.is_empty() {
if exclude.force() {
return Some(ExitCode::SUCCESS);
}
eprintln!("badness: no .tex or .bib files found under the provided input paths");
return Some(ExitCode::FAILURE);
}
let outcomes: Vec<FixOutcome> = files
.par_iter()
.map(
|(path, kind)| match fix_file(path, *kind, include_unsafe, rules) {
Ok(0) => FixOutcome::Unchanged,
Ok(n) => FixOutcome::Applied {
path: path.clone(),
count: n,
},
Err(err) => {
FixOutcome::Failed(format!("badness: cannot fix {}: {err}", path.display()))
}
},
)
.collect();
let mut failed = false;
for outcome in outcomes {
match outcome {
FixOutcome::Unchanged => {}
FixOutcome::Applied { path, count } => {
eprintln!("{}: {count} fix{} applied", path.display(), plural(count))
}
FixOutcome::Failed(message) => {
eprintln!("{message}");
failed = true;
}
}
}
failed.then_some(ExitCode::FAILURE)
}
enum FixOutcome {
Unchanged,
Applied { path: PathBuf, count: usize },
Failed(String),
}
fn fix_file(
path: &Path,
kind: FileKind,
include_unsafe: bool,
rules: &RuleSelection,
) -> std::io::Result<usize> {
let mut content = std::fs::read_to_string(path)?;
let errors_before = debug_parse_error_count(&content, kind);
let mut total = 0usize;
for _ in 0..MAX_FIX_ITERATIONS {
let diagnostics = match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls | FileKind::Dtx | FileKind::Ins => {
check_document_fixable(path, &content, kind.lex_config())
}
FileKind::Bib => badness::bib::linter::check_document(path, &content),
};
let fixes: Vec<_> = diagnostics
.into_iter()
.filter(|d| rules.is_active(d.rule))
.filter_map(|d| d.fix)
.collect();
if fixes.is_empty() {
break;
}
let outcome = apply_fixes(&content, &fixes, include_unsafe);
if outcome.applied == 0 {
break;
}
total += outcome.applied;
content = outcome.output;
}
if total > 0 {
debug_assert_fixes_preserved(path, kind, &content, errors_before);
std::fs::write(path, &content)?;
}
Ok(total)
}
fn debug_parse_error_count(content: &str, kind: FileKind) -> usize {
if !cfg!(debug_assertions) {
return 0;
}
match kind {
FileKind::Bib => badness::bib::parse(content).errors.len(),
_ => parse_with_flavor(content, kind.lex_config()).errors.len(),
}
}
fn debug_assert_fixes_preserved(path: &Path, kind: FileKind, content: &str, errors_before: usize) {
if !cfg!(debug_assertions) {
return;
}
let (reconstructed, errors_after) = match kind {
FileKind::Bib => {
let parsed = badness::bib::parse(content);
(parsed.syntax().to_string(), parsed.errors.len())
}
_ => {
let parsed = parse_with_flavor(content, kind.lex_config());
(
SyntaxNode::new_root(parsed.green.clone()).to_string(),
parsed.errors.len(),
)
}
};
debug_assert_eq!(
reconstructed,
content,
"--fix produced non-lossless output for {}",
path.display()
);
debug_assert!(
errors_after <= errors_before,
"--fix introduced {} new parse error(s) in {} ({errors_before} -> {errors_after})",
errors_after.saturating_sub(errors_before),
path.display()
);
}
fn plural(n: usize) -> &'static str {
if n == 1 { "" } else { "es" }
}
fn run_parse(path: Option<&Path>) -> ExitCode {
let input = match path {
Some(path) => match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) => {
eprintln!("badness: cannot read {}: {err}", path.display());
return ExitCode::FAILURE;
}
},
None => {
let mut input = String::new();
if let Err(err) = std::io::stdin().read_to_string(&mut input) {
eprintln!("badness: cannot read stdin: {err}");
return ExitCode::FAILURE;
}
input
}
};
let config = path.map_or(LexConfig::default(), |p| file_kind_or_tex(p).lex_config());
let parsed = parse_with_flavor(&input, config);
let mut out = String::new();
render_cst(&parsed.syntax(), 0, &mut out);
if let Err(err) = std::io::stdout().write_all(out.as_bytes()) {
eprintln!("badness: cannot write stdout: {err}");
return ExitCode::FAILURE;
}
if parsed.errors.is_empty() {
ExitCode::SUCCESS
} else {
for err in &parsed.errors {
eprintln!("error @{}..{}: {}", err.start, err.end, err.message);
}
ExitCode::FAILURE
}
}
fn render_cst(node: &SyntaxNode, depth: usize, out: &mut String) {
out.push_str(&format!(
"{:indent$}{:?}@{:?}\n",
"",
node.kind(),
node.text_range(),
indent = depth * 2
));
for child in node.children_with_tokens() {
match child {
NodeOrToken::Node(n) => render_cst(&n, depth + 1, out),
NodeOrToken::Token(t) => out.push_str(&format!(
"{:indent$}{:?}@{:?} {:?}\n",
"",
t.kind(),
t.text_range(),
t.text(),
indent = (depth + 1) * 2
)),
}
}
}
fn run_format(
paths: &[PathBuf],
check: bool,
stdin_filepath: Option<&Path>,
style: FormatStyle,
wrap_override: Option<WrapMode>,
sentence: SentenceOptions<'_>,
exclude: &ExcludeFilter,
) -> ExitCode {
if check {
return run_check(paths, style, wrap_override, sentence, exclude);
}
if paths.is_empty() {
run_format_stdin(stdin_filepath, style, wrap_override, sentence)
} else {
run_format_paths(paths, style, wrap_override, sentence, exclude)
}
}
fn run_check(
paths: &[PathBuf],
style: FormatStyle,
wrap_override: Option<WrapMode>,
sentence: SentenceOptions<'_>,
exclude: &ExcludeFilter,
) -> ExitCode {
match check_paths_with_style(paths, style, wrap_override, sentence, exclude) {
Ok(result) => {
if result.changed_files.is_empty() {
ExitCode::SUCCESS
} else {
for path in &result.changed_files {
eprintln!("would reformat {}", path.display());
}
eprintln!(
"{} of {} file(s) would be reformatted",
result.changed_files.len(),
result.checked_files
);
ExitCode::FAILURE
}
}
Err(err) => {
eprintln!("badness: {err}");
ExitCode::FAILURE
}
}
}
fn run_format_stdin(
stdin_filepath: Option<&Path>,
mut style: FormatStyle,
wrap_override: Option<WrapMode>,
sentence: SentenceOptions<'_>,
) -> ExitCode {
let mut input = String::new();
if let Err(err) = std::io::stdin().read_to_string(&mut input) {
eprintln!("badness: cannot read stdin: {err}");
return ExitCode::FAILURE;
}
let kind = stdin_filepath.map_or(FileKind::Tex, file_kind_or_tex);
style.wrap = wrap_override.unwrap_or(kind.default_wrap());
let formatted = match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls | FileKind::Dtx | FileKind::Ins => {
format_with_style_flavored_sentence(&input, style, kind.lex_config(), sentence)
.map_err(|e| e.to_string())
}
FileKind::Bib => badness::bib::format_with_style(&input, style).map_err(|e| e.to_string()),
};
match formatted {
Ok(formatted) => {
if let Err(err) = std::io::stdout().write_all(formatted.as_bytes()) {
eprintln!("badness: cannot write stdout: {err}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
Err(msg) => {
eprintln!("badness: {msg}");
ExitCode::FAILURE
}
}
}
fn report_discovery_error(err: &FileDiscoveryError) {
match err {
FileDiscoveryError::UnsupportedLintFilePath { path } => {
eprintln!(
"badness: input file {} is not a .tex, .sty, .cls, .dtx, .ins, or .bib file",
path.display()
);
}
FileDiscoveryError::WalkError { path, message } => {
eprintln!(
"badness: failed while scanning {}: {message}",
path.display()
);
}
}
}
fn run_format_paths(
paths: &[PathBuf],
style: FormatStyle,
wrap_override: Option<WrapMode>,
sentence: SentenceOptions<'_>,
exclude: &ExcludeFilter,
) -> ExitCode {
let files = match collect_lint_files(paths, exclude) {
Ok(files) => files,
Err(err) => {
report_discovery_error(&err);
return ExitCode::FAILURE;
}
};
if files.is_empty() {
if exclude.force() {
return ExitCode::SUCCESS;
}
eprintln!(
"badness: no .tex, .sty, .cls, .dtx, .ins, or .bib files found under the provided input paths"
);
return ExitCode::FAILURE;
}
let outcomes: Vec<Option<String>> = files
.par_iter()
.map(|(path, kind)| {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) => return Some(format!("badness: cannot read {}: {err}", path.display())),
};
let mut style = style;
style.wrap = wrap_override.unwrap_or(kind.default_wrap());
let formatted = match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls | FileKind::Dtx | FileKind::Ins => {
format_file_with_packages_sentence(
&content,
path,
style,
kind.lex_config(),
sentence,
)
.map_err(|e| e.to_string())
}
FileKind::Bib => {
badness::bib::format_with_style(&content, style).map_err(|e| e.to_string())
}
};
match formatted {
Ok(formatted) => {
if formatted != *content
&& let Err(err) = std::fs::write(path, formatted)
{
return Some(format!("badness: cannot write {}: {err}", path.display()));
}
None
}
Err(msg) => Some(format!("badness: cannot format {}: {msg}", path.display())),
}
})
.collect();
let mut failed = false;
for message in outcomes.into_iter().flatten() {
eprintln!("{message}");
failed = true;
}
if failed {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum CheckKind {
Losslessness,
Idempotency,
FormatError,
}
impl CheckKind {
fn label(self) -> &'static str {
match self {
CheckKind::Losslessness => "losslessness",
CheckKind::Idempotency => "idempotency",
CheckKind::FormatError => "format-error",
}
}
}
struct DebugFailure {
kind: CheckKind,
left: String,
right: String,
}
#[derive(Default)]
struct DebugArtifacts {
losslessness: Option<(String, String)>,
idempotency: Option<(String, String, String)>,
failures: Vec<DebugFailure>,
}
fn checks_label(checks: DebugChecksArg) -> &'static str {
match checks {
DebugChecksArg::Idempotency => "idempotency",
DebugChecksArg::Losslessness => "losslessness",
DebugChecksArg::All => "all",
}
}
fn sanitize_path_for_filename(path: &str) -> String {
path.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect()
}
fn first_diff_line(left: &str, right: &str) -> usize {
let mut left_lines = left.lines();
let mut right_lines = right.lines();
let mut line = 1;
loop {
match (left_lines.next(), right_lines.next()) {
(Some(a), Some(b)) if a == b => line += 1,
_ => return line,
}
}
}
fn render_window_diff(left: &str, right: &str, out: &mut String) {
const CONTEXT: usize = 3;
const MAX_SIDE: usize = 40;
let start = first_diff_line(left, right) - 1;
let context_start = start.saturating_sub(CONTEXT);
for line in left.lines().skip(context_start).take(start - context_start) {
out.push(' ');
out.push_str(line);
out.push('\n');
}
for (side, text) in [('-', left), ('+', right)] {
let mut lines = text.lines().skip(start);
for line in lines.by_ref().take(MAX_SIDE) {
out.push(side);
out.push_str(line);
out.push('\n');
}
if lines.next().is_some() {
out.push(side);
out.push_str(" [truncated]\n");
}
}
}
fn build_debug_report(
checks: DebugChecksArg,
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: {files_checked}\n- Failures: {}\n\n",
checks_label(checks),
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() {
out.push_str(&format!(
"### {}. `{}` ({})\n\n",
idx + 1,
file,
failure.kind.label()
));
if failure.kind == CheckKind::FormatError {
out.push_str(&format!("- Error: {}\n\n", failure.left));
continue;
}
out.push_str(&format!(
"- Approx. diff start line: {}\n\n",
first_diff_line(&failure.left, &failure.right)
));
out.push_str("```diff\n");
render_window_diff(&failure.left, &failure.right, &mut out);
out.push_str("```\n\n");
}
out
}
fn write_debug_artifacts(
dump_dir: &Path,
stem: &str,
artifacts: &DebugArtifacts,
dump_passes: bool,
) -> std::io::Result<()> {
std::fs::create_dir_all(dump_dir)?;
let failed = |kind: CheckKind| artifacts.failures.iter().any(|f| f.kind == kind);
if let Some((input, parsed)) = artifacts.losslessness.as_ref()
&& (dump_passes || failed(CheckKind::Losslessness))
{
std::fs::write(
dump_dir.join(format!("{stem}.losslessness.input.txt")),
input,
)?;
std::fs::write(
dump_dir.join(format!("{stem}.losslessness.parsed.txt")),
parsed,
)?;
}
if let Some((input, once, twice)) = artifacts.idempotency.as_ref()
&& (dump_passes || failed(CheckKind::Idempotency))
{
std::fs::write(
dump_dir.join(format!("{stem}.idempotency.input.txt")),
input,
)?;
std::fs::write(dump_dir.join(format!("{stem}.idempotency.once.txt")), once)?;
std::fs::write(
dump_dir.join(format!("{stem}.idempotency.twice.txt")),
twice,
)?;
}
for failure in &artifacts.failures {
let kind = failure.kind.label();
std::fs::write(
dump_dir.join(format!("{stem}.{kind}.left.txt")),
&failure.left,
)?;
std::fs::write(
dump_dir.join(format!("{stem}.{kind}.right.txt")),
&failure.right,
)?;
}
Ok(())
}
fn run_debug_checks_for_file(
path: &Path,
kind: FileKind,
content: &str,
style: FormatStyle,
wrap_override: Option<WrapMode>,
sentence: SentenceOptions<'_>,
checks: DebugChecksArg,
) -> DebugArtifacts {
let mut artifacts = DebugArtifacts::default();
if matches!(checks, DebugChecksArg::Losslessness | DebugChecksArg::All) {
let reconstructed = match kind {
FileKind::Bib => badness::bib::parse(content).syntax().to_string(),
_ => parse_with_flavor(content, kind.lex_config())
.syntax()
.to_string(),
};
artifacts.losslessness = Some((content.to_string(), reconstructed.clone()));
if reconstructed != content {
artifacts.failures.push(DebugFailure {
kind: CheckKind::Losslessness,
left: content.to_string(),
right: reconstructed,
});
}
}
if matches!(checks, DebugChecksArg::Idempotency | DebugChecksArg::All) {
let mut style = style;
style.wrap = wrap_override.unwrap_or(kind.default_wrap());
let fmt = |input: &str| match kind {
FileKind::Bib => {
badness::bib::format_with_style(input, style).map_err(|e| e.to_string())
}
_ => {
format_file_with_packages_sentence(input, path, style, kind.lex_config(), sentence)
.map_err(|e| e.to_string())
}
};
match fmt(content) {
Err(msg) => artifacts.failures.push(DebugFailure {
kind: CheckKind::FormatError,
left: msg,
right: String::new(),
}),
Ok(once) => match fmt(&once) {
Ok(twice) => {
artifacts.idempotency =
Some((content.to_string(), once.clone(), twice.clone()));
if once != twice {
artifacts.failures.push(DebugFailure {
kind: CheckKind::Idempotency,
left: once,
right: twice,
});
}
}
Err(msg) => {
artifacts.idempotency =
Some((content.to_string(), once.clone(), String::new()));
artifacts.failures.push(DebugFailure {
kind: CheckKind::Idempotency,
left: once,
right: format!("second pass failed to format: {msg}"),
});
}
},
}
}
artifacts
}
#[allow(clippy::too_many_arguments)]
fn run_debug_format(
paths: &[PathBuf],
checks: DebugChecksArg,
report: bool,
dump_dir: Option<&Path>,
dump_passes: bool,
style: FormatStyle,
wrap_override: Option<WrapMode>,
sentence: SentenceOptions<'_>,
exclude: &ExcludeFilter,
) -> ExitCode {
if paths.is_empty() {
eprintln!("badness: debug format requires at least one file or directory");
return ExitCode::from(2);
}
let files = match collect_lint_files(paths, exclude) {
Ok(files) => files,
Err(err) => {
report_discovery_error(&err);
return ExitCode::FAILURE;
}
};
if files.is_empty() {
if exclude.force() {
return ExitCode::SUCCESS;
}
eprintln!(
"badness: no .tex, .sty, .cls, .dtx, .ins, or .bib files found under the provided input paths"
);
return ExitCode::FAILURE;
}
let outcomes: Vec<(String, Result<DebugArtifacts, String>)> = files
.par_iter()
.map(|(path, kind)| {
let label = path.display().to_string();
let outcome = match std::fs::read_to_string(path) {
Ok(content) => Ok(run_debug_checks_for_file(
path,
*kind,
&content,
style,
wrap_override,
sentence,
checks,
)),
Err(err) => Err(format!("badness: cannot read {label}: {err}")),
};
(label, outcome)
})
.collect();
let mut files_checked = 0usize;
let mut io_failed = false;
let mut collected: Vec<(String, DebugFailure)> = Vec::new();
for (label, outcome) in outcomes {
match outcome {
Err(msg) => {
eprintln!("{msg}");
io_failed = true;
}
Ok(artifacts) => {
files_checked += 1;
if let Some(dir) = dump_dir {
let stem = sanitize_path_for_filename(&label);
if let Err(err) = write_debug_artifacts(dir, &stem, &artifacts, dump_passes) {
eprintln!(
"badness: cannot write debug artifacts to {}: {err}",
dir.display()
);
io_failed = true;
}
}
for failure in artifacts.failures {
if !report {
eprintln!("Debug check failed ({}) in {label}", failure.kind.label());
if failure.kind == CheckKind::FormatError {
eprintln!(" {}", failure.left);
}
}
collected.push((label.clone(), failure));
}
}
}
}
if report {
print!("{}", build_debug_report(checks, files_checked, &collected));
} else if collected.is_empty() && !io_failed {
println!(
"All checks passed (checks: {}, files: {files_checked})",
checks_label(checks)
);
}
if collected.is_empty() && !io_failed {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_matches_the_workflow_sed_class() {
assert_eq!(
sanitize_path_for_filename("sub dir/a b.tex"),
"sub_dir_a_b.tex"
);
assert_eq!(sanitize_path_for_filename("ok-1.2_x.bib"), "ok-1.2_x.bib");
assert_eq!(sanitize_path_for_filename("å/ü.tex"), "___.tex");
}
#[test]
fn first_diff_line_finds_the_first_mismatch() {
assert_eq!(first_diff_line("a\nb\nc\n", "a\nB\nc\n"), 2);
assert_eq!(first_diff_line("a\n", "a\nb\n"), 2);
assert_eq!(first_diff_line("x", "y"), 1);
assert_eq!(first_diff_line("a\nb", "a\nb\n\n"), 3);
}
#[test]
fn report_carries_the_ci_contract_strings() {
let failures = vec![(
"sub/file.tex".to_string(),
DebugFailure {
kind: CheckKind::Idempotency,
left: "a\nb\nc\n".to_string(),
right: "a\nB\nc\n".to_string(),
},
)];
let report = build_debug_report(DebugChecksArg::All, 3, &failures);
assert!(report.contains("# Debug-format regression report"));
assert!(report.contains("- Files checked: 3"));
assert!(report.contains("### 1. `sub/file.tex` (idempotency)"));
assert!(report.contains("- Approx. diff start line: 2"));
assert!(report.contains("```diff\n a\n-b\n-c\n+B\n+c\n```"));
}
#[test]
fn report_on_all_passing_files_has_no_failure_sections() {
let report = build_debug_report(DebugChecksArg::All, 2, &[]);
assert!(report.contains("- Failures: 0"));
assert!(report.contains("All checks passed."));
assert!(!report.contains("## Failures"));
}
#[test]
fn format_error_report_entry_avoids_the_invariant_substrings() {
let failures = vec![(
"bad.tex".to_string(),
DebugFailure {
kind: CheckKind::FormatError,
left:
"input contains 1 parser diagnostic(s); formatter only supports parseable input"
.to_string(),
right: String::new(),
},
)];
let report = build_debug_report(DebugChecksArg::Idempotency, 1, &failures);
assert!(report.contains("### 1. `bad.tex` (format-error)"));
let lower = report.to_lowercase();
let failure_section = &lower[lower.find("## failures").unwrap()..];
assert!(!failure_section.contains("idempot"));
assert!(!failure_section.contains("lossless"));
}
}