Skip to main content

leo_package/
compilation_unit.rs

1// Copyright (C) 2019-2026 Provable Inc.
2// This file is part of the Leo library.
3
4// The Leo library is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// The Leo library is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with the Leo library. If not, see <https://www.gnu.org/licenses/>.
16
17use crate::{MAX_PROGRAM_SIZE, *};
18
19use leo_errors::Result;
20use leo_span::Symbol;
21
22use snarkvm::prelude::{Program as SvmProgram, TestnetV0};
23
24use indexmap::{IndexMap, IndexSet};
25use std::path::Path;
26
27/// Find the latest cached edition for a program in the local registry.
28/// Returns None if no cached version exists.
29fn find_cached_edition(cache_directory: &Path, name: &str) -> Option<u16> {
30    let program_cache = cache_directory.join(name);
31    if !program_cache.exists() {
32        return None;
33    }
34
35    // List edition directories and find the highest one
36    std::fs::read_dir(&program_cache)
37        .ok()?
38        .filter_map(|entry| entry.ok())
39        .filter_map(|entry| {
40            let file_name = entry.file_name();
41            let name = file_name.to_str()?;
42            name.parse::<u16>().ok()
43        })
44        .max()
45}
46
47/// The kind of a Leo compilation unit: a deployable program, a library, or a test.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub enum PackageKind {
50    /// A deployable program with a `main.leo` entry point.
51    Program,
52    /// A library with a `lib.leo` entry point; not directly deployable.
53    Library,
54    /// A test file; compiled only during `leo test`.
55    Test,
56}
57
58impl PackageKind {
59    pub fn is_program(&self) -> bool {
60        matches!(self, Self::Program)
61    }
62
63    pub fn is_library(&self) -> bool {
64        matches!(self, Self::Library)
65    }
66
67    pub fn is_test(&self) -> bool {
68        matches!(self, Self::Test)
69    }
70}
71
72/// Information about a single Leo compilation unit.
73#[derive(Clone, Debug)]
74pub struct CompilationUnit {
75    // The name of the program. For local packages this is the bare name (no ".aleo" suffix,
76    // e.g. `my_program` or `my_lib`). For network-fetched programs this includes the ".aleo"
77    // suffix (e.g. `credits.aleo`). TODO: unify the invariant so the suffix is always absent.
78    pub name: Symbol,
79    pub data: ProgramData,
80    pub edition: Option<u16>,
81    pub dependencies: IndexSet<Dependency>,
82    pub is_local: bool,
83    pub kind: PackageKind,
84}
85
86impl CompilationUnit {
87    /// Given the location `path` of a `.aleo` file, read the filesystem
88    /// to obtain a `CompilationUnit`.
89    pub fn from_aleo_path<P: AsRef<Path>>(name: Symbol, path: P, map: &IndexMap<Symbol, Dependency>) -> Result<Self> {
90        Self::from_aleo_path_impl(name, path.as_ref(), map)
91    }
92
93    fn from_aleo_path_impl(name: Symbol, path: &Path, map: &IndexMap<Symbol, Dependency>) -> Result<Self> {
94        let bytecode = std::fs::read_to_string(path).map_err(|e| {
95            crate::errors::util_file_io_error(format_args!("Trying to read aleo file at {}", path.display()), e)
96        })?;
97
98        let dependencies = parse_dependencies_from_aleo(name, &bytecode, map)?;
99
100        Ok(CompilationUnit {
101            name,
102            data: ProgramData::Bytecode(bytecode),
103            edition: None,
104            dependencies,
105            is_local: true,
106            kind: PackageKind::Program,
107        })
108    }
109
110    /// Given the location `path` of a local Leo package, read the filesystem
111    /// to obtain a `CompilationUnit`.
112    pub fn from_package_path<P: AsRef<Path>>(name: Symbol, path: P) -> Result<Self> {
113        Self::from_package_path_impl(name, path.as_ref())
114    }
115
116    fn from_package_path_impl(name: Symbol, path: &Path) -> Result<Self> {
117        let manifest = Manifest::read_from_file(path.join(MANIFEST_FILENAME))?;
118        let manifest_symbol = crate::symbol(&manifest.program)?;
119        if name != manifest_symbol {
120            return Err(
121                crate::errors::conflicting_manifest(format_args!("{name}"), format_args!("{manifest_symbol}")).into()
122            );
123        }
124        let source_directory = path.join(SOURCE_DIRECTORY);
125        source_directory.read_dir().map_err(|e| {
126            crate::errors::util_file_io_error(
127                format_args!("Failed to read directory {}", source_directory.display()),
128                e,
129            )
130        })?;
131
132        let main_path = source_directory.join(MAIN_FILENAME);
133        let lib_path = source_directory.join(LIB_FILENAME);
134
135        let (source_path, kind) = match (main_path.exists(), lib_path.exists()) {
136            (true, true) => {
137                return Err(crate::errors::ambiguous_entry_file(
138                    source_directory.display(),
139                    MAIN_FILENAME,
140                    LIB_FILENAME,
141                )
142                .into());
143            }
144            (true, false) => (main_path, PackageKind::Program),
145            (false, true) => (lib_path, PackageKind::Library),
146            (false, false) => {
147                return Err(
148                    crate::errors::invalid_entry_file(source_directory.display(), MAIN_FILENAME, LIB_FILENAME).into()
149                );
150            }
151        };
152
153        Ok(CompilationUnit {
154            name,
155            data: ProgramData::SourcePath { directory: path.to_path_buf(), source: source_path },
156            edition: None,
157            dependencies: manifest
158                .dependencies
159                .unwrap_or_default()
160                .into_iter()
161                .map(|dependency| {
162                    let dep = canonicalize_dependency_path_relative_to(path, dependency)?;
163                    if dep.location == Location::Workspace { resolve_workspace_dependency(path, dep) } else { Ok(dep) }
164                })
165                .collect::<Result<IndexSet<_>, _>>()?,
166            is_local: true,
167            kind,
168        })
169    }
170
171    /// Given the path to the source file of a test, create a `CompilationUnit`.
172    ///
173    /// Unlike `CompilationUnit::from_package_path`, the path is to the source file,
174    /// and the name of the program is determined from the filename.
175    ///
176    /// `main_program` must be provided since every test is dependent on it.
177    pub fn from_test_path<P: AsRef<Path>>(source_path: P, main_program: Dependency) -> Result<Self> {
178        Self::from_path_test_impl(source_path.as_ref(), main_program)
179    }
180
181    fn from_path_test_impl(source_path: &Path, main_program: Dependency) -> Result<Self> {
182        let name = filename_no_leo_extension(source_path)
183            .ok_or_else(|| crate::errors::failed_path(source_path.display(), ""))?;
184        let test_directory = source_path.parent().ok_or_else(|| {
185            crate::errors::failed_to_open_file(format_args!(
186                "Failed to find directory for test {}",
187                source_path.display()
188            ))
189        })?;
190        let package_directory = test_directory.parent().ok_or_else(|| {
191            crate::errors::failed_to_open_file(format_args!(
192                "Failed to find package for test {}",
193                source_path.display()
194            ))
195        })?;
196        let manifest = Manifest::read_from_file(package_directory.join(MANIFEST_FILENAME))?;
197        // Like Cargo, tests see the package's `dependencies` plus test-only `dev_dependencies`; a
198        // library in both lists is redundant but harmless, since the `IndexSet` dedups the entries.
199        let mut dependencies = manifest
200            .dependencies
201            .into_iter()
202            .flatten()
203            .chain(manifest.dev_dependencies.into_iter().flatten())
204            .map(|dependency| {
205                let dep = canonicalize_dependency_path_relative_to(package_directory, dependency)?;
206                if dep.location == Location::Workspace {
207                    resolve_workspace_dependency(package_directory, dep)
208                } else {
209                    Ok(dep)
210                }
211            })
212            .collect::<Result<IndexSet<_>, _>>()?;
213        dependencies.insert(main_program);
214
215        Ok(CompilationUnit {
216            name: Symbol::intern(&(name.to_owned() + ".aleo")),
217            edition: None,
218            data: ProgramData::SourcePath {
219                directory: test_directory.to_path_buf(),
220                source: source_path.to_path_buf(),
221            },
222            dependencies,
223            is_local: true,
224            kind: PackageKind::Test,
225        })
226    }
227
228    /// Given an Aleo program on a network, fetch it to build a `CompilationUnit`.
229    /// If no edition is found, the latest edition is pulled from the network.
230    pub fn fetch<P: AsRef<Path>>(
231        name: Symbol,
232        edition: Option<u16>,
233        home_path: P,
234        network: NetworkName,
235        endpoint: &str,
236        no_cache: bool,
237        network_retries: u32,
238    ) -> Result<Self> {
239        Self::fetch_impl(name, edition, home_path.as_ref(), network, endpoint, no_cache, network_retries)
240    }
241
242    fn fetch_impl(
243        name: Symbol,
244        edition: Option<u16>,
245        home_path: &Path,
246        network: NetworkName,
247        endpoint: &str,
248        no_cache: bool,
249        network_retries: u32,
250    ) -> Result<Self> {
251        // Callers may pass the name with or without the ".aleo" suffix; normalise to bare name
252        // here so cache paths and network URLs are constructed consistently.
253        let name = Symbol::intern(name.to_string().strip_suffix(".aleo").unwrap_or(&name.to_string()));
254
255        // It's not a local program; let's check the cache.
256        let cache_directory = home_path.join(format!("registry/{network}"));
257
258        // If the edition is not specified, try to find a cached version first,
259        // then fall back to querying the network for the latest edition.
260        let edition = match edition {
261            // Credits program always has edition 0.
262            _ if name == Symbol::intern("credits") => 0,
263            Some(edition) => edition,
264            None if !no_cache => {
265                // Check if we have a cached version - avoid network call if possible.
266                match find_cached_edition(&cache_directory, &name.to_string()) {
267                    Some(cached_edition) => cached_edition,
268                    None => crate::fetch_latest_edition(&name.to_string(), endpoint, network, network_retries)?,
269                }
270            }
271            // no_cache is set - user wants fresh data from network.
272            None => crate::fetch_latest_edition(&name.to_string(), endpoint, network, network_retries)?,
273        };
274
275        // Define the full cache path for the program.
276
277        // Build cache paths.
278        let cache_directory = cache_directory.join(format!("{name}/{edition}"));
279        let full_cache_path = cache_directory.join(format!("{name}.aleo"));
280        if !cache_directory.exists() {
281            // Create directory if it doesn't exist.
282            std::fs::create_dir_all(&cache_directory).map_err(|err| {
283                crate::errors::util_file_io_error(format!("Could not write path {}", cache_directory.display()), err)
284            })?;
285        }
286
287        // Get the existing bytecode if the file exists.
288        let existing_bytecode = match full_cache_path.exists() {
289            false => None,
290            true => {
291                let existing_contents = std::fs::read_to_string(&full_cache_path).map_err(|e| {
292                    crate::errors::util_file_io_error(
293                        format_args!("Trying to read cached file at {}", full_cache_path.display()),
294                        e,
295                    )
296                })?;
297                Some(existing_contents)
298            }
299        };
300
301        let bytecode = match (existing_bytecode, no_cache) {
302            // If we are using the cache, we can just return the bytecode.
303            (Some(bytecode), false) => bytecode,
304            // Otherwise, we need to fetch it from the network.
305            (existing, _) => {
306                // Define the primary URL to fetch the program from.
307                let primary_url = if name == Symbol::intern("credits") {
308                    format!("{endpoint}/{network}/program/credits.aleo")
309                } else {
310                    format!("{endpoint}/{network}/program/{name}.aleo/{edition}")
311                };
312                let secondary_url = format!("{endpoint}/{network}/program/{name}.aleo");
313                let contents = fetch_from_network(&primary_url, network_retries)
314                    .or_else(|_| fetch_from_network(&secondary_url, network_retries))
315                    .map_err(|err| {
316                        crate::errors::failed_to_retrieve_from_endpoint(
317                            primary_url,
318                            format_args!("Failed to fetch program `{name}` from network `{network}`: {err}"),
319                        )
320                    })?;
321
322                // If the file already exists, compare it to the new contents.
323                if let Some(existing_contents) = existing
324                    && existing_contents != contents
325                {
326                    println!(
327                        "Warning: The cached file at `{}` is different from the one fetched from the network. The cached file will be overwritten.",
328                        full_cache_path.display()
329                    );
330                }
331
332                // Write the bytecode to the cache.
333                std::fs::write(&full_cache_path, &contents).map_err(|err| {
334                    crate::errors::util_file_io_error(
335                        format_args!("Could not open file `{}`", full_cache_path.display()),
336                        err,
337                    )
338                })?;
339
340                contents
341            }
342        };
343
344        let dependencies = parse_dependencies_from_aleo(name, &bytecode, &IndexMap::new())?;
345
346        Ok(CompilationUnit {
347            // Network programs store the name with the ".aleo" suffix (unlike local packages).
348            // TODO: unify the invariant so the suffix is always absent.
349            name: Symbol::intern(&(name.to_string() + ".aleo")),
350            data: ProgramData::Bytecode(bytecode),
351            edition: Some(edition),
352            dependencies,
353            is_local: false,
354            kind: PackageKind::Program,
355        })
356    }
357
358    /// Resolve a git dependency, check it out, and build a `CompilationUnit`.
359    pub fn from_git(
360        name: Symbol,
361        dependency: &Dependency,
362        home_path: &Path,
363        old_lock: &Lock,
364        new_lock: &mut Lock,
365        offline: bool,
366        declared_deps: &IndexMap<Symbol, Dependency>,
367    ) -> Result<Self> {
368        let git = dependency
369            .git
370            .as_ref()
371            .ok_or_else(|| crate::errors::invalid_manifest_dependency(&dependency.name, "missing `git` URL"))?;
372        let url = git.url.as_str();
373        let reference =
374            git.reference().map_err(|reason| crate::errors::invalid_manifest_dependency(&dependency.name, reason))?;
375        let reference_str = reference.lock_string();
376
377        // Reuse a resolution of the same `(url, reference)` already performed in this build, so
378        // all dependencies into one repository see the same commit and it is cloned only once.
379        let memoized = new_lock
380            .commit_for_source(url, &reference_str)
381            .map(|commit| (crate::git::checkout_dir(home_path, url, commit), commit.to_string()))
382            .filter(|(dir, _)| dir.is_dir());
383        let (checkout, commit) = match memoized {
384            Some(hit) => hit,
385            None => {
386                let locked = old_lock.commit_for(&dependency.name, url, &reference_str).map(str::to_string);
387                crate::git::resolve(home_path, &dependency.name, url, &reference, locked.as_deref(), offline)?
388            }
389        };
390        new_lock.record(dependency.name.clone(), url.to_string(), reference_str, commit);
391
392        let located = find_in_checkout(&checkout, &dependency.name)?;
393        let mut unit = if located.extension().and_then(|e| e.to_str()) == Some("aleo") && located.is_file() {
394            Self::from_aleo_path(name, located, declared_deps)?
395        } else {
396            Self::from_package_path(name, located)?
397        };
398
399        // Intra-checkout deps (workspace siblings, local paths) become git deps on the same source
400        // so every route to a package in this repository is the same dependency.
401        unit.dependencies = unit
402            .dependencies
403            .into_iter()
404            .map(|dep| {
405                if dep.location == Location::Local && dep.path.as_ref().is_some_and(|p| p.starts_with(&checkout)) {
406                    Dependency {
407                        name: dep.name,
408                        location: Location::Git,
409                        path: None,
410                        edition: None,
411                        git: Some(git.clone()),
412                    }
413                } else {
414                    dep
415                }
416            })
417            .collect();
418        Ok(unit)
419    }
420}
421
422/// Locate the package named `dep_name` within a git checkout (by name, Cargo-style): a package
423/// directory whose `program.json` declares it (searched recursively), else a `<name>.aleo` file at
424/// the root. A directory declaring exactly the requested form wins over the alternate (`.aleo`)
425/// form; multiple candidates for the chosen form are an error.
426pub fn find_in_checkout(checkout: &Path, dep_name: &str) -> Result<std::path::PathBuf> {
427    // Match both name forms so callers may pass a library (`foo`) or program (`foo.aleo`) name.
428    let bare = crate::bare_unit_name(dep_name);
429    let with_aleo = format!("{bare}.aleo");
430    let (requested, alternate) =
431        if dep_name.ends_with(".aleo") { (with_aleo.as_str(), bare) } else { (bare, with_aleo.as_str()) };
432
433    let mut exact = Vec::new();
434    let mut alt = Vec::new();
435    collect_matching_manifest_dirs(checkout, requested, alternate, 0, &mut exact, &mut alt);
436    let matches = if exact.is_empty() { alt } else { exact };
437    if matches.len() > 1 {
438        let listed = matches
439            .iter()
440            .map(|d| d.strip_prefix(checkout).unwrap_or(d).display().to_string())
441            .collect::<Vec<_>>()
442            .join("`, `");
443        return Err(crate::errors::git_ambiguous_package(dep_name, listed).into());
444    }
445    if let Some(dir) = matches.into_iter().next() {
446        return Ok(dir);
447    }
448    let aleo = checkout.join(&with_aleo);
449    if aleo.is_file() {
450        return Ok(aleo);
451    }
452    Err(crate::errors::git_package_not_found(dep_name, checkout.display()).into())
453}
454
455/// Recursively search `dir` (up to a bounded depth) for directories whose `program.json` declares
456/// `requested` (collected into `exact`) or `alternate` (collected into `alt`).
457fn collect_matching_manifest_dirs(
458    dir: &Path,
459    requested: &str,
460    alternate: &str,
461    depth: usize,
462    exact: &mut Vec<std::path::PathBuf>,
463    alt: &mut Vec<std::path::PathBuf>,
464) {
465    // Enough to reach workspace members, and bounds the scan of a deep repository.
466    const MAX_DEPTH: usize = 6;
467    if depth > MAX_DEPTH {
468        return;
469    }
470
471    // Just the `program` field, parsed without validation so a malformed sibling can't fail the search.
472    #[derive(serde::Deserialize)]
473    struct ManifestProgramOnly {
474        program: String,
475    }
476
477    let manifest = dir.join(MANIFEST_FILENAME);
478    if manifest.is_file()
479        && !manifest.is_symlink()
480        && let Ok(contents) = std::fs::read_to_string(&manifest)
481        && let Ok(parsed) = serde_json::from_str::<ManifestProgramOnly>(&contents)
482    {
483        if parsed.program == requested {
484            exact.push(dir.to_path_buf());
485        } else if parsed.program == alternate {
486            alt.push(dir.to_path_buf());
487        }
488    }
489
490    // Sort so any ambiguity report is deterministic.
491    let mut entries: Vec<_> = std::fs::read_dir(dir).ok().into_iter().flatten().flatten().map(|e| e.path()).collect();
492    entries.sort();
493    for path in entries {
494        // Don't follow symlinks: a repo symlink could let the search escape the checkout.
495        if path.is_symlink() || !path.is_dir() {
496            continue;
497        }
498        // Skip dotfiles/VCS and build output directories.
499        let Some(name) = path.file_name().map(|n| n.to_string_lossy()) else {
500            continue;
501        };
502        if name.starts_with('.') || name == BUILD_DIRECTORY || name == "target" {
503            continue;
504        }
505        collect_matching_manifest_dirs(&path, requested, alternate, depth + 1, exact, alt);
506    }
507}
508
509/// Canonicalize the path in `dependency` relative to `base`. Absolute paths are canonicalized too
510/// so git-checkout containment checks see resolved symlinks and no `..` components.
511pub(crate) fn canonicalize_dependency_path_relative_to(base: &Path, mut dependency: Dependency) -> Result<Dependency> {
512    if let Some(path) = &mut dependency.path {
513        let joined = if path.is_absolute() { path.clone() } else { base.join(&path) };
514        *path = joined.canonicalize().map_err(|e| crate::errors::failed_path(joined.display(), e))?;
515    }
516    Ok(dependency)
517}
518
519/// Parse the `.aleo` file's imports and construct `Dependency`s.
520fn parse_dependencies_from_aleo(
521    name: Symbol,
522    bytecode: &str,
523    existing: &IndexMap<Symbol, Dependency>,
524) -> Result<IndexSet<Dependency>> {
525    // Check if the program size exceeds the maximum allowed limit.
526    let program_size = bytecode.len();
527
528    if program_size > MAX_PROGRAM_SIZE {
529        return Err(leo_errors::LeoError::Backtraced(crate::errors::program_size_limit_exceeded(
530            name,
531            program_size,
532            MAX_PROGRAM_SIZE,
533        )));
534    }
535
536    // Parse the bytecode into an SVM program.
537    let svm_program: SvmProgram<TestnetV0> =
538        bytecode.parse().map_err(|_| crate::errors::snarkvm_parsing_error(name))?;
539    let dependencies = svm_program
540        .imports()
541        .keys()
542        .map(|program_id| {
543            // If the dependency already exists, use it.
544            // Otherwise, assume it's a network dependency.
545            if let Some(dependency) = existing.get(&Symbol::intern(&program_id.to_string())) {
546                dependency.clone()
547            } else {
548                let name = program_id.to_string();
549                Dependency { name, location: Location::Network, path: None, edition: None, ..Default::default() }
550            }
551        })
552        .collect();
553    Ok(dependencies)
554}