From 45385f2eaed3e3900e6e31f097af3538136a6bee Mon Sep 17 00:00:00 2001 From: Illia Kripaka Date: Tue, 8 Sep 2026 16:18:43 +0300 Subject: [PATCH 1/6] fixtures: format contracts --- fixtures/simf/imports/multidep.simf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixtures/simf/imports/multidep.simf b/fixtures/simf/imports/multidep.simf index 257d8d72..a58c7b31 100644 --- a/fixtures/simf/imports/multidep.simf +++ b/fixtures/simf/imports/multidep.simf @@ -9,7 +9,7 @@ fn get_block_value_hash(prev_hash: u32, tx1: u32, tx2: u32) -> u32 { fn main() { let block_val_hash: u32 = get_block_value_hash(param::PREV_HASH, 10, 20); assert_eq_32(block_val_hash, 27); - + let first_value: u32 = witness::TX1; let second_value: u32 = 22; assert_eq_32(and_hash(first_value, second_value), 6); From b2975664c8ca8a0041d941f20f94cd01820b0f8b Mon Sep 17 00:00:00 2001 From: Illia Kripaka Date: Wed, 9 Sep 2026 12:28:25 +0300 Subject: [PATCH 2/6] CI: update release workflow (include simfmt) --- .github/workflows/release.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3cd7eff9..b4dd520f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,17 +67,23 @@ jobs: cargo build --target $TARGET --profile $RUST_PROFILE --bins mv $OUT_DIR/${{ env.RUST_PROFILE }}/simplex simplex - - name: Download nextest, elementsd and electrs + - name: Download nextest, simfmt, elementsd and electrs env: + SIMFMT_VERSION: ${{ vars.SIFMMT_VERSION }} + SIMFMT_TARGET: ${{ matrix.target }} NEXTEST_VERSION: "0.9.137" ELEMENTSD_VERSION: "23.3.1" ELEMENTSD_TARGET: ${{ matrix.elementsd_target }} ELECTRS_TARGET: ${{ matrix.electrs_target }} NEXTEST_TARGET: ${{ matrix.nextest_target }} run: | + #!/usr/bin/env bash + set -eo pipefail + ELEMENTSD_FILENAME="elements-${ELEMENTSD_VERSION}-${ELEMENTSD_TARGET}.tar.gz" ELECTRS_FILENAME="electrs_${ELECTRS_TARGET}_esplora_027e38d3ebc2f85b28ae76f8f3448438ee4fc7b1_liquid.zip" NEXTEST_FILENAME="cargo-nextest-${NEXTEST_VERSION}-${NEXTEST_TARGET}.tar.gz" + SIMFMT_FILENAME="simfmt-v${SIMFMT_VERSION}-${SIMFMT_TARGET}.tar.gz" curl -Ls "https://github.com/ElementsProject/elements/releases/download/elements-${ELEMENTSD_VERSION}/${ELEMENTSD_FILENAME}" -o ${ELEMENTSD_FILENAME} tar -xzf ${ELEMENTSD_FILENAME} && rm ${ELEMENTSD_FILENAME} @@ -90,6 +96,9 @@ jobs: curl -Ls "https://github.com/nextest-rs/nextest/releases/download/cargo-nextest-${NEXTEST_VERSION}/${NEXTEST_FILENAME}" -o ${NEXTEST_FILENAME} tar -xzf ${NEXTEST_FILENAME} && rm ${NEXTEST_FILENAME} mv cargo-nextest smplx-nextest + + curl -fLsS "https://github.com/BlockstreamResearch/simfmt/releases/download/v${SIMFMT_VERSION}/${SIMFMT_FILENAME}" -o "${SIMFMT_FILENAME}" + tar -xzf "${SIMFMT_FILENAME}" && rm "${SIMFMT_FILENAME}" - name: Sign elementsd binary run: | @@ -106,6 +115,7 @@ jobs: elementsd electrs smplx-nextest + simfmt - name: Record attestation URL env: @@ -116,7 +126,7 @@ jobs: - name: Archive binaries env: ARCHIVE: simplex-${{ github.event.inputs.tag || github.ref_name }}-${{ matrix.archive }} - run: tar czf ${ARCHIVE} simplex elementsd electrs smplx-nextest + run: tar czf ${ARCHIVE} simplex elementsd electrs smplx-nextest simfmt - name: Upload build artifacts uses: actions/upload-artifact@v6 From 75275fda70e00cfd7b3eabdea4d55b369b4e8f90 Mon Sep 17 00:00:00 2001 From: Illia Kripaka Date: Wed, 9 Sep 2026 12:52:06 +0300 Subject: [PATCH 3/6] simplexup: include simfmt binary --- simplexup/simplexup | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/simplexup/simplexup b/simplexup/simplexup index eaeb4d50..7f5c317e 100755 --- a/simplexup/simplexup +++ b/simplexup/simplexup @@ -645,12 +645,19 @@ resolve_dep_bins() { fi # The first version with `smplx-nextest` binary - local simplex_turning_point="0.0.6" + local simplex_turning_point_nextest="0.0.6" - if version_gt "${simplex_turning_point}" "$version"; then - DEP_BINS=(elementsd electrs) - else - DEP_BINS=(elementsd electrs smplx-nextest) + # The first version with `simfmt` binary + local simplex_turning_point_simfmt="0.0.11" + + DEP_BINS=(elementsd electrs) + + if ! version_gt "${simplex_turning_point_nextest}" "$version"; then + DEP_BINS+=(smplx-nextest) + fi + + if ! version_gt "${simplex_turning_point_simfmt}" "$version"; then + DEP_BINS+=(simfmt) fi BINS=("${BINS[@]}" "${DEP_BINS[@]}") From d5c9473984c86067d06d43a89446d9ba1aee3ea0 Mon Sep 17 00:00:00 2001 From: Illia Kripaka Date: Wed, 9 Sep 2026 13:14:29 +0300 Subject: [PATCH 4/6] simplexup: add function to remove stale binaries --- simplexup/simplexup | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/simplexup/simplexup b/simplexup/simplexup index 7f5c317e..de8f1f5f 100755 --- a/simplexup/simplexup +++ b/simplexup/simplexup @@ -16,10 +16,12 @@ DEFAULT_SIMPLEX_REPO="BlockstreamResearch/smplx" SMPLX_BIN_NAME="simplex" DEP_BINS=() -BINS=("${SMPLX_BIN_NAME}") +BINS=() HASH_NAMES=() HASH_VALUES=() +VERSIONED_DEP_BINS=(smplx-nextest simfmt) + main() { need_cmd git need_cmd curl @@ -369,6 +371,7 @@ use() { [ -z "$SIMPLEX_VERSION" ] && err "no version provided" SIMPLEX_VERSION=$(add_v_prefix "${SIMPLEX_VERSION}") + resolve_dep_bins "${SIMPLEX_VERSION#v}" SIMPLEX_VERSION_DIR="$SIMPLEX_VERSIONS_DIR/$SIMPLEX_VERSION" @@ -403,6 +406,8 @@ to allow the newly installed version to take precedence! EOF fi done + + remove_unneeded_binaries exit 0 else err "version $SIMPLEX_VERSION not installed" @@ -660,7 +665,26 @@ resolve_dep_bins() { DEP_BINS+=(simfmt) fi - BINS=("${BINS[@]}" "${DEP_BINS[@]}") + BINS=("${SMPLX_BIN_NAME}" "${DEP_BINS[@]}") +} + +remove_unneeded_binaries() { + local versioned_bin required_bin required + + for versioned_bin in "${VERSIONED_DEP_BINS[@]}"; do + required=false + + for required_bin in "${BINS[@]}"; do + if [ "$versioned_bin" = "$required_bin" ]; then + required=true + break + fi + done + + if ! $required; then + ensure rm -f "$SIMPLEX_BIN_DIR/$versioned_bin" + fi + done } add_v_prefix() { From 9a3118eb7b497b18ee83d9d1edafa571ba220fbd Mon Sep 17 00:00:00 2001 From: Illia Kripaka Date: Wed, 9 Sep 2026 13:14:47 +0300 Subject: [PATCH 5/6] simplexup: bump version from 0.0.6 to 0.0.7 --- simplexup/simplexup | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplexup/simplexup b/simplexup/simplexup index de8f1f5f..9fb15540 100755 --- a/simplexup/simplexup +++ b/simplexup/simplexup @@ -3,7 +3,7 @@ set -eo pipefail # NOTE: if you make modifications to this script, please increment the version number. # WARNING: the SemVer pattern: major.minor.patch must be followed as we use it to determine if the script is up to date. -SIMPLEXUP_INSTALLER_VERSION="0.0.6" +SIMPLEXUP_INSTALLER_VERSION="0.0.7" BASE_DIR=${XDG_CONFIG_HOME:-$HOME} SIMPLEX_DIR=${SIMPLEX_DIR:-"$BASE_DIR/.simplex"} From d8d9e765bb6328703960f1a7c8e421def6acb31e Mon Sep 17 00:00:00 2001 From: Illia Kripaka Date: Thu, 10 Sep 2026 17:19:23 +0300 Subject: [PATCH 6/6] fmt: add formatting functionality into a cli * extract resolving of simf files into separate function in ArtifactsResolver * make manifest discovery in inner folders * add ability to format certain files --- crates/build/src/error.rs | 3 + crates/build/src/resolver.rs | 49 ++- crates/cli/src/cli.rs | 35 +++ crates/cli/src/commands/core.rs | 46 +++ crates/cli/src/commands/error.rs | 30 ++ crates/cli/src/commands/fmt.rs | 509 +++++++++++++++++++++++++++++++ crates/cli/src/commands/mod.rs | 1 + 7 files changed, 661 insertions(+), 12 deletions(-) create mode 100644 crates/cli/src/commands/fmt.rs diff --git a/crates/build/src/error.rs b/crates/build/src/error.rs index 14f9ea3a..1744b4f6 100644 --- a/crates/build/src/error.rs +++ b/crates/build/src/error.rs @@ -61,6 +61,9 @@ pub enum BuildError { #[error("Glob error: {0}")] Glob(#[from] GlobError), + #[error("Failed to resolve globpaths, directory walk error: {0}")] + Walk(#[from] globwalk::WalkError), + #[error("Failed to deserialize config: '{0}'")] ConfigDeserialize(#[from] toml::de::Error), diff --git a/crates/build/src/resolver.rs b/crates/build/src/resolver.rs index d39d42a0..6ce1ac71 100644 --- a/crates/build/src/resolver.rs +++ b/crates/build/src/resolver.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeSet; use std::hash::{DefaultHasher, Hash as _, Hasher as _}; use std::path::{Path, PathBuf}; use std::{env, fs}; @@ -18,21 +19,16 @@ use super::error::BuildError; pub struct ArtifactsResolver {} impl ArtifactsResolver { - pub fn resolve_files_to_build(src_dir: &String, simfs: &[String]) -> Result, BuildError> { + pub fn resolve_files_to_build( + src_dir: impl AsRef, + simfs: &[impl AsRef], + ) -> Result, BuildError> { let cwd = env::current_dir()?; - let base = cwd.join(src_dir); - + let files = Self::resolve_simf_files(&cwd, src_dir, simfs)?; let mut paths = Vec::new(); - let walker = globwalk::GlobWalkerBuilder::from_patterns(base, simfs) - .follow_links(true) - .file_type(FileType::FILE) - .build()? - .filter_map(Result::ok); - - for img in walker { - let path = img.path().to_path_buf().canonicalize()?; - let content = std::fs::read_to_string(&path)?; + for path in files { + let content = fs::read_to_string(&path)?; if Self::contains_main(&content) { paths.push(path); @@ -42,6 +38,35 @@ impl ArtifactsResolver { Ok(paths) } + /// Resolves every source file matched by `simfs` beneath `src_dir` relative + /// to `project_root`. + /// + /// Returned paths are canonicalized, sorted, and deduplicated. Unlike + /// [`Self::resolve_files_to_build`], this does not require a source file to + /// contain a `main` function. + /// + /// # Errors + /// Returns a [`BuildError`] if a glob cannot be constructed, the directory + /// walk fails, or a matching path cannot be canonicalized. + pub fn resolve_simf_files( + project_root: impl AsRef, + src_dir: impl AsRef, + simfs: &[impl AsRef], + ) -> Result, BuildError> { + let base = project_root.as_ref().join(src_dir.as_ref()); + let walker = globwalk::GlobWalkerBuilder::from_patterns(base, simfs) + .follow_links(true) + .file_type(FileType::FILE) + .build()?; + let mut paths = BTreeSet::new(); + + for entry in walker { + paths.insert(entry?.path().canonicalize()?); + } + + Ok(paths) + } + pub fn resolve_local_dir(path: &impl AsRef) -> Result { let mut path_outer = PathBuf::from(path.as_ref()); diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index bd2947e3..0832995b 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -7,6 +7,8 @@ use smplx_build::DependencyConfig; use crate::commands::Command; use crate::commands::build::Build; use crate::commands::clean::Clean; +use crate::commands::error::{CommandError, FmtError}; +use crate::commands::fmt::Format; use crate::commands::init::Init; use crate::commands::install::Install; use crate::commands::regtest::Regtest; @@ -89,6 +91,39 @@ impl Cli { Ok(Clean::run(&loaded_config.build.out_dir, flags)?) } + Command::Fmt { opts } => { + use std::io::Write; + + let exit_status = if Format::is_info_request(opts) { + Format::run_info(opts)? + } else { + let files = if opts.files.is_empty() { + let config_path = Format::manifest_path(opts).map_err(CommandError::from)?; + let project_root = config_path + .parent() + .ok_or_else(|| FmtError::InvalidManifestPath(config_path.clone())) + .map_err(CommandError::from)?; + let loaded_config = Config::load(&config_path)?; + + Format::resolve_files(&loaded_config.build, project_root)? + .into_iter() + .collect::>() + } else { + opts.files + .iter() + .map(|s| { + let p = PathBuf::from(s); + p.canonicalize().unwrap_or(p) + }) + .collect::>() + }; + + Format::run(opts, &files)? + }; + + std::io::stdout().flush()?; + std::process::exit(exit_status); + } } } } diff --git a/crates/cli/src/commands/core.rs b/crates/cli/src/commands/core.rs index 03305c8e..5a11e1ab 100644 --- a/crates/cli/src/commands/core.rs +++ b/crates/cli/src/commands/core.rs @@ -34,6 +34,11 @@ pub enum Command { #[command(flatten)] flags: CleanFlags, }, + /// Formats the configured Simplex source files using simfmt + Fmt { + #[command(flatten)] + opts: FormatOpts, + }, } #[allow(clippy::struct_excessive_bools)] @@ -80,3 +85,44 @@ pub struct CleanFlags { #[arg(long = "all")] pub remove_all: bool, } + +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Args)] +pub struct FormatOpts { + /// Path to the file. + #[arg(value_hint = clap::ValueHint::FilePath, value_name = "PATH", num_args(1..))] + pub files: Vec, + + /// No output printed to stdout + #[arg(short = 'q', long = "quiet")] + pub quiet: bool, + + /// Use verbose output + #[arg(short = 'v', long = "verbose")] + pub verbose: bool, + + /// Print simfmt version and exit + #[arg(long = "version")] + pub version: bool, + + /// Specify path to Simplex.toml + #[arg(long = "manifest-path", value_name = "manifest-path")] + pub manifest_path: Option, + + #[arg( + short = 'f', + long = "message-format", + value_name = "message-format", + help = format!("Specify message-format: {}", crate::commands::fmt::MessageFormat::OPTIONS) + )] + pub message_format: Option, + + /// Options passed to simfmt + // `raw = true` makes the `--` separator explicit. + #[arg(id = "simfmt_options", raw = true)] + pub simfmt_options: Vec, + + /// Run simfmt in check mode + #[arg(long = "check")] + pub check: bool, +} diff --git a/crates/cli/src/commands/error.rs b/crates/cli/src/commands/error.rs index b6996024..cb1307dd 100644 --- a/crates/cli/src/commands/error.rs +++ b/crates/cli/src/commands/error.rs @@ -23,6 +23,9 @@ pub enum CommandError { #[error(transparent)] Install(#[from] InstallError), + #[error(transparent)] + Fmt(#[from] FmtError), + #[error("IO error: {0}")] Io(#[from] std::io::Error), @@ -30,6 +33,33 @@ pub enum CommandError { BadVersbosityMode(u8), } +#[derive(thiserror::Error, Debug)] +pub enum FmtError { + #[error("Quiet mode and verbose mode are not compatible")] + ConflictingVerbosity, + + #[error("Failed to find manifest in current and parent directories")] + FailedToFindManifest, + + #[error("The manifest-path must be a path to a Simplex.toml file, got: '{}'", .0.display())] + InvalidManifestPath(PathBuf), + + #[error("Invalid --message-format value: {0}. Allowed values are: short|human")] + InvalidMessageFormat(String), + + #[error("no files matched the configured simf_files patterns under '{}'", .0.display())] + NoFiles(PathBuf), + + #[error("Failed to determine the current directory: {0}")] + CurrentDir(std::io::Error), + + #[error("Failed to determine the simplex executable path: {0}")] + CurrentExecutable(std::io::Error), + + #[error("could not run simfmt at '{}': {source}", binary.display())] + RunSimfmt { binary: PathBuf, source: std::io::Error }, +} + #[derive(thiserror::Error, Debug)] pub enum InitError { #[error("Failed to open file '{1}': {0}")] diff --git a/crates/cli/src/commands/fmt.rs b/crates/cli/src/commands/fmt.rs new file mode 100644 index 00000000..10ff2f9c --- /dev/null +++ b/crates/cli/src/commands/fmt.rs @@ -0,0 +1,509 @@ +use std::collections::BTreeSet; +use std::env; +use std::ffi::{OsStr, OsString}; +use std::ops::Not; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus, Stdio}; +use std::str::FromStr; + +use smplx_build::{ArtifactsResolver, BuildConfig}; + +use crate::commands::FormatOpts; +use crate::commands::error::{CommandError, FmtError}; +use crate::config::CONFIG_FILENAME; + +const SIMFMT_BIN_NAME: &str = "simfmt"; +const SIMFMT_BIN_PATH_VAR_ENV: &str = "SIMFMT"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Verbosity { + Verbose, + Normal, + Quiet, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum MessageFormat { + #[default] + Short, + Human, +} + +impl MessageFormat { + pub(crate) const OPTIONS: &str = "short|human"; +} + +impl FromStr for MessageFormat { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + if "short".eq_ignore_ascii_case(s) { + Ok(MessageFormat::Short) + } else if "human".eq_ignore_ascii_case(s) { + Ok(MessageFormat::Human) + } else { + Err("invalid message format") + } + } +} + +pub struct Format; + +impl Format { + /// Returns whether the request only asks `simfmt` for information and does not + /// need a Simplex project to be loaded first. + #[must_use] + pub fn is_info_request(opts: &FormatOpts) -> bool { + opts.version + || opts.simfmt_options.iter().any(|arg| { + ["--print-config", "-h", "--help", "-V", "--version"].contains(&arg.as_str()) + || arg.starts_with("--help=") + || arg.starts_with("--print-config=") + }) + } + + /// Resolves the selected manifest to an absolute path. + /// + /// # Errors + /// Returns a [`FmtError`] when the current directory cannot be read or the + /// supplied path does not name a `Simplex.toml` file. + pub fn manifest_path(opts: &FormatOpts) -> Result { + let current_dir = env::current_dir().map_err(FmtError::CurrentDir)?; + Self::resolve_manifest_path(opts, ¤t_dir) + } + + fn resolve_manifest_path(opts: &FormatOpts, current_dir: impl AsRef) -> Result { + let current_dir = current_dir.as_ref(); + let manifest_path = { + let path = match opts.manifest_path.as_deref() { + None => current_dir + .ancestors() + .map(|directory| directory.join(CONFIG_FILENAME)) + .find(|candidate| candidate.is_file()), + Some(x) => { + let path: PathBuf = x.into(); + if path.is_file() { + Some(path) + } else { + return Err(FmtError::InvalidManifestPath(path)); + } + } + }; + + if path.is_none() { + return Err(FmtError::FailedToFindManifest); + } + + path.unwrap() + }; + + if manifest_path.file_name() != Some(OsStr::new(CONFIG_FILENAME)) { + return Err(FmtError::InvalidManifestPath(manifest_path)); + } + + if manifest_path.is_absolute() { + Ok(manifest_path) + } else { + Ok(current_dir.join(manifest_path)) + } + } + + /// Runs an informational `simfmt` request such as `--version` or a raw + /// `--help`, without loading a Simplex manifest. + /// + /// # Errors + /// Returns a [`CommandError`] if the options conflict or `simfmt` cannot be + /// executed. + pub fn run_info(opts: &FormatOpts) -> Result { + let verbosity = Self::verbosity(opts)?; + let args = if opts.version { + vec![OsString::from("--version")] + } else { + opts.simfmt_options.iter().map(OsString::from).collect() + }; + + Ok(Self::run_simfmt(&args, verbosity)?) + } + + /// Formats every configured `.simf` source file in one `simfmt` process. + /// + /// # Errors + /// Returns a [`CommandError`] if the options are invalid, source discovery + /// fails, no source files match, or `simfmt` cannot be executed. + pub fn run(opts: &FormatOpts, files: &[PathBuf]) -> Result { + let verbosity = Self::verbosity(opts)?; + let args = Self::build_simfmt_args(opts, files)?; + + Ok(Self::run_simfmt(&args, verbosity)?) + } + + fn verbosity(opts: &FormatOpts) -> Result { + match (opts.verbose, opts.quiet) { + (false, false) => Ok(Verbosity::Normal), + (false, true) => Ok(Verbosity::Quiet), + (true, false) => Ok(Verbosity::Verbose), + (true, true) => Err(FmtError::ConflictingVerbosity), + } + } + + pub(crate) fn resolve_files( + config: &BuildConfig, + project_root: impl AsRef, + ) -> Result, CommandError> { + let src_dir = project_root.as_ref().join(&config.src_dir); + let files = ArtifactsResolver::resolve_simf_files(project_root, &config.src_dir, &config.simf_files)?; + + if files.is_empty() { + return Err(FmtError::NoFiles(src_dir).into()); + } + + Ok(files) + } + + fn build_simfmt_args(opts: &FormatOpts, files: &[PathBuf]) -> Result, FmtError> { + let mut simfmt_args = Vec::with_capacity(opts.simfmt_options.len() + 3); + + if opts.quiet { + simfmt_args.push(OsString::from("--quiet")); + } + if opts.verbose { + simfmt_args.push(OsString::from("--verbose")); + } + if opts.check && !opts.simfmt_options.iter().any(|arg| arg == "--check") { + simfmt_args.push(OsString::from("--check")); + } + simfmt_args.extend(opts.simfmt_options.iter().map(OsString::from)); + + if let Some(message_format) = &opts.message_format { + Self::convert_message_format_to_simfmt_args(message_format, &mut simfmt_args)?; + } + + let mut args = Vec::with_capacity(files.len() + simfmt_args.len()); + + for file in files { + args.push(file.as_os_str().to_owned()); + } + + args.extend(simfmt_args); + Ok(args) + } + + fn convert_message_format_to_simfmt_args( + message_format: &str, + simfmt_args: &mut Vec, + ) -> Result<(), FmtError> { + match MessageFormat::from_str(message_format) + .map_err(|_| FmtError::InvalidMessageFormat(message_format.to_owned()))? + { + MessageFormat::Short => { + let contains_list_files = simfmt_args.iter().any(|arg| arg == "-l" || arg == "--files-with-diff"); + if contains_list_files.not() { + simfmt_args.push(OsString::from("-l")); + } + Ok(()) + } + MessageFormat::Human => Ok(()), + } + } + + fn run_simfmt(args: &[OsString], verbosity: Verbosity) -> Result { + let binary = Self::simfmt_binary()?; + let mut command = Command::new(&binary); + command + .args(args) + .stdin(Stdio::inherit()) + .stderr(Stdio::inherit()) + .stdout(if verbosity == Verbosity::Quiet { + Stdio::null() + } else { + Stdio::inherit() + }); + + if verbosity == Verbosity::Verbose { + println!("{command:?}"); + } + + let status = command + .status() + .map_err(|source| FmtError::RunSimfmt { binary, source })?; + + Ok(Self::normalize_status(status)) + } + + /// Resolves the file path to the `simfmt` binary in the same folder where `simplex` is situated. + /// Given that `simfmt` would be placed in one directory with `simplex`, this is suitable for our usecase. + /// + /// Path to the binary can be overrided with `SIMFMT_BIN_PATH_VAR_ENV`. + /// + /// # Warning + /// Can fail to search for binary when env isn't set and `simfmt` binary lies in the other directory. + /// Even when `simfmt` is visible through PATH + fn simfmt_binary() -> Result { + if let Some(simfmt) = env::var_os(SIMFMT_BIN_PATH_VAR_ENV) { + return Ok(PathBuf::from(simfmt)); + } + + Ok(env::current_exe() + .map_err(FmtError::CurrentExecutable)? + .with_file_name(SIMFMT_BIN_NAME)) + } + + fn normalize_status(status: ExitStatus) -> i32 { + const SUCCESS: i32 = 0; + const FAILURE: i32 = 1; + + if status.success() { SUCCESS } else { FAILURE } + } +} + +#[cfg(test)] +mod tests { + use std::ffi::{OsStr, OsString}; + use std::path::{Path, PathBuf}; + + use clap::Parser; + use smplx_build::BuildConfig; + + use crate::Cli; + use crate::commands::Command as SimplexCommand; + + use super::{CONFIG_FILENAME, Format, FormatOpts}; + + fn fmt_opts(args: impl IntoIterator) -> FormatOpts { + let cli = Cli::try_parse_from(args).expect("arguments should parse"); + match cli.command { + SimplexCommand::Fmt { opts } => opts, + _ => panic!("expected fmt command"), + } + } + + #[test] + fn parses_default_options() { + let opts = fmt_opts(["simplex", "fmt"]); + + assert!(!opts.quiet); + assert!(!opts.verbose); + assert!(!opts.version); + assert!(!opts.check); + assert_eq!(opts.manifest_path, None); + assert_eq!(opts.message_format, None); + assert!(opts.simfmt_options.is_empty()); + } + + #[test] + fn parses_all_options_and_raw_simfmt_arguments() { + let opts = fmt_opts([ + "simplex", + "fmt", + "--quiet", + "--version", + "--manifest-path", + "project/Simplex.toml", + "--message-format", + "short", + "--check", + "--", + "--emit", + "stdout", + ]); + + assert!(opts.quiet); + assert!(!opts.verbose); + assert!(opts.version); + assert!(opts.check); + assert_eq!(opts.manifest_path.as_deref(), Some("project/Simplex.toml")); + assert_eq!(opts.message_format.as_deref(), Some("short")); + assert_eq!(opts.simfmt_options, ["--emit", "stdout"]); + } + + #[test] + fn raw_simfmt_arguments_require_separator() { + assert!(Cli::try_parse_from(["simplex", "fmt", "--emit", "stdout"]).is_err()); + assert!(Cli::try_parse_from(["simplex", "fmt", "--", "--emit", "stdout"]).is_ok()); + + assert!(Cli::try_parse_from(["simplex", "fmt", "--color", "auto", "--version"]).is_err()); + assert!(Cli::try_parse_from(["simplex", "fmt", "--", "--color", "auto", "--version"]).is_ok()); + } + + #[test] + fn rejects_unknown_wrapper_arguments() { + assert!(Cli::try_parse_from(["simplex", "fmt", "--package", "demo"]).is_err()); + assert!(Cli::try_parse_from(["simplex", "fmt", "--all"]).is_err()); + } + + #[test] + fn discovers_manifest_in_current_directory() { + let opts = fmt_opts(["simplex", "fmt"]); + let project_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures"); + + let manifest_path = Format::resolve_manifest_path(&opts, &project_root).expect("manifest should be discovered"); + + assert_eq!(manifest_path, project_root.join(CONFIG_FILENAME)); + } + + #[test] + fn discovers_manifest_from_nested_directory() { + let opts = fmt_opts(["simplex", "fmt"]); + let project_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures"); + let nested_directory = project_root.join("simf/nested/nested_2/imp2"); + + let manifest_path = + Format::resolve_manifest_path(&opts, &nested_directory).expect("manifest should be discovered"); + + assert_eq!(manifest_path, project_root.join(CONFIG_FILENAME)); + } + + #[test] + fn discovers_nearest_manifest() { + let opts = fmt_opts(["simplex", "fmt"]); + let fixtures_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures"); + let project_root = fixtures_root.join("deps/math"); + let nested_directory = project_root.join("simf"); + + let manifest_path = + Format::resolve_manifest_path(&opts, &nested_directory).expect("manifest should be discovered"); + + assert_eq!(manifest_path, project_root.join(CONFIG_FILENAME)); + } + + #[test] + fn explicit_manifest_path_overrides_discovery() { + let fixtures_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures"); + let nested_directory = fixtures_root.join("deps/math/simf"); + let explicit_manifest = fixtures_root.join(CONFIG_FILENAME); + let mut opts = fmt_opts(["simplex", "fmt"]); + opts.manifest_path = Some(explicit_manifest.to_string_lossy().into_owned()); + + let manifest_path = + Format::resolve_manifest_path(&opts, &nested_directory).expect("explicit manifest should be used"); + + assert_eq!(manifest_path, explicit_manifest); + } + + #[test] + fn rejects_explicit_manifest_with_wrong_filename() { + let opts = fmt_opts(["simplex", "fmt", "--manifest-path", "project/simplex.toml"]); + let current_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + + assert!(matches!( + Format::resolve_manifest_path(&opts, current_dir), + Err(crate::commands::error::FmtError::InvalidManifestPath(path)) + if path == *"project/simplex.toml" + )); + } + + #[test] + fn builds_deterministic_simfmt_arguments() { + let opts = fmt_opts([ + "simplex", + "fmt", + "--verbose", + "--check", + "--message-format", + "human", + "--", + "--emit", + "files", + ]); + let files = [PathBuf::from("/tmp/a.simf"), PathBuf::from("/tmp/path/spaces/b.simf")]; + + let args = Format::build_simfmt_args(&opts, &files).expect("arguments should build"); + + assert_eq!( + args, + [ + OsString::from("/tmp/a.simf"), + OsString::from("/tmp/path/spaces/b.simf"), + OsString::from("--verbose"), + OsString::from("--check"), + OsString::from("--emit"), + OsString::from("files"), + ] + ); + } + + #[test] + fn builds_deterministic_simfmt_arguments_with_files() { + let opts = fmt_opts([ + "simplex", + "fmt", + "/tmp/a.simf", + "--verbose", + "--check", + "--message-format", + "human", + "/tmp/b.simf", + "/tmp/path/spaces/b.simf", + "--", + "--emit", + "files", + ]); + + let args = Format::build_simfmt_args(&opts, &opts.files).expect("arguments should build"); + + assert_eq!( + args, + [ + OsString::from("/tmp/a.simf"), + OsString::from("/tmp/b.simf"), + OsString::from("/tmp/path/spaces/b.simf"), + OsString::from("--verbose"), + OsString::from("--check"), + OsString::from("--emit"), + OsString::from("files"), + ] + ); + } + + #[test] + fn converts_short_message_format_to_list_files() { + let opts = fmt_opts(["simplex", "fmt", "--message-format", "short"]); + let args = Format::build_simfmt_args(&opts, &["/tmp/a.simf".into()]).expect("arguments should build"); + + assert_eq!(args.last(), Some(&OsString::from("-l"))); + } + + #[test] + fn does_not_duplicate_existing_list_files_flag() { + let opts = fmt_opts(["simplex", "fmt", "--message-format", "short", "--", "--files-with-diff"]); + let args = Format::build_simfmt_args(&opts, &["/tmp/a.simf".into()]).expect("arguments should build"); + + assert_eq!(args.iter().filter(|arg| *arg == "--files-with-diff").count(), 1); + assert!(!args.iter().any(|arg| arg == "-l")); + } + + #[test] + fn does_not_duplicate_raw_check_flag() { + let opts = fmt_opts(["simplex", "fmt", "--check", "--", "--check"]); + let args = Format::build_simfmt_args(&opts, &["/tmp/a.simf".into()]).expect("arguments should build"); + + assert_eq!(args.iter().filter(|arg| *arg == "--check").count(), 1); + } + + #[test] + fn resolves_all_configured_files_in_sorted_order() { + let project_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures"); + let config = BuildConfig { + simf_files: vec!["*.simf".into(), "**/*.simf".into()], + src_dir: "simf".into(), + out_dir: "None".into(), + }; + + let files = Format::resolve_files(&config, &project_root).expect("fixture files should resolve"); + + assert_eq!(files.len(), 6); + assert!(files.iter().all(|path| path.extension() == Some(OsStr::new("simf")))); + } + + #[test] + fn rejects_an_empty_formatter_workspace() { + let project_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures"); + let config = BuildConfig { + simf_files: vec!["no-such-file-*.simf".into()], + src_dir: "simf".into(), + out_dir: "None".into(), + }; + + assert!(Format::resolve_files(&config, &project_root).is_err()); + } +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index b78fadac..154dad6e 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod build; pub mod clean; pub mod core; pub mod error; +pub mod fmt; pub mod init; pub mod install; pub mod regtest;