use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand, ValueEnum};
use crate::compiler::{self, Plan};
use crate::model::ADOPTING_GUIDE;
use crate::project::{self, GENERATED_HEADER};
use crate::{paths, pkl_source, publication, report, schema, watch};
#[derive(Debug, Parser)]
#[command(
name = "toolconfig",
version,
about = "EditorConfig, extended to your tools",
disable_help_subcommand = true
)]
struct Arguments {
#[arg(long, global = true, value_name = "DIR", env = "TOOLCONFIG_ROOT")]
root: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Init {
#[arg(long, value_enum, default_value_t = Format::Toml)]
format: Format,
#[arg(long)]
gitignore: bool,
},
Generate {
#[arg(long)]
watch: bool,
#[arg(long)]
strict: bool,
},
Check {
#[arg(long)]
strict: bool,
},
Validate {
#[arg(long)]
strict: bool,
},
List,
Explain {
tool: Option<String>,
#[arg(long, value_name = "DIR")]
dir: Option<PathBuf>,
},
Clean,
#[command(hide = true)]
Schema,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum Format {
Toml,
Pkl,
}
pub(crate) fn run() -> Result<()> {
let arguments = Arguments::parse();
let requested = arguments.root.as_deref();
match arguments.command {
Command::Init { format, gitignore } => init(requested, format, gitignore),
Command::Generate { watch: true, .. } => watch::run(&project::find_root(requested)?),
Command::Generate {
watch: false,
strict,
} => {
let root = project::find_root(requested)?;
let project = publication::Project::acquire(&root)?;
let plan = build(&root)?;
report::warnings(&plan, strict)?;
project.generate(&plan)
}
Command::Check { strict } => {
let plan = build(&project::find_root(requested)?)?;
report::warnings(&plan, strict)?;
let problems = publication::drift(&plan)?;
if problems.is_empty() {
println!("Up to date: {} generated files", plan.outputs.len());
return Ok(());
}
for (status, path) in &problems {
println!("{status}\t{path}");
}
bail!(
"{} generated file{} out of date; run toolconfig generate",
problems.len(),
if problems.len() == 1 { " is" } else { "s are" }
)
}
Command::Validate { strict } => {
let plan = build(&project::find_root(requested)?)?;
report::warnings(&plan, strict)?;
publication::validate(&plan)?;
println!("Valid: {} generated files", plan.outputs.len());
Ok(())
}
Command::List => {
let root = project::find_root(requested)?;
let plan = build(&root)?;
println!("STATUS\tFORMAT\tOUTPUT\tSOURCE");
for output in &plan.outputs {
println!(
"{}\t{}\t{}\t{}",
publication::status(&root, &output.path)?,
output.format,
paths::relative(&root, &output.path),
paths::relative(&root, &output.source)
);
}
Ok(())
}
Command::Explain { tool, dir } => explain(requested, tool.as_deref(), dir.as_deref()),
Command::Clean => {
let root = match project::find_root(requested) {
Ok(root) => root,
Err(error) => state_root().ok_or(error)?,
};
publication::Project::acquire(&root)?.clean()
}
Command::Schema => {
println!("{}", serde_json::to_string_pretty(&schema::generate())?);
Ok(())
}
}
}
fn build(root: &Path) -> Result<Plan> {
compiler::build(root).0
}
fn state_root() -> Option<PathBuf> {
let cwd = std::env::current_dir().ok()?.canonicalize().ok()?;
cwd.ancestors()
.find(|ancestor| ancestor.join(paths::STATE_DIR).join("state.json").is_file())
.map(Path::to_path_buf)
}
fn init(requested: Option<&Path>, format: Format, gitignore: bool) -> Result<()> {
let cwd = std::env::current_dir()
.context("cannot read the current directory")?
.canonicalize()?;
let dir = match requested {
Some(requested) => cwd.join(requested),
None => cwd,
};
std::fs::create_dir_all(&dir).with_context(|| format!("cannot create {}", dir.display()))?;
let dir = dir.canonicalize()?;
let existing = project::sources_in(&dir)?;
let (path, created) = match existing.as_slice() {
[path] => (path.clone(), false),
[] => {
let editorconfig = dir.join(".editorconfig");
if let Ok(bytes) = std::fs::read(&editorconfig)
&& !bytes.starts_with(GENERATED_HEADER.as_bytes())
{
bail!(
"{} is hand-written; convert it into a .toolconfig.toml first (see {ADOPTING_GUIDE})",
editorconfig.display()
);
}
let (name, text) = match format {
Format::Toml => (".toolconfig.toml", starter_toml()),
Format::Pkl => (".toolconfig.pkl", starter_pkl()),
};
let path = dir.join(name);
let mut file = tempfile::Builder::new()
.prefix(compiler::STAGE_PREFIX)
.tempfile_in(&dir)?;
use std::io::Write;
file.write_all(text.as_bytes())?;
file.as_file().sync_all()?;
file.persist_noclobber(&path)
.map_err(|error| error.error)
.with_context(|| format!("cannot create {}", path.display()))?;
(path, true)
}
_ => bail!(
"{} has more than one ToolConfig source; keep exactly one",
dir.display()
),
};
if gitignore {
let plan = build(&dir)?;
publication::Project::acquire(&dir)?.establish_ignore(&plan)?;
}
println!(
"{} {}",
if created { "Created" } else { "Using" },
path.display()
);
Ok(())
}
fn starter_toml() -> String {
format!(
r#"#:schema {}
root = true
[toolconfig]
# Tools whose configuration is generated in this directory, such as "rustfmt" or "prettier".
tools = []
["*"]
charset = "utf-8"
end_of_line = "lf"
insert_final_newline = true
trim_trailing_whitespace = true
"#,
schema::url()
)
}
fn starter_pkl() -> String {
format!(
r#"amends "{}#/ToolConfig.pkl"
root = true
toolconfig {{
// Tools whose configuration is generated in this directory, such as "rustfmt" or "prettier".
tools = List()
}}
sections {{
["*"] {{
charset = "utf-8"
end_of_line = "lf"
insert_final_newline = true
trim_trailing_whitespace = true
}}
}}
"#,
pkl_source::package_uri()
)
}
fn explain(requested: Option<&Path>, tool: Option<&str>, dir: Option<&Path>) -> Result<()> {
let root = project::find_root(requested)?;
let plan = build(&root)?;
let dir = dir
.map(|dir| -> Result<PathBuf> {
let cwd = std::env::current_dir()?;
Ok(cwd.join(dir).canonicalize()?)
})
.transpose()?;
let mut shown = 0;
for output in &plan.outputs {
if output.is_editorconfig() || output.label.starts_with("files.") {
continue;
}
if tool.is_some_and(|tool| tool != output.label)
|| dir
.as_deref()
.is_some_and(|dir| output.path.parent() != Some(dir))
{
continue;
}
if shown > 0 {
println!();
}
shown += 1;
println!(
"{} -> {} (from {})",
output.label,
paths::relative(&root, &output.path),
paths::relative(&root, &output.source)
);
let width = output
.explanation
.iter()
.map(|entry| entry.key.len())
.max()
.unwrap_or(0);
let value_width = output
.explanation
.iter()
.map(|entry| entry.value.len().min(24))
.max()
.unwrap_or(0);
for entry in &output.explanation {
let scope = entry
.files
.as_ref()
.map(|files| format!(" (override for {files})"))
.unwrap_or_default();
println!(
" {:width$} {:value_width$} {}{scope}",
entry.key, entry.value, entry.from
);
}
if output.explanation.is_empty() {
println!(" (no keys)");
}
}
if shown == 0 {
match tool {
Some(tool) => bail!("no generated configuration for {tool}"),
None => println!("No tool configuration is generated."),
}
}
Ok(())
}