use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use badness::file_discovery::{FileDiscoveryError, FileKind, collect_lint_files, file_kind_or_tex};
use badness::formatter::{
FormatStyle, WrapMode, check_paths_with_style, format_with_style_flavored,
};
use badness::linter::{
Diagnostic, OutputMode, apply_fixes, check_document, lint_document, render_findings,
};
use std::collections::HashMap;
use badness::parser::{LatexFlavor, parse_with_flavor};
use badness::project::labels::{document_label_names, is_document_root};
use badness::project::{
CiteFileFacts, FileFacts, IncludeGraph, ResolvedCitations, ResolvedLabels,
collect_bib_resource_targets, collect_include_edge_keys,
};
use badness::semantic::SemanticModel;
use badness::syntax::SyntaxNode;
use clap::{Parser, Subcommand, ValueEnum};
use rowan::NodeOrToken;
use smol_str::SmolStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum WrapArg {
Reflow,
Sentence,
Semantic,
Preserve,
}
impl From<WrapArg> for WrapMode {
fn from(arg: WrapArg) -> Self {
match arg {
WrapArg::Reflow => WrapMode::Reflow,
WrapArg::Sentence => WrapMode::Sentence,
WrapArg::Semantic => WrapMode::Semantic,
WrapArg::Preserve => WrapMode::Preserve,
}
}
}
#[derive(Parser)]
#[command(
name = "badness",
version,
about = "A formatter, linter, and language server for LaTeX"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Format {
paths: Vec<PathBuf>,
#[arg(long)]
check: bool,
#[arg(long, value_name = "PATH")]
stdin_filepath: Option<PathBuf>,
#[arg(long)]
line_width: Option<usize>,
#[arg(long)]
indent_width: Option<usize>,
#[arg(long, value_enum)]
wrap: Option<WrapArg>,
},
Lint {
paths: Vec<PathBuf>,
#[arg(long)]
fix: bool,
#[arg(long)]
unsafe_fixes: bool,
#[arg(long, value_name = "PATH")]
stdin_filepath: Option<PathBuf>,
},
Parse {
path: Option<PathBuf>,
},
Lsp,
}
fn main() -> ExitCode {
let cli = Cli::parse();
match cli.command {
Command::Format {
paths,
check,
stdin_filepath,
line_width,
indent_width,
wrap,
} => {
let mut style = FormatStyle::default();
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(Into::into);
run_format(
&paths,
check,
stdin_filepath.as_deref(),
style,
wrap_override,
)
}
Command::Lint {
paths,
fix,
unsafe_fixes,
stdin_filepath,
} => run_lint(&paths, fix, unsafe_fixes, stdin_filepath.as_deref()),
Command::Parse { path } => run_parse(path.as_deref()),
Command::Lsp => run_lsp(),
}
}
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_lint(
paths: &[PathBuf],
fix: bool,
unsafe_fixes: bool,
stdin_filepath: Option<&Path>,
) -> ExitCode {
if fix
&& !paths.is_empty()
&& let Some(code) = apply_fixes_to_paths(paths, unsafe_fixes)
{
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) {
Ok(files) => files,
Err(err) => {
report_discovery_error(&err);
return ExitCode::FAILURE;
}
};
if files.is_empty() {
eprintln!(
"badness: no .tex, .sty, .cls, or .bib files found under the provided input paths"
);
return ExitCode::FAILURE;
}
for (path, kind) in files {
match std::fs::read_to_string(&path) {
Ok(content) => sources.push((path, content, kind)),
Err(err) => {
eprintln!("badness: cannot read {}: {err}", path.display());
failed = true;
}
}
}
}
let mut diagnostics: Vec<Diagnostic> = Vec::new();
let mut analyzed: Vec<(&PathBuf, SyntaxNode, 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 bib_keys: HashMap<PathBuf, Vec<SmolStr>> = HashMap::new();
for (path, content, kind) in &sources {
match kind {
FileKind::Bib => {
let parsed = badness::bib::parse(content);
diagnostics.extend(parsed.errors.iter().map(|err| Diagnostic {
rule: "parse",
severity: badness::linter::Severity::Error,
path: path.clone(),
start: err.start,
end: err.end,
message: err.message.clone(),
fix: None,
}));
let root = parsed.syntax();
let model = badness::bib::semantic::Model::build(&root);
bib_keys.insert(
path.clone(),
model.entries().iter().map(|e| e.key.clone()).collect(),
);
diagnostics.extend(badness::bib::linter::lint_document(path, &root, &model));
}
FileKind::Tex | FileKind::Sty | FileKind::Cls => {
let parsed = parse_with_flavor(content, kind.latex_flavor());
diagnostics.extend(
parsed
.errors
.iter()
.map(|err| Diagnostic::from_parse(path.clone(), err)),
);
let root = SyntaxNode::new_root(parsed.green);
let model = SemanticModel::build(&root);
facts.push(FileFacts {
path: path.clone(),
include_edges: collect_include_edge_keys(&root, path.parent()),
});
label_inputs.push((
path.clone(),
document_label_names(&model),
is_document_root(&root),
));
cite_facts.push(CiteFileFacts {
path: path.clone(),
bib_targets: collect_bib_resource_targets(&root, path.parent()),
nocite_all: model.has_wildcard_nocite(),
is_document_root: is_document_root(&root),
});
analyzed.push((path, root, model));
}
}
}
let graph = IncludeGraph::build(&facts, None);
let resolved = ResolvedLabels::build(&label_inputs, &graph);
let resolved_citations = ResolvedCitations::build(&cite_facts, &graph, &bib_keys);
for (path, root, model) in &analyzed {
diagnostics.extend(lint_document(
path,
root,
model,
Some(&resolved),
Some(&resolved_citations),
));
}
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_for = |path: &Path| {
sources
.iter()
.find(|(p, _, _)| p == path)
.map(|(_, text, _)| text.clone())
};
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) -> Option<ExitCode> {
let files = match collect_lint_files(paths) {
Ok(files) => files,
Err(err) => {
report_discovery_error(&err);
return Some(ExitCode::FAILURE);
}
};
if files.is_empty() {
eprintln!("badness: no .tex or .bib files found under the provided input paths");
return Some(ExitCode::FAILURE);
}
for (path, kind) in files {
match fix_file(&path, kind, include_unsafe) {
Ok(0) => {}
Ok(n) => eprintln!("{}: {n} fix{} applied", path.display(), plural(n)),
Err(err) => {
eprintln!("badness: cannot fix {}: {err}", path.display());
return Some(ExitCode::FAILURE);
}
}
}
None
}
fn fix_file(path: &Path, kind: FileKind, include_unsafe: bool) -> std::io::Result<usize> {
let mut content = std::fs::read_to_string(path)?;
let mut total = 0usize;
for _ in 0..MAX_FIX_ITERATIONS {
let diagnostics = match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls => {
check_document(path, &content, kind.latex_flavor())
}
FileKind::Bib => badness::bib::linter::check_document(path, &content),
};
let fixes: Vec<_> = diagnostics.into_iter().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 {
std::fs::write(path, &content)?;
}
Ok(total)
}
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 flavor = path.map_or(LatexFlavor::Document, |p| {
file_kind_or_tex(p).latex_flavor()
});
let parsed = parse_with_flavor(&input, flavor);
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>,
) -> ExitCode {
if check {
return run_check(paths, style, wrap_override);
}
if paths.is_empty() {
run_format_stdin(stdin_filepath, style, wrap_override)
} else {
run_format_paths(paths, style, wrap_override)
}
}
fn run_check(paths: &[PathBuf], style: FormatStyle, wrap_override: Option<WrapMode>) -> ExitCode {
match check_paths_with_style(paths, style, wrap_override) {
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>,
) -> 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 => {
format_with_style_flavored(&input, style, kind.latex_flavor())
.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::NonTexFilePath { path } => {
eprintln!(
"badness: input file {} is not a .tex file; only .tex files are supported",
path.display()
);
}
FileDiscoveryError::UnsupportedLintFilePath { path } => {
eprintln!(
"badness: input file {} is not a .tex, .sty, .cls, or .bib file",
path.display()
);
}
FileDiscoveryError::WalkError { path, message } => {
eprintln!(
"badness: failed while scanning {}: {message}",
path.display()
);
}
}
}
fn run_format_paths(
paths: &[PathBuf],
mut style: FormatStyle,
wrap_override: Option<WrapMode>,
) -> ExitCode {
let files = match collect_lint_files(paths) {
Ok(files) => files,
Err(err) => {
report_discovery_error(&err);
return ExitCode::FAILURE;
}
};
if files.is_empty() {
eprintln!(
"badness: no .tex, .sty, .cls, or .bib files found under the provided input paths"
);
return ExitCode::FAILURE;
}
let mut failed = false;
for (path, kind) in &files {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(err) => {
eprintln!("badness: cannot read {}: {err}", path.display());
failed = true;
continue;
}
};
style.wrap = wrap_override.unwrap_or(kind.default_wrap());
let formatted = match kind {
FileKind::Tex | FileKind::Sty | FileKind::Cls => {
format_with_style_flavored(&content, style, kind.latex_flavor())
.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)
{
eprintln!("badness: cannot write {}: {err}", path.display());
failed = true;
}
}
Err(msg) => {
eprintln!("badness: cannot format {}: {msg}", path.display());
failed = true;
}
}
}
if failed {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}