use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
fn tool_available(tool: &str) -> bool {
let finder = if cfg!(windows) { "where" } else { "which" };
let exists = Command::new(finder)
.arg(tool)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !exists {
return false;
}
match tool {
"terraform" => {
Command::new("terraform")
.arg("version")
.output()
.map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains("Terraform"))
.unwrap_or(false)
}
"black" => {
Command::new("black")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
"shuck" => {
Command::new("shuck")
.args(["check", "--help"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
_ => {
match Command::new(tool).arg("--version").spawn() {
Ok(mut child) => {
let _ = child.kill();
true
}
Err(_) => false,
}
}
}
}
fn setup(config_lang: &str, slot: &str, tool: &str, lang_tag: &str, code: &str) -> TempDir {
let dir = tempfile::tempdir().unwrap();
let config = format!(
"[code-block-tools]\nenabled = true\nnormalize-language = \"exact\"\non-error = \"warn\"\n\n\
[code-block-tools.languages]\n{config_lang} = {{ {slot} = [\"{tool}\"] }}\n"
);
fs::write(dir.path().join(".rumdl.toml"), config).unwrap();
fs::write(dir.path().join("t.md"), format!("# T\n\n```{lang_tag}\n{code}\n```\n")).unwrap();
dir
}
fn run(dir: &Path, args: &[&str]) -> String {
run_with_runner_env(dir, args, false)
}
fn run_on_a_runner(dir: &Path, args: &[&str]) -> String {
run_with_runner_env(dir, args, true)
}
fn run_with_runner_env(dir: &Path, args: &[&str], github_actions: bool) -> String {
let mut command = Command::new(env!("CARGO_BIN_EXE_rumdl"));
command.current_dir(dir).args(args);
if github_actions {
command.env("GITHUB_ACTIONS", "true");
} else {
command.env_remove("GITHUB_ACTIONS");
}
let output = command.output().expect("failed to run rumdl");
format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
}
fn lint(config_lang: &str, tool: &str, lang_tag: &str, code: &str) -> String {
let dir = setup(config_lang, "lint", tool, lang_tag, code);
run(dir.path(), &["check", "--no-cache", "t.md"])
}
fn lint_on_a_runner(config_lang: &str, tool: &str, lang_tag: &str, code: &str) -> String {
let dir = setup(config_lang, "lint", tool, lang_tag, code);
run_on_a_runner(dir.path(), &["check", "--no-cache", "t.md"])
}
fn format(config_lang: &str, tool: &str, lang_tag: &str, code: &str) -> String {
let dir = setup(config_lang, "format", tool, lang_tag, code);
run(dir.path(), &["fmt", "--no-cache", "t.md"]);
fs::read_to_string(dir.path().join("t.md")).unwrap()
}
macro_rules! require_tool {
($tool:expr) => {
if !tool_available($tool) {
eprintln!("skipping: `{}` not installed", $tool);
return;
}
};
}
const NOT_FORMATTED: &str = "Code block is not formatted";
const FIRST_CODE_LINE: usize = 4;
fn at(line_offset: usize, column: usize, tool: &str) -> String {
format!("t.md:{}:{column}: [{tool}", FIRST_CODE_LINE + line_offset)
}
fn assert_lint_is_silent(config_lang: &str, tool: &str, lang_tag: &str, clean: &str) {
let out = lint(config_lang, tool, lang_tag, clean);
assert!(
!out.contains(&format!("[{tool}")),
"{tool} should report nothing on a block it accepts:\n{out}"
);
}
fn fenced_block(md: &str) -> String {
let mut lines = md.lines().skip_while(|l| !l.trim_start().starts_with("```"));
lines.next().expect("document has a fenced block");
lines.take_while(|l| l.trim() != "```").collect::<Vec<_>>().join("\n")
}
fn assert_lint_matches_fmt(config_lang: &str, tool: &str, lang_tag: &str, unformatted: &str) {
let out = lint(config_lang, tool, lang_tag, unformatted);
assert!(
out.contains(NOT_FORMATTED),
"{tool} should report the unformatted block:\n{out}"
);
let formatted = fenced_block(&format(config_lang, tool, lang_tag, unformatted));
assert_ne!(
formatted.trim_end(),
unformatted.trim_end(),
"{tool} left the sample unchanged, so the check above proves nothing"
);
let out = lint(config_lang, tool, lang_tag, formatted.trim_end());
assert!(
!out.contains(NOT_FORMATTED),
"{tool} should accept a block it formatted itself:\n{out}"
);
}
macro_rules! lint_by_format_test {
($name:ident, $binary:expr, $tool:expr, $lang:expr, $tag:expr, $unformatted:expr) => {
#[test]
fn $name() {
require_tool!($binary);
assert_lint_matches_fmt($lang, $tool, $tag, $unformatted);
}
};
}
#[test]
fn ruff_check_lints_python() {
require_tool!("ruff");
let out = lint("python", "ruff:check", "python", "import sys\nx = 1\n");
assert!(out.contains("F401"), "ruff:check should flag the unused import:\n{out}");
assert!(
out.contains(&at(0, 8, "ruff:check")),
"ruff:check should report the import at its own column:\n{out}"
);
assert_lint_is_silent("python", "ruff:check", "python", "x = 1\n");
}
#[test]
fn shellcheck_lints_shell() {
require_tool!("shellcheck");
let out = lint("shell", "shellcheck", "shell", "echo $foo\n");
assert!(
out.contains("Double quote to prevent globbing"),
"shellcheck should flag the unquoted variable (SC2086):\n{out}"
);
assert!(
!out.contains("target shell"),
"shellcheck should not emit the shell-unknown tip with --shell=bash:\n{out}"
);
assert_lint_is_silent("shell", "shellcheck", "shell", "foo=bar\necho \"$foo\"\n");
}
#[test]
fn shuck_lints_shell() {
require_tool!("shuck");
let out = lint("shell", "shuck", "shell", "name=\"world\"\necho \"hello $nombre\"\n");
assert!(
out.contains("referenced before assignment") || out.contains("C006"),
"shuck should flag the reference to the undefined variable:\n{out}"
);
assert!(
out.contains(&at(1, 13, "shuck")),
"shuck should report the reference on the block's second line:\n{out}"
);
assert_lint_is_silent("shell", "shuck", "shell", "name=\"world\"\necho \"hello $name\"\n");
}
#[test]
fn jq_lints_invalid_json() {
require_tool!("jq");
let out = lint("json", "jq", "json", "{\"a\": 1,}");
assert!(
out.contains("parse error"),
"jq should report a JSON parse error:\n{out}"
);
assert!(
out.contains(&at(0, 9, "jq")),
"jq should report the parse error at the position its message names:\n{out}"
);
assert_lint_is_silent("json", "jq", "json", "{\"a\": 1}");
}
#[test]
fn ruff_format_formats_python() {
require_tool!("ruff");
let out = format("python", "ruff:format", "python", "x=1");
assert!(out.contains("x = 1"), "ruff:format should reformat the block:\n{out}");
}
#[test]
fn prettier_formats_javascript() {
require_tool!("prettier");
let out = format("javascript", "prettier", "javascript", "const x=1");
assert!(
out.contains("const x = 1;"),
"prettier should reformat the block:\n{out}"
);
}
#[test]
fn rustfmt_formats_rust() {
require_tool!("rustfmt");
let out = format("rust", "rustfmt", "rust", "fn main(){let x=1;}");
assert!(out.contains("fn main()"), "rustfmt should reformat the block:\n{out}");
}
#[test]
fn gofmt_formats_go() {
require_tool!("gofmt");
let out = format("go", "gofmt", "go", "package main\nfunc main(){}");
assert!(out.contains("func main()"), "gofmt should reformat the block:\n{out}");
}
#[test]
fn jq_formats_json() {
require_tool!("jq");
let out = format("json", "jq", "json", "{\"a\":1,\"b\":2}");
assert!(
out.contains("\"a\": 1") && out.contains('\n'),
"jq should pretty-print the JSON block:\n{out}"
);
}
#[test]
fn deno_fmt_formats_typescript() {
require_tool!("deno");
let out = format("typescript", "deno-fmt:ts", "typescript", "const x=1");
assert!(
out.contains("const x = 1;"),
"deno-fmt:ts should reformat the block:\n{out}"
);
}
#[test]
fn black_formats_python() {
require_tool!("black");
let out = format("python", "black", "python", "x=1");
assert!(out.contains("x = 1"), "black should reformat the block:\n{out}");
}
#[test]
fn shfmt_formats_shell() {
require_tool!("shfmt");
let out = format("shell", "shfmt", "shell", "if true;then echo hi;fi");
assert!(out.contains("; then"), "shfmt should reformat the block:\n{out}");
}
#[test]
fn shuck_formats_shell() {
require_tool!("shuck");
let out = format("shell", "shuck:format", "shell", "if [ \"$x\" = 1 ];then echo hi;fi");
assert!(out.contains("; then"), "shuck:format should reformat the block:\n{out}");
}
#[test]
fn goimports_formats_go() {
require_tool!("goimports");
let out = format("go", "goimports", "go", "package main\nfunc main(){}");
assert!(
out.contains("func main()"),
"goimports should reformat the block:\n{out}"
);
}
#[test]
fn clang_format_formats_cpp() {
require_tool!("clang-format");
let out = format("cpp", "clang-format", "cpp", "int main(){return 0;}");
assert!(
out.contains("int main()"),
"clang-format should reformat the block:\n{out}"
);
}
#[test]
fn yamlfmt_formats_yaml() {
require_tool!("yamlfmt");
let out = format("yaml", "yamlfmt", "yaml", "a: 1");
assert!(out.contains("a: 1"), "yamlfmt should reformat the block:\n{out}");
}
#[test]
fn taplo_formats_toml() {
require_tool!("taplo");
let out = format("toml", "taplo", "toml", "a=1");
assert!(out.contains("a = 1"), "taplo should reformat the block:\n{out}");
}
#[test]
fn terraform_formats_terraform() {
require_tool!("terraform");
let out = format("terraform", "terraform", "terraform", "a=1");
assert!(out.contains("a = 1"), "terraform fmt should reformat the block:\n{out}");
}
#[test]
fn terraform_fmt_alias_still_formats() {
require_tool!("terraform");
let out = format("terraform", "terraform-fmt", "terraform", "a=1");
assert!(
out.contains("a = 1"),
"the terraform-fmt alias should still reformat the block:\n{out}"
);
}
#[test]
fn stylua_formats_lua() {
require_tool!("stylua");
let out = format("lua", "stylua", "lua", "x=1");
assert!(out.contains("x = 1"), "stylua should reformat the block:\n{out}");
}
#[test]
fn oxfmt_formats_javascript() {
require_tool!("oxfmt");
let out = format("javascript", "oxfmt", "javascript", "const x=1");
assert!(out.contains("const x = 1;"), "oxfmt should reformat the block:\n{out}");
}
#[test]
fn tombi_formats_toml() {
require_tool!("tombi");
let out = format("toml", "tombi:format", "toml", "a=1");
assert!(out.contains("a = 1"), "tombi:format should reformat the block:\n{out}");
}
#[test]
fn beautysh_formats_shell() {
require_tool!("beautysh");
let out = format("shell", "beautysh", "shell", "if true\nthen\necho hi\nfi");
assert!(out.contains(" echo hi"), "beautysh should indent the block:\n{out}");
}
#[test]
fn nixfmt_formats_nix() {
require_tool!("nixfmt");
let out = format("nix", "nixfmt", "nix", "{ a=1; }");
assert!(out.contains("a = 1"), "nixfmt should reformat the block:\n{out}");
}
#[test]
fn ormolu_formats_haskell() {
require_tool!("ormolu");
let out = format("haskell", "ormolu", "haskell", "main=putStrLn \"hi\"");
assert!(
out.contains("main = putStrLn"),
"ormolu should reformat the block:\n{out}"
);
}
#[test]
fn swift_format_formats_swift() {
require_tool!("swift-format");
let out = format("swift", "swift-format", "swift", "let x = 1");
assert!(
out.contains("let x = 1"),
"swift-format should reformat the block:\n{out}"
);
}
#[test]
fn ktfmt_formats_kotlin() {
require_tool!("ktfmt");
let out = format("kotlin", "ktfmt", "kotlin", "fun main(){}");
assert!(out.contains("fun main() {}"), "ktfmt should reformat the block:\n{out}");
}
#[test]
fn elm_format_formats_elm() {
require_tool!("elm-format");
let out = format("elm", "elm-format", "elm", "module Main exposing (main)\nmain= 1");
assert!(
out.contains("main =\n 1"),
"elm-format should reformat the block:\n{out}"
);
}
#[test]
fn sqlfluff_lints_sql_with_dialect() {
require_tool!("sqlfluff");
let out = lint("sql", "sqlfluff:lint", "sql", "SELECT 1 FROM t");
assert!(
!out.contains("No dialect") && !out.contains("User Error"),
"sqlfluff should lint with a dialect, not error:\n{out}"
);
for column in [7, 11, 17] {
assert!(
out.contains(&at(0, column, "sqlfluff:lint")),
"sqlfluff should report LT01 at column {column} of the block:\n{out}"
);
}
assert_lint_is_silent("sql", "sqlfluff:lint", "sql", "SELECT 1 FROM t");
}
#[test]
fn djlint_lints_html() {
require_tool!("djlint");
let out = lint("html", "djlint", "html", "<div>\n<p>hi</div>");
assert!(out.contains("orphan"), "djlint should flag the orphan tag:\n{out}");
assert!(
out.contains(&at(1, 0, "djlint")),
"djlint should report the orphan on the block's second line:\n{out}"
);
assert_lint_is_silent("html", "djlint", "html", "<div>\n <p>hi</p>\n</div>");
}
#[test]
fn djlint_lints_html_on_a_runner() {
require_tool!("djlint");
let out = lint_on_a_runner("html", "djlint", "html", "<div>\n<p>hi</div>");
assert!(out.contains("orphan"), "djlint should flag the orphan tag:\n{out}");
assert!(
out.contains(&at(1, 1, "djlint")),
"djlint should report the orphan on the block's second line:\n{out}"
);
let clean = lint_on_a_runner("html", "djlint", "html", "<div>\n <p>hi</p>\n</div>");
assert!(
!clean.contains("[djlint"),
"djlint should report nothing on a block it accepts:\n{clean}"
);
}
#[test]
fn djlint_reformats_html() {
require_tool!("djlint");
let out = format("html", "djlint", "html", "<div><p>hi</p></div>");
assert!(
out.contains("<div>\n <p>hi</p>\n</div>"),
"djlint:reformat should indent the block:\n{out}"
);
}
#[test]
fn tombi_lints_toml() {
require_tool!("tombi");
let out = lint("toml", "tombi", "toml", "a = ");
assert!(
out.contains(&at(0, 5, "tombi")),
"tombi should report the incomplete key/value pair where the value belongs:\n{out}"
);
assert_lint_is_silent("toml", "tombi", "toml", "a = 1");
}
lint_by_format_test!(
black_lints_python_by_formatting,
"black",
"black",
"python",
"python",
"x=1"
);
lint_by_format_test!(
ruff_format_lints_python_by_formatting,
"ruff",
"ruff:format",
"python",
"python",
"x=1"
);
lint_by_format_test!(
prettier_lints_javascript_by_formatting,
"prettier",
"prettier",
"javascript",
"javascript",
"const x=1"
);
lint_by_format_test!(
rustfmt_lints_rust_by_formatting,
"rustfmt",
"rustfmt",
"rust",
"rust",
"fn main(){let x=1;}"
);
lint_by_format_test!(
gofmt_lints_go_by_formatting,
"gofmt",
"gofmt",
"go",
"go",
"package main\nfunc main(){}"
);
lint_by_format_test!(
goimports_lints_go_by_formatting,
"goimports",
"goimports",
"go",
"go",
"package main\nfunc main(){}"
);
lint_by_format_test!(
clang_format_lints_cpp_by_formatting,
"clang-format",
"clang-format",
"cpp",
"cpp",
"int main(){return 0;}"
);
lint_by_format_test!(
yamlfmt_lints_yaml_by_formatting,
"yamlfmt",
"yamlfmt",
"yaml",
"yaml",
"a: 1"
);
lint_by_format_test!(taplo_lints_toml_by_formatting, "taplo", "taplo", "toml", "toml", "a=1");
lint_by_format_test!(
terraform_lints_terraform_by_formatting,
"terraform",
"terraform",
"terraform",
"terraform",
"a=1"
);
lint_by_format_test!(
nixfmt_lints_nix_by_formatting,
"nixfmt",
"nixfmt",
"nix",
"nix",
"{ a=1; }"
);
lint_by_format_test!(stylua_lints_lua_by_formatting, "stylua", "stylua", "lua", "lua", "x=1");
lint_by_format_test!(
ormolu_lints_haskell_by_formatting,
"ormolu",
"ormolu",
"haskell",
"haskell",
"main=putStrLn \"hi\""
);
lint_by_format_test!(
elm_format_lints_elm_by_formatting,
"elm-format",
"elm-format",
"elm",
"elm",
"module Main exposing (main)\nmain= 1"
);
lint_by_format_test!(
swift_format_lints_swift_by_formatting,
"swift-format",
"swift-format",
"swift",
"swift",
"let x = 1"
);
lint_by_format_test!(
ktfmt_lints_kotlin_by_formatting,
"ktfmt",
"ktfmt",
"kotlin",
"kotlin",
"fun main(){}"
);
lint_by_format_test!(
beautysh_lints_shell_by_formatting,
"beautysh",
"beautysh",
"shell",
"shell",
"if true\nthen\necho hi\nfi"
);
lint_by_format_test!(
shfmt_lints_shell_by_formatting,
"shfmt",
"shfmt",
"shell",
"shell",
"if true;then echo hi;fi"
);
lint_by_format_test!(
shuck_format_lints_shell_by_formatting,
"shuck",
"shuck:format",
"shell",
"shell",
"if [ \"$x\" = 1 ];then echo hi;fi"
);
lint_by_format_test!(
deno_fmt_lints_typescript_by_formatting,
"deno",
"deno-fmt:ts",
"typescript",
"typescript",
"const x=1"
);
lint_by_format_test!(
oxfmt_lints_javascript_by_formatting,
"oxfmt",
"oxfmt",
"javascript",
"javascript",
"const x=1"
);
lint_by_format_test!(
tombi_format_lints_toml_by_formatting,
"tombi",
"tombi:format",
"toml",
"toml",
"a=1"
);
#[test]
fn builtin_linter_in_format_slot_leaves_the_block_alone() {
require_tool!("ruff");
let declined = format("python", "ruff:check", "python", "x=1");
assert_eq!(
fenced_block(&declined),
"x=1",
"a linter in a format slot must not touch the block:\n{declined}"
);
let formatted = format("python", "ruff:format", "python", "x=1");
assert_eq!(
fenced_block(&formatted),
"x = 1",
"ruff did not format the sample, so the assertion above proves nothing:\n{formatted}"
);
}
const VERIFIED_LINT: &[&str] = &[
"ruff:check",
"ruff:format",
"black",
"prettier",
"shellcheck",
"shfmt",
"shuck",
"shuck:format",
"rustfmt",
"gofmt",
"goimports",
"clang-format",
"sqlfluff:lint",
"jq",
"yamlfmt",
"taplo",
"terraform:format",
"nixfmt",
"stylua",
"ormolu",
"elm-format",
"swift-format",
"ktfmt",
"djlint",
"beautysh",
"tombi",
"tombi:format",
"oxfmt",
"deno-fmt:ts",
];
const VERIFIED_FORMAT: &[&str] = &[
"ruff:format",
"black",
"prettier",
"shfmt",
"shuck:format",
"rustfmt",
"gofmt",
"goimports",
"clang-format",
"jq",
"yamlfmt",
"taplo",
"terraform:format",
"nixfmt",
"stylua",
"ormolu",
"elm-format",
"swift-format",
"ktfmt",
"djlint",
"beautysh",
"tombi:format",
"oxfmt",
"deno-fmt:ts",
];
const EXEMPT: &[(&str, &str)] = &[
(
"prettier:json",
"prettier variant (different --stdin-filepath extension)",
),
("prettier:yaml", "prettier variant"),
("prettier:html", "prettier variant"),
("prettier:css", "prettier variant"),
("prettier:markdown", "prettier variant"),
("sqlfluff:fix", "sqlfluff variant (sqlfluff:lint verified)"),
("djlint:lint", "djlint variant"),
(
"djlint:reformat",
"djlint variant (bare `djlint` resolves to it in a format slot)",
),
("tombi:lint", "tombi variant (bare `tombi` verified)"),
(
"terraform-fmt",
"legacy alias of terraform:format, same definition (alias resolution guarded by \
terraform_fmt_alias_still_formats)",
),
("oxfmt:js", "oxfmt variant"),
("oxfmt:ts", "oxfmt variant"),
("oxfmt:jsx", "oxfmt variant"),
("oxfmt:tsx", "oxfmt variant"),
("oxfmt:json", "oxfmt variant"),
("oxfmt:css", "oxfmt variant"),
("deno-fmt", "deno-fmt variant (deno-fmt:ts verified)"),
("deno-fmt:js", "deno-fmt variant"),
("deno-fmt:json", "deno-fmt variant"),
("deno-fmt:jsonc", "deno-fmt variant"),
("deno-fmt:md", "deno-fmt variant"),
];
#[test]
fn every_builtin_tool_is_verified_or_exempt() {
use rumdl_lib::code_block_tools::builtin_tool_formats;
use std::collections::BTreeSet;
let registry: BTreeSet<&str> = rumdl_lib::code_block_tools::builtin_tool_ids().into_iter().collect();
let verified_lint: BTreeSet<&str> = VERIFIED_LINT.iter().copied().collect();
let verified_format: BTreeSet<&str> = VERIFIED_FORMAT.iter().copied().collect();
let exempt: BTreeSet<&str> = EXEMPT.iter().map(|(id, _)| *id).collect();
let verified: BTreeSet<&str> = verified_lint.union(&verified_format).copied().collect();
let both: Vec<&&str> = verified.intersection(&exempt).collect();
assert!(both.is_empty(), "ids listed as both verified and exempt: {both:?}");
let listed: BTreeSet<&str> = verified.union(&exempt).copied().collect();
let stale: Vec<&&str> = listed.difference(®istry).collect();
assert!(
stale.is_empty(),
"VERIFIED_LINT/VERIFIED_FORMAT/EXEMPT reference tools no longer in the registry (remove them): {stale:?}"
);
let mut missing_lint = Vec::new();
let mut missing_format = Vec::new();
let mut formats_but_lint_only = Vec::new();
for id in registry.iter().filter(|id| !exempt.contains(*id)) {
if !verified_lint.contains(id) {
missing_lint.push(*id);
}
let formats = builtin_tool_formats(id).expect("registry id has docs metadata");
if formats && !verified_format.contains(id) {
missing_format.push(*id);
}
if !formats && verified_format.contains(id) {
formats_but_lint_only.push(*id);
}
}
assert!(
missing_lint.is_empty(),
"built-in tools with no `lint`-slot execution test (add one and list it in \
VERIFIED_LINT, or add an EXEMPT entry): {missing_lint:?}"
);
assert!(
missing_format.is_empty(),
"built-in tools that format but have no `format`-slot execution test (add one and \
list it in VERIFIED_FORMAT, or add an EXEMPT entry): {missing_format:?}"
);
assert!(
formats_but_lint_only.is_empty(),
"VERIFIED_FORMAT lists tools that have no format invocation: {formats_but_lint_only:?}"
);
}