diff --git a/Cargo.lock b/Cargo.lock index fc31002d0..ce95abcb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,24 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "blake2" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cf849ee05b2ee5fba5e36f97ff8ec2533916700fc0758d40d92136a42f3388" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "0.2.17" @@ -101,6 +119,27 @@ dependencies = [ "once_cell", ] +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adfbc57365a37acbd2ebf2b64d7e69bb766e2fea813521ed536f5d0520dcf86c" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "encode_unicode" version = "0.3.6" @@ -113,6 +152,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2022715d62ab30faffd124d40b76f4134a550a87792276512b18d63272333394" +[[package]] +name = "generic-array" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bff49e947297f3312447abdca79f45f4738097cc82b06e72054d2223f601f1b9" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getopts" version = "0.2.21" @@ -143,6 +192,14 @@ dependencies = [ "vm", ] +[[package]] +name = "ipm" +version = "0.10.0" +dependencies = [ + "blake2", + "getopts", +] + [[package]] name = "jemalloc-sys" version = "0.3.2" @@ -312,6 +369,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + [[package]] name = "terminal_size" version = "0.1.17" @@ -322,6 +385,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "typenum" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" + [[package]] name = "types" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index 9a8b7e984..47ed6582f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["ast", "bytecode", "inko", "compiler", "vm"] +members = ["ast", "bytecode", "inko", "compiler", "vm", "ipm"] [profile.release] panic = "abort" diff --git a/Makefile b/Makefile index 12a219566..69b07bc21 100644 --- a/Makefile +++ b/Makefile @@ -11,8 +11,11 @@ else INSTALL_PREFIX = ${PREFIX} endif -# The directory to place the executable in. -INSTALL_BIN := ${INSTALL_PREFIX}/bin/inko +# The directory to place the Inko executable in. +INSTALL_INKO := ${INSTALL_PREFIX}/bin/inko + +# The directory to place the package manager executable in. +INSTALL_IPM := ${INSTALL_PREFIX}/bin/ipm # The directory to place the standard library in. INSTALL_STD := ${INSTALL_PREFIX}/lib/inko/libstd @@ -82,6 +85,7 @@ ${SOURCE_TAR}: ${TMP_DIR} libstd/src \ vm \ types \ + ipm \ | gzip > "${@}" release/source: ${SOURCE_TAR} @@ -117,21 +121,27 @@ ${INSTALL_STD}: mkdir -p "${@}" cp -r libstd/src/* "${@}" -${INSTALL_BIN}: +${INSTALL_INKO}: mkdir -p "$$(dirname ${@})" install -m755 target/release/inko "${@}" +${INSTALL_IPM}: + mkdir -p "$$(dirname ${@})" + install -m755 target/release/ipm "${@}" + ${INSTALL_LICENSE}: mkdir -p "$$(dirname ${@})" install -m644 LICENSE "${@}" install: ${INSTALL_STD} \ - ${INSTALL_BIN} \ + ${INSTALL_INKO} \ + ${INSTALL_IPM} \ ${INSTALL_LICENSE} uninstall: rm -rf ${INSTALL_STD} - rm -f ${INSTALL_BIN} + rm -f ${INSTALL_INKO} + rm -f ${INSTALL_IPM} rm -rf ${INSTALL_PREFIX}/share/licenses/inko clean: diff --git a/compiler/src/config.rs b/compiler/src/config.rs index 38d522bc4..633f0ec77 100644 --- a/compiler/src/config.rs +++ b/compiler/src/config.rs @@ -17,6 +17,9 @@ pub(crate) const MAIN_MODULE: &str = "main"; /// The name of the directory containing a project's source code. pub(crate) const SOURCE: &str = "src"; +/// The name of the directory containing third-party dependencies. +const DEP: &str = "dep"; + /// The name of the directory containing a project's unit tests. const TESTS: &str = "test"; @@ -32,6 +35,9 @@ pub struct Config { /// The directory containing the project's source code. pub(crate) source: PathBuf, + /// The directory containing the project's dependencies. + pub(crate) dependencies: PathBuf, + /// The directory containing the project's unit tests. pub tests: PathBuf, @@ -73,6 +79,7 @@ impl Config { source: cwd.join(SOURCE), tests: cwd.join(TESTS), build: cwd.join(BUILD), + dependencies: cwd.join(DEP), sources: SourcePaths::new(), presenter: Box::new(TextPresenter::with_colors()), implicit_imports: vec![], @@ -88,6 +95,10 @@ impl Config { if self.source.is_dir() && self.source != self.libstd { self.sources.add(self.source.clone()); } + + if self.dependencies.is_dir() { + self.sources.add(self.dependencies.clone()); + } } fn add_default_implicit_imports(&mut self) { diff --git a/deny.toml b/deny.toml index e5eb86501..74e4d8346 100644 --- a/deny.toml +++ b/deny.toml @@ -6,11 +6,7 @@ unmaintained = "warn" yanked = "warn" notice = "warn" unsound = "warn" -ignore = [ - # https://gitlab.com/inko-lang/inko/-/issues/240#note_798519215 - "RUSTSEC-2020-0071", - "RUSTSEC-2020-0056" -] +ignore = [] [licenses] unlicensed = "deny" @@ -20,7 +16,6 @@ allow = [ "MPL-2.0", "ISC", "CC0-1.0", - "Unicode-DFS-2016", "BSD-3-Clause", ] copyleft = "deny" diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index b8e2ddd1a..fa91a5e4e 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -43,6 +43,7 @@ nav: - getting-started/error-handling.md - getting-started/concurrency.md - getting-started/pattern-matching.md + - getting-started/modules.md - Guides: - guides/contributing.md - guides/style-guide.md diff --git a/docs/source/getting-started/installation.md b/docs/source/getting-started/installation.md index e03277aae..e5887c6f5 100644 --- a/docs/source/getting-started/installation.md +++ b/docs/source/getting-started/installation.md @@ -17,6 +17,9 @@ Unix compatibility layer such as [MSYS2][msys2]. - A CPU with AES-NI support - Rust 1.62 or newer +Inko's package manager (ipm) also required Git to be installed, and the `git` +executable to be available in your PATH. + For Unix based platforms, the following must also be available - Make @@ -131,7 +134,10 @@ cd 0.10.0 ``` To compile a development build, run `cargo build`. For a release build, -run `cargo build --release` instead. +run `cargo build --release` instead. After building you can find the `inko` +executable in `target/release/inko` (or `target/debug/inko` for a debug build), +and the `ipm` executable in `target/release/ipm` (or `target/debug/ipm` for +debug builds). By default Inko uses the standard library provided in the Git repository, located in `libstd/src`. If you wish to use a different directory, set the diff --git a/docs/source/getting-started/modules.md b/docs/source/getting-started/modules.md new file mode 100644 index 000000000..231921986 --- /dev/null +++ b/docs/source/getting-started/modules.md @@ -0,0 +1,202 @@ +# Modules & packages + +Inko projects are organised using "modules". A module is just an Inko source +file you can import into another file using the `import` keyword. For example: + +```inko +import std::stdio +``` + +This imports the module `std::stdio` and exposes it using the name `stdio`. You +an also import specific symbols, such as types: + +```inko +import std::stdio::STDOUT +``` + +For more information about the syntax of `import` statements, refer to the +[Imports](syntax.md#imports) syntax documentation. + +## Import paths + +When importing modules, the compiler looks in the following places to find the +module: + +1. The standard library +1. Your project's `src/` directory (see + [Project structure](../guides/structure.md)) +1. Your project's `dep/` directory + +If a module isn't found, a compile-time error is produced. + +Inko doesn't supporting importing modules relative to another module. + +## Third-party dependencies + +Inko supports adding third-party dependencies using its package manager "ipm". +Packages are just Git repositories hosted on a platform such as GitLab or +GitHub. There's no central package registry. + +### Manifest format + +The dependencies or your project are listed in the file `inko.pkg` (called a +"package manifest") in the root directory of your project. The format of this +file is a simple line based format that looks as follows: + +``` +# This is a comment +require gitlab.com/bob/http 1.0.1 ece1027ada626bddd1efc74ba88a87dbdc19522c +require github.com/alice/json 1.0.0 f3f378ad8ea4b617401b40ace743614995904755 +``` + +Each line is either a comment (when it starts with a `#`), or a command. The +only command supported for now is `require`, which uses the following syntax: + +``` +require URL VERSION CHECKSUM +``` + +`URL` is the URL of the Git repository of the dependency. You can use any URL +supported by Git, including local file paths. + +`VERSION` is the version of the package in the format `MAJOR.MINOR.PATCH`. + +`CHECKSUM` is the SHA1 checksum of the Git commit the version points to. This +value is used to ensure that package contents aren't changed after the package +is published. + +### Version selection + +ipm uses [semantic versioning](https://semver.org/) for its versions, and +[minimal version selection](https://research.swtch.com/vgo-mvs) for version +selection. + +Minimal version selection means that you list the _minimum_ version of a package +that you need. If multiple packages depend on different versions of the same +package, ipm picks the most recent requirement from that list. Take these +requirements for example: + +``` +json >= 1.2.3 +json >= 1.5.3 +json >= 1.8.2 +``` + +Here the most recent version that satisfies all requirements is 1.8.2, so ipm +will pick that version of the "json" package. + +If packages require different major versions of another package, ipm produces an +error as we don't support using multiple major versions of the same package. + +Using minimal version selection offers several benefits: + +- The implementation is much simpler compared to SAT solvers used for other + version selecting techniques. Because of this the implementation is also much + faster. +- You don't need a lock file of sorts that lists all the exact packages and + versions to use. +- You won't end up using a version of a package that you never tested your code + against. + +For more details we suggest reading through the article by Russ Cox. + +### Handling security updates + +If a new version of a package is released, ipm ignores it due to the use of +minimal version selection; instead picking the most recent version from the list +of required versions. At first glance this may seem like a bad idea, as you +won't be able to take advantage of security updates of your dependencies. +There's a simple solution to this problem: add the dependency to your `inko.pkg` +with the appropriate minimum version, and ipm takes care of the rest. + +## Using ipm + +For a more in-depth overview of the available commands and flags, run `ipm +--help`. This also works for the various sub-commands, such as `ipm sync +--help`. + +When installing Inko using [ivm](ivm.md), ipm is already installed. When using a +package provided by your system's package manager, ipm should also be installed, +though on some platforms you may need to install ipm separately. If you're not +sure, we recommend using ivm to install Inko and its package manager. + +### Setting up + +Creating an empty `inko.pkg` is done using the `ipm init` command. + +### Adding dependencies + +Adding a package is done using `ipm add`, which takes the package URL and +version to add. For example: + +```bash +ipm add gitlab.com/inko-lang/example-package 1.2.3 +``` + +This command only adds the package to your `inko.pkg` file, it doesn't install +it into your project. + +### Removing dependencies + +The inverse of `ipm add` is the `ipm remove` command, which takes a package URL +and removes it from your `inko.pkg`. For example: + +```bash +ipm remove gitlab.com/inko-lang/example-package +``` + +### Installing dependencies + +!!! warning + The `ipm sync` command removes all files in the `dep` directory before + installing the dependencis, so make sure to not place files not managed by + ipm in this directory. + +Installing dependencies into your project is done using `ipm sync`. This command +downloads all the necessary dependencies, selects the appropriate versions, then +installs them in `./dep`. For example: + +``` +$ ipm sync +Updating package cache + Downloading /home/yorickpeterse/Projects/inko/ipm-test/http 1.0.1 + Downloading /home/yorickpeterse/Projects/inko/ipm-test/json 1.0.0 + Downloading /home/yorickpeterse/Projects/inko/ipm-test/test-package-with-dependency/ 0.5.2 + Downloading /home/yorickpeterse/Projects/inko/ipm-test/test-package 1.1.1 +Removing existing ./dep +Installing + /home/yorickpeterse/Projects/inko/ipm-test/json 1.0.0 + /home/yorickpeterse/Projects/inko/ipm-test/http 1.0.1 + /home/yorickpeterse/Projects/inko/ipm-test/test-package 1.1.1 + /home/yorickpeterse/Projects/inko/ipm-test/test-package-with-dependency/ 0.5.2 +``` + +Once installed you can import the dependencies using the `import` statement. + +The `dep` directory shouldn't be tracked by Git, so make sure to add it to your +`.gitignore` file like so: + +``` +/dep +``` + +### Updating dependencies + +Updating dependencies to their latest version is done using the `ipm update` +command. This command either takes a package URL and only updates that package, +or updates all packages if no URL is specified. + +By default this command only updates versions to the latest version using the +same major version. For example, if you depend on "json" version 1.2.3, and +1.2.5 is released, `ipm update` updates the required version to 1.2.5. When +version 2.0.0 is released, `ipm update` ignores it because this version isn't +backwards compatible with version 1. To update across major versions, use the +following: + +```bash +ipm update --major +``` + +Note that if other packages depend on the previous major version of the package +you're updating, you won't be able to update your `dep` directory using `ipm +sync`. diff --git a/docs/source/guides/structure.md b/docs/source/guides/structure.md index 485fe5ee1..3b086fab1 100644 --- a/docs/source/guides/structure.md +++ b/docs/source/guides/structure.md @@ -9,6 +9,10 @@ Build files should go in the `build/` directory. Inko creates this directory for you if needed. This directory should not be tracked using your version control system of choice. +Third-party dependencies are stored in a `dep/` directory. This directory is +managed using Inko's package manager, and you shouldn't put files in it +yourself. + As a reference, this is what the standard library's structure looks like: ``` diff --git a/docs/vale/docs/too_wordy.yml b/docs/vale/docs/too_wordy.yml index ed032a2a9..f3bfc137e 100644 --- a/docs/vale/docs/too_wordy.yml +++ b/docs/vale/docs/too_wordy.yml @@ -134,7 +134,6 @@ tokens: - magnitude - methodology - minimize - - minimum - modify - necessitate - nevertheless @@ -176,7 +175,6 @@ tokens: - relocate - remainder - remuneration - - requirement - reside - residence - retain diff --git a/ipm/Cargo.toml b/ipm/Cargo.toml new file mode 100644 index 000000000..5051fb8ae --- /dev/null +++ b/ipm/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "ipm" +version = "0.10.0" # VERSION +authors = ["Yorick Peterse "] +edition = "2021" +license = "MPL-2.0" + +[dependencies] +getopts = "^0.2" +blake2 = "^0.10" diff --git a/ipm/src/command/add.rs b/ipm/src/command/add.rs new file mode 100644 index 000000000..fb0c5c7f4 --- /dev/null +++ b/ipm/src/command/add.rs @@ -0,0 +1,84 @@ +use crate::error::Error; +use crate::git::Repository; +use crate::manifest::{Checksum, Manifest, Url, MANIFEST_FILE}; +use crate::util::{data_dir, usage}; +use crate::version::Version; +use getopts::Options; + +const USAGE: &str = "ipm add [OPTIONS] [URL] [VERSION] + +Add a package to the manifest in the current working directory. + +This command doesn't resolve any sub-dependencies or install the package into +your project, for that you need to run `ipm sync`. + +Examples: + + ipm add gitlab.com/inko-lang/example 1.2.3"; + +pub(crate) fn run(args: &[String]) -> Result<(), Error> { + let mut options = Options::new(); + + options.optflag("h", "help", "Show this help message"); + + let matches = options.parse(args)?; + + if matches.opt_present("h") || matches.free.is_empty() { + usage(&options, USAGE); + return Ok(()); + } + + if matches.free.len() != 2 { + fail!("You must specify a package and version to add"); + } + + let url = matches + .free + .get(0) + .and_then(|uri| Url::parse(uri)) + .ok_or_else(|| error!("The package URL is invalid"))?; + + let version = matches + .free + .get(1) + .and_then(|uri| Version::parse(uri)) + .ok_or_else(|| error!("The package version is invalid"))?; + + let dir = data_dir()?.join(url.directory_name()); + let (mut repo, fetch) = if dir.is_dir() { + (Repository::open(&dir)?, true) + } else { + (Repository::clone(&url.to_string(), &dir)?, false) + }; + + if fetch { + repo.fetch()?; + } + + let tag_name = version.tag_name(); + let tag = if let Some(tag) = repo.tag(&tag_name) { + Some(tag) + } else if fetch { + println!("Updating {}", url); + repo.fetch()?; + repo.tag(&tag_name) + } else { + None + }; + + let hash = tag + .map(|t| t.target) + .ok_or_else(|| error!("Version {} doesn't exist", version))?; + + let checksum = Checksum::new(&hash); + let mut manifest = Manifest::load(&MANIFEST_FILE)?; + + if let Some(existing) = manifest.find_dependency(&url) { + existing.version = version; + existing.checksum = checksum; + } else { + manifest.add_dependency(url, version, checksum); + } + + manifest.save(&MANIFEST_FILE) +} diff --git a/ipm/src/command/init.rs b/ipm/src/command/init.rs new file mode 100644 index 000000000..9ed21ff30 --- /dev/null +++ b/ipm/src/command/init.rs @@ -0,0 +1,56 @@ +use crate::error::Error; +use crate::manifest::MANIFEST_FILE; +use crate::util::usage; +use getopts::Options; +use std::env; +use std::fs::File; +use std::io::Write; +use std::path::PathBuf; + +const USAGE: &str = "ipm init [OPTIONS] [DIR] + +Create a new package in an existing directory + +Examples: + + ipm init + ipm init example/"; + +const TEMPLATE: &str = "\ +# This file contains your project's dependencies. For more information, refer +# to TODO"; + +pub(crate) fn run(args: &[String]) -> Result<(), Error> { + let mut options = Options::new(); + + options.optflag("h", "help", "Show this help message"); + + let matches = options.parse(args)?; + + if matches.opt_present("h") { + usage(&options, USAGE); + return Ok(()); + } + + let dir = if matches.free.is_empty() { + env::current_dir()? + } else { + PathBuf::from(&matches.free[0]) + }; + + if !dir.is_dir() { + fail!("The directory {:?} doesn't exist", dir); + } + + let path = dir.join(MANIFEST_FILE); + + if path.exists() { + Ok(()) + } else { + let mut file = File::create(&path) + .map_err(|e| error!("Failed to create {:?}: {}", path, e))?; + + file.write_all(TEMPLATE.as_bytes()) + .map_err(|e| error!("Failed to write to {:?}: {}", path, e)) + } +} diff --git a/ipm/src/command/main.rs b/ipm/src/command/main.rs new file mode 100644 index 000000000..9aea74e74 --- /dev/null +++ b/ipm/src/command/main.rs @@ -0,0 +1,58 @@ +use crate::command::add; +use crate::command::init; +use crate::command::remove; +use crate::command::sync; +use crate::command::update; +use crate::error::Error; +use crate::util::usage; +use getopts::{Options, ParsingStyle}; +use std::env::args; + +const USAGE: &str = "ipm [OPTIONS] [COMMAND] + +ipm is Inko's package manager, used to install and manage dependencies of +your project. + +Commands: + + init Create a new package + add Add or update a dependency + remove Remove a dependency + sync Download and install dependencies + update Update all dependencies to the latest version + +Examples: + + ipm init + ipm add gitlab.com/hello/world 1.2.3"; + +pub(crate) fn run() -> Result<(), Error> { + let args: Vec<_> = args().collect(); + let mut options = Options::new(); + + options.parsing_style(ParsingStyle::StopAtFirstFree); + options.optflag("h", "help", "Show this help message"); + options.optflag("v", "version", "Print the version number"); + + let matches = options.parse(&args[1..])?; + + if matches.opt_present("h") { + usage(&options, USAGE); + return Ok(()); + } + + if matches.opt_present("v") { + println!("ipm version {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + + match matches.free.get(0).map(|s| s.as_str()) { + Some("init") => init::run(&matches.free[1..]), + Some("add") => add::run(&matches.free[1..]), + Some("remove") => remove::run(&matches.free[1..]), + Some("sync") => sync::run(&matches.free[1..]), + Some("update") => update::run(&matches.free[1..]), + Some(cmd) => fail!("The command {:?} is invalid", cmd), + None => sync::run(&[]), + } +} diff --git a/ipm/src/command/mod.rs b/ipm/src/command/mod.rs new file mode 100644 index 000000000..746b068d5 --- /dev/null +++ b/ipm/src/command/mod.rs @@ -0,0 +1,6 @@ +pub(crate) mod add; +pub(crate) mod init; +pub(crate) mod main; +pub(crate) mod remove; +pub(crate) mod sync; +pub(crate) mod update; diff --git a/ipm/src/command/remove.rs b/ipm/src/command/remove.rs new file mode 100644 index 000000000..8eac5fc64 --- /dev/null +++ b/ipm/src/command/remove.rs @@ -0,0 +1,39 @@ +use crate::error::Error; +use crate::manifest::{Manifest, Url, MANIFEST_FILE}; +use crate::util::usage; +use getopts::Options; + +const USAGE: &str = "ipm remove [OPTIONS] [URI] + +Remove a dependency from the manifest. + +This command doesn't remove the dependency from your project, for that you need +to run `ipm sync`. + +Examples: + + ipm remove gitlab.com/inko-lang/example"; + +pub(crate) fn run(args: &[String]) -> Result<(), Error> { + let mut options = Options::new(); + + options.optflag("h", "help", "Show this help message"); + + let matches = options.parse(args)?; + + if matches.opt_present("h") || matches.free.is_empty() { + usage(&options, USAGE); + return Ok(()); + } + + let url = matches + .free + .get(0) + .and_then(|uri| Url::parse(uri)) + .ok_or_else(|| error!("The package URL is invalid"))?; + + let mut manifest = Manifest::load(&MANIFEST_FILE)?; + + manifest.remove_dependency(&url); + manifest.save(&MANIFEST_FILE) +} diff --git a/ipm/src/command/sync.rs b/ipm/src/command/sync.rs new file mode 100644 index 000000000..b230da5b0 --- /dev/null +++ b/ipm/src/command/sync.rs @@ -0,0 +1,256 @@ +use crate::error::Error; +use crate::git::Repository; +use crate::manifest::{Dependency, Manifest, Url, MANIFEST_FILE}; +use crate::util::{cp_r, data_dir, usage, DEP_DIR}; +use crate::version::{select, Version}; +use getopts::Options; +use std::collections::HashMap; +use std::collections::HashSet; +use std::fs::remove_dir_all; +use std::path::{Path, PathBuf}; + +/// The name of the directory to copy source files from and into the ./dep +/// directory. +const SRC_DIR: &str = "src"; + +/// The dependant string to use for the root project. +const ROOT_DEPENDANT: &str = "Your project"; + +const USAGE: &str = "ipm sync [OPTIONS] + +Installs all necessary dependencies into your project, and removes dependencies +no longer in use. + +Examples: + + ipm sync"; + +#[derive(Clone)] +enum Dependant { + Project, + Package(Url), +} + +struct Package { + dependant: Dependant, + repository: Repository, + dependency: Dependency, +} + +pub(crate) fn run(args: &[String]) -> Result<(), Error> { + let mut options = Options::new(); + + options.optflag("h", "help", "Show this help message"); + + let matches = options.parse(args)?; + + if matches.opt_present("h") { + usage(&options, USAGE); + return Ok(()); + } + + println!("Updating package cache"); + + let packages = download_packages()?; + let versions = select_versions(&packages)?; + let dep_dir = PathBuf::from(DEP_DIR); + + remove_dependencies(&dep_dir)?; + println!("Installing"); + install_packages(packages, versions, &dep_dir) +} + +fn download_packages() -> Result, Error> { + let data_dir = data_dir()?; + let mut manifests = + vec![(Dependant::Project, Manifest::load(&MANIFEST_FILE)?)]; + let mut packages = Vec::new(); + let mut downloaded = HashSet::new(); + + while let Some((dependant, manifest)) = manifests.pop() { + for dep in manifest.into_dependencies() { + let key = (dep.url.clone(), dep.version.clone()); + + if downloaded.contains(&key) { + continue; + } else { + downloaded.insert(key); + } + + match download_dependency(&data_dir, dependant.clone(), dep)? { + (package, Some(manifest)) => { + let dependant = + Dependant::Package(package.dependency.url.clone()); + + manifests.push((dependant, manifest)); + packages.push(package); + } + (package, None) => packages.push(package), + } + } + } + + Ok(packages) +} + +fn download_dependency( + cache_dir: &Path, + dependant: Dependant, + dependency: Dependency, +) -> Result<(Package, Option), Error> { + let dir = cache_dir.join(dependency.url.directory_name()); + let url = dependency.url.to_string(); + let (mut repo, fetch) = if dir.is_dir() { + (Repository::open(&dir)?, true) + } else { + println!(" Downloading {} {}", dependency.url, dependency.version); + (Repository::clone(&url, &dir)?, false) + }; + + let tag_name = dependency.version.tag_name(); + let tag = if let Some(tag) = repo.tag(&tag_name) { + Some(tag) + } else if fetch { + println!(" Updating {}", dependency.url); + + repo.fetch()?; + repo.tag(&tag_name) + } else { + None + }; + + let tag = tag.ok_or_else(|| { + error!( + "The version {} of package {} doesn't exist", + dependency.version, url + ) + })?; + + repo.checkout(&tag.target).map_err(|err| { + error!( + "Failed to checkout tag {} of package {}: {}", + tag_name, url, err + ) + })?; + + if tag.target != dependency.checksum.to_string() { + fail!( + "The checksum of {} version {} didn't match. + +The checksum that is expected is: + + {} + +The actual checksum is: + + {} + +This means that either your checksum is incorrect, or the version's contents +have changed since it was last published. + +If the version's contents have changed you'll need to check with the package's +maintainer to ensure this is expected. + +DO NOT PROCEED BLINDLY, as you may be including unexpected or even malicious +changes.", + url, + dependency.version, + dependency.checksum, + tag.target + ); + } + + let package = Package { dependant, repository: repo, dependency }; + let manifest_path = dir.join(MANIFEST_FILE); + + if manifest_path.is_file() { + Ok((package, Some(Manifest::load(&manifest_path)?))) + } else { + Ok((package, None)) + } +} + +fn select_versions(packages: &[Package]) -> Result, Error> { + match select(packages.iter().map(|p| &p.dependency)) { + Ok(versions) => Ok(versions), + Err(url) => Err(conflicting_versions_error(url, packages)), + } +} + +fn remove_dependencies(directory: &Path) -> Result<(), Error> { + if directory.is_dir() { + println!("Removing existing ./{}", DEP_DIR); + + remove_dir_all(&directory).map_err(|err| { + error!("Failed to remove the existing ./{}: {}", DEP_DIR, err) + })?; + } + + Ok(()) +} + +fn install_packages( + packages: Vec, + versions: Vec<(Url, Version)>, + directory: &Path, +) -> Result<(), Error> { + let repos = packages + .into_iter() + .map(|pkg| (pkg.dependency.url, pkg.repository)) + .collect::>(); + + for (url, ver) in versions { + println!(" {} {}", url, ver); + + let repo = repos.get(&url).unwrap(); + let tag_name = ver.tag_name(); + let tag = repo.tag(&tag_name).unwrap(); + + repo.checkout(&tag.target).map_err(|err| { + error!("Failed to check out {}: {}", tag_name, err) + })?; + + cp_r(&repo.path.join(SRC_DIR), directory)?; + } + + Ok(()) +} + +fn conflicting_versions_error(url: Url, packages: &[Package]) -> Error { + let reqs: Vec<_> = packages + .iter() + .filter_map(|pkg| { + if pkg.dependency.url == url { + let dependant = match &pkg.dependant { + Dependant::Project => ROOT_DEPENDANT.to_string(), + Dependant::Package(url) => url.to_string(), + }; + + Some(format!( + "{} requires:\n >= {}, < {}.0.0", + dependant, + pkg.dependency.version, + pkg.dependency.version.major + 1 + )) + } else { + None + } + }) + .collect(); + + error!( + "\ +The dependency graph contains conflicting major version requirements for the \ +package {}. + +These conflicting requirements are as follows: + + {} + +To resolve these conflicts, you need to ensure all version requirements for \ +package {} require the same major version.", + url, + reqs.join("\n\n "), + url + ) +} diff --git a/ipm/src/command/update.rs b/ipm/src/command/update.rs new file mode 100644 index 000000000..bd5c849cb --- /dev/null +++ b/ipm/src/command/update.rs @@ -0,0 +1,139 @@ +use crate::error::Error; +use crate::git::Repository; +use crate::manifest::{Checksum, Dependency, Manifest, Url, MANIFEST_FILE}; +use crate::util::{data_dir, usage}; +use crate::version::Version; +use getopts::Options; + +const USAGE: &str = "ipm update [OPTIONS] [PACKAGE] + +Update the version requirements of one or more packages to the latest compatible +version. This command only updates the entries in the package manifest. + +By default this command updates packages to their latest minor version. To +update them to the latest major version, use the -m/--major flag. + +Examples: + + ipm update + ipm update gitlab.com/inko-lang/example + ipm update gitlab.com/inko-lang/example --major"; + +pub(crate) fn run(args: &[String]) -> Result<(), Error> { + let mut options = Options::new(); + + options.optflag("h", "help", "Show this help message"); + options.optflag("m", "major", "Update across major versions"); + + let matches = options.parse(args)?; + + if matches.opt_present("h") { + usage(&options, USAGE); + return Ok(()); + } + + let major = matches.opt_present("m"); + let mut manifest = Manifest::load(&MANIFEST_FILE)?; + let update = if let Some(url) = + matches.free.get(0).and_then(|uri| Url::parse(uri)) + { + if let Some(dep) = manifest.find_dependency(&url) { + vec![dep] + } else { + fail!("The package {} isn't listed in {}", url, MANIFEST_FILE); + } + } else { + manifest.dependencies_mut() + }; + + for dep in update { + let dir = data_dir()?.join(dep.url.directory_name()); + let repo = if dir.is_dir() { + let mut repo = Repository::open(&dir)?; + + repo.fetch()?; + repo + } else { + Repository::clone(&dep.url.to_string(), &dir)? + }; + + let tag_names = repo.version_tag_names(); + + if tag_names.is_empty() { + fail!("The package {} doesn't have any versions", dep.url); + } + + let mut candidates = version_candidates(dep, tag_names, major); + + candidates.sort(); + + let version = match candidates.pop() { + Some(version) if version != dep.version => version, + _ => continue, + }; + + println!( + "Updating {} from version {} to version {}", + dep.url, dep.version, version + ); + + let tag = repo.tag(&version.tag_name()).unwrap(); + + dep.version = version; + dep.checksum = Checksum::new(tag.target); + } + + manifest.save(&MANIFEST_FILE) +} + +fn version_candidates( + dependency: &Dependency, + tags: Vec, + major: bool, +) -> Vec { + tags.into_iter() + .filter_map(|v| Version::parse(&v[1..])) + .filter( + |v| { + if major { + true + } else { + v.major == dependency.version.major + } + }, + ) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version_candidates() { + let tags = vec![ + "v1.2.4".to_string(), + "v1.3.8".to_string(), + "v2.3.1".to_string(), + ]; + let dep = Dependency { + url: Url::new("gitlab.com/foo/bar"), + version: Version::new(1, 2, 3), + checksum: Checksum::new("abc"), + }; + + assert_eq!( + version_candidates(&dep, tags.clone(), false), + vec![Version::new(1, 2, 4), Version::new(1, 3, 8)] + ); + + assert_eq!( + version_candidates(&dep, tags, true), + vec![ + Version::new(1, 2, 4), + Version::new(1, 3, 8), + Version::new(2, 3, 1) + ] + ); + } +} diff --git a/ipm/src/error.rs b/ipm/src/error.rs new file mode 100644 index 000000000..5731e5483 --- /dev/null +++ b/ipm/src/error.rs @@ -0,0 +1,32 @@ +use getopts::Fail; +use std::fmt; +use std::io; + +#[derive(Debug, PartialEq, Eq)] +pub struct Error { + message: String, +} + +impl Error { + pub fn new>(message: S) -> Self { + Error { message: message.into() } + } +} + +impl From for Error { + fn from(fail: Fail) -> Self { + Error { message: fail.to_string() } + } +} + +impl From for Error { + fn from(error: io::Error) -> Self { + Error { message: error.to_string() } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.message) + } +} diff --git a/ipm/src/git.rs b/ipm/src/git.rs new file mode 100644 index 000000000..21934a70e --- /dev/null +++ b/ipm/src/git.rs @@ -0,0 +1,203 @@ +use crate::error::Error; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const REMOTE: &str = "origin"; + +pub(crate) struct Repository { + /// The path to the local clone of the repository. + pub(crate) path: PathBuf, +} + +pub(crate) struct Tag { + /// The SHA of the commit the tag points to. + pub(crate) target: String, +} + +impl Repository { + pub(crate) fn open(path: &Path) -> Result { + if path.is_dir() { + Ok(Self { path: path.to_path_buf() }) + } else { + fail!("The Git repository at {} doesn't exist", path.display()) + } + } + + pub(crate) fn clone(url: &str, path: &Path) -> Result { + run("clone", None, &[OsStr::new(url), path.as_os_str()]) + .map_err(|err| error!("Failed to clone {}: {}", url, err))?; + + Ok(Self { path: path.to_path_buf() }) + } + + pub(crate) fn fetch(&mut self) -> Result<(), Error> { + run( + "fetch", + Some(self.path.as_path()), + &[OsStr::new(REMOTE), OsStr::new("--tags")], + ) + .map_err(|err| { + error!("Failed to update {}: {}", self.path.display(), err) + })?; + + Ok(()) + } + + pub(crate) fn tag(&self, name: &str) -> Option { + run( + "rev-list", + Some(self.path.as_path()), + &[OsStr::new("-n"), OsStr::new("1"), OsStr::new(name)], + ) + .ok() + .map(|output| Tag { target: output.trim().to_string() }) + } + + pub(crate) fn version_tag_names(&self) -> Vec { + if let Ok(output) = run( + "tag", + Some(self.path.as_path()), + &[OsStr::new("-l"), OsStr::new("v*")], + ) { + output.split('\n').map(|s| s.to_string()).collect() + } else { + Vec::new() + } + } + + pub(crate) fn checkout(&self, name: &str) -> Result<(), Error> { + run("checkout", Some(self.path.as_path()), &[OsStr::new(name)])?; + Ok(()) + } +} + +fn run( + command: &str, + working_directory: Option<&Path>, + arguments: &[&OsStr], +) -> Result { + let mut cmd = Command::new("git"); + + cmd.arg(command); + cmd.args(arguments); + + if let Some(path) = working_directory { + cmd.current_dir(path); + } + + cmd.stdin(Stdio::null()); + cmd.stderr(Stdio::piped()); + cmd.stdout(Stdio::piped()); + + let child = cmd + .spawn() + .map_err(|err| error!("Failed to spawn 'git {}': {}", command, err))?; + let output = child.wait_with_output().map_err(|err| error!("{}", err))?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout) + .into_owned() + .trim() + .to_string()) + } else { + Err(Error::new( + String::from_utf8_lossy(&output.stderr) + .into_owned() + .trim() + .to_string(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::remove_dir_all; + + fn create_tag(repo: &mut Repository, name: &str) { + run("tag", Some(repo.path.as_path()), &[OsStr::new(name)]).unwrap(); + } + + fn source_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") + } + + fn temp_dir() -> PathBuf { + PathBuf::from(source_dir()).join("tmp") + } + + #[test] + fn test_repository_open() { + let repo = Repository::open(&PathBuf::from(source_dir())); + + assert!(repo.is_ok()); + } + + #[test] + fn test_repository_clone() { + let temp = temp_dir().join("ipm-test_repository_clone"); + let repo = Repository::clone(source_dir().to_str().unwrap(), &temp); + + assert!(repo.is_ok()); + assert!(temp.is_dir()); + + remove_dir_all(temp).unwrap(); + } + + #[test] + fn test_repository_fetch() { + let temp = temp_dir().join("ipm-test_repository_fetch"); + let mut repo = + Repository::clone(source_dir().to_str().unwrap(), &temp).unwrap(); + + assert!(repo.fetch().is_ok()); + + remove_dir_all(temp).unwrap(); + } + + #[test] + fn test_repository_tag() { + let temp = temp_dir().join("ipm-test_repository_tag"); + let mut repo = + Repository::clone(source_dir().to_str().unwrap(), &temp).unwrap(); + + assert!(repo.tag("test").is_none()); + create_tag(&mut repo, "test"); + assert!(repo.tag("test").is_some()); + + remove_dir_all(temp).unwrap(); + } + + #[test] + fn test_repository_checkout() { + let temp = temp_dir().join("ipm-test_repository_checkout"); + let mut repo = + Repository::clone(source_dir().to_str().unwrap(), &temp).unwrap(); + + create_tag(&mut repo, "test"); + + let tag = repo.tag("test").unwrap(); + + assert!(repo.checkout(&tag.target).is_ok()); + + remove_dir_all(temp).unwrap(); + } + + #[test] + fn test_repository_version_tag_names() { + let temp = temp_dir().join("ipm-test_repository_version_tag_names"); + let mut repo = + Repository::clone(source_dir().to_str().unwrap(), &temp).unwrap(); + + create_tag(&mut repo, "v999.0.0"); + create_tag(&mut repo, "v999.0.1"); + + let names = repo.version_tag_names(); + + assert!(names.contains(&"v999.0.0".to_string())); + assert!(names.contains(&"v999.0.1".to_string())); + + remove_dir_all(temp).unwrap(); + } +} diff --git a/ipm/src/macros.rs b/ipm/src/macros.rs new file mode 100644 index 000000000..50ddc1711 --- /dev/null +++ b/ipm/src/macros.rs @@ -0,0 +1,11 @@ +macro_rules! fail { + ($message:expr $(,$arg:expr)*) => { + return Err(Error::new(format!($message $(,$arg)*))) + }; +} + +macro_rules! error { + ($message:expr $(,$arg:expr)*) => { + Error::new(format!($message $(,$arg)*)) + }; +} diff --git a/ipm/src/main.rs b/ipm/src/main.rs new file mode 100644 index 000000000..f1c437e00 --- /dev/null +++ b/ipm/src/main.rs @@ -0,0 +1,20 @@ +#[macro_use] +mod macros; + +mod command; +mod error; +mod git; +mod manifest; +mod util; +mod version; + +use crate::util::red; +use command::main; +use std::process::exit; + +fn main() { + if let Err(err) = main::run() { + eprintln!("{} {}", red("error:"), err); + exit(1); + } +} diff --git a/ipm/src/manifest.rs b/ipm/src/manifest.rs new file mode 100644 index 000000000..98ce99bfd --- /dev/null +++ b/ipm/src/manifest.rs @@ -0,0 +1,475 @@ +use crate::error::Error; +use crate::version::Version; +use blake2::{digest::consts::U16, Blake2b, Digest}; +use std::fmt; +use std::fs::File; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::Path; + +pub(crate) const MANIFEST_FILE: &str = "inko.pkg"; + +/// The URL of a package. +#[derive(Eq, PartialEq, Debug, Clone, Hash)] +pub(crate) struct Url { + pub(crate) value: String, +} + +impl Url { + pub(crate) fn parse(input: &str) -> Option { + if input.contains(' ') || input.is_empty() { + return None; + } + + // GitLab and GitHub URLs will be the most common, so we allow using + // these URLs in a slightly shorter form, making them a bit easier to + // work with from the CLI. + let value = if input.starts_with("gitlab.com") + || input.starts_with("github.com") + { + format!("https://{}", input) + } else { + input.to_string() + }; + + Some(Url::new(value)) + } + + pub(crate) fn new>(value: S) -> Self { + Self { value: value.into() } + } + + pub(crate) fn directory_name(&self) -> String { + // We don't need ultra long hashes, as all we care about is being able + // to generate a directory name from a URL _without_ it colliding with + // literally everything. + let mut hasher: Blake2b = Blake2b::new(); + + hasher.update(&self.value); + format!("{:x}", hasher.finalize()) + } +} + +impl fmt::Display for Url { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.value.fmt(f) + } +} + +/// A Git (SHA1) checksum. +#[derive(Eq, PartialEq, Debug, Clone)] +pub(crate) struct Checksum { + pub(crate) value: String, +} + +impl Checksum { + pub(crate) fn parse(input: &str) -> Option { + if input.len() != 40 { + return None; + } + + Some(Checksum::new(input)) + } + + pub(crate) fn new>(value: S) -> Self { + Self { value: value.into() } + } +} + +impl fmt::Display for Checksum { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + self.value.fmt(f) + } +} + +/// A dependency as specified in the manifest. +#[derive(Eq, PartialEq, Debug, Clone)] +pub(crate) struct Dependency { + pub(crate) url: Url, + pub(crate) version: Version, + pub(crate) checksum: Checksum, +} + +impl fmt::Display for Dependency { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "require {} {} {}", self.url, self.version, self.checksum) + } +} + +#[derive(Eq, PartialEq, Debug)] +pub(crate) enum Entry { + Comment(String), + Dependency(Dependency), + EmptyLine, +} + +impl fmt::Display for Entry { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Entry::Comment(comment) => write!(f, "#{}", comment), + Entry::EmptyLine => Ok(()), + Entry::Dependency(dep) => dep.fmt(f), + } + } +} + +/// A dependency manifest parsed from a `inko.pkg` file. +#[derive(Eq, PartialEq, Debug)] +pub(crate) struct Manifest { + pub(crate) entries: Vec, +} + +impl Manifest { + pub(crate) fn load>(path: &P) -> Result { + let path = path.as_ref(); + + File::open(path) + .map_err(|e| error!("Failed to read {}: {}", path.display(), e)) + .and_then(|mut file| Self::parse(&mut file)) + } + + pub(crate) fn parse(stream: &mut R) -> Result { + let reader = BufReader::new(stream); + let mut manifest = Self { entries: Vec::new() }; + + for (index, line) in reader.lines().enumerate() { + let lnum = index + 1; + let line = line.map_err(|err| { + error!("Failed to read lines from the manifest: {}", err) + })?; + + let trimmed = line.trim(); + + if trimmed.is_empty() { + manifest.entries.push(Entry::EmptyLine); + continue; + } + + if let Some(stripped) = trimmed.strip_prefix('#') { + manifest.entries.push(Entry::Comment(stripped.to_string())); + continue; + } + + let chunks: Vec<_> = trimmed.split(' ').collect(); + + if chunks.len() != 4 { + fail!("The entry on line {} is invalid", lnum); + } + + // Currently this is the only action we support. + if chunks[0] != "require" { + fail!( + "Expected line {} to start with 'require', not '{}'", + lnum, + chunks[0] + ); + } + + let url = Url::parse(chunks[1]) + .ok_or_else(|| error!("The URI on line {} is invalid", lnum))?; + let version = Version::parse(chunks[2]).ok_or_else(|| { + error!("The version on line {} is invalid", lnum) + })?; + let checksum = Checksum::parse(chunks[3]).ok_or_else(|| { + error!("The checksum on line {} is invalid", lnum) + })?; + + manifest.entries.push(Entry::Dependency(Dependency { + url, + version, + checksum, + })); + } + + Ok(manifest) + } + + pub(crate) fn add_dependency( + &mut self, + url: Url, + version: Version, + checksum: Checksum, + ) { + self.entries.push(Entry::Dependency(Dependency { + url, + version, + checksum, + })); + } + + pub(crate) fn find_dependency( + &mut self, + url: &Url, + ) -> Option<&mut Dependency> { + self.entries.iter_mut().find_map(|entry| match entry { + Entry::Dependency(dep) if &dep.url == url => Some(dep), + _ => None, + }) + } + + pub(crate) fn remove_dependency(&mut self, url: &Url) { + self.entries.retain( + |val| !matches!(val, Entry::Dependency(dep) if &dep.url == url), + ) + } + + pub(crate) fn dependencies_mut(&mut self) -> Vec<&mut Dependency> { + self.entries + .iter_mut() + .filter_map(|entry| match entry { + Entry::Dependency(dep) => Some(dep), + _ => None, + }) + .collect() + } + + pub(crate) fn into_dependencies(self) -> Vec { + self.entries + .into_iter() + .filter_map(|entry| match entry { + Entry::Dependency(dep) => Some(dep), + _ => None, + }) + .collect() + } + + pub(crate) fn save>(&self, path: &P) -> Result<(), Error> { + let path = path.as_ref(); + + File::create(path) + .and_then(|mut file| file.write_all(self.to_string().as_bytes())) + .map_err(|e| error!("Failed to update {}: {}", path.display(), e)) + } +} + +impl fmt::Display for Manifest { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + for entry in &self.entries { + writeln!(f, "{}", entry)?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_url_parse() { + assert_eq!( + Url::parse("https://gitlab.com/foo/bar"), + Some(Url::new("https://gitlab.com/foo/bar")) + ); + assert_eq!( + Url::parse("gitlab.com/foo/bar"), + Some(Url::new("https://gitlab.com/foo/bar")) + ); + assert_eq!( + Url::parse("https://github.com/foo/bar"), + Some(Url::new("https://github.com/foo/bar")) + ); + assert_eq!( + Url::parse("github.com/foo/bar"), + Some(Url::new("https://github.com/foo/bar")) + ); + assert_eq!( + Url::parse("git@gitlab.com:foo/bar.git"), + Some(Url::new("git@gitlab.com:foo/bar.git")) + ); + + assert_eq!(Url::parse(""), None); + assert_eq!(Url::parse("git@gitlab .com:foo/bar.git"), None); + } + + #[test] + fn test_url_directory_name() { + assert_eq!( + Url::new("https://gitlab.com/foo/bar").directory_name(), + "4efb5ddfa8b68f5e1885fc8b75838f43".to_string() + ); + assert_eq!( + Url::new("http://gitlab.com/foo/bar").directory_name(), + "2334066f1e6f5fea14ebf3fb71f714ca".to_string() + ); + } + + #[test] + fn test_manifest_parse_invalid() { + let missing_chunks = "# Ignore me + require https://gitlab.com/inko-lang/foo 1.2.3"; + + let invalid_cmd = "# Ignore me + bla https://gitlab.com/inko-lang/foo 1.2.3 abcdef123"; + + let invalid_version = + "require https://gitlab.com/inko-lang/foo 1.2 abc"; + let invalid_checksum = + "require https://gitlab.com/inko-lang/foo 1.2.3 abc"; + + assert_eq!( + Manifest::parse(&mut missing_chunks.as_bytes()), + Err(Error::new("The entry on line 2 is invalid".to_string())) + ); + assert_eq!( + Manifest::parse(&mut invalid_cmd.as_bytes()), + Err(Error::new( + "Expected line 2 to start with 'require', not 'bla'" + .to_string() + )) + ); + assert_eq!( + Manifest::parse(&mut invalid_version.as_bytes()), + Err(Error::new("The version on line 1 is invalid".to_string())) + ); + assert_eq!( + Manifest::parse(&mut invalid_checksum.as_bytes()), + Err(Error::new("The checksum on line 1 is invalid".to_string())) + ); + } + + #[test] + fn test_manifest_parse_valid() { + let input = "# Ignore me +# + +require https://gitlab.com/inko-lang/foo 1.2.3 633d02e92b2a96623c276b7d7fe09568f9f2e1ad"; + + assert_eq!( + Manifest::parse(&mut input.as_bytes()), + Ok(Manifest { + entries: vec![ + Entry::Comment(" Ignore me".to_string()), + Entry::Comment(String::new()), + Entry::EmptyLine, + Entry::Dependency(Dependency { + url: Url::new("https://gitlab.com/inko-lang/foo"), + version: Version::new(1, 2, 3), + checksum: Checksum::new( + "633d02e92b2a96623c276b7d7fe09568f9f2e1ad" + ), + }) + ] + }) + ); + } + + #[test] + fn test_manifest_to_string() { + let manifest = Manifest { + entries: vec![ + Entry::Comment(" Ignore me".to_string()), + Entry::Comment(String::new()), + Entry::EmptyLine, + Entry::Dependency(Dependency { + url: Url::new("https://gitlab.com/inko-lang/foo"), + version: Version::new(1, 2, 3), + checksum: Checksum::new("abc"), + }), + Entry::Dependency(Dependency { + url: Url::new("https://github.com/inko-lang/bar"), + version: Version::new(4, 5, 6), + checksum: Checksum::new("def"), + }), + ], + }; + + let output = "# Ignore me +# + +require https://gitlab.com/inko-lang/foo 1.2.3 abc +require https://github.com/inko-lang/bar 4.5.6 def +"; + + assert_eq!(manifest.to_string(), output); + } + + #[test] + fn test_manifest_add_dependency() { + let mut manifest = Manifest { entries: Vec::new() }; + let url = Url::new("test"); + let version = Version::new(1, 2, 3); + let checksum = Checksum::new("abc"); + + manifest.add_dependency(url, version, checksum); + + assert_eq!( + manifest.entries, + vec![Entry::Dependency(Dependency { + url: Url::new("test"), + version: Version::new(1, 2, 3), + checksum: Checksum::new("abc") + })] + ); + } + + #[test] + fn test_manifest_find_dependency() { + let mut manifest = Manifest { entries: Vec::new() }; + let url = Url::new("test"); + let version = Version::new(1, 2, 3); + let checksum = Checksum::new("abc"); + + manifest.add_dependency(url.clone(), version, checksum); + + assert_eq!( + manifest.find_dependency(&url), + Some(&mut Dependency { + url: Url::new("test"), + version: Version::new(1, 2, 3), + checksum: Checksum::new("abc") + }) + ); + } + + #[test] + fn test_manifest_remove_dependency() { + let mut manifest = Manifest { entries: Vec::new() }; + let url = Url::new("test"); + let version = Version::new(1, 2, 3); + let checksum = Checksum::new("abc"); + + manifest.add_dependency(url.clone(), version, checksum); + manifest.remove_dependency(&url); + + assert!(manifest.entries.is_empty()); + } + + #[test] + fn test_manifest_into_dependencies() { + let mut manifest = Manifest { entries: Vec::new() }; + let url = Url::new("test"); + let version = Version::new(1, 2, 3); + let checksum = Checksum::new("abc"); + + manifest.add_dependency(url.clone(), version, checksum); + + assert_eq!( + manifest.into_dependencies(), + vec![Dependency { + url: Url::new("test"), + version: Version::new(1, 2, 3), + checksum: Checksum::new("abc") + }] + ); + } + + #[test] + fn test_manifest_dependencies_mut() { + let mut manifest = Manifest { entries: Vec::new() }; + let url = Url::new("test"); + let version = Version::new(1, 2, 3); + let checksum = Checksum::new("abc"); + + manifest.add_dependency(url.clone(), version, checksum); + + assert_eq!( + manifest.dependencies_mut(), + vec![&Dependency { + url: Url::new("test"), + version: Version::new(1, 2, 3), + checksum: Checksum::new("abc") + }] + ); + } +} diff --git a/ipm/src/util.rs b/ipm/src/util.rs new file mode 100644 index 000000000..044b1e9c8 --- /dev/null +++ b/ipm/src/util.rs @@ -0,0 +1,127 @@ +use crate::error::Error; +use getopts::Options; +use std::env; +use std::fs::{copy, create_dir_all, read_dir}; +use std::path::{Path, PathBuf}; + +const DIR_NAME: &str = "ipm"; + +/// The directory to install dependencies into. +pub(crate) const DEP_DIR: &str = "dep"; + +fn windows_local_appdata() -> Option { + env::var_os("LOCALAPPDATA") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .or_else(|| home_dir().map(|h| h.join("AppData").join("Local"))) +} + +fn home_dir() -> Option { + let var = if cfg!(windows) { + env::var_os("USERPROFILE") + } else { + env::var_os("HOME") + }; + + var.filter(|v| !v.is_empty()).map(PathBuf::from) +} + +pub(crate) fn usage(options: &Options, summary: &str) { + let out = options.usage_with_format(|opts| { + format!( + "{}\n\nOptions:\n\n{}", + summary, + opts.collect::>().join("\n") + ) + }); + + println!("{}", out); +} + +pub(crate) fn data_dir() -> Result { + let base = if cfg!(windows) { + windows_local_appdata() + } else if cfg!(macos) { + home_dir().map(|h| h.join("Library").join("Application Support")) + } else { + env::var_os("XDG_DATA_HOME") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .or_else(|| home_dir().map(|h| h.join(".local").join("share"))) + }; + + base.map(|p| p.join(DIR_NAME)) + .ok_or_else(|| error!("No data directory could be determined")) +} + +pub(crate) fn cp_r(source: &Path, target: &Path) -> Result<(), Error> { + create_dir_all(target)?; + + let mut pending = vec![source.to_path_buf()]; + + while let Some(path) = pending.pop() { + let entries = read_dir(&path)?; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + pending.push(path); + continue; + } + + let rel = path.strip_prefix(&source).unwrap(); + let target = target.join(rel); + let dir = target.parent().unwrap(); + + create_dir_all(&dir) + .map_err(|e| error!("Failed to create {:?}: {}", dir, e))?; + + if target.is_file() { + fail!( + "Failed to copy {} to {} as the target file already exists", + path.display(), + target.display() + ); + } + + copy(&path, &target).map_err(|error| { + error!( + "Failed to copy {} to {}: {}", + path.to_string_lossy(), + target.to_string_lossy(), + error + ) + })?; + } + } + + Ok(()) +} + +pub(crate) fn red>(message: S) -> String { + if cfg!(windows) { + message.into() + } else { + format!("\x1b[1m\x1b[31m{}\x1b[0m\x1b[0m", message.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs::remove_dir_all; + + #[test] + fn test_cp_r() { + let src = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let temp = env::temp_dir().join("ipm-test_cp_r"); + + assert!(cp_r(&src.join("src"), &temp).is_ok()); + assert!(temp.join("util.rs").is_file()); + + remove_dir_all(temp).unwrap(); + } +} diff --git a/ipm/src/version.rs b/ipm/src/version.rs new file mode 100644 index 000000000..f4e195474 --- /dev/null +++ b/ipm/src/version.rs @@ -0,0 +1,255 @@ +use crate::manifest::{Dependency, Url}; +use std::cmp::{Ord, Ordering}; +use std::collections::HashMap; +use std::fmt; + +/// The version of a dependency. +/// +/// We only support versions in the format `MAJOR.MINOR.PATCH`, pre-release +/// versions are explicitly not supported. +/// +/// The maximum value for each component is (2^16)-1, which should prove more +/// than sufficient for any software. +#[derive(Eq, PartialEq, Debug, Clone, Hash)] +pub(crate) struct Version { + pub(crate) major: u16, + pub(crate) minor: u16, + pub(crate) patch: u16, +} + +impl Version { + pub(crate) fn parse(input: &str) -> Option { + let chunks: Vec = input + .split('.') + .filter_map(|v| { + // We disallow leading zeroes because why on earth would you + // want those? + if v.len() > 1 && v.starts_with('0') { + return None; + } + + // +1 or -1 as version components makes no sense, so we reject + // them. + if v.starts_with('+') || v.starts_with('-') { + return None; + } + + v.parse::().ok() + }) + .collect(); + + if chunks.len() == 3 { + Some(Version::new(chunks[0], chunks[1], chunks[2])) + } else { + None + } + } + + pub(crate) fn new(major: u16, minor: u16, patch: u16) -> Self { + Self { major, minor, patch } + } + + pub(crate) fn tag_name(&self) -> String { + format!("v{}", self) + } +} + +impl fmt::Display for Version { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + self.major + .cmp(&other.major) + .then(self.minor.cmp(&other.minor)) + .then(self.patch.cmp(&other.patch)) + } +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Returns a list of dependency URIs and their versions to use. +/// +/// For duplicate dependencies, this function returns the _minimum_ version that +/// satisfies all the requirements. For example, imagine we have these +/// dependencies: +/// +/// json >= 1.0 +/// http >= 1.2 +/// http >= 1.1 +/// +/// In this case the dependencies returned would be [json >= 1.0, http >= 1.2]. +/// +/// Unlike Go and Futhark, we don't allow different major versions of the same +/// package. This simplifies how imports are handled, and prevents subtle bugs +/// where data from different major versions of a package is passed between +/// those versions. When such a conflict is encountered, the return type is an +/// `Err` wrapping the conflicting package. +/// +/// For more information, refer to the following links: +/// +/// - https://research.swtch.com/vgo-mvs +/// - https://github.com/diku-dk/futhark/blob/master/src/Futhark/Pkg/Solve.hs +/// - https://github.com/diku-dk/smlpkg/blob/master/src/solve/solve.sml +pub(crate) fn select<'a>( + dependencies: impl Iterator, +) -> Result, Url> { + let mut versions: HashMap<&Url, &Version> = HashMap::new(); + + for dep in dependencies { + let key = &dep.url; + + match versions.get(&key) { + Some(version) if dep.version.major != version.major => { + return Err(dep.url.clone()); + } + Some(version) if &dep.version > version => { + versions.insert(key, &dep.version); + } + None => { + versions.insert(key, &dep.version); + } + _ => {} + } + } + + let selected = versions + .into_iter() + .map(|(uri, ver)| (uri.clone(), ver.clone())) + .collect(); + + Ok(selected) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::Checksum; + + #[test] + fn test_version_parse() { + assert_eq!(Version::parse("1.2.3"), Some(Version::new(1, 2, 3))); + assert_eq!(Version::parse("1.0.0"), Some(Version::new(1, 0, 0))); + + assert_eq!(Version::parse(""), None); + assert_eq!(Version::parse("1"), None); + assert_eq!(Version::parse("1.2"), None); + assert_eq!(Version::parse("1.2."), None); + assert_eq!(Version::parse("1.2. 3"), None); + assert_eq!(Version::parse("1.2.3.4"), None); + assert_eq!(Version::parse("001.002.003"), None); + assert_eq!(Version::parse("ff.ff.ff"), None); + assert_eq!(Version::parse("1.2.3-alpha1"), None); + } + + #[test] + fn test_version_cmp() { + assert_eq!( + Version::new(1, 2, 0).cmp(&Version::new(1, 2, 0)), + Ordering::Equal + ); + assert_eq!( + Version::new(1, 2, 1).cmp(&Version::new(1, 2, 0)), + Ordering::Greater + ); + assert_eq!( + Version::new(1, 3, 0).cmp(&Version::new(1, 2, 0)), + Ordering::Greater + ); + assert_eq!( + Version::new(2, 2, 0).cmp(&Version::new(1, 2, 0)), + Ordering::Greater + ); + assert_eq!( + Version::new(0, 0, 0).cmp(&Version::new(1, 0, 0)), + Ordering::Less + ); + assert_eq!( + Version::new(0, 0, 1).cmp(&Version::new(1, 0, 0)), + Ordering::Less + ); + assert_eq!( + Version::new(0, 1, 0).cmp(&Version::new(1, 0, 0)), + Ordering::Less + ); + assert_eq!( + Version::new(1, 2, 3).cmp(&Version::new(1, 2, 5)), + Ordering::Less + ); + assert_eq!( + Version::new(1, 2, 5).cmp(&Version::new(1, 2, 3)), + Ordering::Greater + ); + } + + #[test] + fn test_version_tag_name() { + assert_eq!(Version::new(1, 2, 3).tag_name(), "v1.2.3".to_string()); + } + + #[test] + fn test_select_valid() { + let versions = select( + [ + Dependency { + url: Url::new("https://gitlab.com/foo/bar"), + version: Version::new(1, 2, 3), + checksum: Checksum::new("a"), + }, + Dependency { + url: Url::new("https://gitlab.com/foo/bar"), + version: Version::new(1, 2, 5), + checksum: Checksum::new("a"), + }, + Dependency { + url: Url::new("https://gitlab.com/foo/json"), + version: Version::new(0, 1, 2), + checksum: Checksum::new("a"), + }, + ] + .iter(), + ); + + assert!(versions.is_ok()); + + let versions = versions.unwrap(); + + assert_eq!(versions.len(), 2); + assert!(versions.contains(&( + Url::new("https://gitlab.com/foo/bar"), + Version::new(1, 2, 5), + ))); + assert!(versions.contains(&( + Url::new("https://gitlab.com/foo/json"), + Version::new(0, 1, 2), + ))); + } + + #[test] + fn test_select_invalid() { + let versions = select( + [ + Dependency { + url: Url::new("https://gitlab.com/foo/bar"), + version: Version::new(1, 2, 3), + checksum: Checksum::new("a"), + }, + Dependency { + url: Url::new("https://gitlab.com/foo/bar"), + version: Version::new(2, 2, 0), + checksum: Checksum::new("a"), + }, + ] + .iter(), + ); + + assert_eq!(versions, Err(Url::new("https://gitlab.com/foo/bar"))); + } +} diff --git a/rustfmt.toml b/rustfmt.toml index fc40e1fac..f56780322 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,7 +1,7 @@ max_width = 80 use_try_shorthand = true reorder_imports = true -edition = "2018" +edition = "2021" # The default setting results in too aggressive/clunky wrapping for a lot of # code. For example, this: @@ -20,5 +20,5 @@ edition = "2018" # } # # Setting this to "Max" results in a more consistent and less infuriating -# wrapping stype. +# wrapping style. use_small_heuristics = 'Max'