Skip to main content

miden_project/
dependencies.rs

1#[cfg(all(feature = "std", feature = "serde"))]
2mod graph;
3
4use alloc::{format, sync::Arc, vec};
5use core::fmt;
6
7use miden_assembly_syntax::debuginfo::Spanned;
8pub use miden_package_registry::{SemVer, Version, VersionReq, VersionRequirement};
9
10#[cfg(all(feature = "std", feature = "serde"))]
11pub use self::graph::*;
12use crate::{Diagnostic, Linkage, SourceSpan, Span, Uri, miette};
13
14/// Represents a project/package dependency declaration
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Dependency {
17    /// The name of the dependency.
18    name: Span<Arc<str>>,
19    /// The version requirement and resolution scheme for this dependency.
20    version: DependencyVersionScheme,
21    /// The linkage for this dependency
22    linkage: Linkage,
23}
24
25impl Dependency {
26    /// Construct a new [Dependency] with the given name and version scheme
27    pub const fn new(
28        name: Span<Arc<str>>,
29        version: DependencyVersionScheme,
30        linkage: Linkage,
31    ) -> Self {
32        Self { name, version, linkage }
33    }
34
35    /// Get the name of this dependency
36    pub fn name(&self) -> &Arc<str> {
37        &self.name
38    }
39
40    /// Get the versioning scheme/requirement for this dependency
41    pub fn scheme(&self) -> &DependencyVersionScheme {
42        &self.version
43    }
44
45    /// Get the linkage mode for this dependency
46    pub const fn linkage(&self) -> Linkage {
47        self.linkage
48    }
49
50    /// Get the version requirement for this dependency, if one was given
51    pub fn required_version(&self) -> VersionRequirement {
52        let req = match &self.version {
53            DependencyVersionScheme::Registry(version) => return version.clone(),
54            DependencyVersionScheme::Workspace { version, .. } => version.clone(),
55            DependencyVersionScheme::WorkspacePath { version, .. } => version.clone(),
56            DependencyVersionScheme::Path { version, .. } => version.clone(),
57            DependencyVersionScheme::Git { version, .. } => {
58                version.as_ref().map(|spanned| VersionRequirement::Semantic(spanned.clone()))
59            },
60        };
61        req.unwrap_or_else(|| VersionRequirement::from(VersionReq::STAR.clone()))
62    }
63}
64
65impl Spanned for Dependency {
66    fn span(&self) -> SourceSpan {
67        self.name.span()
68    }
69}
70
71/// Represents the versioning requirement and resolution method for a specific dependency.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum DependencyVersionScheme {
74    /// Resolve the given semantic version requirement or digest using the configured package
75    /// registry, to an assembled Miden package artifact.
76    ///
77    /// Resolution of packages using this scheme relies on the specific implementation of the
78    /// package registry in use, which can vary depending on context.
79    Registry(VersionRequirement),
80    /// Resolve the given workspace-relative path to a declared member of the current workspace.
81    Workspace {
82        /// The workspace-relative member path.
83        member: Span<Uri>,
84        /// If specified on the corresponding `[workspace.dependencies]` entry, the version of the
85        /// referenced project/package must satisfy this requirement.
86        version: Option<VersionRequirement>,
87    },
88    /// Resolve the given path inherited from `[workspace.dependencies]`, relative to the
89    /// workspace root, to either a Miden project/workspace or an assembled package artifact.
90    WorkspacePath {
91        /// The path as declared in `[workspace.dependencies]`.
92        path: Span<Uri>,
93        /// If specified, the version of the referenced project/package _must_ match this version
94        /// requirement.
95        version: Option<VersionRequirement>,
96    },
97    /// Resolve the given path to a Miden project/workspace, or assembled Miden package artifact.
98    Path {
99        /// The path to a Miden project directory containing a `miden-project.toml` OR a Miden
100        /// package file (i.e. a file with the `.masp` extension, as produced by the assembler).
101        path: Span<Uri>,
102        /// If specified, the version of the referenced project/package _must_ match this version
103        /// requirement.
104        ///
105        /// If unspecified, no additional version validation is performed; the current version
106        /// declared by the referenced source/package is used as-is.
107        version: Option<VersionRequirement>,
108    },
109    /// Resolve the given Git repository to a Miden project/workspace.
110    Git {
111        /// The Git repository URI.
112        ///
113        /// NOTE: Supports any URI scheme supported by the `git` CLI.
114        repo: Span<Uri>,
115        /// The specific revision to clone.
116        revision: Span<GitRevision>,
117        /// If specified, the version declared in the manifest found in the cloned repository
118        /// _must_ match this version requirement.
119        ///
120        /// If unspecified, no additional version validation is performed; the current version
121        /// declared by the checked out sources is used as-is.
122        version: Option<Span<VersionReq>>,
123    },
124}
125
126/// A reference to a revision in Git
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum GitRevision {
129    /// A reference to the HEAD revision of the given branch.
130    Branch(Arc<str>),
131    /// A reference to a specific revision with the given hash identifier
132    Commit(Arc<str>),
133}
134
135impl fmt::Display for GitRevision {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        match self {
138            Self::Branch(name) => f.write_str(name.as_ref()),
139            Self::Commit(rev) => write!(f, "sha256:{rev}"),
140        }
141    }
142}
143
144#[derive(Debug, thiserror::Error, Diagnostic)]
145pub enum InvalidDependencySpecError {
146    #[error("package is not a member of a workspace")]
147    NotAWorkspace {
148        #[label(primary)]
149        span: SourceSpan,
150    },
151    #[error("digests cannot be used with 'git' dependencies")]
152    #[diagnostic(help(
153        "Package digests are only valid when depending on an already-assembled package"
154    ))]
155    GitWithDigest {
156        #[label(primary)]
157        span: SourceSpan,
158    },
159    #[error("'git' dependencies must also specify a revision using either 'branch' or 'rev'")]
160    MissingGitRevision {
161        #[label(primary)]
162        span: SourceSpan,
163    },
164    #[error(
165        "conflicting 'git' revisions: 'branch' and 'rev' may refer to different commits, you cannot specify both"
166    )]
167    ConflictingGitRevision {
168        #[label(primary)]
169        first: SourceSpan,
170        #[label]
171        second: SourceSpan,
172    },
173    #[error("missing version: expected one of 'version', 'git', or 'digest' to be provided")]
174    MissingVersion {
175        #[label(primary)]
176        span: SourceSpan,
177    },
178}
179
180#[cfg(feature = "serde")]
181impl TryFrom<Span<&crate::ast::DependencySpec>> for DependencyVersionScheme {
182    type Error = InvalidDependencySpecError;
183
184    fn try_from(ast: Span<&crate::ast::DependencySpec>) -> Result<Self, Self::Error> {
185        if ast.inherits_workspace_version() {
186            return Err(InvalidDependencySpecError::NotAWorkspace { span: ast.span() });
187        }
188
189        if ast.is_host_resolved() {
190            ast.version()
191                .cloned()
192                .map(Self::Registry)
193                .ok_or(InvalidDependencySpecError::MissingVersion { span: ast.span() })
194        } else if ast.is_git() {
195            let version = match ast.version() {
196                Some(VersionRequirement::Digest(digest)) => {
197                    return Err(InvalidDependencySpecError::GitWithDigest { span: digest.span() });
198                },
199                Some(VersionRequirement::Exact(_)) => {
200                    return Err(InvalidDependencySpecError::GitWithDigest { span: ast.span() });
201                },
202                Some(VersionRequirement::Semantic(v)) => Some(v.clone()),
203                None => None,
204            };
205            if let Some(branch) = ast.branch.as_ref()
206                && let Some(rev) = ast.rev.as_ref()
207            {
208                return Err(InvalidDependencySpecError::ConflictingGitRevision {
209                    first: branch.span(),
210                    second: rev.span(),
211                });
212            }
213            let revision = ast
214                .branch
215                .as_ref()
216                .map(|branch| Span::new(branch.span(), GitRevision::Branch(branch.inner().clone())))
217                .or_else(|| {
218                    ast.rev
219                        .as_ref()
220                        .map(|rev| Span::new(rev.span(), GitRevision::Commit(rev.inner().clone())))
221                })
222                .ok_or_else(|| InvalidDependencySpecError::MissingGitRevision {
223                    span: ast.span(),
224                })?;
225            Ok(Self::Git {
226                repo: ast.git.clone().unwrap(),
227                revision,
228                version,
229            })
230        } else {
231            Ok(Self::Path {
232                path: ast.path.clone().unwrap(),
233                version: ast.version_or_digest.clone(),
234            })
235        }
236    }
237}
238
239#[cfg(feature = "serde")]
240impl DependencyVersionScheme {
241    /// Parse a dependency spec into [DependencyVersionScheme], taking into account workspace
242    /// context.
243    #[cfg(feature = "std")]
244    pub fn try_from_in_workspace(
245        spec: Span<&crate::ast::DependencySpec>,
246        workspace: &crate::ast::WorkspaceFile,
247    ) -> Result<Self, InvalidDependencySpecError> {
248        use std::path::Path;
249
250        use crate::absolutize_path;
251
252        // If the dependency is a path dependency, check if the path refers to any of the workspace
253        // members, and if so, convert the dependency version scheme to `Workspace` to aid in
254        // dependency resolution
255        match Self::try_from(spec)? {
256            Self::Path { path: uri, version } => {
257                let workspace_path = workspace
258                    .source_file
259                    .as_ref()
260                    .map(|file| Path::new(file.content().uri().path()));
261                if uri.scheme().is_none_or(|scheme| scheme == "file")
262                    && let Some(workspace_path) = workspace_path.and_then(|p| p.canonicalize().ok())
263                    && let Some(workspace_root) = workspace_path.parent()
264                    && let Ok(resolved_uri) = absolutize_path(Path::new(uri.path()), workspace_root)
265                {
266                    let is_member = workspace.workspace.members.iter().any(|member| {
267                        let member_path = member.path();
268                        uri.path() == member_path
269                            || uri.path() == format!("{member_path}/miden-project.toml")
270                            || absolutize_path(Path::new(member_path), workspace_root)
271                                .ok()
272                                .is_some_and(|member_dir| {
273                                    resolved_uri == member_dir
274                                        || resolved_uri == member_dir.join("miden-project.toml")
275                                })
276                    });
277                    if is_member {
278                        Ok(Self::Workspace { member: uri.clone(), version })
279                    } else {
280                        Ok(Self::WorkspacePath { path: uri.clone(), version })
281                    }
282                } else {
283                    Ok(Self::Path { path: uri, version })
284                }
285            },
286            scheme => Ok(scheme),
287        }
288    }
289
290    #[cfg(not(feature = "std"))]
291    pub fn try_from_in_workspace(
292        spec: Span<&crate::ast::DependencySpec>,
293        workspace: &crate::ast::WorkspaceFile,
294    ) -> Result<Self, InvalidDependencySpecError> {
295        use alloc::format;
296
297        match Self::try_from(spec)? {
298            Self::Path { path: uri, version } => {
299                let workspace_path =
300                    workspace.source_file.as_ref().map(|file| file.content().uri().path());
301                if uri.scheme().is_none_or(|scheme| scheme == "file") &&
302                    let Some(workspace_root) = workspace_path.and_then(|p| p.strip_suffix("miden-project.toml")) &&
303                    // Make sure the uri is relative to workspace root
304                    (!workspace_root.is_empty() && !(uri.path().starts_with('/') || uri.path().starts_with("..")))
305                {
306                    let is_member = workspace.workspace.members.iter().any(|member| {
307                        let member_path = member.path();
308                        uri.path() == member_path
309                            || uri.path() == format!("{member_path}/miden-project.toml")
310                    });
311                    if is_member {
312                        Ok(Self::Workspace { member: uri.clone(), version })
313                    } else {
314                        Ok(Self::WorkspacePath { path: uri.clone(), version })
315                    }
316                } else {
317                    Ok(Self::Path { path: uri, version })
318                }
319            },
320            scheme => Ok(scheme),
321        }
322    }
323}