use markdoc_lint::{Diagnostic, Severity};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::tools;
pub const CONFIG_FILENAME: &str = "tw.toml";
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
pub check: CheckConfig,
pub extensions: Vec<String>,
pub tools: BTreeMap<String, ToolConfig>,
pub preview: PreviewConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolConfig {
pub pin: String,
pub asset: Option<String>,
pub env: Option<String>,
pub checkout: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct PreviewConfig {
pub run: Option<PreviewRun>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(untagged)]
pub enum PreviewRun {
Program(String),
Command(Vec<String>),
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct CheckConfig {
pub external: Vec<External>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct External {
pub run: Vec<String>,
pub enabled: Option<bool>,
pub severity: Option<Severity>,
}
impl External {
pub fn is_enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
pub fn name(&self) -> &str {
self.run.first().map(String::as_str).unwrap_or("")
}
pub fn command_line(&self) -> String {
self.run.join(" ")
}
}
impl Config {
pub fn load(dir: &Path) -> Result<Self, String> {
let path = dir.join(CONFIG_FILENAME);
match std::fs::read_to_string(&path) {
Ok(text) => Self::parse(&text).map_err(|e| format!("{}: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
pub fn parse(text: &str) -> Result<Self, String> {
let config: Self = toml::from_str(text).map_err(|e| e.to_string())?;
for entry in &config.check.external {
if entry.run.is_empty() {
return Err("[[check.external]] run = [] names no command".to_string());
}
}
let plain =
|s: &str| !s.is_empty() && s != "." && s != ".." && !s.contains(['/', '\\', ':']);
for (name, tool) in &config.tools {
if !plain(name) {
return Err(format!("[tools.{name}]: `{name}` is not a program's name"));
}
if tools::builtin(name).is_some() {
return Err(format!(
"[tools.{name}]: tw pins {name} itself, from the `{name}` \
entry of tooling-versions.json; this would never be used"
));
}
if !plain(&tool.pin) {
return Err(format!(
"[tools.{name}] pin = `{}` is not a pin's name",
tool.pin
));
}
if let Some(asset) = &tool.asset
&& !plain(&asset.replace(['{', '}'], ""))
{
return Err(format!(
"[tools.{name}] asset = `{asset}` is not a file name"
));
}
if let Some(checkout) = &tool.checkout
&& !plain(checkout)
{
return Err(format!(
"[tools.{name}] checkout = `{checkout}` is not a directory's name"
));
}
}
Ok(config)
}
pub fn launched(&self, name: &str) -> Option<tools::Launched> {
tools::builtin(name).or_else(|| {
self.tools
.get(name)
.map(|tool| tools::Launched::configured(name, tool))
})
}
pub fn all_launched(&self) -> Vec<tools::Launched> {
[tools::MARKDOC_LS, tools::MARKDOC_PDF]
.into_iter()
.chain(
self.tools
.iter()
.map(|(name, tool)| tools::Launched::configured(name, tool)),
)
.collect()
}
pub fn active(&self) -> impl Iterator<Item = &External> {
self.check.external.iter().filter(|e| e.is_enabled())
}
}
pub fn run(entry: &External, root: &Path, config: &Config) -> Result<Vec<Diagnostic>, String> {
let program = resolve(entry.name(), root, config)
.map_err(|e| format!("{e}\n (named by [[check.external]] in tw.toml)"))?;
let mut command = Command::new(&program);
crate::dotenv::apply(&mut command);
let output = command
.args(&entry.run[1..])
.current_dir(root)
.output()
.map_err(|e| format!("{}: {e}", program.display()))?;
if !output.stderr.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
match output.status.code() {
Some(0 | 1) => {}
Some(code) => {
return Err(format!(
"{} exited {code}. Its findings were not read.",
entry.command_line()
));
}
None => {
return Err(format!(
"{} was killed before it finished.",
entry.command_line()
));
}
}
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
return Err(format!(
"{} printed nothing. A tool named by [[check.external]] prints, \
under --json, an object with a \"diagnostics\" array of \
markdoc-lint diagnostics -- including when it found none.",
entry.command_line()
));
}
let report: Report = serde_json::from_str(&stdout).map_err(|e| {
format!(
"{} did not print a report: {e}\n\
A tool named by [[check.external]] prints, under --json, an \
object with a \"diagnostics\" array of markdoc-lint diagnostics.",
entry.command_line()
)
})?;
let mut diagnostics = report.diagnostics;
if let Some(severity) = entry.severity {
for d in &mut diagnostics {
d.severity = severity;
}
}
Ok(diagnostics)
}
#[derive(Deserialize)]
struct Report {
#[serde(default)]
diagnostics: Vec<Diagnostic>,
}
pub fn resolve(name: &str, root: &Path, config: &Config) -> Result<PathBuf, String> {
resolve_in(name, root, config, &tools::Search::from_env())
}
fn resolve_in(
name: &str,
root: &Path,
config: &Config,
search: &tools::Search,
) -> Result<PathBuf, String> {
if let Some(launched) = config.launched(name) {
return launched.resolve(root);
}
search.find(name).ok_or_else(|| {
format!(
"{name} is not pinned -- neither by tw nor under [tools] in tw.toml -- \
and is not on PATH."
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_config_means_no_external_checks() {
let c = Config::default();
assert_eq!(c.active().count(), 0);
}
#[test]
fn an_entry_is_a_command_and_two_optional_knobs() {
let c = Config::parse(
r#"
[[check.external]]
run = ["site-gen", "--check", "--json"]
"#,
)
.unwrap();
let entry = c.active().next().unwrap();
assert_eq!(entry.name(), "site-gen");
assert_eq!(entry.command_line(), "site-gen --check --json");
assert!(entry.severity.is_none());
}
#[test]
fn an_entry_can_be_switched_off_and_re_levelled() {
let c = Config::parse(
r#"
[[check.external]]
run = ["a"]
enabled = false
[[check.external]]
run = ["b"]
severity = "warning"
"#,
)
.unwrap();
assert_eq!(c.check.external.len(), 2);
let active: Vec<_> = c.active().collect();
assert_eq!(active.len(), 1);
assert_eq!(active[0].name(), "b");
assert_eq!(active[0].severity, Some(Severity::Warning));
}
#[test]
fn a_misspelled_key_is_an_error_rather_than_a_silent_no_op() {
let err = Config::parse("[[check.external]]\nrun = [\"a\"]\nseverty = \"warning\"\n")
.unwrap_err();
assert!(err.contains("severty"), "{err}");
}
#[test]
fn a_command_with_no_command_in_it_is_refused() {
let err = Config::parse("[[check.external]]\nrun = []\n").unwrap_err();
assert!(err.contains("names no command"), "{err}");
}
#[test]
fn a_corpus_names_its_own_pinned_programs_and_what_preview_runs() {
let c = Config::parse(
r#"
[tools.site-gen]
pin = "site-toolkit"
checkout = "site-toolkit-src"
[preview]
run = "site-gen"
"#,
)
.unwrap();
assert_eq!(
c.preview.run,
Some(PreviewRun::Program("site-gen".to_string()))
);
let line =
Config::parse("[preview]\nrun = [\"npm\", \"run\", \"preview\", \"--\"]\n").unwrap();
assert_eq!(
line.preview.run,
Some(PreviewRun::Command(
["npm", "run", "preview", "--"].map(String::from).to_vec()
))
);
let tool = c.launched("site-gen").unwrap();
assert_eq!(tool.source.pinned_by, "site-toolkit");
assert_eq!(tool.env_override, "SITE_GEN_BIN");
assert_eq!(tool.built_in.as_deref(), Some("site-toolkit-src"));
assert!(c.launched("markdoc-ls").is_some());
assert!(c.launched("something-on-path").is_none());
let names: Vec<_> = c
.all_launched()
.into_iter()
.map(|l| l.stem.to_string())
.collect();
assert_eq!(names, ["markdoc-ls", "markdoc-pdf", "site-gen"]);
}
#[test]
fn a_pinned_program_s_names_are_names_and_not_paths() {
for (toml, says) in [
(
"[tools.markdoc-ls]\npin = \"x\"",
"tw pins markdoc-ls itself",
),
("[tools.\"../x\"]\npin = \"x\"", "not a program's name"),
("[tools.x]\npin = \"a/b\"", "not a pin's name"),
(
"[tools.x]\npin = \"x\"\nasset = \"../x-{platform}\"",
"not a file name",
),
(
"[tools.x]\npin = \"x\"\ncheckout = \"..\"",
"not a directory's name",
),
("[tools.x]\npin = \"x\"\npinn = \"y\"", "pinn"),
("[tools.x]\n", "pin"),
("[preview]\nrun = \"x\"\nargs = []", "args"),
] {
let err = Config::parse(toml).unwrap_err();
assert!(err.contains(says), "{toml}: {err}");
}
}
fn node_dir(test: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("tw-resolve-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for file in ["npm", "npm.cmd", "npm.ps1"] {
std::fs::write(dir.join(file), "").unwrap();
}
dir
}
#[test]
fn a_program_on_path_is_found_as_a_command_line_finds_it() {
let dir = node_dir("pathext");
let root = Path::new("/nonexistent");
let config = Config::default();
let windows = tools::Search {
dirs: vec![dir.clone()],
pathext: [".COM", ".EXE", ".BAT", ".CMD"].map(String::from).to_vec(),
};
assert_eq!(
resolve_in("npm", root, &config, &windows),
Ok(dir.join("npm.cmd"))
);
assert_eq!(
resolve_in("npm.cmd", root, &config, &windows),
Ok(dir.join("npm.cmd"))
);
let elsewhere = tools::Search {
dirs: vec![dir.clone()],
pathext: Vec::new(),
};
assert_eq!(
resolve_in("npm", root, &config, &elsewhere),
Ok(dir.join("npm"))
);
let err = resolve_in("npx", root, &config, &windows).unwrap_err();
assert!(err.starts_with("npx is not pinned"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(windows)]
#[test]
fn on_windows_resolve_finds_npm_cmd_on_path() {
let dir = node_dir("windows");
let _path = crate::PATH_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let saved = std::env::var_os("PATH");
let in_front = std::env::join_paths(
std::iter::once(dir.clone()).chain(saved.iter().flat_map(std::env::split_paths)),
)
.unwrap();
unsafe { std::env::set_var("PATH", &in_front) };
let found = resolve("npm", Path::new("/nonexistent"), &Config::default());
unsafe {
match saved {
Some(v) => std::env::set_var("PATH", v),
None => std::env::remove_var("PATH"),
}
}
assert_eq!(found, Ok(dir.join("npm.cmd")));
let _ = std::fs::remove_dir_all(&dir);
}
}