mod dotenv;
#[cfg(test)]
pub(crate) static PATH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
mod extension;
mod external;
mod forge;
mod setup;
mod tools;
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
use markdoc_lint::{Corpus, Diagnostic, Severity, corpus, lint_document};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
#[derive(Parser)]
#[command(name = "tw", version, about = "One command for a Markdoc corpus")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Check {
#[arg(long, value_name = "DIR")]
root: Option<PathBuf>,
#[arg(long)]
json: bool,
#[arg(long)]
no_prose: bool,
#[arg(long, value_name = "N")]
jobs: Option<usize>,
#[arg(long, conflicts_with = "paths")]
staged: bool,
#[arg(long, conflicts_with_all = ["staged", "paths"])]
list_rules: bool,
#[arg(value_name = "PATH")]
paths: Vec<PathBuf>,
},
Setup {
#[arg(long, default_value = ".", value_name = "DIR")]
root: PathBuf,
#[arg(long)]
no_tools: bool,
},
Update {
#[arg(long, default_value = ".", value_name = "DIR")]
root: PathBuf,
#[arg(long, default_value = "markdoc-rs/tw", value_name = "OWNER/REPO")]
repo: String,
},
#[command(verbatim_doc_comment)]
Completions {
#[arg(value_name = "SHELL")]
shell: Shell,
},
Fmt {
#[arg(long, default_value = ".", value_name = "DIR")]
root: PathBuf,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Lsp {
#[arg(long, default_value = ".", value_name = "DIR")]
root: PathBuf,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Preview {
#[arg(long, value_name = "DIR")]
root: Option<PathBuf>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Build {
#[arg(long, default_value = ".", value_name = "DIR")]
root: PathBuf,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
#[command(external_subcommand)]
External(Vec<String>),
}
fn launch(path: &Path, root: &Path, args: &[String]) -> Result<ExitCode, String> {
let mut command = std::process::Command::new(path);
command.arg("--root").arg(root).args(args);
hand_over(command, path)
}
fn launch_in(path: &Path, root: &Path, args: &[String]) -> Result<ExitCode, String> {
let mut command = std::process::Command::new(path);
command.args(args).current_dir(root);
hand_over(command, path)
}
fn hand_over(mut command: std::process::Command, path: &Path) -> Result<ExitCode, String> {
dotenv::apply(&mut command);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
let error = command.exec();
Err(format!("{}: {error}", path.display()))
}
#[cfg(not(unix))]
{
let status = command
.status()
.map_err(|e| format!("{}: {e}", path.display()))?;
Ok(ExitCode::from(status.code().unwrap_or(1) as u8))
}
}
fn staged_markdown(root: &Path) -> Result<Vec<PathBuf>, String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["diff", "--cached", "--name-only", "--diff-filter=ACM"])
.output()
.map_err(|e| format!("git: {e}"))?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| l.ends_with(".md"))
.map(|l| root.join(l))
.filter(|p| p.is_file())
.collect())
}
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().collect();
if argv.len() == 2 && matches!(argv[1].as_str(), "help" | "--help" | "-h") {
return help();
}
let cli = Cli::parse();
let start = match &cli.command {
Command::Check { root, .. } | Command::Preview { root, .. } => root.clone(),
Command::Setup { root, .. }
| Command::Update { root, .. }
| Command::Fmt { root, .. }
| Command::Lsp { root, .. }
| Command::Build { root, .. } => Some(root.clone()),
Command::Completions { .. } | Command::External(_) => None,
};
if !matches!(cli.command, Command::Completions { .. }) {
let at = start
.and_then(|r| r.canonicalize().ok())
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."));
if let Err(e) = dotenv::init(&at) {
eprintln!("tw: {e}");
return ExitCode::from(2);
}
}
match cli.command {
Command::Check {
root,
json,
no_prose,
jobs,
staged,
list_rules,
paths,
} => {
let root = corpus_root(root);
if list_rules {
report(rules(&root, json))
} else {
report(check_staged(&root, json, no_prose, jobs, staged, &paths))
}
}
Command::Setup { root, no_tools } => {
report(setup::run(&root, no_tools).map(|()| ExitCode::SUCCESS))
}
Command::Update { root, repo } => {
report(setup::update(&root, &repo).map(|()| ExitCode::SUCCESS))
}
Command::Completions { shell } => {
let mut command = Cli::command();
let name = command.get_name().to_string();
clap_complete::generate(shell, &mut command, name, &mut std::io::stdout());
ExitCode::SUCCESS
}
Command::Fmt { root, args } => {
report(launched(&tools::MARKDOC_LS, &root, "--format", &args))
}
Command::Lsp { root, args } => report(launched(&tools::MARKDOC_LS, &root, "", &args)),
Command::Preview { root, args } => report(preview(&corpus_root(root), &args)),
Command::Build { root, args } => report(launched(&tools::MARKDOC_PDF, &root, "", &args)),
Command::External(args) => {
let (root, config) = corpus();
report(extension::run(&args, &root, &config))
}
}
}
fn help() -> ExitCode {
let mut command = Cli::command();
let (root, config) = corpus();
let found = extension::discover(&extension::pinned_dirs(&root, &config));
if !found.is_empty() {
let builtins: Vec<String> = command
.get_subcommands()
.map(|s| s.get_name().to_string())
.collect();
command = command.after_long_help(extension::help_section(&found, &builtins));
}
let _ = command.print_long_help();
println!();
ExitCode::SUCCESS
}
fn corpus() -> (PathBuf, external::Config) {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let root = tools::corpus(&cwd);
let config = external::Config::load(&root).unwrap_or_else(|e| {
eprintln!("tw: {e}");
external::Config::default()
});
(root, config)
}
fn corpus_root(root: Option<PathBuf>) -> PathBuf {
root.unwrap_or_else(|| {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
tools::corpus(&cwd)
})
}
fn report(result: Result<ExitCode, String>) -> ExitCode {
match result {
Ok(code) => code,
Err(message) => {
eprintln!("tw: {message}");
ExitCode::from(2)
}
}
}
fn preview(root: &Path, args: &[String]) -> Result<ExitCode, String> {
let root = root
.canonicalize()
.map_err(|e| format!("{}: {e}", root.display()))?;
let root = tools::corpus(&root);
let config = external::Config::load(&root)?;
let run = config.preview.run.clone().ok_or_else(|| {
format!(
"this corpus names no site generator for `tw preview`. Say which \
program builds and serves it in {} at its root:\n \
[preview]\n run = \"<program>\" # run as <program> --root <root>\n \
run = [\"<program>\", \"<arg>\"] # or a command line, run as written\n \
and pin it with a [tools.<program>] entry, or put it on PATH.",
external::CONFIG_FILENAME
)
})?;
match run {
external::PreviewRun::Program(program) => {
let path = external::resolve(&program, &root, &config)?;
launch(&path, &root, args)
}
external::PreviewRun::Command(words) => {
let Some((program, lead)) = words.split_first() else {
return Err(format!(
"[preview] run in {} is an empty list: name the command that builds \
and serves the site.",
external::CONFIG_FILENAME
));
};
let path = external::resolve(program, &root, &config)?;
let mut all = lead.to_vec();
all.extend_from_slice(args);
launch_in(&path, &root, &all)
}
}
}
fn launched(
tool: &tools::Launched,
root: &Path,
lead: &str,
args: &[String],
) -> Result<ExitCode, String> {
let root = root
.canonicalize()
.map_err(|e| format!("{}: {e}", root.display()))?;
let path = tool.resolve(&root)?;
let mut all: Vec<String> = Vec::new();
if !lead.is_empty() {
all.push(lead.to_string());
}
all.extend_from_slice(args);
launch(&path, &root, &all)
}
fn rules(root: &Path, json: bool) -> Result<ExitCode, String> {
let root = root
.canonicalize()
.map_err(|e| format!("{}: {e}", root.display()))?;
let config = markdoc_lint::Config::load(&root).map_err(|e| e.to_string())?;
let status = markdoc_lint::rules::status(&config);
let tw_config = external::Config::load(&root)?;
if json {
let externals: Vec<_> = tw_config
.check
.external
.iter()
.map(|e| {
serde_json::json!({
"run": e.run,
"active": e.is_enabled(),
"severity_override": e.severity.map(|s| s.label()),
})
})
.collect();
let out = serde_json::json!({ "rules": status, "external": externals });
println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
return Ok(ExitCode::SUCCESS);
}
for rule in &status {
let state = if rule.disabled {
"off, switched off"
} else if !rule.configured {
"off, not configured"
} else {
"on"
};
let severity = match rule.severity_override {
Some(s) => format!(
"{} (the rule says {})",
s.label(),
rule.info.default_severity.label()
),
None => rule.info.default_severity.label().to_string(),
};
let width = status.iter().map(|r| r.info.name.len()).max().unwrap_or(0);
println!("{:<width$} {state:<21} {severity}", rule.info.name);
println!(" {}", rule.info.summary);
if rule.disabled {
println!(
" switched off by [rules.{}] enabled = false",
rule.info.name
);
} else if let Some(by) = rule.info.configured_by
&& !rule.configured
{
println!(" turn it on with: {by}");
}
println!(" emits: {}", rule.info.diagnostics.join(", "));
println!();
}
if !tw_config.check.external.is_empty() {
println!("asked as well, over --check --json (tw.toml):");
for entry in &tw_config.check.external {
let state = if entry.is_enabled() {
"on"
} else {
"off, switched off"
};
println!(" {:<40} {state}", entry.command_line());
if let Some(s) = entry.severity {
println!(" every finding re-levelled to {}", s.label());
}
}
println!();
}
eprintln!(
"{} rule(s), {} active, {} external check(s). Prose findings come from \
markdoc-prose, which is configured separately in markdoc-prose.toml.",
status.len(),
status.iter().filter(|r| r.active).count(),
tw_config.active().count(),
);
Ok(ExitCode::SUCCESS)
}
fn check_staged(
root: &Path,
json: bool,
no_prose: bool,
jobs: Option<usize>,
staged: bool,
paths: &[PathBuf],
) -> Result<ExitCode, String> {
if !staged {
return check(root, json, no_prose, jobs, paths);
}
let files = staged_markdown(root)?;
if files.is_empty() {
return Ok(ExitCode::SUCCESS);
}
check(root, json, no_prose, jobs, &files)
}
fn targets(root: &Path, paths: &[PathBuf]) -> Result<Vec<PathBuf>, String> {
let is_markdown = |p: &Path| p.extension().and_then(|s| s.to_str()) == Some("md");
if paths.is_empty() {
return Ok(corpus::find_files(root, &is_markdown));
}
let mut out = Vec::new();
for path in paths {
let p = path.canonicalize().map_err(|e| {
format!(
"{}: {}",
path.display(),
if e.kind() == std::io::ErrorKind::NotFound {
"no such file or directory".to_string()
} else {
e.to_string()
}
)
})?;
if p.is_dir() {
out.extend(corpus::find_files(&p, &is_markdown));
} else {
out.push(p);
}
}
Ok(out)
}
fn rel_of(root: &Path, file: &Path) -> Option<String> {
let rel = file.strip_prefix(root).ok()?;
Some(rel.to_string_lossy().replace('\\', "/"))
}
fn default_jobs() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
}
fn check(
root: &Path,
json: bool,
no_prose: bool,
jobs: Option<usize>,
paths: &[PathBuf],
) -> Result<ExitCode, String> {
let root = root
.canonicalize()
.map_err(|e| format!("{}: {e}", root.display()))?;
let lint_config = markdoc_lint::Config::load(&root).map_err(|e| e.to_string())?;
let prose_config = markdoc_prose::Config::load(&root)?;
let tw_config = external::Config::load(&root)?;
let index = Corpus::load_with(&root, lint_config);
let mut sources: Vec<(String, String)> = Vec::new();
for file in targets(&root, paths)? {
let Some(rel) = rel_of(&root, &file) else {
continue;
};
if index.config.is_skipped(&rel) {
continue;
}
let Ok(source) = std::fs::read_to_string(&file) else {
continue;
};
sources.push((rel, source));
}
let mut all: Vec<Diagnostic> = if paths.is_empty() {
let mut corpus_wide = markdoc_lint::lint_corpus(&index);
for entry in tw_config.active() {
corpus_wide.extend(external::run(entry, &root, &tw_config)?);
}
corpus_wide
} else {
Vec::new()
};
let linted = markdoc_lint::corpus::in_parallel(&sources, |(rel, source)| {
lint_document(rel, source, &index).diagnostics
});
all.extend(linted.into_iter().flatten());
if prose_config.engine.enabled && !no_prose {
let mut prose = markdoc_prose::Prose::new(&prose_config, &root)?;
let jobs = jobs.unwrap_or_else(default_jobs);
for diagnostics in prose
.lint_many(&sources, jobs, |source| Some(source.clone()))
.into_iter()
.flatten()
{
all.extend(diagnostics);
}
}
all.sort_by_key(|d| {
(
d.file.clone(),
d.span.map(|s| s.start_line).unwrap_or(0),
d.span.map(|s| s.start_char).unwrap_or(0),
)
});
let errors = all.iter().filter(|d| d.severity == Severity::Error).count();
let warnings = all
.iter()
.filter(|d| d.severity == Severity::Warning)
.count();
if json {
let out = serde_json::json!({
"files": sources.len(),
"prose": !no_prose && prose_config.engine.enabled,
"indexed": index.files,
"errors": errors,
"warnings": warnings,
"diagnostics": all,
});
println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
} else {
for d in &all {
println!("{}", d.to_text());
}
eprintln!(
"\n{} file(s) checked against an index of {}; {errors} error(s), {warnings} warning(s).",
sources.len(),
index.files
);
}
Ok(if errors > 0 {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_path_that_is_not_there_is_an_error_naming_it() {
let d = std::env::temp_dir().join(format!("tw-targets-{}", std::process::id()));
std::fs::create_dir_all(d.join("docs")).unwrap();
std::fs::write(d.join("docs").join("a.md"), "# A\n").unwrap();
let missing = d.join("does-not-exist.md");
let err = targets(&d, &[d.join("docs").join("a.md"), missing.clone()]).unwrap_err();
assert!(
err.contains(&missing.display().to_string()) && err.contains("no such file"),
"{err}"
);
let found = targets(&d, &[d.join("docs")]).unwrap();
assert_eq!(found.len(), 1);
let _ = std::fs::remove_dir_all(&d);
}
}