diff --git a/README.md b/README.md index c181e03..9aaabf0 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ Simplex CLI provides the following commands: - `simplex init` - Initializes a new Simplex project. - `simplex config` - Prints the current config. -- `simplex install ` - Installs SimplicityHL dependencies. Without a `` provided, installs everything listed in the `[dependencies]` config section. With one or more `` arguments, appends new entries to the config and then installs everything. +- `simplex install ` - Installs SimplicityHL dependencies. Without a `` provided, installs everything listed in the `[dependencies]` config section. With one or more `` arguments, appends new entries to the config and then installs everything. The bare name `std` pins the latest `SimplicityHL` [standard library](https://github.com/BlockstreamResearch/simplicityhl-std) release. - `simplex build` - Generates simplicity artifacts. - `simplex regtest` - Spins up local Electrs + Elements nodes. - `simplex test` - Runs Simplex tests. diff --git a/crates/build/src/config/dep_spec.rs b/crates/build/src/config/dep_spec.rs index 7bac0e7..e70553e 100644 --- a/crates/build/src/config/dep_spec.rs +++ b/crates/build/src/config/dep_spec.rs @@ -1,14 +1,23 @@ use std::fmt::Write; +use std::process::Command; + +use toml_edit::{InlineTable, Value}; use crate::error::TomlEditError; +/// The bare token that resolves to the `SimplicityHL` standard library. +const STD_ALIAS: &str = "std"; + +/// The repository backing [`STD_ALIAS`]. +const STD_URL: &str = "https://github.com/BlockstreamResearch/simplicityhl-std.git"; + pub(super) struct DepSpec { pub alias: String, pub source: Source, } pub(super) enum Source { - Git(String), + Git { url: String, tag: Option }, Path(String), } @@ -16,6 +25,7 @@ impl DepSpec { /// Parses a raw CLI token into a [`DepSpec`]. /// /// Accepted forms: + /// - `std`: Shorthand for [`STD_URL`], pinned to its latest release tag. /// - ``: The alias is derived from the last path segment of the source, /// with a trailing `.git` stripped. /// - `=`: Both parts must be non-empty. @@ -27,7 +37,13 @@ impl DepSpec { /// - `TomlEditError::MalformedDep`: If `raw` contains `=` but either side is empty, /// or if the alias cannot be derived from the source (e.g. the source contains /// no non-empty path segment). + /// - `TomlEditError::RemoteTags` / `TomlEditError::NoTags`: If `raw` is `std` and + /// its latest tag cannot be resolved. pub(super) fn parse_dep(raw: &str) -> Result { + if raw == STD_ALIAS { + return Self::std_spec(); + } + let (alias, source_str) = match raw.split_once('=') { Some((a, s)) if !a.is_empty() && !s.is_empty() => (a.to_owned(), s), Some(_) => return Err(TomlEditError::MalformedDep(raw.to_owned())), @@ -39,6 +55,74 @@ impl DepSpec { Ok(DepSpec { alias, source }) } + /// Builds the spec for the standard library, pinned to the newest tag currently + /// published by [`STD_URL`]. + fn std_spec() -> Result { + let tag = Self::latest_tag(STD_URL)?; + + Ok(DepSpec { + alias: STD_ALIAS.to_owned(), + source: Source::Git { + url: STD_URL.to_owned(), + tag: Some(tag), + }, + }) + } + + /// Returns the highest release tag advertised by the remote at `url`. + /// + /// # Errors + /// - `TomlEditError::RemoteTags`: If `git ls-remote` cannot be run, exits non-zero, + /// or emits non-UTF-8 output. + /// - `TomlEditError::NoTags`: If the remote advertises no release tag. + fn latest_tag(url: &str) -> Result { + let failed = |reason: String| TomlEditError::RemoteTags { + url: url.to_owned(), + reason, + }; + + let output = Command::new("git") + .args(["ls-remote", "--tags", "--refs", "--sort=-v:refname", url]) + .output() + .map_err(|err| failed(err.to_string()))?; + + if !output.status.success() { + return Err(failed(String::from_utf8_lossy(&output.stderr).trim().to_owned())); + } + + let stdout = String::from_utf8(output.stdout).map_err(|err| failed(err.to_string()))?; + + stdout + .lines() + .filter_map(|line| line.split_once('\t')) + .filter_map(|(_, reference)| reference.strip_prefix("refs/tags/")) + // skip pre-releases + .find(|tag| !tag.contains('-')) + .map(str::to_owned) + .ok_or_else(|| TomlEditError::NoTags(url.to_owned())) + } + + /// Renders the spec as the inline table written under `[dependencies]`. + #[must_use] + pub(super) fn to_inline(&self) -> InlineTable { + let mut inline = InlineTable::new(); + + match &self.source { + Source::Git { url, tag } => { + inline.insert("git", Value::from(url.as_str())); + + if let Some(tag) = tag { + inline.insert("tag", Value::from(tag.as_str())); + } + } + Source::Path(p) => { + inline.insert("path", Value::from(p.as_str())); + } + } + + inline + } + /// Formats a batch of dependency specs as a bracketed, one-per-line list. #[must_use] pub(super) fn format_batch(specs: &[DepSpec]) -> String { @@ -53,11 +137,7 @@ impl DepSpec { out.push(','); } - let source = match &spec.source { - Source::Git(url) => url.as_str(), - Source::Path(p) => p.as_str(), - }; - let _ = write!(out, "\n {} = {}", spec.alias, source); + let _ = write!(out, "\n {} = {}", spec.alias, spec.to_inline()); } out.push_str("\n]"); @@ -94,7 +174,10 @@ impl DepSpec { || s.starts_with("ssh://") || git_ext { - Source::Git(s.to_owned()) + Source::Git { + url: s.to_owned(), + tag: None, + } } else { Source::Path(s.to_owned()) } diff --git a/crates/build/src/config/dependency.rs b/crates/build/src/config/dependency.rs index 9de32e3..9b256d3 100644 --- a/crates/build/src/config/dependency.rs +++ b/crates/build/src/config/dependency.rs @@ -1,12 +1,11 @@ use std::collections::HashMap; use std::path::Path; -use toml_edit::{DocumentMut, InlineTable, Item, Value}; +use toml_edit::{DocumentMut, Item}; use serde::Deserialize; use super::dep_spec::DepSpec; -use super::dep_spec::Source; use crate::error::{BuildError, DependencyValidationError, TomlEditError}; @@ -103,31 +102,12 @@ impl DependencyConfig { .as_table_mut() .ok_or(TomlEditError::MalformedDependenciesTable)?; - // Batches are small (typically <=10), so a linear scan over a Vec is cheaper - // than the constant overhead of a HashSet. - let mut seen_in_batch: Vec<&str> = Vec::with_capacity(specs.len()); - for spec in &specs { - if seen_in_batch.contains(&spec.alias.as_str()) || deps_table.contains_key(&spec.alias) { + if deps_table.contains_key(&spec.alias) { return Err(TomlEditError::DuplicateAlias(spec.alias.clone())); } - seen_in_batch.push(spec.alias.as_str()); - } - - for spec in &specs { - let mut inline = InlineTable::new(); - - match &spec.source { - Source::Git(url) => { - inline.insert("git", Value::from(url.as_str())); - } - Source::Path(p) => { - inline.insert("path", Value::from(p.as_str())); - } - } - - deps_table.insert(&spec.alias, Item::Value(Value::InlineTable(inline))); + deps_table.insert(&spec.alias, Item::Value(spec.to_inline().into())); } std::fs::write(path, doc.to_string())?; diff --git a/crates/build/src/error.rs b/crates/build/src/error.rs index 8b6ce53..b5a430b 100644 --- a/crates/build/src/error.rs +++ b/crates/build/src/error.rs @@ -42,6 +42,12 @@ pub enum TomlEditError { #[error("dependency `{0}` already exists")] DuplicateAlias(String), + + #[error("failed to list the tags of `{url}`: {reason}")] + RemoteTags { url: String, reason: String }, + + #[error("`{0}` publishes no release tag")] + NoTags(String), } #[derive(thiserror::Error, Debug)] diff --git a/crates/cli/src/commands/core.rs b/crates/cli/src/commands/core.rs index 3302e24..03305c8 100644 --- a/crates/cli/src/commands/core.rs +++ b/crates/cli/src/commands/core.rs @@ -23,6 +23,7 @@ pub enum Command { /// If `deps` is empty, install everything from `Simplex.toml`. Install { /// Dependencies to install, as `` or `=`. + /// The bare name `std` pins the latest `SimplicityHL` standard library release. #[arg(value_name = "DEP")] deps: Vec, },