use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use bynk_emit::project::{self, CompileOptions, ProjectPathsError, try_read_project_paths_with};
use bynk_fmt::{FormatOptions, IndentStyle, format_source};
pub mod coverage;
pub mod discovery;
pub mod output;
pub mod probe;
pub mod schema_lock;
pub mod test_json;
pub mod test_runner;
pub use output::{write_document, write_output};
pub fn project_options(input: &Path) -> Result<CompileOptions, discovery::DiscoveryError> {
if input.join("bynk.toml").exists() || input.join("src").is_dir() {
let paths = try_read_project_paths_with(input, &manifest_overlay(input))
.unwrap_or_else(|_| project::ProjectPaths::conventional(input));
options_for_split(input, paths)
} else {
let sources = discovery::read_bynk_tree_single(input)?;
Ok(CompileOptions::single(input.to_path_buf()).sources(sources))
}
}
pub fn try_project_options(input: &Path) -> Result<CompileOptions, ProjectOptionsError> {
if input.join("bynk.toml").exists() || input.join("src").is_dir() {
let paths = try_read_project_paths_with(input, &manifest_overlay(input))?;
Ok(options_for_split(input, paths)?)
} else {
let sources = discovery::read_bynk_tree_single(input)?;
Ok(CompileOptions::single(input.to_path_buf()).sources(sources))
}
}
fn manifest_overlay(input: &Path) -> HashMap<PathBuf, String> {
let toml_path = input.join("bynk.toml");
match fs::read_to_string(&toml_path) {
Ok(text) => HashMap::from([(toml_path, text)]),
Err(_) => HashMap::new(),
}
}
fn options_for_split(
input: &Path,
paths: project::ProjectPaths,
) -> Result<CompileOptions, discovery::DiscoveryError> {
let roots = project::Roots::Split {
project_root: input.to_path_buf(),
paths: paths.clone(),
};
let sources = discovery::sources_for_roots(&roots)?;
Ok(CompileOptions::split(input.to_path_buf(), paths).sources(sources))
}
#[derive(Debug)]
pub enum ProjectOptionsError {
Paths(ProjectPathsError),
Discovery(discovery::DiscoveryError),
}
impl std::fmt::Display for ProjectOptionsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Paths(e) => write!(f, "{e}"),
Self::Discovery(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for ProjectOptionsError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Paths(_) => None,
Self::Discovery(e) => Some(e),
}
}
}
impl From<ProjectPathsError> for ProjectOptionsError {
fn from(e: ProjectPathsError) -> Self {
Self::Paths(e)
}
}
impl From<discovery::DiscoveryError> for ProjectOptionsError {
fn from(e: discovery::DiscoveryError) -> Self {
Self::Discovery(e)
}
}
pub fn print_project_failure(failure: &project::ProjectFailure) {
for ae in &failure.errors {
match attributed_snapshot(ae, &failure.snapshots) {
Some((label, text)) => {
bynk_render::print_errors(std::slice::from_ref(&ae.error), text, &label);
}
None => {
eprintln!("[{}] {}", ae.error.category, ae.error.message);
for note in &ae.error.notes {
eprintln!(" note: {note}");
}
for (_, label) in &ae.error.labels {
eprintln!(" label: {label}");
}
}
}
}
}
pub fn print_project_warnings(
warnings: &[project::AttributedError],
snapshots: &[(PathBuf, String)],
) {
for w in warnings {
match attributed_snapshot(w, snapshots) {
Some((label, text)) => {
bynk_render::print_errors(std::slice::from_ref(&w.error), text, &label)
}
None => {
let where_ = w
.source_path
.as_deref()
.map(|p| format!("{}: ", p.to_string_lossy().replace('\\', "/")))
.unwrap_or_default();
eprintln!("{where_}warning[{}]: {}", w.error.category, w.error.message);
for note in &w.error.notes {
eprintln!(" note: {note}");
}
for (_, label) in &w.error.labels {
eprintln!(" label: {label}");
}
}
}
}
}
pub fn print_project_warnings_short(
warnings: &[project::AttributedError],
snapshots: &[(PathBuf, String)],
) {
for w in warnings {
match attributed_snapshot(w, snapshots) {
Some((label, text)) => eprintln!("{}", bynk_render::short_line(&label, text, &w.error)),
None => eprintln!(
"{}[{}]: {}",
bynk_render::severity_word(&w.error),
w.error.category,
w.error.message
),
}
}
}
fn attributed_snapshot<'a>(
ae: &project::AttributedError,
snapshots: &'a [(PathBuf, String)],
) -> Option<(String, &'a str)> {
let path = ae.source_path.as_deref()?;
let text = snapshots
.iter()
.find(|(p, _)| p.as_path() == path)
.map(|(_, t)| t.as_str())?;
Some((path.to_string_lossy().replace('\\', "/"), text))
}
pub fn print_project_failure_short(failure: &project::ProjectFailure) {
for line in project_failure_short_lines(failure) {
eprintln!("{line}");
}
}
pub fn project_failure_short_lines(failure: &project::ProjectFailure) -> Vec<String> {
failure
.errors
.iter()
.map(|ae| match attributed_snapshot(ae, &failure.snapshots) {
Some((label, text)) => bynk_render::short_line(&label, text, &ae.error),
None => format!(
"{}[{}]: {}",
bynk_render::severity_word(&ae.error),
ae.error.category,
ae.error.message
),
})
.collect()
}
pub fn print_project_check(check: &project::ProjectCheck) {
for ae in &check.errors {
match attributed_snapshot(ae, &check.snapshots) {
Some((label, text)) => {
bynk_render::print_errors(std::slice::from_ref(&ae.error), text, &label);
}
None => {
eprintln!(
"{}[{}]: {}",
bynk_render::severity_word(&ae.error),
ae.error.category,
ae.error.message
);
for note in &ae.error.notes {
eprintln!(" note: {note}");
}
for (_, label) in &ae.error.labels {
eprintln!(" label: {label}");
}
}
}
}
}
pub fn project_check_short_lines(check: &project::ProjectCheck) -> Vec<String> {
check
.errors
.iter()
.map(|ae| match attributed_snapshot(ae, &check.snapshots) {
Some((label, text)) => bynk_render::short_line(&label, text, &ae.error),
None => format!(
"{}[{}]: {}",
bynk_render::severity_word(&ae.error),
ae.error.category,
ae.error.message
),
})
.collect()
}
pub fn print_project_check_short(check: &project::ProjectCheck) {
for line in project_check_short_lines(check) {
eprintln!("{line}");
}
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum IndentKind {
Tab,
Spaces,
}
#[derive(clap::Args, Debug)]
pub struct FmtArgs {
pub inputs: Vec<PathBuf>,
#[arg(long)]
pub check: bool,
#[arg(long, value_enum)]
pub indent: Option<IndentKind>,
#[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(0..=64))]
pub indent_width: Option<u8>,
#[arg(long, value_name = "COLUMNS", value_parser = clap::value_parser!(u32).range(1..))]
pub max_line_width: Option<u32>,
#[arg(long, overrides_with = "no_trailing_comma")]
pub trailing_comma: bool,
#[arg(long, overrides_with = "trailing_comma")]
pub no_trailing_comma: bool,
#[arg(long)]
pub no_config: bool,
}
impl FmtArgs {
pub fn apply_to(&self, base: FormatOptions) -> Result<FormatOptions, String> {
let base_width = match base.indent {
IndentStyle::Spaces(n) => Some(n),
IndentStyle::Tab => None,
};
let kind = self.indent.unwrap_or(match base.indent {
IndentStyle::Tab => IndentKind::Tab,
IndentStyle::Spaces(_) => IndentKind::Spaces,
});
let indent = match (kind, self.indent_width) {
(IndentKind::Tab, None) => IndentStyle::Tab,
(IndentKind::Tab, Some(_)) => {
return Err(
"`--indent-width` applies only to spaces indentation, and this run resolves \
to tabs (pass `--indent spaces`, or set `[fmt] indent` in bynk.toml)"
.to_string(),
);
}
(IndentKind::Spaces, width) => IndentStyle::Spaces(width.or(base_width).unwrap_or(2)),
};
Ok(FormatOptions {
indent,
max_line_width: self.max_line_width.unwrap_or(base.max_line_width),
trailing_comma: if self.no_trailing_comma {
false
} else if self.trailing_comma {
true
} else {
base.trailing_comma
},
})
}
}
enum FmtOptionsError {
Manifest(PathBuf, bynk_fmt::ConfigError),
Args(String),
}
impl std::fmt::Display for FmtOptionsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Manifest(path, e) => write!(f, "{}: {e}", path.display()),
Self::Args(e) => write!(f, "{e}"),
}
}
}
struct ManifestCache {
disabled: bool,
by_dir: std::collections::HashMap<PathBuf, FormatOptions>,
}
impl ManifestCache {
fn new(disabled: bool) -> Self {
Self {
disabled,
by_dir: std::collections::HashMap::new(),
}
}
fn options_for(&mut self, input: &Path) -> Result<FormatOptions, FmtOptionsError> {
if self.disabled {
return Ok(FormatOptions::default());
}
let start: PathBuf = if input.as_os_str() == "-" {
PathBuf::from(".")
} else {
match input.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
}
};
let start = std::env::current_dir()
.map(|cwd| cwd.join(&start))
.unwrap_or(start);
if let Some(hit) = self.by_dir.get(&start) {
return Ok(*hit);
}
let opts = match bynk_fmt::find_manifest(&start) {
None => FormatOptions::default(),
Some(manifest) => {
let text = std::fs::read_to_string(&manifest).map_err(|e| {
FmtOptionsError::Manifest(
manifest.clone(),
bynk_fmt::ConfigError::Read(e.to_string()),
)
})?;
bynk_fmt::FmtConfig::from_manifest_str(&text)
.map_err(|e| FmtOptionsError::Manifest(manifest, e))?
.apply(FormatOptions::default())
}
};
self.by_dir.insert(start, opts);
Ok(opts)
}
}
pub fn run_fmt(prog: &str, args: &FmtArgs) -> ExitCode {
let (inputs, check) = (&args.inputs, args.check);
if inputs.is_empty() {
eprintln!("{prog} fmt: no input files (pass file paths or `-` for stdin)");
return ExitCode::FAILURE;
}
let mut manifests = ManifestCache::new(args.no_config);
let mut resolved: Vec<FormatOptions> = Vec::with_capacity(inputs.len());
for input in inputs {
match manifests
.options_for(input)
.and_then(|base| args.apply_to(base).map_err(FmtOptionsError::Args))
{
Ok(opts) => resolved.push(opts),
Err(e) => {
eprintln!("{prog} fmt: {e}");
return ExitCode::FAILURE;
}
}
}
let mut had_diff = false;
let mut had_error = false;
for (input, opts) in inputs.iter().zip(resolved) {
if input.as_os_str() == "-" {
use std::io::Read;
let mut source = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut source) {
eprintln!("{prog} fmt: read from stdin: {e}");
return ExitCode::FAILURE;
}
match format_source(&source, &opts) {
Ok(formatted) => {
if check {
if formatted != source {
eprintln!("{prog} fmt: <stdin> is not canonically formatted");
had_diff = true;
}
} else {
print!("{formatted}");
}
}
Err(e) => {
bynk_render::print_errors(&e.errors, &source, "<stdin>");
return ExitCode::FAILURE;
}
}
continue;
}
let source = match std::fs::read_to_string(input) {
Ok(s) => s,
Err(e) => {
eprintln!("{prog} fmt: read `{}`: {e}", input.display());
had_error = true;
continue;
}
};
let filename = input.display().to_string();
match format_source(&source, &opts) {
Ok(formatted) => {
if check {
if formatted != source {
eprintln!(
"{prog} fmt: {} is not canonically formatted",
input.display()
);
had_diff = true;
}
} else if formatted != source
&& let Err(e) = atomic_write(input, &formatted)
{
eprintln!("{prog} fmt: write `{}`: {e}", input.display());
had_error = true;
}
}
Err(e) => {
bynk_render::print_errors(&e.errors, &source, &filename);
had_error = true;
}
}
}
if had_error || (check && had_diff) {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
use std::io::Write as _;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let (mut file, tmp) = loop {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp_name = format!(".{file_name}.bynk-fmt.{}.{n}.tmp", std::process::id());
let tmp = match dir {
Some(d) => d.join(tmp_name),
None => PathBuf::from(tmp_name),
};
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)
{
Ok(f) => break (f, tmp),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
};
let write_result = (|| {
if let Ok(meta) = std::fs::metadata(path) {
let _ = file.set_permissions(meta.permissions());
}
file.write_all(contents.as_bytes())?;
file.sync_all()
})();
if let Err(e) = write_result {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
}
pub fn run_check(prog: &str, input: &Path, short: bool) -> ExitCode {
if input.is_dir() {
let options = match try_project_options(input) {
Ok(o) => o,
Err(e) => {
eprintln!("{prog}: {e}");
return ExitCode::FAILURE;
}
};
let check = project::check_project(&options);
let has_errors = check.has_errors();
if short {
print_project_check_short(&check);
} else {
print_project_check(&check);
}
if has_errors {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
} else {
let source = match std::fs::read_to_string(input) {
Ok(s) => s,
Err(e) => {
eprintln!("{prog}: could not read `{}`: {e}", input.display());
return ExitCode::FAILURE;
}
};
let filename = input.display().to_string();
match bynk_emit::compile_with_warnings(&source, &filename) {
Ok(compiled) => {
if !compiled.warnings.is_empty() {
if short {
bynk_render::print_errors_short(&compiled.warnings, &source, &filename);
} else {
bynk_render::print_errors(&compiled.warnings, &source, &filename);
}
}
ExitCode::SUCCESS
}
Err(errors) => {
if short {
bynk_render::print_errors_short(&errors, &source, &filename);
} else {
bynk_render::print_errors(&errors, &source, &filename);
}
ExitCode::FAILURE
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(clap::Parser, Debug)]
struct Harness {
#[command(flatten)]
args: FmtArgs,
}
fn parse(argv: &[&str]) -> FmtArgs {
let mut full = vec!["fmt"];
full.extend_from_slice(argv);
Harness::parse_from(full).args
}
fn resolve(argv: &[&str]) -> Result<FormatOptions, String> {
parse(argv).apply_to(FormatOptions::default())
}
fn manifest(toml: &str) -> FormatOptions {
bynk_fmt::FmtConfig::from_manifest_str(toml)
.expect("manifest parses")
.apply(FormatOptions::default())
}
#[test]
fn no_flags_is_the_canonical_style() {
let opts = resolve(&["a.bynk"]).expect("valid");
assert_eq!(opts, FormatOptions::default());
}
#[test]
fn spaces_without_a_width_falls_back_to_two() {
let opts = resolve(&["--indent", "spaces", "a.bynk"]).expect("valid");
assert_eq!(opts.indent, IndentStyle::Spaces(2));
}
#[test]
fn spaces_takes_the_given_width() {
let opts =
resolve(&["--indent", "spaces", "--indent-width", "4", "a.bynk"]).expect("valid");
assert_eq!(opts.indent, IndentStyle::Spaces(4));
}
#[test]
fn a_width_with_tabs_is_an_error_not_a_silent_no_op() {
let err = resolve(&["--indent", "tab", "--indent-width", "4", "a.bynk"])
.expect_err("a meaningless width must be reported");
assert!(err.contains("--indent-width"), "{err}");
assert!(err.contains("spaces"), "{err}");
}
#[test]
fn the_trailing_comma_pair_is_last_one_wins() {
assert!(
resolve(&["--no-trailing-comma", "--trailing-comma", "a.bynk"])
.expect("valid")
.trailing_comma
);
assert!(
!resolve(&["--trailing-comma", "--no-trailing-comma", "a.bynk"])
.expect("valid")
.trailing_comma
);
}
#[test]
fn max_line_width_is_taken_verbatim_and_zero_is_refused() {
assert_eq!(
resolve(&["--max-line-width", "60", "a.bynk"])
.expect("valid")
.max_line_width,
60
);
assert!(
Harness::try_parse_from(["fmt", "--max-line-width", "0", "a.bynk"]).is_err(),
"`--max-line-width 0` must not parse"
);
}
#[test]
fn an_unflagged_run_takes_the_manifest_whole() {
let base = manifest(
"[fmt]\nindent = \"spaces\"\nindent_width = 4\nmax_line_width = 120\ntrailing_comma = false\n",
);
let opts = parse(&["a.bynk"]).apply_to(base).expect("valid");
assert_eq!(opts.indent, IndentStyle::Spaces(4));
assert_eq!(opts.max_line_width, 120);
assert!(!opts.trailing_comma);
}
#[test]
fn a_flag_beats_the_manifest_field_it_names_and_no_other() {
let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\nmax_line_width = 120\n");
let opts = parse(&["--max-line-width", "80", "a.bynk"])
.apply_to(base)
.expect("valid");
assert_eq!(opts.max_line_width, 80, "the flag wins where it speaks");
assert_eq!(
opts.indent,
IndentStyle::Spaces(4),
"and stays silent everywhere else"
);
}
#[test]
fn an_absent_flag_does_not_reset_the_manifest_to_the_default() {
let base = manifest("[fmt]\nmax_line_width = 120\n");
assert_eq!(
parse(&["a.bynk"])
.apply_to(base)
.expect("valid")
.max_line_width,
120
);
}
#[test]
fn indent_spaces_alone_keeps_the_manifest_width() {
let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\n");
let opts = parse(&["--indent", "spaces", "a.bynk"])
.apply_to(base)
.expect("valid");
assert_eq!(opts.indent, IndentStyle::Spaces(4), "not reset to 2");
}
#[test]
fn indent_width_alone_applies_to_a_manifest_that_chose_spaces() {
let base = manifest("[fmt]\nindent = \"spaces\"\n");
let opts = parse(&["--indent-width", "8", "a.bynk"])
.apply_to(base)
.expect("valid");
assert_eq!(opts.indent, IndentStyle::Spaces(8));
}
#[test]
fn indent_width_alone_is_refused_when_the_run_resolves_to_tabs() {
let err = parse(&["--indent-width", "8", "a.bynk"])
.apply_to(manifest("[fmt]\nindent = \"tab\"\n"))
.expect_err("refused");
assert!(err.contains("resolves"), "{err}");
}
#[test]
fn an_explicit_tab_flag_overrides_a_manifest_choosing_spaces() {
let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\n");
let opts = parse(&["--indent", "tab", "a.bynk"])
.apply_to(base)
.expect("valid");
assert_eq!(opts.indent, IndentStyle::Tab);
}
#[test]
fn trailing_comma_flag_overrides_a_manifest_that_turned_it_off() {
let base = manifest("[fmt]\ntrailing_comma = false\n");
assert!(
parse(&["--trailing-comma", "a.bynk"])
.apply_to(base)
.expect("valid")
.trailing_comma
);
assert!(
!parse(&["a.bynk"])
.apply_to(base)
.expect("valid")
.trailing_comma
);
}
#[test]
fn no_config_is_parsed_and_defaults_are_used_in_its_presence() {
let args = parse(&["--no-config", "a.bynk"]);
assert!(args.no_config);
assert_eq!(
args.apply_to(FormatOptions::default()).expect("valid"),
FormatOptions::default()
);
}
struct Scratch(PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn scratch_dir(tag: &str) -> Scratch {
let dir = std::env::temp_dir().join(format!(
"bynk_1077_{tag}_{}_{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
Scratch(dir)
}
#[test]
fn manifest_overlay_keys_and_reads_a_real_bynk_toml() {
let dir = scratch_dir("manifest_overlay");
let toml = "[paths]\ninclude = [\"lib\"]\n";
fs::write(dir.0.join("bynk.toml"), toml).unwrap();
let overlay = manifest_overlay(&dir.0);
assert_eq!(
overlay.get(&dir.0.join("bynk.toml")).map(String::as_str),
Some(toml)
);
}
#[test]
fn manifest_overlay_is_empty_with_no_bynk_toml() {
let dir = scratch_dir("manifest_overlay_missing");
assert!(manifest_overlay(&dir.0).is_empty());
}
#[test]
fn try_project_options_surfaces_an_unknown_paths_key() {
let dir = scratch_dir("try_project_options_unknown_key");
fs::write(dir.0.join("bynk.toml"), "[paths]\ninculde = [\"src\"]\n").unwrap();
fs::create_dir_all(dir.0.join("src")).unwrap();
fs::write(dir.0.join("src/thing.bynk"), "context thing\n").unwrap();
let err = match try_project_options(&dir.0) {
Err(e) => e,
Ok(_) => panic!("an unrecognised [paths] key must be reported, not silently ignored"),
};
assert!(
matches!(
&err,
ProjectOptionsError::Paths(ProjectPathsError::UnknownKey(k)) if k == "inculde"
),
"expected Paths(UnknownKey(\"inculde\")), got: {err:?}"
);
}
}